Explorar el Código

DrinkProductionQty 支援 actual/planned、真實產量不 fallback reqQty,並回傳工序/QC/上架人員;Item Lot Trace 補 JO→生產步驟連線、提料單行節點與 FG UOM

fix負數倉
tommy hace 1 mes
padre
commit
a4aaefd1f7
Se han modificado 13 ficheros con 550 adiciones y 67 borrados
  1. +15
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt
  2. +212
    -22
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  3. +9
    -2
      src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt
  4. +25
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt
  5. +2
    -0
      src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultRepository.kt
  6. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  7. +17
    -3
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt
  8. +89
    -8
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt
  9. +109
    -9
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt
  10. +17
    -10
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt
  11. +7
    -4
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt
  12. +29
    -6
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt
  13. +17
    -3
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt

+ 15
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt Ver fichero

@@ -7,6 +7,7 @@ import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.util.* import java.util.*
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime


interface JobOrderProcessAggregate { interface JobOrderProcessAggregate {
val jobOrderId: Long val jobOrderId: Long
@@ -27,6 +28,20 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe
fun findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds: List<Long>): List<ProductProcess> fun findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds: List<Long>): List<ProductProcess>
fun findByJobOrder_IdInAndDeletedIsFalseOrderByDateDescProductionPriorityAsc(jobOrderIds: Collection<Long>): List<ProductProcess> fun findByJobOrder_IdInAndDeletedIsFalseOrderByDateDescProductionPriorityAsc(jobOrderIds: Collection<Long>): List<ProductProcess>


@Query(
"""
SELECT p FROM ProductProcess p
WHERE p.deleted = false
AND p.startTime IS NOT NULL
AND p.startTime >= :startFrom
AND p.startTime < :startToExclusive
"""
)
fun findByDeletedFalseAndStartTimeFromBeforeExclusive(
@Param("startFrom") startFrom: LocalDateTime,
@Param("startToExclusive") startToExclusive: LocalDateTime,
): List<ProductProcess>

