# Conflicts: # src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.ktbomUpdateTest
| @@ -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 | * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 | ||||
| * (stockAdjustment/submit → INVENTORY_ADJUST only) | * (stockAdjustment/submit → INVENTORY_ADJUST only) | ||||
| */ | */ | ||||
| @@ -122,9 +122,9 @@ public class SecurityConfig { | |||||
| .hasAuthority("ITEM_TRACING") | .hasAuthority("ITEM_TRACING") | ||||
| .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") | .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") | ||||
| .hasAuthority("ITEM_TRACING") | .hasAuthority("ITEM_TRACING") | ||||
| /* 工單 生產流程 完成工單 */ | |||||
| /* 工單 生產流程 完成工單:僅 ADMIN */ | |||||
| .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/complete/**") | .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/complete/**") | ||||
| .hasAuthority("PRODUCT_PROCESS") | |||||
| .hasAuthority("ADMIN") | |||||
| /* 工序「已完成」(Just Pass):僅 ADMIN */ | /* 工序「已完成」(Just Pass):僅 ADMIN */ | ||||
| .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") | .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") | ||||
| .hasAuthority("ADMIN") | .hasAuthority("ADMIN") | ||||
| @@ -256,31 +256,34 @@ fun findAllReleasedJoWorkbenchPickOrders( | |||||
| fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int | 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( | @Query( | ||||
| value = """ | value = """ | ||||
| SELECT DISTINCT po.joId | |||||
| SELECT po.joId | |||||
| FROM pick_order po | FROM pick_order po | ||||
| INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 | 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 | WHERE po.deleted = 0 | ||||
| AND po.joId IN (:joIds) | 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, | nativeQuery = true, | ||||
| ) | ) | ||||
| fun findPickedJobOrderIds(@Param("joIds") joIds: Collection<Long>): List<Long> | 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( | @Query( | ||||
| value = """ | value = """ | ||||
| SELECT DISTINCT po.joId | |||||
| SELECT po.joId | |||||
| FROM pick_order po | FROM pick_order po | ||||
| INNER JOIN job_order jo | INNER JOIN job_order jo | ||||
| ON jo.id = po.joId | ON jo.id = po.joId | ||||
| @@ -293,13 +296,13 @@ fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int | |||||
| AND LOWER(pp.status) = 'pending' | AND LOWER(pp.status) = 'pending' | ||||
| AND pp.startTime IS NULL | AND pp.startTime IS NULL | ||||
| INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 | 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 | WHERE po.deleted = 0 | ||||
| AND po.joId IS NOT NULL | 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, | nativeQuery = true, | ||||
| ) | ) | ||||
| @@ -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 | ||||
| @@ -57,7 +72,7 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe | |||||
| ): List<JobOrderProcessAggregate> | ): 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. | * Production list lookback: inclusive date range on ProductProcess.date. | ||||
| */ | */ | ||||
| @Query( | @Query( | ||||
| @@ -114,7 +129,7 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe | |||||
| GROUP BY p.jobOrder.id | 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( | fun findCandidateJobOrderAggregatesByJobOrderIds( | ||||
| @Param("jobOrderIds") jobOrderIds: Collection<Long>, | @Param("jobOrderIds") jobOrderIds: Collection<Long>, | ||||
| @Param("itemCode") itemCode: String?, | @Param("itemCode") itemCode: String?, | ||||
| @@ -58,6 +58,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 | ||||
| @@ -89,7 +90,12 @@ open class ProductProcessService( | |||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val bomOutputQtyService: BomOutputQtyService, | private val bomOutputQtyService: BomOutputQtyService, | ||||
| private val bomMaterialQtyService: BomMaterialQtyService, | 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> { | open fun findAll(pageable: Pageable): Page<ProductProcess> { | ||||
| println(" Service: Finding all ProductProcess with page: ${pageable.pageNumber}, size: ${pageable.pageSize}") | 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 { | open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse { | ||||
| println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | ||||
| val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | ||||
| @@ -1309,6 +1316,12 @@ open class ProductProcessService( | |||||
| // productProcessLine.endTime = LocalDateTime.now() | // productProcessLine.endTime = LocalDateTime.now() | ||||
| productProcessLineRepository.save(productProcessLine) | productProcessLineRepository.save(productProcessLine) | ||||
| println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}") | 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) | syncProductProcessStatusFromLines(productProcessLine.productProcess?.id ?: 0L) | ||||
| return MessageResponse( | 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 决定。 | * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 | ||||
| * (包裝 Complete/Pass 時會自動 Pass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。) | |||||
| * | * | ||||
| * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 | * 注意: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 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( | JobOrderQcKey( | ||||
| jobOrderId = jobOrderId, | jobOrderId = jobOrderId, | ||||
| @@ -2174,6 +2185,51 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| return n == "inprogress" || n == "paused" | 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. | * Align parent [ProductProcess.status] with all line states. | ||||
| * - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed]) | * - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed]) | ||||
| @@ -2686,21 +2742,16 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| } | } | ||||
| // ===== Drink Production Qty Dashboard ===== | // ===== Drink Production Qty Dashboard ===== | ||||
| /** 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?, | ||||
| @@ -2716,11 +2767,71 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| 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() | ||||
| @@ -2728,6 +2839,15 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| 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) | ||||
| @@ -2742,9 +2862,34 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| 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() | ||||
| } | } | ||||
| @@ -2769,25 +2914,111 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| 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) -> | ||||
| @@ -2808,6 +3039,17 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| 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 ?: "" }, | ||||
| ) | ) | ||||
| @@ -2829,9 +3071,8 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| 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 { | ||||
| @@ -2849,7 +3090,7 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| return acceptedQty | return acceptedQty | ||||
| } | } | ||||
| return jobOrder.reqQty ?: BigDecimal.ZERO | |||||
| return BigDecimal.ZERO | |||||
| } | } | ||||
| // ===== Operator KPI Dashboard ===== | // ===== Operator KPI Dashboard ===== | ||||
| @@ -200,7 +200,7 @@ class ProductProcessController( | |||||
| return productProcessService.getAllJoborderProductProcessInfo(bomType) | 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") | @GetMapping("/Demo/Process/search") | ||||
| fun demoprocesssearch( | fun demoprocesssearch( | ||||
| @RequestParam(required = false) date: String?, | @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 */ | /** 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) | |||||
| } | } | ||||
| } | } | ||||
| @@ -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( | ||||
| @@ -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 | ||||
| @@ -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> | ||||
| @@ -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() | ||||
| @@ -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 || | ||||
| @@ -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, | ||||
| @@ -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 -> | ||||
| @@ -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 | ||||
| @@ -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)) | ||||
| @@ -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 | ||||
| @@ -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) | @Transactional(rollbackFor = Exception.class) | ||||
| public List<Map<String, Object>> listAuth(Map<String, Object> args) { | public List<Map<String, Object>> listAuth(Map<String, Object> args) { | ||||
| @@ -3,6 +3,6 @@ | |||||
| --changeset fpsms:product_process_complete_authority | --changeset fpsms:product_process_complete_authority | ||||
| --preconditions onFail:MARK_RAN | --preconditions onFail:MARK_RAN | ||||
| --precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'PRODUCT_PROCESS' | --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`) | INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) | ||||
| VALUES ('PRODUCT_PROCESS', '工單 生產流程 完成工單', 'JOB_ORDER', 'Allow completing job order product process lines'); | VALUES ('PRODUCT_PROCESS', '工單 生產流程 完成工單', 'JOB_ORDER', 'Allow completing job order product process lines'); | ||||