소스 검색

Merge branch 'production' into bomUpdateTest

# Conflicts:
#	src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
bomUpdateTest
CANCERYS\kw093 1 개월 전
부모
커밋
2ce9b8b67a
17개의 변경된 파일634개의 추가작업 그리고 97개의 파일을 삭제
  1. +3
    -3
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  2. +19
    -16
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt
  3. +17
    -2
      src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt
  4. +269
    -28
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  5. +10
    -3
      src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt
  6. +25
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt
  7. +2
    -0
      src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultRepository.kt
  8. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  9. +17
    -3
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt
  10. +89
    -8
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt
  11. +109
    -9
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt
  12. +17
    -10
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt
  13. +7
    -4
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt
  14. +29
    -6
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt
  15. +17
    -3
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt
  16. +1
    -1
      src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java
  17. +1
    -1
      src/main/resources/db/changelog/changes/20260805_product_process_complete/01_product_process_complete_authority.sql

+ 3
- 3
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java 파일 보기

@@ -75,7 +75,7 @@ public class SecurityConfig {
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06
* FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05
* (stockAdjustment/submit → INVENTORY_ADJUST only)
*/
@@ -122,9 +122,9 @@ public class SecurityConfig {
.hasAuthority("ITEM_TRACING")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**")
.hasAuthority("ITEM_TRACING")
/* 工單 生產流程 完成工單 */
/* 工單 生產流程 完成工單:僅 ADMIN */
.requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/complete/**")
.hasAuthority("PRODUCT_PROCESS")
.hasAuthority("ADMIN")
/* 工序「已完成」(Just Pass):僅 ADMIN */
.requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**")
.hasAuthority("ADMIN")


+ 19
- 16
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt 파일 보기

@@ -256,31 +256,34 @@ fun findAllReleasedJoWorkbenchPickOrders(
fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03
* JO ids that have at least one stock_out_line counted as picked
* (checked / partially_completed / completed / picking, or rejected with qty > 0).
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06
* JO ids whose pick order lines are all completed (one JO ↔ one pick order in practice).
* Used for production-process 「已提料」 bucket.
*/
@Query(
value = """
SELECT DISTINCT po.joId
SELECT po.joId
FROM pick_order po
INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0
INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0
WHERE po.deleted = 0
AND po.joId IN (:joIds)
AND (
LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking')
OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0)
)
GROUP BY po.joId, po.id
HAVING COUNT(*) > 0
AND COUNT(*) = SUM(
CASE WHEN LOWER(COALESCE(pol.status, '')) = 'completed' THEN 1 ELSE 0 END
)
""",
nativeQuery = true,
)
fun findPickedJobOrderIds(@Param("joIds") joIds: Collection<Long>): List<Long>

/** Unlimited: picked + product process still pending / not started. */
/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06
* Unlimited: all POL completed + product process still pending / not started.
*/
@Query(
value = """
SELECT DISTINCT po.joId
SELECT po.joId
FROM pick_order po
INNER JOIN job_order jo
ON jo.id = po.joId
@@ -293,13 +296,13 @@ fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int
AND LOWER(pp.status) = 'pending'
AND pp.startTime IS NULL
INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0
INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0
WHERE po.deleted = 0
AND po.joId IS NOT NULL
AND (
LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking')
OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0)
)
GROUP BY po.joId, po.id
HAVING COUNT(*) > 0
AND COUNT(*) = SUM(
CASE WHEN LOWER(COALESCE(pol.status, '')) = 'completed' THEN 1 ELSE 0 END
)
""",
nativeQuery = true,
)


+ 17
- 2
src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt 파일 보기

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

interface JobOrderProcessAggregate {
val jobOrderId: Long
@@ -27,6 +28,20 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe
fun findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds: List<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(
"""
SELECT
@@ -57,7 +72,7 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe
): List<JobOrderProcessAggregate>

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06
* Production list lookback: inclusive date range on ProductProcess.date.
*/
@Query(
@@ -114,7 +129,7 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe
GROUP BY p.jobOrder.id
"""
)
/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */
/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 */
fun findCandidateJobOrderAggregatesByJobOrderIds(
@Param("jobOrderIds") jobOrderIds: Collection<Long>,
@Param("itemCode") itemCode: String?,


+ 269
- 28
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt 파일 보기

@@ -58,6 +58,7 @@ import java.math.RoundingMode
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.qc.entity.QcResultRepository
import org.springframework.dao.DataIntegrityViolationException
@Service
@Transactional
@@ -89,7 +90,12 @@ open class ProductProcessService(
private val stockInLineRepository: StockInLineRepository,
private val bomOutputQtyService: BomOutputQtyService,
private val bomMaterialQtyService: BomMaterialQtyService,
private val qcResultRepository: QcResultRepository,
) {
companion object {
private const val PACKAGING_PROCESS_NAME = "包裝"
private const val DRINK_PLANNED_LOOKBACK_DAYS = 30L
}

open fun findAll(pageable: Pageable): Page<ProductProcess> {
println(" Service: Finding all ProductProcess with page: ${pageable.pageNumber}, size: ${pageable.pageSize}")
@@ -1301,6 +1307,7 @@ open class ProductProcessService(
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */
open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse {
println(" Service: Updating ProductProcessLine Status: $productProcessLineId")
val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null)
@@ -1309,6 +1316,12 @@ open class ProductProcessService(
// productProcessLine.endTime = LocalDateTime.now()
productProcessLineRepository.save(productProcessLine)
println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}")

// One packaging Complete/Pass → auto-Pass remaining packaging lines on the same JO.
if (isLineDone(status) && isPackagingLine(productProcessLine)) {
autoPassSiblingPackagingLines(productProcessLineId)
}

syncProductProcessStatusFromLines(productProcessLine.productProcess?.id ?: 0L)

return MessageResponse(
@@ -1555,8 +1568,9 @@ bomDescription = productProcesses.bom?.bomKind,
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.2 | 2026-08-09
* QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。
* (包裝 Complete/Pass 時會自動 Pass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。)
*
* 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。
*/
@@ -1737,14 +1751,11 @@ bomDescription = productProcesses.bom?.bomKind,
}
// After packaging Complete/Pass auto-passes sibling 包裝 lines, qcReady is simply all lines done.
val jobLines = linesByJobOrder[jobOrderId].orEmpty()
val packing = jobLines.filter { (it.name ?: "").trim() == "包裝" }
val nonPacking = jobLines.filter { (it.name ?: "").trim() != "包裝" }

val nonPackingOk = nonPacking.isEmpty() || nonPacking.all { done(it.status) }
val packingOk = packing.isEmpty() || packing.any { done(it.status) }
val allLinesDone = jobLines.isNotEmpty() && jobLines.all { done(it.status) }

val ready = includedInList && stockInLine != null && nonPackingOk && packingOk
val ready = includedInList && stockInLine != null && allLinesDone
JobOrderQcKey(
jobOrderId = jobOrderId,
@@ -2174,6 +2185,51 @@ bomDescription = productProcess.bom?.bomKind,
return n == "inprogress" || n == "paused"
}

/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */
private fun isPackagingLine(line: ProductProcessLine): Boolean =
(line.name ?: "").trim() == PACKAGING_PROCESS_NAME

/**
* FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09
* When one 包裝 line is Completed/Pass, auto-Pass other unfinished 包裝 lines on the same job order,
* then sync each affected ProductProcess.status (Pass counts as done → COMPLETED).
*/
private fun autoPassSiblingPackagingLines(triggerLineId: Long) {
val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return
if (!isPackagingLine(trigger)) return

val jobOrderId = trigger.productProcess?.jobOrder?.id ?: return
val processes = productProcessRepository.findByJobOrder_Id(jobOrderId)
.filter { it.deleted != true }
val processIds = processes.mapNotNull { it.id }
if (processIds.isEmpty()) return

val allLines = productProcessLineRepository.findByProductProcess_IdIn(processIds)
val siblings = allLines.filter { line ->
line.id != null &&
line.id != triggerLineId &&
isPackagingLine(line) &&
!isLineDone(line.status)
}
if (siblings.isEmpty()) return

val now = LocalDateTime.now()
val affectedProcessIds = linkedSetOf<Long>()
for (line in siblings) {
if (line.startTime == null) {
line.startTime = now
}
line.endTime = now
line.status = "Pass"
productProcessLineRepository.save(line)
line.productProcess?.id?.let { affectedProcessIds.add(it) }
}

for (processId in affectedProcessIds) {
syncProductProcessStatusFromLines(processId)
}
}

/**
* Align parent [ProductProcess.status] with all line states.
* - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed])
@@ -2686,21 +2742,16 @@ bomDescription = productProcess.bom?.bomKind,
}

// ===== Drink Production Qty Dashboard =====

/** 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 startOfDay = targetDate.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(
val itemCode: String?,
@@ -2716,11 +2767,71 @@ bomDescription = productProcess.bom?.bomKind,
val jobOrderId: Long?,
val jobOrderCode: String?,
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.status != JobOrderStatus.PLANNING }
.filter { jo ->
isPlannedView || jo.status != JobOrderStatus.PLANNING
}

if (drinkOrders.isEmpty()) {
return emptyList()
@@ -2728,6 +2839,15 @@ bomDescription = productProcess.bom?.bomKind,

val jobOrderIds = drinkOrders.mapNotNull { it.id }
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
.findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds)
@@ -2742,9 +2862,34 @@ bomDescription = productProcess.bom?.bomKind,
emptyMap()
}

val stockInLineByJobOrderId = if (jobOrderIds.isNotEmpty()) {
val stockInLinesByJobOrderId = if (jobOrderIds.isNotEmpty()) {
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 {
emptyMap()
}
@@ -2769,25 +2914,111 @@ bomDescription = productProcess.bom?.bomKind,
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 bomId = bom?.id
val itemId = bom?.item?.id
val process = processesByJobOrderId[jo.id]
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(
itemCode = bom?.item?.code,
itemName = bom?.item?.name,
uom = resolveDrinkProductionUom(bom, itemId, uomDescByItemId),
reqQty = jo.reqQty ?: BigDecimal.ZERO,
productionQty = resolveDrinkProductionQty(jo, lines, stockInLine),
productionQty = resolveDrinkProductionQty(lines, stockInLine),
jobOrderId = jo.id,
jobOrderCode = jo.code,
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
.groupBy { GroupKey(it.itemCode, it.itemName) }
.map { (key, rows) ->
@@ -2808,6 +3039,17 @@ bomDescription = productProcess.bom?.bomKind,
productionDate = row.productionDate,
reqQty = row.reqQty,
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 ?: "" },
)
@@ -2829,9 +3071,8 @@ bomDescription = productProcess.bom?.bomKind,
return null
}

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

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

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


+ 10
- 3
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt 파일 보기

@@ -200,7 +200,7 @@ class ProductProcessController(
return productProcessService.getAllJoborderProductProcessInfo(bomType)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */
/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 */
@GetMapping("/Demo/Process/search")
fun demoprocesssearch(
@RequestParam(required = false) date: String?,
@@ -320,10 +320,17 @@ class ProductProcessController(

/** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */
@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 {
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 파일 보기

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

// ===== 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(
val jobOrderId: Long,
val jobOrderCode: String?,
@@ -366,6 +380,17 @@ data class DrinkProductionQtyJobOrderDetail(
val productionDate: LocalDate?,
val reqQty: 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(


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultRepository.kt 파일 보기

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

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

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


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt 파일 보기

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

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

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

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

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


+ 17
- 3
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt 파일 보기

@@ -122,7 +122,7 @@ open class TraceBomLoader(
COALESCE(fg_il.lotNo, fg_sil.lotNo, '') AS finishedLotNo,
fg_sil.id AS finishedStockInLineId,
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
FROM stock_out_line sol
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 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 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)
ORDER BY jobOrderCode, finishedLotNo
""".trimIndent()


+ 89
- 8
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt 파일 보기

@@ -1,5 +1,6 @@
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.ItemLotTraceMaterialInput
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult
@@ -389,11 +390,42 @@ internal object TraceGraphNodeBuilder {
val ctx = PreludeBuildContext()
val lotPicks = buildLotPickOrderMap(joPrelude.materialInputs)
val chainNodes = buildMaterialInputChainNodes(joPrelude.materialInputs, lotPicks, ctx)
val pickNodes =
val coveredItems = coveredPickItemCodes(joPrelude.materialInputs)
val lotPickNodes =
joPrelude.materialInputs.mapIndexed { i, m ->
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(
@@ -447,13 +479,38 @@ internal object TraceGraphNodeBuilder {

if (joProduced && m.nestedJoPrelude != null) {
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,
)

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 =
m.productionSteps.isNotEmpty() ||
m.nestedJoPrelude != null ||


+ 109
- 9
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt 파일 보기

@@ -428,14 +428,22 @@ internal object TraceGraphEdgeSemantics {
to: TraceGraphLayoutNode,
): Boolean {
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
}
// Allow pick/JO_CREATED → PRODUCTION_STEP (dedicated builders).
if (
to.kind == "PRODUCTION_STEP" &&
from.kind != "MATERIAL_PICK" &&
from.kind != "PICK_GROUP" &&
from.kind != "PRODUCTION_STEP"
from.kind != "PRODUCTION_STEP" &&
from.kind != "JO_CREATED"
) {
return true
}
@@ -574,9 +582,9 @@ internal object TraceGraphEdgeSemantics {
val pairs = mutableListOf<ItemLotTraceGraphEdge>()
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 (shouldSkipTraceFlowEdge(from, to)) return
if (!bypassSkip && shouldSkipTraceFlowEdge(from, to)) return
val key = "${from.id}->${to.id}"
if (!seen.add(key)) return
pairs += ItemLotTraceGraphEdge(fromKey = from.id, toKey = to.id)
@@ -614,6 +622,8 @@ internal object TraceGraphEdgeSemantics {
val toList = nodesInPhase(dayNodes, phasesPresent[i + 1])
if (fromList.isEmpty() || toList.isEmpty()) 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
if (phasesPresent[i] == "PUTAWAY" && phasesPresent[i + 1] == "WAREHOUSE") {
connectPutawayToWarehousePhase(fromList, toList, ::add)
@@ -627,6 +637,7 @@ internal object TraceGraphEdgeSemantics {
buildProductionStepChainEdges(nodes, ::add)
buildProductionToFgQcEdges(nodes, ::add)
buildJoCreatedToMaterialPickEdges(nodes, ::add)
buildJoCreatedToProductionStepEdges(nodes, ::add)
buildReplenishmentToDoOutEdges(nodes, ::add)
buildMaterialPickToProductionStepEdges(nodes, ::add)
buildProductionStepLossEdges(nodes, ::add)
@@ -651,6 +662,9 @@ internal object TraceGraphEdgeSemantics {

nodes.forEach { n ->
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
byLot.getOrPut(key) { mutableListOf() } += n
}
@@ -678,11 +692,14 @@ internal object TraceGraphEdgeSemantics {
if (toPhase == "MATERIAL_PICK") {
val upstream = fromList.last()
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 {
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(
nodes: List<TraceGraphLayoutNode>,
add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,


+ 17
- 10
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt 파일 보기

@@ -72,7 +72,8 @@ open class TraceInboundLoader(
COALESCE(i.name, '') AS itemName,
COALESCE(pol.qtyM18, pol.qty, 0) AS orderQty,
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.name, '') AS supplierName,
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
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 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 uc_pol ON uc_pol.id = pol.uomId AND uc_pol.deleted = 0
LEFT JOIN (
SELECT
sil_pa.purchaseOrderLineId,
@@ -99,8 +99,11 @@ open class TraceInboundLoader(
FROM stock_in_line sil_pa
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
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
AND sil_pa.purchaseOrderLineId IS NOT NULL
AND COALESCE(ill.inQty, 0) > 0
@@ -111,7 +114,7 @@ open class TraceInboundLoader(
AND sil.purchaseOrderLineId IS NOT NULL
GROUP BY
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
ORDER BY po.orderDate ASC, pol.id ASC
""".trimIndent()
@@ -125,7 +128,8 @@ open class TraceInboundLoader(
COALESCE(i.code, '') AS itemCode,
COALESCE(i.name, '') AS itemName,
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.name, '') AS supplierName,
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
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 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
AND sil.id IN (:stockInLineIds)
AND sil.purchaseOrderId IS NOT 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
""".trimIndent()
val fromHeader = jdbcDao.queryForList(headerQuery, mapOf("stockInLineIds" to stockInLineIds)).map { row ->


+ 7
- 4
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt 파일 보기

@@ -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.fpsms.modules.stock.web.model.*
@@ -159,7 +159,7 @@ open class TraceJoPreludeLoader(
mat_ill.id AS pickedInventoryLotLineId,
COALESCE(wh_pick.code, '') AS pickedWarehouseCode,
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,
COALESCE(po.code, '') AS pickOrderCode,
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 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 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_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


+ 29
- 6
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt 파일 보기

@@ -130,12 +130,24 @@ open class TraceLotResolver(
ORDER BY sil.receiptDate DESC, sil.id DESC
LIMIT 1) AS productionDate,
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
FROM inventory_lot il
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
""".trimIndent()
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
LIMIT 1) AS productionDate,
il.stockInDate AS stockInDate,
COALESCE(uc.udfudesc, '') AS uom,
COALESCE(uc_tx.udfudesc, uc_fb.udfudesc, '') AS uom,
il.stockInLineId AS primaryStockInLineId
FROM inventory_lot il
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
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds))


+ 17
- 3
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt 파일 보기

@@ -70,7 +70,7 @@ open class TraceProductionLoader(
COALESCE(bp_it.code, '') AS itemCode,
COALESCE(bp_it.name, '') AS itemName,
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(sil.created, sil.modified, sil.receiptDate) AS producedAt,
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 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
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 productprocessline ppl ON ppl.productprocessid = pp.id
AND ppl.deleted = 0


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java 파일 보기

@@ -187,7 +187,7 @@ public class GroupService extends AbstractBaseEntityService<Group, Long, GroupRe
}


/** FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05 */
/** FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 */
@Transactional(rollbackFor = Exception.class)
public List<Map<String, Object>> listAuth(Map<String, Object> args) {



+ 1
- 1
src/main/resources/db/changelog/changes/20260805_product_process_complete/01_product_process_complete_authority.sql 파일 보기

@@ -3,6 +3,6 @@
--changeset fpsms:product_process_complete_authority
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'PRODUCT_PROCESS'
--comment: FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05 — Allow completing job order product process lines (工單 生產流程 完成工單)
--comment: FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 — Allow completing job order product process lines (工單 生產流程 完成工單)
INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`)
VALUES ('PRODUCT_PROCESS', '工單 生產流程 完成工單', 'JOB_ORDER', 'Allow completing job order product process lines');

불러오는 중...
취소
저장