Просмотр исходного кода

Import Bom Excel check

if excel have issue will show and can veiw import data and can edit correct, and till isuue fix other wise not allow save.
issue import bom check those check
If Is FG but item LocationCode is null.

Bom detail
can edit bom
have version compare
can switch version of bom and select version to active/inactive

排程
imporve efficent of predect排程, export excel function, and else

BOM/貨品單位問題
if current active bom uom  can not convert to item base uom, will be issue show in this page
if current active bom's bommaterail qty can not convert to item base uom , will be issue show in this page
have fix form to update bom/bom materail data to help convert to base uom correct

jobOrder Create
if Bom have 單位問題, will not allow create Job Order

bom martail remove column base/stock/sale qty/uom
bom qty/uom from store stock change to base
And these two have realte convert function to convert back realte uom/qty need to use
bomUpdateTest
CANCERYS\kw093 1 неделю назад
Родитель
Сommit
daa7cbce65
50 измененных файлов: 4894 добавлений и 1289 удалений
  1. +9
    -26
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  2. +1
    -1
      src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt
  3. +5
    -5
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  4. +2
    -2
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt
  5. +27
    -9
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderBomMaterialService.kt
  6. +15
    -7
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt
  7. +108
    -63
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt
  8. +9
    -2
      src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt
  9. +0
    -21
      src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt
  10. +13
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt
  11. +8
    -2
      src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt
  12. +61
    -3
      src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt
  13. +2
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/EquipmentRepository.kt
  14. +4
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt
  15. +6
    -2
      src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt
  16. +295
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomMaterialQtyService.kt
  17. +36
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyMigrationRunner.kt
  18. +162
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyService.kt
  19. +6
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomProcessConstants.kt
  20. +82
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomRecipeUomOptionsService.kt
  21. +70
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomScaleValidation.kt
  22. +1529
    -587
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  23. +804
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomUomAlignmentService.kt
  24. +53
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomCacheContext.kt
  25. +73
    -50
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  26. +53
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  27. +207
    -152
      src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt
  28. +448
    -259
      src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt
  29. +74
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt
  30. +91
    -8
      src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt
  31. +15
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt
  32. +17
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboDto.kt
  33. +117
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/BomImportPreviewModels.kt
  34. +153
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/BomUomAlignmentModels.kt
  35. +43
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/BomVersionModels.kt
  36. +1
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt
  37. +39
    -3
      src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt
  38. +10
    -8
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  39. +3
    -3
      src/main/java/com/ffii/fpsms/modules/productProcess/entity/projections/ProductProcessInfo.kt
  40. +114
    -59
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  41. +1
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt
  42. +3
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  43. +7
    -7
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  44. +6
    -4
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt
  45. +3
    -3
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt
  46. +28
    -0
      src/main/resources/db/changelog/changes/20260617_Enson/02_setting.sql
  47. +10
    -0
      src/main/resources/db/changelog/changes/20260622_01_Enson/01_add_putaway_location_code_to_bom.sql
  48. +33
    -0
      src/main/resources/db/changelog/changes/20260703_01_Enson/01_drop_bom_material_derived_qty_columns.sql
  49. +22
    -0
      src/main/resources/db/changelog/changes/20260707_01_Enson/01_backfill_bom_revision_no.sql
  50. +16
    -0
      src/main/resources/db/changelog/changes/20260708_01_codex/01_create_index.sql

+ 9
- 26
src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt Просмотреть файл

@@ -150,7 +150,7 @@ open class M18BomForShopService(
udfEffectiveDate = udfEffectiveDate, udfEffectiveDate = udfEffectiveDate,
bomYield = bom.yield, bomYield = bom.yield,
bomName = bom.name, bomName = bom.name,
bomDescription = bom.description,
bomDescription = bom.bomKind,
flowTypeId = flowTypeId, flowTypeId = flowTypeId,
headerM18IdOverride = headerM18IdOverride, headerM18IdOverride = headerM18IdOverride,
bomM18Id = bom.m18Id?.takeIf { it > 0 }, bomM18Id = bom.m18Id?.takeIf { it > 0 },
@@ -160,8 +160,8 @@ open class M18BomForShopService(
id = headerM18IdForRequest?.toString(), id = headerM18IdForRequest?.toString(),
code = headerCode, code = headerCode,
beId = bomShopMainBeId, beId = bomShopMainBeId,
desc = bom.name ?: bom.description,
descEn = bom.name ?: bom.description,
desc = bom.name ?: bom.bomKind,
descEn = bom.name ?: bom.bomKind,
udfBomCode = itemCode, udfBomCode = itemCode,
rev = rev, rev = rev,
udfUnit = udfUnit, udfUnit = udfUnit,
@@ -302,29 +302,12 @@ open class M18BomForShopService(
logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}") logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}")
return outputQty.stripTrailingZeros().toPlainString() to null return outputQty.stripTrailingZeros().toPlainString() to null
} }
val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty()
if (stockCode.isEmpty()) {
logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}")
return outputQty.stripTrailingZeros().toPlainString() to null
}
val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode)
if (match == null) {
logger.warn(
"[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " +
"udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId",
)
return outputQty.stripTrailingZeros().toPlainString() to null
}
val mult = match.groupValues[2].toBigDecimalOrNull()
if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) {
logger.warn(
"[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}",
)
return outputQty.stripTrailingZeros().toPlainString() to null
}
val unitSuffix = match.groupValues[3]
val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros()
return harvestQty.toPlainString() to unitSuffix
// outputQty is stored in base unit; harvest = base qty, unit = base suffix or uom code tail.
val baseUom = bom.uom ?: itemUomService.findBaseUnitByItemId(itemId)?.uom
val harvestUnit = bom.outputQtyUom?.trim()?.takeIf { it.isNotEmpty() }
?: baseUom?.udfShortDesc?.trim()?.takeIf { it.isNotEmpty() }
?: baseUom?.code?.trim()?.takeIf { it.isNotEmpty() }
return outputQty.stripTrailingZeros().toPlainString() to harvestUnit
} }