@Query( @Query(
""" """
SELECT SELECT


+ 212
- 22
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt Ver fichero

@@ -54,6 +54,7 @@ import java.math.RoundingMode
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.qc.entity.QcResultRepository
import org.springframework.dao.DataIntegrityViolationException import org.springframework.dao.DataIntegrityViolationException
@Service @Service
@Transactional @Transactional
@@ -81,7 +82,8 @@ open class ProductProcessService(
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 qcResultRepository: QcResultRepository,
) { ) {


open fun findAll(pageable: Pageable): Page<ProductProcess> { open fun findAll(pageable: Pageable): Page<ProductProcess> {
@@ -2629,21 +2631,19 @@ open class ProductProcessService(
} }


// ===== Drink Production Qty Dashboard ===== // ===== Drink Production Qty Dashboard =====
private companion object {
const val DRINK_PLANNED_LOOKBACK_DAYS = 30L
}

/** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */ /** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */
open fun getDrinkProductionQty(date: LocalDate?): List<DrinkProductionQtyResponse> {
open fun getDrinkProductionQty(
date: LocalDate?,
view: String = "actual",
): List<DrinkProductionQtyResponse> {
val targetDate = date ?: LocalDate.now() val targetDate = date ?: LocalDate.now()
val startOfDay = targetDate.atStartOfDay() val startOfDay = targetDate.atStartOfDay()
val endOfDayExclusive = targetDate.plusDays(1).atStartOfDay() val endOfDayExclusive = targetDate.plusDays(1).atStartOfDay()

// 依「生產日」彙總:與既有 dashboard 的用法一致,採半開區間 [startOfDay, endExclusive)
val jobOrders = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(
planStartFrom = startOfDay,
planStartToExclusive = endOfDayExclusive,
)

if (jobOrders.isEmpty()) {
return emptyList()
}
val isPlannedView = view.equals("planned", ignoreCase = true)


data class GroupKey( data class GroupKey(
val itemCode: String?, val itemCode: String?,
@@ -2659,11 +2659,71 @@ open class ProductProcessService(
val jobOrderId: Long?, val jobOrderId: Long?,
val jobOrderCode: String?, val jobOrderCode: String?,
val productionDate: LocalDate?, val productionDate: LocalDate?,
val jobOrderStatus: String?,
val startTime: LocalDateTime?,
val assumeTimeNeedMins: Int,
val assumeEndTime: LocalDateTime?,
val actualEndTime: LocalDateTime?,
val latestStartBy: LocalDateTime?,
val processOperators: String?,
val processHandlers: String?,
val qcUsers: String?,
val putAwayUsers: String?,
val processSteps: List<DrinkProductionQtyProcessStep>,
) )


val drinkOrders = jobOrders
val candidateJobOrders = if (isPlannedView) {
val lookbackStart = targetDate.minusDays(DRINK_PLANNED_LOOKBACK_DAYS).atStartOfDay()
val byPlanStart = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(
planStartFrom = lookbackStart,
planStartToExclusive = endOfDayExclusive,
)
val processesInWindow = productProcessRepository.findByDeletedFalseAndStartTimeFromBeforeExclusive(
startFrom = lookbackStart,
startToExclusive = endOfDayExclusive,
)
val joIdsFromProcess = processesInWindow.mapNotNull { it.jobOrder?.id }.distinct()
val byProcessStart = if (joIdsFromProcess.isNotEmpty()) {
jobOrderRepository.findAllById(joIdsFromProcess).filter { it.deleted != true }
} else {
emptyList()
}
(byPlanStart + byProcessStart)
.filter { it.id != null }
.associateBy { it.id!! }
.values
.toList()
} else {
val byPlanStart = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(
planStartFrom = startOfDay,
planStartToExclusive = endOfDayExclusive,
)
val processesStartedToday = productProcessRepository.findByDeletedFalseAndStartTimeFromBeforeExclusive(
startFrom = startOfDay,
startToExclusive = endOfDayExclusive,
)
val joIdsFromProcess = processesStartedToday.mapNotNull { it.jobOrder?.id }.distinct()
val byProcessStart = if (joIdsFromProcess.isNotEmpty()) {
jobOrderRepository.findAllById(joIdsFromProcess).filter { it.deleted != true }
} else {
emptyList()
}
(byPlanStart + byProcessStart)
.filter { it.id != null }
.associateBy { it.id!! }
.values
.toList()
}

if (candidateJobOrders.isEmpty()) {
return emptyList()
}

val drinkOrders = candidateJobOrders
.filter { it.bom?.isDrink == true } .filter { it.bom?.isDrink == true }
.filter { it.status != JobOrderStatus.PLANNING }
.filter { jo ->
isPlannedView || jo.status != JobOrderStatus.PLANNING
}


if (drinkOrders.isEmpty()) { if (drinkOrders.isEmpty()) {
return emptyList() return emptyList()
@@ -2671,6 +2731,15 @@ open class ProductProcessService(


val jobOrderIds = drinkOrders.mapNotNull { it.id } val jobOrderIds = drinkOrders.mapNotNull { it.id }
val itemIds = drinkOrders.mapNotNull { it.bom?.item?.id }.distinct() val itemIds = drinkOrders.mapNotNull { it.bom?.item?.id }.distinct()
val bomIds = drinkOrders.mapNotNull { it.bom?.id }.distinct()

val bomDurationMinsByBomId: Map<Long, Int> = if (bomIds.isNotEmpty()) {
bomProcessRepository.sumDurationByBomIds(bomIds).associate { agg ->
agg.bomId to agg.totalMinutes.toInt().coerceAtLeast(0)
}
} else {
emptyMap()
}


val processesByJobOrderId = productProcessRepository val processesByJobOrderId = productProcessRepository
.findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds) .findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds)
@@ -2685,9 +2754,34 @@ open class ProductProcessService(
emptyMap() emptyMap()
} }


val stockInLineByJobOrderId = if (jobOrderIds.isNotEmpty()) {
val stockInLinesByJobOrderId = if (jobOrderIds.isNotEmpty()) {
stockInLineRepository.findAllByJobOrder_IdInAndDeletedFalse(jobOrderIds) stockInLineRepository.findAllByJobOrder_IdInAndDeletedFalse(jobOrderIds)
.associateBy { it.jobOrder?.id }
.groupBy { it.jobOrder?.id }
} else {
emptyMap()
}

val allStockInLines = stockInLinesByJobOrderId.values.flatten()
val stockInLineIds = allStockInLines.mapNotNull { it.id }
val qcUsersByStockInLineId: Map<Long, List<String>> = if (stockInLineIds.isNotEmpty()) {
qcResultRepository.findByStockInLine_IdInAndDeletedFalse(stockInLineIds)
.groupBy { it.stockInLine?.id ?: 0L }
.mapValues { (_, results) ->
results.mapNotNull { it.createdBy?.trim()?.takeIf { n -> n.isNotEmpty() } }
.distinct()
}
} else {
emptyMap()
}

val inventoryLotIds = allStockInLines.mapNotNull { it.inventoryLot?.id }.distinct()
val putAwayUsersByLotId: Map<Long, List<String>> = if (inventoryLotIds.isNotEmpty()) {
inventoryLotLineRepository.findByInventoryLot_IdInAndDeletedFalse(inventoryLotIds)
.groupBy { it.inventoryLot?.id ?: 0L }
.mapValues { (_, ills) ->
ills.mapNotNull { it.createdBy?.trim()?.takeIf { n -> n.isNotEmpty() } }
.distinct()
}
} else { } else {
emptyMap() emptyMap()
} }
@@ -2712,25 +2806,111 @@ open class ProductProcessService(
uomById[iu.uom?.id]?.udfudesc uomById[iu.uom?.id]?.udfudesc
} }


val drinkRows = drinkOrders.map { jo ->
fun joinNames(names: Collection<String>): String? =
names.map { it.trim() }.filter { it.isNotEmpty() }.distinct()
.takeIf { it.isNotEmpty() }?.joinToString("; ")

val drinkRows = drinkOrders.mapNotNull { jo ->
val bom = jo.bom val bom = jo.bom
val bomId = bom?.id
val itemId = bom?.item?.id val itemId = bom?.item?.id
val process = processesByJobOrderId[jo.id] val process = processesByJobOrderId[jo.id]
val lines = process?.id?.let { linesByProcessId[it].orEmpty() }.orEmpty() val lines = process?.id?.let { linesByProcessId[it].orEmpty() }.orEmpty()
val stockInLine = stockInLineByJobOrderId[jo.id]
val sils = stockInLinesByJobOrderId[jo.id].orEmpty()
val stockInLine = sils.maxByOrNull { it.acceptedQty ?: BigDecimal.ZERO }
val startTime = process?.startTime
val lineProcessingMins = lines.sumOf { it.processingTime ?: 0 }
val bomDurationMins = bomId?.let { bomDurationMinsByBomId[it] } ?: 0

val assumeTimeNeedMins = if (isPlannedView) {
if (bomDurationMins > 0) bomDurationMins else lineProcessingMins
} else {
if (lineProcessingMins > 0) lineProcessingMins else bomDurationMins
}

val assumeEndTime =
startTime?.plusMinutes(assumeTimeNeedMins.toLong())

if (isPlannedView) {
val completionDate = when {
assumeEndTime != null -> assumeEndTime.toLocalDate()
jo.planStart != null && assumeTimeNeedMins > 0 ->
jo.planStart!!.plusMinutes(assumeTimeNeedMins.toLong()).toLocalDate()
jo.planStart != null -> jo.planStart!!.toLocalDate()
else -> null
}
if (completionDate == null || completionDate != targetDate) {
return@mapNotNull null
}
}

val latestStartBy = if (isPlannedView && startTime == null && assumeTimeNeedMins > 0) {
endOfDayExclusive.minusMinutes(assumeTimeNeedMins.toLong())
} else {
null
}

val processOperators = joinNames(lines.mapNotNull { it.operator?.name })
val processHandlers = joinNames(lines.mapNotNull { it.handler?.name })
val qcUsers = joinNames(
sils.flatMap { sil ->
val id = sil.id ?: return@flatMap emptyList()
qcUsersByStockInLineId[id].orEmpty()
},
)
val putAwayUsers = joinNames(
sils.flatMap { sil ->
val fromLot = sil.inventoryLot?.id?.let { putAwayUsersByLotId[it] }.orEmpty()
val fromIll = listOfNotNull(sil.inventoryLotLine?.createdBy)
val fromSil = listOfNotNull(sil.createdBy, sil.user?.name)
fromLot + fromIll + fromSil
},
)
val processSteps = lines
.sortedBy { it.seqNo ?: 0L }
.map { line ->
DrinkProductionQtyProcessStep(
jobOrderId = jo.id ?: 0L,
jobOrderCode = jo.code,
itemCode = bom?.item?.code,
itemName = bom?.item?.name,
seqNo = line.seqNo,
processName = line.name,
operatorName = line.operator?.name,
handlerName = line.handler?.name,
startTime = line.startTime,
endTime = line.endTime,
status = line.status,
)
}


DrinkOrderRow( DrinkOrderRow(
itemCode = bom?.item?.code, itemCode = bom?.item?.code,
itemName = bom?.item?.name, itemName = bom?.item?.name,
uom = resolveDrinkProductionUom(bom, itemId, uomDescByItemId), uom = resolveDrinkProductionUom(bom, itemId, uomDescByItemId),
reqQty = jo.reqQty ?: BigDecimal.ZERO, reqQty = jo.reqQty ?: BigDecimal.ZERO,
productionQty = resolveDrinkProductionQty(jo, lines, stockInLine),
productionQty = resolveDrinkProductionQty(lines, stockInLine),
jobOrderId = jo.id, jobOrderId = jo.id,
jobOrderCode = jo.code, jobOrderCode = jo.code,
productionDate = jo.planStart?.toLocalDate(), productionDate = jo.planStart?.toLocalDate(),
jobOrderStatus = jo.status?.value,
startTime = startTime,
assumeTimeNeedMins = assumeTimeNeedMins,
assumeEndTime = assumeEndTime,
actualEndTime = process?.endTime,
latestStartBy = latestStartBy,
processOperators = processOperators,
processHandlers = processHandlers,
qcUsers = qcUsers,
putAwayUsers = putAwayUsers,
processSteps = processSteps,
) )
} }


if (drinkRows.isEmpty()) {
return emptyList()
}

return drinkRows return drinkRows
.groupBy { GroupKey(it.itemCode, it.itemName) } .groupBy { GroupKey(it.itemCode, it.itemName) }
.map { (key, rows) -> .map { (key, rows) ->
@@ -2751,6 +2931,17 @@ open class ProductProcessService(
productionDate = row.productionDate, productionDate = row.productionDate,
reqQty = row.reqQty, reqQty = row.reqQty,
productionQty = row.productionQty, productionQty = row.productionQty,
jobOrderStatus = row.jobOrderStatus,
startTime = row.startTime,
assumeTimeNeedMins = row.assumeTimeNeedMins,
assumeEndTime = row.assumeEndTime,
actualEndTime = row.actualEndTime,
latestStartBy = row.latestStartBy,
processOperators = row.processOperators,
processHandlers = row.processHandlers,
qcUsers = row.qcUsers,
putAwayUsers = row.putAwayUsers,
processSteps = row.processSteps,
) )
}.sortedBy { it.jobOrderCode ?: "" }, }.sortedBy { it.jobOrderCode ?: "" },
) )
@@ -2772,9 +2963,8 @@ open class ProductProcessService(
return null return null
} }


/** 實際生產數量:優先工序產出 → QC 入庫接受量 → 工單 reqQty(當日計劃生產量) */
/** 實際生產數量:優先工序產出 → QC 入庫接受量;皆無則 0(不 fallback 到 reqQty) */
private fun resolveDrinkProductionQty( private fun resolveDrinkProductionQty(
jobOrder: com.ffii.fpsms.modules.jobOrder.entity.JobOrder,
lines: List<ProductProcessLine>, lines: List<ProductProcessLine>,
stockInLine: com.ffii.fpsms.modules.stock.entity.StockInLine?, stockInLine: com.ffii.fpsms.modules.stock.entity.StockInLine?,
): BigDecimal { ): BigDecimal {
@@ -2792,7 +2982,7 @@ open class ProductProcessService(
return acceptedQty return acceptedQty
} }


return jobOrder.reqQty ?: BigDecimal.ZERO
return BigDecimal.ZERO
} }


// ===== Operator KPI Dashboard ===== // ===== Operator KPI Dashboard =====


+ 9
- 2
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt Ver fichero

@@ -320,10 +320,17 @@ class ProductProcessController(


/** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */ /** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */
@GetMapping("/Demo/DrinkProductionQty") @GetMapping("/Demo/DrinkProductionQty")
fun getDrinkProductionQty(@RequestParam(required = false) date: String?): List<DrinkProductionQtyResponse> {
fun getDrinkProductionQty(
@RequestParam(required = false) date: String?,
@RequestParam(required = false, defaultValue = "actual") view: String?,
): List<DrinkProductionQtyResponse> {
val parsedDate = date?.takeIf { it.isNotBlank() }?.let { val parsedDate = date?.takeIf { it.isNotBlank() }?.let {
LocalDate.parse(it, DateTimeFormatter.ISO_DATE) LocalDate.parse(it, DateTimeFormatter.ISO_DATE)
} }
return productProcessService.getDrinkProductionQty(parsedDate)
val viewMode = when (view?.trim()?.lowercase()) {
"planned" -> "planned"
else -> "actual"
}
return productProcessService.getDrinkProductionQty(parsedDate, viewMode)
} }
} }

