diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt index deb791b..9cd98d3 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.productProcess.entity import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import java.time.LocalDateTime @Repository @@ -12,6 +13,16 @@ interface ProductProcessLineRepository : JpaRepository fun findByHandler_IdAndStartTimeIsNotNullAndEndTimeIsNull(handlerId: Long): List fun findByProductProcess_IdIn(ids: List): List + @Query(""" + SELECT l + FROM ProductProcessLine l + LEFT JOIN FETCH l.operator + LEFT JOIN FETCH l.equipment + WHERE l.deleted = false + AND l.productProcess.id IN :ids + """) + fun findByProductProcess_IdInWithOperatorAndEquipment(@Param("ids") ids: List): List + @Query("SELECT l FROM ProductProcessLine l LEFT JOIN FETCH l.equipment WHERE l.productProcess.id = :productProcessId") fun findByProductProcess_IdWithEquipment(productProcessId: Long): List diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt index 1fb49af..a257a18 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.stereotype.Repository import java.util.* +import java.time.LocalDate @Repository interface ProductProcessRepository : JpaRepository, JpaSpecificationExecutor { @@ -14,4 +15,6 @@ interface ProductProcessRepository : JpaRepository, JpaSpe fun findByIdAndDeletedIsFalse(id: Long): Optional fun findAllByDeletedIsFalse(): List fun findByBom_Id(bomId: Long): List + fun findAllByDeletedIsFalseAndDate(date: LocalDate): List + fun findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds: List): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index 6fd2eb7..1c299d3 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus import com.ffii.fpsms.modules.productProcess.web.model.* import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.data.domain.PageRequest import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository import com.ffii.fpsms.modules.productProcess.entity.projections.jobOrderLineInfo import org.springframework.stereotype.Service @@ -1492,6 +1493,272 @@ open class ProductProcessService( ) } + /** + * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 + * + * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 + */ + open fun searchJoborderProductProcessesPaged( + date: LocalDate?, + itemCode: String?, + jobOrderCode: String?, + bomIds: List?, + qcReady: Boolean?, + isDrink: Boolean?, + page: Int, + size: Int + ): JobOrderProductProcessPageResponse { + val safeSize = if (size <= 0) 50 else size + val safePage = if (page < 0) 0 else page + + val trimmedItemCode = itemCode?.trim()?.takeIf { it.isNotBlank() } + val trimmedJobOrderCode = jobOrderCode?.trim()?.takeIf { it.isNotBlank() } + + // 1) 找出候选 jobOrder:用 date/itemCode/jobOrderCode/bomIds/isDrink 先把数据缩小到今天等范围 + val candidateProcesses = if (date != null) { + productProcessRepository.findAllByDeletedIsFalseAndDate(date) + } else { + productProcessRepository.findAllByDeletedIsFalse() + } + + val filteredCandidateProcesses = candidateProcesses.filter { p -> + if (isDrink != null) { + val bomIsDrink = p.bom?.isDrink + if (bomIsDrink != isDrink) return@filter false + } + if (trimmedItemCode != null) { + val code = p.item?.code + if (code == null || !code.contains(trimmedItemCode, ignoreCase = true)) return@filter false + } + if (trimmedJobOrderCode != null) { + val code = p.jobOrder?.code + if (code == null || !code.contains(trimmedJobOrderCode, ignoreCase = true)) return@filter false + } + if (bomIds != null && bomIds.isNotEmpty()) { + val bid = p.bom?.id + if (bid == null || !bomIds.contains(bid)) return@filter false + } + true + } + + val candidateJobOrderIds = filteredCandidateProcesses + .mapNotNull { it.jobOrder?.id } + .distinct() + + if (candidateJobOrderIds.isEmpty()) { + return JobOrderProductProcessPageResponse( + content = emptyList(), + totalJobOrders = 0L, + page = safePage, + size = safeSize + ) + } + + // 2) 把候选 jobOrder 下全部 productProcess / lines 取出来用于 qcReady 判断 + val allCandidateProcesses = productProcessRepository.findByJobOrder_IdInAndDeletedIsFalse(candidateJobOrderIds) + val allCandidateProcessIds = allCandidateProcesses.mapNotNull { it.id } + + val lines = if (allCandidateProcessIds.isNotEmpty()) { + productProcessLineRepository.findByProductProcess_IdInWithOperatorAndEquipment(allCandidateProcessIds) + } else { + emptyList() + } + + val linesByProcessId = lines.groupBy { it.productProcess.id ?: 0L } + val processesByJobOrderId = allCandidateProcesses + .filter { it.jobOrder?.id != null } + .groupBy { it.jobOrder!!.id!! } + + val jobOrders = jobOrderRepository.findAllById(candidateJobOrderIds) + val jobOrderById = jobOrders.associateBy { it.id } + + val stockInLineByJobOrderId = candidateJobOrderIds.associateWith { jobOrderId -> + stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(jobOrderId) + } + + // 3) 计算每个 jobOrder 的 qcReady,并做排序/分页 + data class JobOrderQcKey( + val jobOrderId: Long, + val maxDate: LocalDate, + val minPriority: Int, + val ready: Boolean + ) + + val qcKeys = candidateJobOrderIds.mapNotNull { jobOrderId -> + val jobOrder = jobOrderById[jobOrderId] ?: return@mapNotNull null + if (jobOrder.status == JobOrderStatus.PLANNING) return@mapNotNull null + + val processes = processesByJobOrderId[jobOrderId].orEmpty() + if (processes.isEmpty()) return@mapNotNull null + + val jobOrderLines = processes.flatMap { p -> + linesByProcessId[p.id ?: 0L].orEmpty() + } + + val allLinesDone = jobOrderLines.isNotEmpty() && + jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } + + val stockInLine = stockInLineByJobOrderId[jobOrderId] + val stockStatus = stockInLine?.status + val stockInEligibleForQc = stockStatus != "completed" && stockStatus != "rejected" + + // 列表只排除 completed/rejected(和 planning),null stockInLine 也会显示在 tab0 + val includedInList = stockInEligibleForQc + val ready = includedInList && allLinesDone && stockInLine != null + + if (!includedInList) { + return@mapNotNull null + } + + val maxDate = processes.mapNotNull { it.date }.maxOrNull() ?: LocalDate.MIN + val minPriority = processes.mapNotNull { it.productionPriority }.minOrNull() ?: Int.MAX_VALUE + + JobOrderQcKey( + jobOrderId = jobOrderId, + maxDate = maxDate, + minPriority = minPriority, + ready = ready + ) + } + + val filteredQcKeys = qcKeys.filter { key -> qcReady == null || key.ready == qcReady } + val totalJobOrders = filteredQcKeys.size.toLong() + + val sortedQcKeys = filteredQcKeys.sortedWith( + compareByDescending { it.maxDate }.thenBy { it.minPriority } + ) + + val from = safePage * safeSize + if (from >= sortedQcKeys.size) { + return JobOrderProductProcessPageResponse(emptyList(), totalJobOrders, safePage, safeSize) + } + + val to = minOf(from + safeSize, sortedQcKeys.size) + val pagedJobOrderIds = sortedQcKeys.subList(from, to).map { it.jobOrderId } + val pagedJobOrderIdSet = pagedJobOrderIds.toSet() + + // 4) 构造 content:返回 paged jobOrders 下的全部 productProcess + val pickOrdersByJobOrderId = pagedJobOrderIds.associateWith { joId -> + pickOrderRepository.findAllByJobOrder_Id(joId).firstOrNull() + } + val joPickOrdersByPickOrderId = pickOrdersByJobOrderId.mapNotNull { (joId, pick) -> + val pickId = pick?.id ?: return@mapNotNull null + joId to pickId + }.map { it.second }.distinct().associateWith { pickOrderId -> + joPickOrderRepository.findByPickOrderId(pickOrderId) + } + + val timeNeedToCompleteByBomId = pagedJobOrderIds.flatMap { _ -> + // placeholder, real cache built below + emptyList() + }.toSet() + + // 缓存:按 bomId 计算 timeNeedToComplete + val bomIdsInScope = allCandidateProcesses + .filter { it.jobOrder?.id != null && pagedJobOrderIdSet.contains(it.jobOrder!!.id!!) } + .mapNotNull { it.bom?.id } + .distinct() + + val timeNeedToCompleteCache = bomIdsInScope.associateWith { bomId -> + bomProcessRepository.findByBomId(bomId) + .filter { !it.deleted } + .sumOf { it.durationInMinute ?: 0 } + } + + // 缓存:Uom(itemUom -> uomConversion.udfudesc) + val itemIdsInScope = allCandidateProcesses + .filter { it.jobOrder?.id != null && pagedJobOrderIdSet.contains(it.jobOrder!!.id!!) } + .mapNotNull { it.item?.id } + .distinct() + + val uomByItemId = itemIdsInScope.associateWith { itemId -> + val itemUom = itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId) + val bomUom = uomConversionRepository.findById(itemUom?.uom?.id ?: 0L).orElse(null) + bomUom?.udfudesc + } + + val contentProcesses = allCandidateProcesses + .filter { p -> p.jobOrder?.id != null && pagedJobOrderIdSet.contains(p.jobOrder!!.id!!) } + + val sortedContentProcesses = contentProcesses.sortedWith( + compareByDescending { it.date ?: LocalDate.MIN } + .thenBy { it.productionPriority ?: Int.MAX_VALUE } + ) + + val content = sortedContentProcesses.map { productProcess -> + val jobOrderId = productProcess.jobOrder?.id ?: 0L + val jobOrder = jobOrderById[jobOrderId] + val stockInLine = stockInLineByJobOrderId[jobOrderId] + val pickOrder = pickOrdersByJobOrderId[jobOrderId] + val pickOrderId = pickOrder?.id + val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() + + val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() + val finishedCount = productProcessLines.count { it.status == "Completed" } + + val bomIsDrink = productProcess.bom?.isDrink + val matchStatus = if (joPickOrdersList.isNotEmpty() && + joPickOrdersList.all { it.matchStatus == JoPickOrderStatus.completed } + ) { + "completed" + } else if (joPickOrdersList.any { it.matchStatus == JoPickOrderStatus.scanned }) { + "scanned" + } else { + "pending" + } + + val bomId = productProcess.bom?.id + val timeNeedToComplete = bomId?.let { timeNeedToCompleteCache[it] } ?: 0 + val uom = productProcess.item?.id?.let { uomByItemId[it] } + + AllJoborderProductProcessInfoResponse( + id = productProcess.id ?: 0L, + productProcessCode = productProcess.productProcessCode, + status = productProcess.status.value, + startTime = productProcess.startTime, + endTime = productProcess.endTime, + isDrink = bomIsDrink, + matchStatus = matchStatus, + RequiredQty = jobOrder?.reqQty?.toInt() ?: 0, + Uom = uom, + lotNo = stockInLine?.lotNo, + productionPriority = productProcess.productionPriority, + date = productProcess.date, + bomId = bomId, + TimeNeedToComplete = timeNeedToComplete, + assignedTo = pickOrder?.assignTo?.id, + itemName = productProcess.item?.name, + itemCode = productProcess.item?.code, + pickOrderId = pickOrder?.id, + pickOrderStatus = pickOrder?.status?.value, + jobOrderId = productProcess.jobOrder?.id, + stockInLineId = stockInLine?.id, + jobOrderCode = jobOrder?.code, + productProcessLineCount = productProcessLines.size, + FinishedProductProcessLineCount = finishedCount, + lines = productProcessLines.map { line -> + ProductProcessInfoResponse( + id = line.id ?: 0, + operatorId = line.operator?.id, + operatorName = line.operator?.name ?: "", + equipmentId = line.equipment?.id, + equipmentName = line.equipmentType ?: "", + startTime = line.startTime, + endTime = line.endTime, + status = line.status ?: "" + ) + } + ) + } + + return JobOrderProductProcessPageResponse( + content = content, + totalJobOrders = totalJobOrders, + page = safePage, + size = safeSize + ) + } + open fun updateProductProcessLineStartTime(productProcessLineId: Long): MessageResponse { val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) productProcessLine.startTime = LocalDateTime.now() diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt index 82c58a4..b1806b1 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt @@ -198,6 +198,38 @@ class ProductProcessController( ): List { return productProcessService.getAllJoborderProductProcessInfo(isDrink) } + + @GetMapping("/Demo/Process/search") + fun demoprocesssearch( + @RequestParam(required = false) date: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) jobOrderCode: String?, + @RequestParam(required = false) bomIds: String?, + @RequestParam(required = false) qcReady: Boolean?, + @RequestParam(required = false) isDrink: Boolean?, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "50") size: Int + ): JobOrderProductProcessPageResponse { + val parsedDate = date?.takeIf { it.isNotBlank() }?.let { + LocalDate.parse(it, DateTimeFormatter.ISO_DATE) + } + + val parsedBomIds: List? = bomIds + ?.takeIf { it.isNotBlank() } + ?.split(",") + ?.mapNotNull { token -> token.trim().toLongOrNull() } + + return productProcessService.searchJoborderProductProcessesPaged( + date = parsedDate, + itemCode = itemCode, + jobOrderCode = jobOrderCode, + bomIds = parsedBomIds, + qcReady = qcReady, + isDrink = isDrink, + page = page, + size = size + ) + } @PostMapping("/Demo/ProcessLine/start/{lineId}") fun startProductProcessLine(@PathVariable lineId: Long): MessageResponse { return productProcessService.StartProductProcessLine(lineId) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt index 5be0981..e6fa01b 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt @@ -185,6 +185,13 @@ data class AllJoborderProductProcessInfoResponse( val isDrink: Boolean?, val lines: List ) + +data class JobOrderProductProcessPageResponse( + val content: List, + val totalJobOrders: Long, + val page: Int, + val size: Int +) data class ProductProcessInfoResponse( val id: Long, val operatorId: Long?, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 019fb62..6ba9fa8 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -630,7 +630,7 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { val stockOutLine = stockOutLineRepository.findById(request.id).orElseThrow { IllegalArgumentException("StockOutLine not found with ID: ${request.id}") } - + println("Updating StockOutLine ID: ${request.id}") println("Current status: ${stockOutLine.status}") println("New status: ${request.status}") @@ -652,6 +652,37 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { stockOutLine.qty = (newQty) } val savedStockOutLine = stockOutLineRepository.saveAndFlush(stockOutLine) + + val statusLower = request.status.trim().lowercase() + val isPickEnd = statusLower == "completed" || statusLower == "partially_completed" + val deltaQty = request.qty + + // non jo/do: 手工拣货增量扣减时,補寫出库 ledger(幂等 + 跳过 jo/do,避免 double write) + if (isPickEnd && deltaQty != null && deltaQty > 0 && savedStockOutLine.id != null) { + val itemId = savedStockOutLine.item?.id + val po = savedStockOutLine.pickOrderLine?.pickOrder + + if (itemId != null && po != null) { + val isJoOrDo = (po.jobOrder != null || po.deliveryOrder != null) + if (!isJoOrDo) { + val exists = jdbcDao.queryForInt( + "SELECT COUNT(*) FROM stock_ledger WHERE deleted = false AND stockOutLineId = :id", + mapOf("id" to savedStockOutLine.id) + ) > 0 + + if (!exists) { + val invBefore = inventoryRepository.findByItemId(itemId).orElse(null) + val onHandBefore = invBefore?.onHandQty?.toDouble() ?: 0.0 + + createStockLedgerForPickDelta( + stockOutLineId = savedStockOutLine.id!!, + deltaQty = BigDecimal(deltaQty.toString()), + onHandQtyBeforeUpdate = onHandBefore + ) + } + } + } + } println("Updated StockOutLine: ${savedStockOutLine.id} with status: ${savedStockOutLine.status}") // If this stock out line is in end status, try completing its pick order line if (isEndStatus(savedStockOutLine.status)) {