diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 48fca04..a234894 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -23,6 +23,8 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic import com.ffii.fpsms.config.security.jwt.JwtRequestFilter; +import org.springframework.http.HttpMethod; + import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -82,6 +84,13 @@ public class SecurityConfig { authRequest -> authRequest .requestMatchers(URL_WHITELIST).permitAll() .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() + /* PO stock-in nav alerts: TESTING / ADMIN / STOCK (no @PreAuthorize on Kotlin controllers). */ + .requestMatchers(HttpMethod.GET, "/stockInLine/alerts/purchase-incomplete-count") + .hasAnyAuthority("TESTING", "ADMIN", "STOCK") + .requestMatchers(HttpMethod.GET, "/stockInLine/alerts/purchase-incomplete") + .hasAnyAuthority("TESTING", "ADMIN", "STOCK") + .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") + .hasAuthority("TESTING") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) 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 88867a3..464e406 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 @@ -1760,6 +1760,89 @@ open class ProductProcessService( ) } + /** + * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass, stock-in exists and not completed/rejected; + * only job orders that have a product process dated **today or yesterday** (server local date). + */ + open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { + val today = LocalDate.now() + val yesterday = today.minusDays(1) + + val processesToday = productProcessRepository.findAllByDeletedIsFalseAndDate(today) + val processesYesterday = productProcessRepository.findAllByDeletedIsFalseAndDate(yesterday) + val candidateJobOrderIds = (processesToday + processesYesterday) + .asSequence() + .filter { it.jobOrder?.isHidden != true } + .mapNotNull { it.jobOrder?.id } + .distinct() + .toList() + + if (candidateJobOrderIds.isEmpty()) { + return JobOrderFgAlertsResponse(qc = emptyList(), putAway = emptyList()) + } + + val allProcesses = productProcessRepository.findByJobOrder_IdInAndDeletedIsFalse(candidateJobOrderIds) + val allProcessIds = allProcesses.mapNotNull { it.id } + val lines = if (allProcessIds.isNotEmpty()) { + productProcessLineRepository.findByProductProcess_IdInWithOperatorAndEquipment(allProcessIds) + } else { + emptyList() + } + + val linesByProcessId = lines.groupBy { it.productProcess.id ?: 0L } + val processesByJobOrderId = allProcesses + .filter { it.jobOrder?.id != null } + .groupBy { it.jobOrder!!.id!! } + + val jobOrders = jobOrderRepository.findAllById(candidateJobOrderIds) + val jobOrderById = jobOrders.associateBy { it.id } + + val qcRows = mutableListOf() + val putAwayRows = mutableListOf() + + for (jobOrderId in candidateJobOrderIds) { + val jobOrder = jobOrderById[jobOrderId] ?: continue + if (jobOrder.status == JobOrderStatus.PLANNING) continue + + val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(jobOrderId) ?: continue + if (stockInLine.purchaseOrderLine != null) continue + + val statusNorm = (stockInLine.status ?: "").trim().lowercase() + if (statusNorm == "completed" || statusNorm == "rejected") continue + + val processes = processesByJobOrderId[jobOrderId].orEmpty() + if (processes.isEmpty()) continue + + val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } + val allLinesDone = jobOrderLines.isNotEmpty() && + jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } + if (!allLinesDone) continue + + val maxDate = processes.mapNotNull { it.date }.maxOrNull() + val row = JobOrderFgAlertRowResponse( + stockInLineId = stockInLine.id!!, + jobOrderId = jobOrderId, + jobOrderCode = jobOrder.code, + itemNo = stockInLine.itemNo, + itemName = stockInLine.item?.name, + status = stockInLine.status, + processDate = maxDate, + lotNo = stockInLine.lotNo, + ) + + when (statusNorm) { + "received", "partially_completed" -> putAwayRows.add(row) + else -> qcRows.add(row) + } + } + + val sortByDate = compareByDescending { it.processDate ?: LocalDate.MIN } + return JobOrderFgAlertsResponse( + qc = qcRows.sortedWith(sortByDate), + putAway = putAwayRows.sortedWith(sortByDate), + ) + } + 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 b1806b1..f9177f8 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 @@ -230,6 +230,12 @@ class ProductProcessController( size = size ) } + + /** Nav: QC vs 上架 reminders (今日/昨日產程、等同完成QC工單列表資格). */ + @GetMapping("/Demo/Process/alerts/fg-qc-putaway") + fun getFgQcPutAwayAlerts(): JobOrderFgAlertsResponse = + productProcessService.findJobOrderFgQcAndPutAwayAlertsForTodayYesterday() + @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 e6fa01b..1c2b146 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 @@ -192,6 +192,27 @@ data class JobOrderProductProcessPageResponse( val page: Int, val size: Int ) + +/** + * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass, stock-in not completed/rejected; + * job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed. + */ +data class JobOrderFgAlertRowResponse( + val stockInLineId: Long, + val jobOrderId: Long, + val jobOrderCode: String?, + val itemNo: String?, + val itemName: String?, + val status: String?, + val processDate: LocalDate?, + val lotNo: String?, +) + +data class JobOrderFgAlertsResponse( + val qc: List, + val putAway: List, +) + data class ProductProcessInfoResponse( val id: Long, val operatorId: Long?, diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt index 4e3c769..cced27c 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt @@ -57,7 +57,8 @@ class PurchaseOrderController( return RecordsRes(paginatedList, fullList.size) } - @GetMapping("/po/summary") + /** Class mapping is `/po`; path must be `/summary` → full path `/api/po/summary` (not `/po/po/summary`). */ + @GetMapping("/summary") fun getPoSummaries( @RequestParam ids: List ): List { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt index ede7185..35f8cfb 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt @@ -3,10 +3,13 @@ package com.ffii.fpsms.modules.stock.entity import com.ffii.core.support.AbstractRepository import com.ffii.fpsms.modules.stock.entity.projection.QrCodeInfo import com.ffii.fpsms.modules.stock.entity.projection.StockInLineInfo +import com.ffii.fpsms.modules.stock.web.model.PurchaseStockInAlertRow +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository import java.util.Optional import java.time.LocalDate +import java.time.LocalDateTime import org.springframework.data.repository.query.Param @@ -118,4 +121,37 @@ fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? and sil.lotNo like concat(:prefix, '%') """) fun findLatestLotNoByPrefix(@Param("prefix") prefix: String): String? + + /** + * PO stock-in lines in pending / receiving, created on or after [since] (recent backlog). + * ([StockInLineService] filters out lines whose POL already has put-away >= order qty in stock units.) + */ + @Query( + """ + SELECT new com.ffii.fpsms.modules.stock.web.model.PurchaseStockInAlertRow( + sil.id, + po.id, + pol.id, + po.code, + sil.itemNo, + item.name, + sil.status, + sil.created, + sil.receiptDate, + sil.lotNo + ) + FROM StockInLine sil + JOIN sil.purchaseOrderLine pol + JOIN sil.purchaseOrder po + JOIN sil.item item + WHERE sil.deleted = false + AND LOWER(COALESCE(sil.status, '')) IN ('pending', 'receiving') + AND sil.created >= :since + ORDER BY sil.created DESC + """ + ) + fun listPurchaseStockInAlertsSince( + @Param("since") since: LocalDateTime, + pageable: Pageable, + ): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 784b10d..ddfd673 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -54,6 +54,7 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintQrCodeForDoRequest import com.ffii.fpsms.modules.stock.service.InventoryLotLineService import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository import com.ffii.fpsms.modules.stock.entity.InventoryRepository +import org.springframework.data.domain.PageRequest import org.springframework.http.HttpStatus import org.springframework.web.server.ResponseStatusException import kotlin.text.toDouble @@ -105,6 +106,8 @@ open class StockInLineService( @Lazy private val m18PurchaseOrderService: M18PurchaseOrderService, /** When false (default), M18 GRN create API is not called — set true in production only. */ @Value("\${scheduler.m18Grn.createEnabled:false}") private val m18GrnCreateEnabled: Boolean, + /** Recent window for PO stock-in alerts (pending / receiving) in nav / list UI. */ + @Value("\${fpsms.purchase-stock-in-alert.lookback-days:7}") private val purchaseStockInAlertLookbackDays: Int, ) : AbstractBaseEntityService(jdbcDao, stockInLineRepository) { private val logger = LoggerFactory.getLogger(StockInLineService::class.java) @@ -118,6 +121,89 @@ open class StockInLineService( open fun getReceivedStockInLineInfo(stockInLineId: Long): StockInLineInfo { return stockInLineRepository.findStockInLineInfoByIdAndStatusAndDeletedFalse(id = stockInLineId, status = StockInLineStatus.RECEIVED.status).orElseThrow() } + + open fun purchaseStockInAlertSinceDays(overrideDays: Int?): LocalDateTime { + val d = (overrideDays ?: purchaseStockInAlertLookbackDays).coerceIn(1, 90) + return LocalDateTime.now().minusDays(d.toLong()) + } + + /** + * When total put-away (inventory lot line inQty, stock unit) for the POL is already at or above + * order demand in stock units, do not count / list the line for PO stock-in reminders. + */ + @Transactional(readOnly = true) + open fun countPurchaseStockInAlertsSince(since: LocalDateTime): Long { + val suppressCache = mutableMapOf() + var total = 0L + var page = 0 + val pageSize = 100 + while (true) { + val batch = stockInLineRepository.listPurchaseStockInAlertsSince(since, PageRequest.of(page, pageSize)) + if (batch.isEmpty()) break + total += batch.count { !polPutAwaySuppressesPoStockInAlert(it.purchaseOrderLineId, suppressCache) } + page++ + if (batch.size < pageSize) break + } + return total + } + + @Transactional(readOnly = true) + open fun listPurchaseStockInAlerts(since: LocalDateTime, limit: Int): List { + val cap = limit.coerceIn(1, 200) + val out = mutableListOf() + val suppressCache = mutableMapOf() + var page = 0 + val pageSize = 50 + while (out.size < cap) { + val batch = stockInLineRepository.listPurchaseStockInAlertsSince(since, PageRequest.of(page, pageSize)) + if (batch.isEmpty()) break + for (row in batch) { + if (!polPutAwaySuppressesPoStockInAlert(row.purchaseOrderLineId, suppressCache)) { + out.add(row) + } + if (out.size >= cap) break + } + page++ + if (batch.size < pageSize) break + } + return out + } + + private fun polPutAwaySuppressesPoStockInAlert(polId: Long, cache: MutableMap): Boolean = + cache.getOrPut(polId) { + val pol = polRepository.findById(polId).orElse(null) ?: return@getOrPut false + val orderStock = polOrderStockQtyForAlert(pol) + if (orderStock <= BigDecimal.ZERO) return@getOrPut false + val putAway = totalPutAwayStockQtyForPol(polId) + putAway.compareTo(orderStock) >= 0 + } + + private fun polOrderStockQtyForAlert(pol: PurchaseOrderLine): BigDecimal { + val itemId = pol.item?.id ?: return BigDecimal.ZERO + val qtyM18 = pol.qtyM18 + val uomM18Id = pol.uomM18?.id + val stockQtyFromM18 = + if (qtyM18 != null && uomM18Id != null) { + itemUomService.convertQtyToStockQtyPrecise(itemId, uomM18Id, qtyM18) + } else { + BigDecimal.ZERO + } + return if (stockQtyFromM18 > BigDecimal.ZERO) { + stockQtyFromM18 + } else { + itemUomService.convertPurchaseQtyToStockQtyPrecise(itemId, pol.qty ?: BigDecimal.ZERO) + } + } + + private fun totalPutAwayStockQtyForPol(polId: Long): BigDecimal { + return stockInLineRepository.findStockInLineInfoByPurchaseOrderLineIdAndDeletedFalse(polId) + .asSequence() + .flatMap { it.putAwayLines?.asSequence() ?: emptySequence() } + .fold(BigDecimal.ZERO) { acc, line -> + acc + (line.stockQty ?: BigDecimal.ZERO).coerceAtLeast(BigDecimal.ZERO) + } + } + /* open fun assignLotNo(): String { val prefix = "LT" diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockInLineController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockInLineController.kt index f9fbdca..aea4d51 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockInLineController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockInLineController.kt @@ -6,6 +6,7 @@ import com.ffii.fpsms.modules.stock.entity.StockInLine import com.ffii.fpsms.modules.stock.entity.projection.StockInLineInfo import com.ffii.fpsms.modules.stock.service.StockInLineService import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest +import com.ffii.fpsms.modules.stock.web.model.PurchaseStockInAlertRow import com.ffii.fpsms.modules.stock.web.model.PrintQrCodeForSilRequest import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest import jakarta.servlet.http.HttpServletResponse @@ -25,6 +26,25 @@ import java.text.ParseException class StockInLineController( private val stockInLineService: StockInLineService ) { + /** Count of recent PO stock-in lines in pending / receiving (same filter as [listPurchaseStockInAlerts]). */ + @GetMapping("/alerts/purchase-incomplete-count") + fun getPurchaseStockInIncompleteCount( + @RequestParam(name = "days", required = false) days: Int?, + ): Map { + val since = stockInLineService.purchaseStockInAlertSinceDays(days) + return mapOf("count" to stockInLineService.countPurchaseStockInAlertsSince(since)) + } + + /** Actionable list: recent PO stock-in lines in pending / receiving (deep-link to PO edit). */ + @GetMapping("/alerts/purchase-incomplete") + fun listPurchaseStockInAlerts( + @RequestParam(name = "days", required = false) days: Int?, + @RequestParam(name = "limit", defaultValue = "50") limit: Int, + ): List { + val since = stockInLineService.purchaseStockInAlertSinceDays(days) + return stockInLineService.listPurchaseStockInAlerts(since, limit) + } + @GetMapping("/{stockInLineId}") fun get(@PathVariable stockInLineId: Long): StockInLineInfo { return stockInLineService.getStockInLineInfo(stockInLineId) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/PurchaseStockInAlertRow.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/PurchaseStockInAlertRow.kt new file mode 100644 index 0000000..01fe2ac --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/PurchaseStockInAlertRow.kt @@ -0,0 +1,19 @@ +package com.ffii.fpsms.modules.stock.web.model + +import java.time.LocalDateTime + +/** + * PO-linked stock-in line needing user action (pending / receiving), for alert list UI. + */ +data class PurchaseStockInAlertRow( + val stockInLineId: Long, + val purchaseOrderId: Long, + val purchaseOrderLineId: Long, + val poCode: String?, + val itemNo: String?, + val itemName: String?, + val status: String?, + val lineCreated: LocalDateTime?, + val receiptDate: LocalDateTime?, + val lotNo: String?, +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f6d66b6..d6275a9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,6 +25,11 @@ scheduler: inventoryLotExpiry: enabled: true +# Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). +fpsms: + purchase-stock-in-alert: + lookback-days: 7 + spring: servlet: multipart: