| @@ -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"))) | |||
| @@ -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<JobOrderFgAlertRowResponse>() | |||
| val putAwayRows = mutableListOf<JobOrderFgAlertRowResponse>() | |||
| 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<JobOrderFgAlertRowResponse> { 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() | |||
| @@ -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) | |||
| @@ -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<JobOrderFgAlertRowResponse>, | |||
| val putAway: List<JobOrderFgAlertRowResponse>, | |||
| ) | |||
| data class ProductProcessInfoResponse( | |||
| val id: Long, | |||
| val operatorId: Long?, | |||
| @@ -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<Long> | |||
| ): List<PurchaseOrderSummary> { | |||
| @@ -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<PurchaseStockInAlertRow> | |||
| } | |||
| @@ -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<StockInLine, Long, StockInLineRepository>(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<Long, Boolean>() | |||
| 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<PurchaseStockInAlertRow> { | |||
| val cap = limit.coerceIn(1, 200) | |||
| val out = mutableListOf<PurchaseStockInAlertRow>() | |||
| val suppressCache = mutableMapOf<Long, Boolean>() | |||
| 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<Long, Boolean>): 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" | |||
| @@ -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<String, Long> { | |||
| 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<PurchaseStockInAlertRow> { | |||
| val since = stockInLineService.purchaseStockInAlertSinceDays(days) | |||
| return stockInLineService.listPurchaseStockInAlerts(since, limit) | |||
| } | |||
| @GetMapping("/{stockInLineId}") | |||
| fun get(@PathVariable stockInLineId: Long): StockInLineInfo { | |||
| return stockInLineService.getStockInLineInfo(stockInLineId) | |||
| @@ -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?, | |||
| ) | |||
| @@ -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: | |||