private fun toProductLine( private fun toProductLine(


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt Просмотреть файл

@@ -1497,7 +1497,7 @@ open class ChartService(
FROM stock_in_line sil FROM stock_in_line sil
INNER JOIN job_order joSil ON joSil.id = sil.jobOrderId AND joSil.deleted = 0 INNER JOIN job_order joSil ON joSil.id = sil.jobOrderId AND joSil.deleted = 0
INNER JOIN bom b ON b.id = joSil.bomId AND b.deleted = 0 INNER JOIN bom b ON b.id = joSil.bomId AND b.deleted = 0
AND UPPER(TRIM(COALESCE(b.description, ''))) IN ('FG', 'WIP')
AND UPPER(TRIM(COALESCE(b.bomKind, ''))) IN ('FG', 'WIP')
WHERE sil.deleted = 0 AND sil.jobOrderId IS NOT NULL WHERE sil.deleted = 0 AND sil.jobOrderId IS NOT NULL
GROUP BY sil.jobOrderId GROUP BY sil.jobOrderId
) silAgg ON silAgg.joId = jo.id ) silAgg ON silAgg.joId = jo.id


+ 5
- 5
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt Просмотреть файл

@@ -1320,13 +1320,13 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri
val allStockInLines = stockInLineRepository.findByReceiptDateAndDeletedFalse(date) val allStockInLines = stockInLineRepository.findByReceiptDateAndDeletedFalse(date)
println("📦 Found ${allStockInLines.size} StockInLines with receiptDate = $date") println("📦 Found ${allStockInLines.size} StockInLines with receiptDate = $date")
println("📦 All StockInLines: ${allStockInLines.map { it.id }}, ${allStockInLines.map { it.jobOrder?.id }}") println("📦 All StockInLines: ${allStockInLines.map { it.id }}, ${allStockInLines.map { it.jobOrder?.id }}")
println("📦 All StockInLines: ${allStockInLines.map { it.status }}, ${allStockInLines.map { it.jobOrder?.bom?.description }}")
println("📦 All StockInLines: ${allStockInLines.map { it.status }}, ${allStockInLines.map { it.jobOrder?.bom?.bomKind }}")
// 2. 进一步过滤:status = "received" 或 "completed",且 BOM type = "WIP" 或 "FG" // 2. 进一步过滤:status = "received" 或 "completed",且 BOM type = "WIP" 或 "FG"
val stockInLines = allStockInLines.filter { val stockInLines = allStockInLines.filter {
it.jobOrder != null it.jobOrder != null
//it.status == "received" && // 只显示 QC 完成但尚未 putaway 的 //it.status == "received" && // 只显示 QC 完成但尚未 putaway 的
// it.jobOrder?.bom?.description in listOf("WIP", "FG")
// it.jobOrder?.bom?.bomKind in listOf("WIP", "FG")
} }
println("✅ After filtering: ${stockInLines.size} StockInLines") println("✅ After filtering: ${stockInLines.size} StockInLines")


@@ -1348,7 +1348,7 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri


for (stockInLine in stockInLines) { for (stockInLine in stockInLines) {
val jobOrder = jobOrders.find { it.id == stockInLine.jobOrder?.id } val jobOrder = jobOrders.find { it.id == stockInLine.jobOrder?.id }
if (jobOrder != null && jobOrder.bom?.description in listOf("WIP", "FG")) {
if (jobOrder != null && jobOrder.bom?.bomKind in listOf("WIP", "FG")) {
// Check if this job order is already in the result (to avoid duplicates) // Check if this job order is already in the result (to avoid duplicates)
val existing = result.find { val existing = result.find {
it.stockInLineId == stockInLine.id it.stockInLineId == stockInLine.id
@@ -1366,7 +1366,7 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri
finihedTime = jobOrder.planEnd ?: jobOrder.modified ?: LocalDateTime.now() finihedTime = jobOrder.planEnd ?: jobOrder.modified ?: LocalDateTime.now()
) )
result.add(response) result.add(response)
println("✅ Added StockInLine ${stockInLine.id} (JobOrder ${jobOrder.id}, BOM: ${jobOrder.bom?.description})")
println("✅ Added StockInLine ${stockInLine.id} (JobOrder ${jobOrder.id}, BOM: ${jobOrder.bom?.bomKind})")
} }
} else { } else {
println("❌ StockInLine ${stockInLine.id}: JobOrder ${stockInLine.jobOrder?.id} not found or BOM type not WIP/FG") println("❌ StockInLine ${stockInLine.id}: JobOrder ${stockInLine.jobOrder?.id} not found or BOM type not WIP/FG")
@@ -2382,7 +2382,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
jobOrderType = jobOrderType?.name, jobOrderType = jobOrderType?.name,
itemId = item.id ?: 0L, itemId = item.id ?: 0L,
itemName = item.name ?: "", itemName = item.name ?: "",
bomDescription = bom?.description,
bomDescription = bom?.bomKind,
reqQty = jobOrder.reqQty ?: BigDecimal.ZERO, reqQty = jobOrder.reqQty ?: BigDecimal.ZERO,
//uomId = bom.outputQtyUom?.id : 0L, //uomId = bom.outputQtyUom?.id : 0L,
uomId = 0, uomId = 0,


+ 2
- 2
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt Просмотреть файл

@@ -193,8 +193,8 @@ open class JoWorkbenchMainService(
* Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line.
*/ */
open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse {
println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===")
println("pickOrderId: $pickOrderId")
//println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===")
//println("pickOrderId: $pickOrderId")


return try { return try {
val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null)


+ 27
- 9
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderBomMaterialService.kt Просмотреть файл

@@ -8,6 +8,8 @@ import com.ffii.fpsms.modules.jobOrder.web.model.CreateJobOrderBomMaterialReques
import com.ffii.fpsms.modules.master.entity.ItemsRepository import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.master.entity.UomConversionRepository import com.ffii.fpsms.modules.master.entity.UomConversionRepository
import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.BomMaterialQtyService
import com.ffii.fpsms.modules.master.service.BomOutputQtyService
import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.master.web.models.MessageResponse
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.math.BigDecimal import java.math.BigDecimal
@@ -20,22 +22,23 @@ open class JobOrderBomMaterialService(
private val jobOrderRepository: JobOrderRepository, private val jobOrderRepository: JobOrderRepository,
private val itemsRepository: ItemsRepository, private val itemsRepository: ItemsRepository,
val itemUomService: ItemUomService, val itemUomService: ItemUomService,
private val uomConversionRepository: UomConversionRepository
private val uomConversionRepository: UomConversionRepository,
private val bomOutputQtyService: BomOutputQtyService,
private val bomMaterialQtyService: BomMaterialQtyService,
) { ) {
open fun createJobOrderBomMaterialRequests(joId: Long): List<CreateJobOrderBomMaterialRequest> { open fun createJobOrderBomMaterialRequests(joId: Long): List<CreateJobOrderBomMaterialRequest> {
val zero = BigDecimal.ZERO val zero = BigDecimal.ZERO
val jo = jobOrderRepository.findById(joId).getOrNull() ?: throw NoSuchElementException() val jo = jobOrderRepository.findById(joId).getOrNull() ?: throw NoSuchElementException()
val proportion = (jo.reqQty ?: zero).divide(jo.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP)
val bomStockOutputQty = bomOutputQtyService.stockOutputQty(jo.bom)
val proportion = (jo.reqQty ?: zero).divide(bomStockOutputQty, 5, RoundingMode.HALF_UP)


val jobmRequests = jo.bom?.bomMaterials?.map { bm -> val jobmRequests = jo.bom?.bomMaterials?.map { bm ->
// val salesUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) }
val stockUnit = bm.item?.id?.let { itemUomService.findStockUnitByItemId(it) } val stockUnit = bm.item?.id?.let { itemUomService.findStockUnitByItemId(it) }
CreateJobOrderBomMaterialRequest( CreateJobOrderBomMaterialRequest(
joId = joId, joId = joId,
itemId = bm.item?.id, itemId = bm.item?.id,
//reqQty = (bm.qty?.times(proportion) ?: zero).setScale(0,RoundingMode.CEILING),
reqQty = (bm.stockQty?.times(proportion) ?: zero).setScale(0, RoundingMode.CEILING),
uomId = stockUnit?.uom?.id
reqQty = bomMaterialQtyService.stockQtyForJob(bm, proportion),
uomId = bomMaterialQtyService.stockUomIdForJob(bm) ?: stockUnit?.uom?.id
) )
} ?: listOf() } ?: listOf()


@@ -43,10 +46,25 @@ open class JobOrderBomMaterialService(
} }


fun createJobOrderBomMaterials(request: List<CreateJobOrderBomMaterialRequest>): MessageResponse { fun createJobOrderBomMaterials(request: List<CreateJobOrderBomMaterialRequest>): MessageResponse {
val joIds = request.mapNotNull { it.joId }.distinct()
val itemIds = request.mapNotNull { it.itemId }.distinct()
val uomIds = request.mapNotNull { it.uomId }.distinct()

// 批量取回,避免在 map 裡逐筆 findById 造成 N+1。
val joById = if (joIds.isNotEmpty()) {
jobOrderRepository.findAllById(joIds).associateBy { it.id }
} else emptyMap()
val itemById = if (itemIds.isNotEmpty()) {
itemsRepository.findAllById(itemIds).associateBy { it.id }
} else emptyMap()
val uomById = if (uomIds.isNotEmpty()) {
uomConversionRepository.findAllById(uomIds).associateBy { it.id }
} else emptyMap()

val joBomMaterials = request.map { req -> val joBomMaterials = request.map { req ->
val jo = req.joId?.let { jobOrderRepository.findById(it).getOrNull() }
val item = req.itemId?.let { itemsRepository.findById(it).getOrNull() }
val uom = req.uomId?.let { uomConversionRepository.findById(it).getOrNull() }
val jo = req.joId?.let { joById[it] }
val item = req.itemId?.let { itemById[it] }
val uom = req.uomId?.let { uomById[it] }
val roundedReqQty = req.reqQty?.setScale(0, RoundingMode.CEILING) val roundedReqQty = req.reqQty?.setScale(0, RoundingMode.CEILING)
val statusEnum = JobOrderBomMaterialStatus.entries.find { it.value == req.status } val statusEnum = JobOrderBomMaterialStatus.entries.find { it.value == req.status }
?: JobOrderBomMaterialStatus.PENDING ?: JobOrderBomMaterialStatus.PENDING


+ 15
- 7
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt Просмотреть файл

@@ -69,6 +69,8 @@ import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import com.ffii.fpsms.modules.master.entity.BomMaterialRepository import com.ffii.fpsms.modules.master.entity.BomMaterialRepository
import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.BomMaterialQtyService
import com.ffii.fpsms.modules.master.service.BomOutputQtyService
import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest
import com.ffii.fpsms.modules.stock.service.StockInLineService import com.ffii.fpsms.modules.stock.service.StockInLineService
import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest
@@ -95,8 +97,9 @@ open class JobOrderService(
val productProcessRepository: ProductProcessRepository, val productProcessRepository: ProductProcessRepository,
val jobOrderBomMaterialRepository: JobOrderBomMaterialRepository, val jobOrderBomMaterialRepository: JobOrderBomMaterialRepository,
val bomMaterialRepository: BomMaterialRepository, val bomMaterialRepository: BomMaterialRepository,
val itemUomService: ItemUomService

val itemUomService: ItemUomService,
private val bomOutputQtyService: BomOutputQtyService,
private val bomMaterialQtyService: BomMaterialQtyService,
) { ) {


open fun allJobOrdersByPage(request: SearchJobOrderInfoRequest): RecordsRes<JobOrderInfo> { open fun allJobOrdersByPage(request: SearchJobOrderInfoRequest): RecordsRes<JobOrderInfo> {
@@ -579,10 +582,14 @@ open class JobOrderService(
} }
// ✅ 使用 stockReqQty (bomMaterial.saleQty) 和 stockUom (bomMaterial.salesUnit) // ✅ 使用 stockReqQty (bomMaterial.saleQty) 和 stockUom (bomMaterial.salesUnit)
val stockReqQty = jobm.reqQty ?: bomMaterial?.stockQty ?: BigDecimal.ZERO
val stockUomId = bomMaterial?.stockUnit?.toLong()
?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id // Fallback: 从 Item 获取库存单位
?: jobm.uom?.id // 最后的 fallback
val stockReqQty = jobm.reqQty
?: bomMaterial?.let { bm ->
bomMaterialQtyService.stockQtyForJob(bm, BigDecimal.ONE)
}
?: BigDecimal.ZERO
val stockUomId = bomMaterial?.let { bomMaterialQtyService.stockUomIdForJob(it) }
?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id
?: jobm.uom?.id
SavePickOrderLineRequest( SavePickOrderLineRequest(
itemId = itemId, itemId = itemId,
@@ -1124,7 +1131,8 @@ open fun updateJoReqQty(request: UpdateJoReqQtyRequest): MessageResponse {
// 更新相关的 JobOrderBomMaterial 的 reqQty(根据新的比例重新计算) // 更新相关的 JobOrderBomMaterial 的 reqQty(根据新的比例重新计算)
val bom = jobOrder.bom val bom = jobOrder.bom
if (bom != null && bom.outputQty != null && bom.outputQty!! > BigDecimal.ZERO) { if (bom != null && bom.outputQty != null && bom.outputQty!! > BigDecimal.ZERO) {
val proportion = newReqQty.divide(bom.outputQty!!, 5, RoundingMode.HALF_UP)
val bomStockOutputQty = bomOutputQtyService.stockOutputQty(bom)
val proportion = newReqQty.divide(bomStockOutputQty, 5, RoundingMode.HALF_UP)
val jobOrderBomMaterials = jobOrderBomMaterialRepository.findAllByJobOrderId(jobOrder.id) val jobOrderBomMaterials = jobOrderBomMaterialRepository.findAllByJobOrderId(jobOrder.id)
jobOrderBomMaterials.forEach { jobm -> jobOrderBomMaterials.forEach { jobm ->


+ 108
- 63
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt Просмотреть файл

@@ -19,8 +19,13 @@ import java.math.RoundingMode
open class PSService( open class PSService(
private val jdbcDao: JdbcDao, private val jdbcDao: JdbcDao,
) { ) {
/** Default: 6 days before today to 1 day after today. */
/**
* FG BOM settings rows for 排期設定.
* Default date window: 6 days before today to 1 day after today.
*
* Performance: DO daily avg is pre-aggregated once (date-first via idx_do_deleted_eta),
* then joined — avoids per-item correlated scans of full delivery_order_line history.
*/
fun getItemDailyOut(fromDate: LocalDate? = null, toDate: LocalDate? = null): List<Map<String, Any>> { fun getItemDailyOut(fromDate: LocalDate? = null, toDate: LocalDate? = null): List<Map<String, Any>> {
val defaultToday = LocalDate.now() val defaultToday = LocalDate.now()
val to = toDate ?: defaultToday.plusDays(1) val to = toDate ?: defaultToday.plusDays(1)
@@ -30,41 +35,59 @@ open class PSService(
"toDate" to to.toString(), "toDate" to to.toString(),
) )
val sql = """ val sql = """
WITH do_daily AS (
SELECT
dol.itemId,
DATE(do.estimatedArrivalDate) AS shipDate,
SUM(dol.qty) AS dayQty
FROM delivery_order do
INNER JOIN delivery_order_line dol
ON dol.deliveryOrderId = do.id AND dol.deleted = 0
WHERE do.deleted = 0
AND do.estimatedArrivalDate >= :fromDate
AND do.estimatedArrivalDate <= :toDate
GROUP BY dol.itemId, DATE(do.estimatedArrivalDate)
),
avg_daily AS (
SELECT itemId, IFNULL(ROUND(AVG(dayQty)), 0) AS avgQtyLastMonth
FROM do_daily
GROUP BY itemId
),
pending_job AS (
SELECT bomId, SUM(reqQty) AS pendingJobQty
FROM job_order
WHERE status != 'completed'
GROUP BY bomId
),
cot AS (
SELECT
itemCode,
SUM(CASE WHEN systemType = 'coffee' THEN 1 ELSE 0 END) AS isCoffee,
SUM(CASE WHEN systemType = 'tea' THEN 1 ELSE 0 END) AS isTea,
SUM(CASE WHEN systemType = 'lemon' THEN 1 ELSE 0 END) AS isLemon
FROM coffee_or_tea
WHERE deleted = 0
GROUP BY itemCode
)
SELECT SELECT
(SELECT dailyQty FROM item_daily_out WHERE itemCode = items.code) AS dailyQty,
(SELECT
IFNULL(ROUND(AVG(d.dailyQty)), 0)
FROM
(SELECT
SUM(dol.qty) AS dailyQty
FROM
delivery_order_line dol
LEFT JOIN delivery_order do ON dol.deliveryOrderId = do.id
WHERE
do.deleted = 0
AND dol.itemId = items.id
AND do.estimatedArrivalDate >= :fromDate AND do.estimatedArrivalDate <= :toDate
GROUP BY do.estimatedArrivalDate) AS d) AS avgQtyLastMonth,
(SELECT SUM(reqQty) FROM job_order WHERE bomId = bom.id AND status != 'completed') AS pendingJobQty,
(SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'coffee' AND itemCode = items.code AND deleted = 0) AS isCoffee,
(SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'tea' AND itemCode = items.code AND deleted = 0) AS isTea,
(SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'lemon' AND itemCode = items.code AND deleted = 0) AS isLemon,
CASE WHEN item_fake_onhand.onHandQty IS NOT NULL THEN item_fake_onhand.onHandQty
ELSE inventory.onHandQty - 500 END AS stockQty,
ido.dailyQty AS dailyQty,
IFNULL(ad.avgQtyLastMonth, 0) AS avgQtyLastMonth,
pj.pendingJobQty AS pendingJobQty,
IFNULL(cot.isCoffee, 0) AS isCoffee,
IFNULL(cot.isTea, 0) AS isTea,
IFNULL(cot.isLemon, 0) AS isLemon,
CASE WHEN ifo.onHandQty IS NOT NULL THEN ifo.onHandQty
ELSE inventory.onHandQty - 500 END AS stockQty,
bom.baseScore, bom.baseScore,
bom.outputQty, bom.outputQty,
bom.outputQtyUom, bom.outputQtyUom,
(SELECT udfudesc
FROM delivery_order_line
LEFT JOIN uom_conversion ON delivery_order_line.uomId = uom_conversion.id
WHERE delivery_order_line.itemId = bom.itemId
LIMIT 1) AS doUom,
NULL AS doUom,
items.code AS itemCode, items.code AS itemCode,
items.name AS itemName, items.name AS itemName,
uc_stock.udfudesc AS unit, uc_stock.udfudesc AS unit,
bom.description,
bom.bomKind,
inventory.onHandQty, inventory.onHandQty,
item_fake_onhand.onHandQty AS fakeOnHandQty,
ifo.onHandQty AS fakeOnHandQty,
bom.itemId, bom.itemId,
bom.id AS bomId, bom.id AS bomId,
CASE WHEN bom.isDark = 5 THEN 11 WHEN bom.isDark = 3 THEN 6 WHEN bom.isDark = 1 THEN 2 ELSE 0 END AS markDark, CASE WHEN bom.isDark = 5 THEN 11 WHEN bom.isDark = 3 THEN 6 WHEN bom.isDark = 1 THEN 2 ELSE 0 END AS markDark,
@@ -74,14 +97,19 @@ open class PSService(
bom.complexity AS markComplexity, bom.complexity AS markComplexity,
CASE WHEN bom.allergicSubstances = 5 THEN 11 ELSE 0 END AS markAS, CASE WHEN bom.allergicSubstances = 5 THEN 11 ELSE 0 END AS markAS,
inventory.id AS inventoryId inventory.id AS inventoryId
FROM
bom
FROM bom
LEFT JOIN items ON bom.itemId = items.id LEFT JOIN items ON bom.itemId = items.id
LEFT JOIN inventory ON items.id = inventory.itemId LEFT JOIN inventory ON items.id = inventory.itemId
LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode
LEFT JOIN item_fake_onhand ifo ON items.code = ifo.itemCode
LEFT JOIN item_daily_out ido ON items.code = ido.itemCode
LEFT JOIN item_uom iu ON iu.itemId = items.id AND iu.stockUnit = 1 LEFT JOIN item_uom iu ON iu.itemId = items.id AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu.uomId LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu.uomId
WHERE bom.deleted = 0 and bom.description = 'FG'
LEFT JOIN avg_daily ad ON ad.itemId = items.id
LEFT JOIN pending_job pj ON pj.bomId = bom.id
LEFT JOIN cot ON cot.itemCode = items.code
WHERE bom.deleted = 0
AND bom.bomKind = 'FG'
ORDER BY items.code
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(sql, args) return jdbcDao.queryForList(sql, args)
} }
@@ -177,7 +205,7 @@ open class PSService(
INNER JOIN ( INNER JOIN (
SELECT DISTINCT b.itemId SELECT DISTINCT b.itemId
FROM bom b FROM bom b
WHERE b.deleted = 0 AND b.description = 'FG'
WHERE b.deleted = 0 AND b.bomKind = 'FG'
) bom_items ON bom_items.itemId = i.itemId ) bom_items ON bom_items.itemId = i.itemId
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
@@ -269,49 +297,64 @@ open class PSService(
*/ */
fun listBomItemsWithStockUnit(): List<Map<String, Any>> { fun listBomItemsWithStockUnit(): List<Map<String, Any>> {
val sql = """ val sql = """
SELECT DISTINCT
SELECT
items.code AS itemCode, items.code AS itemCode,
items.name AS itemName, items.name AS itemName,
uc_stock.udfudesc AS stockUnit uc_stock.udfudesc AS stockUnit
FROM bom
INNER JOIN items ON bom.itemId = items.id AND items.deleted = 0
LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0
FROM (
SELECT DISTINCT b.itemId
FROM bom b
WHERE b.deleted = 0
) bom_items
INNER JOIN items ON items.id = bom_items.itemId AND items.deleted = 0
LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id
AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0
LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu_stock.uomId LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu_stock.uomId
WHERE bom.deleted = 0
ORDER BY items.code ORDER BY items.code
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(sql, emptyMap<String, Any>()) return jdbcDao.queryForList(sql, emptyMap<String, Any>())
} }


/** /**
* Sum of [delivery_order_line.qty] (already stored in stock unit) by item and ETA date.
* Only items that have a BOM.
* Sum of [delivery_order_line.qty] (stock unit) by item code and ETA date.
* When [bomItemCodes] is provided, only matching rows are returned (BOM filter in memory).
*/ */
fun sumDeliveryOrderQtyByItemAndDate(fromDate: LocalDate, toDate: LocalDate): List<Map<String, Any>> {
fun sumDeliveryOrderQtyByItemAndDate(
fromDate: LocalDate,
toDate: LocalDate,
bomItemCodes: Set<String>? = null,
): List<Map<String, Any>> {
val args = mapOf( val args = mapOf(
"fromDate" to fromDate.toString(), "fromDate" to fromDate.toString(),
"toDate" to toDate.toString(),
"toDateExclusive" to toDate.plusDays(1).toString(),
) )
val sql = """ val sql = """
WITH do_in_range AS (
SELECT id, estimatedArrivalDate
FROM delivery_order
WHERE deleted = 0
AND estimatedArrivalDate IS NOT NULL
AND estimatedArrivalDate >= :fromDate
AND estimatedArrivalDate < :toDateExclusive
)
SELECT SELECT
items.code AS itemCode, items.code AS itemCode,
DATE(do.estimatedArrivalDate) AS shipDate, DATE(do.estimatedArrivalDate) AS shipDate,
SUM(COALESCE(dol.qty, 0)) AS qtySum SUM(COALESCE(dol.qty, 0)) AS qtySum
FROM delivery_order do
FROM do_in_range do
INNER JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 INNER JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
INNER JOIN items ON items.id = dol.itemId AND items.deleted = 0 INNER JOIN items ON items.id = dol.itemId AND items.deleted = 0
WHERE do.deleted = 0
AND do.estimatedArrivalDate IS NOT NULL
AND DATE(do.estimatedArrivalDate) >= :fromDate
AND DATE(do.estimatedArrivalDate) <= :toDate
AND EXISTS (
SELECT 1 FROM bom b
WHERE b.itemId = items.id AND b.deleted = 0
)
GROUP BY items.code, DATE(do.estimatedArrivalDate) GROUP BY items.code, DATE(do.estimatedArrivalDate)
ORDER BY items.code, shipDate ORDER BY items.code, shipDate
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(sql, args)
val rows = jdbcDao.queryForList(sql, args)
if (bomItemCodes.isNullOrEmpty()) {
return rows
}
return rows.filter { row ->
val itemCode = row["itemCode"]?.toString() ?: return@filter false
itemCode in bomItemCodes
}
} }


fun exportDeliveryOrderQtyByDateExcel(fromDate: LocalDate, toDate: LocalDate): ByteArray { fun exportDeliveryOrderQtyByDateExcel(fromDate: LocalDate, toDate: LocalDate): ByteArray {
@@ -321,7 +364,8 @@ open class PSService(


val dates = generateSequence(fromDate) { it.plusDays(1) }.takeWhile { !it.isAfter(toDate) }.toList() val dates = generateSequence(fromDate) { it.plusDays(1) }.takeWhile { !it.isAfter(toDate) }.toList()
val items = listBomItemsWithStockUnit() val items = listBomItemsWithStockUnit()
val qtyRows = sumDeliveryOrderQtyByItemAndDate(fromDate, toDate)
val bomItemCodes = items.mapNotNull { it["itemCode"]?.toString() }.toSet()
val qtyRows = sumDeliveryOrderQtyByItemAndDate(fromDate, toDate, bomItemCodes)


val qtyByItemDate = mutableMapOf<String, MutableMap<LocalDate, BigDecimal>>() val qtyByItemDate = mutableMapOf<String, MutableMap<LocalDate, BigDecimal>>()
qtyRows.forEach { row -> qtyRows.forEach { row ->
@@ -345,10 +389,6 @@ open class PSService(
val textStyle = workbook.createCellStyle().apply { val textStyle = workbook.createCellStyle().apply {
verticalAlignment = VerticalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER
} }
val numberStyle = workbook.createCellStyle().apply {
verticalAlignment = VerticalAlignment.CENTER
dataFormat = workbook.createDataFormat().getFormat("#,##0")
}


val headerRow = sheet.createRow(0) val headerRow = sheet.createRow(0)
val headers = mutableListOf("Item Code", "Item Name", "UOM") val headers = mutableListOf("Item Code", "Item Name", "UOM")
@@ -374,15 +414,20 @@ open class PSService(
dates.forEachIndexed { dateIdx, date -> dates.forEachIndexed { dateIdx, date ->
val qty = (dateQtyMap?.get(date) ?: BigDecimal.ZERO) val qty = (dateQtyMap?.get(date) ?: BigDecimal.ZERO)
.setScale(0, RoundingMode.HALF_UP) .setScale(0, RoundingMode.HALF_UP)
row.createCell(3 + dateIdx).apply {
setCellValue(qty.toLong().toDouble())
cellStyle = numberStyle
}
row.createCell(3 + dateIdx).setCellValue(qty.toLong().toDouble())
} }
} }


for (col in 0 until headers.size) { for (col in 0 until headers.size) {
sheet.autoSizeColumn(col)
sheet.setColumnWidth(
col,
when (col) {
0 -> 4500
1 -> 9000
2 -> 3200
else -> 3200
},
)
} }


ByteArrayOutputStream().use { out -> ByteArrayOutputStream().use { out ->


+ 9
- 2
src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt Просмотреть файл

@@ -53,8 +53,11 @@ open class Bom : BaseEntity<Long>() {


@Size(max = 100) @Size(max = 100)
@NotNull @NotNull
@Column(name = "description", nullable = false, length = 100)
open var description: String? = null
@Column(name = "bomKind", nullable = false, length = 100)
open var bomKind: String? = null

@Column(name = "revisionNo")
open var revisionNo: Int? = null


@Column(name = "outputQty", precision = 14, scale = 2) @Column(name = "outputQty", precision = 14, scale = 2)
open var outputQty: BigDecimal? = null open var outputQty: BigDecimal? = null
@@ -93,4 +96,8 @@ open class Bom : BaseEntity<Long>() {
@Column(name = "status", nullable = false, length = 20) @Column(name = "status", nullable = false, length = 20)
@Convert(converter = BomStatusConverter::class) @Convert(converter = BomStatusConverter::class)
open var status: BomStatus = BomStatus.ACTIVE open var status: BomStatus = BomStatus.ACTIVE

@Size(max = 255)
@Column(name = "putawayLocationCode", length = 255)
open var putawayLocationCode: String? = null
} }

+ 0
- 21
src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt Просмотреть файл

@@ -37,27 +37,6 @@ open class BomMaterial : BaseEntity<Long>() {
@Column(name = "uomName", length = 100) @Column(name = "uomName", length = 100)
open var uomName: String? = null open var uomName: String? = null


@Column(name = "saleQty", precision = 14, scale = 2)
open var saleQty: BigDecimal? = null

@ManyToOne
@JoinColumn(name = "salesUnitId")
open var salesUnit: UomConversion? = null

@Column(name = "salesUnitCode")
open var salesUnitCode: String? = null
@Column(name = "baseQty", precision = 14, scale = 2)
open var baseQty: BigDecimal? = null
@Column(name = "baseUnit", nullable = false)
open var baseUnit: Integer? = null
@Column(name = "baseUnitName", length = 100)
open var baseUnitName: String? = null
@Column(name = "stockQty", precision = 14, scale = 2)
open var stockQty: BigDecimal? = null
@Column(name = "stockUnit", nullable = false)
open var stockUnit: Integer? = null
@Column(name = "stockUnitName", length = 100)
open var stockUnitName: String? = null
@NotNull @NotNull
@ManyToOne(optional = false) @ManyToOne(optional = false)
@JoinColumn(name = "bomId", nullable = false) @JoinColumn(name = "bomId", nullable = false)


+ 13
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt Просмотреть файл

@@ -1,7 +1,9 @@
package com.ffii.fpsms.modules.master.entity package com.ffii.fpsms.modules.master.entity


import com.ffii.core.support.AbstractRepository import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.master.enums.BomStatus
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.io.Serializable import java.io.Serializable


@@ -24,9 +26,19 @@ interface BomMaterialRepository : AbstractRepository<BomMaterial, Long> {
JOIN FETCH bm.bom b JOIN FETCH bm.bom b
LEFT JOIN FETCH bm.item LEFT JOIN FETCH bm.item
LEFT JOIN FETCH bm.uom LEFT JOIN FETCH bm.uom
LEFT JOIN FETCH bm.salesUnit
WHERE bm.deleted = false AND b.deleted = false WHERE bm.deleted = false AND b.deleted = false
""", """,
) )
fun findAllActiveForActiveBoms(): List<BomMaterial> fun findAllActiveForActiveBoms(): List<BomMaterial>

@Query(
"""
SELECT bm FROM BomMaterial bm
JOIN FETCH bm.bom b
LEFT JOIN FETCH bm.item
LEFT JOIN FETCH bm.uom
WHERE bm.deleted = false AND b.deleted = false AND b.status = :status
""",
)
fun findAllByBomStatusAndDeletedFalse(@Param("status") status: BomStatus): List<BomMaterial>
} }

+ 8
- 2
src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt Просмотреть файл

@@ -15,8 +15,8 @@ open class BomProcess : BaseEntity<Long>() {
@JoinColumn(name = "processId", nullable = false) @JoinColumn(name = "processId", nullable = false)
open var process: Process? = null open var process: Process? = null


@ManyToOne
@JoinColumn(name = "equipmentId", nullable = false)
@ManyToOne(optional = true)
@JoinColumn(name = "equipmentId", nullable = true)
open var equipment: Equipment? = null open var equipment: Equipment? = null


@Column(name = "description", nullable = false) @Column(name = "description", nullable = false)
@@ -41,6 +41,12 @@ open class BomProcess : BaseEntity<Long>() {
@Column(name = "postProdTimeInMinute", nullable = false) @Column(name = "postProdTimeInMinute", nullable = false)
open var postProdTimeInMinute: Int? = null open var postProdTimeInMinute: Int? = null


@Column(name = "byProduct", nullable = true)
open var byProduct: String? = null

@Column(name = "byProductUom", nullable = true)
open var byProductUom: String? = null

@NotNull @NotNull
@ManyToOne(optional = false) @ManyToOne(optional = false)
@JsonBackReference @JsonBackReference


+ 61
- 3
src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt Просмотреть файл

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Repository
import java.io.Serializable import java.io.Serializable
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import org.springframework.data.jpa.repository.Modifying
@Repository @Repository
interface BomRepository : AbstractRepository<Bom, Long> { interface BomRepository : AbstractRepository<Bom, Long> {
fun findAllByDeletedIsFalse(): List<Bom> fun findAllByDeletedIsFalse(): List<Bom>
@@ -21,6 +22,16 @@ interface BomRepository : AbstractRepository<Bom, Long> {
) )
fun findAllActiveWithItemAndUom(): List<Bom> fun findAllActiveWithItemAndUom(): List<Bom>


@Query(
"""
SELECT b FROM Bom b
LEFT JOIN FETCH b.item
LEFT JOIN FETCH b.uom
WHERE b.deleted = false AND b.status = :status
""",
)
fun findAllByDeletedFalseAndStatusWithItemAndUom(@Param("status") status: BomStatus): List<Bom>

fun findByIdAndDeletedIsFalse(id: Serializable): Bom? fun findByIdAndDeletedIsFalse(id: Serializable): Bom?


fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom? fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom?
@@ -37,15 +48,62 @@ interface BomRepository : AbstractRepository<Bom, Long> {


fun findByCodeAndDeletedIsFalse(code: String): Bom? fun findByCodeAndDeletedIsFalse(code: String): Bom?


fun findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code: String, description: String): Bom?
fun findByCodeAndBomKindIgnoreCaseAndDeletedIsFalse(code: String, bomKind: String): Bom?

fun findByCodeAndBomKindIgnoreCaseAndStatusAndDeletedIsFalse(
code: String,
bomKind: String,
status: BomStatus,
): Bom?

fun countByCodeAndBomKindIgnoreCaseAndDeletedIsFalse(code: String, bomKind: String): Long

@Query(
"""
SELECT COALESCE(MAX(COALESCE(b.revisionNo, 0)), 0)
FROM Bom b
WHERE b.deleted = false
AND b.code = :code
AND lower(b.bomKind) = lower(:bomKind)
""",
)
fun maxRevisionNoByCodeAndBomKind(
@Param("code") code: String,
@Param("bomKind") bomKind: String,
): Int

fun findAllByCodeAndBomKindIgnoreCaseAndDeletedIsFalseOrderByRevisionNoAsc(
code: String,
bomKind: String,
): List<Bom>


@Query(""" @Query("""
select b.item.id select b.item.id
from Bom b from Bom b
where b.deleted = false where b.deleted = false
and lower(b.description) = 'wip'
and lower(b.bomKind) = 'wip'
and b.item.id in :itemIds and b.item.id in :itemIds
""") """)
fun findWipItemIds(@Param("itemIds") itemIds: List<Long>): List<Long> fun findWipItemIds(@Param("itemIds") itemIds: List<Long>): List<Long>
fun findFirstByItemIdAndDescriptionIgnoreCaseAndDeletedIsFalse(itemId: Long, description: String): Bom?
fun findFirstByItemIdAndBomKindIgnoreCaseAndDeletedIsFalse(itemId: Long, bomKind: String): Bom?

fun findTopByCodeAndBomKindIgnoreCaseAndDeletedIsFalseOrderByRevisionNoDesc(code: String, bomKind: String): Bom?

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update Bom b
set b.status = :inactive
where b.deleted = false
and b.code = :code
and lower(b.bomKind) = lower(:bomKind)
and b.status = :active
""",
)
fun markActiveVersionsInactiveByCodeAndBomKind(
@Param("code") code: String,
@Param("bomKind") bomKind: String,
@Param("active") active: BomStatus = BomStatus.ACTIVE,
@Param("inactive") inactive: BomStatus = BomStatus.INACTIVE,
): Int
} }

+ 2
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/EquipmentRepository.kt Просмотреть файл

@@ -15,4 +15,6 @@ interface EquipmentRepository : AbstractRepository<Equipment, Long> {
fun findByNameAndDeletedIsFalse(name: String): Equipment?; fun findByNameAndDeletedIsFalse(name: String): Equipment?;


fun findByDescriptionAndDeletedIsFalse(description: String): Equipment?; fun findByDescriptionAndDeletedIsFalse(description: String): Equipment?;

fun findFirstByDescriptionAndNameAndDeletedFalse(description: String, name: String): Equipment?;
} }

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt Просмотреть файл

@@ -10,6 +10,10 @@ import java.util.Optional
interface ItemsRepository : AbstractRepository<Items, Long> { interface ItemsRepository : AbstractRepository<Items, Long> {
fun findAllByDeletedFalse(): List<Items>; fun findAllByDeletedFalse(): List<Items>;


fun findAllByIsBagTrueAndDeletedFalse(): List<Items>;

fun findAllByCodeInAndDeletedFalse(codes: Collection<String>): List<Items>;

fun findByIdAndDeletedFalse(id: Long): Items?; fun findByIdAndDeletedFalse(id: Long): Items?;


fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?;


+ 6
- 2
src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt Просмотреть файл

@@ -7,11 +7,15 @@ interface BomCombo {
val id: Long; val id: Long;
@get:Value("#{target.id}") @get:Value("#{target.id}")
val value: Long; val value: Long;
@get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}")
val code: String?;
val revisionNo: Int?;
@get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''} (V#{target.revisionNo != null ? target.revisionNo : 1})")
val label: String; val label: String;
val outputQty: BigDecimal; val outputQty: BigDecimal;
val outputQtyUom: String?; val outputQtyUom: String?;
@get:Value("#{target.description}")
@get:Value("#{target.bomKind}")
val bomKind: String?;
@get:Value("#{target.bomKind}")
val description: String?; val description: String?;
@get:Value("#{target.status?.value}") @get:Value("#{target.status?.value}")
val status: String?; val status: String?;

+ 295
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomMaterialQtyService.kt Просмотреть файл

@@ -0,0 +1,295 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.core.exception.BadRequestException
import com.ffii.fpsms.modules.master.entity.BomMaterial
import com.ffii.fpsms.modules.master.entity.ItemUom
import com.ffii.fpsms.modules.master.entity.UomConversion
import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest
import java.math.BigDecimal
import java.math.RoundingMode
import org.springframework.stereotype.Service

data class BomMaterialDerivedQtys(
val saleQty: BigDecimal?,
val stockQty: BigDecimal?,
val baseQty: BigDecimal?,
val salesUnit: UomConversion?,
val salesUnitCode: String?,
val stockUnit: Integer?,
val stockUnitName: String?,
val baseUnit: Integer?,
val baseUnitName: String?,
)

@Service
open class BomMaterialQtyService(
private val itemUomService: ItemUomService,
) {
open fun deriveFromRecipe(
itemId: Long,
recipeQty: BigDecimal,
recipeUom: UomConversion,
): BomMaterialDerivedQtys = deriveFromRecipeInternal(itemId, recipeQty, recipeUom, cache = null)

open fun deriveFromRecipe(
itemId: Long,
recipeQty: BigDecimal,
recipeUom: UomConversion,
cache: ItemUomCacheContext,
): BomMaterialDerivedQtys = deriveFromRecipeInternal(itemId, recipeQty, recipeUom, cache)

private fun deriveFromRecipeInternal(
itemId: Long,
recipeQty: BigDecimal,
recipeUom: UomConversion,
cache: ItemUomCacheContext?,
): BomMaterialDerivedQtys {
val baseItemUom = resolveBaseUnit(itemId, cache)
?: throw BadRequestException("Item base unit not configured: itemId=$itemId")
val stockItemUom = resolveStockUnit(itemId, cache)
?: throw BadRequestException("Item stock unit not configured: itemId=$itemId")
val salesItemUom = resolveSalesUnit(itemId, cache)
?: throw BadRequestException("Item sales unit not configured: itemId=$itemId")
val baseUom = baseItemUom.uom
?: throw BadRequestException("Base UOM conversion not configured: itemId=$itemId")
val stockUom = stockItemUom.uom
?: throw BadRequestException("Stock UOM conversion not configured: itemId=$itemId")
val salesUom = salesItemUom.uom
?: throw BadRequestException("Sales UOM conversion not configured: itemId=$itemId")

val computedBaseQty = convertRecipeToBase(itemId, recipeQty, recipeUom, baseUom, cache)
val computedStockQty = resolveStockQtyFromBaseRecipe(
itemId = itemId,
recipeQty = recipeQty,
recipeUom = recipeUom,
baseQty = computedBaseQty,
baseUom = baseUom,
stockUom = stockUom,
stockItemUom = stockItemUom,
salesUom = salesUom,
cache = cache,
)
val convertRequest = ConvertUomByItemRequest(
itemId = itemId,
qty = computedStockQty,
uomId = stockUom.id!!,
targetUnit = "salesUnit",
)
val computedSaleQty = if (cache != null) {
itemUomService.convertUomByItem(convertRequest, cache).newQty
} else {
itemUomService.convertUomByItem(convertRequest).newQty
}

return BomMaterialDerivedQtys(
saleQty = computedSaleQty,
stockQty = computedStockQty,
baseQty = computedBaseQty,
salesUnit = salesUom,
salesUnitCode = salesUom.udfudesc,
stockUnit = stockUom.id!!.toInt().let { Integer.valueOf(it) } as? Integer,
stockUnitName = stockUom.udfudesc,
baseUnit = baseUom.id!!.toInt().let { Integer.valueOf(it) } as? Integer,
baseUnitName = baseUom.udfudesc,
)
}

private fun resolveBaseUnit(itemId: Long, cache: ItemUomCacheContext?): ItemUom? =
cache?.baseUnit(itemId) ?: itemUomService.findBaseUnitByItemId(itemId)

private fun resolveStockUnit(itemId: Long, cache: ItemUomCacheContext?): ItemUom? =
cache?.stockUnit(itemId) ?: itemUomService.findStockUnitByItemId(itemId)

private fun resolveSalesUnit(itemId: Long, cache: ItemUomCacheContext?): ItemUom? =
cache?.salesUnit(itemId) ?: itemUomService.findSalesUnitByItemId(itemId)

open fun deriveForEntity(material: BomMaterial): BomMaterialDerivedQtys? {
val itemId = material.item?.id ?: return null
val recipeQty = material.qty ?: return null
val recipeUom = material.uom ?: return null
return try {
deriveFromRecipe(itemId, recipeQty, recipeUom)
} catch (_: Exception) {
null
}
}

open fun stockQtyForJob(material: BomMaterial, proportion: BigDecimal): BigDecimal {
val derived = deriveForEntity(material) ?: return BigDecimal.ZERO
val stockQty = derived.stockQty ?: return BigDecimal.ZERO
return stockQty.times(proportion).setScale(0, RoundingMode.CEILING)
}

open fun stockUomIdForJob(material: BomMaterial): Long? {
val itemId = material.item?.id
return deriveForEntity(material)?.stockUnit?.toLong()
?: itemId?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id }
}

/** Whether current recipe UOM can be converted to item base (preview left-column base/stock). */
open fun canConvertRecipeToBase(
itemId: Long,
recipeUom: UomConversion,
baseUom: UomConversion,
): Boolean {
if (recipeUom.id == baseUom.id) return true

val recipeInMatrix = StandardUomMatrix.toMatrixUnit(recipeUom)
val baseInMatrix = StandardUomMatrix.toMatrixUnit(baseUom)
if (recipeInMatrix != null && baseInMatrix != null) {
return try {
StandardUomMatrix.convertByUom(BigDecimal.ONE, recipeUom, baseUom)
true
} catch (_: Exception) {
false
}
}
if (recipeInMatrix != null && baseInMatrix == null) {
return false
}

return try {
itemUomService.convertUomByItem(
ConvertUomByItemRequest(
itemId = itemId,
qty = BigDecimal.ONE,
uomId = recipeUom.id!!,
targetUnit = "baseUnit",
),
)
true
} catch (_: Exception) {
false
}
}

/** Matrix recipe (e.g. LB) → item stock unit via gram bridge and item_uom ratioN. */
open fun convertMatrixRecipeToStock(
itemId: Long,
recipeQty: BigDecimal,
recipeUom: UomConversion,
): BigDecimal = convertMatrixRecipeToStock(itemId, recipeQty, recipeUom, cache = null)

private fun convertMatrixRecipeToStock(
itemId: Long,
recipeQty: BigDecimal,
recipeUom: UomConversion,
cache: ItemUomCacheContext?,
): BigDecimal {
val stockItemUom = resolveStockUnit(itemId, cache)
?: throw BadRequestException("Item stock unit not configured: itemId=$itemId")
val recipeInMatrix = StandardUomMatrix.toMatrixUnit(recipeUom)
?: run {
val request = ConvertUomByItemRequest(
itemId = itemId,
qty = recipeQty,
uomId = recipeUom.id!!,
targetUnit = "stockUnit",
)
return if (cache != null) {
itemUomService.convertUomByItem(request, cache).newQty
} else {
itemUomService.convertUomByItem(request).newQty
}
}
val gramQty = StandardUomMatrix.convert(
recipeQty,
recipeInMatrix,
StandardUomMatrix.MatrixUnit.G,
)
val ratioN = stockItemUom.ratioN ?: BigDecimal.ONE
val ratioD = stockItemUom.ratioD ?: BigDecimal.ONE
return gramQty.multiply(ratioD).divide(ratioN, 2, RoundingMode.HALF_UP)
}

private fun resolveStockQtyFromBaseRecipe(
itemId: Long,
recipeQty: BigDecimal,
recipeUom: UomConversion,
baseQty: BigDecimal,
baseUom: UomConversion,
stockUom: UomConversion,
stockItemUom: ItemUom,
salesUom: UomConversion,
cache: ItemUomCacheContext?,
): BigDecimal {
if (recipeUom.id == baseUom.id) {
stockQtyViaLbPerPack(recipeQty, stockUom)?.let { return it }
}
if (StandardUomMatrix.toMatrixUnit(recipeUom) != null && recipeUom.id != baseUom.id) {
return convertMatrixRecipeToStock(itemId, recipeQty, recipeUom, cache)
}
val saleRequest = ConvertUomByItemRequest(
itemId = itemId,
qty = baseQty,
uomId = baseUom.id!!,
targetUnit = "salesUnit",
)
val saleQty = if (cache != null) {
itemUomService.convertUomByItem(saleRequest, cache).newQty
} else {
itemUomService.convertUomByItem(saleRequest).newQty
}
val stockRequest = ConvertUomByItemRequest(
itemId = itemId,
qty = saleQty,
uomId = salesUom.id!!,
targetUnit = "stockUnit",
)
return if (cache != null) {
itemUomService.convertUomByItem(stockRequest, cache).newQty
} else {
itemUomService.convertUomByItem(stockRequest).newQty
}
}

/** e.g. 250 (lb-count base PACK) / 50 lb per PACK50LB → 5 stock packs. */
private fun stockQtyViaLbPerPack(recipeQty: BigDecimal, stockUom: UomConversion): BigDecimal? {
val unit2 = stockUom.unit2?.trim()?.uppercase() ?: return null
if (unit2 != "LB" && unit2 != "LBS") return null
val lbPerPack = stockUom.unit2Qty ?: return null
if (lbPerPack <= 0) return null
return recipeQty.divide(BigDecimal.valueOf(lbPerPack), 2, RoundingMode.HALF_UP)
}

private fun convertRecipeToBase(
itemId: Long,
recipeQty: BigDecimal,
recipeUom: UomConversion,
baseUom: UomConversion,
cache: ItemUomCacheContext?,
): BigDecimal {
if (recipeUom.id == baseUom.id) {
return recipeQty
}

val recipeInMatrix = StandardUomMatrix.toMatrixUnit(recipeUom)
val baseInMatrix = StandardUomMatrix.toMatrixUnit(baseUom)
if (recipeInMatrix != null && baseInMatrix != null) {
return StandardUomMatrix.convertByUom(recipeQty, recipeUom, baseUom)
}
if (recipeInMatrix != null && baseInMatrix == null) {
return recipeQty
}

return try {
val request = ConvertUomByItemRequest(
itemId = itemId,
qty = recipeQty,
uomId = recipeUom.id
?: throw BadRequestException("Recipe UOM id not found: itemId=$itemId"),
targetUnit = "baseUnit",
)
if (cache != null) {
itemUomService.convertUomByItem(request, cache).newQty
} else {
itemUomService.convertUomByItem(request).newQty
}
} catch (e: Exception) {
throw BadRequestException(
"Recipe UOM cannot convert to item base unit: itemId=$itemId, " +
"recipe=${recipeUom.code}, base=${baseUom.code}. ${e.message ?: ""}".trim(),
)
}
}
}

+ 36
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyMigrationRunner.kt Просмотреть файл

@@ -0,0 +1,36 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.modules.master.entity.BomRepository
import org.slf4j.LoggerFactory
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

/**
* Converts legacy BOM header output qty (stock/sales unit) to base unit on startup.
* Idempotent: rows already on base unit are skipped.
*/
@Component
open class BomOutputQtyMigrationRunner(
private val bomRepository: BomRepository,
private val bomOutputQtyService: BomOutputQtyService,
) {
private val logger = LoggerFactory.getLogger(BomOutputQtyMigrationRunner::class.java)

@EventListener(ApplicationReadyEvent::class)
@Transactional
open fun migrateLegacyBomOutputToBase() {
val boms = bomRepository.findAllByDeletedIsFalse()
var updated = 0
for (bom in boms) {
if (bomOutputQtyService.migrateLegacyOutputToBase(bom)) {
bomRepository.saveAndFlush(bom)
updated++
}
}
if (updated > 0) {
logger.info("BOM output qty migration: converted {} header(s) to base unit storage", updated)
}
}
}

+ 162
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyService.kt Просмотреть файл

@@ -0,0 +1,162 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.core.exception.BadRequestException
import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.UomConversion
import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest
import java.math.BigDecimal
import java.math.RoundingMode
import org.springframework.stereotype.Service

/** Display/stock-facing output qty + label (UI, job-order multipliers, proportions). */
data class BomOutputDisplay(
val qty: BigDecimal,
val uomLabel: String?,
val uomId: Long?,
)

/** Persisted base-unit output qty written to [Bom.outputQty] / [Bom.uom]. */
data class BomOutputPersisted(
val qty: BigDecimal,
val uomLabel: String?,
val uom: UomConversion?,
)

/**
* BOM header output qty is stored in **base unit** on [Bom].
* UI and job-order flows use **stock unit** via [displayAsStockUnit] / [stockOutputQty].
*/
@Service
open class BomOutputQtyService(
private val itemUomService: ItemUomService,
) {
private val calcScale = 10

open fun isStoredAsBaseUnit(bom: Bom): Boolean {
val itemId = bom.item?.id ?: return false
val baseUomId = itemUomService.findBaseUnitByItemId(itemId)?.uom?.id ?: return false
return bom.uom?.id == baseUomId
}

/** Stock/sales qty from UI or Excel → persist on [Bom] as base unit. */
open fun applyStockQtyToBom(bom: Bom, stockQty: BigDecimal, sourceUomId: Long) {
val itemId = bom.item?.id
?: throw BadRequestException("BOM 未關聯貨品,無法換算產出數量")
val persisted = persistFromSourceUnit(itemId, stockQty, sourceUomId)
bom.outputQty = persisted.qty
bom.outputQtyUom = persisted.uomLabel
bom.uom = persisted.uom
}

/** Stock/sales qty from UI or Excel → base values for DB. */
open fun persistFromSourceUnit(
itemId: Long,
sourceQty: BigDecimal,
sourceUomId: Long,
): BomOutputPersisted {
val baseItemUom = itemUomService.findBaseUnitByItemId(itemId)
?: throw BadRequestException("貨品未設定 base unit: itemId=$itemId")
val baseUom = baseItemUom.uom
?: throw BadRequestException("貨品 base unit 換算未設定: itemId=$itemId")
val baseQty = itemUomService.convertQtyToBaseQtyPrecise(itemId, sourceUomId, sourceQty)
?: throw BadRequestException("無法將產出數量換算為 base unit: itemId=$itemId, uomId=$sourceUomId")
return BomOutputPersisted(
qty = baseQty.setScale(2, RoundingMode.HALF_UP),
uomLabel = baseUom.udfudesc,
uom = baseUom,
)
}

/** Persist when qty is already expressed in base unit (e.g. M18 udfHarvest). */
open fun applyBaseQtyToBom(bom: Bom, baseQty: BigDecimal) {
val itemId = bom.item?.id
?: throw BadRequestException("BOM 未關聯貨品,無法設定產出數量")
val baseUom = itemUomService.findBaseUnitByItemId(itemId)?.uom
?: throw BadRequestException("貨品未設定 base unit: itemId=$itemId")
bom.outputQty = baseQty.setScale(2, RoundingMode.HALF_UP)
bom.outputQtyUom = baseUom.udfudesc
bom.uom = baseUom
}

/** DB base → stock unit for display and job-order math. */
open fun displayAsStockUnit(bom: Bom): BomOutputDisplay {
val itemId = bom.item?.id
val baseQty = bom.outputQty
if (itemId == null || baseQty == null) {
return BomOutputDisplay(
qty = baseQty ?: BigDecimal.ZERO,
uomLabel = bom.outputQtyUom,
uomId = bom.uom?.id,
)
}
val baseUomId = bom.uom?.id
?: itemUomService.findBaseUnitByItemId(itemId)?.uom?.id
if (baseUomId == null || baseQty.compareTo(BigDecimal.ZERO) == 0) {
val stockUom = itemUomService.findStockUnitByItemId(itemId)?.uom
return BomOutputDisplay(baseQty, stockUom?.udfudesc ?: bom.outputQtyUom, stockUom?.id)
}
return try {
val converted = itemUomService.convertUomByItem(
ConvertUomByItemRequest(
itemId = itemId,
qty = baseQty,
uomId = baseUomId,
targetUnit = "stockUnit",
),
)
BomOutputDisplay(
qty = converted.newQty,
uomLabel = converted.udfudesc,
uomId = itemUomService.findStockUnitByItemId(itemId)?.uom?.id,
)
} catch (_: Exception) {
BomOutputDisplay(baseQty, bom.outputQtyUom, baseUomId)
}
}

/** Stock-unit batch output qty for proportions / scheduling (falls back to stored qty). */
open fun stockOutputQty(bom: Bom?): BigDecimal {
if (bom == null) return BigDecimal.ONE
val display = displayAsStockUnit(bom)
val qty = display.qty
return if (qty > BigDecimal.ZERO) qty else (bom.outputQty ?: BigDecimal.ONE)
}

open fun stockOutputUomLabel(bom: Bom?): String? = bom?.let { displayAsStockUnit(it).uomLabel }

/**
* One-time style migration helper: convert legacy sales/stock-stored rows to base.
* Returns true when the entity was updated.
*/
open fun migrateLegacyOutputToBase(bom: Bom): Boolean {
val itemId = bom.item?.id ?: return false
val baseItemUom = itemUomService.findBaseUnitByItemId(itemId) ?: return false
val baseUom = baseItemUom.uom ?: return false
val baseUomId = baseUom.id ?: return false
if (bom.uom?.id == baseUomId) return false

val qty = bom.outputQty ?: return false
val outputDesc = bom.outputQtyUom?.trim().orEmpty()
val baseDesc = baseUom.udfudesc?.trim().orEmpty()
val baseShort = baseUom.udfShortDesc?.trim().orEmpty()
val qtyAlreadyInBase = outputDesc.isNotEmpty() &&
(outputDesc.equals(baseDesc, ignoreCase = true) || outputDesc.equals(baseShort, ignoreCase = true))

if (qtyAlreadyInBase) {
bom.uom = baseUom
bom.outputQtyUom = baseUom.udfudesc
return true
}

val sourceUomId = bom.uom?.id
?: itemUomService.findSalesUnitByItemId(itemId)?.uom?.id
?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id
?: return false

val baseQty = itemUomService.convertQtyToBaseQtyPrecise(itemId, sourceUomId, qty) ?: return false
bom.outputQty = baseQty.setScale(2, RoundingMode.HALF_UP)
bom.outputQtyUom = baseUom.udfudesc
bom.uom = baseUom
return true
}
}

+ 6
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomProcessConstants.kt Просмотреть файл

@@ -0,0 +1,6 @@
package com.ffii.fpsms.modules.master.service

object BomProcessConstants {
/** Fixed `process.code` for packaging steps (工序說明 = bag items). */
const val PACKAGING_PROCESS_CODE = "包裝"
}

+ 82
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomRecipeUomOptionsService.kt Просмотреть файл

@@ -0,0 +1,82 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.modules.master.entity.UomConversionRepository
import com.ffii.fpsms.modules.master.web.models.BomMaterialUomOption
import org.springframework.stereotype.Service

/**
* Recipe UOM dropdown options for BOM materials.
* If item base is in [StandardUomMatrix], offer standard matrix units (+ current when needed).
* Otherwise only the item base unit is allowed (no legacy/current recipe UOM).
*
* Important: when Excel/current recipe UOM is non-matrix (e.g. PCS) but item base IS in matrix
* (e.g. G), still offer the full matrix list so the user can correct to a convertible unit.
*/
@Service
open class BomRecipeUomOptionsService(
private val itemUomService: ItemUomService,
private val uomConversionRepository: UomConversionRepository,
) {
open fun buildAvailableRecipeUoms(itemId: Long, currentRecipeUomId: Long?): List<BomMaterialUomOption> {
val baseUom = itemUomService.findBaseUnitByItemId(itemId)?.uom

// Base not in matrix → only M18 base unit is selectable
if (baseUom != null && StandardUomMatrix.toMatrixUnit(baseUom) == null) {
val baseId = baseUom.id ?: return emptyList()
return listOf(
BomMaterialUomOption(
uomId = baseId,
label = baseUom.udfudesc?.takeIf { it.isNotBlank() } ?: baseUom.code ?: "-",
code = baseUom.code,
),
)
}

// Base in matrix (or missing): offer full matrix list
val seen = linkedSetOf<Long>()
val options = mutableListOf<BomMaterialUomOption>()

for (code in StandardUomMatrix.STANDARD_CODES) {
val uom = uomConversionRepository.findByCodeAndDeletedFalse(code) ?: continue
val id = uom.id ?: continue
if (seen.add(id)) {
options.add(
BomMaterialUomOption(
uomId = id,
label = uom.udfudesc?.takeIf { it.isNotBlank() } ?: code,
code = uom.code,
),
)
}
}

baseUom?.let { base ->
val id = base.id ?: return@let
if (id !in seen) {
options.add(
BomMaterialUomOption(
uomId = id,
label = base.udfudesc?.takeIf { it.isNotBlank() } ?: base.code ?: "-",
code = base.code,
),
)
seen.add(id)
}
}

// Keep invalid Excel current (e.g. PCS) visible so user can see and change it
if (currentRecipeUomId != null && currentRecipeUomId !in seen) {
uomConversionRepository.findByIdAndDeletedFalse(currentRecipeUomId)?.let { uom ->
options.add(
BomMaterialUomOption(
uomId = currentRecipeUomId,
label = uom.udfudesc?.takeIf { it.isNotBlank() } ?: uom.code ?: "-",
code = uom.code,
),
)
}
}

return options
}
}

+ 70
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomScaleValidation.kt Просмотреть файл

@@ -0,0 +1,70 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.core.exception.BadRequestException
import com.ffii.fpsms.modules.master.entity.Bom

/**
* Allowed BOM basic-info scale values.
* Must stay aligned with frontend [bomAttributeScales.ts] and Excel import format checks.
*/
object BomScaleValidation {
private val ALLOWED_DARK = setOf(0, 1, 2, 3, 4, 5)
private val ALLOWED_FLOAT = setOf(0, 3, 5)
private val ALLOWED_DENSE = setOf(0, 3, 5)
private val ALLOWED_TIME_SEQUENCE = setOf(0, 1, 5)
private val ALLOWED_COMPLEXITY = setOf(0, 3, 5, 10)
private val ALLOWED_ALLERGIC = setOf(0, 5)

fun validateBomScaleFields(bom: Bom) {
bom.isDark?.let { validateDark(it) }
bom.isFloat?.let { validateFloat(it) }
bom.isDense?.let { validateDense(it) }
bom.timeSequence?.let { validateTimeSequence(it) }
bom.complexity?.let { validateComplexity(it) }
bom.allergicSubstances?.let { validateAllergic(it) }
}

fun validateDark(value: Int) {
if (value !in ALLOWED_DARK) {
throw BadRequestException("基本資料:『顔色深淺度』必須為 1~5 的數值")
}
}

fun validateFloat(value: Int) {
if (value !in ALLOWED_FLOAT) {
throw BadRequestException("基本資料:『浮沉』必須為 0、3 或 5")
}
}

fun validateDense(value: Int) {
if (value !in ALLOWED_DENSE) {
throw BadRequestException("基本資料:『濃淡程度』必須為 0、3 或 5")
}
}

fun validateTimeSequence(value: Int) {
if (value !in ALLOWED_TIME_SEQUENCE) {
throw BadRequestException("基本資料:『生產時段先後數值』必須為 0、1 或 5")
}
}

fun validateComplexity(value: Int) {
if (value !in ALLOWED_COMPLEXITY) {
throw BadRequestException("基本資料:『生產複雜度』必須為 0、3、5 或 10")
}
}

fun validateAllergic(value: Int) {
if (value !in ALLOWED_ALLERGIC) {
throw BadRequestException("基本資料:『過敏原』應為數值 0 或 5")
}
}

/** Edit BOM: scored attributes cannot remain 0 (不適用). */
fun validateBomScaleFieldsForEdit(bom: Bom) {
validateBomScaleFields(bom)
if (bom.isDark == null || bom.isDark == 0) {
throw BadRequestException("色深請設定數值,不可為不適用")
}
}
}

+ 1529
- 587
src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 804
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomUomAlignmentService.kt Просмотреть файл

@@ -0,0 +1,804 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.BomMaterial
import com.ffii.fpsms.modules.master.entity.BomMaterialRepository
import com.ffii.fpsms.modules.master.entity.BomRepository
import com.ffii.fpsms.modules.master.entity.UomConversion
import com.ffii.fpsms.modules.master.entity.UomConversionRepository
import com.ffii.fpsms.modules.master.web.models.*
import com.ffii.core.exception.BadRequestException
import java.math.BigDecimal
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
open class BomUomAlignmentService(
private val bomRepository: BomRepository,
private val bomMaterialRepository: BomMaterialRepository,
private val itemUomService: ItemUomService,
private val uomConversionService: UomConversionService,
private val uomConversionRepository: UomConversionRepository,
private val masterDataIssueService: MasterDataIssueService,
private val bomOutputQtyService: BomOutputQtyService,
private val bomMaterialQtyService: BomMaterialQtyService,
private val bomRecipeUomOptionsService: BomRecipeUomOptionsService,
) {
private data class ItemUomSet(
val sales: UomConversion?,
val stock: UomConversion?,
val base: UomConversion?,
)

@Transactional(readOnly = true)
open fun previewAlignment(request: BomUomAlignmentPreviewRequest): BomUomAlignmentPreviewResponse {
val filterBomIds = request.bomIds?.toSet()
val filterMaterialIds = request.bomMaterialIds?.toSet()

val headers = mutableListOf<BomHeaderUomAlignmentPreview>()
val materials = mutableListOf<BomMaterialUomAlignmentPreview>()
val skipped = mutableListOf<BomUomAlignmentSkipped>()
val headerDone = mutableSetOf<Long>()
val itemUomCache = mutableMapOf<Long, ItemUomSet>()

// Fast path: when row scope is provided, avoid full master-data issue scan.
if (filterBomIds != null || filterMaterialIds != null) {
if (filterMaterialIds == null) {
for (bomId in filterBomIds.orEmpty()) {
if (!headerDone.add(bomId)) continue
val bom = bomRepository.findById(bomId).orElse(null) ?: continue
buildHeaderPreview(bom)?.let { headers.add(it) }
?: skipped.add(
BomUomAlignmentSkipped(
bomId = bomId,
bomCode = bom.code,
reason = "無法建立 BOM 表頭預覽",
),
)
}
}

val scopedMaterials =
when {
filterMaterialIds != null ->
filterMaterialIds.mapNotNull { id ->
bomMaterialRepository.findByIdAndDeletedIsFalse(id)
}
filterBomIds != null ->
filterBomIds.flatMap { bomId ->
bomMaterialRepository.findAllByBomIdAndDeletedIsFalse(bomId)
}
else -> emptyList()
}

for (material in scopedMaterials) {
val bomId = material.bom?.id ?: continue
if (filterBomIds != null && bomId !in filterBomIds) continue
buildMaterialPreview(material, itemUomCache)?.let { materials.add(it) }
?: skipped.add(
BomUomAlignmentSkipped(
bomId = bomId,
bomCode = material.bom?.code,
bomMaterialId = material.id,
itemCode = material.item?.code,
reason = "無法建立原料預覽(缺少可換算單位或換算失敗)",
),
)
}

return BomUomAlignmentPreviewResponse(
headers = headers.sortedBy { it.bomCode?.uppercase() ?: "" },
materials = materials.sortedWith(
compareBy(
{ it.itemCode?.uppercase() ?: "" },
{ it.bomCode?.uppercase() ?: "" },
),
),
skipped = skipped,
)
}

val issues = masterDataIssueService.findBomMasterDataIssues()
val headerBomIds = issues
.filter {
it.issueCode == "BOM_OUTPUT_UOM_MISMATCH_BASE" ||
it.issueCode == "BOM_OUTPUT_UOM_TEXT_DRIFT"
}
.mapNotNull { it.bomId }
.toSet()
val materialIds = issues
.filter {
it.issueCode == "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE" ||
it.issueCode == "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX" ||
it.issueCode == "BOM_MATERIAL_RECIPE_UOM_NOT_IN_MATRIX" ||
it.issueCode == "BOM_MATERIAL_DERIVE_FAILED"
}
.mapNotNull { it.bomMaterialId }
.toSet()

for (bomId in headerBomIds) {
if (filterMaterialIds != null) continue
if (filterBomIds != null && bomId !in filterBomIds) continue
if (!headerDone.add(bomId)) continue
val bom = bomRepository.findById(bomId).orElse(null) ?: continue
buildHeaderPreview(bom)?.let { headers.add(it) }
?: skipped.add(
BomUomAlignmentSkipped(
bomId = bomId,
bomCode = bom.code,
reason = "無法建立 BOM 表頭預覽",
),
)
}

for (materialId in materialIds) {
if (filterMaterialIds != null && materialId !in filterMaterialIds) continue
val material = bomMaterialRepository.findByIdAndDeletedIsFalse(materialId) ?: continue
val bomId = material.bom?.id ?: continue
if (filterMaterialIds == null && filterBomIds != null && bomId !in filterBomIds) continue
buildMaterialPreview(material, itemUomCache)?.let { materials.add(it) }
?: skipped.add(
BomUomAlignmentSkipped(
bomId = bomId,
bomCode = material.bom?.code,
bomMaterialId = materialId,
itemCode = material.item?.code,
reason = "無法建立原料預覽(缺少可換算單位或換算失敗)",
),
)
}

return BomUomAlignmentPreviewResponse(
headers = headers.sortedBy { it.bomCode?.uppercase() ?: "" },
materials = materials.sortedWith(
compareBy(
{ it.itemCode?.uppercase() ?: "" },
{ it.bomCode?.uppercase() ?: "" },
),
),
skipped = skipped,
)
}

@Transactional(readOnly = true)
open fun recalculateMaterialQty(request: BomMaterialQtyRecalculateRequest): BomMaterialQtyRecalculateResponse {
val itemId = request.itemId
val qty = request.qty
return when (request.anchor) {
BomMaterialQtyAnchor.SALE_QTY -> {
val stockQty = convertSaleToStockQty(itemId, qty, request.salesUomId, request.stockUomId)
val baseQty = convertQty(itemId, qty, request.salesUomId, "baseUnit")
BomMaterialQtyRecalculateResponse(
saleQty = qty,
stockQty = stockQty,
baseQty = baseQty,
saleUom = uomConversionService.findById(request.salesUomId)?.udfudesc,
stockUom = uomConversionService.findById(request.stockUomId)?.udfudesc,
baseUom = uomConversionService.findById(request.baseUomId)?.udfudesc,
)
}
BomMaterialQtyAnchor.STOCK_QTY -> {
val saleQty = convertStockToSaleQty(itemId, qty, request.salesUomId, request.stockUomId)
val baseQty = convertQty(itemId, qty, request.stockUomId, "baseUnit")
BomMaterialQtyRecalculateResponse(
saleQty = saleQty,
stockQty = qty,
baseQty = baseQty,
saleUom = uomConversionService.findById(request.salesUomId)?.udfudesc,
stockUom = uomConversionService.findById(request.stockUomId)?.udfudesc,
baseUom = uomConversionService.findById(request.baseUomId)?.udfudesc,
)
}
BomMaterialQtyAnchor.BASE_QTY -> {
val saleQty = convertQty(itemId, qty, request.baseUomId, "salesUnit")
val stockQty = convertSaleToStockQty(itemId, saleQty, request.salesUomId, request.stockUomId)
BomMaterialQtyRecalculateResponse(
saleQty = saleQty,
stockQty = stockQty,
baseQty = qty,
saleUom = uomConversionService.findById(request.salesUomId)?.udfudesc,
stockUom = uomConversionService.findById(request.stockUomId)?.udfudesc,
baseUom = uomConversionService.findById(request.baseUomId)?.udfudesc,
)
}
BomMaterialQtyAnchor.RECIPE_QTY -> {
val recipeUomId = request.recipeUomId
?: throw BadRequestException("recipeUomId is required for RECIPE_QTY anchor")
val recipeUom = uomConversionService.findById(recipeUomId)
?: throw BadRequestException("Recipe UOM not found: id=$recipeUomId")
val derived = bomMaterialQtyService.deriveFromRecipe(request.itemId, qty, recipeUom)
BomMaterialQtyRecalculateResponse(
saleQty = derived.saleQty,
stockQty = derived.stockQty,
baseQty = derived.baseQty,
saleUom = derived.salesUnitCode,
stockUom = derived.stockUnitName,
baseUom = derived.baseUnitName,
)
}
}
}

@Transactional
open fun applyAlignment(request: BomUomAlignmentApplyRequest): BomUomAlignmentApplyResponse {
var headersUpdated = 0
var materialsUpdated = 0

for (header in request.headers) {
val bom = bomRepository.findById(header.bomId).orElse(null) ?: continue
if (header.outputQtyStock != null && header.stockUomId != null) {
bomOutputQtyService.applyStockQtyToBom(bom, header.outputQtyStock!!, header.stockUomId!!)
} else if (header.outputQty != null) {
bomOutputQtyService.applyBaseQtyToBom(bom, header.outputQty!!)
} else if (header.uomId != null) {
val uom = uomConversionService.findById(header.uomId) ?: continue
bom.uom = uom
bom.outputQtyUom = uom.udfudesc
}
bomRepository.saveAndFlush(bom)
headersUpdated++
}

for (mat in request.materials) {
val material = bomMaterialRepository.findByIdAndDeletedIsFalse(mat.bomMaterialId) ?: continue
mat.qty?.let { material.qty = it }
mat.uomId?.let { id ->
material.uom = uomConversionService.findById(id)
material.uomName = material.uom?.udfudesc
}
bomMaterialRepository.saveAndFlush(material)
materialsUpdated++
}

return BomUomAlignmentApplyResponse(
headersUpdated = headersUpdated,
materialsUpdated = materialsUpdated,
message = "Success",
)
}

private fun buildHeaderPreview(bom: Bom): BomHeaderUomAlignmentPreview? {
val bomId = bom.id ?: return null
val itemId = bom.item?.id
if (itemId == null) {
return BomHeaderUomAlignmentPreview(
bomId = bomId,
bomCode = bom.code,
bomName = bom.name,
canApply = false,
skipReason = "BOM 未關聯貨品",
beforeOutputQty = bom.outputQty,
afterOutputQty = bom.outputQty,
beforeUom = toLabel(bom.uom),
afterUom = null,
)
}

val baseUom = itemUomService.findBaseUnitByItemId(itemId)?.uom
val stockUom = itemUomService.findStockUnitByItemId(itemId)?.uom
if (baseUom?.id == null) {
return BomHeaderUomAlignmentPreview(
bomId = bomId,
bomCode = bom.code,
bomName = bom.name,
itemId = itemId,
canApply = false,
skipReason = "成品未設定 base unit",
beforeOutputQty = bom.outputQty,
afterOutputQty = bom.outputQty,
beforeUom = toLabel(bom.uom),
afterUom = null,
beforeStockUom = toLabel(stockUom),
)
}

val baseLabel = toLabel(baseUom)
val stockLabel = toLabel(stockUom)
val storedAsBase = bomOutputQtyService.isStoredAsBaseUnit(bom)

if (storedAsBase) {
val stockDisplay = bomOutputQtyService.displayAsStockUnit(bom)
val afterBaseQty =
resolveBaseOutputQtyFromStock(itemId, stockDisplay.qty, stockUom?.id)
?: bom.outputQty
return BomHeaderUomAlignmentPreview(
bomId = bomId,
bomCode = bom.code,
bomName = bom.name,
itemId = itemId,
canApply = false,
beforeOutputQty = bom.outputQty,
afterOutputQty = afterBaseQty,
beforeOutputQtyStock = stockDisplay.qty,
afterOutputQtyStock = stockDisplay.qty,
beforeUom = baseLabel,
afterUom = baseLabel,
beforeBaseUom = baseLabel,
afterBaseUom = baseLabel,
beforeStockUom = stockLabel,
afterStockUom = stockLabel,
)
}

val sourceQty = bom.outputQty
val sourceUomId = bom.uom?.id
val legacyUomLabel = toLabel(bom.uom)
val afterStockQty = resolveStockOutputFromSource(itemId, sourceQty, sourceUomId, stockUom?.id)
val afterBaseQty = resolveBaseOutputQty(bom, itemId, baseUom.id!!)

return BomHeaderUomAlignmentPreview(
bomId = bomId,
bomCode = bom.code,
bomName = bom.name,
itemId = itemId,
canApply = bom.uom?.id != baseUom.id,
beforeOutputQty = sourceQty,
afterOutputQty = afterBaseQty,
beforeOutputQtyStock = null,
afterOutputQtyStock = afterStockQty,
beforeUom = legacyUomLabel,
afterUom = baseLabel,
beforeBaseUom = legacyUomLabel,
afterBaseUom = baseLabel,
beforeStockUom = null,
afterStockUom = stockLabel,
)
}

@Transactional(readOnly = true)
open fun recalculateHeaderOutputQty(
request: BomHeaderOutputQtyRecalculateRequest,
): BomHeaderOutputQtyRecalculateResponse {
val baseUomRow = itemUomService.findBaseUnitByItemId(request.itemId)?.uom
val baseUomLabel = baseUomRow?.udfudesc
val baseUomId = baseUomRow?.id
val baseQty = convertQty(
request.itemId,
request.stockQty,
request.stockUomId,
"baseUnit",
)
return BomHeaderOutputQtyRecalculateResponse(
stockQty = request.stockQty,
baseQty = baseQty,
baseUom = baseUomLabel,
baseUomId = baseUomId,
)
}

/** Align base reference qty with stock-unit row (same path as apply + header recalculate). */
private fun resolveBaseOutputQtyFromStock(
itemId: Long,
stockQty: BigDecimal?,
stockUomId: Long?,
): BigDecimal? {
if (stockQty == null || stockUomId == null) return null
return try {
convertQty(itemId, stockQty, stockUomId, "baseUnit")
} catch (_: Exception) {
null
}
}

/** Legacy BOM source qty (Excel/packaging UOM) → item stock unit for alignment preview. */
private fun resolveStockOutputFromSource(
itemId: Long,
sourceQty: BigDecimal?,
sourceUomId: Long?,
stockUomId: Long?,
): BigDecimal? {
if (sourceQty == null || sourceUomId == null || stockUomId == null) return null
return try {
convertQty(itemId, sourceQty, sourceUomId, "stockUnit")
} catch (_: Exception) {
null
}
}

/** Convert BOM stored output qty to item base unit (fallback when stock conversion unavailable). */
private fun resolveBaseOutputQty(bom: Bom, itemId: Long, baseUomId: Long): BigDecimal? {
val storedQty = bom.outputQty ?: return null
val sourceUomId = bom.uom?.id ?: return storedQty
if (sourceUomId == baseUomId) return storedQty
return try {
bomOutputQtyService.persistFromSourceUnit(itemId, storedQty, sourceUomId).qty
} catch (_: Exception) {
storedQty
}
}

private fun buildMaterialPreview(
material: BomMaterial,
itemUomCache: MutableMap<Long, ItemUomSet> = mutableMapOf(),
): BomMaterialUomAlignmentPreview? {
val bomMaterialId = material.id ?: return null
val itemId = material.item?.id ?: return null
val bom = material.bom

val uoms = itemUomCache.getOrPut(itemId) {
ItemUomSet(
sales = itemUomService.findSalesUnitByItemId(itemId)?.uom,
stock = itemUomService.findStockUnitByItemId(itemId)?.uom,
base = itemUomService.findBaseUnitByItemId(itemId)?.uom,
)
}
val salesUom = uoms.sales
val stockUom = uoms.stock
val baseUom = uoms.base

if (salesUom?.id == null || stockUom?.id == null || baseUom?.id == null) {
return BomMaterialUomAlignmentPreview(
bomId = bom?.id ?: 0L,
bomCode = bom?.code,
bomName = bom?.name,
bomMaterialId = bomMaterialId,
itemId = itemId,
itemCode = material.item?.code,
itemName = material.item?.name ?: material.itemName,
canApply = false,
skipReason = "貨品缺少 M18 銷售/庫存/基本單位",
beforeRecipeQty = beforeRecipe(material),
)
}

val recipeQty = material.qty
val basePreview = BomMaterialUomAlignmentPreview(
bomId = bom?.id ?: 0L,
bomCode = bom?.code,
bomName = bom?.name,
bomMaterialId = bomMaterialId,
itemId = itemId,
itemCode = material.item?.code,
itemName = material.item?.name ?: material.itemName,
beforeSaleQty = null,
beforeStockQty = resolveBeforeStockQty(
itemId, recipeQty = material.qty, recipeUom = material.uom, baseUom, stockUom,
),
beforeBaseQty = resolveBeforeBaseQty(
itemId, recipeQty = material.qty, recipeUom = material.uom, baseUom,
),
beforeRecipeQty = beforeRecipe(material),
)

if (recipeQty == null) {
return basePreview.copy(
canApply = false,
skipReason = "配方用量未設定",
warnings = collectNullBomQtyWarnings(material),
)
}
val availableRecipeUoms = buildAlignmentRecipeUoms(
itemId,
material.uom?.id,
recipeQty,
baseUom,
)
if (availableRecipeUoms.isEmpty()) {
return enrichWithDisplayAfterPreview(
basePreview.copy(
canApply = false,
skipReason = "無可用配方單位選項",
warnings = collectNullBomQtyWarnings(material),
availableRecipeUoms = availableRecipeUoms,
),
itemId,
recipeQty,
salesUom,
stockUom,
baseUom,
)
}

val suggestedRecipeUomId = resolveSuggestedRecipeUomId(
itemId = itemId,
recipeQty = recipeQty,
currentRecipeUom = material.uom,
baseUom = baseUom,
salesUom = salesUom,
availableRecipeUoms = availableRecipeUoms,
)
if (suggestedRecipeUomId == null) {
return enrichWithDisplayAfterPreview(
basePreview.copy(
canApply = false,
skipReason = "配方單位無法換算至 M18 單位",
warnings = collectNullBomQtyWarnings(material),
availableRecipeUoms = availableRecipeUoms,
),
itemId,
recipeQty,
salesUom,
stockUom,
baseUom,
)
}

val suggestedUom = uomConversionService.findById(suggestedRecipeUomId)
?: return enrichWithDisplayAfterPreview(
basePreview.copy(
canApply = false,
skipReason = "建議配方單位不存在",
availableRecipeUoms = availableRecipeUoms,
),
itemId,
recipeQty,
salesUom,
stockUom,
baseUom,
)

val suggestedRecipeQty = resolveSuggestedRecipeQty(
itemId = itemId,
originalRecipeQty = recipeQty,
currentRecipeUom = material.uom,
suggestedUom = suggestedUom,
baseUom = baseUom,
salesUom = salesUom,
)

val derived = try {
bomMaterialQtyService.deriveFromRecipe(itemId, suggestedRecipeQty, suggestedUom)
} catch (e: Exception) {
return enrichWithDisplayAfterPreview(
basePreview.copy(
canApply = false,
skipReason = e.message ?: "換算失敗",
warnings = collectNullBomQtyWarnings(material),
availableRecipeUoms = availableRecipeUoms,
suggestedRecipeUomId = suggestedRecipeUomId,
),
itemId,
recipeQty,
salesUom,
stockUom,
baseUom,
)
}

return basePreview.copy(
canApply = true,
warnings = collectNullBomQtyWarnings(material),
availableRecipeUoms = availableRecipeUoms,
suggestedRecipeUomId = suggestedRecipeUomId,
afterSaleQty = BomUomQtyValue(derived.saleQty, toLabel(salesUom)),
afterStockQty = BomUomQtyValue(derived.stockQty, toLabel(stockUom)),
afterBaseQty = BomUomQtyValue(
if (suggestedUom.id == baseUom.id) suggestedRecipeQty else derived.baseQty,
toLabel(baseUom),
),
afterRecipeQty = BomUomQtyValue(suggestedRecipeQty, toLabel(suggestedUom)),
)
}

private fun resolveBeforeBaseQty(
itemId: Long,
recipeQty: BigDecimal?,
recipeUom: UomConversion?,
baseUom: UomConversion?,
): BomUomQtyValue? {
if (recipeQty == null || recipeUom == null || baseUom == null) return null
if (!bomMaterialQtyService.canConvertRecipeToBase(itemId, recipeUom, baseUom)) return null
return try {
val derived = bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom)
BomUomQtyValue(derived.baseQty, toLabel(baseUom))
} catch (_: Exception) {
null
}
}

private fun resolveBeforeStockQty(
itemId: Long,
recipeQty: BigDecimal?,
recipeUom: UomConversion?,
baseUom: UomConversion?,
stockUom: UomConversion?,
): BomUomQtyValue? {
if (recipeQty == null || recipeUom == null || baseUom == null || stockUom == null) return null
if (!bomMaterialQtyService.canConvertRecipeToBase(itemId, recipeUom, baseUom)) return null
return try {
val derived = bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom)
BomUomQtyValue(derived.stockQty, toLabel(stockUom))
} catch (_: Exception) {
null
}
}

/** When aligning to M18 base, convert legacy qty (e.g. 23 PCS) to base grams via sales UOM. */
private fun resolveSuggestedRecipeQty(
itemId: Long,
originalRecipeQty: BigDecimal,
currentRecipeUom: UomConversion?,
suggestedUom: UomConversion,
baseUom: UomConversion,
salesUom: UomConversion?,
): BigDecimal {
if (suggestedUom.id != baseUom.id) return originalRecipeQty
if (currentRecipeUom?.id == baseUom.id) return originalRecipeQty

currentRecipeUom?.let { current ->
if (canDeriveFromRecipe(itemId, originalRecipeQty, current)) {
return bomMaterialQtyService.deriveFromRecipe(itemId, originalRecipeQty, current).baseQty
?: originalRecipeQty
}
}
salesUom?.let { sales ->
if (canDeriveFromRecipe(itemId, originalRecipeQty, sales)) {
return bomMaterialQtyService.deriveFromRecipe(itemId, originalRecipeQty, sales).baseQty
?: originalRecipeQty
}
}
return originalRecipeQty
}

private fun resolveSuggestedRecipeUomId(
itemId: Long,
recipeQty: BigDecimal,
currentRecipeUom: UomConversion?,
baseUom: UomConversion,
salesUom: UomConversion?,
availableRecipeUoms: List<BomMaterialUomOption>,
): Long? {
// Item base not in standard matrix → recipe must follow M18 base unit (e.g. PCS not G).
if (StandardUomMatrix.toMatrixUnit(baseUom) == null) {
baseUom.id?.let { id ->
if (availableRecipeUoms.any { it.uomId == id }) return id
}
}

// M18 fallback: legacy non-matrix recipe → base unit is the only recipe option.
if (availableRecipeUoms.size == 1) {
return availableRecipeUoms.first().uomId
}

currentRecipeUom?.let { current ->
if (canDeriveFromRecipe(itemId, recipeQty, current)) return current.id
}
baseUom.id?.let { id ->
if (availableRecipeUoms.any { it.uomId == id }) return id
}
salesUom?.id?.let { salesId ->
if (availableRecipeUoms.any { it.uomId == salesId }) {
val uom = uomConversionService.findById(salesId)
if (uom != null && canDeriveFromRecipe(itemId, recipeQty, uom)) return salesId
}
}
for (opt in availableRecipeUoms) {
val uom = uomConversionService.findById(opt.uomId) ?: continue
if (canDeriveFromRecipe(itemId, recipeQty, uom)) return opt.uomId
}
return null
}

private fun canDeriveFromRecipe(itemId: Long, recipeQty: BigDecimal, recipeUom: UomConversion): Boolean =
try {
bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom)
true
} catch (_: Exception) {
false
}

private fun collectNullBomQtyWarnings(material: BomMaterial): List<String> {
val warnings = mutableListOf<String>()
if (material.qty == null) warnings.add("BOM_RECIPE_QTY_NOT_SET")
if (material.uom?.id == null) warnings.add("BOM_RECIPE_UOM_NOT_SET")
return warnings
}

private fun convertSaleToStockQty(
itemId: Long,
saleQty: BigDecimal,
salesUomId: Long,
stockUomId: Long,
): BigDecimal =
if (salesUomId == stockUomId) saleQty
else convertQty(itemId, saleQty, salesUomId, "stockUnit")

private fun convertStockToSaleQty(
itemId: Long,
stockQty: BigDecimal,
salesUomId: Long,
stockUomId: Long,
): BigDecimal =
if (salesUomId == stockUomId) stockQty
else convertQty(itemId, stockQty, stockUomId, "salesUnit")

private fun convertQty(itemId: Long, qty: BigDecimal, uomId: Long, targetUnit: String): BigDecimal =
itemUomService.convertUomByItem(
ConvertUomByItemRequest(
itemId = itemId,
qty = qty,
uomId = uomId,
targetUnit = targetUnit,
),
).newQty

private fun beforeRecipe(material: BomMaterial): BomUomQtyValue =
BomUomQtyValue(material.qty, toLabel(material.uom))

/**
* Recipe UOM options for alignment.
* Item base not in matrix → only M18 base is selectable.
* Legacy non-matrix recipe that cannot convert → only M18 base.
*/
private fun buildAlignmentRecipeUoms(
itemId: Long,
currentRecipeUomId: Long?,
recipeQty: BigDecimal,
baseUom: UomConversion?,
): List<BomMaterialUomOption> {
if (baseUom?.id != null && StandardUomMatrix.toMatrixUnit(baseUom) == null) {
val label = toLabel(baseUom)?.label ?: baseUom.code ?: "-"
return listOf(
BomMaterialUomOption(
uomId = baseUom.id!!,
label = label,
code = baseUom.code,
),
)
}
val currentUom = currentRecipeUomId?.let { uomConversionService.findById(it) }
if (
currentUom != null &&
StandardUomMatrix.toMatrixUnit(currentUom) == null &&
baseUom?.id != null &&
!canDeriveFromRecipe(itemId, recipeQty, currentUom)
) {
val label = toLabel(baseUom)?.label ?: baseUom.code ?: "-"
return listOf(
BomMaterialUomOption(
uomId = baseUom.id!!,
label = label,
code = baseUom.code,
),
)
}
return bomRecipeUomOptionsService.buildAvailableRecipeUoms(itemId, currentRecipeUomId)
}

/** Populate read-only after columns for blocked rows (M18 suggestion preview). */
private fun enrichWithDisplayAfterPreview(
preview: BomMaterialUomAlignmentPreview,
itemId: Long,
recipeQty: BigDecimal,
salesUom: UomConversion,
stockUom: UomConversion,
baseUom: UomConversion,
): BomMaterialUomAlignmentPreview {
if (preview.afterRecipeQty != null) return preview
val candidates = linkedSetOf<UomConversion>()
preview.suggestedRecipeUomId?.let { uomConversionService.findById(it) }?.let { candidates.add(it) }
salesUom.let { candidates.add(it) }
baseUom.let { candidates.add(it) }
stockUom.let { candidates.add(it) }
for (uom in candidates) {
try {
val derived = bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, uom)
return preview.copy(
afterRecipeQty = BomUomQtyValue(recipeQty, toLabel(uom)),
afterBaseQty = BomUomQtyValue(
if (uom.id == baseUom.id) recipeQty else derived.baseQty,
toLabel(baseUom),
),
afterStockQty = BomUomQtyValue(derived.stockQty, toLabel(stockUom)),
afterSaleQty = BomUomQtyValue(derived.saleQty, toLabel(salesUom)),
)
} catch (_: Exception) {
continue
}
}
return preview
}

private fun toLabel(uom: UomConversion?): BomUomLabel? {
if (uom == null) return null
val code = uom.code?.trim().orEmpty()
val desc = uom.udfudesc?.trim().orEmpty()
val label = when {
code.isNotEmpty() && desc.isNotEmpty() -> "$code / $desc"
desc.isNotEmpty() -> desc
code.isNotEmpty() -> code
else -> "-"
}
return BomUomLabel(uomId = uom.id, code = code.ifEmpty { null }, udfudesc = desc.ifEmpty { null }, label = label)
}
}

+ 53
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomCacheContext.kt Просмотреть файл

@@ -0,0 +1,53 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.modules.master.entity.ItemUom
import com.ffii.fpsms.modules.master.entity.UomConversion

/**
* In-memory item_uom / uom_conversion lookups for bulk master-data scans.
* Avoids per-row repository calls when [uomsByItemId] is preloaded.
*/
data class ItemUomCacheContext(
val uomsByItemId: Map<Long, List<ItemUom>>,
val uomById: Map<Long, UomConversion>,
) {
fun baseUnit(itemId: Long): ItemUom? =
uomsByItemId[itemId]?.firstOrNull { it.baseUnit == true }

fun stockUnit(itemId: Long): ItemUom? =
uomsByItemId[itemId]?.firstOrNull { it.stockUnit == true }

fun salesUnit(itemId: Long): ItemUom? =
uomsByItemId[itemId]?.firstOrNull { it.salesUnit == true }

fun purchaseUnit(itemId: Long): ItemUom? =
uomsByItemId[itemId]?.firstOrNull { it.purchaseUnit == true }

fun pickingUnit(itemId: Long): ItemUom? =
uomsByItemId[itemId]?.firstOrNull { it.pickingUnit == true }

fun byItemAndUomId(itemId: Long, uomId: Long): ItemUom? =
uomsByItemId[itemId]?.firstOrNull { it.uom?.id == uomId }

fun targetUnit(itemId: Long, targetUnit: String): ItemUom? =
when (targetUnit.lowercase()) {
"baseunit", "base_unit" -> baseUnit(itemId)
"stockunit", "stock_unit" -> stockUnit(itemId)
"purchaseunit", "purchase_unit" -> purchaseUnit(itemId)
"salesunit", "sales_unit" -> salesUnit(itemId)
"pickingunit", "picking_unit" -> pickingUnit(itemId)
else -> null
}

fun uom(id: Long): UomConversion? = uomById[id]

fun itemCode(itemId: Long): String? =
uomsByItemId[itemId]?.firstOrNull()?.item?.code

companion object {
fun from(
uomsByItemId: Map<Long, List<ItemUom>>,
uomById: Map<Long, UomConversion>,
): ItemUomCacheContext = ItemUomCacheContext(uomsByItemId, uomById)
}
}

+ 73
- 50
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Просмотреть файл

@@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.master.service


import com.ffii.fpsms.modules.master.entity.ItemUom import com.ffii.fpsms.modules.master.entity.ItemUom
import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.master.entity.ItemUomRespository
import com.ffii.fpsms.modules.master.entity.UomConversion
import com.ffii.fpsms.modules.master.web.models.* import com.ffii.fpsms.modules.master.web.models.*
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -346,20 +347,48 @@ open class ItemUomService(
} }




open fun convertUomByItem(request: ConvertUomByItemRequest): ConvertUomByItemResponse {
open fun convertUomByItem(request: ConvertUomByItemRequest): ConvertUomByItemResponse =
convertUomByItemInternal(request, cache = null)

open fun convertUomByItem(
request: ConvertUomByItemRequest,
cache: ItemUomCacheContext,
): ConvertUomByItemResponse = convertUomByItemInternal(request, cache)

private fun convertUomByItemInternal(
request: ConvertUomByItemRequest,
cache: ItemUomCacheContext?,
): ConvertUomByItemResponse {
fun baseUnit(itemId: Long): ItemUom? =
cache?.baseUnit(itemId) ?: findBaseUnitByItemId(itemId)

fun targetItemUom(itemId: Long, targetUnit: String): ItemUom? =
cache?.targetUnit(itemId, targetUnit) ?: findTargetItemUom(itemId, targetUnit)

fun sourceItemUom(itemId: Long, uomId: Long): ItemUom? =
cache?.byItemAndUomId(itemId, uomId)
?: itemUomRespository.findFirstByItemIdAndUomIdAndDeletedIsFalse(itemId, uomId)

fun uomById(id: Long): UomConversion? =
cache?.uom(id) ?: uomConversionService.findById(id)

fun itemCode(itemId: Long): String =
cache?.itemCode(itemId) ?: itemsRepository.findById(itemId).getOrNull()?.code
?: itemId.toString()

// Special case: Direct conversion from KG (784) to gram (4) or vice versa // Special case: Direct conversion from KG (784) to gram (4) or vice versa
// This handles cases where ItemUom records might not exist // This handles cases where ItemUom records might not exist
if (request.uomId == 784L && request.targetUnit.lowercase() == "baseunit") { if (request.uomId == 784L && request.targetUnit.lowercase() == "baseunit") {
// Check if base unit is gram (id=4) // Check if base unit is gram (id=4)
val baseUnitItemUom = findBaseUnitByItemId(request.itemId)
val baseUnitItemUom = baseUnit(request.itemId)
val baseUnitUomId = baseUnitItemUom?.uom?.id val baseUnitUomId = baseUnitItemUom?.uom?.id
if (baseUnitUomId == 4L) { if (baseUnitUomId == 4L) {
// Direct conversion: 1 KG = 1000 grams // Direct conversion: 1 KG = 1000 grams
val convertedQty = request.qty.multiply(BigDecimal(1000)) val convertedQty = request.qty.multiply(BigDecimal(1000))
val gramUom = uomConversionService.findById(4L)
val gramUom = uomById(4L)
?: throw IllegalArgumentException("UomConversion not found for id=4 (gram)") ?: throw IllegalArgumentException("UomConversion not found for id=4 (gram)")
return ConvertUomByItemResponse( return ConvertUomByItemResponse(
newQty = convertedQty, newQty = convertedQty,
udfudesc = gramUom.udfudesc, udfudesc = gramUom.udfudesc,
@@ -367,18 +396,18 @@ open class ItemUomService(
) )
} }
} }
if (request.uomId == 784L) { if (request.uomId == 784L) {
val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit)
val targetUomId = targetItemUom?.uom?.id
val targetItemUomRow = targetItemUom(request.itemId, request.targetUnit)
val targetUomId = targetItemUomRow?.uom?.id
if (targetUomId == 18L) { if (targetUomId == 18L) {
// Direct conversion: 1 KG = 1000 milliliters (assuming density = 1 g/ml for water-based products) // Direct conversion: 1 KG = 1000 milliliters (assuming density = 1 g/ml for water-based products)
// Note: This is a general conversion, actual conversion may vary by product density // Note: This is a general conversion, actual conversion may vary by product density
val convertedQty = request.qty.multiply(BigDecimal(1000)) val convertedQty = request.qty.multiply(BigDecimal(1000))
val milliliterUom = uomConversionService.findById(19L)
val milliliterUom = uomById(19L)
?: throw IllegalArgumentException("UomConversion not found for id=19 (milliliter)") ?: throw IllegalArgumentException("UomConversion not found for id=19 (milliliter)")
return ConvertUomByItemResponse( return ConvertUomByItemResponse(
newQty = convertedQty, newQty = convertedQty,
udfudesc = milliliterUom.udfudesc, udfudesc = milliliterUom.udfudesc,
@@ -386,33 +415,33 @@ open class ItemUomService(
) )
} }
} }
// Find source ItemUom by itemId and uomId // Find source ItemUom by itemId and uomId
var sourceItemUom = itemUomRespository.findFirstByItemIdAndUomIdAndDeletedIsFalse(request.itemId, request.uomId)
var resolvedSourceItemUom = sourceItemUom(request.itemId, request.uomId)
// Special handling: If source is KG (784) but ItemUom doesn't exist, try indirect conversion via baseUnit // Special handling: If source is KG (784) but ItemUom doesn't exist, try indirect conversion via baseUnit
if (sourceItemUom == null && request.uomId == 784L) {
val baseUnitItemUom = findBaseUnitByItemId(request.itemId)
if (resolvedSourceItemUom == null && request.uomId == 784L) {
val baseUnitItemUom = baseUnit(request.itemId)
val baseUnitUomId = baseUnitItemUom?.uom?.id val baseUnitUomId = baseUnitItemUom?.uom?.id
// If base unit is gram (id=4), convert KG -> G first, then G -> target // If base unit is gram (id=4), convert KG -> G first, then G -> target
if (baseUnitUomId == 4L) { if (baseUnitUomId == 4L) {
// Step 1: Convert KG to G (baseUnit) // Step 1: Convert KG to G (baseUnit)
val baseQty = request.qty.multiply(BigDecimal(1000)) val baseQty = request.qty.multiply(BigDecimal(1000))
// Step 2: Convert from baseUnit to targetUnit // Step 2: Convert from baseUnit to targetUnit
val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit)
val targetItemUomRow = targetItemUom(request.itemId, request.targetUnit)
?: throw IllegalArgumentException("Target ItemUom not found for itemId=${request.itemId}, targetUnit=${request.targetUnit}") ?: throw IllegalArgumentException("Target ItemUom not found for itemId=${request.itemId}, targetUnit=${request.targetUnit}")
val targetRatioN = targetItemUom.ratioN ?: BigDecimal.ONE
val targetRatioD = targetItemUom.ratioD ?: BigDecimal.ONE
val targetRatioN = targetItemUomRow.ratioN ?: BigDecimal.ONE
val targetRatioD = targetItemUomRow.ratioD ?: BigDecimal.ONE
// Convert base (G) to target: baseQty * ratioD / ratioN // Convert base (G) to target: baseQty * ratioD / ratioN
val newQty = baseQty.multiply(targetRatioD).divide(targetRatioN, 2, RoundingMode.UP) val newQty = baseQty.multiply(targetRatioD).divide(targetRatioN, 2, RoundingMode.UP)
val uomConversion = targetItemUom.uom
val uomConversion = targetItemUomRow.uom
?: throw IllegalArgumentException("Target UomConversion not found for target ItemUom") ?: throw IllegalArgumentException("Target UomConversion not found for target ItemUom")
return ConvertUomByItemResponse( return ConvertUomByItemResponse(
newQty = newQty, newQty = newQty,
udfudesc = uomConversion.udfudesc, udfudesc = uomConversion.udfudesc,
@@ -420,43 +449,37 @@ open class ItemUomService(
) )
} }
} }
// var sourceItemUom = itemUomRespository.findFirstByItemIdAndUomIdAndDeletedIsFalse(request.itemId, request.uomId)

// 這裡先查 items、uom_conversion,準備錯誤訊息要用的 code
val item = itemsRepository.findById(request.itemId).getOrNull()
val itemCode = item?.code ?: request.itemId.toString()
val uom = uomConversionService.findById(request.uomId)

val uom = uomById(request.uomId)
val uomCode = uom?.code ?: request.uomId.toString() val uomCode = uom?.code ?: request.uomId.toString()

// If still no source ItemUom found, throw error // If still no source ItemUom found, throw error
sourceItemUom ?: throw IllegalArgumentException(
"Source ItemUom not found for items.code=$itemCode, uom_conversion.code=$uomCode"
resolvedSourceItemUom ?: throw IllegalArgumentException(
"Source ItemUom not found for items.code=${itemCode(request.itemId)}, uom_conversion.code=$uomCode"
) )
// Find target ItemUom by itemId and targetUnit(這段可視需求決定要不要也查 code)
val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit)
// Find target ItemUom by itemId and targetUnit
val targetItemUomRow = targetItemUom(request.itemId, request.targetUnit)
?: throw IllegalArgumentException( ?: throw IllegalArgumentException(
"Target ItemUom not found for items.code=$itemCode, targetUnit=${request.targetUnit}"
"Target ItemUom not found for items.code=${itemCode(request.itemId)}, targetUnit=${request.targetUnit}"
) )
// Convert quantity using ratioN/ratioD via base unit // Convert quantity using ratioN/ratioD via base unit
val one = BigDecimal.ONE val one = BigDecimal.ONE
val sourceRatioN = sourceItemUom.ratioN ?: one
val sourceRatioD = sourceItemUom.ratioD ?: one
val targetRatioN = targetItemUom.ratioN ?: one
val targetRatioD = targetItemUom.ratioD ?: one
val sourceRatioN = resolvedSourceItemUom.ratioN ?: one
val sourceRatioD = resolvedSourceItemUom.ratioD ?: one
val targetRatioN = targetItemUomRow.ratioN ?: one
val targetRatioD = targetItemUomRow.ratioD ?: one
// Convert source qty to base: qty * ratioN / ratioD // Convert source qty to base: qty * ratioN / ratioD
val baseQty = request.qty.multiply(sourceRatioN).divide(sourceRatioD, 2, RoundingMode.UP) val baseQty = request.qty.multiply(sourceRatioN).divide(sourceRatioD, 2, RoundingMode.UP)
// Convert base to target: baseQty * ratioD / ratioN // Convert base to target: baseQty * ratioD / ratioN
val newQty = baseQty.multiply(targetRatioD).divide(targetRatioN, 2, RoundingMode.UP) val newQty = baseQty.multiply(targetRatioD).divide(targetRatioN, 2, RoundingMode.UP)
// Get UomConversion from target ItemUom // Get UomConversion from target ItemUom
val uomConversion = targetItemUom.uom
val uomConversion = targetItemUomRow.uom
?: throw IllegalArgumentException("Target UomConversion not found for target ItemUom") ?: throw IllegalArgumentException("Target UomConversion not found for target ItemUom")
return ConvertUomByItemResponse( return ConvertUomByItemResponse(
newQty = newQty, newQty = newQty,
udfudesc = uomConversion.udfudesc, udfudesc = uomConversion.udfudesc,


+ 53
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt Просмотреть файл

@@ -266,6 +266,59 @@ open class ItemsService(
return jdbcDao.queryForList(sql.toString(), args); return jdbcDao.queryForList(sql.toString(), args);
} }


open fun listBagItemsCombo(): List<Map<String, String>> {
return itemsRepository.findAllByIsBagTrueAndDeletedFalse()
.mapNotNull { item ->
val code = item.code?.trim().orEmpty()
if (code.isEmpty()) return@mapNotNull null
mapOf(
"code" to code,
"name" to item.name?.trim().orEmpty(),
)
}
.sortedBy { it["code"] }
}

open fun itemExistsByCode(code: String): Map<String, Any?> {
val trimmed = code.trim()
if (trimmed.isEmpty()) {
return mapOf("exists" to false, "code" to trimmed)
}
val item = itemsRepository.findByCodeAndDeletedFalse(trimmed)
return if (item != null) {
mapOf(
"exists" to true,
"code" to item.code,
"name" to item.name,
)
} else {
mapOf("exists" to false, "code" to trimmed)
}
}

open fun itemsExistsByCodes(codes: List<String>): List<Map<String, Any?>> {
val trimmedByInputIndex = codes.map { it.trim() }
// 預覽校驗只需要去重後查一次,再回填每個 code 的 exists/name。
val uniqueCodes = trimmedByInputIndex.filter { it.isNotEmpty() }.distinct()
if (uniqueCodes.isEmpty()) return emptyList()

val items = itemsRepository.findAllByCodeInAndDeletedFalse(uniqueCodes)
val itemByCode = items.associateBy { it.code }

return uniqueCodes.map { code ->
val item = itemByCode[code]
if (item != null) {
mapOf(
"exists" to true,
"code" to item.code,
"name" to item.name,
)
} else {
mapOf("exists" to false, "code" to code)
}
}
}

open fun getPickOrderItemsByPage(args: Map<String, Any>): List<Map<String, Any>> { open fun getPickOrderItemsByPage(args: Map<String, Any>): List<Map<String, Any>> {
try { try {
println("=== Debug: getPickOrderItemsByPage in ItemsService ===") println("=== Debug: getPickOrderItemsByPage in ItemsService ===")


+ 207
- 152
src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt Просмотреть файл

@@ -9,9 +9,12 @@ import com.ffii.fpsms.modules.master.entity.Items
import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.master.entity.ItemUomRespository
import com.ffii.fpsms.modules.master.entity.ItemsRepository import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.master.entity.UomConversion import com.ffii.fpsms.modules.master.entity.UomConversion
import com.ffii.fpsms.modules.master.enums.BomStatus
import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest
import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse
import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryResponse import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryResponse
import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryTiming import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryTiming
import java.math.BigDecimal
import java.time.LocalDateTime import java.time.LocalDateTime
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -23,7 +26,14 @@ open class MasterDataIssueService(
private val itemsRepository: ItemsRepository, private val itemsRepository: ItemsRepository,
private val itemUomRespository: ItemUomRespository, private val itemUomRespository: ItemUomRespository,
private val uomConversionService: UomConversionService, private val uomConversionService: UomConversionService,
private val itemUomService: ItemUomService,
private val bomMaterialQtyService: BomMaterialQtyService,
) { ) {
private data class BomMaterialIssueHit(
val issueCode: String,
val expectedValue: String? = null,
val actualValue: String? = null,
)
data class UnitHealth( data class UnitHealth(
val value: String?, val value: String?,
val correct: Boolean, val correct: Boolean,
@@ -52,15 +62,16 @@ open class MasterDataIssueService(
open fun findBomMasterDataIssues(): List<MasterDataIssueResponse> { open fun findBomMasterDataIssues(): List<MasterDataIssueResponse> {
val issues = mutableListOf<MasterDataIssueResponse>() val issues = mutableListOf<MasterDataIssueResponse>()
val uomsByItemId = loadActiveUomsByItemId() val uomsByItemId = loadActiveUomsByItemId()
val materials = bomMaterialRepository.findAllActiveForActiveBoms()
val materials = bomMaterialRepository.findAllByBomStatusAndDeletedFalse(BomStatus.ACTIVE)
val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L } val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L }
val uomCache = buildUomCache(uomsByItemId, emptyMap(), materials) val uomCache = buildUomCache(uomsByItemId, emptyMap(), materials)
val boms = bomRepository.findAllActiveWithItemAndUom()
val uomLookup = ItemUomCacheContext.from(uomsByItemId, uomCache)
val boms = bomRepository.findAllByDeletedFalseAndStatusWithItemAndUom(BomStatus.ACTIVE)
for (bom in boms) { for (bom in boms) {
collectBomHeaderIssues(bom, issues, uomsByItemId) collectBomHeaderIssues(bom, issues, uomsByItemId)
val bomId = bom.id ?: continue val bomId = bom.id ?: continue
for (material in materialsByBomId[bomId].orEmpty()) { for (material in materialsByBomId[bomId].orEmpty()) {
collectBomMaterialIssues(bom, material, issues, uomsByItemId, uomCache)
collectBomMaterialIssues(bom, material, issues, uomsByItemId, uomLookup)
} }
} }
return sortIssues(issues) return sortIssues(issues)
@@ -105,11 +116,11 @@ open class MasterDataIssueService(
} }
val materials = val materials =
if (includeTiming) { if (includeTiming) {
timed { bomMaterialRepository.findAllActiveForActiveBoms() }
timed { bomMaterialRepository.findAllByBomStatusAndDeletedFalse(BomStatus.ACTIVE) }
.also { loadMaterialsMs = it.second } .also { loadMaterialsMs = it.second }
.first .first
} else { } else {
bomMaterialRepository.findAllActiveForActiveBoms()
bomMaterialRepository.findAllByBomStatusAndDeletedFalse(BomStatus.ACTIVE)
} }
val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L } val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L }
val uomCache = val uomCache =
@@ -120,19 +131,22 @@ open class MasterDataIssueService(
} else { } else {
buildUomCache(uomsByItemId, deletedUomsByItemId, materials) buildUomCache(uomsByItemId, deletedUomsByItemId, materials)
} }
val uomLookup = ItemUomCacheContext.from(uomsByItemId, uomCache)
val boms = val boms =
if (includeTiming) { if (includeTiming) {
timed { bomRepository.findAllActiveWithItemAndUom() }.also { loadBomsMs = it.second }.first
timed { bomRepository.findAllByDeletedFalseAndStatusWithItemAndUom(BomStatus.ACTIVE) }
.also { loadBomsMs = it.second }
.first
} else { } else {
bomRepository.findAllActiveWithItemAndUom()
bomRepository.findAllByDeletedFalseAndStatusWithItemAndUom(BomStatus.ACTIVE)
} }
val bomGroupCount = val bomGroupCount =
if (includeTiming) { if (includeTiming) {
timed { timed {
scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache)
scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomLookup)
}.also { scanBomTabMs = it.second }.first }.also { scanBomTabMs = it.second }.first
} else { } else {
scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache)
scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomLookup)
} }
val items = val items =
if (includeTiming) { if (includeTiming) {
@@ -188,7 +202,7 @@ open class MasterDataIssueService(
boms: List<Bom>, boms: List<Bom>,
uomsByItemId: Map<Long, List<ItemUom>>, uomsByItemId: Map<Long, List<ItemUom>>,
materialsByBomId: Map<Long, List<BomMaterial>>, materialsByBomId: Map<Long, List<BomMaterial>>,
uomCache: Map<Long, UomConversion>,
uomLookup: ItemUomCacheContext,
): Int { ): Int {
val headerKeys = mutableSetOf<String>() val headerKeys = mutableSetOf<String>()
val materialKeys = mutableSetOf<String>() val materialKeys = mutableSetOf<String>()
@@ -196,7 +210,7 @@ open class MasterDataIssueService(
scanBomHeaderGroupKeys(bom, headerKeys, uomsByItemId) scanBomHeaderGroupKeys(bom, headerKeys, uomsByItemId)
val bomId = bom.id ?: continue val bomId = bom.id ?: continue
for (material in materialsByBomId[bomId].orEmpty()) { for (material in materialsByBomId[bomId].orEmpty()) {
scanBomMaterialGroupKeys(bom, material, materialKeys, uomsByItemId, uomCache)
scanBomMaterialGroupKeys(bom, material, materialKeys, uomsByItemId, uomLookup)
} }
} }
return headerKeys.size + materialKeys.size return headerKeys.size + materialKeys.size
@@ -233,7 +247,6 @@ open class MasterDataIssueService(
var hasIssue = false var hasIssue = false


if (bom.code.isNullOrBlank()) hasIssue = true if (bom.code.isNullOrBlank()) hasIssue = true
if (bom.name.isNullOrBlank()) hasIssue = true


val item = bom.item val item = bom.item
val itemId = item?.id val itemId = item?.id
@@ -241,10 +254,19 @@ open class MasterDataIssueService(
if (item == null || item.deleted == true) { if (item == null || item.deleted == true) {
hasIssue = true hasIssue = true
} else { } else {
val bomCodeNorm = bom.code?.trim().orEmpty()
val itemCodeNorm = item.code?.trim().orEmpty()
if (
bomCodeNorm.isNotEmpty() &&
itemCodeNorm.isNotEmpty() &&
!bomCodeNorm.equals(itemCodeNorm, ignoreCase = true)
) {
hasIssue = true
}
if (itemHasAnyUnitIssue(item, uomsByItemId, unitSnapshot)) hasIssue = true if (itemHasAnyUnitIssue(item, uomsByItemId, unitSnapshot)) hasIssue = true
val bomUom = bom.uom val bomUom = bom.uom
val salesConv = findSalesUnitRow(item.id!!, uomsByItemId)?.uom
if (bomUom != null && salesConv != null && bomUom.id != salesConv.id) hasIssue = true
val baseConv = findBaseUnitRow(item.id!!, uomsByItemId)?.uom
if (bomUom != null && baseConv != null && bomUom.id != baseConv.id) hasIssue = true
if (bomUom != null) { if (bomUom != null) {
val uomDesc = bomUom.udfudesc?.trim().orEmpty() val uomDesc = bomUom.udfudesc?.trim().orEmpty()
val outputDesc = bom.outputQtyUom?.trim().orEmpty() val outputDesc = bom.outputQtyUom?.trim().orEmpty()
@@ -261,10 +283,9 @@ open class MasterDataIssueService(
material: BomMaterial, material: BomMaterial,
materialKeys: MutableSet<String>, materialKeys: MutableSet<String>,
uomsByItemId: Map<Long, List<ItemUom>>, uomsByItemId: Map<Long, List<ItemUom>>,
uomCache: Map<Long, UomConversion>,
uomLookup: ItemUomCacheContext,
) { ) {
val matItem = material.item val matItem = material.item
val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId)
val itemId = matItem?.id val itemId = matItem?.id
val itemCode = matItem?.code val itemCode = matItem?.code
val bomMaterialId = material.id val bomMaterialId = material.id
@@ -280,60 +301,157 @@ open class MasterDataIssueService(
materialKeys.add("$itemKey|$patternKey") materialKeys.add("$itemKey|$patternKey")
} }


if (matItem == null || matItem.deleted == true) {
addMaterialKey("BOM_MATERIAL_MISSING_ITEM")
return
for (hit in findBomMaterialRecipeIssues(material, uomsByItemId, uomLookup)) {
addMaterialKey(hit.issueCode, hit.expectedValue, hit.actualValue)
} }
}

private fun materialPatternKey(issueCode: String, expected: String?, actual: String?): String {
val exp = expected ?: ""
val act = actual ?: ""
return when (issueCode) {
"BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE",
"BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX",
-> "recipe:$exp|$act"
else -> "$issueCode|$exp|$act"
}
}


val matUom = material.uom
if (matUom != null && matUom.deleted == true) {
addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "uomId=${matUom.id}")
/**
* Recipe-qty model: validate material.uom + qty can derive sale/stock/base (see [BomMaterialQtyService]).
* Item-tab unit gaps are not duplicated here when item base is missing.
*/
private fun findBomMaterialRecipeIssues(
material: BomMaterial,
uomsByItemId: Map<Long, List<ItemUom>>,
uomLookup: ItemUomCacheContext,
): List<BomMaterialIssueHit> {
val hits = mutableListOf<BomMaterialIssueHit>()
val matItem = material.item
if (matItem == null || matItem.deleted == true) {
hits += BomMaterialIssueHit("BOM_MATERIAL_MISSING_ITEM")
return hits
} }


val salesUnitFk = material.salesUnit
if (salesUnitFk != null && salesUnitFk.deleted == true) {
addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "salesUnitId=${salesUnitFk.id}")
val itemId = matItem.id ?: return hits

if (material.qty == null) {
hits += BomMaterialIssueHit("BOM_MATERIAL_MISSING_QTY")
} }


val itemSales = findSalesUnitRow(matItem.id!!, uomsByItemId)?.uom
if (salesUnitFk != null && itemSales != null && salesUnitFk.id != itemSales.id) {
addMaterialKey(
"BOM_MATERIAL_SALES_UOM_MISMATCH",
formatUom(itemSales),
formatUom(salesUnitFk),
val recipeUom = material.uom
if (recipeUom == null) {
hits += BomMaterialIssueHit("BOM_MATERIAL_MISSING_RECIPE_UOM")
return hits
}
if (recipeUom.deleted == true) {
hits += BomMaterialIssueHit(
"BOM_MATERIAL_UOM_FK_INVALID",
actualValue = "uomId=${recipeUom.id}",
) )
return hits
}

val itemBase = findBaseUnitRow(itemId, uomsByItemId)?.uom
if (itemBase?.id == null) {
return hits
}

evaluateRecipeToBaseIssue(itemId, recipeUom, itemBase, uomLookup)?.let { hits += it }

val qty = material.qty
val recipeMatchesBase = recipeUom.id == itemBase.id
if (
qty != null &&
!recipeMatchesBase &&
hits.none { it.issueCode == "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE" || it.issueCode == "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX" }
) {
try {
bomMaterialQtyService.deriveFromRecipe(itemId, qty, recipeUom, uomLookup)
} catch (e: Exception) {
val classified = classifyBomMaterialDeriveFailure(e)
if (hits.none { it.issueCode == classified.issueCode }) {
hits += classified
}
}
} }


val itemBase = findBaseUnitRow(matItem.id!!, uomsByItemId)?.uom
val matBaseId = material.baseUnit?.toLong()
if (matBaseId != null && itemBase != null && matBaseId != itemBase.id) {
addMaterialKey(
"BOM_MATERIAL_BASE_UOM_MISMATCH",
formatUom(itemBase),
formatUomById(matBaseId, uomCache),
return hits
}

private fun evaluateRecipeToBaseIssue(
itemId: Long,
recipeUom: UomConversion,
itemBase: UomConversion,
uomLookup: ItemUomCacheContext,
): BomMaterialIssueHit? {
if (recipeUom.id == itemBase.id) {
return null
}

val recipeInMatrix = StandardUomMatrix.toMatrixUnit(recipeUom)
val baseInMatrix = StandardUomMatrix.toMatrixUnit(itemBase)

if (recipeInMatrix != null && baseInMatrix != null) {
return try {
StandardUomMatrix.convertByUom(BigDecimal.ONE, recipeUom, itemBase)
null
} catch (_: Exception) {
BomMaterialIssueHit(
"BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE",
expectedValue = formatUom(itemBase),
actualValue = formatUom(recipeUom),
)
}
}

if (recipeInMatrix != null && baseInMatrix == null) {
return BomMaterialIssueHit(
"BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX",
expectedValue = formatUom(itemBase),
actualValue = formatUom(recipeUom),
) )
} }


val itemStock = findStockUnitRow(matItem.id!!, uomsByItemId)?.uom
val matStockId = material.stockUnit?.toLong()
if (matStockId != null && itemStock != null && matStockId != itemStock.id) {
addMaterialKey(
"BOM_MATERIAL_STOCK_UOM_MISMATCH",
formatUom(itemStock),
formatUomById(matStockId, uomCache),
return try {
itemUomService.convertUomByItem(
ConvertUomByItemRequest(
itemId = itemId,
qty = BigDecimal.ONE,
uomId = recipeUom.id!!,
targetUnit = "baseUnit",
),
uomLookup,
)
null
} catch (_: Exception) {
BomMaterialIssueHit(
"BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE",
expectedValue = formatUom(itemBase),
actualValue = formatUom(recipeUom),
) )
} }
} }


private fun materialPatternKey(issueCode: String, expected: String?, actual: String?): String {
val exp = expected ?: ""
val act = actual ?: ""
return when (issueCode) {
"BOM_MATERIAL_SALES_UOM_MISMATCH",
"BOM_MATERIAL_STOCK_UOM_MISMATCH",
-> "uom:$exp|$act"
"BOM_MATERIAL_BASE_UOM_MISMATCH" -> "base:$exp|$act"
else -> "$issueCode|$exp|$act"
private fun classifyBomMaterialDeriveFailure(e: Exception): BomMaterialIssueHit {
val msg = e.message.orEmpty()
return when {
msg.contains("Recipe UOM is not in standard matrix", ignoreCase = true) ->
BomMaterialIssueHit(
"BOM_MATERIAL_RECIPE_UOM_NOT_IN_MATRIX",
actualValue = msg.take(300),
)
msg.contains("Item base UOM is not in standard matrix", ignoreCase = true) ->
BomMaterialIssueHit(
"BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX",
expectedValue = StandardUomMatrix.STANDARD_CODES.joinToString(", "),
actualValue = msg.take(300),
)
else ->
BomMaterialIssueHit(
"BOM_MATERIAL_DERIVE_FAILED",
actualValue = msg.take(300).ifBlank { e.javaClass.simpleName },
)
} }
} }


@@ -412,7 +530,6 @@ open class MasterDataIssueService(
} }
for (material in materials) { for (material in materials) {
put(material.uom) put(material.uom)
put(material.salesUnit)
} }
return map return map
} }
@@ -539,7 +656,7 @@ open class MasterDataIssueService(
val bomId = bom.id ?: return val bomId = bom.id ?: return
val bomCode = bom.code val bomCode = bom.code
val bomName = bom.name val bomName = bom.name
val description = bom.description
val description = bom.bomKind
val ctx = IssueContext( val ctx = IssueContext(
scope = "BOM", scope = "BOM",
bomId = bomId, bomId = bomId,
@@ -551,9 +668,6 @@ open class MasterDataIssueService(
if (bomCode.isNullOrBlank()) { if (bomCode.isNullOrBlank()) {
issues.add(issue(ctx, null, "MISSING_BOM_CODE")) issues.add(issue(ctx, null, "MISSING_BOM_CODE"))
} }
if (bomName.isNullOrBlank()) {
issues.add(issue(ctx, null, "MISSING_BOM_NAME"))
}


val item = bom.item val item = bom.item
val itemId = item?.id val itemId = item?.id
@@ -563,19 +677,40 @@ open class MasterDataIssueService(
return return
} }


val bomCodeNorm = bomCode?.trim().orEmpty()
val itemCodeNorm = item.code?.trim().orEmpty()
if (
bomCodeNorm.isNotEmpty() &&
itemCodeNorm.isNotEmpty() &&
!bomCodeNorm.equals(itemCodeNorm, ignoreCase = true)
) {
issues.add(
issue(
ctx,
item.id,
"BOM_ITEM_CODE_MISMATCH",
itemCode = item.code,
itemName = item.name,
expectedValue = bomCodeNorm,
actualValue = itemCodeNorm,
unitSnapshot = unitSnapshot,
),
)
}

collectItemUomIssues(item, ctx, issues, uomsByItemId, unitSnapshot = unitSnapshot) collectItemUomIssues(item, ctx, issues, uomsByItemId, unitSnapshot = unitSnapshot)


val bomUom = bom.uom val bomUom = bom.uom
val salesConv = findSalesUnitRow(item.id!!, uomsByItemId)?.uom
if (bomUom != null && salesConv != null && bomUom.id != salesConv.id) {
val baseConv = findBaseUnitRow(item.id!!, uomsByItemId)?.uom
if (bomUom != null && baseConv != null && bomUom.id != baseConv.id) {
issues.add( issues.add(
issue( issue(
ctx, ctx,
item.id, item.id,
"BOM_OUTPUT_UOM_MISMATCH_SALES",
"BOM_OUTPUT_UOM_MISMATCH_BASE",
itemCode = item.code, itemCode = item.code,
itemName = item.name, itemName = item.name,
expectedValue = formatUom(salesConv),
expectedValue = formatUom(baseConv),
actualValue = formatUom(bomUom), actualValue = formatUom(bomUom),
unitSnapshot = unitSnapshot, unitSnapshot = unitSnapshot,
), ),
@@ -606,7 +741,7 @@ open class MasterDataIssueService(
material: BomMaterial, material: BomMaterial,
issues: MutableList<MasterDataIssueResponse>, issues: MutableList<MasterDataIssueResponse>,
uomsByItemId: Map<Long, List<ItemUom>>, uomsByItemId: Map<Long, List<ItemUom>>,
uomCache: Map<Long, UomConversion> = emptyMap(),
uomLookup: ItemUomCacheContext,
) { ) {
val bomId = bom.id ?: return val bomId = bom.id ?: return
val materialId = material.id val materialId = material.id
@@ -616,104 +751,24 @@ open class MasterDataIssueService(
bomCode = bom.code, bomCode = bom.code,
bomName = bom.name, bomName = bom.name,
bomMaterialId = materialId, bomMaterialId = materialId,
description = bom.description,
description = bom.bomKind,
) )


val matItem = material.item val matItem = material.item
val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId) val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId)
if (matItem == null || matItem.deleted == true) {
issues.add(
issue(
ctx,
matItem?.id,
"BOM_MATERIAL_MISSING_ITEM",
itemCode = matItem?.code,
itemName = matItem?.name ?: material.itemName,
unitSnapshot = unitSnapshot,
),
)
return
}

// Item master UOM gaps belong on the Item tab only — not repeated per BOM here.
val matItemCode = matItem.code
val matItemName = matItem.name?.takeIf { it.isNotBlank() } ?: material.itemName

val matUom = material.uom
if (matUom != null && matUom.deleted == true) {
issues.add(
issue(
ctx,
matItem.id,
"BOM_MATERIAL_UOM_FK_INVALID",
itemCode = matItemCode,
itemName = matItemName,
actualValue = "uomId=${matUom.id}",
unitSnapshot = unitSnapshot,
),
)
}

val salesUnitFk = material.salesUnit
if (salesUnitFk != null && salesUnitFk.deleted == true) {
issues.add(
issue(
ctx,
matItem.id,
"BOM_MATERIAL_UOM_FK_INVALID",
itemCode = matItemCode,
itemName = matItemName,
actualValue = "salesUnitId=${salesUnitFk.id}",
unitSnapshot = unitSnapshot,
),
)
}
val matItemCode = matItem?.code
val matItemName = matItem?.name?.takeIf { it.isNotBlank() } ?: material.itemName


val itemSales = findSalesUnitRow(matItem.id!!, uomsByItemId)?.uom
if (salesUnitFk != null && itemSales != null && salesUnitFk.id != itemSales.id) {
for (hit in findBomMaterialRecipeIssues(material, uomsByItemId, uomLookup)) {
issues.add( issues.add(
issue( issue(
ctx, ctx,
matItem.id,
"BOM_MATERIAL_SALES_UOM_MISMATCH",
itemCode = matItemCode,
itemName = matItemName,
expectedValue = formatUom(itemSales),
actualValue = formatUom(salesUnitFk),
unitSnapshot = unitSnapshot,
),
)
}

val itemBase = findBaseUnitRow(matItem.id!!, uomsByItemId)?.uom
val matBaseId = material.baseUnit?.toLong()
if (matBaseId != null && itemBase != null && matBaseId != itemBase.id) {
issues.add(
issue(
ctx,
matItem.id,
"BOM_MATERIAL_BASE_UOM_MISMATCH",
itemCode = matItemCode,
itemName = matItemName,
expectedValue = formatUom(itemBase),
actualValue = formatUomById(matBaseId, uomCache),
unitSnapshot = unitSnapshot,
),
)
}

val itemStock = findStockUnitRow(matItem.id!!, uomsByItemId)?.uom
val matStockId = material.stockUnit?.toLong()
if (matStockId != null && itemStock != null && matStockId != itemStock.id) {
issues.add(
issue(
ctx,
matItem.id,
"BOM_MATERIAL_STOCK_UOM_MISMATCH",
matItem?.id,
hit.issueCode,
itemCode = matItemCode, itemCode = matItemCode,
itemName = matItemName, itemName = matItemName,
expectedValue = formatUom(itemStock),
actualValue = formatUomById(matStockId, uomCache),
expectedValue = hit.expectedValue,
actualValue = hit.actualValue,
unitSnapshot = unitSnapshot, unitSnapshot = unitSnapshot,
), ),
) )


+ 448
- 259
src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt Просмотреть файл

@@ -79,6 +79,10 @@ open class ProductionScheduleService(
private val productProcessService: ProductProcessService, private val productProcessService: ProductProcessService,
private val jobOrderCreationService: JobOrderCreationService, private val jobOrderCreationService: JobOrderCreationService,
private val settingsService: SettingsService, private val settingsService: SettingsService,
private val bomOutputQtyService: BomOutputQtyService,
private val bomMaterialQtyService: BomMaterialQtyService,
private val uomConversionRepository: UomConversionRepository,
private val itemUomRespository: ItemUomRespository,
) : AbstractBaseEntityService<ProductionSchedule, Long, ProductionScheduleRepository>( ) : AbstractBaseEntityService<ProductionSchedule, Long, ProductionScheduleRepository>(
jdbcDao, jdbcDao,
productionScheduleRepository productionScheduleRepository
@@ -434,9 +438,9 @@ open class ProductionScheduleService(
val bom = bomService.findByItemId(itemId) val bom = bomService.findByItemId(itemId)
?: throw NoSuchElementException("BOM not found for Item ID $itemId.") ?: throw NoSuchElementException("BOM not found for Item ID $itemId.")


// 4. Update Prod Schedule Line fields (keep prodQty = total = outputQty * batchNeed)
val outputQtyDouble = bom.outputQty?.toDouble()
?: throw IllegalStateException("BOM output quantity is null for Item ID $itemId.")
// 4. Update Prod Schedule Line fields (keep prodQty = total = stock outputQty * batchNeed)
val stockOutputQty = bomOutputQtyService.stockOutputQty(bom)
val outputQtyDouble = stockOutputQty.toDouble()
prodScheduleLine.apply { prodScheduleLine.apply {
prodQty = outputQtyDouble * batchNeed.toLong().coerceAtLeast(0) prodQty = outputQtyDouble * batchNeed.toLong().coerceAtLeast(0)
approverId = approver?.id approverId = approver?.id
@@ -452,9 +456,10 @@ open class ProductionScheduleService(
//repeat(prodScheduleLine.needNoOfJobOrder) { //repeat(prodScheduleLine.needNoOfJobOrder) {
// 6. Create Job Order // 6. Create Job Order
val produceAt = prodScheduleLine.productionSchedule?.produceAt val produceAt = prodScheduleLine.productionSchedule?.produceAt
val stockBatchQty = bomOutputQtyService.stockOutputQty(bom)
val joRequest = CreateJobOrderRequest( val joRequest = CreateJobOrderRequest(
bomId = bom.id, // bom is guaranteed non-null here bomId = bom.id, // bom is guaranteed non-null here
reqQty = bom.outputQty?.multiply(BigDecimal.valueOf(prodScheduleLine.batchNeed.toLong())),
reqQty = stockBatchQty.multiply(BigDecimal.valueOf(prodScheduleLine.batchNeed.toLong())),
approverId = approver?.id, approverId = approver?.id,


planStart = produceAt, planStart = produceAt,
@@ -543,9 +548,10 @@ open class ProductionScheduleService(
val approver = SecurityUtils.getUser().getOrNull() // Get approver once val approver = SecurityUtils.getUser().getOrNull() // Get approver once


val produceAt = prodScheduleLine.productionSchedule?.produceAt val produceAt = prodScheduleLine.productionSchedule?.produceAt
val stockBatchQty = bomOutputQtyService.stockOutputQty(bom)
val joRequest = CreateJobOrderRequest( val joRequest = CreateJobOrderRequest(
bomId = bom.id, // bom is guaranteed non-null here bomId = bom.id, // bom is guaranteed non-null here
reqQty = bom.outputQty?.multiply(BigDecimal.valueOf(prodScheduleLine.batchNeed.toLong())),
reqQty = stockBatchQty.multiply(BigDecimal.valueOf(prodScheduleLine.batchNeed.toLong())),
approverId = approver?.id, approverId = approver?.id,


planStart = produceAt, planStart = produceAt,
@@ -633,118 +639,109 @@ open class ProductionScheduleService(
val args = mapOf("fromDate" to fromDate.toString(), "toDate" to toDate.toString()) val args = mapOf("fromDate" to fromDate.toString(), "toDate" to toDate.toString())


val sql = """ val sql = """
SELECT
i.outputQty,
WITH do_daily_qty AS (
SELECT
dol.itemId,
do.estimatedArrivalDate,
SUM(dol.qty) AS dailyQty
FROM delivery_order_line dol
LEFT JOIN delivery_order do ON dol.deliveryOrderId = do.id
WHERE do.deleted = 0
AND do.estimatedArrivalDate >= :fromDate
AND do.estimatedArrivalDate <= :toDate
GROUP BY dol.itemId, do.estimatedArrivalDate
),
avg_daily AS (
SELECT itemId, ROUND(AVG(dailyQty)) AS avgQtyLastMonth
FROM do_daily_qty
GROUP BY itemId
),
pending_job AS (
SELECT bomId, SUM(reqQty) AS pendingJobQty
FROM job_order
WHERE status != 'completed'
GROUP BY bomId
)
SELECT
i.outputQty,
i.avgQtyLastMonth, i.avgQtyLastMonth,
-- (i.onHandQty -500) * 1.0 / i.avgQtyLastMonth as daysLeft,
0 as needQty,
0 AS needQty,
i.stockQty, i.stockQty,
-- CASE
-- WHEN stockQty * 1.0 / i.avgQtyLastMonth <= 1.9 THEN
-- CEIL((i.avgQtyLastMonth * 2.6 - stockQty) / i.outputQty)
-- ELSE 0
-- END AS needNoOfJobOrder,
-- The first date only consider how many batch pending for job order
0 AS needNoOfJobOrder, 0 AS needNoOfJobOrder,
0 AS batchNeed, 0 AS batchNeed,
i.pendingJobQty, i.pendingJobQty,
((i.stockQty * 1.0) + ifnull(i.pendingJobQty, 0) ) / i.avgQtyLastMonth as daysLeft,
ifnull(i.baseScore, 0) as priority,
-- 25 + 25 + markDark + markFloat + markDense + markAS + markTimeSequence + markComplexity as priority,
((i.stockQty * 1.0) + IFNULL(i.pendingJobQty, 0)) / i.avgQtyLastMonth AS daysLeft,
IFNULL(i.baseScore, 0) AS priority,
i.* i.*
FROM
(SELECT
COALESCE(
(SELECT dailyQty FROM item_daily_out WHERE itemCode = items.code),
(SELECT
ROUND(AVG(d.dailyQty))
FROM
(SELECT
SUM(dol.qty) AS dailyQty
FROM
delivery_order_line dol
LEFT JOIN delivery_order do ON dol.deliveryOrderId = do.id
WHERE
do.deleted = 0 and
dol.itemId = items.id
AND do.estimatedArrivalDate >= :fromDate AND do.estimatedArrivalDate <= :toDate
GROUP BY do.estimatedArrivalDate) AS d)
) AS avgQtyLastMonth,

(select sum(reqQty) from job_order where bomId = bom.id and status != 'completed') AS pendingJobQty,

(select count(1) from coffee_or_tea where systemType = 'coffee' and itemCode = items.code) as isCoffee,
(select count(1) from coffee_or_tea where systemType = 'tea' and itemCode = items.code) as isTea,
(select count(1) from coffee_or_tea where systemType = 'lemon' and itemCode = items.code) as isLemon,
CASE WHEN item_fake_onhand.onHandQty is not null THEN item_fake_onhand.onHandQty
ELSE inventory.onHandQty END AS stockQty,
bom.baseScore,
bom.outputQty,
bom.outputQtyUom,
(SELECT
udfudesc
FROM
delivery_order_line
LEFT JOIN uom_conversion ON delivery_order_line.uomId = uom_conversion.id
WHERE
delivery_order_line.itemId = bom.itemId
LIMIT 1) AS doUom,
items.code,
items.name,
bom.description,
inventory.onHandQty,
bom.itemId,
bom.id AS bomId,

CASE WHEN bom.isDark = 5 THEN 11
WHEN bom.isDark = 3 THEN 6
WHEN bom.isDark = 1 THEN 2
ELSE 0 END as markDark,

CASE WHEN bom.isFloat = 5 THEN 11
WHEN bom.isFloat = 3 THEN 6
WHEN bom.isFloat = 1 THEN 2
ELSE 0 END as markFloat,

CASE WHEN bom.isDense = 5 THEN 11
WHEN bom.isDense = 3 THEN 6
WHEN bom.isDense = 1 THEN 2
ELSE 0 END as markDense,

bom.timeSequence as markTimeSequence,

bom.complexity as markComplexity,

CASE WHEN bom.allergicSubstances = 5 THEN 11
ELSE 0 END as markAS,

inventory.id AS inventoryId
FROM
bom
FROM (
SELECT
COALESCE(ido.dailyQty, ad.avgQtyLastMonth) AS avgQtyLastMonth,
pj.pendingJobQty,
(SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'coffee' AND itemCode = items.code) AS isCoffee,
(SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'tea' AND itemCode = items.code) AS isTea,
(SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'lemon' AND itemCode = items.code) AS isLemon,
CASE WHEN item_fake_onhand.onHandQty IS NOT NULL THEN item_fake_onhand.onHandQty
ELSE inventory.onHandQty END AS stockQty,
bom.baseScore,
bom.outputQty,
bom.outputQtyUom,
(
SELECT udfudesc
FROM delivery_order_line
LEFT JOIN uom_conversion ON delivery_order_line.uomId = uom_conversion.id
WHERE delivery_order_line.itemId = bom.itemId
LIMIT 1
) AS doUom,
items.code,
items.name,
bom.bomKind,
inventory.onHandQty,
bom.itemId,
bom.id AS bomId,
CASE WHEN bom.isDark = 5 THEN 11
WHEN bom.isDark = 3 THEN 6
WHEN bom.isDark = 1 THEN 2
ELSE 0 END AS markDark,
CASE WHEN bom.isFloat = 5 THEN 11
WHEN bom.isFloat = 3 THEN 6
WHEN bom.isFloat = 1 THEN 2
ELSE 0 END AS markFloat,
CASE WHEN bom.isDense = 5 THEN 11
WHEN bom.isDense = 3 THEN 6
WHEN bom.isDense = 1 THEN 2
ELSE 0 END AS markDense,
bom.timeSequence AS markTimeSequence,
bom.complexity AS markComplexity,
CASE WHEN bom.allergicSubstances = 5 THEN 11
ELSE 0 END AS markAS,
inventory.id AS inventoryId
FROM bom
LEFT JOIN items ON bom.itemId = items.id LEFT JOIN items ON bom.itemId = items.id
LEFT JOIN inventory ON items.id = inventory.itemId LEFT JOIN inventory ON items.id = inventory.itemId
left join item_fake_onhand on items.code = item_fake_onhand.itemCode
WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active'
-- and bom.itemId != 16771
) AS i
WHERE 1
and i.avgQtyLastMonth is not null
and i.onHandQty is not null
-- and (i.onHandQty -500) * 1.0 / i.avgQtyLastMonth <= 1.9
-- and avgQtyLastMonth - (onHandQty - 500) > 0
LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode
LEFT JOIN item_daily_out ido ON ido.itemCode = items.code
LEFT JOIN avg_daily ad ON ad.itemId = items.id
LEFT JOIN pending_job pj ON pj.bomId = bom.id
WHERE bom.deleted = 0
AND bom.bomKind = 'FG'
AND bom.status = 'active'
) AS i
WHERE i.avgQtyLastMonth IS NOT NULL
AND i.onHandQty IS NOT NULL
""".trimIndent() """.trimIndent()


val rows: List<Map<String, Any>> = jdbcDao.queryForList(sql, args) val rows: List<Map<String, Any>> = jdbcDao.queryForList(sql, args)


return rows.map { row -> return rows.map { row ->
val itemId = (row["itemId"] as Number).toLong()
val bom = bomService.findByItemId(itemId)
val stockOutputQty = bom?.let { bomOutputQtyService.stockOutputQty(it).toDouble() }
?: (row["outputQty"] as Number).toDouble()
NeedQtyRecord().apply { NeedQtyRecord().apply {
name = row["name"] as String name = row["name"] as String
id = (row["itemId"] as Number).toLong()
id = itemId
needQty = (row["needQty"] as Number).toDouble() needQty = (row["needQty"] as Number).toDouble()
outputQty = (row["outputQty"] as Number).toDouble()
outputQty = stockOutputQty
stockQty = (row["stockQty"] as Number).toDouble() stockQty = (row["stockQty"] as Number).toDouble()
avgQtyLastMonth = (row["avgQtyLastMonth"] as Number).toDouble() avgQtyLastMonth = (row["avgQtyLastMonth"] as Number).toDouble()
needNoOfJobOrder = (row["needNoOfJobOrder"] as Number).toLong() needNoOfJobOrder = (row["needNoOfJobOrder"] as Number).toLong()
@@ -1752,6 +1749,138 @@ open class ProductionScheduleService(
} }
} }


private fun exportLatestPsByProduceAtCte(): String = """
latest_ps AS (
SELECT MAX(id) AS id, produceAt
FROM production_schedule
WHERE produceAt >= :fromDate
GROUP BY produceAt
)
""".trimIndent()

private fun exportLatestPsByDayCte(): String = """
latest_ps AS (
SELECT MAX(id) AS id, DATE(produceAt) AS produceDay
FROM production_schedule
WHERE produceAt >= DATE_ADD(:fromDate, INTERVAL 1 DAY)
AND produceAt < DATE_ADD(:fromDate, INTERVAL 8 DAY)
GROUP BY DATE(produceAt)
)
""".trimIndent()

private data class RecipeBaseQtyKey(
val itemId: Long,
val recipeUomId: Long,
val recipeQty: BigDecimal,
)

private fun buildExportUomLookup(
materialItemIds: Set<Long>,
recipeUomById: Map<Long, UomConversion>,
): ItemUomCacheContext {
if (materialItemIds.isEmpty()) {
return ItemUomCacheContext.from(emptyMap(), recipeUomById)
}
val uomsByItemId = itemUomRespository.findAllActiveWithUom()
.mapNotNull { row ->
val itemId = row.item?.id ?: return@mapNotNull null
if (itemId !in materialItemIds) return@mapNotNull null
itemId to row
}
.groupBy({ it.first }, { it.second })
val uomById = mutableMapOf<Long, UomConversion>()
uomsByItemId.values.flatten().forEach { row ->
row.uom?.id?.let { uomById[it] = row.uom!! }
}
recipeUomById.values.forEach { uom -> uom.id?.let { uomById[it] = uom } }
return ItemUomCacheContext.from(uomsByItemId, uomById)
}

private fun cachedRecipeBaseQty(
itemId: Long,
recipeQty: BigDecimal,
recipeUom: UomConversion,
uomLookup: ItemUomCacheContext,
memo: MutableMap<RecipeBaseQtyKey, BigDecimal?>,
): BigDecimal? {
val recipeUomId = recipeUom.id ?: return null
val key = RecipeBaseQtyKey(itemId, recipeUomId, recipeQty.stripTrailingZeros())
memo[key]?.let { return it }
val result = try {
bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom, uomLookup).baseQty
} catch (_: Exception) {
null
}
memo[key] = result
return result
}

private val exportPurchasedQtyFutureCte = """
purchased_qty_future AS (
SELECT
pol.itemId,
SUM(pol.qty) AS purchasedQty
FROM purchase_order_line pol
INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId
WHERE po.completeDate IS NULL
AND po.estimatedArrivalDate >= CURDATE()
GROUP BY pol.itemId
)
""".trimIndent()

private val exportPurchaseUomCte = """
purchase_uom AS (
SELECT
iu.itemId,
(iu.ratioD / iu.ratioN) AS purchaseRatio,
uc.code AS purchaseUnit
FROM item_uom iu
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE iu.purchaseUnit = 1 AND iu.deleted = 0
)
""".trimIndent()

private val exportLatestSupplierUomCte = """
latest_supplier_uom AS (
SELECT x.materialId, x.uomIdM18
FROM (
SELECT
pol.itemId AS materialId,
pol.uomIdM18,
ROW_NUMBER() OVER (
PARTITION BY pol.itemId
ORDER BY COALESCE(po.orderDate, po.created) DESC, pol.id DESC
) AS rn
FROM purchase_order_line pol
JOIN purchase_order po ON po.id = pol.purchaseOrderId
WHERE pol.deleted = 0
AND po.deleted = 0
AND pol.uomIdM18 IS NOT NULL
) x
WHERE x.rn = 1
)
""".trimIndent()

private val exportPurchasedQtyFromDateCte = """
purchased_qty_from_date AS (
SELECT
pol.itemId,
CEIL(SUM(
pol.qty * (itum.ratioN / itum.ratioD)
* (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))
)) AS purchasedQty
FROM purchase_order_line pol
INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId
INNER JOIN item_uom itum ON itum.itemId = pol.itemId AND itum.purchaseUnit = 1
LEFT JOIN latest_supplier_uom lsu ON lsu.materialId = pol.itemId
LEFT JOIN item_uom ium18 ON ium18.itemId = pol.itemId
AND ium18.uomId = lsu.uomIdM18 AND ium18.deleted = 0
WHERE po.completeDate IS NULL
AND po.estimatedArrivalDate >= :fromDate
GROUP BY pol.itemId
)
""".trimIndent()

//====================細排相關 START====================// //====================細排相關 START====================//
open fun searchExportProdSchedule(fromDate: LocalDate): List<Map<String, Any>> { open fun searchExportProdSchedule(fromDate: LocalDate): List<Map<String, Any>> {


@@ -1760,186 +1889,246 @@ open class ProductionScheduleService(
) )


val sql = """ val sql = """
select
it.code as itemCode,
it.name as itemName,
WITH ${exportLatestPsByProduceAtCte()}
SELECT
it.code AS itemCode,
it.name AS itemName,
ps.produceAt, ps.produceAt,
psl.*
from production_schedule_line psl
left join production_schedule ps on psl.prodScheduleId = ps.id
left join items it on psl.itemId = it.id

where ps.produceAt >= :fromDate

and ps.id = (select id from production_schedule where produceAt = ps.produceAt order by id desc limit 1)

order by ps.produceAt asc, psl.itemPriority desc, itemCode asc
""";
psl.*
FROM production_schedule_line psl
INNER JOIN latest_ps lps ON lps.id = psl.prodScheduleId
INNER JOIN production_schedule ps ON ps.id = lps.id
LEFT JOIN items it ON psl.itemId = it.id
WHERE ps.produceAt >= :fromDate
ORDER BY ps.produceAt ASC, psl.itemPriority DESC, itemCode ASC
""".trimIndent()


return jdbcDao.queryForList(sql, args); return jdbcDao.queryForList(sql, args);
} }




open fun searchExportProdScheduleMaterial(fromDate: LocalDate): List<Map<String, Any>> { open fun searchExportProdScheduleMaterial(fromDate: LocalDate): List<Map<String, Any>> {

val args = mapOf(
"fromDate" to fromDate,
)
val args = mapOf("fromDate" to fromDate)


val sql = """ val sql = """
select
group_concat(distinct it.code) as itemCode,
group_concat(distinct it.name) as itemName,
itm.code as matCode,
itm.name as matName,
sum(bm.qty * psl.batchNeed) as totalMatQtyNeed,
WITH ${exportLatestPsByProduceAtCte()},
$exportPurchasedQtyFutureCte,
$exportPurchaseUomCte
SELECT
it.code AS fgItemCode,
it.name AS fgItemName,
itm.id AS materialId,
itm.code AS matCode,
itm.name AS matName,
bm.qty AS recipeQty,
bm.uomId AS recipeUomId,
bm.uomName AS recipeUomName,
psl.batchNeed AS batchNeed,
iv.onHandQty, iv.onHandQty,
iv.unavailableQty, iv.unavailableQty,
(select sum(qty) from purchase_order_line
left join purchase_order on purchase_order_line.purchaseOrderId = purchase_order.id
where purchase_order_line.itemId = itm.id and date(purchase_order.estimatedArrivalDate) >= date(now()) and purchase_order.completeDate is null) as purchasedQty,
bm.uomName,
(select ratioD/ratioN from item_uom where purchaseUnit = 1 and itemId = it.id and item_uom.deleted = 0
) as purchaseRatio,
(select ceil(ratioD/ratioN*sum(bm.qty * psl.batchNeed)) from item_uom where purchaseUnit = 1 and itemId = it.id and item_uom.deleted = 0
) as purchaseQtyNeed,
(select code from item_uom
left join uom_conversion on item_uom.uomId = uom_conversion.id
where item_uom.purchaseUnit = 1 and item_uom.itemId = it.id and item_uom.deleted = 0
) as purchaseUnit,
min(ps.produceAt) as toProdcueFrom,
max(ps.produceAt) as toProdcueAt,
ucs.udfudesc as stockUnit,
psl.*
from production_schedule_line psl
left join production_schedule ps on psl.prodScheduleId = ps.id
left join items it on psl.itemId = it.id
left join bom on it.id = bom.itemId
left join bom_material bm on bom.id = bm.bomId
left join items itm on bm.itemId = itm.id
left join inventory iv on itm.id = iv.itemId
left join item_uom ius on itm.id = ius.itemId and ius.stockUnit = 1
left join uom_conversion ucs on ius.uomId = ucs.id
where ps.produceAt >= :fromDate
and ps.id = (select id from production_schedule where produceAt = ps.produceAt order by id desc limit 1)
-- This is water
pqf.purchasedQty,
ucs.udfudesc AS stockUnit,
pu.purchaseRatio,
pu.purchaseUnit
FROM production_schedule_line psl
INNER JOIN latest_ps lps ON lps.id = psl.prodScheduleId
INNER JOIN production_schedule ps ON ps.id = lps.id
LEFT JOIN items it ON psl.itemId = it.id
LEFT JOIN bom ON it.id = bom.itemId
LEFT JOIN bom_material bm ON bom.id = bm.bomId
LEFT JOIN items itm ON bm.itemId = itm.id
LEFT JOIN inventory iv ON itm.id = iv.itemId
LEFT JOIN item_uom ius ON itm.id = ius.itemId AND ius.stockUnit = 1
LEFT JOIN uom_conversion ucs ON ius.uomId = ucs.id
LEFT JOIN purchased_qty_future pqf ON pqf.itemId = itm.id
LEFT JOIN purchase_uom pu ON pu.itemId = itm.id
WHERE ps.produceAt >= :fromDate
AND it.code != 'GI3236' AND it.code != 'GI3236'
-- order by ps.produceAt asc, psl.itemPriority desc, itemCode asc
group by matCode
""";
AND bm.itemId IS NOT NULL
""".trimIndent()


return jdbcDao.queryForList(sql, args);
val rows = jdbcDao.queryForList(sql, args)
if (rows.isEmpty()) {
return emptyList()
}

val recipeUomIds = rows.mapNotNull { asLong(it["recipeUomId"]) }.distinct()
val recipeUomById = uomConversionRepository.findAllByIdInAndDeletedFalse(recipeUomIds)
.associateBy { it.id!! }
val materialItemIds = rows.mapNotNull { asLong(it["materialId"]) }.toSet()
val uomLookup = buildExportUomLookup(materialItemIds, recipeUomById)
val recipeBaseQtyMemo = mutableMapOf<RecipeBaseQtyKey, BigDecimal?>()

return rows.groupBy { it["matCode"]?.toString().orEmpty() }
.map { (_, materialRows) ->
val first = materialRows.first()
val materialId = asLong(first["materialId"])
val purchaseRatio = asBigDecimal(first["purchaseRatio"])

val totalMatQtyNeed = materialRows.fold(BigDecimal.ZERO) { acc, row ->
val recipeQty = asBigDecimal(row["recipeQty"])
val batchNeed = asBigDecimal(row["batchNeed"])
acc + recipeQty.multiply(batchNeed)
}

val baseNeedSum = materialRows.fold(BigDecimal.ZERO) { acc, row ->
val matId = asLong(row["materialId"]) ?: return@fold acc
val recipeQty = asBigDecimal(row["recipeQty"])
val batchNeed = asBigDecimal(row["batchNeed"])
val recipeUomId = asLong(row["recipeUomId"])
val recipeUom = recipeUomId?.let { recipeUomById[it] } ?: return@fold acc
val baseQty = cachedRecipeBaseQty(matId, recipeQty, recipeUom, uomLookup, recipeBaseQtyMemo)
?: return@fold acc
acc + baseQty.multiply(batchNeed)
}
val purchaseQtyNeed = baseNeedSum.multiply(purchaseRatio).setScale(0, RoundingMode.CEILING)

val fgCodes = materialRows.mapNotNull { it["fgItemCode"]?.toString() }.distinct().sorted()
val fgNames = materialRows.mapNotNull { it["fgItemName"]?.toString() }.distinct().sorted()

linkedMapOf<String, Any>(
"itemCode" to fgCodes.joinToString(","),
"itemName" to fgNames.joinToString(","),
"matCode" to (first["matCode"]?.toString() ?: ""),
"matName" to (first["matName"]?.toString() ?: ""),
"totalMatQtyNeed" to totalMatQtyNeed,
"onHandQty" to asBigDecimal(first["onHandQty"]),
"unavailableQty" to asBigDecimal(first["unavailableQty"]),
"purchasedQty" to asBigDecimal(first["purchasedQty"]),
"uomName" to (first["recipeUomName"]?.toString() ?: ""),
"purchaseRatio" to purchaseRatio,
"purchaseQtyNeed" to purchaseQtyNeed,
"purchaseUnit" to (first["purchaseUnit"]?.toString() ?: ""),
"stockUnit" to (first["stockUnit"]?.toString() ?: ""),
"materialId" to (materialId ?: 0L),
)
}
.sortedBy { it["matCode"]?.toString().orEmpty() }
} }


open fun searchExportDailyMaterial(fromDate: LocalDate): List<Map<String, Any>> { open fun searchExportDailyMaterial(fromDate: LocalDate): List<Map<String, Any>> {

val args = mapOf(
"fromDate" to fromDate,
)
val args = mapOf("fromDate" to fromDate)


val sql = """ val sql = """
WITH latest_supplier_uom AS (
SELECT x.materialId, x.uomIdM18
FROM (
SELECT
pol.itemId AS materialId,
pol.uomIdM18,
ROW_NUMBER() OVER (
PARTITION BY pol.itemId
ORDER BY COALESCE(po.orderDate, po.created) DESC, pol.id DESC
) AS rn
FROM purchase_order_line pol
JOIN purchase_order po ON po.id = pol.purchaseOrderId
WHERE pol.deleted = 0
AND po.deleted = 0
AND pol.uomIdM18 IS NOT NULL
) x
WHERE x.rn = 1
),
daily_needs AS (
SELECT
itm.id AS materialId,
itm.code AS matCode,
itm.name AS matName,
COALESCE(uomM18.code, uomP.code) AS uom,
ceil((iv.onHandQty * (itsm.ratioN / itsm.ratioD)) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))) as onHandQty,
ceil((iv.unavailableQty * (itsm.ratioN / itsm.ratioD)) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))) as unavailableQty,
COALESCE((
SELECT ceil(SUM(pol.qty * (itum.ratioN / itum.ratioD) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))))
FROM purchase_order_line pol
JOIN purchase_order po ON pol.purchaseOrderId = po.id
WHERE pol.itemId = itm.id
AND DATE(po.estimatedArrivalDate) >= :fromDate
AND po.completeDate IS NULL
), 0) AS purchasedQty,
DATE(ps.produceAt) AS produceDate,
ceil( SUM(bm.baseQty * psl.batchNeed) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN)) ) AS qtyNeeded
FROM production_schedule_line psl
JOIN production_schedule ps ON psl.prodScheduleId = ps.id
JOIN items it ON psl.itemId = it.id
JOIN bom ON it.id = bom.itemId
JOIN bom_material bm ON bom.id = bm.bomId
JOIN items itm ON bm.itemId = itm.id
JOIN item_uom itum ON itm.id = itum.itemId and itum.purchaseUnit = 1
JOIN uom_conversion uomP ON itum.uomId = uomP.id
LEFT JOIN latest_supplier_uom lsu ON lsu.materialId = itm.id
LEFT JOIN item_uom ium18 ON ium18.itemId = itm.id AND ium18.uomId = lsu.uomIdM18 AND ium18.deleted = 0
LEFT JOIN uom_conversion uomM18 ON uomM18.id = ium18.uomId
JOIN item_uom itsm ON itm.id = itsm.itemId and itsm.stockUnit = 1
LEFT JOIN inventory iv ON itm.id = iv.itemId
WHERE DATE(ps.produceAt) >= DATE_ADD(:fromDate, INTERVAL 1 DAY)
AND DATE(ps.produceAt) < DATE_ADD(:fromDate, INTERVAL 8 DAY)
AND ps.id = (
SELECT ps2.id
FROM production_schedule ps2
WHERE DATE(ps2.produceAt) = DATE(ps.produceAt)
ORDER BY ps2.id DESC
LIMIT 1
)
AND bm.itemId IS NOT NULL
AND itm.code != 'GI3236'
GROUP BY
itm.id,
DATE(ps.produceAt)
WITH $exportLatestSupplierUomCte,
${exportLatestPsByDayCte()},
$exportPurchasedQtyFromDateCte
SELECT
itm.id AS materialId,
itm.code AS matCode,
itm.name AS matName,
COALESCE(uomM18.code, uomP.code) AS uom,
CEIL((iv.onHandQty * (itsm.ratioN / itsm.ratioD))
* (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))) AS onHandQty,
COALESCE(pqd.purchasedQty, 0) AS purchasedQty,
DATE(ps.produceAt) AS produceDate,
bm.qty AS recipeQty,
bm.uomId AS recipeUomId,
psl.batchNeed AS batchNeed,
(COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN)) AS purchaseRatio
FROM production_schedule_line psl
INNER JOIN latest_ps lps ON lps.id = psl.prodScheduleId
INNER JOIN production_schedule ps ON ps.id = lps.id
JOIN items it ON psl.itemId = it.id
JOIN bom ON it.id = bom.itemId
JOIN bom_material bm ON bom.id = bm.bomId
JOIN items itm ON bm.itemId = itm.id
JOIN item_uom itum ON itm.id = itum.itemId AND itum.purchaseUnit = 1
JOIN uom_conversion uomP ON itum.uomId = uomP.id
LEFT JOIN latest_supplier_uom lsu ON lsu.materialId = itm.id
LEFT JOIN item_uom ium18 ON ium18.itemId = itm.id AND ium18.uomId = lsu.uomIdM18 AND ium18.deleted = 0
LEFT JOIN uom_conversion uomM18 ON uomM18.id = ium18.uomId
JOIN item_uom itsm ON itm.id = itsm.itemId AND itsm.stockUnit = 1
LEFT JOIN inventory iv ON itm.id = iv.itemId
LEFT JOIN purchased_qty_from_date pqd ON pqd.itemId = itm.id
WHERE DATE(ps.produceAt) >= DATE_ADD(:fromDate, INTERVAL 1 DAY)
AND DATE(ps.produceAt) < DATE_ADD(:fromDate, INTERVAL 8 DAY)
AND bm.itemId IS NOT NULL
AND itm.code != 'GI3236'
""".trimIndent()

val rows = jdbcDao.queryForList(sql, args)
if (rows.isEmpty()) {
return emptyList()
}

val recipeUomIds = rows.mapNotNull { asLong(it["recipeUomId"]) }.distinct()
val recipeUomById = uomConversionRepository.findAllByIdInAndDeletedFalse(recipeUomIds)
.associateBy { it.id!! }
val materialItemIds = rows.mapNotNull { asLong(it["materialId"]) }.toSet()
val uomLookup = buildExportUomLookup(materialItemIds, recipeUomById)
val recipeBaseQtyMemo = mutableMapOf<RecipeBaseQtyKey, BigDecimal?>()

val qtyNeededByMaterialDate = rows.groupBy { row ->
asLong(row["materialId"]) to toLocalDate(row["produceDate"])
}.mapValues { (_, groupRows) ->
val purchaseRatio = asBigDecimal(groupRows.first()["purchaseRatio"])
val baseNeedSum = groupRows.fold(BigDecimal.ZERO) { acc, row ->
val materialId = asLong(row["materialId"]) ?: return@fold acc
val recipeQty = asBigDecimal(row["recipeQty"])
val batchNeed = asBigDecimal(row["batchNeed"])
val recipeUomId = asLong(row["recipeUomId"])
val recipeUom = recipeUomId?.let { recipeUomById[it] } ?: return@fold acc
val baseQty = cachedRecipeBaseQty(materialId, recipeQty, recipeUom, uomLookup, recipeBaseQtyMemo)
?: return@fold acc
acc + baseQty.multiply(batchNeed)
}
baseNeedSum.multiply(purchaseRatio).setScale(0, RoundingMode.CEILING)
}

return rows.groupBy { asLong(it["materialId"]) }
.mapNotNull { (materialId, materialRows) ->
if (materialId == null) return@mapNotNull null
val first = materialRows.first()
val onHandQty = asBigDecimal(first["onHandQty"])
val purchasedQty = asBigDecimal(first["purchasedQty"])
val dayNeeds = (1..7).associate { offset ->
val produceDate = fromDate.plusDays(offset.toLong())
val qty = qtyNeededByMaterialDate[materialId to produceDate] ?: BigDecimal.ZERO
"day$offset" to qty
}
val totalNeed7Days = dayNeeds.values.fold(BigDecimal.ZERO, BigDecimal::add)
val grossAvailable = onHandQty.add(purchasedQty)
linkedMapOf<String, Any>(
"matCode" to (first["matCode"]?.toString() ?: ""),
"matName" to (first["matName"]?.toString() ?: ""),
"uom" to (first["uom"]?.toString() ?: ""),
"currentStock" to onHandQty,
"incomingPO" to purchasedQty,
"grossAvailable" to grossAvailable,
).apply {
putAll(dayNeeds)
put("totalNeed7Days", totalNeed7Days)
put("netAfter7Days", grossAvailable.subtract(totalNeed7Days))
}
}
.sortedWith(
compareBy<Map<String, Any>> { asBigDecimal(it["netAfter7Days"]) }
.thenByDescending { asBigDecimal(it["totalNeed7Days"]) },
) )
}


SELECT
matCode,
matName,
uom,
onHandQty AS currentStock,
purchasedQty AS incomingPO,
(onHandQty + purchasedQty) AS grossAvailable,
-- Dynamic columns for next 7 days
COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 1 DAY) THEN qtyNeeded END), 0) AS day1,
COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 2 DAY) THEN qtyNeeded END), 0) AS day2,
COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 3 DAY) THEN qtyNeeded END), 0) AS day3,
COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 4 DAY) THEN qtyNeeded END), 0) AS day4,
COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 5 DAY) THEN qtyNeeded END), 0) AS day5,
COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 6 DAY) THEN qtyNeeded END), 0) AS day6,
COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 7 DAY) THEN qtyNeeded END), 0) AS day7,
-- Total and net
COALESCE(SUM(qtyNeeded), 0) AS totalNeed7Days,
(onHandQty + purchasedQty - COALESCE(SUM(qtyNeeded), 0)) AS netAfter7Days

FROM daily_needs
GROUP BY
materialId, matCode, matName, uom, onHandQty, purchasedQty
ORDER BY netAfter7Days ASC, totalNeed7Days DESC;
""";
private fun asBigDecimal(value: Any?): BigDecimal = when (value) {
null -> BigDecimal.ZERO
is BigDecimal -> value
is Number -> BigDecimal.valueOf(value.toDouble())
else -> BigDecimal(value.toString())
}


return jdbcDao.queryForList(sql, args);
private fun asLong(value: Any?): Long? = when (value) {
null -> null
is Long -> value
is Number -> value.toLong()
else -> value.toString().toLongOrNull()
}

private fun toLocalDate(value: Any?): LocalDate? = when (value) {
null -> null
is LocalDate -> value
is java.sql.Date -> value.toLocalDate()
is Timestamp -> value.toLocalDateTime().toLocalDate()
else -> value.toString().take(10).let { runCatching { LocalDate.parse(it) }.getOrNull() }
} }


@Transactional @Transactional


+ 74
- 0
src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt Просмотреть файл

@@ -0,0 +1,74 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.core.exception.BadRequestException
import com.ffii.fpsms.modules.master.entity.UomConversion
import java.math.BigDecimal
import java.math.RoundingMode

/**
* Standard recipe-unit conversion matrix (KG / L / LB / OZ / CATTY / G / ML).
* All units normalize via gram-equivalent factors derived from the business matrix.
*/
object StandardUomMatrix {
enum class MatrixUnit(val code: String) {
KG("KG"),
L("L"),
LB("LB"),
OZ("OZ"),
CATTY("CATTY"),
G("G"),
ML("ML"),
}

private val gramsPerUnit: Map<MatrixUnit, BigDecimal> = mapOf(
MatrixUnit.KG to BigDecimal("1000"),
MatrixUnit.L to BigDecimal("1000"),
MatrixUnit.LB to BigDecimal("453.592"),
MatrixUnit.OZ to BigDecimal("28.35"),
MatrixUnit.CATTY to BigDecimal("604.790"),
MatrixUnit.G to BigDecimal.ONE,
MatrixUnit.ML to BigDecimal.ONE,
)

val STANDARD_CODES: List<String> = MatrixUnit.entries.map { it.code }

fun toMatrixUnit(uomCode: String?): MatrixUnit? {
val normalized = uomCode?.trim()?.uppercase() ?: return null
return when (normalized) {
"KG" -> MatrixUnit.KG
"L", "LITTER", "LITRE", "LITER" -> MatrixUnit.L
"LB" -> MatrixUnit.LB
"OZ" -> MatrixUnit.OZ
"CATTY" -> MatrixUnit.CATTY
"G", "G (BASE UNIT)" -> MatrixUnit.G
"ML" -> MatrixUnit.ML
else -> null
}
}

fun toMatrixUnit(uom: UomConversion?): MatrixUnit? = toMatrixUnit(uom?.code)

fun convert(qty: BigDecimal, from: MatrixUnit, to: MatrixUnit): BigDecimal {
if (from == to) return qty.setScale(2, RoundingMode.HALF_UP)
val fromGrams = gramsPerUnit[from]
?: throw BadRequestException("Unsupported matrix unit: ${from.code}")
val toGrams = gramsPerUnit[to]
?: throw BadRequestException("Unsupported matrix unit: ${to.code}")
return qty.multiply(fromGrams)
.divide(toGrams, 10, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP)
}

fun convertByUom(qty: BigDecimal, fromUom: UomConversion, toUom: UomConversion): BigDecimal {
val from = toMatrixUnit(fromUom)
?: throw BadRequestException(
"Recipe UOM is not in standard matrix: code=${fromUom.code}",
)
val to = toMatrixUnit(toUom)
?: throw BadRequestException(
"Item base UOM is not in standard matrix: code=${toUom.code}. " +
"Base must be one of ${STANDARD_CODES.joinToString(", ")}",
)
return convert(qty, from, to)
}
}

+ 91
- 8
src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt Просмотреть файл

@@ -3,7 +3,7 @@ package com.ffii.fpsms.modules.master.web
import com.ffii.fpsms.modules.master.entity.Bom import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.BomRepository import com.ffii.fpsms.modules.master.entity.BomRepository
import com.ffii.fpsms.modules.master.enums.BomStatus import com.ffii.fpsms.modules.master.enums.BomStatus
import com.ffii.fpsms.modules.master.entity.projections.BomCombo
import com.ffii.fpsms.modules.master.web.models.BomComboDto
import com.ffii.fpsms.modules.master.service.BomService import com.ffii.fpsms.modules.master.service.BomService
import org.springframework.core.io.ByteArrayResource import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource import org.springframework.core.io.Resource
@@ -26,6 +26,12 @@ import jakarta.servlet.http.Part
import com.ffii.fpsms.modules.master.web.models.BomFormatCheckResponse import com.ffii.fpsms.modules.master.web.models.BomFormatCheckResponse
import com.ffii.fpsms.modules.master.web.models.BomUploadResponse import com.ffii.fpsms.modules.master.web.models.BomUploadResponse
import com.ffii.fpsms.modules.master.web.models.BomFormatCheckRequest import com.ffii.fpsms.modules.master.web.models.BomFormatCheckRequest
import com.ffii.fpsms.modules.master.web.models.BomImportPreviewRequest
import com.ffii.fpsms.modules.master.web.models.BomImportPreviewResponse
import com.ffii.fpsms.modules.master.web.models.BomImportRevalidateRequest
import com.ffii.fpsms.modules.master.web.models.BomImportRevalidateResponse
import com.ffii.fpsms.modules.master.web.models.BomImportExportCorrectedRequest
import com.ffii.fpsms.modules.master.web.models.BomImportMaterialItemContext
import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload
import com.ffii.fpsms.modules.master.web.models.BomDetailResponse import com.ffii.fpsms.modules.master.web.models.BomDetailResponse
import com.ffii.fpsms.modules.master.web.models.EditBomRequest import com.ffii.fpsms.modules.master.web.models.EditBomRequest
@@ -33,6 +39,17 @@ import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress
import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse
import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse
import com.ffii.fpsms.modules.master.service.MasterDataIssueService import com.ffii.fpsms.modules.master.service.MasterDataIssueService
import com.ffii.fpsms.modules.master.service.BomUomAlignmentService
import com.ffii.fpsms.modules.master.web.models.BomMaterialQtyRecalculateRequest
import com.ffii.fpsms.modules.master.web.models.BomMaterialQtyRecalculateResponse
import com.ffii.fpsms.modules.master.web.models.BomHeaderOutputQtyRecalculateRequest
import com.ffii.fpsms.modules.master.web.models.BomHeaderOutputQtyRecalculateResponse
import com.ffii.fpsms.modules.master.web.models.BomUomAlignmentApplyRequest
import com.ffii.fpsms.modules.master.web.models.BomUomAlignmentApplyResponse
import com.ffii.fpsms.modules.master.web.models.BomUomAlignmentPreviewRequest
import com.ffii.fpsms.modules.master.web.models.BomUomAlignmentPreviewResponse
import com.ffii.fpsms.modules.master.web.models.BomVersionSummary
import com.ffii.fpsms.modules.master.web.models.SaveBomAsNewVersionRequest
import com.ffii.core.exception.BadRequestException import com.ffii.core.exception.BadRequestException
import java.util.logging.Logger import java.util.logging.Logger
import java.nio.file.Files import java.nio.file.Files
@@ -44,6 +61,7 @@ class BomController (
val bomService: BomService, val bomService: BomService,
private val bomRepository: BomRepository, private val bomRepository: BomRepository,
private val masterDataIssueService: MasterDataIssueService, private val masterDataIssueService: MasterDataIssueService,
private val bomUomAlignmentService: BomUomAlignmentService,
) { ) {
private val log = LoggerFactory.getLogger(BomController::class.java) private val log = LoggerFactory.getLogger(BomController::class.java)


@@ -95,6 +113,36 @@ fun downloadBomFormatIssueLog(
fun checkBomFormat(@RequestBody request: BomFormatCheckRequest): BomFormatCheckResponse { fun checkBomFormat(@RequestBody request: BomFormatCheckRequest): BomFormatCheckResponse {
return bomService.checkBomExcelFormat(request.batchId) return bomService.checkBomExcelFormat(request.batchId)
} }

@PostMapping("/import-bom/preview")
fun previewImportBom(@RequestBody request: BomImportPreviewRequest): BomImportPreviewResponse {
return bomService.previewImportBom(request.batchId, request.fileNames)
}

@PostMapping("/import-bom/revalidate")
fun revalidateImportBomItem(@RequestBody request: BomImportRevalidateRequest): BomImportRevalidateResponse {
return bomService.revalidateImportBomItem(request.batchId, request.fileName, request.overrides)
}

@PostMapping("/import-bom/export-corrected")
fun exportCorrectedImportBomExcel(@RequestBody request: BomImportExportCorrectedRequest): ResponseEntity<Resource> {
// Do not put Chinese filename in Content-Disposition (Tomcat rejects non-Latin-1 headers).
// Frontend names the download from the original upload fileName (v.X bump).
val result = bomService.exportCorrectedImportBomExcel(request.batchId, request.fileName, request.overrides)
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment")
.header(HttpHeaders.CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.body(ByteArrayResource(result.bytes))
}

@GetMapping("/import-bom/material-item-context")
fun importBomMaterialItemContext(
@RequestParam itemCode: String,
@RequestParam(required = false) currentUomCode: String?,
@RequestParam(required = false) currentUomId: Long?,
): BomImportMaterialItemContext {
return bomService.resolveImportMaterialItemContext(itemCode, currentUomCode, currentUomId)
}
/* /*
@GetMapping("/import-bom/format-check/progress") @GetMapping("/import-bom/format-check/progress")
fun getBomFormatCheckProgress(@RequestParam batchId: String): ResponseEntity<BomExcelCheckProgress> { fun getBomFormatCheckProgress(@RequestParam batchId: String): ResponseEntity<BomExcelCheckProgress> {
@@ -111,13 +159,7 @@ fun downloadBomFormatIssueLog(
@GetMapping("/combo") @GetMapping("/combo")
fun getCombo( fun getCombo(
@RequestParam(defaultValue = "false") includeInactive: Boolean, @RequestParam(defaultValue = "false") includeInactive: Boolean,
): List<BomCombo> {
return if (includeInactive) {
bomRepository.findBomComboByDeletedIsFalse()
} else {
bomRepository.findBomComboByDeletedIsFalseAndStatus(BomStatus.ACTIVE)
}
}
): List<BomComboDto> = bomService.listBomCombos(includeInactive)


@GetMapping("/combo/issues") @GetMapping("/combo/issues")
fun getComboIssues(): List<MasterDataIssueResponse> { fun getComboIssues(): List<MasterDataIssueResponse> {
@@ -129,6 +171,30 @@ fun downloadBomFormatIssueLog(
return masterDataIssueService.findBomMasterDataIssues(); return masterDataIssueService.findBomMasterDataIssues();
} }


@PostMapping("/master-data/uom-alignment/preview")
fun previewBomUomAlignment(
@RequestBody(required = false) request: BomUomAlignmentPreviewRequest?,
): BomUomAlignmentPreviewResponse =
bomUomAlignmentService.previewAlignment(request ?: BomUomAlignmentPreviewRequest())

@PostMapping("/master-data/uom-alignment/recalculate")
fun recalculateBomMaterialQty(
@RequestBody request: BomMaterialQtyRecalculateRequest,
): BomMaterialQtyRecalculateResponse =
bomUomAlignmentService.recalculateMaterialQty(request)

@PostMapping("/master-data/uom-alignment/recalculate-header")
fun recalculateBomHeaderOutputQty(
@RequestBody request: BomHeaderOutputQtyRecalculateRequest,
): BomHeaderOutputQtyRecalculateResponse =
bomUomAlignmentService.recalculateHeaderOutputQty(request)

@PostMapping("/master-data/uom-alignment/apply")
fun applyBomUomAlignment(
@RequestBody request: BomUomAlignmentApplyRequest,
): BomUomAlignmentApplyResponse =
bomUomAlignmentService.applyAlignment(request)

@PostMapping("/import-bom") @PostMapping("/import-bom")
fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> { fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> {
val reportResult = bomService.importBOM(payload.batchId, payload.items) val reportResult = bomService.importBOM(payload.batchId, payload.items)
@@ -153,11 +219,28 @@ fun downloadBomFormatIssueLog(
return bomService.findBomSummaryByItemCode(code.trim()) return bomService.findBomSummaryByItemCode(code.trim())
} }


@GetMapping("/versions")
fun listBomVersions(
@RequestParam code: String,
@RequestParam bomKind: String,
): List<BomVersionSummary> = bomService.listBomVersions(code, bomKind)

@PostMapping("/{id}/save-as-new-version")
fun saveBomAsNewVersion(
@PathVariable id: Long,
@RequestBody request: SaveBomAsNewVersionRequest,
): BomDetailResponse = bomService.saveBomAsNewVersion(id, request)

@GetMapping("/{id}/detail") @GetMapping("/{id}/detail")
fun getBomDetail(@PathVariable id: Long): BomDetailResponse { fun getBomDetail(@PathVariable id: Long): BomDetailResponse {
return bomService.getBomDetail(id) return bomService.getBomDetail(id)
} }


@PostMapping("/{id}/activate-version")
fun activateBomVersion(@PathVariable id: Long): BomDetailResponse {
return bomService.activateBomVersion(id)
}

/** /**
* Edit BOM (basic fields + materials + process lines). * Edit BOM (basic fields + materials + process lines).
* baseScore is recalculated on server. * baseScore is recalculated on server.


+ 15
- 0
src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt Просмотреть файл

@@ -45,6 +45,21 @@ class ItemsController(
fun allConsumables(): List<Map<String, Any>> { fun allConsumables(): List<Map<String, Any>> {
return itemsService.allConsumables() return itemsService.allConsumables()
} }

@GetMapping("/bag-combo")
fun bagItemsCombo(): List<Map<String, String>> {
return itemsService.listBagItemsCombo()
}

@GetMapping("/exists-by-code")
fun itemExistsByCode(@RequestParam code: String): Map<String, Any?> {
return itemsService.itemExistsByCode(code)
}

@PostMapping("/exists-by-codes")
fun itemsExistsByCodes(@RequestBody codes: List<String>): List<Map<String, Any?>> {
return itemsService.itemsExistsByCodes(codes)
}
// @GetMapping("/getRecordByPage") // @GetMapping("/getRecordByPage")
// fun getAllItemsByPage(@RequestBody filterRequest: HttpServletRequest): RecordsRes<Map<String, Any>> { // fun getAllItemsByPage(@RequestBody filterRequest: HttpServletRequest): RecordsRes<Map<String, Any>> {
// val pageSize = filterRequest.getParameter("pageSize").toString().toInt(); // Default to 10 if not provided // val pageSize = filterRequest.getParameter("pageSize").toString().toInt(); // Default to 10 if not provided


+ 17
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboDto.kt Просмотреть файл

@@ -0,0 +1,17 @@
package com.ffii.fpsms.modules.master.web.models

import java.math.BigDecimal

/** BOM combo row with output qty/uom in stock unit for UI. */
data class BomComboDto(
val id: Long,
val value: Long,
val code: String?,
val revisionNo: Int?,
val label: String,
val outputQty: BigDecimal,
val outputQtyUom: String?,
val bomKind: String?,
val description: String?,
val status: String?,
)

+ 117
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/BomImportPreviewModels.kt Просмотреть файл

@@ -0,0 +1,117 @@
package com.ffii.fpsms.modules.master.web.models

import java.math.BigDecimal

data class BomImportPreviewRequest(
val batchId: String,
val fileNames: List<String>? = null,
)

data class BomImportPreviewMaterialLine(
val itemCode: String? = null,
val itemName: String? = null,
val itemId: Long? = null,
val qty: BigDecimal? = null,
val uomCode: String? = null,
val uomId: Long? = null,
val salesUomId: Long? = null,
val stockUomId: Long? = null,
val baseUomId: Long? = null,
val availableRecipeUoms: List<BomMaterialUomOption> = emptyList(),
val baseQty: BigDecimal? = null,
val baseUom: String? = null,
val stockQty: BigDecimal? = null,
val stockUom: String? = null,
val joinStepSeqNo: Int? = null,
)

data class BomImportPreviewProcessLine(
val seqNo: Long? = null,
val processCode: String? = null,
val processName: String? = null,
val description: String? = null,
val byProduct: String? = null,
val byProductUom: String? = null,
val equipmentDescription: String? = null,
val equipmentName: String? = null,
val durationInMinute: Int? = null,
val prepTimeInMinute: Int? = null,
val postProdTimeInMinute: Int? = null,
)

data class BomImportPreviewLine(
val fileName: String,
val code: String? = null,
val name: String? = null,
val bomKind: String? = null,
val outputQty: BigDecimal? = null,
val outputQtyUom: String? = null,
val isDark: Int? = null,
val isFloat: Int? = null,
val isDense: Int? = null,
val scrapRate: Int? = null,
val timeSequence: Int? = null,
val complexity: Int? = null,
val allergicSubstances: Int? = null,
val putawayLocationCode: String? = null,
val materialCount: Int = 0,
val processCount: Int = 0,
val materials: List<BomImportPreviewMaterialLine> = emptyList(),
val processes: List<BomImportPreviewProcessLine> = emptyList(),
)

data class BomImportPreviewResponse(
val items: List<BomImportPreviewLine>,
)

data class BomImportItemOverrides(
val code: String? = null,
val name: String? = null,
val bomKind: String? = null,
val outputQty: BigDecimal? = null,
val outputQtyUom: String? = null,
val isDark: Int? = null,
val isFloat: Int? = null,
val isDense: Int? = null,
val scrapRate: Int? = null,
val timeSequence: Int? = null,
val complexity: Int? = null,
val allergicSubstances: Int? = null,
val putawayLocationCode: String? = null,
val materials: List<BomImportPreviewMaterialLine>? = null,
val processes: List<BomImportPreviewProcessLine>? = null,
)

data class BomImportRevalidateRequest(
val batchId: String,
val fileName: String,
val overrides: BomImportItemOverrides? = null,
)

data class BomImportRevalidateResponse(
val passed: Boolean,
val problems: List<String> = emptyList(),
)

data class BomImportExportCorrectedRequest(
val batchId: String,
val fileName: String,
val overrides: BomImportItemOverrides? = null,
)

data class BomImportExportCorrectedResult(
val bytes: ByteArray,
val downloadFileName: String,
)

/** Resolve item + recipe UOM options when user corrects material itemCode in import preview. */
data class BomImportMaterialItemContext(
val exists: Boolean,
val itemCode: String,
val itemId: Long? = null,
val itemName: String? = null,
val salesUomId: Long? = null,
val stockUomId: Long? = null,
val baseUomId: Long? = null,
val availableRecipeUoms: List<BomMaterialUomOption> = emptyList(),
)

+ 153
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/BomUomAlignmentModels.kt Просмотреть файл

@@ -0,0 +1,153 @@
package com.ffii.fpsms.modules.master.web.models

import java.math.BigDecimal

data class BomUomLabel(
val uomId: Long? = null,
val code: String? = null,
val udfudesc: String? = null,
val label: String? = null,
)

data class BomUomQtyValue(
val qty: BigDecimal? = null,
val uom: BomUomLabel? = null,
)

data class BomHeaderUomAlignmentPreview(
val bomId: Long,
val bomCode: String?,
val bomName: String?,
val itemId: Long? = null,
val canApply: Boolean = true,
val skipReason: String? = null,
val beforeOutputQty: BigDecimal? = null,
val afterOutputQty: BigDecimal? = null,
val beforeOutputQtyStock: BigDecimal? = null,
val afterOutputQtyStock: BigDecimal? = null,
/** Current BOM stored UOM (may differ from item base). */
val beforeUom: BomUomLabel? = null,
/** Target base UOM after fix (same as [afterBaseUom]). */
val afterUom: BomUomLabel? = null,
val beforeBaseUom: BomUomLabel? = null,
val afterBaseUom: BomUomLabel? = null,
val beforeStockUom: BomUomLabel? = null,
val afterStockUom: BomUomLabel? = null,
)

data class BomMaterialUomAlignmentPreview(
val bomId: Long,
val bomCode: String?,
val bomName: String?,
val bomMaterialId: Long,
val itemId: Long?,
val itemCode: String?,
val itemName: String?,
val canApply: Boolean = true,
val skipReason: String? = null,
/** e.g. UOM_CONVERSION_ASSUMED_1_TO_1, BOM_SALE_QTY_NOT_SET, BOM_STOCK_QTY_NOT_SET, BOM_BASE_QTY_NOT_SET */
val warnings: List<String> = emptyList(),
val beforeSaleQty: BomUomQtyValue? = null,
val afterSaleQty: BomUomQtyValue? = null,
val beforeStockQty: BomUomQtyValue? = null,
val afterStockQty: BomUomQtyValue? = null,
val beforeBaseQty: BomUomQtyValue? = null,
val afterBaseQty: BomUomQtyValue? = null,
val beforeRecipeQty: BomUomQtyValue? = null,
val afterRecipeQty: BomUomQtyValue? = null,
val availableRecipeUoms: List<BomMaterialUomOption> = emptyList(),
val suggestedRecipeUomId: Long? = null,
)

data class BomUomAlignmentSkipped(
val bomId: Long? = null,
val bomCode: String? = null,
val bomMaterialId: Long? = null,
val itemCode: String? = null,
val reason: String,
)

data class BomUomAlignmentPreviewResponse(
val headers: List<BomHeaderUomAlignmentPreview> = emptyList(),
val materials: List<BomMaterialUomAlignmentPreview> = emptyList(),
val skipped: List<BomUomAlignmentSkipped> = emptyList(),
)

data class BomUomAlignmentPreviewRequest(
val bomIds: List<Long>? = null,
val bomMaterialIds: List<Long>? = null,
)

enum class BomMaterialQtyAnchor {
SALE_QTY,
STOCK_QTY,
BASE_QTY,
RECIPE_QTY,
}

data class BomMaterialQtyRecalculateRequest(
val itemId: Long,
val anchor: BomMaterialQtyAnchor,
val qty: BigDecimal,
val salesUomId: Long,
val stockUomId: Long,
val baseUomId: Long,
val recipeUomId: Long? = null,
)

data class BomMaterialQtyRecalculateResponse(
val saleQty: BigDecimal? = null,
val stockQty: BigDecimal? = null,
val baseQty: BigDecimal? = null,
val saleUom: String? = null,
val stockUom: String? = null,
val baseUom: String? = null,
)

data class BomHeaderOutputQtyRecalculateRequest(
val itemId: Long,
val stockQty: BigDecimal,
val stockUomId: Long,
)

data class BomHeaderOutputQtyRecalculateResponse(
val stockQty: BigDecimal? = null,
val baseQty: BigDecimal? = null,
/** Target/base-unit UOM label for UI display & persistence. */
val baseUom: String? = null,
val baseUomId: Long? = null,
)

data class BomHeaderUomAlignmentApply(
val bomId: Long,
/** Base-unit qty (legacy); prefer [outputQtyStock] for UI stock input. */
val outputQty: BigDecimal? = null,
/** Base UOM id for UOM-only alignment when no qty change. */
val uomId: Long,
/** Stock-unit output qty from alignment UI. */
val outputQtyStock: BigDecimal? = null,
val stockUomId: Long? = null,
)

data class BomMaterialUomAlignmentApply(
val bomMaterialId: Long,
val saleQty: BigDecimal? = null,
val salesUomId: Long? = null,
val stockQty: BigDecimal? = null,
val stockUomId: Long? = null,
val baseQty: BigDecimal? = null,
val baseUomId: Long? = null,
val qty: BigDecimal? = null,
val uomId: Long? = null,
)

data class BomUomAlignmentApplyRequest(
val headers: List<BomHeaderUomAlignmentApply> = emptyList(),
val materials: List<BomMaterialUomAlignmentApply> = emptyList(),
)

data class BomUomAlignmentApplyResponse(
val headersUpdated: Int = 0,
val materialsUpdated: Int = 0,
val message: String? = null,
)

+ 43
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/BomVersionModels.kt Просмотреть файл

@@ -0,0 +1,43 @@
package com.ffii.fpsms.modules.master.web.models

import java.math.BigDecimal

data class BomVersionSummary(
val id: Long,
val revisionNo: Int,
val status: String,
val bomKind: String?,
val outputQty: BigDecimal?,
val outputQtyUom: String?,
)

data class BomMaterialVersionEditLine(
val sourceBomMaterialId: Long? = null,
val itemCode: String,
val qty: BigDecimal,
val uomId: Long,
val isConsumable: Boolean? = null,
val bomProcessIds: List<Long> = emptyList(),
)

data class BomProcessVersionEditLine(
val seqNo: Long? = null,
val processCode: String,
val description: String? = null,
val byProduct: String? = null,
val byProductUom: String? = null,
val equipmentDescription: String? = null,
val equipmentName: String? = null,
val equipmentCode: String? = null,
val durationInMinute: Int? = null,
val prepTimeInMinute: Int? = null,
val postProdTimeInMinute: Int? = null,
)

data class SaveBomAsNewVersionRequest(
val materials: List<BomMaterialVersionEditLine>,
/** When non-null, set on new BOM revision (empty string clears). When null, copy from source. */
val putawayLocationCode: String? = null,
/** When non-null and changed vs source, rebuild bom_process on the new revision. */
val processes: List<BomProcessVersionEditLine>? = null,
)

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt Просмотреть файл

@@ -31,6 +31,7 @@ data class EditBomRequest(
val isDrink: Boolean? = null, val isDrink: Boolean? = null,
val isPowderMixture: Boolean? = null, val isPowderMixture: Boolean? = null,
val status: String? = null, val status: String? = null,
val putawayLocationCode: String? = null,


// children // children
val materials: List<EditBomMaterialRequest>? = null, val materials: List<EditBomMaterialRequest>? = null,


+ 39
- 3
src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt Просмотреть файл

@@ -66,6 +66,7 @@ data class ImportBomItemRequest(
val isAlsoWip: Boolean = false, val isAlsoWip: Boolean = false,
val isDrink: Boolean = false, val isDrink: Boolean = false,
val isPowderMixture: Boolean = false, val isPowderMixture: Boolean = false,
val overrides: BomImportItemOverrides? = null,
) )


data class ImportBomRequestPayload( data class ImportBomRequestPayload(
@@ -73,27 +74,47 @@ data class ImportBomRequestPayload(
val items: List<ImportBomItemRequest> val items: List<ImportBomItemRequest>
) )


data class BomMaterialUomOption(
val uomId: Long,
val label: String,
val code: String? = null,
)

data class BomMaterialDto( data class BomMaterialDto(
val id: Long? = null,
val itemId: Long? = null,
val itemCode: String?, val itemCode: String?,
val itemName: String?, val itemName: String?,
val isConsumable: Boolean?, val isConsumable: Boolean?,
val recipeQty: BigDecimal? = null,
val recipeUom: String? = null,
val recipeUomId: Long? = null,
val salesUomId: Long? = null,
val stockUomId: Long? = null,
val baseUomId: Long? = null,
val availableRecipeUoms: List<BomMaterialUomOption> = emptyList(),
val baseQty: BigDecimal?, val baseQty: BigDecimal?,
val baseUom: String?, val baseUom: String?,
val stockQty: BigDecimal?, val stockQty: BigDecimal?,
val stockUom: String?, val stockUom: String?,
val salesQty: BigDecimal?, val salesQty: BigDecimal?,
val salesUom: String?
val salesUom: String?,
val processSteps: List<String> = emptyList(),
val processStepIds: List<Long> = emptyList(),
) )


data class BomProcessDto( data class BomProcessDto(
val id: Long? = null,
val seqNo: Long?, val seqNo: Long?,
val processCode: String?, val processCode: String?,
val processName: String?, val processName: String?,
val processDescription: String?, val processDescription: String?,
val byProduct: String? = null,
val byProductUom: String? = null,
// For display we prefer equipment.code (stable identifier). Keep equipmentName for backward compatibility. // For display we prefer equipment.code (stable identifier). Keep equipmentName for backward compatibility.
val equipmentCode: String? = null, val equipmentCode: String? = null,
val equipmentName: String? = null, val equipmentName: String? = null,
val equipmentDescription: String? = null,
val durationInMinute: Int?, val durationInMinute: Int?,
val prepTimeInMinute: Int?, val prepTimeInMinute: Int?,
val postProdTimeInMinute: Int? val postProdTimeInMinute: Int?
@@ -107,9 +128,10 @@ data class BomExcelCheckProgress(
) )
data class BomDetailResponse( data class BomDetailResponse(
val id: Long, val id: Long,
val itemId: Long?,
val itemCode: String?, val itemCode: String?,
val itemName: String?, val itemName: String?,
val isDark: Boolean?,
val isDark: Int?,
val isFloat: Int?, val isFloat: Int?,
val isDense: Int?, val isDense: Int?,
val isDrink: Boolean?, val isDrink: Boolean?,
@@ -120,9 +142,23 @@ data class BomDetailResponse(
val complexity: Int?, val complexity: Int?,
val baseScore: Int?, val baseScore: Int?,
val description: String?, val description: String?,
val bomKind: String?,
val revisionNo: Int?,
/** Stored base qty on BOM header. */
val outputQty: BigDecimal?, val outputQty: BigDecimal?,
/** Stored base qty UOM label on BOM header. */
val outputQtyUom: String?, val outputQtyUom: String?,
/** Stock-facing output qty preview; null when conversion fails. */
val outputQtyStock: BigDecimal? = null,
/** Stock unit label for output qty display/input. */
val outputQtyStockUom: String? = null,
/** Stock unit id used by recalculate-header API. */
val outputQtyStockUomId: Long? = null,
/** True when stock-facing output qty is convertible from stored base qty. */
val outputQtyStockConvertible: Boolean = true,
val status: String?, val status: String?,
val putawayLocationCode: String? = null,
val itemLocationCode: String? = null,
val materials: List<BomMaterialDto>, val materials: List<BomMaterialDto>,
val processes: List<BomProcessDto> val processes: List<BomProcessDto>
) )

+ 10
- 8
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt Просмотреть файл

@@ -51,6 +51,7 @@ import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest
import com.ffii.fpsms.modules.master.service.ItemUomService
@Service @Service
open class PickExecutionIssueService( open class PickExecutionIssueService(
private val pickExecutionIssueRepository: PickExecutionIssueRepository, private val pickExecutionIssueRepository: PickExecutionIssueRepository,
@@ -60,6 +61,7 @@ open class PickExecutionIssueService(
private val suggestedPickLotService: SuggestedPickLotService, private val suggestedPickLotService: SuggestedPickLotService,
private val pickOrderRepository: PickOrderRepository, private val pickOrderRepository: PickOrderRepository,
private val jdbcDao: JdbcDao, private val jdbcDao: JdbcDao,
private val itemUomService: ItemUomService,
private val stockOutRepository: StockOutRepository, private val stockOutRepository: StockOutRepository,
private val stockOutLineService: StockOutLineService, private val stockOutLineService: StockOutLineService,
private val pickOrderLineRepository: PickOrderLineRepository, private val pickOrderLineRepository: PickOrderLineRepository,
@@ -861,7 +863,7 @@ private fun handleMissItemWithPartialPick(request: PickExecutionIssueRequest, ac
val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null)
// ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty
val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null)
val inventoryBeforeUpdate = itemUomService.findInventoryForItemBaseUom(itemId)
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
if (inventoryLotLine != null) { if (inventoryLotLine != null) {
@@ -927,7 +929,7 @@ private fun handleMissItemOnly(request: PickExecutionIssueRequest, missQty: BigD
val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null)
// ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty
val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null)
val inventoryBeforeUpdate = itemUomService.findInventoryForItemBaseUom(itemId)
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
if (inventoryLotLine != null) { if (inventoryLotLine != null) {
@@ -1129,7 +1131,7 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty
val itemId = request.itemId ?: return val itemId = request.itemId ?: return
// ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty
val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null)
val inventoryBeforeUpdate = itemUomService.findInventoryForItemBaseUom(itemId)
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
// ✅ 修复:更新 stock_out_line,但不要累积 qty // ✅ 修复:更新 stock_out_line,但不要累积 qty
@@ -2308,7 +2310,7 @@ private fun updateLotLineAfterIssue(lotLineId: Long, qty: BigDecimal, isMissItem
private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) { private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) {
try { try {
val item = lotLine.inventoryLot?.item ?: return val item = lotLine.inventoryLot?.item ?: return
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
// 使用 SQL 查询计算所有相关 lot lines 的总和 // 使用 SQL 查询计算所有相关 lot lines 的总和
val sql = """ val sql = """
@@ -2383,7 +2385,7 @@ private fun createStockLedgerForStockOut(
if (balance < 0) 0.0 else balance if (balance < 0) 0.0 else balance
} else { } else {
// 重新查询 inventory 以获取触发器更新后的最新 onHandQty // 重新查询 inventory 以获取触发器更新后的最新 onHandQty
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
val currentOnHandQty = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val currentOnHandQty = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val newBalance = currentOnHandQty - outQty val newBalance = currentOnHandQty - outQty
if (newBalance < 0) 0.0 else newBalance if (newBalance < 0) 0.0 else newBalance
@@ -2392,7 +2394,7 @@ private fun createStockLedgerForStockOut(
// 使用传入的 ledgerType,如果没有则使用 stockOutLine.type // 使用传入的 ledgerType,如果没有则使用 stockOutLine.type
val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor"
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
this.stockOutLine = stockOutLine this.stockOutLine = stockOutLine
@@ -2781,7 +2783,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse
} }
// ✅ 修复:在创建和保存 stockOutLine 之前获取 inventory 的当前 onHandQty // ✅ 修复:在创建和保存 stockOutLine 之前获取 inventory 的当前 onHandQty
val inventoryBeforeUpdate = inventoryRepository.findByItemId(request.itemId).orElse(null)
val inventoryBeforeUpdate = itemUomService.findInventoryForItemBaseUom(request.itemId)
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
println("=== submitIssueWithQty: Before update ===") println("=== submitIssueWithQty: Before update ===")
@@ -2878,7 +2880,7 @@ private fun createStockLedgerForStockOutWithBalance(
// Use传入的 ledgerType,如果没有则使用 stockOutLine.type // Use传入的 ledgerType,如果没有则使用 stockOutLine.type
val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor"
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
this.stockOutLine = stockOutLine this.stockOutLine = stockOutLine


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/productProcess/entity/projections/ProductProcessInfo.kt Просмотреть файл

@@ -24,12 +24,12 @@ data class ProductProcessInfo(
val jobOrderStatus: String?, val jobOrderStatus: String?,
val jobType: String?, val jobType: String?,
val bomDescription: String?, val bomDescription: String?,
val isDark: String?,
val isDark: Int?,
val isDense: Int?, val isDense: Int?,
val isFloat: String?,
val isFloat: Int?,
val scrapRate: Int?, val scrapRate: Int?,
//val matchStatus: String?, //val matchStatus: String?,
val allergicSubstance: String?,
val allergicSubstance: Int?,
val itemId: Long?, val itemId: Long?,
val itemCode: String?, val itemCode: String?,
val itemName: String?, val itemName: String?,


+ 114
- 59
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt Просмотреть файл

@@ -30,6 +30,8 @@ import com.ffii.fpsms.modules.master.entity.Equipment
import com.ffii.fpsms.modules.master.entity.BomProcess import com.ffii.fpsms.modules.master.entity.BomProcess
import com.ffii.fpsms.modules.master.entity.Bom import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.BomOutputQtyService
import com.ffii.fpsms.modules.master.service.BomMaterialQtyService
import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderBomMaterialRepository import com.ffii.fpsms.modules.jobOrder.entity.JobOrderBomMaterialRepository
import com.ffii.fpsms.modules.master.service.ProductionScheduleService import com.ffii.fpsms.modules.master.service.ProductionScheduleService
@@ -44,6 +46,8 @@ import com.ffii.fpsms.modules.master.entity.BomMaterialRepository
import com.ffii.fpsms.modules.productProcess.entity.ProductionProcessIssue import com.ffii.fpsms.modules.productProcess.entity.ProductionProcessIssue
import com.ffii.fpsms.modules.jobOrder.entity.JobTypeRepository import com.ffii.fpsms.modules.jobOrder.entity.JobTypeRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository
import java.time.ZoneOffset import java.time.ZoneOffset
import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository
import com.ffii.fpsms.modules.jobOrder.enums.JoPickOrderStatus import com.ffii.fpsms.modules.jobOrder.enums.JoPickOrderStatus
@@ -77,11 +81,14 @@ open class ProductProcessService(
private val equipmentDetailRepository: EquipmentDetailRepository, private val equipmentDetailRepository: EquipmentDetailRepository,
private val jobTypeRepository: JobTypeRepository, private val jobTypeRepository: JobTypeRepository,
private val pickOrderRepository: PickOrderRepository, private val pickOrderRepository: PickOrderRepository,
private val pickOrderLineRepository: PickOrderLineRepository,
private val joPickOrderRepository: JoPickOrderRepository, private val joPickOrderRepository: JoPickOrderRepository,
private val itemUomRepository: ItemUomRespository, private val itemUomRepository: ItemUomRespository,
private val uomConversionRepository: UomConversionRepository, private val uomConversionRepository: UomConversionRepository,
private val itemUomService: ItemUomService, private val itemUomService: ItemUomService,
private val stockInLineRepository: StockInLineRepository
private val stockInLineRepository: StockInLineRepository,
private val bomOutputQtyService: BomOutputQtyService,
private val bomMaterialQtyService: BomMaterialQtyService,
) { ) {


open fun findAll(pageable: Pageable): Page<ProductProcess> { open fun findAll(pageable: Pageable): Page<ProductProcess> {
@@ -604,34 +611,27 @@ open class ProductProcessService(
val (stockQty, _) = stockQtyMap[itemId] ?: (BigDecimal.ZERO to null) val (stockQty, _) = stockQtyMap[itemId] ?: (BigDecimal.ZERO to null)
stockQty.toInt() stockQty.toInt()
} }
// 获取 productionPriority
val itemId = jobOrder?.bom?.item?.id
val planEndDate = jobOrder?.planEnd?.toLocalDate()



fun calculateColourScore(value: Int?): String {
return when (value) {
0 -> "淺"
1 -> "深"
else -> ""
}
val isPlanning = jobOrder?.status == JobOrderStatus.PLANNING
val pickOrderLinesByItemId = if (!isPlanning) {
loadPickOrderLinesByItemId(joid)
} else {
emptyMap()
} }


fun calculateAllergicSubstanceScore(value: Int?): String {
return when (value) {
0 -> "No"
5 -> "Yes"
else -> "N/A"
val jobOrderReqQty = jobOrder?.reqQty
val bomStockOutputQty = bomOutputQtyService.stockOutputQty(jobOrder?.bom)
val proportion =
if (jobOrderReqQty != null && bomStockOutputQty > BigDecimal.ZERO) {
jobOrderReqQty.divide(bomStockOutputQty, 5, RoundingMode.UP)
} else {
BigDecimal.ONE
} }
}


fun calculateFloatScore(value: Int?): String {
return when (value) {
0 -> "沉"
1 -> "浮"
else -> ""
}
}
// 获取 productionPriority
val itemId = jobOrder?.bom?.item?.id
val planEndDate = jobOrder?.planEnd?.toLocalDate()



return productProcesses.map { process -> return productProcesses.map { process ->


@@ -647,19 +647,19 @@ open class ProductProcessService(
jobOrderCode = jobOrder?.code ?: "", jobOrderCode = jobOrder?.code ?: "",
jobOrderStatus = jobOrder?.status?.value ?: "", jobOrderStatus = jobOrder?.status?.value ?: "",
jobType = jobType?.name ?: "", jobType = jobType?.name ?: "",
bomDescription = bom?.description ?: "",
bomDescription = bom?.bomKind ?: "",
itemId = bom?.item?.id ?: 0, itemId = bom?.item?.id ?: 0,
itemCode = bom?.item?.code ?: "", itemCode = bom?.item?.code ?: "",
itemName = bom?.item?.name ?: "", itemName = bom?.item?.name ?: "",
timeSequence = bom?.timeSequence ?: 0, timeSequence = bom?.timeSequence ?: 0,
complexity = bom?.complexity ?: 0, complexity = bom?.complexity ?: 0,
bomBaseQty = bom.outputQty ?: BigDecimal.ZERO, bomBaseQty = bom.outputQty ?: BigDecimal.ZERO,
isDark = calculateColourScore(bom?.isDark ?: 0),
isDense = bom?.isDense ?: 0,
isFloat = calculateFloatScore(bom?.isFloat ?: 0),
isDark = bom?.isDark,
isDense = bom?.isDense,
isFloat = bom?.isFloat,
scrapRate = bom?.scrapRate ?: -1, scrapRate = bom?.scrapRate ?: -1,
allergicSubstance = calculateAllergicSubstanceScore(bom?.allergicSubstances),
outputQtyUom = bom?.outputQtyUom ?: "",
allergicSubstance = bom?.allergicSubstances,
outputQtyUom = bomOutputQtyService.stockOutputUomLabel(bom) ?: "",
//matchStatus = joPickOrders?.matchStatus?.value?:"", //matchStatus = joPickOrders?.matchStatus?.value?:"",
//outputQty = bom?.outputQty?.toInt()?:0, //outputQty = bom?.outputQty?.toInt()?:0,
outputQty = jobOrder?.reqQty?.toInt() ?: 0, outputQty = jobOrder?.reqQty?.toInt() ?: 0,
@@ -737,7 +737,9 @@ open class ProductProcessService(
// ✅ 获取 req UOM - 对于 reqQty,使用 bomMaterial.uom(BOM 的 UOM) // ✅ 获取 req UOM - 对于 reqQty,使用 bomMaterial.uom(BOM 的 UOM)
// ✅ 对于 stockReqQty,使用 line.uom(库存单位,已按比例调整) // ✅ 对于 stockReqQty,使用 line.uom(库存单位,已按比例调整)
val reqUomId = bomMaterial?.uom?.id ?: line.uom?.id ?: 0L // BOM 的 UOM val reqUomId = bomMaterial?.uom?.id ?: line.uom?.id ?: 0L // BOM 的 UOM
val stockReqUomId = line.uom?.id ?: bomMaterial?.stockUnit?.toLong() ?: 0L // 库存单位 UOM
val stockReqUomId = line.uom?.id
?: bomMaterial?.let { bomMaterialQtyService.stockUomIdForJob(it) }
?: 0L // 库存单位 UOM


val reqUom = reqUomId.takeIf { it > 0 }?.let { uomConversionRepository.findByIdAndDeletedFalse(it) } val reqUom = reqUomId.takeIf { it > 0 }?.let { uomConversionRepository.findByIdAndDeletedFalse(it) }
val uomName = reqUom?.udfudesc val uomName = reqUom?.udfudesc
@@ -745,32 +747,45 @@ open class ProductProcessService(


val stockReqUom = val stockReqUom =
stockReqUomId.takeIf { it > 0 }?.let { uomConversionRepository.findByIdAndDeletedFalse(it) } stockReqUomId.takeIf { it > 0 }?.let { uomConversionRepository.findByIdAndDeletedFalse(it) }
val stockUomName = stockReqUom?.udfudesc
var stockUomName = stockReqUom?.udfudesc


val stockUom = val stockUom =
stockUomId?.takeIf { it > 0 }?.let { uomConversionRepository.findByIdAndDeletedFalse(it) } stockUomId?.takeIf { it > 0 }?.let { uomConversionRepository.findByIdAndDeletedFalse(it) }
val stockUomNameForStock = stockUom?.udfudesc val stockUomNameForStock = stockUom?.udfudesc


println("=== Quantity Calculation for Item: ${line.item?.code} (id=$itemId) ===")
println("JobOrderBomMaterial reqQty (adjusted, stock unit): $actualReqQty in UOM: ${stockReqUom?.udfudesc} (id=$stockReqUomId)")
println("BomMaterial qty (base): ${bomMaterial?.qty}, stockQty (base): ${bomMaterial?.stockQty}")
println("Original stockQty: $stockQtyValue in UOM: $stockUomNameForStock (id=$stockUomId)")

val jobOrder = jobOrderRepository.findById(joid).orElse(null)
val jobOrderReqQty = jobOrder?.reqQty
val bomOutputQty = jobOrder?.bom?.outputQty
val proportion =
if (jobOrderReqQty != null && bomOutputQty != null && bomOutputQty > BigDecimal.ZERO) {
jobOrderReqQty.divide(bomOutputQty, 5, RoundingMode.UP)
// println("=== Quantity Calculation for Item: ${line.item?.code} (id=$itemId) ===")
// println("JobOrder status: ${jobOrder?.status?.value}, usePickOrderLine=${!isPlanning}")
// println("JobOrderBomMaterial reqQty (adjusted, stock unit): $actualReqQty in UOM: ${stockReqUom?.udfudesc} (id=$stockReqUomId)")
// println("BomMaterial qty (base): ${bomMaterial?.qty}, stockQty (base): ${bomMaterial?.stockQty}")
// println("Original stockQty: $stockQtyValue in UOM: $stockUomNameForStock (id=$stockUomId)")

val pickOrderLine = pickOrderLinesByItemId[itemId]
var effectiveStockReqUomId = stockReqUomId

val reqQtyInBomUnit: BigDecimal
val stockReqQtyInStockUnit: BigDecimal

if (pickOrderLine != null) {
val polQty = pickOrderLine.qty ?: BigDecimal.ZERO
val polUomId = pickOrderLine.uom?.id ?: 0L
stockReqQtyInStockUnit = polQty
effectiveStockReqUomId = polUomId.takeIf { it > 0 } ?: stockReqUomId
stockUomName = pickOrderLine.uom?.udfudesc
?: effectiveStockReqUomId.takeIf { it > 0 }
?.let { uomConversionRepository.findByIdAndDeletedFalse(it)?.udfudesc }
?: stockUomName
reqQtyInBomUnit = if (polQty > BigDecimal.ZERO && polUomId > 0 && reqUomId > 0) {
convertQtyBetweenUomIds(itemId, polQty, polUomId, reqUomId) ?: polQty
} else { } else {
BigDecimal.ONE
polQty
} }

// ✅ reqQty 使用 bomMaterial.qty * proportion(BOM 单位)
val reqQtyInBomUnit = (bomMaterial?.qty?.times(proportion) ?: BigDecimal.ZERO)

// ✅ stockReqQty 使用 bomMaterial.stockQty * proportion(库存单位,已按比例调整)
val stockReqQtyInStockUnit = (bomMaterial?.stockQty?.times(proportion) ?: BigDecimal.ZERO)
// println("Using PickOrderLine qty=$polQty uomId=$polUomId -> reqQtyInBomUnit=$reqQtyInBomUnit")
} else {
reqQtyInBomUnit = bomMaterial?.qty?.times(proportion) ?: BigDecimal.ZERO
stockReqQtyInStockUnit = bomMaterial?.let {
bomMaterialQtyService.stockQtyForJob(it, proportion)
} ?: BigDecimal.ZERO
}


// ✅ Convert reqQty (BOM unit) to base unit // ✅ Convert reqQty (BOM unit) to base unit
val baseReqQtyResult = if (reqUomId > 0 && reqQtyInBomUnit > BigDecimal.ZERO) { val baseReqQtyResult = if (reqUomId > 0 && reqQtyInBomUnit > BigDecimal.ZERO) {
@@ -840,13 +855,13 @@ open class ProductProcessService(
val baseStockShortUom = baseShortUom val baseStockShortUom = baseShortUom


// ✅ 计算 baseStockReqQty(stockReqQty 转换为 base unit) // ✅ 计算 baseStockReqQty(stockReqQty 转换为 base unit)
val baseStockReqQtyResult = if (stockReqUomId > 0 && stockReqQtyInStockUnit > BigDecimal.ZERO) {
val baseStockReqQtyResult = if (effectiveStockReqUomId > 0 && stockReqQtyInStockUnit > BigDecimal.ZERO) {
try { try {
val result = itemUomService.convertUomByItem( val result = itemUomService.convertUomByItem(
ConvertUomByItemRequest( ConvertUomByItemRequest(
itemId = itemId, itemId = itemId,
qty = stockReqQtyInStockUnit, // 库存单位数量
uomId = stockReqUomId,
qty = stockReqQtyInStockUnit,
uomId = effectiveStockReqUomId,
targetUnit = "baseUnit" targetUnit = "baseUnit"
) )
) )
@@ -898,7 +913,7 @@ open class ProductProcessService(


stockQty = stockQty, stockQty = stockQty,
// ✅ stockReqQty:使用 bomMaterial.stockQty * proportion(库存单位,已按比例调整) // ✅ stockReqQty:使用 bomMaterial.stockQty * proportion(库存单位,已按比例调整)
stockReqQty = if (stockReqUomId in decimalUomIds) {
stockReqQty = if (effectiveStockReqUomId in decimalUomIds) {
stockReqQtyInStockUnit.toDouble() stockReqQtyInStockUnit.toDouble()
} else { } else {
stockReqQtyInStockUnit.setScale(0, RoundingMode.UP).toDouble() stockReqQtyInStockUnit.setScale(0, RoundingMode.UP).toDouble()
@@ -920,6 +935,48 @@ open class ProductProcessService(
} }
} }


private fun loadPickOrderLinesByItemId(jobOrderId: Long): Map<Long, PickOrderLine> {
val pickOrder = pickOrderRepository.findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId)
?: pickOrderRepository.findAllByJobOrder_Id(jobOrderId).firstOrNull()
?: return emptyMap()
val pickOrderId = pickOrder.id ?: return emptyMap()
return pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId)
.mapNotNull { line ->
val itemId = line.item?.id ?: return@mapNotNull null
itemId to line
}
.toMap()
}

private fun convertQtyBetweenUomIds(
itemId: Long,
qty: BigDecimal,
sourceUomId: Long,
targetUomId: Long,
): BigDecimal? {
if (qty <= BigDecimal.ZERO || sourceUomId <= 0 || targetUomId <= 0) return null
if (sourceUomId == targetUomId) return qty
return try {
val baseResult = itemUomService.convertUomByItem(
ConvertUomByItemRequest(
itemId = itemId,
qty = qty,
uomId = sourceUomId,
targetUnit = "baseUnit",
),
)
val targetItemUom = itemUomRepository.findFirstByItemIdAndUomIdAndDeletedIsFalse(itemId, targetUomId)
?: return null
val one = BigDecimal.ONE
val targetRatioN = targetItemUom.ratioN ?: one
val targetRatioD = targetItemUom.ratioD ?: one
baseResult.newQty.multiply(targetRatioD).divide(targetRatioN, 10, RoundingMode.HALF_UP)
} catch (e: Exception) {
println("convertQtyBetweenUomIds failed itemId=$itemId: ${e.message}")
null
}
}

open fun createProductProcessByJobOrderId(jobOrderId: Long, productionPriority: Int?): MessageResponse { open fun createProductProcessByJobOrderId(jobOrderId: Long, productionPriority: Int?): MessageResponse {
val jobOrder = jobOrderRepository.findById(jobOrderId).orElse(null) val jobOrder = jobOrderRepository.findById(jobOrderId).orElse(null)
val bom = bomRepository.findById(jobOrder?.bom?.id ?: 0L).orElse(null) val bom = bomRepository.findById(jobOrder?.bom?.id ?: 0L).orElse(null)
@@ -1459,7 +1516,7 @@ open class ProductProcessService(
assignedTo = pickOrder?.assignTo?.id, assignedTo = pickOrder?.assignTo?.id,
itemName = productProcesses.item?.name, itemName = productProcesses.item?.name,
itemCode = productProcesses.item?.code, itemCode = productProcesses.item?.code,
bomDescription = productProcesses.bom?.description,
bomDescription = productProcesses.bom?.bomKind,
pickOrderId = pickOrder?.id, pickOrderId = pickOrder?.id,
pickOrderStatus = pickOrder?.status?.value, pickOrderStatus = pickOrder?.status?.value,
jobOrderId = productProcesses.jobOrder?.id, jobOrderId = productProcesses.jobOrder?.id,
@@ -1755,7 +1812,7 @@ open class ProductProcessService(
assignedTo = pickOrder?.assignTo?.id, assignedTo = pickOrder?.assignTo?.id,
itemName = productProcess.item?.name, itemName = productProcess.item?.name,
itemCode = productProcess.item?.code, itemCode = productProcess.item?.code,
bomDescription = productProcess.bom?.description,
bomDescription = productProcess.bom?.bomKind,
pickOrderId = pickOrder?.id, pickOrderId = pickOrder?.id,
pickOrderStatus = pickOrder?.status?.value, pickOrderStatus = pickOrder?.status?.value,
jobOrderId = productProcess.jobOrder?.id, jobOrderId = productProcess.jobOrder?.id,
@@ -2737,6 +2794,4 @@ open class ProductProcessService(
errorPosition = null, errorPosition = null,
) )
} }
}


}

+ 1
- 1
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt Просмотреть файл

@@ -57,7 +57,7 @@ interface StockInLineInfo {
@get:Value("#{target.item?.type}") @get:Value("#{target.item?.type}")
val itemType: String? val itemType: String?
val dnNo: String? val dnNo: String?
@get:Value("#{target.jobOrder?.bom?.description}")
@get:Value("#{target.jobOrder?.bom?.bomKind}")
val bomDescription: String? val bomDescription: String?
@get:Value("#{target.escalationLog.^[status.value == 'pending']?.handler?.id}") @get:Value("#{target.escalationLog.^[status.value == 'pending']?.handler?.id}")
val handlerId: Long? val handlerId: Long?


+ 3
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt Просмотреть файл

@@ -43,6 +43,7 @@ import com.ffii.fpsms.modules.stock.web.model.SameItemLotInfo
import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse
import com.ffii.fpsms.modules.jobOrder.service.JobOrderService import com.ffii.fpsms.modules.jobOrder.service.JobOrderService
import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.PrinterService import com.ffii.fpsms.modules.master.service.PrinterService
import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest
import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest
@@ -59,6 +60,7 @@ open class InventoryLotLineService(
private val itemUomRespository: ItemUomRespository, private val itemUomRespository: ItemUomRespository,
private val stockInLineRepository: StockInLineRepository, private val stockInLineRepository: StockInLineRepository,
private val inventoryRepository: InventoryRepository, private val inventoryRepository: InventoryRepository,
private val itemUomService: ItemUomService,
private val printerService: PrinterService, private val printerService: PrinterService,
@Lazy @Lazy
private val jobOrderService: JobOrderService private val jobOrderService: JobOrderService
@@ -228,7 +230,7 @@ open class InventoryLotLineService(
} }


// Update the inventory table // Update the inventory table
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
val inventory = itemUomService.findInventoryForItemBaseUom(itemId)
if (inventory != null) { if (inventory != null) {
inventory.onHoldQty = onHoldQty inventory.onHoldQty = onHoldQty
inventory.unavailableQty = unavailableQty inventory.unavailableQty = unavailableQty


+ 7
- 7
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Просмотреть файл

@@ -31,7 +31,7 @@ import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus
import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.master.entity.ItemUomRespository
import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.master.entity.WarehouseRepository
import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.master.service.ItemUomService
//import com.ffii.fpsms.modules.master.service.BomOutputQtyService
import com.ffii.fpsms.modules.master.service.BomOutputQtyService
import com.ffii.fpsms.modules.master.service.PrinterService import com.ffii.fpsms.modules.master.service.PrinterService
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine
@@ -111,7 +111,7 @@ open class StockInLineService(
@Value("\${scheduler.m18Grn.createEnabled:false}") private val m18GrnCreateEnabled: Boolean, @Value("\${scheduler.m18Grn.createEnabled:false}") private val m18GrnCreateEnabled: Boolean,
/** Recent window for PO stock-in alerts (pending / receiving) in nav / list UI. */ /** Recent window for PO stock-in alerts (pending / receiving) in nav / list UI. */
@Value("\${fpsms.purchase-stock-in-alert.lookback-days:7}") private val purchaseStockInAlertLookbackDays: Int, @Value("\${fpsms.purchase-stock-in-alert.lookback-days:7}") private val purchaseStockInAlertLookbackDays: Int,
// private val bomOutputQtyService: BomOutputQtyService,
private val bomOutputQtyService: BomOutputQtyService,
) : AbstractBaseEntityService<StockInLine, Long, StockInLineRepository>(jdbcDao, stockInLineRepository) { ) : AbstractBaseEntityService<StockInLine, Long, StockInLineRepository>(jdbcDao, stockInLineRepository) {


private val logger = LoggerFactory.getLogger(StockInLineService::class.java) private val logger = LoggerFactory.getLogger(StockInLineService::class.java)
@@ -425,8 +425,8 @@ open class StockInLineService(
} }
// Set demandQty based on source // Set demandQty based on source
if (jo != null && jo?.bom != null) { if (jo != null && jo?.bom != null) {
//this.demandQty = bomOutputQtyService.stockOutputQty(jo?.bom)
this.demandQty = jo?.bom?.outputQty
this.demandQty = bomOutputQtyService.stockOutputQty(jo?.bom)
} else if (pol != null && item.id != null) { } else if (pol != null && item.id != null) {
val m18UomId = pol.uomM18?.id val m18UomId = pol.uomM18?.id
val qtyM18 = pol.qtyM18 val qtyM18 = pol.qtyM18
@@ -1003,8 +1003,8 @@ open class StockInLineService(
// Set demandQty based on source // Set demandQty based on source
if (this.jobOrder != null && this.jobOrder?.bom != null) { if (this.jobOrder != null && this.jobOrder?.bom != null) {
// For job orders, demandQty comes from BOM's outputQty // For job orders, demandQty comes from BOM's outputQty
//this.demandQty = this.jobOrder?.bom?.let { bomOutputQtyService.stockOutputQty(it) } ?: this.demandQty
this.demandQty = this.jobOrder?.bom?.outputQty ?: this.demandQty
this.demandQty = this.jobOrder?.bom?.let { bomOutputQtyService.stockOutputQty(it) } ?: this.demandQty
//this.demandQty = this.jobOrder?.bom?.outputQty ?: this.demandQty
} else if (this.purchaseOrderLine != null && this.item?.id != null) { } else if (this.purchaseOrderLine != null && this.item?.id != null) {
val itemId = this.item!!.id!! val itemId = this.item!!.id!!
val pol = this.purchaseOrderLine!! val pol = this.purchaseOrderLine!!
@@ -1088,7 +1088,7 @@ open class StockInLineService(
if (putAwayStockQty >= requiredStockQty) { if (putAwayStockQty >= requiredStockQty) {
val _tStatus = System.nanoTime() val _tStatus = System.nanoTime()
stockInLine.apply { stockInLine.apply {
val isWipJobOrder = stockInLine.jobOrder?.bom?.description == "WIP"
val isWipJobOrder = stockInLine.jobOrder?.bom?.bomKind == "WIP"
this.status = if (isWipJobOrder) { this.status = if (isWipJobOrder) {
StockInLineStatus.COMPLETE.status StockInLineStatus.COMPLETE.status
} else { } else {


+ 6
- 4
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt Просмотреть файл

@@ -266,7 +266,8 @@ fun handleQc(stockOutLine: StockOutLine, request: UpdateStockOutLineRequest): Li
} }
inventoryLotLineRepository.save(targetLotLineEntry) inventoryLotLineRepository.save(targetLotLineEntry)
// update inventory // update inventory
val inventory = inventoryRepository.findByItemId(request.itemId).orElseThrow()
val inventory = itemUomService.findInventoryForItemBaseUom(request.itemId)
?: throw IllegalArgumentException("Inventory not found for itemId=${request.itemId}")
val inventoryEntry = inventory.apply { val inventoryEntry = inventory.apply {
this.onHandQty = this.onHandQty!!.minus((request.qty.div(ratio)).toBigDecimal()) this.onHandQty = this.onHandQty!!.minus((request.qty.div(ratio)).toBigDecimal())
this.onHoldQty = this.onHoldQty!!.minus((request.qty.div(ratio)).toBigDecimal()) this.onHoldQty = this.onHoldQty!!.minus((request.qty.div(ratio)).toBigDecimal())
@@ -863,7 +864,7 @@ private fun updateInventoryTableAfterLotRejection(inventoryLotLine: InventoryLot
println("Calculated unavailableQty: $unavailableQty") println("Calculated unavailableQty: $unavailableQty")
// Update the inventory table // Update the inventory table
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
val inventory = itemUomService.findInventoryForItemBaseUom(itemId)
if (inventory != null) { if (inventory != null) {
println("Found inventory record: ${inventory.id}") println("Found inventory record: ${inventory.id}")
println("Current inventory - onHoldQty: ${inventory.onHoldQty}, unavailableQty: ${inventory.unavailableQty}") println("Current inventory - onHoldQty: ${inventory.onHoldQty}, unavailableQty: ${inventory.unavailableQty}")
@@ -956,7 +957,8 @@ open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
inventoryLotLineRepository.save(lotLine) inventoryLotLineRepository.save(lotLine)


// 更新 inventory // 更新 inventory
val inv = inventoryRepository.findByItemId(lotLine.inventoryLot?.item?.id!!).orElseThrow()
val inv = itemUomService.findInventoryForItemBaseUom(lotLine.inventoryLot?.item?.id!!)
?: throw IllegalStateException("Inventory not found for itemId=${lotLine.inventoryLot?.item?.id}")
inv.apply { inv.apply {
this.onHandQty = (this.onHandQty ?: zero) - baseQty this.onHandQty = (this.onHandQty ?: zero) - baseQty
this.onHoldQty = (this.onHoldQty ?: zero) - baseQty this.onHoldQty = (this.onHoldQty ?: zero) - baseQty
@@ -1273,7 +1275,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {


try { try {
// 1) Bulk load lot lines(下方會用 lotLines 取批次/袋裝等) // 1) Bulk load lot lines(下方會用 lotLines 取批次/袋裝等)
// 注意:勿呼叫 inventoryRepository.findByItemId —— 若歷史資料同一 item 有多筆 inventory,
// 注意:勿呼叫 itemUomService.findInventoryForItemBaseUom —— 若歷史資料同一 item 有多筆 inventory,
// Spring Data 的 Optional 單筆查詢會拋 NonUniqueResultException(「unique result: 2」)。 // Spring Data 的 Optional 單筆查詢會拋 NonUniqueResultException(「unique result: 2」)。
// 先前預載的 inventories 從未使用(save 已註解),已移除以避免批量提交無故失敗。 // 先前預載的 inventories 從未使用(save 已註解),已移除以避免批量提交無故失敗。
val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId } val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId }


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt Просмотреть файл

@@ -1089,7 +1089,7 @@ val basePickOrders = (listOf(pickOrder) + allCompetingPickOrders).distinctBy { i
remainingQty remainingQty
} }
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
val inventory = itemUomService.findInventoryForItemBaseUom(itemId)
if (inventory != null) { if (inventory != null) {
inventory.onHoldQty = onHoldQty inventory.onHoldQty = onHoldQty
inventory.unavailableQty = unavailableQty inventory.unavailableQty = unavailableQty
@@ -1354,7 +1354,7 @@ private fun updateInventoryTableAfterResuggest(pickOrder: PickOrder) {
} }
// Update the inventory table // Update the inventory table
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
val inventory = itemUomService.findInventoryForItemBaseUom(itemId)
if (inventory != null) { if (inventory != null) {
inventory.onHoldQty = onHoldQty inventory.onHoldQty = onHoldQty
inventory.unavailableQty = unavailableQty inventory.unavailableQty = unavailableQty
@@ -1791,7 +1791,7 @@ private fun handleBrokenItem(inventoryLotLine: InventoryLotLine, failQty: BigDec
// 2. 更新库存表的不可用数量 // 2. 更新库存表的不可用数量
val itemId = inventoryLotLine.inventoryLot?.item?.id val itemId = inventoryLotLine.inventoryLot?.item?.id
if (itemId != null) { if (itemId != null) {
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
val inventory = itemUomService.findInventoryForItemBaseUom(itemId)
if (inventory != null) { if (inventory != null) {
val currentUnavailableQty = inventory.unavailableQty ?: BigDecimal.ZERO val currentUnavailableQty = inventory.unavailableQty ?: BigDecimal.ZERO
inventory.unavailableQty = currentUnavailableQty.plus(failQty) inventory.unavailableQty = currentUnavailableQty.plus(failQty)


+ 28
- 0
src/main/resources/db/changelog/changes/20260617_Enson/02_setting.sql Просмотреть файл

@@ -0,0 +1,28 @@
--liquibase formatted sql


-- Add BOM versioning columns
--changeset Enson:20260617-01
ALTER TABLE bom
ADD COLUMN bomKind VARCHAR(100) DEFAULT NULL,
ADD COLUMN revisionNo INT DEFAULT NULL;

-- Backfill bomKind from description for existing rows
--changeset Enson:20260617-02
UPDATE bom
SET bomKind = description
WHERE bomKind IS NULL OR TRIM(bomKind) = '';

-- Backfill revisionNo for existing rows
--changeset Enson:20260617-03
UPDATE bom
SET revisionNo = 1
WHERE revisionNo IS NULL OR revisionNo <= 0;

-- Drop duplicate bomKind column (data lives in description)
--changeset Enson:20260617-04
ALTER TABLE bom DROP COLUMN bomKind;

-- Rename description -> bomKind (single FG/WIP column)
--changeset Enson:20260617-05
ALTER TABLE bom CHANGE COLUMN description bomKind VARCHAR(100) NOT NULL;

+ 10
- 0
src/main/resources/db/changelog/changes/20260622_01_Enson/01_add_putaway_location_code_to_bom.sql Просмотреть файл

@@ -0,0 +1,10 @@
-- liquibase formatted sql
-- changeset Enson:add_putaway_location_code_to_bom

ALTER TABLE `fpsmsdb`.`bom`
ADD COLUMN `putawayLocationCode` VARCHAR(255) NULL AFTER `status`;

UPDATE `fpsmsdb`.`bom` b
INNER JOIN `fpsmsdb`.`items` i ON i.id = b.itemId AND i.deleted = 0
SET b.putawayLocationCode = NULLIF(TRIM(i.LocationCode), '')
WHERE b.deleted = 0;

+ 33
- 0
src/main/resources/db/changelog/changes/20260703_01_Enson/01_drop_bom_material_derived_qty_columns.sql Просмотреть файл

@@ -0,0 +1,33 @@
-- liquibase formatted sql
-- changeset Enson:drop_bom_material_derived_qty_columns
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'bom_material' AND column_name = 'saleQty'

SET @fk_sales_unit := (
SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'bom_material'
AND COLUMN_NAME = 'salesUnitId'
AND REFERENCED_TABLE_NAME IS NOT NULL
LIMIT 1
);
SET @sql_drop_fk := IF(
@fk_sales_unit IS NOT NULL,
CONCAT('ALTER TABLE `bom_material` DROP FOREIGN KEY `', @fk_sales_unit, '`'),
'SELECT 1'
);
PREPARE stmt_drop_fk FROM @sql_drop_fk;
EXECUTE stmt_drop_fk;
DEALLOCATE PREPARE stmt_drop_fk;

ALTER TABLE `bom_material`
DROP COLUMN `saleQty`,
DROP COLUMN `salesUnitId`,
DROP COLUMN `salesUnitCode`,
DROP COLUMN `stockQty`,
DROP COLUMN `stockUnit`,
DROP COLUMN `stockUnitName`,
DROP COLUMN `baseQty`,
DROP COLUMN `baseUnit`,
DROP COLUMN `baseUnitName`;

+ 22
- 0
src/main/resources/db/changelog/changes/20260707_01_Enson/01_backfill_bom_revision_no.sql Просмотреть файл

@@ -0,0 +1,22 @@
--liquibase formatted sql

-- Backfill NULL revisionNo for non-deleted BOM rows (per code + bomKind).
--changeset Enson:20260707-01
UPDATE bom b
INNER JOIN (
SELECT b2.id,
COALESCE((
SELECT MAX(COALESCE(b3.revisionNo, 0))
FROM bom b3
WHERE b3.deleted = 0
AND b3.code = b2.code
AND b3.bomKind = b2.bomKind
), 0) + ROW_NUMBER() OVER (
PARTITION BY b2.code, b2.bomKind
ORDER BY b2.created, b2.id
) AS new_rev
FROM bom b2
WHERE b2.deleted = 0
AND (b2.revisionNo IS NULL OR b2.revisionNo <= 0)
) ranked ON b.id = ranked.id
SET b.revisionNo = ranked.new_rev;

+ 16
- 0
src/main/resources/db/changelog/changes/20260708_01_codex/01_create_index.sql Просмотреть файл

@@ -0,0 +1,16 @@
-- liquibase formatted sql
-- changeset codex:create_ps_query_indexes_20260708

CREATE INDEX idx_bom_kind_status_deleted_itemid ON bom (bomKind, status, deleted, itemId);

-- delivery 平均值子查詢
CREATE INDEX idx_dol_delivery_item ON delivery_order_line (deliveryOrderId, itemId);
CREATE INDEX idx_do_deleted_eta_id ON delivery_order (deleted, estimatedArrivalDate, id);

-- pending job 子查詢
CREATE INDEX idx_job_order_bomid_status ON job_order (bomId, status);
--changeset codex:20260708-01
-- 匯出排程:最新排程與 PO 預聚合
CREATE INDEX idx_ps_produceat_id ON production_schedule (produceAt, id);
CREATE INDEX idx_po_complete_eta ON purchase_order (completeDate, estimatedArrivalDate);
CREATE INDEX idx_pol_item_po ON purchase_order_line (itemId, purchaseOrderId);

Загрузка…
Отмена
Сохранить