+ 25
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt Ver fichero

@@ -359,6 +359,20 @@ data class EquipmentStatusByTypeResponse(
) )


// ===== Drink Production Qty Dashboard ===== // ===== Drink Production Qty Dashboard =====
data class DrinkProductionQtyProcessStep(
val jobOrderId: Long,
val jobOrderCode: String?,
val itemCode: String?,
val itemName: String?,
val seqNo: Long?,
val processName: String?,
val operatorName: String?,
val handlerName: String?,
val startTime: LocalDateTime?,
val endTime: LocalDateTime?,
val status: String?,
)

data class DrinkProductionQtyJobOrderDetail( data class DrinkProductionQtyJobOrderDetail(
val jobOrderId: Long, val jobOrderId: Long,
val jobOrderCode: String?, val jobOrderCode: String?,
@@ -366,6 +380,17 @@ data class DrinkProductionQtyJobOrderDetail(
val productionDate: LocalDate?, val productionDate: LocalDate?,
val reqQty: BigDecimal, val reqQty: BigDecimal,
val productionQty: BigDecimal, val productionQty: BigDecimal,
val jobOrderStatus: String? = null,
val startTime: LocalDateTime? = null,
val assumeTimeNeedMins: Int = 0,
val assumeEndTime: LocalDateTime? = null,
val actualEndTime: LocalDateTime? = null,
val latestStartBy: LocalDateTime? = null,
val processOperators: String? = null,
val processHandlers: String? = null,
val qcUsers: String? = null,
val putAwayUsers: String? = null,
val processSteps: List<DrinkProductionQtyProcessStep> = emptyList(),
) )


data class DrinkProductionQtyResponse( data class DrinkProductionQtyResponse(


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultRepository.kt Ver fichero

@@ -12,6 +12,8 @@ interface QcResultRepository: AbstractRepository<QcResult, Long> {
fun findQcResultInfoByStockInLineIdAndDeletedFalse(stockInLineId: Long): List<QcResultInfo> fun findQcResultInfoByStockInLineIdAndDeletedFalse(stockInLineId: Long): List<QcResultInfo>
fun findQcResultInfoByStockOutLineIdAndDeletedFalse(stockOutLineId: Long): List<QcResultInfo> fun findQcResultInfoByStockOutLineIdAndDeletedFalse(stockOutLineId: Long): List<QcResultInfo>


fun findByStockInLine_IdInAndDeletedFalse(stockInLineIds: Collection<Long>): List<QcResult>

@Query( @Query(
""" """
SELECT DISTINCT qr.stockInLine.id SELECT DISTINCT qr.stockInLine.id


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Ver fichero

@@ -118,6 +118,8 @@ WHERE ill.id = :id


fun findAllByInventoryLotId(id: Serializable): List<InventoryLotLine> fun findAllByInventoryLotId(id: Serializable): List<InventoryLotLine>


fun findByInventoryLot_IdInAndDeletedFalse(inventoryLotIds: Collection<Long>): List<InventoryLotLine>

fun findByInventoryLotStockInLineIdAndWarehouseId(inventoryLotStockInLineId: Long, warehouseId: Long): InventoryLotLine? fun findByInventoryLotStockInLineIdAndWarehouseId(inventoryLotStockInLineId: Long, warehouseId: Long): InventoryLotLine?


fun findAllByInventoryLotItemIdAndStatus(itemId: Long, status: String): List<InventoryLotLine> fun findAllByInventoryLotItemIdAndStatus(itemId: Long, status: String): List<InventoryLotLine>


+ 17
- 3
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt Ver fichero

@@ -122,7 +122,7 @@ open class TraceBomLoader(
COALESCE(fg_il.lotNo, fg_sil.lotNo, '') AS finishedLotNo, COALESCE(fg_il.lotNo, fg_sil.lotNo, '') AS finishedLotNo,
fg_sil.id AS finishedStockInLineId, fg_sil.id AS finishedStockInLineId,
COALESCE(fg_sil.acceptedQty, 0) AS fgQty, COALESCE(fg_sil.acceptedQty, 0) AS fgQty,
COALESCE(fg_uc.udfudesc, '') AS fgUom,
COALESCE(fg_uc_tx.udfudesc, fg_uc_fb.udfudesc, '') AS fgUom,
COALESCE(sol.qty, 0) AS materialQtyUsed COALESCE(sol.qty, 0) AS materialQtyUsed
FROM stock_out_line sol FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
@@ -136,8 +136,22 @@ open class TraceBomLoader(
LEFT JOIN inventory_lot fg_il ON fg_sil.inventoryLotId = fg_il.id AND fg_il.deleted = 0 LEFT JOIN inventory_lot fg_il ON fg_sil.inventoryLotId = fg_il.id AND fg_il.deleted = 0
LEFT JOIN items fg_stock_it ON fg_il.itemId = fg_stock_it.id AND fg_stock_it.deleted = 0 LEFT JOIN items fg_stock_it ON fg_il.itemId = fg_stock_it.id AND fg_stock_it.deleted = 0
LEFT JOIN items fg_item ON fg_item.id = COALESCE(fg_stock_it.id, fg_bom_it.id) LEFT JOIN items fg_item ON fg_item.id = COALESCE(fg_stock_it.id, fg_bom_it.id)
LEFT JOIN item_uom fg_iu ON fg_item.id = fg_iu.itemId AND fg_iu.stockUnit = 1 AND fg_iu.deleted = 0
LEFT JOIN uom_conversion fg_uc ON fg_iu.uomId = fg_uc.id AND fg_uc.deleted = 0
LEFT JOIN inventory_lot_line fg_ill ON fg_ill.id = COALESCE(
fg_sil.inventoryLotLineId,
(
SELECT ill2.id
FROM inventory_lot_line ill2
WHERE ill2.inventoryLotId = fg_il.id
AND ill2.deleted = 0
AND ill2.stockItemUomId IS NOT NULL
ORDER BY ill2.id ASC
LIMIT 1
)
)
LEFT JOIN item_uom fg_iu_tx ON fg_iu_tx.id = fg_ill.stockItemUomId AND fg_iu_tx.deleted = 0
LEFT JOIN uom_conversion fg_uc_tx ON fg_uc_tx.id = fg_iu_tx.uomId AND fg_uc_tx.deleted = 0
LEFT JOIN item_uom fg_iu_fb ON fg_item.id = fg_iu_fb.itemId AND fg_iu_fb.stockUnit = 1 AND fg_iu_fb.deleted = 0
LEFT JOIN uom_conversion fg_uc_fb ON fg_iu_fb.uomId = fg_uc_fb.id AND fg_uc_fb.deleted = 0
WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds)
ORDER BY jobOrderCode, finishedLotNo ORDER BY jobOrderCode, finishedLotNo
""".trimIndent() """.trimIndent()


+ 89
- 8
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt Ver fichero

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.stock.trace package com.ffii.fpsms.modules.stock.trace


import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPickLine
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPrelude import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPrelude
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMaterialInput import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMaterialInput
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult
@@ -389,11 +390,42 @@ internal object TraceGraphNodeBuilder {
val ctx = PreludeBuildContext() val ctx = PreludeBuildContext()
val lotPicks = buildLotPickOrderMap(joPrelude.materialInputs) val lotPicks = buildLotPickOrderMap(joPrelude.materialInputs)
val chainNodes = buildMaterialInputChainNodes(joPrelude.materialInputs, lotPicks, ctx) val chainNodes = buildMaterialInputChainNodes(joPrelude.materialInputs, lotPicks, ctx)
val pickNodes =
val coveredItems = coveredPickItemCodes(joPrelude.materialInputs)
val lotPickNodes =
joPrelude.materialInputs.mapIndexed { i, m -> joPrelude.materialInputs.mapIndexed { i, m ->
createMaterialPickNode(m, i, ctx) createMaterialPickNode(m, i, ctx)
} }
return (chainNodes + pickNodes).sortedWith(compareBy<TraceGraphNode> { it.sortKey }.thenBy { it.id })
val linePickNodes = mutableListOf<TraceGraphNode>()
joPrelude.pickOrders.forEach { po ->
po.lines.forEachIndexed { li, line ->
val code = line.itemCode.trim().uppercase()
if (code.isEmpty() || !coveredItems.add(code)) return@forEachIndexed
linePickNodes +=
createMaterialPickNodeFromPickLine(
line = line,
index = li,
ctx = ctx,
pickOrderCode = po.pickOrderCode,
pickOrderId = po.pickOrderId,
consoCode = po.consoCode,
jobOrderCode = joPrelude.jobOrder.jobOrderCode,
timestamp = po.releasedDate ?: po.targetDate ?: joPrelude.jobOrder.createdAt,
feedsProductionScopeLotNo = null,
)
}
}
return (chainNodes + lotPickNodes + linePickNodes).sortedWith(
compareBy<TraceGraphNode> { it.sortKey }.thenBy { it.id },
)
}

private fun coveredPickItemCodes(materialInputs: List<ItemLotTraceMaterialInput>): MutableSet<String> {
val set = mutableSetOf<String>()
materialInputs.forEach { m ->
val code = m.materialItemCode.trim().uppercase()
if (code.isNotEmpty()) set += code
}
return set
} }


private fun buildLotPickOrderMap( private fun buildLotPickOrderMap(
@@ -447,13 +479,38 @@ internal object TraceGraphNodeBuilder {


if (joProduced && m.nestedJoPrelude != null) { if (joProduced && m.nestedJoPrelude != null) {
buildNestedJoCreatedNode(m, ctx)?.let { nodes += it } buildNestedJoCreatedNode(m, ctx)?.let { nodes += it }
}


if (joProduced && nestedInputs.isNotEmpty()) {
val nestedLotPicks = buildLotPickOrderMap(nestedInputs)
nodes += buildMaterialInputChainNodes(nestedInputs, nestedLotPicks, ctx)
nestedInputs.forEachIndexed { ni, nm ->
nodes += createMaterialPickNode(nm, ni, ctx, m.materialLotNo)
val nestedPrelude = m.nestedJoPrelude!!
if (nestedInputs.isNotEmpty()) {
val nestedLotPicks = buildLotPickOrderMap(nestedInputs)
nodes += buildMaterialInputChainNodes(nestedInputs, nestedLotPicks, ctx)
nestedInputs.forEachIndexed { ni, nm ->
nodes += createMaterialPickNode(nm, ni, ctx, m.materialLotNo)
}
}

// Always show nested JO pick-order lines (even with no stock-outs yet).
val nestedCovered = coveredPickItemCodes(nestedInputs)
nestedPrelude.pickOrders.forEach { po ->
po.lines.forEachIndexed { li, line ->
val code = line.itemCode.trim().uppercase()
if (code.isEmpty() || !nestedCovered.add(code)) return@forEachIndexed
nodes +=
createMaterialPickNodeFromPickLine(
line = line,
index = li,
ctx = ctx,
pickOrderCode = po.pickOrderCode,
pickOrderId = po.pickOrderId,
consoCode = po.consoCode,
jobOrderCode = nestedPrelude.jobOrder.jobOrderCode,
timestamp =
po.releasedDate
?: po.targetDate
?: nestedPrelude.jobOrder.createdAt,
feedsProductionScopeLotNo = m.materialLotNo,
)
}
} }
} }


@@ -635,6 +692,30 @@ internal object TraceGraphNodeBuilder {
feedsProductionScopeLotNo = feedsProductionScopeLotNo, feedsProductionScopeLotNo = feedsProductionScopeLotNo,
) )


private fun createMaterialPickNodeFromPickLine(
line: ItemLotTraceJoPickLine,
index: Int,
ctx: PreludeBuildContext,
pickOrderCode: String,
pickOrderId: Long?,
consoCode: String,
jobOrderCode: String,
timestamp: String?,
feedsProductionScopeLotNo: String?,
): TraceGraphNode =
TraceGraphNode(
id = "mat-pick-line-$index-$pickOrderId-${line.pickOrderLineId}",
kind = "MATERIAL_PICK",
timestamp = timestamp,
sortKey = parseSortKey(timestamp, ctx.seq++),
refCode = pickOrderCode,
refId = pickOrderId,
traceItemCode = line.itemCode,
consoCode = consoCode,
jobOrderCode = jobOrderCode.trim().ifEmpty { null },
feedsProductionScopeLotNo = feedsProductionScopeLotNo,
)

private fun isJoProducedMaterial(m: ItemLotTraceMaterialInput): Boolean = private fun isJoProducedMaterial(m: ItemLotTraceMaterialInput): Boolean =
m.productionSteps.isNotEmpty() || m.productionSteps.isNotEmpty() ||
m.nestedJoPrelude != null || m.nestedJoPrelude != null ||


+ 109
- 9
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt Ver fichero

@@ -428,14 +428,22 @@ internal object TraceGraphEdgeSemantics {
to: TraceGraphLayoutNode, to: TraceGraphLayoutNode,
): Boolean { ): Boolean {
if (from.kind == "JO_CREATED" && !isMaterialPreludeNode(from)) { if (from.kind == "JO_CREATED" && !isMaterialPreludeNode(from)) {
if (to.kind == "MATERIAL_PICK" || to.kind == "PICK_GROUP") return false
if (
to.kind == "MATERIAL_PICK" ||
to.kind == "PICK_GROUP" ||
to.kind == "PRODUCTION_STEP"
) {
return false
}
return true return true
} }
// Allow pick/JO_CREATED → PRODUCTION_STEP (dedicated builders).
if ( if (
to.kind == "PRODUCTION_STEP" && to.kind == "PRODUCTION_STEP" &&
from.kind != "MATERIAL_PICK" && from.kind != "MATERIAL_PICK" &&
from.kind != "PICK_GROUP" && from.kind != "PICK_GROUP" &&
from.kind != "PRODUCTION_STEP"
from.kind != "PRODUCTION_STEP" &&
from.kind != "JO_CREATED"
) { ) {
return true return true
} }
@@ -574,9 +582,9 @@ internal object TraceGraphEdgeSemantics {
val pairs = mutableListOf<ItemLotTraceGraphEdge>() val pairs = mutableListOf<ItemLotTraceGraphEdge>()
val seen = mutableSetOf<String>() val seen = mutableSetOf<String>()


fun add(from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) {
fun add(from: TraceGraphLayoutNode, to: TraceGraphLayoutNode, bypassSkip: Boolean = false) {
if (NO_FLOW_EDGE_KINDS.contains(from.kind) || NO_FLOW_EDGE_KINDS.contains(to.kind)) return if (NO_FLOW_EDGE_KINDS.contains(from.kind) || NO_FLOW_EDGE_KINDS.contains(to.kind)) return
if (shouldSkipTraceFlowEdge(from, to)) return
if (!bypassSkip && shouldSkipTraceFlowEdge(from, to)) return
val key = "${from.id}->${to.id}" val key = "${from.id}->${to.id}"
if (!seen.add(key)) return if (!seen.add(key)) return
pairs += ItemLotTraceGraphEdge(fromKey = from.id, toKey = to.id) pairs += ItemLotTraceGraphEdge(fromKey = from.id, toKey = to.id)
@@ -614,6 +622,8 @@ internal object TraceGraphEdgeSemantics {
val toList = nodesInPhase(dayNodes, phasesPresent[i + 1]) val toList = nodesInPhase(dayNodes, phasesPresent[i + 1])
if (fromList.isEmpty() || toList.isEmpty()) continue if (fromList.isEmpty() || toList.isEmpty()) continue
if (isMaterialPreludeNode(fromList.last()) || isMaterialPreludeNode(toList.first())) continue if (isMaterialPreludeNode(fromList.last()) || isMaterialPreludeNode(toList.first())) continue
// Dedicated builder owns JO_CREATED → PRODUCTION_STEP.
if (fromList.last().kind == "JO_CREATED" && toList.first().kind == "PRODUCTION_STEP") continue
// STOCK_TAKE now precedes OUTBOUND in phase order; edge flows naturally // STOCK_TAKE now precedes OUTBOUND in phase order; edge flows naturally
if (phasesPresent[i] == "PUTAWAY" && phasesPresent[i + 1] == "WAREHOUSE") { if (phasesPresent[i] == "PUTAWAY" && phasesPresent[i + 1] == "WAREHOUSE") {
connectPutawayToWarehousePhase(fromList, toList, ::add) connectPutawayToWarehousePhase(fromList, toList, ::add)
@@ -627,6 +637,7 @@ internal object TraceGraphEdgeSemantics {
buildProductionStepChainEdges(nodes, ::add) buildProductionStepChainEdges(nodes, ::add)
buildProductionToFgQcEdges(nodes, ::add) buildProductionToFgQcEdges(nodes, ::add)
buildJoCreatedToMaterialPickEdges(nodes, ::add) buildJoCreatedToMaterialPickEdges(nodes, ::add)
buildJoCreatedToProductionStepEdges(nodes, ::add)
buildReplenishmentToDoOutEdges(nodes, ::add) buildReplenishmentToDoOutEdges(nodes, ::add)
buildMaterialPickToProductionStepEdges(nodes, ::add) buildMaterialPickToProductionStepEdges(nodes, ::add)
buildProductionStepLossEdges(nodes, ::add) buildProductionStepLossEdges(nodes, ::add)
@@ -651,6 +662,9 @@ internal object TraceGraphEdgeSemantics {


nodes.forEach { n -> nodes.forEach { n ->
if (!isMaterialPreludeNode(n)) return@forEach if (!isMaterialPreludeNode(n)) return@forEach
// JO_CREATED sits in MATERIAL_PICK for layout; its early planStart would pull that
// phase before PUTAWAY and break 已上架 → 工單提料. Dedicated builder handles JO→提料.
if (n.kind == "JO_CREATED") return@forEach
val key = materialLotKey(n) ?: return@forEach val key = materialLotKey(n) ?: return@forEach
byLot.getOrPut(key) { mutableListOf() } += n byLot.getOrPut(key) { mutableListOf() } += n
} }
@@ -678,11 +692,14 @@ internal object TraceGraphEdgeSemantics {
if (toPhase == "MATERIAL_PICK") { if (toPhase == "MATERIAL_PICK") {
val upstream = fromList.last() val upstream = fromList.last()
val seenPickTargets = mutableSetOf<String>() val seenPickTargets = mutableSetOf<String>()
toList.forEach { pick ->
val target = resolvePickFlowTarget(pick, nodes)
if (!seenPickTargets.add(target.id)) return@forEach
add(upstream, target)
}
toList
.filter { it.kind == "MATERIAL_PICK" || it.kind == "PICK_GROUP" }
.forEach { pick ->
val target = resolvePickFlowTarget(pick, nodes)
if (seenPickTargets.add(target.id)) {
add(upstream, target)
}
}
} else { } else {
add(fromList.last(), toList.first()) add(fromList.last(), toList.first())
} }
@@ -1012,6 +1029,89 @@ internal object TraceGraphEdgeSemantics {
} }
} }


/** When a JO has no 提料 that reaches 生產步驟, link 工單建立 → first 生產步驟 in scope. */
private fun buildJoCreatedToProductionStepEdges(
nodes: List<TraceGraphLayoutNode>,
add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
) {
val joCreatedNodes = nodes.filter { it.kind == "JO_CREATED" }
val prodSteps = nodes.filter { it.kind == "PRODUCTION_STEP" && !isDoGroupChild(it) }
if (joCreatedNodes.isEmpty() || prodSteps.isEmpty()) return

val pickTargets =
nodes.filter {
!isDoGroupChild(it) &&
(it.kind == "PICK_GROUP" || (it.kind == "MATERIAL_PICK" && it.doGroupId.isNullOrBlank()))
}

val productionKeys =
prodSteps
.mapNotNull { n ->
val bomId = n.bomProcessId ?: return@mapNotNull null
"${productionScopeKey(n)}::$bomId"
}
.toSet()

fun feedLotForTarget(target: TraceGraphLayoutNode): String =
when (target.kind) {
"PICK_GROUP" ->
nodes
.firstOrNull {
it.doGroupId == target.id && !it.feedsProductionScopeLotNo.isNullOrBlank()
}
?.feedsProductionScopeLotNo
?.trim()
.orEmpty()
else -> target.feedsProductionScopeLotNo?.trim().orEmpty()
}

fun materialPicksForTarget(target: TraceGraphLayoutNode): List<TraceGraphLayoutNode> =
if (target.kind == "PICK_GROUP") {
nodes.filter { it.doGroupId == target.id && it.kind == "MATERIAL_PICK" }
} else {
listOf(target)
}

fun joHasPickReachingProduction(jo: TraceGraphLayoutNode): Boolean {
val joCode = jo.refCode?.trim()?.ifEmpty { null } ?: return false
val joLot = jo.traceLotNo?.trim().orEmpty()
val scope = productionScopeKey(jo)
return pickTargets.any { target ->
if (joCodeForPickTarget(target, nodes) != joCode) return@any false
val feedLot = feedLotForTarget(target)
if (joLot.isNotEmpty()) {
if (feedLot != joLot) return@any false
} else if (feedLot.isNotEmpty()) {
return@any false
}
materialPicksForTarget(target).any { pick ->
val bomId = pick.bomProcessId ?: return@any false
productionKeys.contains("$scope::$bomId")
}
}
}

joCreatedNodes.forEach { jo ->
if (joHasPickReachingProduction(jo)) return@forEach
val scope = productionScopeKey(jo)
val firstStep =
prodSteps
.filter { productionScopeKey(it) == scope }
.sortedWith { a, b ->
val sa = a.bomProcessSeqNo
val sb = b.bomProcessSeqNo
when {
sa != null && sb != null && sa != sb -> sa.compareTo(sb)
sa != null && sb == null -> -1
sa == null && sb != null -> 1
else -> TraceGraphLayoutSupport.sortNodesInPhase.compare(a, b)
}
}
.firstOrNull()
if (firstStep != null) add(jo, firstStep)
}
}

private fun connectWarehousePhaseChainEdges( private fun connectWarehousePhaseChainEdges(
nodes: List<TraceGraphLayoutNode>, nodes: List<TraceGraphLayoutNode>,
add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,


+ 17
- 10
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt Ver fichero

@@ -72,7 +72,8 @@ open class TraceInboundLoader(
COALESCE(i.name, '') AS itemName, COALESCE(i.name, '') AS itemName,
COALESCE(pol.qtyM18, pol.qty, 0) AS orderQty, COALESCE(pol.qtyM18, pol.qty, 0) AS orderQty,
COALESCE(pa.putAwayQty, 0) AS putAwayQty, COALESCE(pa.putAwayQty, 0) AS putAwayQty,
COALESCE(ucm18.udfudesc, uc.udfudesc, '') AS purchaseUnit,
-- Prefer PO-line UOM snapshot (M18 then pol.uomId); avoid live purchaseUnit flag.
COALESCE(ucm18.udfudesc, uc_pol.udfudesc, '') AS purchaseUnit,
COALESCE(sp.code, '') AS supplierCode, COALESCE(sp.code, '') AS supplierCode,
COALESCE(sp.name, '') AS supplierName, COALESCE(sp.name, '') AS supplierName,
po.orderDate AS orderDate, po.orderDate AS orderDate,
@@ -82,9 +83,8 @@ open class TraceInboundLoader(
INNER JOIN purchase_order po ON pol.purchaseOrderId = po.id AND po.deleted = 0 INNER JOIN purchase_order po ON pol.purchaseOrderId = po.id AND po.deleted = 0
LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0 LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0
LEFT JOIN items i ON pol.itemId = i.id AND i.deleted = 0 LEFT JOIN items i ON pol.itemId = i.id AND i.deleted = 0
LEFT JOIN item_uom iu ON iu.itemId = pol.itemId AND iu.purchaseUnit = true AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0
LEFT JOIN uom_conversion ucm18 ON ucm18.id = pol.uomIdM18 AND ucm18.deleted = 0 LEFT JOIN uom_conversion ucm18 ON ucm18.id = pol.uomIdM18 AND ucm18.deleted = 0
LEFT JOIN uom_conversion uc_pol ON uc_pol.id = pol.uomId AND uc_pol.deleted = 0
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
sil_pa.purchaseOrderLineId, sil_pa.purchaseOrderLineId,
@@ -99,8 +99,11 @@ open class TraceInboundLoader(
FROM stock_in_line sil_pa FROM stock_in_line sil_pa
INNER JOIN inventory_lot_line ill ON sil_pa.inventoryLotLineId = ill.id AND ill.deleted = 0 INNER JOIN inventory_lot_line ill ON sil_pa.inventoryLotLineId = ill.id AND ill.deleted = 0
INNER JOIN items it_pa ON sil_pa.itemId = it_pa.id AND it_pa.deleted = 0 INNER JOIN items it_pa ON sil_pa.itemId = it_pa.id AND it_pa.deleted = 0
LEFT JOIN item_uom iu_s ON iu_s.itemId = it_pa.id AND iu_s.stockUnit = true AND iu_s.deleted = 0
LEFT JOIN item_uom iu_p ON iu_p.itemId = it_pa.id AND iu_p.purchaseUnit = true AND iu_p.deleted = 0
LEFT JOIN purchase_order_line pol_pa ON sil_pa.purchaseOrderLineId = pol_pa.id AND pol_pa.deleted = 0
LEFT JOIN item_uom iu_s ON iu_s.id = ill.stockItemUomId AND iu_s.deleted = 0
LEFT JOIN item_uom iu_p ON iu_p.itemId = it_pa.id
AND iu_p.uomId = COALESCE(pol_pa.uomIdM18, pol_pa.uomId)
AND iu_p.deleted = 0
WHERE sil_pa.deleted = 0 WHERE sil_pa.deleted = 0
AND sil_pa.purchaseOrderLineId IS NOT NULL AND sil_pa.purchaseOrderLineId IS NOT NULL
AND COALESCE(ill.inQty, 0) > 0 AND COALESCE(ill.inQty, 0) > 0
@@ -111,7 +114,7 @@ open class TraceInboundLoader(
AND sil.purchaseOrderLineId IS NOT NULL AND sil.purchaseOrderLineId IS NOT NULL
GROUP BY GROUP BY
pol.id, po.id, po.code, i.code, pol.itemNo, i.name, pol.id, po.id, po.code, i.code, pol.itemNo, i.name,
pol.qtyM18, pol.qty, pa.putAwayQty, ucm18.udfudesc, uc.udfudesc,
pol.qtyM18, pol.qty, pa.putAwayQty, ucm18.udfudesc, uc_pol.udfudesc,
sp.code, sp.name, po.orderDate, pol.status sp.code, sp.name, po.orderDate, pol.status
ORDER BY po.orderDate ASC, pol.id ASC ORDER BY po.orderDate ASC, pol.id ASC
""".trimIndent() """.trimIndent()
@@ -125,7 +128,8 @@ open class TraceInboundLoader(
COALESCE(i.code, '') AS itemCode, COALESCE(i.code, '') AS itemCode,
COALESCE(i.name, '') AS itemName, COALESCE(i.name, '') AS itemName,
COALESCE(SUM(sil.acceptedQty), 0) AS orderQty, COALESCE(SUM(sil.acceptedQty), 0) AS orderQty,
COALESCE(uc.udfudesc, '') AS purchaseUnit,
-- Header-only PO has no POL UOM; use stock-in lot-line UOM (matches acceptedQty).
COALESCE(MAX(uc_tx.udfudesc), MAX(uc_fb.udfudesc), '') AS purchaseUnit,
COALESCE(sp.code, '') AS supplierCode, COALESCE(sp.code, '') AS supplierCode,
COALESCE(sp.name, '') AS supplierName, COALESCE(sp.name, '') AS supplierName,
po.orderDate AS orderDate, po.orderDate AS orderDate,
@@ -134,13 +138,16 @@ open class TraceInboundLoader(
INNER JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 INNER JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0
LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0 LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0
LEFT JOIN items i ON sil.itemId = i.id AND i.deleted = 0 LEFT JOIN items i ON sil.itemId = i.id AND i.deleted = 0
LEFT JOIN item_uom iu ON iu.itemId = sil.itemId AND iu.purchaseUnit = true AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0
LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0
LEFT JOIN item_uom iu_tx ON iu_tx.id = ill.stockItemUomId AND iu_tx.deleted = 0
LEFT JOIN uom_conversion uc_tx ON uc_tx.id = iu_tx.uomId AND uc_tx.deleted = 0
LEFT JOIN item_uom iu_fb ON iu_fb.itemId = sil.itemId AND iu_fb.stockUnit = 1 AND iu_fb.deleted = 0
LEFT JOIN uom_conversion uc_fb ON uc_fb.id = iu_fb.uomId AND uc_fb.deleted = 0
WHERE sil.deleted = 0 WHERE sil.deleted = 0
AND sil.id IN (:stockInLineIds) AND sil.id IN (:stockInLineIds)
AND sil.purchaseOrderId IS NOT NULL AND sil.purchaseOrderId IS NOT NULL
AND sil.purchaseOrderLineId IS NULL AND sil.purchaseOrderLineId IS NULL
GROUP BY po.id, po.code, i.code, i.name, uc.udfudesc, sp.code, sp.name, po.orderDate
GROUP BY po.id, po.code, i.code, i.name, sp.code, sp.name, po.orderDate
ORDER BY po.orderDate ASC, po.id ASC ORDER BY po.orderDate ASC, po.id ASC
""".trimIndent() """.trimIndent()
val fromHeader = jdbcDao.queryForList(headerQuery, mapOf("stockInLineIds" to stockInLineIds)).map { row -> val fromHeader = jdbcDao.queryForList(headerQuery, mapOf("stockInLineIds" to stockInLineIds)).map { row ->


+ 7
- 4
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt Ver fichero

@@ -1,4 +1,4 @@
package com.ffii.fpsms.modules.stock.trace
package com.ffii.fpsms.modules.stock.trace


import com.ffii.core.support.JdbcDao import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.* import com.ffii.fpsms.modules.stock.web.model.*
@@ -159,7 +159,7 @@ open class TraceJoPreludeLoader(
mat_ill.id AS pickedInventoryLotLineId, mat_ill.id AS pickedInventoryLotLineId,
COALESCE(wh_pick.code, '') AS pickedWarehouseCode, COALESCE(wh_pick.code, '') AS pickedWarehouseCode,
COALESCE(sol.qty, 0) AS materialQty, COALESCE(sol.qty, 0) AS materialQty,
COALESCE(mat_uc.udfudesc, '') AS materialUom,
COALESCE(mat_uc_tx.udfudesc, mat_uc_fb.udfudesc, '') AS materialUom,
bm.qty AS bomQtyPerUnit, bm.qty AS bomQtyPerUnit,
COALESCE(po.code, '') AS pickOrderCode, COALESCE(po.code, '') AS pickOrderCode,
po.id AS pickOrderId, po.id AS pickOrderId,
@@ -183,8 +183,11 @@ open class TraceJoPreludeLoader(
LEFT JOIN warehouse wh_pick ON mat_ill.warehouseId = wh_pick.id AND wh_pick.deleted = 0 LEFT JOIN warehouse wh_pick ON mat_ill.warehouseId = wh_pick.id AND wh_pick.deleted = 0
LEFT JOIN stock_in_line mat_sil ON mat_il.stockInLineId = mat_sil.id AND mat_sil.deleted = 0 LEFT JOIN stock_in_line mat_sil ON mat_il.stockInLineId = mat_sil.id AND mat_sil.deleted = 0
LEFT JOIN items mat_it ON mat_il.itemId = mat_it.id AND mat_it.deleted = 0 LEFT JOIN items mat_it ON mat_il.itemId = mat_it.id AND mat_it.deleted = 0
LEFT JOIN item_uom mat_iu ON mat_it.id = mat_iu.itemId AND mat_iu.stockUnit = 1 AND mat_iu.deleted = 0
LEFT JOIN uom_conversion mat_uc ON mat_iu.uomId = mat_uc.id AND mat_uc.deleted = 0
-- UOM frozen on the picked inventory_lot_line (transaction-time), not current master stockUnit.
LEFT JOIN item_uom mat_iu_tx ON mat_iu_tx.id = mat_ill.stockItemUomId AND mat_iu_tx.deleted = 0
LEFT JOIN uom_conversion mat_uc_tx ON mat_iu_tx.uomId = mat_uc_tx.id AND mat_uc_tx.deleted = 0
LEFT JOIN item_uom mat_iu_fb ON mat_it.id = mat_iu_fb.itemId AND mat_iu_fb.stockUnit = 1 AND mat_iu_fb.deleted = 0
LEFT JOIN uom_conversion mat_uc_fb ON mat_iu_fb.uomId = mat_uc_fb.id AND mat_uc_fb.deleted = 0
LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0
LEFT JOIN bom_material bm ON bm.bomId = b.id AND bm.itemId = mat_it.id AND bm.deleted = 0 LEFT JOIN bom_material bm ON bm.bomId = b.id AND bm.itemId = mat_it.id AND bm.deleted = 0
LEFT JOIN jo_pick_order jpo ON jpo.pick_order_id = po.id LEFT JOIN jo_pick_order jpo ON jpo.pick_order_id = po.id


+ 29
- 6
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt Ver fichero

@@ -130,12 +130,24 @@ open class TraceLotResolver(
ORDER BY sil.receiptDate DESC, sil.id DESC ORDER BY sil.receiptDate DESC, sil.id DESC
LIMIT 1) AS productionDate, LIMIT 1) AS productionDate,
il.stockInDate AS stockInDate, il.stockInDate AS stockInDate,
COALESCE(uc.udfudesc, '') AS uom,
-- Prefer UOM frozen on inventory_lot_line at stock-in time (survives master sync).
COALESCE(uc_tx.udfudesc, uc_fb.udfudesc, '') AS uom,
il.stockInLineId AS primaryStockInLineId il.stockInLineId AS primaryStockInLineId
FROM inventory_lot il FROM inventory_lot il
INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0 INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
LEFT JOIN inventory_lot_line ill_uom ON ill_uom.id = (
SELECT ill2.id
FROM inventory_lot_line ill2
WHERE ill2.inventoryLotId = il.id
AND ill2.deleted = 0
AND ill2.stockItemUomId IS NOT NULL
ORDER BY ill2.id ASC
LIMIT 1
)
LEFT JOIN item_uom iu_tx ON iu_tx.id = ill_uom.stockItemUomId AND iu_tx.deleted = 0
LEFT JOIN uom_conversion uc_tx ON uc_tx.id = iu_tx.uomId AND uc_tx.deleted = 0
LEFT JOIN item_uom iu_fb ON it.id = iu_fb.itemId AND iu_fb.stockUnit = 1 AND iu_fb.deleted = 0
LEFT JOIN uom_conversion uc_fb ON iu_fb.uomId = uc_fb.id AND uc_fb.deleted = 0
WHERE il.id = :inventoryLotId AND il.deleted = 0 WHERE il.id = :inventoryLotId AND il.deleted = 0
""".trimIndent() """.trimIndent()
val row = jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).firstOrNull() val row = jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).firstOrNull()
@@ -258,12 +270,23 @@ open class TraceLotResolver(
ORDER BY sil.receiptDate DESC, sil.id DESC ORDER BY sil.receiptDate DESC, sil.id DESC
LIMIT 1) AS productionDate, LIMIT 1) AS productionDate,
il.stockInDate AS stockInDate, il.stockInDate AS stockInDate,
COALESCE(uc.udfudesc, '') AS uom,
COALESCE(uc_tx.udfudesc, uc_fb.udfudesc, '') AS uom,
il.stockInLineId AS primaryStockInLineId il.stockInLineId AS primaryStockInLineId
FROM inventory_lot il FROM inventory_lot il
INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0 INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
LEFT JOIN inventory_lot_line ill_uom ON ill_uom.id = (
SELECT ill2.id
FROM inventory_lot_line ill2
WHERE ill2.inventoryLotId = il.id
AND ill2.deleted = 0
AND ill2.stockItemUomId IS NOT NULL
ORDER BY ill2.id ASC
LIMIT 1
)
LEFT JOIN item_uom iu_tx ON iu_tx.id = ill_uom.stockItemUomId AND iu_tx.deleted = 0
LEFT JOIN uom_conversion uc_tx ON uc_tx.id = iu_tx.uomId AND uc_tx.deleted = 0
LEFT JOIN item_uom iu_fb ON it.id = iu_fb.itemId AND iu_fb.stockUnit = 1 AND iu_fb.deleted = 0
LEFT JOIN uom_conversion uc_fb ON iu_fb.uomId = uc_fb.id AND uc_fb.deleted = 0
WHERE il.id IN (:inventoryLotIds) AND il.deleted = 0 WHERE il.id IN (:inventoryLotIds) AND il.deleted = 0
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)) return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds))


+ 17
- 3
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt Ver fichero

@@ -70,7 +70,7 @@ open class TraceProductionLoader(
COALESCE(bp_it.code, '') AS itemCode, COALESCE(bp_it.code, '') AS itemCode,
COALESCE(bp_it.name, '') AS itemName, COALESCE(bp_it.name, '') AS itemName,
COALESCE(sil.acceptedQty, 0) AS qty, COALESCE(sil.acceptedQty, 0) AS qty,
COALESCE(uc.udfudesc, '') AS uom,
COALESCE(uc_tx.udfudesc, uc_fb.udfudesc, '') AS uom,
COALESCE(ppl.name, '') AS processStepName, COALESCE(ppl.name, '') AS processStepName,
COALESCE(sil.created, sil.modified, sil.receiptDate) AS producedAt, COALESCE(sil.created, sil.modified, sil.receiptDate) AS producedAt,
COALESCE(jo.code, '') AS jobOrderCode, COALESCE(jo.code, '') AS jobOrderCode,
@@ -79,8 +79,22 @@ open class TraceProductionLoader(
INNER JOIN stock_in_line sil ON sil.jobOrderId = jo.id AND sil.deleted = 0 INNER JOIN stock_in_line sil ON sil.jobOrderId = jo.id AND sil.deleted = 0
INNER JOIN inventory_lot il ON sil.inventoryLotId = il.id AND il.deleted = 0 INNER JOIN inventory_lot il ON sil.inventoryLotId = il.id AND il.deleted = 0
INNER JOIN items bp_it ON il.itemId = bp_it.id AND bp_it.deleted = 0 INNER JOIN items bp_it ON il.itemId = bp_it.id AND bp_it.deleted = 0
LEFT JOIN item_uom iu ON bp_it.id = iu.itemId AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
LEFT JOIN inventory_lot_line bp_ill ON bp_ill.id = COALESCE(
sil.inventoryLotLineId,
(
SELECT ill2.id
FROM inventory_lot_line ill2
WHERE ill2.inventoryLotId = il.id
AND ill2.deleted = 0
AND ill2.stockItemUomId IS NOT NULL
ORDER BY ill2.id ASC
LIMIT 1
)
)
LEFT JOIN item_uom iu_tx ON iu_tx.id = bp_ill.stockItemUomId AND iu_tx.deleted = 0
LEFT JOIN uom_conversion uc_tx ON uc_tx.id = iu_tx.uomId AND uc_tx.deleted = 0
LEFT JOIN item_uom iu_fb ON bp_it.id = iu_fb.itemId AND iu_fb.stockUnit = 1 AND iu_fb.deleted = 0
LEFT JOIN uom_conversion uc_fb ON iu_fb.uomId = uc_fb.id AND uc_fb.deleted = 0
LEFT JOIN productprocess pp ON pp.jobOrderId = jo.id AND pp.deleted = 0 LEFT JOIN productprocess pp ON pp.jobOrderId = jo.id AND pp.deleted = 0
LEFT JOIN productprocessline ppl ON ppl.productprocessid = pp.id LEFT JOIN productprocessline ppl ON ppl.productprocessid = pp.id
AND ppl.deleted = 0 AND ppl.deleted = 0


Cargando…
Cancelar
Guardar