diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index c9dbf70..ea18f0b 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -17,8 +17,8 @@ on: workflow_dispatch: concurrency: - group: backend-${{ github.ref_name }} - cancel-in-progress: true + group: backend-gitops-dev + cancel-in-progress: false env: AWS_REGION: ap-northeast-2 @@ -26,7 +26,9 @@ env: ECR_REPOSITORY: aims/backend NAMESPACE: aims-project BACKEND_CONTEXT: . + INFRA_REPOSITORY: SK-Rookies-AIMS/infra + INFRA_BRANCH: dev INFRA_MANIFEST_PATH: k8s/backend/kustomization.yaml jobs: @@ -53,47 +55,142 @@ jobs: - name: Ensure ECR repository exists run: | - aws ecr describe-repositories --repository-names "$ECR_REPOSITORY" --region "$AWS_REGION" >/dev/null 2>&1 || aws ecr create-repository --repository-name "$ECR_REPOSITORY" --region "$AWS_REGION" --image-scanning-configuration scanOnPush=true --image-tag-mutability MUTABLE + set -euo pipefail + + if ! aws ecr describe-repositories \ + --repository-names "$ECR_REPOSITORY" \ + --region "$AWS_REGION" \ + > /dev/null 2>&1; then + + aws ecr create-repository \ + --repository-name "$ECR_REPOSITORY" \ + --region "$AWS_REGION" \ + --image-scanning-configuration scanOnPush=true \ + --image-tag-mutability MUTABLE + fi - name: Build and push Docker image id: build-image run: | + set -euo pipefail + IMAGE_TAG="${GITHUB_REF_NAME}-${GITHUB_SHA::12}" IMAGE_URI="${{ steps.login-ecr.outputs.registry }}/${ECR_REPOSITORY}:${IMAGE_TAG}" BRANCH_IMAGE_URI="${{ steps.login-ecr.outputs.registry }}/${ECR_REPOSITORY}:${GITHUB_REF_NAME}" - docker build -t "$IMAGE_URI" -t "$BRANCH_IMAGE_URI" "$BACKEND_CONTEXT" + echo "Building image: $IMAGE_URI" + + docker build \ + -t "$IMAGE_URI" \ + -t "$BRANCH_IMAGE_URI" \ + "$BACKEND_CONTEXT" + docker push "$IMAGE_URI" docker push "$BRANCH_IMAGE_URI" echo "image_uri=$IMAGE_URI" >> "$GITHUB_OUTPUT" + echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" - name: Checkout infra repository uses: actions/checkout@v4 with: repository: ${{ env.INFRA_REPOSITORY }} - ref: ${{ github.ref_name }} + ref: ${{ env.INFRA_BRANCH }} token: ${{ secrets.INFRA_REPO_TOKEN }} path: infra fetch-depth: 0 - name: Update kubeconfig and ensure namespace - if: github.ref_name == 'dev' run: | - aws eks update-kubeconfig --region "$AWS_REGION" --name "$EKS_CLUSTER_NAME" - kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - + set -euo pipefail + + aws eks update-kubeconfig \ + --region "$AWS_REGION" \ + --name "$EKS_CLUSTER_NAME" + + kubectl create namespace "$NAMESPACE" \ + --dry-run=client \ + -o yaml | + kubectl apply -f - - name: Load backend runtime parameters from SSM - if: github.ref_name == 'dev' run: | - RDS_SECRET_ARN=$(aws ssm get-parameter --name "/aims/dev/rds/secret-arn" --region "$AWS_REGION" --query "Parameter.Value" --output text) - RDS_HOST=$(aws ssm get-parameter --name "/aims/dev/backend/rds-host" --region "$AWS_REGION" --query "Parameter.Value" --output text) - RDS_PORT=$(aws ssm get-parameter --name "/aims/dev/backend/rds-port" --region "$AWS_REGION" --query "Parameter.Value" --output text) - MAIN_DB_NAME=$(aws ssm get-parameter --name "/aims/dev/backend/main-db-name" --region "$AWS_REGION" --query "Parameter.Value" --output text) - SAMPLE_DB_NAME=$(aws ssm get-parameter --name "/aims/dev/backend/sample-db-name" --region "$AWS_REGION" --query "Parameter.Value" --output text) - JWT_SECRET_KEY=$(aws ssm get-parameter --name "/aims/dev/backend/jwt-secret-key" --region "$AWS_REGION" --with-decryption --query "Parameter.Value" --output text) - - for VALUE in "$RDS_SECRET_ARN" "$RDS_HOST" "$RDS_PORT" "$MAIN_DB_NAME" "$SAMPLE_DB_NAME" "$JWT_SECRET_KEY"; do + set -euo pipefail + + RDS_SECRET_ARN=$(aws ssm get-parameter \ + --name "/aims/dev/rds/secret-arn" \ + --region "$AWS_REGION" \ + --query "Parameter.Value" \ + --output text) + + RDS_HOST=$(aws ssm get-parameter \ + --name "/aims/dev/backend/rds-host" \ + --region "$AWS_REGION" \ + --query "Parameter.Value" \ + --output text) + + RDS_PORT=$(aws ssm get-parameter \ + --name "/aims/dev/backend/rds-port" \ + --region "$AWS_REGION" \ + --query "Parameter.Value" \ + --output text) + + MAIN_DB_NAME=$(aws ssm get-parameter \ + --name "/aims/dev/backend/main-db-name" \ + --region "$AWS_REGION" \ + --query "Parameter.Value" \ + --output text) + + SAMPLE_DB_NAME=$(aws ssm get-parameter \ + --name "/aims/dev/backend/sample-db-name" \ + --region "$AWS_REGION" \ + --query "Parameter.Value" \ + --output text) + + JWT_SECRET_KEY=$(aws ssm get-parameter \ + --name "/aims/dev/backend/jwt-secret-key" \ + --region "$AWS_REGION" \ + --with-decryption \ + --query "Parameter.Value" \ + --output text) + + REDIS_HOST=$(aws ssm get-parameter \ + --name "/aims/dev/redis/host" \ + --region "$AWS_REGION" \ + --query "Parameter.Value" \ + --output text) + + REDIS_PORT=$(aws ssm get-parameter \ + --name "/aims/dev/redis/port" \ + --region "$AWS_REGION" \ + --query "Parameter.Value" \ + --output text) + + KAFKA_BOOTSTRAP_SERVER_1=$(aws ssm get-parameter \ + --name "/aims/dev/msk/bootstrap-server-1" \ + --region "$AWS_REGION" \ + --query "Parameter.Value" \ + --output text) + + + KAFKA_BOOTSTRAP_SERVER_2=$(aws ssm get-parameter \ + --name "/aims/dev/msk/bootstrap-server-2" \ + --region "$AWS_REGION" \ + --query "Parameter.Value" \ + --output text) + + for VALUE in \ + "$RDS_SECRET_ARN" \ + "$RDS_HOST" \ + "$RDS_PORT" \ + "$MAIN_DB_NAME" \ + "$SAMPLE_DB_NAME" \ + "$JWT_SECRET_KEY" \ + "$REDIS_HOST" \ + "$REDIS_PORT" \ + "$KAFKA_BOOTSTRAP_SERVER_1" \ + "$KAFKA_BOOTSTRAP_SERVER_2"; do + if [ -z "$VALUE" ] || [ "$VALUE" = "None" ]; then echo "Required backend SSM parameter is empty" exit 1 @@ -109,21 +206,36 @@ jobs: echo "MAIN_DB_NAME=$MAIN_DB_NAME" >> "$GITHUB_ENV" echo "SAMPLE_DB_NAME=$SAMPLE_DB_NAME" >> "$GITHUB_ENV" echo "JWT_SECRET_KEY=$JWT_SECRET_KEY" >> "$GITHUB_ENV" + echo "REDIS_HOST=$REDIS_HOST" >> "$GITHUB_ENV" + echo "REDIS_PORT=$REDIS_PORT" >> "$GITHUB_ENV" + echo "KAFKA_BOOTSTRAP_SERVER_1=$KAFKA_BOOTSTRAP_SERVER_1" >> "$GITHUB_ENV" + echo "KAFKA_BOOTSTRAP_SERVER_2=$KAFKA_BOOTSTRAP_SERVER_2" >> "$GITHUB_ENV" - name: Create or update backend Kubernetes Secret - if: github.ref_name == 'dev' run: | - SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id "$RDS_SECRET_ARN" --region "$AWS_REGION" --query "SecretString" --output text) + set -euo pipefail - DB_USERNAME=$(echo "$SECRET_JSON" | jq -r '.username') - DB_PASSWORD=$(echo "$SECRET_JSON" | jq -r '.password') + SECRET_JSON=$(aws secretsmanager get-secret-value \ + --secret-id "$RDS_SECRET_ARN" \ + --region "$AWS_REGION" \ + --query "SecretString" \ + --output text) - if [ -z "$DB_USERNAME" ] || [ "$DB_USERNAME" = "null" ] || [ -z "$DB_PASSWORD" ] || [ "$DB_PASSWORD" = "null" ]; then - echo "RDS username or password is missing" + DB_USERNAME=$(echo "$SECRET_JSON" | jq -r ".username") + DB_PASSWORD=$(echo "$SECRET_JSON" | jq -r ".password") + + if [ -z "$DB_USERNAME" ] || [ "$DB_USERNAME" = "null" ]; then + echo "RDS username is missing" + exit 1 + fi + + if [ -z "$DB_PASSWORD" ] || [ "$DB_PASSWORD" = "null" ]; then + echo "RDS password is missing" exit 1 fi MAIN_DB_JDBC_URL="jdbc:mysql://$RDS_HOST:$RDS_PORT/$MAIN_DB_NAME?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8" + SAMPLE_DB_JDBC_URL="jdbc:mysql://$RDS_HOST:$RDS_PORT/$SAMPLE_DB_NAME?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8" echo "::add-mask::$DB_USERNAME" @@ -131,22 +243,85 @@ jobs: echo "::add-mask::$MAIN_DB_JDBC_URL" echo "::add-mask::$SAMPLE_DB_JDBC_URL" - kubectl create secret generic backend-rds-secret -n "$NAMESPACE" --from-literal=MAIN_DB_JDBC_URL="$MAIN_DB_JDBC_URL" --from-literal=MAIN_DB_USERNAME="$DB_USERNAME" --from-literal=MAIN_DB_PASSWORD="$DB_PASSWORD" --from-literal=SAMPLE_DB_JDBC_URL="$SAMPLE_DB_JDBC_URL" --from-literal=SAMPLE_DB_USERNAME="$DB_USERNAME" --from-literal=SAMPLE_DB_PASSWORD="$DB_PASSWORD" --from-literal=JWT_SECRET_KEY="$JWT_SECRET_KEY" --dry-run=client -o yaml | kubectl apply -f - + kubectl create secret generic backend-rds-secret \ + --namespace "$NAMESPACE" \ + --from-literal=MAIN_DB_JDBC_URL="$MAIN_DB_JDBC_URL" \ + --from-literal=MAIN_DB_USERNAME="$DB_USERNAME" \ + --from-literal=MAIN_DB_PASSWORD="$DB_PASSWORD" \ + --from-literal=SAMPLE_DB_JDBC_URL="$SAMPLE_DB_JDBC_URL" \ + --from-literal=SAMPLE_DB_USERNAME="$DB_USERNAME" \ + --from-literal=SAMPLE_DB_PASSWORD="$DB_PASSWORD" \ + --from-literal=JWT_SECRET_KEY="$JWT_SECRET_KEY" \ + --from-literal=REDIS_HOST="$REDIS_HOST" \ + --from-literal=REDIS_PORT="$REDIS_PORT" \ + --from-literal=KAFKA_BOOTSTRAP_SERVER_1="$KAFKA_BOOTSTRAP_SERVER_1" \ + --from-literal=KAFKA_BOOTSTRAP_SERVER_2="$KAFKA_BOOTSTRAP_SERVER_2" \ + --dry-run=client \ + -o yaml | + kubectl apply -f - - name: Update backend image in GitOps repository env: IMAGE_URI: ${{ steps.build-image.outputs.image_uri }} run: | + set -euo pipefail + MANIFEST="infra/${INFRA_MANIFEST_PATH}" - test -f "$MANIFEST" + if [ ! -f "$MANIFEST" ]; then + echo "Manifest file not found: $MANIFEST" + exit 1 + fi + IMAGE_TAG="${IMAGE_URI##*:}" - - sed -i -E \ - "s|^([[:space:]]*)newTag:.*$|\1newTag: ${IMAGE_TAG}|" \ - "$MANIFEST" - - grep -n "newTag:" "$MANIFEST" + IMAGE_NAME="${IMAGE_URI%:*}" + TEMP_FILE="${MANIFEST}.tmp" + + echo "Manifest: $MANIFEST" + echo "Image name: $IMAGE_NAME" + echo "Image tag: $IMAGE_TAG" + + awk \ + -v image_name="$IMAGE_NAME" \ + -v image_tag="$IMAGE_TAG" \ + ' + BEGIN { + in_target = 0 + updated = 0 + } + + { + if ($1 == "-" && $2 == "name:") { + in_target = ($3 == image_name) + } + + if (in_target && $1 == "newTag:") { + match($0, /^[[:space:]]*/) + indent = substr($0, 1, RLENGTH) + + print indent "newTag: " image_tag + + updated = 1 + in_target = 0 + next + } + + print + } + + END { + if (!updated) { + print "Backend image block or newTag was not found" > "/dev/stderr" + exit 42 + } + } + ' \ + "$MANIFEST" > "$TEMP_FILE" + + mv "$TEMP_FILE" "$MANIFEST" + + echo "Updated backend image:" + grep -n -A 2 -B 1 "$IMAGE_NAME" "$MANIFEST" cd infra @@ -160,12 +335,18 @@ jobs: exit 0 fi - git commit -m "chore(backend): deploy ${GITHUB_REF_NAME}-${GITHUB_SHA::12}" + git diff --cached + + git commit \ + -m "chore(backend): deploy ${GITHUB_REF_NAME}-${GITHUB_SHA::12}" for ATTEMPT in 1 2 3; do - git pull --rebase origin "$GITHUB_REF_NAME" + echo "GitOps push attempt: $ATTEMPT" + + git pull --rebase origin "$INFRA_BRANCH" - if git push origin "HEAD:$GITHUB_REF_NAME"; then + if git push origin "HEAD:$INFRA_BRANCH"; then + echo "GitOps image update pushed successfully" exit 0 fi diff --git a/README.md b/README.md index b665cbf..a9c620a 100644 --- a/README.md +++ b/README.md @@ -1,225 +1,311 @@ -# backend-main +# AIMS - Backend +### AIMS (Auto Intelligence Manufacturing System) - AI 기반 자동차 스마트팩토리 관제 시스템 + +`backend`는 AIMS의 **Spring Boot 기반 Main Backend 서비스**입니다. 제조/분석 서비스가 Kafka로 전달한 알림 및 공정 분석 이벤트를 소비하고, 알림 이력·분석 결과·AGV 운영 상태를 운영 화면에서 조회할 수 있도록 API와 실시간 WebSocket 메시지를 제공합니다. + +이 Backend가 담당하는 핵심 기능은 다음과 같습니다. + +- Kafka의 `factory.manufacturing.alert` 이벤트를 검증·중복 제거·점수화하여 `AlertEvent`로 저장합니다. +- Kafka의 `factory.manufacturing.analysis` 이벤트 중 `PROCESS_RISK_ANALYSIS` 결과를 이용해 정상 공정의 AGV 배차를 요청합니다. +- 알림 조치 이력과 공정별 분석 결과를 바탕으로 유사 장애 조치 추천을 제공합니다. 이는 현재 코드상 별도 AI 모델 호출이 아니라 Main DB의 유사 분석 결과와 조치 타임라인을 조회하는 방식입니다. +- Redis에 AGV 대기열 및 실시간 운행 상태를 관리하고, `/topic/alerts`와 `/topic/agv`로 운영 화면에 변경 사항을 전달합니다. +- OpenSearch Client 연결 설정은 존재하지만, 이 저장소에서 OpenSearch로 분석 결과를 색인하거나 조회하는 서비스 로직은 확인되지 않습니다. 현재 주된 영속화는 Main DB와 Redis입니다. + +## 핵심 구조 + +```mermaid +flowchart LR + Producer[제조 assembly-service] + Kafka[(Kafka / MSK)] + Backend[AIMS Backend] + MainDB[(MySQL Main DB)] + Redis[(Redis)] + Client[Dashboard Client] + Assembly[Assembly Service] + + Producer -->|factory.manufacturing.alert| Kafka + Producer -->|factory.manufacturing.analysis| Kafka + Kafka -->|AlertEventConsumer| Backend + Kafka -->|ManufacturingAnalysisConsumer| Backend + Backend --> MainDB + Backend --> Redis + Backend -->|STOMP /topic/alerts, /topic/agv| Client + Backend -->|POST /api/internal/agv-arrivals| Assembly +``` -`backend-main`은 AIMS 알림/이벤트 처리 API를 제공하는 Spring Boot 백엔드 서비스입니다. 알림 조회, 상세 확인, 조치 처리, AI 대응 추천, 인증, Gateway 연동을 담당합니다. +현재 Backend 코드에는 `KafkaTemplate` Producer Bean이 정의되어 있지만, 애플리케이션 서비스에서 Kafka로 메시지를 발행하는 호출은 확인되지 않습니다. 따라서 실제 입력 Producer는 외부 제조/분석 서비스로 보는 것이 맞습니다. + +## 알림 처리 + + + + + + + + + + + + + + + + + + +
알림 우선순위 구조알림 목록
+ 알림 우선순위 구조 + + 알림 목록 +
알림 조치알림 상세
+ 알림 조치 + + 알림 조치 후 상세 +
+ +알림은 `factory.manufacturing.alert` 토픽을 `AlertEventConsumer`가 소비합니다. `AlertEventSaveService`는 JSON을 정규화하고 중복 이벤트를 걸러낸 뒤 점수를 계산하여 DB에 저장합니다. + +### 처리 순서 + +```mermaid +sequenceDiagram + participant A as Alert Producer + participant K as Kafka
factory.manufacturing.alert + participant C as AlertEventConsumer + participant S as AlertEventSaveService + participant W as STOMP WebSocket + participant DB as Main DB + participant UI as Dashboard + + A->>K: alert JSON(eventId, alertType, processCode, riskScore) + K->>C: consume(message) + C->>S: save(message) + S->>S: eventId 중복 확인 + S->>S: occurrence / detection / priority 계산 + S->>W: /topic/alerts 실시간 알림 publish + S->>DB: AlertEvent saveAndFlush + W-->>UI: AlertRealtimeMessage +``` -## 실행 프로파일 +### 점수와 상태 -`backend-main`은 `local`, `dev`, `prod` 프로파일을 사용합니다. 기본 프로파일은 `local`이며, `SPRING_PROFILES_ACTIVE` 환경 변수로 변경합니다. +- 지원 공정: `PRESS`, `BODY`, `PAINT`, `ASSEMBLY` +- 알림 유형: `PROCESS`, `EQUIPMENT` +- `riskScore`는 0~100 범위이며 필수입니다. +- `occurrenceScore`: 같은 `eventKey`의 최근 30일 발생 빈도 기반 +- `detectionScore`: 과거 조치 상태(`COMPLETED`, `INCOMPLETE`, `NOT_NEEDED`) 기반 +- `priorityScore`: `riskScore × (1 + occurrenceScore) × (1 + detectionScore)` +- `priorityScore >= 250`이면 `DANGER`, 그 미만이면 `CAUTION` +- 같은 `eventId`가 이미 저장되어 있으면 중복 처리하지 않습니다. -| 프로파일 | 용도 | 주요 설정 | -| --- | --- | --- | -| `local` | 로컬 개발 환경 | `.env` 기반 로컬 DB/Redis/Kafka/OpenSearch 접속, 상세 SQL 로그 활성화 | -| `dev` | 개발 서버 환경 | 개발 서버용 외부 의존성 접속, Kafka group 기본값 `backend-dev`, 개발 로그 레벨 | -| `prod` | 운영 환경 | 운영 서버용 외부 의존성 접속, Kafka group 기본값 `backend-prod`, 운영 로그 레벨 | +알림 실시간 메시지는 `/topic/alerts`로 발행되며, 알림 이력과 조치 정보는 Main DB의 `alert_event`, `action_timeline`을 통해 조회합니다. WebSocket publish가 실패하더라도 DB 저장 흐름 자체는 계속 진행하도록 처리되어 있습니다. -프로파일 변경 예시: +### 알림 API -```properties -SPRING_PROFILES_ACTIVE=local +| 기능 | HTTP API | +| --- | --- | +| 알림 목록/검색 | `GET /api/event` | +| 우선순위 요약 | `GET /api/event/priority-summary?days=7` | +| 알림 상세 | `GET /api/event/{logNo}` | +| eventId로 조회 | `GET /api/event/by-event-id/{eventId}` | +| 조치 상태 변경 | `PATCH /api/event/{logNo}/action` | +| 조치 타임라인 조회 | `GET /api/event/{logNo}/action-timeline` | +| 조치 타임라인 등록 | `POST /api/event/{logNo}/action-timeline` | +| 유사 장애 조치 추천 | `GET /api/event/{logNo}/recommendation` | + +## AGV 배차 및 운반 +AGV + +AGV는 분석 결과를 직접 Kafka로 재발행하지 않고, 분석 이벤트를 소비한 뒤 Redis 대기열과 DB 상태를 조합하여 시뮬레이션합니다. + +### 분석 이벤트 → 배차 흐름 + +`ManufacturingAnalysisConsumer`는 `factory.manufacturing.analysis`를 소비하고, `analysisType == PROCESS_RISK_ANALYSIS`인 이벤트만 처리합니다. 동일 `eventId`는 Redis의 `agv:analysis:processed:{eventId}` 키로 1분 동안 중복 방지합니다. 분석 결과가 abnormal이면 AGV를 배차하지 않습니다. + +```mermaid +flowchart TD + E[factory.manufacturing.analysis] --> C[ManufacturingAnalysisConsumer] + C --> T{analysisType == PROCESS_RISK_ANALYSIS?} + T -- 아니오 --> I[무시] + T -- 예 --> D["Redis 중복 확인
agv:analysis:processed:{eventId}"] + D --> X{isAbnormal?} + X -- 예 --> N[AGV 배차하지 않음] + X -- 아니오 --> Q[route별 Redis Queue 적재] + Q --> S[AgvDispatchScheduler
1초 주기 + ShedLock] + S --> A{대기 AGV 존재?} + A -- 아니오 --> R[Queue 선두 재적재 후 대기] + A -- 예 --> DB[agv_operation을 MOVING으로 변경] + DB --> RT[Redis realtime 상태 저장] + RT --> WS["/topic/agv publish"] ``` -Windows PowerShell: +### Route와 Redis 자료구조 -```powershell -$env:SPRING_PROFILES_ACTIVE = "dev" -.\gradlew.bat bootRun -``` +| 출발 공정 | 도착 공정 | routeCode | 배차 Queue 키 | +| --- | --- | --- | --- | +| `PRESS` | `BODY` | `PRESS_BODY` | `agv:dispatch:queue:PRESS_BODY` | +| `BODY` | `PAINT` | `BODY_PAINT` | `agv:dispatch:queue:BODY_PAINT` | +| `PAINT` | `ASSEMBLY` | `PAINT_ASSEMBLY` | `agv:dispatch:queue:PAINT_ASSEMBLY` | +| `ASSEMBLY` | `INSPECTION` | `ASSEMBLY_INSPECTION` | `agv:dispatch:queue:ASSEMBLY_INSPECTION` | -프로파일별 설정 파일: +각 route에는 중복 방지용 Set(`agv:dispatch:event-ids:{routeCode}`)도 함께 사용합니다. 사용 가능한 AGV가 없거나 배차 중 예외가 발생하면 요청을 Queue 앞에 다시 넣습니다. -- 공통 설정: `src/main/resources/application.yaml` -- 로컬 설정: `src/main/resources/application-local.yaml` -- 개발 설정: `src/main/resources/application-dev.yaml` -- 운영 설정: `src/main/resources/application-prod.yaml` +### AGV 상태 머신 -## 주요 기능 +```mermaid +stateDiagram-v2 + [*] --> WAITING + WAITING --> MOVING: 배차 성공 + MOVING --> UNLOADING: 이동 30초 만료 + UNLOADING --> RETURNING: 하역 5초 만료 + RETURNING --> WAITING: 복귀 30초 만료 +``` -### event +- 영속 상태: Main DB `agv_operation` +- 실시간 진행률/예상 도착: Redis `agv:realtime:{agvId}` +- 상태 전이 확인: `AgvTransportStateScheduler`가 1초마다 만료 상태를 검사 +- 다중 Pod 중복 실행 방지: Redis 기반 ShedLock +- Pod 재시작 시 DB 상태와 Redis 상태를 비교하여 하역/복귀 세션을 복구 +- `MOVING` 도착 시 Assembly Service에 `POST {ASSEMBLY_SERVICE_URL}/api/internal/agv-arrivals`로 `eventId`를 전달 +- Assembly 도착 API가 실패해도 AGV 시뮬레이션은 계속 진행 -알림과 이벤트 처리 흐름을 담당합니다. +AGV 상태가 변경될 때 전체 AGV 목록을 `/topic/agv`로 publish합니다. REST 조회는 다음 API를 사용합니다. -| 기능 | 설명 | 권장 API | -| --- | --- | --- | -| 알림 목록 조회 | 조건, 상태, 기간, 키워드 기반 필터 검색 | `GET /api/events/alerts` | -| 알림 상세 조회 | 단일 알림의 상세 정보 조회 | `GET /api/events/alerts/{alertId}` | -| 조치 이력 조회 | 알림별 조치 이력 조회 | `GET /api/events/alerts/{alertId}/actions` | -| AI 대응 추천 조회 | 알림 원인과 상황 기반 AI 대응 추천 조회 | `GET /api/events/alerts/{alertId}/ai/recommendation` | -| AI 매뉴얼 | 알림 유형별 AI 매뉴얼 조회 | `GET /api/events/alerts/{alertId}/ai/manual` | -| AI 신뢰성 평가 | AI 추천 결과에 대한 신뢰도/근거 평가 조회 | `GET /api/events/alerts/{alertId}/ai/reliability` | -| 알림 요약 조회 | 알림 상태, 유형, 심각도 기준 요약 조회 | `GET /api/events/alerts/summary` | -| 오늘 이벤트 현황 | 당일 발생 이벤트 통계 조회 | `GET /api/events/today` | -| 알림 조치 처리 | 담당자 조치 내용 등록 및 상태 변경 | `POST /api/events/alerts/{alertId}/actions` | -| 조치 불필요 처리 | 알림을 조치 불필요 상태로 변경 | `PATCH /api/events/alerts/{alertId}/dismiss` | - -## 인증 - -인증은 JWT Bearer Token 기반입니다. - -- 인증 헤더: `Authorization: Bearer {accessToken}` -- Access Token 만료 시간: `JWT_ACCESS_EXPIRATION` -- Refresh Token 만료 시간: `JWT_REFRESH_EXPIRATION` -- JWT secret: `JWT_SECRET_KEY` - -현재 Security 설정: - -- Stateless session -- CSRF, form login, HTTP basic 비활성화 -- JWT 필터: `JwtAuthenticationFilter` -- 토큰 생성/검증: `TokenProvider` -- 공개 경로: - - `/` - - `/api/test` - - `/actuator/health` - - `/swagger-ui/**` - - `/v3/api-docs/**` - - `/swagger-resources/**` - - `/webjars/**` -- 그 외 API는 인증 필요 - -## Gateway 설정 - -Gateway는 외부 요청을 `backend-main`으로 라우팅하고 JWT 인증 헤더를 전달합니다. - -권장 라우팅: - -```yaml -spring: - cloud: - gateway: - routes: - - id: backend-main - uri: http://backend-main:8080 - predicates: - - Path=/api/events/**,/api/test/**,/v3/api-docs/backend/** - filters: - - StripPrefix=0 +| 기능 | HTTP API | +| --- | --- | +| AGV 상태 요약 | `GET /api/main/agv-status` | +| 공정 흐름 및 AGV 상세 | `GET /api/main/process-flow` | + +## Kafka 설계 + +### 토픽과 Consumer Group + +| 토픽 | 기본 Partition 수 | Backend Consumer | Consumer Group | 목적 | +| --- | ---: | --- | --- | --- | +| `factory.manufacturing.alert` | 2 | `AlertEventConsumer` | `app.kafka.group-id` | 알림 저장 및 WebSocket 전달 | +| `factory.manufacturing.analysis` | 2 | `ManufacturingAnalysisConsumer` | `app.kafka.consumer.agv-group-id` | 정상 공정 분석 이벤트 기반 AGV 배차 | +| `factory.manufacturing.raw` | 4 | Assembly Service 측 처리 | Assembly Service Group | 제조 원천 이벤트 수집·분석 입력 | +| `factory.manufacturing.equipment` | 2 | Assembly Service 측 처리 | Assembly Service Group | 설비 이벤트 수집·분석 입력 | + +`raw`와 `equipment`는 Assembly Service에서 제조 원천·설비 정보를 처리하는 채널입니다. 이 Backend는 해당 원천 이벤트를 직접 소비하기보다, 분석 결과가 발행된 `analysis`와 알림 결과가 발행된 `alert` 토픽을 소비합니다. + +```mermaid +flowchart LR + subgraph K["Kafka / AWS MSK"] + RAW[factory.manufacturing.raw
4 partitions] + ANALYSIS[factory.manufacturing.analysis
2 partitions] + ALERT[factory.manufacturing.alert
2 partitions] + EQUIP[factory.manufacturing.equipment
2 partitions] + end + + AS[Assembly Service] + RAW -->|Assembly Service Group| AS + EQUIP -->|Assembly Service Group| AS + ANALYSIS -->|AGV group
main-agv-group*| AC[ManufacturingAnalysisConsumer
concurrency=2] + ALERT -->|일반 backend group
main-*/backend-*| NC[AlertEventConsumer] + AC --> RQ[Redis AGV Queue] + NC --> DB[MySQL AlertEvent] + NC --> WS["STOMP /topic/alerts"] ``` -Gateway 연동 시 유지해야 하는 헤더: - -- `Authorization` -- `Content-Type` -- `X-Request-Id` +### Kafka Client 동작 -CORS는 `WebConfig`에서 처리하며, 허용 origin은 `cors.allowed-origins` 설정으로 관리합니다. +`KafkaConfig`에서 다음과 같이 구성합니다. -## 기술 스택 +- Producer/Consumer payload: `String` +- Producer: `acks=all`, `enable.idempotence=true` +- Consumer: auto commit 비활성화, `AckMode.RECORD` +- 기본 offset: `earliest` (local profile은 `latest`로 덮어씀) +- 운영/개발 MSK: `SASL_SSL` + `AWS_MSK_IAM` +- 로컬 기본값: `localhost:9092`, `PLAINTEXT` +- Listener 전체 비활성화: `app.kafka.listeners-enabled=false` -- Java 17 -- Spring Boot 4.0.6 -- Spring WebMVC -- Spring Security -- Spring Data JPA -- QueryDSL -- MySQL, H2 -- Redis Cache -- Kafka -- OpenSearch Java Client -- Spring Cloud OpenFeign -- Springdoc OpenAPI -- JWT -- Spring Boot Actuator -- Spring Boot Admin Client +`AlertEventConsumer`는 `app.kafka.listeners-enabled`가 true일 때만 등록됩니다. 반면 AGV 분석 Consumer는 코드상 `app.kafka.consumer.agv-group-id`를 사용하므로 해당 프로퍼티와 Kafka 접속 정보가 실행 환경에 있어야 합니다. -## 패키지 구조 +### 데이터 흐름도 +데이터 기능 흐름도 -현재 기본 패키지는 `com.aims.backend`입니다. +## WebSocket / STOMP -```text -src/main/java/com/aims/backend -+-- BackendApplication.java -+-- common -| +-- code -| +-- response -| +-- status -+-- config -| +-- jwt -| +-- security -+-- controller -+-- domain -| +-- commons -+-- dto -| +-- auth -| +-- test -+-- exception -| +-- handler -+-- mapper -+-- properties -+-- repository -+-- service -+-- utils -``` +STOMP endpoint는 `/ws`와 `/api/ws`이며 SockJS를 지원합니다. 서버 브로커 prefix는 `/topic`입니다. -권장 event 패키지 확장: - -```text -com.aims.backend -+-- controller/event -+-- dto/event -+-- domain/event -+-- repository/event -+-- service/event -+-- mapper/event -``` +| 구독 destination | 메시지 | 발생 시점 | +| --- | --- | --- | +| `/topic/alerts` | `AlertRealtimeMessage` | 새로운 알림을 Kafka에서 수신하고 점수 계산 후 | +| `/topic/agv` | AGV 전체 상태 목록 | 배차, 이동 도착, 하역 시작, 복귀 시작/완료 시 | ## 설정 -프로젝트 루트의 `.env`를 자동으로 읽습니다. +설정 파일은 다음 순서로 관리합니다. -```yaml -spring: - config: - import: optional:file:.env[.properties] -``` +- 공통: `src/main/resources/application.yaml` +- local: `application-local.yaml` +- dev: `application-dev.yaml` +- prod: `application-prod.yaml` -주요 환경 변수: +주요 환경 변수는 다음과 같습니다. | 변수 | 설명 | | --- | --- | -| `APP_NAME` | Spring application name | -| `SPRING_PROFILES_ACTIVE` | 실행 profile | -| `MAIN_DB_JDBC_URL` | Main DB JDBC URL | -| `MAIN_DB_USERNAME` | Main DB 계정 | -| `MAIN_DB_PASSWORD` | Main DB 비밀번호 | -| `SAMPLE_DB_JDBC_URL` | Sample DB JDBC URL | -| `REDIS_HOST` | Redis host | -| `REDIS_PORT` | Redis port | -| `KAFKA_BOOTSTRAP_SERVERS` | Kafka bootstrap servers | -| `KAFKA_GROUP_ID` | Kafka consumer group id | -| `OPENSEARCH_HOST` | OpenSearch host | -| `OPENSEARCH_PORT` | OpenSearch port | -| `JWT_SECRET_KEY` | JWT signing key | +| `SPRING_PROFILES_ACTIVE` | 실행 profile (`local`, `dev`, `prod`) | +| `KAFKA_BOOTSTRAP_SERVER_1`, `KAFKA_BOOTSTRAP_SERVER_2` | Kafka/MSK Broker 주소 | +| `KAFKA_GROUP_ID` | 알림 Consumer Group | +| `AGV_CONSUMER_GROUP_ID` | AGV 분석 Consumer Group | +| `KAFKA_LISTENERS_ENABLED` | Kafka Listener 활성화 여부 | +| `REDIS_HOST`, `REDIS_PORT` | Redis 접속 정보 | +| `MAIN_DB_JDBC_URL`, `MAIN_DB_USERNAME`, `MAIN_DB_PASSWORD` | Main DB 접속 정보 | +| `ASSEMBLY_SERVICE_URL` | Assembly Service 주소 | +| `JWT_SECRET_KEY` | JWT 서명 키 | ## 실행 -```bash -./gradlew bootRun -``` - -Windows: +프로젝트 루트의 `.env`를 준비한 후 실행합니다. ```powershell +$env:SPRING_PROFILES_ACTIVE = "local" .\gradlew.bat bootRun ``` -## 테스트 - -```bash -./gradlew test -``` - -Windows: +테스트: ```powershell .\gradlew.bat test ``` -테스트 profile은 H2 in-memory DB를 사용합니다. +Swagger UI: -## API 문서 +```text +http://localhost:8081/swagger-ui/index.html +``` -로컬 실행 후 Swagger UI에서 API 문서를 확인할 수 있습니다. +## 주요 패키지 ```text -http://localhost:8080/swagger-ui/index.html +src/main/java/com/aims/backend +├─ config/ # Kafka, WebSocket, Security, DB 설정 +├─ controller/alert # 알림 REST API +├─ controller/dashboard # 대시보드·AGV REST API +├─ service/alert # Kafka 알림 소비, 저장, WebSocket publish +├─ service/dashboard # 분석 소비, AGV Queue·Scheduler·상태 전이 +├─ domain/alert # AlertEvent, ActionTimeline +├─ domain/dashboard # AgvOperation 및 공정 도메인 +├─ repository/alert +└─ repository/dashboard ``` diff --git a/build.gradle b/build.gradle index 4132fda..8b79147 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,10 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // ShedLock + implementation "net.javacrumbs.shedlock:shedlock-spring:7.7.0" + implementation "net.javacrumbs.shedlock:shedlock-provider-redis-spring:7.7.0" + // Cache implementation 'org.springframework.boot:spring-boot-starter-cache' diff --git a/src/main/java/com/aims/backend/client/AssemblyArrivalClient.java b/src/main/java/com/aims/backend/client/AssemblyArrivalClient.java new file mode 100644 index 0000000..8f3dcb1 --- /dev/null +++ b/src/main/java/com/aims/backend/client/AssemblyArrivalClient.java @@ -0,0 +1,64 @@ +package com.aims.backend.client; + +import com.aims.backend.domain.dashboard.enums.ProcessCode; +import com.aims.backend.dto.dashboard.AgvArrivalRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.time.LocalDateTime; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AssemblyArrivalClient { + + private final RestClient restClient; + + @Value("${external.assembly-url}") + private String assemblyUrl; + + /** + * AGV 도착 정보를 Assembly Service로 전달 + */ + public void notifyAgvArrived( + String eventId + ) { + + AgvArrivalRequest request = + AgvArrivalRequest.builder() + .eventId(eventId) + .build(); + + String url = + assemblyUrl + "/api/internal/agv-arrivals"; + + log.info(""" + + ============================== + Assembly 도착 API 요청 + + url={} + {} + + ============================== + + """, + url, + eventId + ); + + restClient.post() + .uri(url) + .body(request) + .retrieve() + .toBodilessEntity(); + + log.info( + "[ASSEMBLY ARRIVAL SUCCESS] eventId={}", + eventId + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/common/status/ErrorStatus.java b/src/main/java/com/aims/backend/common/status/ErrorStatus.java index df09a71..2cd2858 100644 --- a/src/main/java/com/aims/backend/common/status/ErrorStatus.java +++ b/src/main/java/com/aims/backend/common/status/ErrorStatus.java @@ -10,6 +10,37 @@ @AllArgsConstructor public enum ErrorStatus implements BaseErrorCode { + + RECOMMENDATION_ANALYSIS_NOT_FOUND( + HttpStatus.NOT_FOUND, + "RECOMMEND404_1", + "분석 결과를 찾을 수 없습니다." + ), + + RECOMMENDATION_PRESS_ANALYSIS_NOT_FOUND( + HttpStatus.NOT_FOUND, + "RECOMMEND404_2", + "PRESS 분석 결과를 찾을 수 없습니다." + ), + + RECOMMENDATION_SIMILAR_EVENT_NOT_FOUND( + HttpStatus.NOT_FOUND, + "RECOMMEND404_3", + "유사 이벤트를 찾을 수 없습니다." + ), + + RECOMMENDATION_ALERT_EVENT_NOT_FOUND( + HttpStatus.NOT_FOUND, + "RECOMMEND404_4", + "유사 이벤트 정보를 찾을 수 없습니다." + ), + + RECOMMENDATION_TIMELINE_NOT_FOUND( + HttpStatus.NOT_FOUND, + "RECOMMEND404_5", + "유사 이벤트의 조치 이력이 존재하지 않습니다." + ), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 내부 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), diff --git a/src/main/java/com/aims/backend/config/AlertImageProperties.java b/src/main/java/com/aims/backend/config/AlertImageProperties.java new file mode 100644 index 0000000..32d67e1 --- /dev/null +++ b/src/main/java/com/aims/backend/config/AlertImageProperties.java @@ -0,0 +1,61 @@ +package com.aims.backend.config; + +import com.aims.backend.domain.alert.AlertSeverity; +import com.aims.backend.domain.alert.AlertType; +import com.aims.backend.domain.dashboard.enums.ProcessCode; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Map; + +@Slf4j +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.alert.image") +public class AlertImageProperties { + + private Map processImages = new EnumMap<>(ProcessCode.class); + + @PostConstruct + public void logLoadedImages() { + log.info("AlertImageProperties loaded. size={}, keys={}", + processImages.size(), processImages.keySet()); + processImages.forEach((code, set) -> + log.info(" {} -> dangerEquipment={}, dangerProcess={}, warning={}", + code, set.getDangerEquipment(), set.getDangerProcess(), set.getWarning())); + } + + public String resolve(ProcessCode processCode, AlertType alertType, AlertSeverity severity) { + + ImageSet imageSet = processImages.get(processCode); + + log.info("resolve() called. processCode={}, alertType={}, severity={}, imageSetFound={}", + processCode, alertType, severity, imageSet != null); + + if (imageSet == null || severity == null) { + return null; + } + + if (severity == AlertSeverity.CAUTION) { + return imageSet.getWarning(); + } + + return alertType == AlertType.EQUIPMENT + ? imageSet.getDangerEquipment() + : imageSet.getDangerProcess(); + } + + @Getter + @Setter + public static class ImageSet { + private String dangerEquipment; + private String dangerProcess; + private String warning; + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/config/KafkaConfig.java b/src/main/java/com/aims/backend/config/KafkaConfig.java index b7d9b07..96a6105 100644 --- a/src/main/java/com/aims/backend/config/KafkaConfig.java +++ b/src/main/java/com/aims/backend/config/KafkaConfig.java @@ -28,6 +28,14 @@ public class KafkaConfig { private final KafkaCustomProperties kafkaCustomProperties; + private String getBootstrapServers() { + + return String.join( + ",", + kafkaCustomProperties.getBootstrapServers() + ); + } + @Bean public ProducerFactory producerFactory() { @@ -35,7 +43,7 @@ public ProducerFactory producerFactory() { properties.put( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, - kafkaCustomProperties.getBootstrapServers() + getBootstrapServers() ); properties.put( diff --git a/src/main/java/com/aims/backend/config/RestClientConfig.java b/src/main/java/com/aims/backend/config/RestClientConfig.java new file mode 100644 index 0000000..01c01af --- /dev/null +++ b/src/main/java/com/aims/backend/config/RestClientConfig.java @@ -0,0 +1,14 @@ +package com.aims.backend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + return RestClient.create(); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/config/SecurityConfig.java b/src/main/java/com/aims/backend/config/SecurityConfig.java index 7c08353..fa365f6 100644 --- a/src/main/java/com/aims/backend/config/SecurityConfig.java +++ b/src/main/java/com/aims/backend/config/SecurityConfig.java @@ -51,7 +51,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/auth/login", "/api/auth/signup", "/api/auth/refresh", - "/api/event/**" + "/api/event/**", + "/api/main/process-flow", + "/ws/**", + "/api/ws/**" ).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/aims/backend/config/ShedLockConfig.java b/src/main/java/com/aims/backend/config/ShedLockConfig.java new file mode 100644 index 0000000..5f280bc --- /dev/null +++ b/src/main/java/com/aims/backend/config/ShedLockConfig.java @@ -0,0 +1,25 @@ +package com.aims.backend.config; + +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; + +@Configuration +@EnableSchedulerLock(defaultLockAtMostFor = "PT30S") +public class ShedLockConfig { + + private static final String ENVIRONMENT = "aims-backend"; + + @Bean + public LockProvider lockProvider( + RedisConnectionFactory redisConnectionFactory + ) { + return new RedisLockProvider( + redisConnectionFactory, + ENVIRONMENT + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/config/WebSocketConfig.java b/src/main/java/com/aims/backend/config/WebSocketConfig.java index 5fa5226..73155ad 100644 --- a/src/main/java/com/aims/backend/config/WebSocketConfig.java +++ b/src/main/java/com/aims/backend/config/WebSocketConfig.java @@ -9,13 +9,20 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/ws") - .setAllowedOriginPatterns("*"); + public void registerStompEndpoints( + StompEndpointRegistry registry + ) { + + registry.addEndpoint("/ws","api/ws") + .setAllowedOriginPatterns("*") + .withSockJS(); + } + @Override public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); registry.setApplicationDestinationPrefixes("/app"); } diff --git a/src/main/java/com/aims/backend/config/jpa/MainJpaConfig.java b/src/main/java/com/aims/backend/config/jpa/MainJpaConfig.java index 725d535..061c22d 100644 --- a/src/main/java/com/aims/backend/config/jpa/MainJpaConfig.java +++ b/src/main/java/com/aims/backend/config/jpa/MainJpaConfig.java @@ -44,7 +44,13 @@ public DataSource mainDataSource() { public LocalContainerEntityManagerFactoryBean mainEntityManagerFactory() { LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean(); em.setDataSource(mainDataSource()); - em.setPackagesToScan("com.aims.backend.domain.commons", "com.aims.backend.domain.mainpage", "com.aims.backend.domain.user", "com.aims.backend.domain.dashboard", "com.aims.backend.domain.alert"); + em.setPackagesToScan( + "com.aims.backend.domain.commons", + "com.aims.backend.domain.mainpage", + "com.aims.backend.domain.user", + "com.aims.backend.domain.dashboard", + "com.aims.backend.domain.alert", + "com.aims.backend.domain.eventAnalysis"); em.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); return em; } diff --git a/src/main/java/com/aims/backend/controller/alert/AlertEventController.java b/src/main/java/com/aims/backend/controller/alert/AlertEventController.java index f84d7e2..ee52a3e 100644 --- a/src/main/java/com/aims/backend/controller/alert/AlertEventController.java +++ b/src/main/java/com/aims/backend/controller/alert/AlertEventController.java @@ -1,33 +1,66 @@ package com.aims.backend.controller.alert; import com.aims.backend.common.response.ApiResponse; +import com.aims.backend.config.jwt.TokenProvider; import com.aims.backend.dto.alert.AlertActionUpdateRequest; import com.aims.backend.dto.alert.AlertEventResponse; +import com.aims.backend.dto.alert.AlertPrioritySummaryResponse; import com.aims.backend.dto.alert.AlertSearchRequest; +import com.aims.backend.dto.eventAnalysis.RecommendationResponse; +import com.aims.backend.service.eventAnalysis.AlertRecommendationService; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import com.aims.backend.dto.alert.ActionTimelineResponse; +import com.aims.backend.service.alert.AlertPrioritySummaryService; +import com.aims.backend.dto.alert.ActionTimelineCreateRequest; import com.aims.backend.service.alert.AlertEventQueryService; +import com.aims.backend.service.alert.ActionTimelineService; +import com.aims.backend.service.alert.ActionTimelineService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.validation.annotation.Validated; + +import java.util.List; @RestController @RequestMapping("/api/event") @RequiredArgsConstructor +@Validated public class AlertEventController { private final AlertEventQueryService alertEventQueryService; + private final AlertPrioritySummaryService alertPrioritySummaryService; + private final ActionTimelineService actionTimelineService; + private final TokenProvider tokenProvider; + private final AlertRecommendationService alertRecommendationService; @GetMapping public ApiResponse> getAlerts(@ModelAttribute AlertSearchRequest request) { return ApiResponse.success(alertEventQueryService.getAlerts(request)); } + @GetMapping("/priority-summary") + public ApiResponse getPrioritySummary( + @RequestParam(defaultValue = "7") @Min(1) @Max(365) int days + ) { + return ApiResponse.success(alertPrioritySummaryService.getPrioritySummary(days)); + } + @GetMapping("/{logNo}") public ApiResponse getAlert(@PathVariable String logNo) { return ApiResponse.success(alertEventQueryService.getAlert(logNo)); @@ -45,4 +78,47 @@ public ApiResponse updateAction( ) { return ApiResponse.success(alertEventQueryService.updateAction(logNo, request)); } + + @GetMapping("/{logNo}/action-timeline") + public ApiResponse> getActionTimeline(@PathVariable String logNo) { + return ApiResponse.success(actionTimelineService.getTimeline(logNo)); + } + + @PostMapping("/{logNo}/action-timeline") + public ApiResponse createActionTimeline( + @PathVariable String logNo, + @Valid @RequestBody ActionTimelineCreateRequest request, + HttpServletRequest httpServletRequest + ) { + + + String accessToken = TokenProvider.resolveToken(httpServletRequest); + if (accessToken == null) { + return ApiResponse.failure("Access token is missing or invalid.", null); + } + + Authentication authentication = tokenProvider.getAuthentication(accessToken); + if (authentication == null || !(authentication.getPrincipal() instanceof TokenProvider.JwtPrincipal principal)) { + return ApiResponse.failure("Invalid authentication principal.", null); + } + + return ApiResponse.success(actionTimelineService.createTimeline(logNo, request, principal)); + } + + @Operation( + summary = "유사 장애 조치 추천", + description = "현재 이벤트와 가장 유사한 과거 이벤트를 찾아 추천 조치 방법을 반환합니다." + ) + @GetMapping("/{logNo}/recommendation") + public ApiResponse getRecommendation( + @PathVariable String logNo) { + + RecommendationResponse response = + alertRecommendationService.getRecommendation(logNo); + + return ApiResponse.success( + response, + "유사 장애 추천 조회 성공" + ); + } } diff --git a/src/main/java/com/aims/backend/controller/dashboard/AgvTestController.java b/src/main/java/com/aims/backend/controller/dashboard/AgvTestController.java deleted file mode 100644 index 8db17f3..0000000 --- a/src/main/java/com/aims/backend/controller/dashboard/AgvTestController.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.aims.backend.controller.dashboard; - -import com.aims.backend.domain.dashboard.enums.ProcessCode; -import com.aims.backend.service.dashboard.AgvSimulationService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RequestMapping("/api/test/agv") -@RestController -@RequiredArgsConstructor -public class AgvTestController { - - private final AgvSimulationService agvSimulationService; - - @PostMapping("/dispatch") - public ResponseEntity dispatch( - @RequestParam Long carMasterId, - @RequestParam ProcessCode processCode - ) { - agvSimulationService.dispatchAgv( - carMasterId, - processCode - ); - - return ResponseEntity.ok().build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/controller/dashboard/ManufacturingEventTestController.java b/src/main/java/com/aims/backend/controller/dashboard/ManufacturingEventTestController.java deleted file mode 100644 index 838a8e2..0000000 --- a/src/main/java/com/aims/backend/controller/dashboard/ManufacturingEventTestController.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.aims.backend.controller.dashboard; - -import com.aims.backend.dto.dashboard.ManufacturingEventRequest; -import com.aims.backend.service.dashboard.AgvSimulationService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/test/manufacturing-events") /* 테스트 */ -@RequiredArgsConstructor -public class ManufacturingEventTestController { - - private final AgvSimulationService agvSimulationService; - - @PostMapping - public ResponseEntity receiveManufacturingEvent( - @RequestBody ManufacturingEventRequest request - ) { - agvSimulationService.handleManufacturingEvent(request); - return ResponseEntity.ok().build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/domain/alert/ActionCategory.java b/src/main/java/com/aims/backend/domain/alert/ActionCategory.java new file mode 100644 index 0000000..14970c1 --- /dev/null +++ b/src/main/java/com/aims/backend/domain/alert/ActionCategory.java @@ -0,0 +1,10 @@ +package com.aims.backend.domain.alert; + +public enum ActionCategory { + CHECK, + REPAIR, + CHANGE, + CLEAN, + ADJUST, + RESTART +} diff --git a/src/main/java/com/aims/backend/domain/alert/ActionTimeline.java b/src/main/java/com/aims/backend/domain/alert/ActionTimeline.java new file mode 100644 index 0000000..947b537 --- /dev/null +++ b/src/main/java/com/aims/backend/domain/alert/ActionTimeline.java @@ -0,0 +1,64 @@ +package com.aims.backend.domain.alert; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Table; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import com.aims.backend.domain.user.UserRole; + +import java.time.LocalDateTime; + +import com.aims.backend.domain.alert.ActionCategory; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "action_timeline") +public class ActionTimeline { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "action_id") + private Long actionId; + + @Column(name = "log_no", nullable = false, length = 20) + private String logNo; + + @Column(name = "emp_no", nullable = false, length = 20) + private String empNo; + + @Column(name = "emp_name", nullable = false, length = 20) + private String empName; + + @Enumerated(EnumType.STRING) + @Column(name = "emp_role", nullable = false, length = 20) + private UserRole empRole; + + @Column(name = "action_time", nullable = false) + private LocalDateTime actionTime; + + @Column(name = "action_content", nullable = false, length = 20) + private String actionContent; + + @Enumerated(EnumType.STRING) + @Column(name = "action_category", nullable = false, length = 20) + private ActionCategory actionCategory; + + @Column(name = "action_result", nullable = false, length = 20) + private String actionResult; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/domain/alert/AlertEvent.java b/src/main/java/com/aims/backend/domain/alert/AlertEvent.java index ab97bf7..ee9a935 100644 --- a/src/main/java/com/aims/backend/domain/alert/AlertEvent.java +++ b/src/main/java/com/aims/backend/domain/alert/AlertEvent.java @@ -81,6 +81,9 @@ public class AlertEvent { @Column(name = "score_calculated_at") private LocalDateTime scoreCalculatedAt; + @Column(name = "image_url") + private String imageUrl; + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/src/main/java/com/aims/backend/domain/dashboard/AgvOperation.java b/src/main/java/com/aims/backend/domain/dashboard/AgvOperation.java index c1d2dd1..6a1a468 100644 --- a/src/main/java/com/aims/backend/domain/dashboard/AgvOperation.java +++ b/src/main/java/com/aims/backend/domain/dashboard/AgvOperation.java @@ -18,6 +18,9 @@ public class AgvOperation extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "event_id") + private String eventId; + @Column(name = "car_master_id") private Long carMasterId; @@ -40,11 +43,13 @@ public class AgvOperation extends BaseEntity { private Integer laneNo; public void dispatch( + String eventId, Long carMasterId, ProcessCode from, ProcessCode to, String routeCode ) { + this.eventId = eventId; this.carMasterId = carMasterId; this.currentProcess = from; this.targetProcess = to; @@ -52,11 +57,15 @@ public void dispatch( this.agvStatus = AgvStatus.MOVING; } + public void changeToUnloading() { + this.currentProcess = this.targetProcess; + this.agvStatus = AgvStatus.UNLOADING; + } + public void changeToReturning() { - ProcessCode arrivedProcess = this.targetProcess; - ProcessCode homeProcess = this.currentProcess; + ProcessCode arrivedProcess = this.currentProcess; + ProcessCode homeProcess = getHomeProcessByRouteCode(this.routeCode); - this.carMasterId = null; this.currentProcess = arrivedProcess; this.targetProcess = homeProcess; this.agvStatus = AgvStatus.RETURNING; @@ -64,13 +73,31 @@ public void changeToReturning() { public void changeToWaiting( ProcessCode homeProcess, - ProcessCode nextProcess, + ProcessCode nextTarget, String routeCode ) { + this.eventId = null; this.carMasterId = null; this.currentProcess = homeProcess; - this.targetProcess = nextProcess; + this.targetProcess = nextTarget; this.routeCode = routeCode; this.agvStatus = AgvStatus.WAITING; } + + private ProcessCode getHomeProcessByRouteCode(String routeCode) { + if ("PRESS_BODY".equals(routeCode)) { + return ProcessCode.PRESS; + } + if ("BODY_PAINT".equals(routeCode)) { + return ProcessCode.BODY; + } + if ("PAINT_ASSEMBLY".equals(routeCode)) { + return ProcessCode.PAINT; + } + if ("ASSEMBLY_INSPECTION".equals(routeCode)) { + return ProcessCode.ASSEMBLY; + } + + return this.currentProcess; + } } \ No newline at end of file diff --git a/src/main/java/com/aims/backend/domain/dashboard/Equipment.java b/src/main/java/com/aims/backend/domain/dashboard/Equipment.java index fbdd368..b997a20 100644 --- a/src/main/java/com/aims/backend/domain/dashboard/Equipment.java +++ b/src/main/java/com/aims/backend/domain/dashboard/Equipment.java @@ -42,9 +42,6 @@ public class Equipment { @Column(name = "current_status") private OperationStatus currentStatus; - @Column(name = "health_status") - private String healthStatus; - @Column(name = "last_fault_time") private LocalDateTime lastFaultTime; diff --git a/src/main/java/com/aims/backend/domain/dashboard/enums/AgvStatus.java b/src/main/java/com/aims/backend/domain/dashboard/enums/AgvStatus.java index d83afa6..7a949ce 100644 --- a/src/main/java/com/aims/backend/domain/dashboard/enums/AgvStatus.java +++ b/src/main/java/com/aims/backend/domain/dashboard/enums/AgvStatus.java @@ -1,7 +1,8 @@ package com.aims.backend.domain.dashboard.enums; public enum AgvStatus { - WAITING, /** 대기중 */ - MOVING, /** 이동중 */ - RETURNING /** 복귀중 */ -} + WAITING, + MOVING, + UNLOADING, + RETURNING +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/domain/dashboard/enums/OperationStatus.java b/src/main/java/com/aims/backend/domain/dashboard/enums/OperationStatus.java index 4260117..ce44500 100644 --- a/src/main/java/com/aims/backend/domain/dashboard/enums/OperationStatus.java +++ b/src/main/java/com/aims/backend/domain/dashboard/enums/OperationStatus.java @@ -12,13 +12,12 @@ public enum OperationStatus { RUNNING("가동"), - IDLE("대기"), STOPPED("정지"), FAULT("고장"), - - MAINTENANCE("정비"); + + WARNING("경고"); /** diff --git a/src/main/java/com/aims/backend/domain/dashboard/scheduler/AgvSimulatorScheduler.java b/src/main/java/com/aims/backend/domain/dashboard/scheduler/AgvSimulatorScheduler.java deleted file mode 100644 index 998c1c6..0000000 --- a/src/main/java/com/aims/backend/domain/dashboard/scheduler/AgvSimulatorScheduler.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.aims.backend.domain.dashboard.scheduler; - -import com.aims.backend.service.dashboard.AgvSimulationService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -public class AgvSimulatorScheduler { - - private final AgvSimulationService agvSimulationService; - - @Scheduled(fixedRate = 3000) - public void run() { - agvSimulationService.updateAgvProgress(); - } -} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/domain/dashboard/scheduler/SimulationClock.java b/src/main/java/com/aims/backend/domain/dashboard/scheduler/SimulationClock.java deleted file mode 100644 index 988dcf5..0000000 --- a/src/main/java/com/aims/backend/domain/dashboard/scheduler/SimulationClock.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.aims.backend.domain.dashboard.scheduler; - -import lombok.Getter; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; - -@Getter -@Component -public class SimulationClock { - - private LocalDateTime currentTime = LocalDateTime.of( - 2026, - 6, - 1, - 8, - 0, - 0 - ); - - public void tickSeconds(long seconds) { - this.currentTime = this.currentTime.plusSeconds(seconds); - } - -} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/domain/eventAnalysis/AssemblyAnalysisResult.java b/src/main/java/com/aims/backend/domain/eventAnalysis/AssemblyAnalysisResult.java new file mode 100644 index 0000000..2f16aa6 --- /dev/null +++ b/src/main/java/com/aims/backend/domain/eventAnalysis/AssemblyAnalysisResult.java @@ -0,0 +1,41 @@ +package com.aims.backend.domain.eventAnalysis; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "assembly_analysis_result") + +public class AssemblyAnalysisResult { + + @Id + @Column(name = "analysis_result_id") + private Long analysisResultId; + + @Column(name = "expected_sequence") + private String expectedSequence; + + @Column(name = "actual_sequence") + private String actualSequence; + + @Column(name = "sequence_error_count") + private Integer sequenceErrorCount; + + @Column(name = "missing_part_count") + private Integer missingPartCount; + + @Column(name = "fastening_error_count") + private Integer fasteningErrorCount; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/aims/backend/domain/eventAnalysis/BodyAnalysisResult.java b/src/main/java/com/aims/backend/domain/eventAnalysis/BodyAnalysisResult.java new file mode 100644 index 0000000..61706cc --- /dev/null +++ b/src/main/java/com/aims/backend/domain/eventAnalysis/BodyAnalysisResult.java @@ -0,0 +1,41 @@ +package com.aims.backend.domain.eventAnalysis; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "body_analysis_result") + +public class BodyAnalysisResult { + + @Id + @Column(name = "analysis_result_id") + private Long analysisResultId; + + @Column(name = "robot_motion_status") + private String robotMotionStatus; + + @Column(name = "robot_operation_mode") + private String robotOperationMode; + + @Column(name = "robot_vibration_score") + private Double robotVibrationScore; + + @Column(name = "frequency_peak_band") + private String frequencyPeakBand; + + @Column(name = "frequency_peak_value") + private Double frequencyPeakValue; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/aims/backend/domain/eventAnalysis/ManufacturingAnalysisResult.java b/src/main/java/com/aims/backend/domain/eventAnalysis/ManufacturingAnalysisResult.java new file mode 100644 index 0000000..4b536e1 --- /dev/null +++ b/src/main/java/com/aims/backend/domain/eventAnalysis/ManufacturingAnalysisResult.java @@ -0,0 +1,61 @@ +package com.aims.backend.domain.eventAnalysis; + +import com.aims.backend.domain.dashboard.enums.ProcessCode; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "manufacturing_analysis_result") + +public class ManufacturingAnalysisResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, unique = true) + private String eventId; + + @Column(name = "car_master_id") + private Long carMasterId; + + @Column(name = "equipment_id") + private Long equipmentId; + + @Enumerated(EnumType.STRING) + @Column(name = "process_code", nullable = false) + private ProcessCode processCode; + + @Column(name = "event_time") + private LocalDateTime eventTime; + + @Column(name = "is_abnormal") + private Integer isAbnormal; + + @Column(name = "abnormal_type") + private String abnormalType; + + @Column(name = "severity") + private String severity; + + @Column(name = "risk_score") + private Double riskScore; + + @Column(name = "analysis_message") + private String analysisMessage; + + @Column(name = "analyzed_at") + private LocalDateTime analyzedAt; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/aims/backend/domain/eventAnalysis/PaintAnalysisResult.java b/src/main/java/com/aims/backend/domain/eventAnalysis/PaintAnalysisResult.java new file mode 100644 index 0000000..7ee2a73 --- /dev/null +++ b/src/main/java/com/aims/backend/domain/eventAnalysis/PaintAnalysisResult.java @@ -0,0 +1,43 @@ +package com.aims.backend.domain.eventAnalysis; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "paint_analysis_result") +public class PaintAnalysisResult { + + @Id + @Column(name = "analysis_result_id") + private Long analysisResultId; + + @Column(name = "image_position") + private String imagePosition; + + @Column(name = "thermal_std_temp") + private Double thermalStdTemp; + + @Column(name = "thickness_value") + private Double thicknessValue; + + @Column(name = "defeat_score") + private Double defeatScore; + + @Column(name = "vision_label") + private String visionLabel; + + @Column(name = "surface_quality_score") + private Double surfaceQualityScore; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/domain/eventAnalysis/PressAnalysisResult.java b/src/main/java/com/aims/backend/domain/eventAnalysis/PressAnalysisResult.java new file mode 100644 index 0000000..e226fa7 --- /dev/null +++ b/src/main/java/com/aims/backend/domain/eventAnalysis/PressAnalysisResult.java @@ -0,0 +1,40 @@ +package com.aims.backend.domain.eventAnalysis; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "press_analysis_result") + +public class PressAnalysisResult { + @Id + @Column(name = "analysis_result_id") + private Long analysisResultId; + + @Column(name = "count_increase_yn") + private Integer countIncreaseYn; + + @Column(name = "target_cycle_time_sec") + private Double targetCycleTimeSec; + + @Column(name = "actual_cycle_time_sec") + private Double actualCycleTimeSec; + + @Column(name = "cycle_time_gap_sec") + private Double cycleTimeGapSec; + + @Column(name = "timestamp_delay_sec") + private Double timestampDelaySec; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/aims/backend/domain/mainpage/TaskStatus.java b/src/main/java/com/aims/backend/domain/mainpage/TaskStatus.java deleted file mode 100644 index 687be62..0000000 --- a/src/main/java/com/aims/backend/domain/mainpage/TaskStatus.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.aims.backend.domain.mainpage; - -public enum TaskStatus { - TODO, - PROGRESS, - DONE -} diff --git a/src/main/java/com/aims/backend/domain/mainpage/UserTask.java b/src/main/java/com/aims/backend/domain/mainpage/UserTask.java index 407252a..97af479 100644 --- a/src/main/java/com/aims/backend/domain/mainpage/UserTask.java +++ b/src/main/java/com/aims/backend/domain/mainpage/UserTask.java @@ -35,9 +35,6 @@ public class UserTask extends BaseEntity { @Column(name = "task_title", nullable = false, length = 255) private String taskTitle; - @Enumerated(EnumType.STRING) - @Column(name = "task_status", nullable = false, length = 20) - private TaskStatus taskStatus; @Column(name = "scheduled_at", nullable = false) private LocalDateTime scheduledAt; diff --git a/src/main/java/com/aims/backend/dto/alert/ActionTimelineCreateRequest.java b/src/main/java/com/aims/backend/dto/alert/ActionTimelineCreateRequest.java new file mode 100644 index 0000000..2f821d8 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/alert/ActionTimelineCreateRequest.java @@ -0,0 +1,33 @@ +package com.aims.backend.dto.alert; + +import com.aims.backend.domain.alert.ActionCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActionTimelineCreateRequest { + + @NotNull(message = "조치 시간은 필수 입력 값입니다.") + private LocalDateTime actionTime; + + @NotBlank(message = "조치 내용은 필수 입력 값입니다.") + @Size(max = 20, message = "조치 내용은 최대 20자까지 입력 가능합니다.") + private String actionContent; + + @NotNull(message = "조치 카테고리는 필수 입력 값입니다.") + private ActionCategory actionCategory; + + @NotBlank(message = "조치 결과는 필수 입력 값입니다.") + @Size(max = 20, message = "조치 결과는 최대 20자까지 입력 가능합니다.") + private String actionResult; +} diff --git a/src/main/java/com/aims/backend/dto/alert/ActionTimelineResponse.java b/src/main/java/com/aims/backend/dto/alert/ActionTimelineResponse.java new file mode 100644 index 0000000..45af99d --- /dev/null +++ b/src/main/java/com/aims/backend/dto/alert/ActionTimelineResponse.java @@ -0,0 +1,36 @@ +package com.aims.backend.dto.alert; + +import java.time.LocalDateTime; + +import com.aims.backend.domain.alert.ActionCategory; +import com.aims.backend.domain.alert.ActionTimeline; +import com.aims.backend.domain.user.UserRole; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ActionTimelineResponse { + private Long actionId; + private String empNo; + private String empName; + private UserRole empRole; + private LocalDateTime actionTime; + private String actionContent; + private ActionCategory actionCategory; + private String actionResult; + + public static ActionTimelineResponse from(ActionTimeline entity) { + return ActionTimelineResponse.builder() + .actionId(entity.getActionId()) + .empNo(entity.getEmpNo()) + .empName(entity.getEmpName()) + .empRole(entity.getEmpRole()) + .actionTime(entity.getActionTime()) + .actionContent(entity.getActionContent()) + .actionCategory(entity.getActionCategory()) + .actionResult(entity.getActionResult()) + .build(); + } +} diff --git a/src/main/java/com/aims/backend/dto/alert/AlertEventResponse.java b/src/main/java/com/aims/backend/dto/alert/AlertEventResponse.java index 42aedc6..ceb4a24 100644 --- a/src/main/java/com/aims/backend/dto/alert/AlertEventResponse.java +++ b/src/main/java/com/aims/backend/dto/alert/AlertEventResponse.java @@ -30,6 +30,7 @@ public class AlertEventResponse { private String actionStatus; private String reason; private LocalDateTime scoreCalculatedAt; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime resolvedAt; @@ -52,6 +53,7 @@ public static AlertEventResponse from(AlertEvent alertEvent) { .actionStatus(alertEvent.getActionStatus() == null ? null : alertEvent.getActionStatus().name()) .reason(alertEvent.getReason()) .scoreCalculatedAt(alertEvent.getScoreCalculatedAt()) + .imageUrl(alertEvent.getImageUrl()) .createdAt(alertEvent.getCreatedAt()) .resolvedAt(alertEvent.getResolvedAt()) .build(); diff --git a/src/main/java/com/aims/backend/dto/alert/AlertPrioritySummaryResponse.java b/src/main/java/com/aims/backend/dto/alert/AlertPrioritySummaryResponse.java new file mode 100644 index 0000000..42561a8 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/alert/AlertPrioritySummaryResponse.java @@ -0,0 +1,18 @@ +package com.aims.backend.dto.alert; + +import java.math.BigDecimal; + +public record AlertPrioritySummaryResponse( + + int periodDays, + + BigDecimal averagePriorityScore, + + BigDecimal averageRiskScore, + + BigDecimal averageOccurrencePercentage, + + BigDecimal actionCompletionRate + +) { +} diff --git a/src/main/java/com/aims/backend/dto/dashboard/AgvArrivalRequest.java b/src/main/java/com/aims/backend/dto/dashboard/AgvArrivalRequest.java new file mode 100644 index 0000000..2143a6c --- /dev/null +++ b/src/main/java/com/aims/backend/dto/dashboard/AgvArrivalRequest.java @@ -0,0 +1,17 @@ +package com.aims.backend.dto.dashboard; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AgvArrivalRequest { + + private String eventId; +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/dto/dashboard/AgvOperationResponse.java b/src/main/java/com/aims/backend/dto/dashboard/AgvOperationResponse.java index c849fe5..b9affb2 100644 --- a/src/main/java/com/aims/backend/dto/dashboard/AgvOperationResponse.java +++ b/src/main/java/com/aims/backend/dto/dashboard/AgvOperationResponse.java @@ -1,17 +1,38 @@ package com.aims.backend.dto.dashboard; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.Instant; import java.time.LocalDateTime; -public record AgvOperationResponse( - Long id, - Long carMasterId, - String agvStatus, - String currentProcess, - String targetProcess, - Double progressRate, - Integer delaySeconds, - String routeCode, - Integer laneNo, - LocalDateTime updatedAt -) { +@Getter +@AllArgsConstructor +public class AgvOperationResponse { + + private Long agvId; + + private String eventId; + + private Long carMasterId; + + private String agvStatus; + + private String currentProcess; + + private String targetProcess; + + private Double progressRate; + + private Integer delaySeconds; + + private Instant startedAt; + + private Instant expectedArrivalTime; + + private String routeCode; + + private Integer laneNo; + + private LocalDateTime updatedAt; } \ No newline at end of file diff --git a/src/main/java/com/aims/backend/dto/dashboard/AgvRealtimeState.java b/src/main/java/com/aims/backend/dto/dashboard/AgvRealtimeState.java index db5f53f..50dfd70 100644 --- a/src/main/java/com/aims/backend/dto/dashboard/AgvRealtimeState.java +++ b/src/main/java/com/aims/backend/dto/dashboard/AgvRealtimeState.java @@ -1,7 +1,13 @@ package com.aims.backend.dto.dashboard; -import com.fasterxml.jackson.annotation.JsonIgnore; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Duration; +import java.time.Instant; @Getter @Setter @@ -12,17 +18,65 @@ public class AgvRealtimeState { private Long agvId; + private String eventId; + + private Long carMasterId; + + /** + * MOVING / RETURNING + */ + private String status; + + private String currentProcess; + + private String targetProcess; + + /** + * 누적 증가값이 아니라 startedAt, expectedArrivalTime 기준으로 계산된 값 + */ private Double progressRate; private Integer delaySeconds; - public void increaseProgress(double amount) { - double current = progressRate == null ? 0.0 : progressRate; - this.progressRate = Math.min(100.0, current + amount); + private Instant startedAt; + + private Instant expectedArrivalTime; + + public static AgvRealtimeState empty(Long agvId) { + return AgvRealtimeState.builder() + .agvId(agvId) + .progressRate(0.0) + .delaySeconds(0) + .build(); } - @JsonIgnore - public boolean isArrived() { - return progressRate != null && progressRate >= 100.0; + public void calculateProgress(Instant now) { + if (startedAt == null || expectedArrivalTime == null) { + this.progressRate = 0.0; + return; + } + + long totalMillis = Duration.between( + startedAt, + expectedArrivalTime + ).toMillis(); + + long elapsedMillis = Duration.between( + startedAt, + now + ).toMillis(); + + if (totalMillis <= 0) { + this.progressRate = 100.0; + return; + } + + double progress = + ((double) elapsedMillis / totalMillis) * 100.0; + + this.progressRate = Math.max( + 0.0, + Math.min(100.0, progress) + ); } } \ No newline at end of file diff --git a/src/main/java/com/aims/backend/dto/dashboard/DispatchRequest.java b/src/main/java/com/aims/backend/dto/dashboard/DispatchRequest.java new file mode 100644 index 0000000..02e7d26 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/dashboard/DispatchRequest.java @@ -0,0 +1,10 @@ +package com.aims.backend.dto.dashboard; + +import com.aims.backend.domain.dashboard.enums.ProcessCode; + +public record DispatchRequest( + String eventId, + Long carMasterId, + ProcessCode processCode +) { +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/dto/eventAnalysis/ActionTimelineResponse.java b/src/main/java/com/aims/backend/dto/eventAnalysis/ActionTimelineResponse.java new file mode 100644 index 0000000..784ed8e --- /dev/null +++ b/src/main/java/com/aims/backend/dto/eventAnalysis/ActionTimelineResponse.java @@ -0,0 +1,33 @@ +package com.aims.backend.dto.eventAnalysis; + +import com.aims.backend.domain.alert.ActionCategory; +import com.aims.backend.domain.user.UserRole; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActionTimelineResponse { + + private Long actionId; + + private LocalDateTime actionTime; + + private String empNo; + + private String empName; + + private UserRole empRole; + + private ActionCategory actionCategory; + + private String actionContent; + + private String actionResult; +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/dto/eventAnalysis/AssemblyAnalysisResultDto.java b/src/main/java/com/aims/backend/dto/eventAnalysis/AssemblyAnalysisResultDto.java new file mode 100644 index 0000000..1ba988d --- /dev/null +++ b/src/main/java/com/aims/backend/dto/eventAnalysis/AssemblyAnalysisResultDto.java @@ -0,0 +1,31 @@ +package com.aims.backend.dto.eventAnalysis; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssemblyAnalysisResultDto { + + private Long analysisResultId; + + private String expectedSequence; + + private String actualSequence; + + private Integer sequenceErrorCount; + + private Integer missingPartCount; + + private Integer fasteningErrorCount; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/aims/backend/dto/eventAnalysis/BodyAnalysisResultDto.java b/src/main/java/com/aims/backend/dto/eventAnalysis/BodyAnalysisResultDto.java new file mode 100644 index 0000000..6fb81c5 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/eventAnalysis/BodyAnalysisResultDto.java @@ -0,0 +1,31 @@ +package com.aims.backend.dto.eventAnalysis; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BodyAnalysisResultDto { + + private Long analysisResultId; + + private String robotMotionStatus; + + private String robotOperationMode; + + private Double robotVibrationScore; + + private String frequencyPeakBand; + + private Double frequencyPeakValue; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/aims/backend/dto/eventAnalysis/ManufacturingAnalysisResultDto.java b/src/main/java/com/aims/backend/dto/eventAnalysis/ManufacturingAnalysisResultDto.java new file mode 100644 index 0000000..d30bfe3 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/eventAnalysis/ManufacturingAnalysisResultDto.java @@ -0,0 +1,44 @@ +package com.aims.backend.dto.eventAnalysis; + +import com.aims.backend.domain.dashboard.enums.ProcessCode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ManufacturingAnalysisResultDto { + + private Long id; + + private String eventId; + + private Long carMasterId; + + private Long equipmentId; + + private ProcessCode processCode; + + private LocalDateTime eventTime; + + private Integer isAbnormal; + + private String abnormalType; + + private String severity; + + private Double riskScore; + + private String analysisMessage; + + private LocalDateTime analyzedAt; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/aims/backend/dto/eventAnalysis/PaintAnalysisResultDto.java b/src/main/java/com/aims/backend/dto/eventAnalysis/PaintAnalysisResultDto.java new file mode 100644 index 0000000..b820e58 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/eventAnalysis/PaintAnalysisResultDto.java @@ -0,0 +1,33 @@ +package com.aims.backend.dto.eventAnalysis; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaintAnalysisResultDto { + + private Long analysisResultId; + + private String imagePosition; + + private Double thermalStdTemp; + + private Double thicknessValue; + + private Double defeatScore; + + private String visionLabel; + + private Double surfaceQualityScore; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/aims/backend/dto/eventAnalysis/PressAnalysisResultDto.java b/src/main/java/com/aims/backend/dto/eventAnalysis/PressAnalysisResultDto.java new file mode 100644 index 0000000..28c7749 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/eventAnalysis/PressAnalysisResultDto.java @@ -0,0 +1,31 @@ +package com.aims.backend.dto.eventAnalysis; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PressAnalysisResultDto { + + private Long analysisResultId; + + private Integer countIncreaseYn; + + private Double targetCycleTimeSec; + + private Double actualCycleTimeSec; + + private Double cycleTimeGapSec; + + private Double timestampDelaySec; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/aims/backend/dto/eventAnalysis/RecommendationResponse.java b/src/main/java/com/aims/backend/dto/eventAnalysis/RecommendationResponse.java new file mode 100644 index 0000000..ede5d14 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/eventAnalysis/RecommendationResponse.java @@ -0,0 +1,28 @@ +package com.aims.backend.dto.eventAnalysis; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecommendationResponse { + + private String similarLogNo; + + private Double confidence; + + private String handler; + + private String recommendedAction; + + private String recommendationReason; + + private List actionTimeline; +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/dto/eventAnalysis/SimilarResult.java b/src/main/java/com/aims/backend/dto/eventAnalysis/SimilarResult.java new file mode 100644 index 0000000..8945213 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/eventAnalysis/SimilarResult.java @@ -0,0 +1,14 @@ +package com.aims.backend.dto.eventAnalysis; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Builder; + +@Builder +@Getter +@AllArgsConstructor +public class SimilarResult { + private String similarLogNo; + private double confidence; + private String logNo; +} diff --git a/src/main/java/com/aims/backend/dto/kafka/ManufacturingAnalysisEvent.java b/src/main/java/com/aims/backend/dto/kafka/ManufacturingAnalysisEvent.java new file mode 100644 index 0000000..0aa6756 --- /dev/null +++ b/src/main/java/com/aims/backend/dto/kafka/ManufacturingAnalysisEvent.java @@ -0,0 +1,39 @@ +package com.aims.backend.dto.kafka; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ManufacturingAnalysisEvent( + + String analysisId, + + String eventId, + + Long carMasterId, + + String processCode, + + String riskLevel, + + String analysisType, + + AnalysisResult analysisResult + +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record AnalysisResult( + + boolean isAbnormal, + + boolean isBottleneck, + + boolean isQualityDefect, + + boolean isEquipmentFault, + + boolean isSequenceError + + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/dto/mainpage/UserTaskResponse.java b/src/main/java/com/aims/backend/dto/mainpage/UserTaskResponse.java index 19d8b1f..7c6ecf7 100644 --- a/src/main/java/com/aims/backend/dto/mainpage/UserTaskResponse.java +++ b/src/main/java/com/aims/backend/dto/mainpage/UserTaskResponse.java @@ -1,6 +1,5 @@ package com.aims.backend.dto.mainpage; -import com.aims.backend.domain.mainpage.TaskStatus; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -21,10 +20,7 @@ public static class MainPageTaskDTO { @Schema(description = "업무명", example = "프레스 라인 점검") private String taskTitle; - @Schema(description = "업무 상태", example = "TODO") - private TaskStatus taskStatus; - @Schema(description = "업무 시간", example = "2026-06-11T09:00:00") - private LocalDateTime scheduledAt; + private String scheduledAt; } } diff --git a/src/main/java/com/aims/backend/repository/alert/ActionTimelineRepository.java b/src/main/java/com/aims/backend/repository/alert/ActionTimelineRepository.java new file mode 100644 index 0000000..80515c8 --- /dev/null +++ b/src/main/java/com/aims/backend/repository/alert/ActionTimelineRepository.java @@ -0,0 +1,13 @@ +package com.aims.backend.repository.alert; + +import org.springframework.stereotype.Repository; + +import com.aims.backend.domain.alert.ActionTimeline; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + + +@Repository +public interface ActionTimelineRepository extends JpaRepository { + List findByLogNoOrderByActionTimeAsc(String logNo); +} diff --git a/src/main/java/com/aims/backend/repository/alert/AlertEventRepository.java b/src/main/java/com/aims/backend/repository/alert/AlertEventRepository.java index eabc340..299822c 100644 --- a/src/main/java/com/aims/backend/repository/alert/AlertEventRepository.java +++ b/src/main/java/com/aims/backend/repository/alert/AlertEventRepository.java @@ -7,11 +7,31 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Optional; public interface AlertEventRepository extends JpaRepository, JpaSpecificationExecutor { + interface PrioritySummaryProjection { + + long getTotalCount(); + + BigDecimal getPriorityScoreSum(); + + long getPriorityScoreCount(); + + BigDecimal getRiskScoreSum(); + + long getRiskScoreCount(); + + BigDecimal getOccurrenceScoreSum(); + + long getOccurrenceScoreCount(); + + long getCompletedCount(); + } + boolean existsByEventId(String eventId); Optional findByEventId(String eventId); @@ -39,4 +59,24 @@ long countByEventKeyAndActionStatus( String eventKey, AlertActionStatus actionStatus ); + + @Query(""" + SELECT COUNT(e) AS totalCount, + SUM(CASE WHEN e.actionStatus = :incompleteStatus THEN e.priorityScore ELSE 0 END) AS priorityScoreSum, + COUNT(CASE WHEN e.actionStatus = :incompleteStatus THEN e.priorityScore ELSE NULL END) AS priorityScoreCount, + SUM(CASE WHEN e.actionStatus = :incompleteStatus THEN e.riskScore ELSE 0 END) AS riskScoreSum, + COUNT(CASE WHEN e.actionStatus = :incompleteStatus THEN e.riskScore ELSE NULL END) AS riskScoreCount, + SUM(CASE WHEN e.actionStatus = :incompleteStatus THEN e.occurrenceScore ELSE 0 END) AS occurrenceScoreSum, + COUNT(CASE WHEN e.actionStatus = :incompleteStatus THEN e.occurrenceScore ELSE NULL END) AS occurrenceScoreCount, + SUM(CASE WHEN e.actionStatus = :completedStatus THEN 1 ELSE 0 END) AS completedCount + FROM AlertEvent e + WHERE e.createdAt >= :from + AND e.createdAt < :to + """) + PrioritySummaryProjection findPrioritySummary( + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + @Param("incompleteStatus") AlertActionStatus incompleteStatus, + @Param("completedStatus") AlertActionStatus completedStatus + ); } diff --git a/src/main/java/com/aims/backend/repository/dashboard/AgvOperationRepository.java b/src/main/java/com/aims/backend/repository/dashboard/AgvOperationRepository.java index 8e32541..3439683 100644 --- a/src/main/java/com/aims/backend/repository/dashboard/AgvOperationRepository.java +++ b/src/main/java/com/aims/backend/repository/dashboard/AgvOperationRepository.java @@ -2,11 +2,9 @@ import com.aims.backend.domain.dashboard.AgvOperation; import com.aims.backend.domain.dashboard.enums.AgvStatus; -import com.aims.backend.dto.dashboard.AgvStatusCountResponse; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; - -import java.util.List; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -21,4 +19,21 @@ Optional findFirstByRouteCodeAndAgvStatusOrderByLaneNoAsc( String routeCode, AgvStatus agvStatus ); -} \ No newline at end of file + + @Query( + value = """ + SELECT * + FROM agv_operation + WHERE route_code = :routeCode + AND agv_status = :agvStatus + ORDER BY lane_no ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + Optional findFirstWaitingAgvForUpdateSkipLocked( + @Param("routeCode") String routeCode, + @Param("agvStatus") String agvStatus + ); +} diff --git a/src/main/java/com/aims/backend/repository/eventAnalysis/AssemblyAnalysisResultRepository.java b/src/main/java/com/aims/backend/repository/eventAnalysis/AssemblyAnalysisResultRepository.java new file mode 100644 index 0000000..235e490 --- /dev/null +++ b/src/main/java/com/aims/backend/repository/eventAnalysis/AssemblyAnalysisResultRepository.java @@ -0,0 +1,31 @@ +package com.aims.backend.repository.eventAnalysis; + +import com.aims.backend.domain.eventAnalysis.AssemblyAnalysisResult; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; +import java.util.List; + +public interface AssemblyAnalysisResultRepository + extends JpaRepository { + + Optional findByAnalysisResultId(Long id); + + List findAll(); + + @Query(""" + SELECT a + FROM AssemblyAnalysisResult a + WHERE a.analysisResultId <> :currentId + ORDER BY ABS(a.sequenceErrorCount - :sequenceErrorCount) + """) + List findTop20Similar( + @Param("currentId") Long currentId, + @Param("sequenceErrorCount") Integer sequenceErrorCount, + Pageable pageable + ); + +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/repository/eventAnalysis/BodyAnalysisResultRepository.java b/src/main/java/com/aims/backend/repository/eventAnalysis/BodyAnalysisResultRepository.java new file mode 100644 index 0000000..096c62e --- /dev/null +++ b/src/main/java/com/aims/backend/repository/eventAnalysis/BodyAnalysisResultRepository.java @@ -0,0 +1,30 @@ +package com.aims.backend.repository.eventAnalysis; + +import com.aims.backend.domain.eventAnalysis.BodyAnalysisResult; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; +import java.util.List; + +public interface BodyAnalysisResultRepository + extends JpaRepository { + + Optional findByAnalysisResultId(Long id); + + List findAll(); + + @Query(""" + SELECT b + FROM BodyAnalysisResult b + WHERE b.analysisResultId <> :currentId + ORDER BY ABS(b.robotVibrationScore - :robotVibrationScore) + """) + List findTop20Similar( + @Param("currentId") Long currentId, + @Param("robotVibrationScore") Double robotVibrationScore, + Pageable pageable + ); +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/repository/eventAnalysis/ManufacturingAnalysisResultRepository.java b/src/main/java/com/aims/backend/repository/eventAnalysis/ManufacturingAnalysisResultRepository.java new file mode 100644 index 0000000..5b53a94 --- /dev/null +++ b/src/main/java/com/aims/backend/repository/eventAnalysis/ManufacturingAnalysisResultRepository.java @@ -0,0 +1,20 @@ +package com.aims.backend.repository.eventAnalysis; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.aims.backend.domain.dashboard.enums.ProcessCode; +import com.aims.backend.domain.eventAnalysis.ManufacturingAnalysisResult; + +import java.util.Optional; +import java.util.List; + +public interface ManufacturingAnalysisResultRepository + extends JpaRepository { + + Optional findByEventId(String eventId); + + Optional findById(Long id); + + List findByProcessCode(ProcessCode processCode); + +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/repository/eventAnalysis/PaintAnalysisResultRepository.java b/src/main/java/com/aims/backend/repository/eventAnalysis/PaintAnalysisResultRepository.java new file mode 100644 index 0000000..7ba6362 --- /dev/null +++ b/src/main/java/com/aims/backend/repository/eventAnalysis/PaintAnalysisResultRepository.java @@ -0,0 +1,31 @@ +package com.aims.backend.repository.eventAnalysis; + +import com.aims.backend.domain.eventAnalysis.PaintAnalysisResult; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; +import java.util.List; + +public interface PaintAnalysisResultRepository + extends JpaRepository { + Optional findByAnalysisResultId(Long id); + + List findAll(); + + @Query(""" + SELECT p + FROM PaintAnalysisResult p + WHERE p.analysisResultId <> :currentId + AND p.visionLabel = :visionLabel + ORDER BY ABS(p.surfaceQualityScore - :surfaceQualityScore) + """) + List findTop20Similar( + @Param("currentId") Long currentId, + @Param("visionLabel") String visionLabel, + @Param("surfaceQualityScore") Double surfaceQualityScore, + Pageable pageable + ); +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/repository/eventAnalysis/PressAnalysisResultRepository.java b/src/main/java/com/aims/backend/repository/eventAnalysis/PressAnalysisResultRepository.java new file mode 100644 index 0000000..72e5a1c --- /dev/null +++ b/src/main/java/com/aims/backend/repository/eventAnalysis/PressAnalysisResultRepository.java @@ -0,0 +1,36 @@ +package com.aims.backend.repository.eventAnalysis; + +import com.aims.backend.domain.eventAnalysis.PressAnalysisResult; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; +import java.util.List; + + +public interface PressAnalysisResultRepository + extends JpaRepository { + + Optional findByAnalysisResultId(Long id); + + List findAll(); + + @Query(""" + SELECT p + FROM PressAnalysisResult p + WHERE p.analysisResultId <> :currentId + AND p.countIncreaseYn = :countIncreaseYn + ORDER BY + ABS(p.cycleTimeGapSec - :cycleGap), + ABS(COALESCE(p.timestampDelaySec, 0.0) - COALESCE(:timestampDelay, 0.0)) + """) + List findMostSimilar( + @Param("currentId") Long currentId, + @Param("countIncreaseYn") Integer countIncreaseYn, + @Param("cycleGap") Double cycleGap, + @Param("timestampDelay") Double timestampDelay, + Pageable pageable + ); +} diff --git a/src/main/java/com/aims/backend/repository/sample/EquipmentRepository.java b/src/main/java/com/aims/backend/repository/sample/EquipmentRepository.java index f9e1445..afa8c1f 100644 --- a/src/main/java/com/aims/backend/repository/sample/EquipmentRepository.java +++ b/src/main/java/com/aims/backend/repository/sample/EquipmentRepository.java @@ -14,7 +14,7 @@ public interface EquipmentRepository extends JpaRepository { @Query("SELECT e.currentStatus, COUNT(e) FROM Equipment e GROUP BY e.currentStatus") List countAllByCurrentStatus(); - @Query("SELECT e.processCode, COUNT(e) FROM Equipment e WHERE e.currentStatus IN ('RUNNING', 'IDLE') GROUP BY e.processCode") + @Query("SELECT e.processCode, COUNT(e) FROM Equipment e WHERE e.currentStatus IN ('RUNNING', 'WARNING') GROUP BY e.processCode") List countActiveByProcessCode(); @Query("SELECT e.processCode, COUNT(e) FROM Equipment e GROUP BY e.processCode") diff --git a/src/main/java/com/aims/backend/service/MainPageService.java b/src/main/java/com/aims/backend/service/MainPageService.java index 6b89b3b..c31e2c7 100644 --- a/src/main/java/com/aims/backend/service/MainPageService.java +++ b/src/main/java/com/aims/backend/service/MainPageService.java @@ -12,6 +12,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.stream.Collectors; @@ -23,6 +24,8 @@ public class MainPageService { private final UserTaskRepository mainPageUserTaskRepository; private final InspectionSummaryRepository inspectionSummaryRepository; + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); + public List getUserTasks(Long userId) { LocalDateTime currentTime = LocalDateTime.now() .withMinute(0) @@ -33,8 +36,7 @@ public List getUserTasks(Long userId) { return userTasks.stream() .map(task -> UserTaskResponse.MainPageTaskDTO.builder() .taskTitle(task.getTaskTitle()) - .taskStatus(task.getTaskStatus()) - .scheduledAt(task.getScheduledAt()) + .scheduledAt(task.getScheduledAt().format(formatter)) .build()) .collect(Collectors.toList()); } diff --git a/src/main/java/com/aims/backend/service/alert/ActionTimelineService.java b/src/main/java/com/aims/backend/service/alert/ActionTimelineService.java new file mode 100644 index 0000000..efee0d3 --- /dev/null +++ b/src/main/java/com/aims/backend/service/alert/ActionTimelineService.java @@ -0,0 +1,55 @@ +package com.aims.backend.service.alert; + +import com.aims.backend.config.jwt.TokenProvider; +import com.aims.backend.domain.alert.ActionTimeline; +import com.aims.backend.domain.user.UserRole; +import com.aims.backend.dto.alert.ActionTimelineCreateRequest; +import com.aims.backend.dto.alert.ActionTimelineResponse; +import com.aims.backend.repository.alert.ActionTimelineRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import java.util.UUID; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ActionTimelineService { + private final ActionTimelineRepository actionTimelineRepository; + + @Transactional() + public List getTimeline(String logNo) { + + return actionTimelineRepository + .findByLogNoOrderByActionTimeAsc(logNo) + .stream() + .map(ActionTimelineResponse::from) + .toList(); + } + + + @Transactional + public ActionTimelineResponse createTimeline( + String logNo, + ActionTimelineCreateRequest request, + TokenProvider.JwtPrincipal principal + ) { + + ActionTimeline actionTimeline = ActionTimeline.builder() + .logNo(logNo) + .empNo(principal.EmpNo().toString()) + .empName(principal.name()) + .empRole(UserRole.valueOf(principal.role())) + .actionTime(request.getActionTime()) + .actionContent(request.getActionContent()) + .actionCategory(request.getActionCategory()) + .actionResult(request.getActionResult()) + .createdAt(LocalDateTime.now()) + .build(); + + ActionTimeline saved = actionTimelineRepository.save(actionTimeline); + return ActionTimelineResponse.from(saved); + } +} diff --git a/src/main/java/com/aims/backend/service/alert/AlertEventConsumer.java b/src/main/java/com/aims/backend/service/alert/AlertEventConsumer.java index edbd7d3..b744936 100644 --- a/src/main/java/com/aims/backend/service/alert/AlertEventConsumer.java +++ b/src/main/java/com/aims/backend/service/alert/AlertEventConsumer.java @@ -22,6 +22,8 @@ public class AlertEventConsumer { groupId = "${app.kafka.group-id:backend-local}" ) public void consume(String message) { + log.info("===== Kafka Alert Message Received ====="); + log.info("rawMessage={}", message); log.info("Alert Kafka message received. eventId={}", eventId(message)); try { diff --git a/src/main/java/com/aims/backend/service/alert/AlertEventQueryService.java b/src/main/java/com/aims/backend/service/alert/AlertEventQueryService.java index 3efa21c..f6c6e76 100644 --- a/src/main/java/com/aims/backend/service/alert/AlertEventQueryService.java +++ b/src/main/java/com/aims/backend/service/alert/AlertEventQueryService.java @@ -2,11 +2,15 @@ import com.aims.backend.common.status.ErrorStatus; import com.aims.backend.domain.alert.AlertEvent; +import com.aims.backend.domain.dashboard.Equipment; +import com.aims.backend.domain.dashboard.enums.OperationStatus; import com.aims.backend.dto.alert.AlertActionUpdateRequest; import com.aims.backend.dto.alert.AlertEventResponse; import com.aims.backend.dto.alert.AlertSearchRequest; import com.aims.backend.exception.GeneralException; import com.aims.backend.repository.alert.AlertEventRepository; +import com.aims.backend.repository.sample.EquipmentRepository; + import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; @@ -38,6 +42,7 @@ public class AlertEventQueryService { ); private final AlertEventRepository alertEventRepository; + private final EquipmentRepository equipmentRepository; @Transactional(readOnly = true) public Page getAlerts(AlertSearchRequest request) { @@ -74,8 +79,7 @@ public AlertEventResponse updateAction( AlertActionUpdateRequest request ) { - AlertEvent alertEvent = - getAlertEvent(logNo); + AlertEvent alertEvent = getAlertEvent(logNo); alertEvent.updateAction( request.getActionBy(), @@ -83,6 +87,17 @@ public AlertEventResponse updateAction( request.getReason() ); + if (alertEvent.getEquipmentId() != null) { + Equipment equipment = equipmentRepository.findById(alertEvent.getEquipmentId()) + .orElseThrow(() -> new GeneralException( + ErrorStatus.NOT_FOUND, + "Equipment not found. id=" + alertEvent.getEquipmentId() + )); + + equipment.setCurrentStatus(OperationStatus.RUNNING); + equipmentRepository.save(equipment); + } + return AlertEventResponse.from(alertEvent); } diff --git a/src/main/java/com/aims/backend/service/alert/AlertEventSaveService.java b/src/main/java/com/aims/backend/service/alert/AlertEventSaveService.java index 5410bbd..00421f1 100644 --- a/src/main/java/com/aims/backend/service/alert/AlertEventSaveService.java +++ b/src/main/java/com/aims/backend/service/alert/AlertEventSaveService.java @@ -1,5 +1,6 @@ package com.aims.backend.service.alert; +import com.aims.backend.config.AlertImageProperties; import com.aims.backend.domain.alert.AlertActionStatus; import com.aims.backend.domain.alert.AlertEvent; import com.aims.backend.domain.alert.AlertSeverity; @@ -131,6 +132,7 @@ private CalculatedAlert toCalculatedAlert(JsonNode root) { defaultContents(alertType, processCode, equipmentId, text(root, "contents", "message", "description")); String eventKey = eventKey(root, alertType, processCode, equipmentId, title, eventId); + BigDecimal riskScore = score(root, BigDecimal.ZERO, BigDecimal.valueOf(100), "riskScore", "risk_score"); if (riskScore == null) { @@ -150,6 +152,9 @@ private CalculatedAlert toCalculatedAlert(JsonNode root) { LocalDateTime scoreCalculatedAt = LocalDateTime.now(); + String imageUrl = + alertImageProperties.resolve(processCode, alertType, severity); + log.info( "Adaptive eRPN calculated. eventId={}, riskScore={}, occurrenceScore={}, detectionScore={}, priorityScore={}, severity={}", eventId, @@ -173,10 +178,12 @@ private CalculatedAlert toCalculatedAlert(JsonNode root) { severity, title, contents, + imageUrl, AlertActionStatus.INCOMPLETE, scoreCalculatedAt ); } + private final AlertImageProperties alertImageProperties; private void publishRealtimeAlert(CalculatedAlert calculatedAlert) { @@ -199,6 +206,7 @@ private void publishRealtimeAlert(CalculatedAlert calculatedAlert) { ); try { + log.info("Kafka Message = {}", message); log.info( "Alert websocket publish start. destination={}, eventId={}", AlertWebSocketPublisher.ALERT_DESTINATION, @@ -241,6 +249,7 @@ private AlertEvent toAlertEvent(CalculatedAlert calculatedAlert) { .severity(calculatedAlert.severity()) .title(truncate(calculatedAlert.title(), 100)) .contents(truncate(calculatedAlert.contents(), 500)) + .imageUrl(calculatedAlert.imageUrl()) .actionStatus(calculatedAlert.actionStatus()) .scoreCalculatedAt(calculatedAlert.scoreCalculatedAt()) .build(); @@ -578,6 +587,7 @@ private record CalculatedAlert( AlertSeverity severity, String title, String contents, + String imageUrl, AlertActionStatus actionStatus, LocalDateTime scoreCalculatedAt ) { diff --git a/src/main/java/com/aims/backend/service/alert/AlertPrioritySummaryService.java b/src/main/java/com/aims/backend/service/alert/AlertPrioritySummaryService.java new file mode 100644 index 0000000..ac13f20 --- /dev/null +++ b/src/main/java/com/aims/backend/service/alert/AlertPrioritySummaryService.java @@ -0,0 +1,108 @@ +package com.aims.backend.service.alert; + +import com.aims.backend.domain.alert.AlertActionStatus; +import com.aims.backend.dto.alert.AlertPrioritySummaryResponse; +import com.aims.backend.repository.alert.AlertEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class AlertPrioritySummaryService { + + private static final BigDecimal ONE_HUNDRED = new BigDecimal("100"); + + private final AlertEventRepository alertEventRepository; + + @Transactional(readOnly = true) + public AlertPrioritySummaryResponse getPrioritySummary(int days) { + LocalDateTime to = LocalDateTime.now(); + LocalDateTime from = to.minusDays(days); + + AlertEventRepository.PrioritySummaryProjection summary = + alertEventRepository.findPrioritySummary( + from, + to, + AlertActionStatus.INCOMPLETE, + AlertActionStatus.COMPLETED + ); + + if (summary == null || summary.getTotalCount() == 0) { + return emptyResponse(days); + } + + BigDecimal averagePriorityScore = + average(summary.getPriorityScoreSum(), summary.getPriorityScoreCount(), 2); + BigDecimal averageRiskScore = + average(summary.getRiskScoreSum(), summary.getRiskScoreCount(), 1); + BigDecimal averageOccurrencePercentage = + percentage(summary.getOccurrenceScoreSum(), summary.getOccurrenceScoreCount()); + BigDecimal actionCompletionRate = + rate(summary.getCompletedCount(), summary.getTotalCount()); + + return new AlertPrioritySummaryResponse( + days, + averagePriorityScore, + averageRiskScore, + averageOccurrencePercentage, + actionCompletionRate + ); + } + + private AlertPrioritySummaryResponse emptyResponse(int days) { + return new AlertPrioritySummaryResponse( + days, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO + ); + } + + private BigDecimal average(BigDecimal sum, long count, int scale) { + if (sum == null || count == 0) { + return BigDecimal.ZERO; + } + + return sum.divide(BigDecimal.valueOf(count), scale, RoundingMode.HALF_UP); + } + + private BigDecimal percentage(BigDecimal sum, long count) { + if (sum == null || count == 0) { + return BigDecimal.ZERO; + } + + BigDecimal percentage = + sum.multiply(ONE_HUNDRED) + .divide(BigDecimal.valueOf(count), 0, RoundingMode.HALF_UP); + return clampPercentage(percentage); + } + + private BigDecimal rate(long completedCount, long totalCount) { + if (totalCount == 0) { + return BigDecimal.ZERO; + } + + BigDecimal percentage = + BigDecimal.valueOf(completedCount) + .multiply(ONE_HUNDRED) + .divide(BigDecimal.valueOf(totalCount), 0, RoundingMode.HALF_UP); + return clampPercentage(percentage); + } + + private BigDecimal clampPercentage(BigDecimal value) { + if (value.compareTo(BigDecimal.ZERO) < 0) { + return BigDecimal.ZERO; + } + if (value.compareTo(ONE_HUNDRED) > 0) { + return ONE_HUNDRED; + } + + return value.setScale(0, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/com/aims/backend/service/dashboard/AgvDispatchQueueService.java b/src/main/java/com/aims/backend/service/dashboard/AgvDispatchQueueService.java new file mode 100644 index 0000000..a76e0ab --- /dev/null +++ b/src/main/java/com/aims/backend/service/dashboard/AgvDispatchQueueService.java @@ -0,0 +1,139 @@ +package com.aims.backend.service.dashboard; + +import com.aims.backend.domain.dashboard.enums.ProcessCode; +import com.aims.backend.dto.dashboard.DispatchRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AgvDispatchQueueService { + + private final AgvDispatchRedisService redisService; + + private static final List ROUTE_CODES = List.of( + "PRESS_BODY", + "BODY_PAINT", + "PAINT_ASSEMBLY", + "ASSEMBLY_INSPECTION" + ); + + public boolean offer(DispatchRequest request) { + String routeCode = resolveRouteCode(request.processCode()); + + if (routeCode == null) { + log.info( + "[AGV QUEUE SKIP] 운반 대상 공정 아님. eventId={}, process={}", + request.eventId(), + request.processCode() + ); + return false; + } + + return offer(routeCode, request); + } + + public boolean offer( + String routeCode, + DispatchRequest request + ) { + Boolean added = redisService.addEventId( + routeCode, + request.eventId() + ); + + if (!Boolean.TRUE.equals(added)) { + log.debug( + "[AGV QUEUE][{}] duplicated eventId={} ignored", + routeCode, + request.eventId() + ); + return false; + } + + try { + redisService.pushLast(routeCode, request); + } catch (Exception e) { + redisService.removeEventId(routeCode, request.eventId()); + throw e; + } + + log.info( + "[AGV QUEUE][{}] queued eventId={}, process={}, queueSize={}, waitingEvents={}", + routeCode, + request.eventId(), + request.processCode(), + size(routeCode), + getWaitingEventIds(routeCode) + ); + + return true; + } + + public void requeueFirst( + String routeCode, + DispatchRequest request + ) { + redisService.pushFirst(routeCode, request); + + log.debug( + "[AGV QUEUE][{}] requeued eventId={}, process={}, queueSize={}", + routeCode, + request.eventId(), + request.processCode(), + size(routeCode) + ); + } + + public Optional poll(String routeCode) { + Optional request = redisService.popFirst(routeCode); + + request.ifPresent(value -> { + redisService.removeEventId(routeCode, value.eventId()); + + log.info( + "[AGV QUEUE][{}] poll eventId={}, process={}, remainQueue={}", + routeCode, + value.eventId(), + value.processCode(), + getWaitingEventIds(routeCode) + ); + }); + + return request; + } + + public List getWaitingEventIds(String routeCode) { + return redisService.findAll(routeCode) + .stream() + .map(DispatchRequest::eventId) + .toList(); + } + + public int size(String routeCode) { + return redisService.size(routeCode); + } + + public List routeCodes() { + return ROUTE_CODES; + } + + public String resolveRouteCode(ProcessCode processCode) { + if (processCode == null) { + return null; + } + + return switch (processCode) { + case PRESS -> "PRESS_BODY"; + case BODY -> "BODY_PAINT"; + case PAINT -> "PAINT_ASSEMBLY"; + case ASSEMBLY -> "ASSEMBLY_INSPECTION"; + default -> null; + }; + } +} diff --git a/src/main/java/com/aims/backend/service/dashboard/AgvDispatchRedisService.java b/src/main/java/com/aims/backend/service/dashboard/AgvDispatchRedisService.java new file mode 100644 index 0000000..48d1d9b --- /dev/null +++ b/src/main/java/com/aims/backend/service/dashboard/AgvDispatchRedisService.java @@ -0,0 +1,100 @@ +package com.aims.backend.service.dashboard; + +import com.aims.backend.dto.dashboard.DispatchRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AgvDispatchRedisService { + + private static final String QUEUE_KEY_PREFIX = "agv:dispatch:queue:"; + private static final String EVENT_SET_KEY_PREFIX = "agv:dispatch:event-ids:"; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public Boolean addEventId( + String routeCode, + String eventId + ) { + return redisTemplate.opsForSet() + .add(makeEventSetKey(routeCode), eventId) == 1L; + } + + public void removeEventId( + String routeCode, + String eventId + ) { + redisTemplate.opsForSet() + .remove(makeEventSetKey(routeCode), eventId); + } + + public void pushLast( + String routeCode, + DispatchRequest request + ) { + redisTemplate.opsForList() + .rightPush(makeQueueKey(routeCode), request); + } + + public void pushFirst( + String routeCode, + DispatchRequest request + ) { + redisTemplate.opsForList() + .leftPush(makeQueueKey(routeCode), request); + } + + public Optional popFirst(String routeCode) { + Object value = redisTemplate.opsForList() + .leftPop(makeQueueKey(routeCode)); + + if (value == null) { + return Optional.empty(); + } + + return Optional.of( + objectMapper.convertValue( + value, + DispatchRequest.class + ) + ); + } + + public List findAll(String routeCode) { + List values = redisTemplate.opsForList() + .range(makeQueueKey(routeCode), 0, -1); + + if (values == null || values.isEmpty()) { + return List.of(); + } + + return values.stream() + .map(value -> objectMapper.convertValue( + value, + DispatchRequest.class + )) + .toList(); + } + + public int size(String routeCode) { + Long size = redisTemplate.opsForList() + .size(makeQueueKey(routeCode)); + + return size == null ? 0 : size.intValue(); + } + + private String makeQueueKey(String routeCode) { + return QUEUE_KEY_PREFIX + routeCode; + } + + private String makeEventSetKey(String routeCode) { + return EVENT_SET_KEY_PREFIX + routeCode; + } +} diff --git a/src/main/java/com/aims/backend/service/dashboard/AgvDispatchScheduler.java b/src/main/java/com/aims/backend/service/dashboard/AgvDispatchScheduler.java new file mode 100644 index 0000000..db34990 --- /dev/null +++ b/src/main/java/com/aims/backend/service/dashboard/AgvDispatchScheduler.java @@ -0,0 +1,116 @@ +package com.aims.backend.service.dashboard; + +import com.aims.backend.dto.dashboard.DispatchRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicBoolean; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.agv.dispatch-scheduler", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class AgvDispatchScheduler { + + private final AgvDispatchQueueService dispatchQueueService; + private final AgvSimulationService agvSimulationService; + + /** + * 같은 JVM 내부에서 Scheduler 중복 실행 방지 + */ + private final AtomicBoolean running = new AtomicBoolean(false); + + @Scheduled( + fixedDelayString = + "${app.agv.dispatch-scheduler.fixed-delay-ms:1000}" + ) + @SchedulerLock( + name = "agvDispatchScheduler", + lockAtMostFor = "PT10S", + lockAtLeastFor = "PT0.5S" + ) + public void dispatchQueuedEvents() { + + LockAssert.assertLocked(); + + if (!running.compareAndSet(false, true)) { + log.debug( + "[AGV DISPATCH SCHEDULER] 이전 작업이 아직 진행 중입니다." + ); + return; + } + + try { + + for (String routeCode : dispatchQueueService.routeCodes()) { + dispatchOne(routeCode); + } + + } finally { + running.set(false); + } + } + + private void dispatchOne(String routeCode) { + + dispatchQueueService + .poll(routeCode) + .ifPresent(request -> dispatch(routeCode, request)); + + } + + private void dispatch( + String routeCode, + DispatchRequest request + ) { + + try { + + boolean dispatched = + agvSimulationService.dispatchAgv( + request.eventId(), + request.carMasterId(), + request.processCode() + ); + + if (!dispatched) { + + dispatchQueueService.requeueFirst( + routeCode, + request + ); + + log.debug( + "[AGV DISPATCH][{}] AGV 없음. eventId={} 재대기", + routeCode, + request.eventId() + ); + } + + } catch (Exception e) { + + dispatchQueueService.requeueFirst( + routeCode, + request + ); + + log.error( + "[AGV DISPATCH][{}] dispatch 실패. eventId={}", + routeCode, + request.eventId(), + e + + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/service/dashboard/AgvRealtimeRedisService.java b/src/main/java/com/aims/backend/service/dashboard/AgvRealtimeRedisService.java index 55ae499..574641f 100644 --- a/src/main/java/com/aims/backend/service/dashboard/AgvRealtimeRedisService.java +++ b/src/main/java/com/aims/backend/service/dashboard/AgvRealtimeRedisService.java @@ -3,22 +3,26 @@ import com.aims.backend.dto.dashboard.AgvRealtimeState; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; import org.springframework.stereotype.Service; +import java.util.ArrayList; +import java.util.List; + +@Slf4j @Service @RequiredArgsConstructor public class AgvRealtimeRedisService { - private final RedisTemplate redisTemplate; - private final ObjectMapper objectMapper = - new ObjectMapper(); - private static final String KEY_PREFIX = "agv:realtime:"; + private static final String KEY_PATTERN = KEY_PREFIX + "*"; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; - /** - * Redis 저장 - */ public void save(AgvRealtimeState state) { redisTemplate.opsForValue().set( makeKey(state.getAgvId()), @@ -26,17 +30,12 @@ public void save(AgvRealtimeState state) { ); } - /** - * Redis 조회 - */ public AgvRealtimeState get(Long agvId) { - - Object value = - redisTemplate.opsForValue() - .get(makeKey(agvId)); + Object value = redisTemplate.opsForValue() + .get(makeKey(agvId)); if (value == null) { - return defaultState(agvId); + return AgvRealtimeState.empty(agvId); } return objectMapper.convertValue( @@ -45,24 +44,43 @@ public AgvRealtimeState get(Long agvId) { ); } - /** - * 진행률 초기화 - */ - public void reset(Long agvId) - { - AgvRealtimeState state = - AgvRealtimeState.builder() - .agvId(agvId) - .progressRate(0.0) - .delaySeconds(0) - .build(); - - save(state); + public List findAll() { + List states = new ArrayList<>(); + + ScanOptions options = ScanOptions.scanOptions() + .match(KEY_PATTERN) + .count(100) + .build(); + + try (Cursor cursor = redisTemplate.scan(options)) { + while (cursor.hasNext()) { + String key = cursor.next(); + Object value = redisTemplate.opsForValue().get(key); + + if (value == null) { + continue; + } + + try { + states.add( + objectMapper.convertValue( + value, + AgvRealtimeState.class + ) + ); + } catch (IllegalArgumentException e) { + log.warn( + "[AGV REDIS READ SKIP] 역직렬화 실패. key={}", + key, + e + ); + } + } + } + + return states; } - /** - * Redis 삭제 - */ public void delete(Long agvId) { redisTemplate.delete(makeKey(agvId)); } @@ -70,12 +88,4 @@ public void delete(Long agvId) { private String makeKey(Long agvId) { return KEY_PREFIX + agvId; } - - private AgvRealtimeState defaultState(Long agvId) { - return AgvRealtimeState.builder() - .agvId(agvId) - .progressRate(0.0) - .delaySeconds(0) - .build(); - } } \ No newline at end of file diff --git a/src/main/java/com/aims/backend/service/dashboard/AgvSimulationService.java b/src/main/java/com/aims/backend/service/dashboard/AgvSimulationService.java index 091635b..4982b2e 100644 --- a/src/main/java/com/aims/backend/service/dashboard/AgvSimulationService.java +++ b/src/main/java/com/aims/backend/service/dashboard/AgvSimulationService.java @@ -1,18 +1,19 @@ package com.aims.backend.service.dashboard; +import com.aims.backend.client.AssemblyArrivalClient; import com.aims.backend.domain.dashboard.AgvOperation; import com.aims.backend.domain.dashboard.enums.AgvStatus; import com.aims.backend.domain.dashboard.enums.ProcessCode; import com.aims.backend.dto.dashboard.AgvOperationResponse; import com.aims.backend.dto.dashboard.AgvRealtimeState; -import com.aims.backend.dto.dashboard.ManufacturingEventRequest; import com.aims.backend.repository.dashboard.AgvOperationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; import java.util.List; import java.util.Map; @@ -24,8 +25,12 @@ public class AgvSimulationService { private final AgvOperationRepository agvOperationRepository; private final AgvRealtimeRedisService agvRealtimeRedisService; private final SimpMessagingTemplate messagingTemplate; + private final TransactionTemplate transactionTemplate; + private final AssemblyArrivalClient assemblyArrivalClient; - private static final double PROGRESS_STEP = 5.0; + private static final int MOVE_DURATION_SECONDS = 30; + private static final int UNLOADING_DURATION_SECONDS = 5; + private static final int RETURN_DURATION_SECONDS = 30; private static final Map ROUTES = Map.of( ProcessCode.PRESS, @@ -57,18 +62,8 @@ public class AgvSimulationService { ) ); - @Transactional - public void handleManufacturingEvent(ManufacturingEventRequest request) { - ProcessCode currentProcess = ProcessCode.valueOf(request.getProcessCode()); - - dispatchAgv( - request.getCarMasterId(), - currentProcess - ); - } - - @Transactional - public void dispatchAgv( + public boolean dispatchAgv( + String eventId, Long carMasterId, ProcessCode currentProcess ) { @@ -76,137 +71,424 @@ public void dispatchAgv( if (routeInfo == null) { log.info( - "[AGV DISPATCH SKIP] 운반 대상 공정 아님. process={}", + "[AGV DISPATCH SKIP] 운반 대상 공정 아님. eventId={}, process={}", + eventId, currentProcess ); - return; + return true; } - AgvOperation agv = agvOperationRepository - .findFirstByRouteCodeAndAgvStatusOrderByLaneNoAsc( - routeInfo.routeCode(), - AgvStatus.WAITING - ) - .orElseThrow(() -> new IllegalStateException( - "대기 중인 AGV가 없습니다. routeCode=" + routeInfo.routeCode() - )); + AgvOperation agv = transactionTemplate.execute(status -> { + AgvOperation selectedAgv = + agvOperationRepository + .findFirstWaitingAgvForUpdateSkipLocked( + routeInfo.routeCode(), + AgvStatus.WAITING.name() + ) + .orElse(null); + + if (selectedAgv == null) { + return null; + } + + selectedAgv.dispatch( + eventId, + carMasterId, + routeInfo.from(), + routeInfo.to(), + routeInfo.routeCode() + ); + + return agvOperationRepository.save(selectedAgv); + }); + + if (agv == null) { + log.info( + "[AGV DISPATCH WAIT] 사용 가능한 AGV 없음. eventId={}, process={}, routeCode={}", + eventId, + currentProcess, + routeInfo.routeCode() + ); + return false; + } - agv.dispatch( + startMovingSession( + agv.getId(), + eventId, carMasterId, - routeInfo.from(), - routeInfo.to(), - routeInfo.routeCode() + routeInfo ); - agvRealtimeRedisService.reset(agv.getId()); + return true; + } + + /** + * ShedLock이 적용된 상태 전이 Scheduler가 호출합니다. + * + * Redis의 status만 신뢰하지 않고 DB의 실제 AGV 상태를 함께 확인하여 + * Pod가 상태 전이 중 종료된 경우에도 다음 Pod가 흐름을 복구할 수 있게 합니다. + */ + public void advanceExpiredState(AgvRealtimeState redisState) { + if (redisState == null || redisState.getAgvId() == null) { + return; + } + + Long agvId = redisState.getAgvId(); + + AgvOperation agv = agvOperationRepository.findById(agvId) + .orElse(null); + + if (agv == null) { + log.warn( + "[AGV STATE RECOVERY] DB에 AGV가 없어 Redis 상태를 삭제합니다. agvId={}", + agvId + ); + agvRealtimeRedisService.delete(agvId); + return; + } + + RouteInfo routeInfo = findRouteInfo(agv.getRouteCode()); + + if (routeInfo == null) { + log.error( + "[AGV STATE RECOVERY FAILED] 알 수 없는 routeCode. agvId={}, routeCode={}", + agvId, + agv.getRouteCode() + ); + return; + } + + switch (agv.getAgvStatus()) { + case MOVING -> handleMovingArrived(agvId, routeInfo); + + case UNLOADING -> { + if (AgvStatus.UNLOADING.name() + .equals(redisState.getStatus())) { + startReturningSession(agvId, routeInfo); + } else { + recoverUnloadingSession(agv, routeInfo); + } + } + + case RETURNING -> { + if (AgvStatus.RETURNING.name() + .equals(redisState.getStatus())) { + handleReturningArrived(agvId, routeInfo); + } else { + recoverReturningSession(agvId, routeInfo); + } + } + + case WAITING -> { + log.info( + "[AGV STALE REDIS STATE DELETE] agvId={}, redisStatus={}", + agvId, + redisState.getStatus() + ); + agvRealtimeRedisService.delete(agvId); + } + } + } - agvOperationRepository.save(agv); + private void startMovingSession( + Long agvId, + String eventId, + Long carMasterId, + RouteInfo routeInfo + ) { + Instant startedAt = Instant.now(); + Instant expectedArrivalTime = + startedAt.plusSeconds(MOVE_DURATION_SECONDS); + + AgvRealtimeState state = AgvRealtimeState.builder() + .agvId(agvId) + .eventId(eventId) + .carMasterId(carMasterId) + .status(AgvStatus.MOVING.name()) + .currentProcess(routeInfo.from().name()) + .targetProcess(routeInfo.to().name()) + .progressRate(0.0) + .delaySeconds(0) + .startedAt(startedAt) + .expectedArrivalTime(expectedArrivalTime) + .build(); + + agvRealtimeRedisService.save(state); log.info( - "[AGV DISPATCH] agvId={}, carMasterId={}, {} -> {}, routeCode={}", - agv.getId(), + "[AGV MOVING START] agvId={}, eventId={}, carMasterId={}, {} -> {}, expectedArrival={}", + agvId, + eventId, carMasterId, routeInfo.from(), routeInfo.to(), - routeInfo.routeCode() + expectedArrivalTime ); sendAgvStatus(); } - @Transactional - public void updateAgvProgress() { - List activeAgvs = - agvOperationRepository.findByAgvStatusIn( - List.of( - AgvStatus.MOVING, - AgvStatus.RETURNING - ) - ); + private void handleMovingArrived( + Long agvId, + RouteInfo routeInfo + ) { + ArrivalContext context = transactionTemplate.execute(status -> { + AgvOperation agv = agvOperationRepository.findById(agvId) + .orElseThrow(() -> new IllegalStateException( + "AGV를 찾을 수 없습니다. agvId=" + agvId + )); + + if (agv.getAgvStatus() != AgvStatus.MOVING) { + return null; + } - for (AgvOperation agv : activeAgvs) { - AgvRealtimeState state = - agvRealtimeRedisService.get(agv.getId()); + String eventId = agv.getEventId(); + Long carMasterId = agv.getCarMasterId(); - state.increaseProgress(PROGRESS_STEP); + agv.changeToUnloading(); + agvOperationRepository.save(agv); - if (!state.isArrived()) { - agvRealtimeRedisService.save(state); + return new ArrivalContext(eventId, carMasterId); + }); - log.debug( - "[AGV PROGRESS] agvId={}, status={}, progress={}", - agv.getId(), - agv.getAgvStatus(), - state.getProgressRate() - ); + if (context == null) { + log.debug( + "[AGV MOVING ARRIVAL SKIP] 이미 다른 상태로 전이됨. agvId={}", + agvId + ); + return; + } - continue; - } + log.info( + "[AGV ARRIVED / UNLOADING START] agvId={}, eventId={}, carMasterId={}, arrivedProcess={}", + agvId, + context.eventId(), + context.carMasterId(), + routeInfo.to() + ); - if (agv.getAgvStatus() == AgvStatus.MOVING) { - handleMovingArrived(agv); - } else if (agv.getAgvStatus() == AgvStatus.RETURNING) { - handleReturningArrived(agv); - } - } + notifyArrival(context.eventId()); + + startUnloadingSession( + agvId, + context.eventId(), + context.carMasterId(), + routeInfo + ); + } + + private void startUnloadingSession( + Long agvId, + String eventId, + Long carMasterId, + RouteInfo routeInfo + ) { + Instant startedAt = Instant.now(); + Instant expectedEndTime = + startedAt.plusSeconds(UNLOADING_DURATION_SECONDS); + + AgvRealtimeState state = AgvRealtimeState.builder() + .agvId(agvId) + .eventId(eventId) + .carMasterId(carMasterId) + .status(AgvStatus.UNLOADING.name()) + .currentProcess(routeInfo.to().name()) + .targetProcess(routeInfo.to().name()) + .progressRate(100.0) + .delaySeconds(0) + .startedAt(startedAt) + .expectedArrivalTime(expectedEndTime) + .build(); + + agvRealtimeRedisService.save(state); + + log.info( + "[AGV UNLOADING] agvId={}, eventId={}, duration={}s, expectedEnd={}", + agvId, + eventId, + UNLOADING_DURATION_SECONDS, + expectedEndTime + ); sendAgvStatus(); } - private void handleMovingArrived(AgvOperation agv) { - ProcessCode arrivedProcess = agv.getTargetProcess(); - ProcessCode homeProcess = agv.getCurrentProcess(); + private void startReturningSession( + Long agvId, + RouteInfo routeInfo + ) { + Boolean changed = transactionTemplate.execute(status -> { + AgvOperation agv = agvOperationRepository.findById(agvId) + .orElseThrow(() -> new IllegalStateException( + "AGV를 찾을 수 없습니다. agvId=" + agvId + )); + + if (agv.getAgvStatus() != AgvStatus.UNLOADING) { + return false; + } + + agv.changeToReturning(); + agvOperationRepository.save(agv); + return true; + }); + + if (!Boolean.TRUE.equals(changed)) { + log.debug( + "[AGV RETURNING START SKIP] UNLOADING 상태가 아님. agvId={}", + agvId + ); + return; + } - agv.changeToReturning(); + saveReturningState(agvId, routeInfo); + } - agvRealtimeRedisService.reset(agv.getId()); + private void saveReturningState( + Long agvId, + RouteInfo routeInfo + ) { + Instant startedAt = Instant.now(); + Instant expectedArrivalTime = + startedAt.plusSeconds(RETURN_DURATION_SECONDS); + + AgvRealtimeState state = AgvRealtimeState.builder() + .agvId(agvId) + .eventId(null) + .carMasterId(null) + .status(AgvStatus.RETURNING.name()) + .currentProcess(routeInfo.to().name()) + .targetProcess(routeInfo.from().name()) + .progressRate(0.0) + .delaySeconds(0) + .startedAt(startedAt) + .expectedArrivalTime(expectedArrivalTime) + .build(); + + agvRealtimeRedisService.save(state); log.info( - "[AGV ARRIVED] agvId={}, arrived={}, returningTo={}", - agv.getId(), - arrivedProcess, - homeProcess + "[AGV RETURNING START] agvId={}, {} -> {}, expectedArrival={}", + agvId, + routeInfo.to(), + routeInfo.from(), + expectedArrivalTime ); - } - private void handleReturningArrived(AgvOperation agv) { - ProcessCode homeProcess = agv.getTargetProcess(); + sendAgvStatus(); + } - RouteInfo routeInfo = ROUTES.get(homeProcess); + private void handleReturningArrived( + Long agvId, + RouteInfo routeInfo + ) { + Boolean changed = transactionTemplate.execute(status -> { + AgvOperation agv = agvOperationRepository.findById(agvId) + .orElseThrow(() -> new IllegalStateException( + "AGV를 찾을 수 없습니다. agvId=" + agvId + )); + + if (agv.getAgvStatus() != AgvStatus.RETURNING) { + return false; + } - if (routeInfo == null) { agv.changeToWaiting( - homeProcess, - homeProcess, - null + routeInfo.from(), + routeInfo.to(), + routeInfo.routeCode() ); - agvRealtimeRedisService.reset(agv.getId()); + agvOperationRepository.save(agv); + return true; + }); - log.info( - "[AGV WAITING] agvId={}, home={}", - agv.getId(), - homeProcess + if (!Boolean.TRUE.equals(changed)) { + log.debug( + "[AGV RETURN COMPLETE SKIP] RETURNING 상태가 아님. agvId={}", + agvId ); - return; } - agv.changeToWaiting( + agvRealtimeRedisService.delete(agvId); + + log.info( + "[AGV RETURN COMPLETE] agvId={}, waitingAt={}, nextTarget={}, routeCode={}", + agvId, routeInfo.from(), routeInfo.to(), routeInfo.routeCode() ); - agvRealtimeRedisService.reset(agv.getId()); + sendAgvStatus(); + } + + /** + * DB는 UNLOADING으로 변경됐지만 Redis가 MOVING에 머문 채 Pod가 종료된 경우 복구합니다. + * 도착 API는 eventId 기준 멱등 처리가 되어 있어야 안전합니다. + */ + private void recoverUnloadingSession( + AgvOperation agv, + RouteInfo routeInfo + ) { + log.warn( + "[AGV UNLOADING RECOVERY] agvId={}, eventId={}", + agv.getId(), + agv.getEventId() + ); + + notifyArrival(agv.getEventId()); - log.info( - "[AGV RETURN COMPLETE] agvId={}, waitingAt={}, nextTarget={}, routeCode={}", + startUnloadingSession( agv.getId(), - routeInfo.from(), - routeInfo.to(), - routeInfo.routeCode() + agv.getEventId(), + agv.getCarMasterId(), + routeInfo + ); + } + + /** + * DB는 RETURNING으로 변경됐지만 Redis가 이전 상태에 머문 채 Pod가 종료된 경우 + * 복귀 타이머를 Redis에 다시 생성합니다. + */ + private void recoverReturningSession( + Long agvId, + RouteInfo routeInfo + ) { + log.warn( + "[AGV RETURNING RECOVERY] agvId={}", + agvId ); + + saveReturningState(agvId, routeInfo); + } + + private void notifyArrival(String eventId) { + if (eventId == null || eventId.isBlank()) { + log.warn("[ASSEMBLY ARRIVAL SKIP] eventId가 없습니다."); + return; + } + + try { + assemblyArrivalClient.notifyAgvArrived(eventId); + } catch (Exception e) { + log.warn( + "[ASSEMBLY ARRIVAL FAILED] AGV 흐름은 계속 진행합니다. eventId={}", + eventId, + e + ); + } + } + + private RouteInfo findRouteInfo(String routeCode) { + if (routeCode == null || routeCode.isBlank()) { + return null; + } + + return ROUTES.values() + .stream() + .filter(route -> route.routeCode().equals(routeCode)) + .findFirst() + .orElse(null); } private void sendAgvStatus() { @@ -226,14 +508,19 @@ private AgvOperationResponse toResponse(AgvOperation agv) { AgvRealtimeState state = agvRealtimeRedisService.get(agv.getId()); + state.calculateProgress(Instant.now()); + return new AgvOperationResponse( agv.getId(), + state.getEventId(), agv.getCarMasterId(), agv.getAgvStatus().name(), agv.getCurrentProcess().name(), agv.getTargetProcess().name(), state.getProgressRate(), state.getDelaySeconds(), + state.getStartedAt(), + state.getExpectedArrivalTime(), agv.getRouteCode(), agv.getLaneNo(), agv.getUpdatedAt() @@ -246,4 +533,10 @@ private record RouteInfo( String routeCode ) { } -} \ No newline at end of file + + private record ArrivalContext( + String eventId, + Long carMasterId + ) { + } +} diff --git a/src/main/java/com/aims/backend/service/dashboard/AgvTransportStateScheduler.java b/src/main/java/com/aims/backend/service/dashboard/AgvTransportStateScheduler.java new file mode 100644 index 0000000..f1a22b5 --- /dev/null +++ b/src/main/java/com/aims/backend/service/dashboard/AgvTransportStateScheduler.java @@ -0,0 +1,69 @@ +package com.aims.backend.service.dashboard; + +import com.aims.backend.dto.dashboard.AgvRealtimeState; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.agv.transport-scheduler", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class AgvTransportStateScheduler { + + private final AgvRealtimeRedisService agvRealtimeRedisService; + private final AgvSimulationService agvSimulationService; + + @Scheduled( + fixedDelayString = + "${app.agv.transport-scheduler.fixed-delay-ms:1000}" + ) + @SchedulerLock( + name = "agvTransportStateScheduler", + lockAtMostFor = "PT20S", + lockAtLeastFor = "PT0.5S" + ) + public void advanceExpiredStates() { + LockAssert.assertLocked(); + + Instant now = Instant.now(); + + for (AgvRealtimeState state : agvRealtimeRedisService.findAll()) { + if (!isExpired(state, now)) { + continue; + } + + try { + agvSimulationService.advanceExpiredState(state); + } catch (Exception e) { + log.error( + "[AGV STATE SCHEDULER FAILED] agvId={}, redisStatus={}", + state.getAgvId(), + state.getStatus(), + e + ); + } + } + } + + private boolean isExpired( + AgvRealtimeState state, + Instant now + ) { + return state != null + && state.getAgvId() != null + && state.getExpectedArrivalTime() != null + && !state.getExpectedArrivalTime().isAfter(now); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/service/dashboard/AnalysisDuplicateService.java b/src/main/java/com/aims/backend/service/dashboard/AnalysisDuplicateService.java new file mode 100644 index 0000000..5831f6d --- /dev/null +++ b/src/main/java/com/aims/backend/service/dashboard/AnalysisDuplicateService.java @@ -0,0 +1,28 @@ +package com.aims.backend.service.dashboard; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class AnalysisDuplicateService { + + private static final String PREFIX = "agv:analysis:processed:"; + + private final RedisTemplate redisTemplate; + + public boolean isFirstProcess(String eventId) { + + Boolean success = + redisTemplate.opsForValue().setIfAbsent( + PREFIX + eventId, + "1", + Duration.ofMinutes(1) + ); + + return Boolean.TRUE.equals(success); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/service/dashboard/DashboardService.java b/src/main/java/com/aims/backend/service/dashboard/DashboardService.java index 2d6b4cd..966f5c6 100644 --- a/src/main/java/com/aims/backend/service/dashboard/DashboardService.java +++ b/src/main/java/com/aims/backend/service/dashboard/DashboardService.java @@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.time.Instant; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; @@ -79,10 +80,8 @@ public OverallStatusResponse getOverallStatus() { .mapToDouble(equipment -> { switch (equipment.getCurrentStatus()) { case RUNNING: return 5.0; - case IDLE: return 4.0; - case MAINTENANCE: return 3.0; + case WARNING: return 4.0; case STOPPED: return 0.0; - case FAULT: return 0.0; default: return 0.0; } }).sum(); @@ -148,9 +147,8 @@ public AgvStatusSummaryResponse getAgvStatusSummary() { agvOperationRepository.count(); long movingCount = - agvOperationRepository.countByAgvStatus( - AgvStatus.MOVING - ); + agvOperationRepository.countByAgvStatus(AgvStatus.MOVING) + + agvOperationRepository.countByAgvStatus(AgvStatus.UNLOADING); long waitingCount = agvOperationRepository.countByAgvStatus( @@ -207,8 +205,11 @@ private AgvOperationResponse toResponse( agv.getId() ); + realtimeState.calculateProgress(Instant.now()); + return new AgvOperationResponse( agv.getId(), + realtimeState.getEventId(), agv.getCarMasterId(), agv.getAgvStatus().name(), @@ -218,6 +219,9 @@ private AgvOperationResponse toResponse( realtimeState.getProgressRate(), realtimeState.getDelaySeconds(), + realtimeState.getStartedAt(), + realtimeState.getExpectedArrivalTime(), + agv.getRouteCode(), agv.getLaneNo(), agv.getUpdatedAt() diff --git a/src/main/java/com/aims/backend/service/dashboard/ManufacturingAnalysisConsumer.java b/src/main/java/com/aims/backend/service/dashboard/ManufacturingAnalysisConsumer.java new file mode 100644 index 0000000..4075a24 --- /dev/null +++ b/src/main/java/com/aims/backend/service/dashboard/ManufacturingAnalysisConsumer.java @@ -0,0 +1,105 @@ +package com.aims.backend.service.dashboard; + +import com.aims.backend.domain.dashboard.enums.ProcessCode; +import com.aims.backend.dto.dashboard.DispatchRequest; +import com.aims.backend.dto.kafka.ManufacturingAnalysisEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ManufacturingAnalysisConsumer { + + private static final String PROCESS_RISK_ANALYSIS = + "PROCESS_RISK_ANALYSIS"; + + private final ObjectMapper objectMapper; + private final AgvDispatchQueueService dispatchQueueService; + private final AnalysisDuplicateService duplicateService; + + @KafkaListener( + topics = "factory.manufacturing.analysis", + groupId = "${app.kafka.consumer.agv-group-id}", + concurrency = "2" + ) + public void consume(String message) { + + try { + + ManufacturingAnalysisEvent event = + objectMapper.readValue( + message, + ManufacturingAnalysisEvent.class + ); + + log.info( + "[ANALYSIS] eventId={}, type={}, abnormal={}, raw={}", + event.eventId(), + event.analysisType(), + event.analysisResult().isAbnormal(), + message + ); + + // Process Risk Analysis만 처리 + if (!PROCESS_RISK_ANALYSIS.equals(event.analysisType())) { + + log.debug( + "[ANALYSIS IGNORE] eventId={}, type={}", + event.eventId(), + event.analysisType() + ); + + return; + } + + // 동일 eventId 중복 처리 방지 + if (!duplicateService.isFirstProcess(event.eventId())) { + + log.debug( + "[ANALYSIS DUPLICATE] eventId={}", + event.eventId() + ); + + return; + } + + // 위험 공정이면 AGV 출발하지 않음 + if (event.analysisResult().isAbnormal()) { + + log.info( + "[AGV SKIP] eventId={}, process={}", + event.eventId(), + event.processCode() + ); + + return; + } + + ProcessCode processCode = + ProcessCode.valueOf(event.processCode()); + + DispatchRequest request = new DispatchRequest( + event.eventId(), + event.carMasterId(), + processCode + ); + + boolean queued = dispatchQueueService.offer(request); + + log.info( + "[AGV QUEUE REQUEST] eventId={}, process={}, queued={}", + event.eventId(), + processCode, + queued + ); + + } catch (Exception e) { + + log.error("Analysis Kafka 처리 실패", e); + } + } +} diff --git a/src/main/java/com/aims/backend/service/dashboard/ManufacturingEventConsumer.java b/src/main/java/com/aims/backend/service/dashboard/ManufacturingEventConsumer.java deleted file mode 100644 index 3c47aed..0000000 --- a/src/main/java/com/aims/backend/service/dashboard/ManufacturingEventConsumer.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.aims.backend.service.dashboard; - -import com.aims.backend.dto.dashboard.ManufacturingEventRequest; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.Map; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ManufacturingEventConsumer { - - private final ObjectMapper objectMapper; - private final AgvSimulationService agvSimulationService; - - @KafkaListener( - topics = "factory.manufacturing.raw", - groupId = "main-agv-group" - ) - public void consume(String message) { - - try { - - // 1. Kafka 원본 메시지 확인 - log.info(""" - - ============================== - Kafka 원본 메시지 수신 - {} - ============================== - - """, message); - - ManufacturingRawEventKafkaDto raw = - objectMapper.readValue( - message, - ManufacturingRawEventKafkaDto.class - ); - - Map processMetrics = - raw.eventJson() == null - ? null - : (Map) raw.eventJson() - .get("processMetrics"); - - ManufacturingEventRequest event = - ManufacturingEventRequest.builder() - .eventId(raw.eventId()) - .carMasterId(raw.carMasterId()) - .processCode(raw.processCode()) - .eventTime(raw.eventTime().toString()) - .processingTimeSec( - getInt(processMetrics, "processingTimeSec") - ) - .waitingTimeSec( - getInt(processMetrics, "waitingTimeSec") - ) - .stationDelaySec( - getInt(processMetrics, "stationDelaySec") - ) - .build(); - - // 2. DTO 변환 결과 확인 - log.info(""" - - ============================== - ManufacturingEventRequest 변환 완료 - - eventId : {} - carMasterId : {} - processCode : {} - eventTime : {} - processingTimeSec : {} - waitingTimeSec : {} - stationDelaySec : {} - - ============================== - - """, - event.getEventId(), - event.getCarMasterId(), - event.getProcessCode(), - event.getEventTime(), - event.getProcessingTimeSec(), - event.getWaitingTimeSec(), - event.getStationDelaySec() - ); - - // 3. AGV 시뮬레이터 호출 - agvSimulationService.handleManufacturingEvent(event); - - log.info( - "[AGV 처리 완료] eventId={}, processCode={}", - event.getEventId(), - event.getProcessCode() - ); - - } catch (Exception e) { - - log.error(""" - - ===================================== - Kafka 메시지 처리 실패 - - message={} - - ===================================== - - """, - message, - e - ); - } - } - - private Integer getInt( - Map map, - String key - ) { - - if (map == null || !map.containsKey(key)) { - return 0; - } - - Object value = map.get(key); - - if (value instanceof Number number) { - return number.intValue(); - } - - return 0; - } - - /** - * assembly-service ManufacturingRawEvent 대응 DTO - */ - public record ManufacturingRawEventKafkaDto( - Long id, - String eventId, - LocalDateTime eventTime, - Long carMasterId, - Long equipmentId, - String processCode, - String stationCode, - String equipmentCode, - String equipmentType, - String equipmentStatus, - String eventType, - Map eventJson - ) { - } -} \ No newline at end of file diff --git a/src/main/java/com/aims/backend/service/eventAnalysis/AlertRecommendationService.java b/src/main/java/com/aims/backend/service/eventAnalysis/AlertRecommendationService.java new file mode 100644 index 0000000..175bfa3 --- /dev/null +++ b/src/main/java/com/aims/backend/service/eventAnalysis/AlertRecommendationService.java @@ -0,0 +1,387 @@ +package com.aims.backend.service.eventAnalysis; + +import com.aims.backend.domain.alert.ActionTimeline; +import com.aims.backend.domain.alert.AlertEvent; +import com.aims.backend.domain.dashboard.enums.ProcessCode; +import com.aims.backend.domain.eventAnalysis.*; +import com.aims.backend.dto.eventAnalysis.*; +import com.aims.backend.repository.alert.ActionTimelineRepository; +import com.aims.backend.repository.alert.AlertEventRepository; +import com.aims.backend.repository.eventAnalysis.*; +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import com.aims.backend.exception.GeneralException; +import com.aims.backend.common.status.ErrorStatus; + +import java.util.List; + + + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AlertRecommendationService { + + private final AlertEventRepository alertEventRepository; + private final ActionTimelineRepository actionTimelineRepository; + + private final ManufacturingAnalysisResultRepository manufacturingAnalysisResultRepository; + + private final PressAnalysisResultRepository pressAnalysisResultRepository; + private final BodyAnalysisResultRepository bodyAnalysisResultRepository; + private final PaintAnalysisResultRepository paintAnalysisResultRepository; + private final AssemblyAnalysisResultRepository assemblyAnalysisResultRepository; + + private String getLogNo(Long analysisResultId) { + + ManufacturingAnalysisResult analysisResult = + manufacturingAnalysisResultRepository + .findById(analysisResultId) + .orElseThrow(() -> + new GeneralException( + ErrorStatus.RECOMMENDATION_ALERT_EVENT_NOT_FOUND, + "AlertEvent를 찾을 수 없습니다. eventId=" + analysisResultId)); + + ManufacturingAnalysisResult analysis = + manufacturingAnalysisResultRepository.findById(analysisResultId) + .orElseThrow(() -> + new IllegalArgumentException( + "분석 결과를 찾을 수 없습니다. analysisResultId=" + analysisResultId)); + + AlertEvent alertEvent = + alertEventRepository.findByEventId(analysis.getEventId()) + .orElseThrow(() -> + new IllegalArgumentException( + "유사 장애의 AlertEvent를 찾을 수 없습니다. eventId=" + + analysis.getEventId())); + + return alertEvent.getLogNo(); + } + + @Transactional(readOnly = true) + public RecommendationResponse getRecommendation(String logNo) { + + AlertEvent alertEvent = alertEventRepository.findById(logNo) + .orElseThrow(() -> + new GeneralException( + ErrorStatus.RECOMMENDATION_ANALYSIS_NOT_FOUND)); + + + + ManufacturingAnalysisResult analysis = + manufacturingAnalysisResultRepository + .findByEventId(alertEvent.getEventId()) + .orElseThrow(() -> + new IllegalArgumentException("분석 결과를 찾을 수 없습니다.")); + + String similarLogNo = switch (analysis.getProcessCode()) { + + case PRESS -> + findSimilarPressEvent(analysis); + + case BODY -> + findSimilarBodyEvent(analysis); + + case PAINT -> + findSimilarPaintEvent(analysis); + + case ASSEMBLY -> + findSimilarAssemblyEvent(analysis); + + default -> + throw new IllegalArgumentException( + "지원하지 않는 공정입니다. : " + analysis.getProcessCode()); + }; + + List timelines = + actionTimelineRepository + .findByLogNoOrderByActionTimeAsc(similarLogNo); + + if (timelines.isEmpty()) { + throw new GeneralException( + ErrorStatus.RECOMMENDATION_TIMELINE_NOT_FOUND); + } + + return buildRecommendationResponse( + similarLogNo, + timelines + ); + } + + private String findSimilarPressEvent( + ManufacturingAnalysisResult currentAnalysis) { + + PressAnalysisResult current = pressAnalysisResultRepository + .findByAnalysisResultId(currentAnalysis.getId()) + .orElseThrow(() -> + new GeneralException( + ErrorStatus.RECOMMENDATION_PRESS_ANALYSIS_NOT_FOUND)); + + PressAnalysisResult best = + pressAnalysisResultRepository.findMostSimilar( + current.getAnalysisResultId(), + current.getCountIncreaseYn(), + current.getCycleTimeGapSec(), + current.getTimestampDelaySec(), + PageRequest.of(0, 1) + ) + .stream() + .findFirst() + .orElseThrow(() -> + new GeneralException( + ErrorStatus.RECOMMENDATION_SIMILAR_EVENT_NOT_FOUND)); + + return getLogNo(best.getAnalysisResultId()); + } + + + private String findSimilarBodyEvent( + ManufacturingAnalysisResult currentAnalysis) { + + BodyAnalysisResult current = + bodyAnalysisResultRepository + .findByAnalysisResultId(currentAnalysis.getId()) + .orElseThrow(() -> + new IllegalArgumentException("현재 BODY 분석 결과가 없습니다.")); + + List candidates = + bodyAnalysisResultRepository.findAll(); + + BodyAnalysisResult best = null; + double minScore = Double.MAX_VALUE; + + for (BodyAnalysisResult candidate : candidates) { + + if (candidate.getAnalysisResultId().equals(current.getAnalysisResultId())) { + continue; + } + + double score = 0; + + if (current.getRobotVibrationScore() != null && + candidate.getRobotVibrationScore() != null) { + + score += Math.abs( + current.getRobotVibrationScore() + - candidate.getRobotVibrationScore()); + } + + if (current.getFrequencyPeakValue() != null && + candidate.getFrequencyPeakValue() != null) { + + score += Math.abs( + current.getFrequencyPeakValue() + - candidate.getFrequencyPeakValue()); + } + + if (current.getFrequencyPeakBand() != null && + candidate.getFrequencyPeakBand() != null && + !current.getFrequencyPeakBand() + .equals(candidate.getFrequencyPeakBand())) { + + score += 5; + } + + if (current.getRobotMotionStatus() != null && + candidate.getRobotMotionStatus() != null && + !current.getRobotMotionStatus() + .equals(candidate.getRobotMotionStatus())) { + + score += 3; + } + + if (current.getRobotOperationMode() != null && + candidate.getRobotOperationMode() != null && + !current.getRobotOperationMode() + .equals(candidate.getRobotOperationMode())) { + + score += 2; + } + + if (score < minScore) { + minScore = score; + best = candidate; + } + } + + if (best == null) { + throw new IllegalArgumentException("유사한 BODY 이벤트가 없습니다."); + } + + return getLogNo(best.getAnalysisResultId()); + } + + private String findSimilarPaintEvent( + ManufacturingAnalysisResult currentAnalysis) { + + PaintAnalysisResult current = + paintAnalysisResultRepository + .findByAnalysisResultId(currentAnalysis.getId()) + .orElseThrow(() -> + new IllegalArgumentException("현재 PAINT 분석 결과가 없습니다.")); + + List candidates = + paintAnalysisResultRepository.findTop20Similar( + current.getAnalysisResultId(), + current.getVisionLabel(), + current.getSurfaceQualityScore(), + PageRequest.of(0, 20) + ); + + PaintAnalysisResult best = null; + double minScore = Double.MAX_VALUE; + + for (PaintAnalysisResult candidate : candidates) { + + if (candidate.getAnalysisResultId().equals(current.getAnalysisResultId())) { + continue; + } + + double score = 0; + + if (current.getThermalStdTemp() != null && + candidate.getThermalStdTemp() != null) { + score += Math.abs( + current.getThermalStdTemp() + - candidate.getThermalStdTemp()); + } + + if (current.getThicknessValue() != null && + candidate.getThicknessValue() != null) { + score += Math.abs( + current.getThicknessValue() + - candidate.getThicknessValue()); + } + + if (current.getDefeatScore() != null && + candidate.getDefeatScore() != null) { + score += Math.abs( + current.getDefeatScore() + - candidate.getDefeatScore()); + } + + if (current.getSurfaceQualityScore() != null && + candidate.getSurfaceQualityScore() != null) { + score += Math.abs( + current.getSurfaceQualityScore() + - candidate.getSurfaceQualityScore()); + } + + if (current.getVisionLabel() != null && + candidate.getVisionLabel() != null && + !current.getVisionLabel().equals(candidate.getVisionLabel())) { + score += 5; + } + + if (score < minScore) { + minScore = score; + best = candidate; + } + } + + if (best == null) { + throw new IllegalArgumentException("유사한 PAINT 이벤트를 찾을 수 없습니다."); + } + + return getLogNo(best.getAnalysisResultId()); + } + + private String findSimilarAssemblyEvent( + ManufacturingAnalysisResult currentAnalysis) { + + AssemblyAnalysisResult current = + assemblyAnalysisResultRepository + .findByAnalysisResultId(currentAnalysis.getId()) + .orElseThrow(() -> + new IllegalArgumentException("현재 ASSEMBLY 분석 결과가 없습니다.")); + + List candidates = + assemblyAnalysisResultRepository.findTop20Similar( + current.getAnalysisResultId(), + current.getSequenceErrorCount(), + PageRequest.of(0, 20) + ); + + AssemblyAnalysisResult best = null; + double minScore = Double.MAX_VALUE; + + for (AssemblyAnalysisResult candidate : candidates) { + + double score = 0; + + if (current.getSequenceErrorCount() != null && + candidate.getSequenceErrorCount() != null) { + score += Math.abs( + current.getSequenceErrorCount() + - candidate.getSequenceErrorCount()); + } + if (current.getMissingPartCount() != null && + candidate.getMissingPartCount() != null) { + score += Math.abs( + current.getMissingPartCount() + - candidate.getMissingPartCount()); + } + if (current.getFasteningErrorCount() != null && + candidate.getFasteningErrorCount() != null) { + score += Math.abs( + current.getFasteningErrorCount() + - candidate.getFasteningErrorCount()); + } + if (current.getExpectedSequence() != null && + candidate.getExpectedSequence() != null && + !current.getExpectedSequence().equals(candidate.getExpectedSequence())) { + score += 5; + } + if (current.getActualSequence() != null && + candidate.getActualSequence() != null && + !current.getActualSequence().equals(candidate.getActualSequence())) { + score += 8; + } + if (score < minScore) { + minScore = score; + best = candidate; + } + } + if (best == null) { + throw new IllegalArgumentException("유사한 ASSEMBLY 이벤트를 찾을 수 없습니다."); + } + + return getLogNo(best.getAnalysisResultId()); + } + + + private RecommendationResponse buildRecommendationResponse( + String similarLogNo, + List timelines + ) { + + ActionTimeline lastTimeline = timelines.get(timelines.size() - 1); + + List timelineResponses = timelines.stream() + .map(timeline -> ActionTimelineResponse.builder() + .actionId(timeline.getActionId()) + .actionTime(timeline.getActionTime()) + .empNo(timeline.getEmpNo()) + .empName(timeline.getEmpName()) + .empRole(timeline.getEmpRole()) + .actionCategory(timeline.getActionCategory()) + .actionContent(timeline.getActionContent()) + .actionResult(timeline.getActionResult()) + .build()) + .toList(); + + return RecommendationResponse.builder() + .similarLogNo(similarLogNo) + .confidence(1.0) + .handler(lastTimeline.getEmpName()) + .recommendedAction(lastTimeline.getActionContent()) + .recommendationReason(lastTimeline.getActionResult()) + .actionTimeline(timelineResponses) + .build(); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 9cd7912..0fa8261 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -4,34 +4,48 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} timeout: ${REDIS_TIMEOUT:3s} + + # Spring Boot 자동 설정이 참조할 Kafka 주소 kafka: - bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + bootstrap-servers: + - ${KAFKA_BOOTSTRAP_SERVER_1} + - ${KAFKA_BOOTSTRAP_SERVER_2} + app: redis: cache: ttl: ${REDIS_CACHE_TTL:10m} + opensearch: scheme: ${OPENSEARCH_SCHEME:http} host: ${OPENSEARCH_HOST:localhost} port: ${OPENSEARCH_PORT:9200} + + # Backend의 KafkaConfig.java가 실제로 읽는 설정 kafka: bootstrap-servers: - - ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} - group-id: ${KAFKA_GROUP_ID:assembly-dev} + - ${KAFKA_BOOTSTRAP_SERVER_1} + - ${KAFKA_BOOTSTRAP_SERVER_2} + + group-id: ${KAFKA_GROUP_ID:main-dev} auto-offset-reset: ${KAFKA_AUTO_OFFSET_RESET:earliest} + listeners-enabled: ${KAFKA_LISTENERS_ENABLED:true} + + consumer: + agv-group-id: ${AGV_CONSUMER_GROUP_ID:main-agv-group} + logging: level: root: info - com.aims.assembly: debug + com.aims.backend: debug org.hibernate.SQL: debug - file: - path: ${LOG_PATH:logs/dev} + management: health: redis: enabled: true kafka: - enabled: false + enabled: false \ No newline at end of file diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 72e5121..c8db95b 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -1,12 +1,38 @@ logging: level: root: info - com.aims.assembly: debug + com.aims.backend: debug org.hibernate.SQL: WARN org.hibernate.orm.jdbc.bind: WARN + file: path: ${LOG_PATH:logs/local} +spring: + data: + redis: + host: localhost + port: 16379 + timeout: 3s + + kafka: + consumer: + auto-offset-reset: latest + +app: + + kafka: + consumer: + agv-group-id: main-agv-group-local + + agv: + dispatch-scheduler: + enabled: false # 배차 스케줄러 중지 + + transport-scheduler: + enabled: true # ShedLock 운행 스케줄러만 실행 + fixed-delay-ms: 1000 + management: health: redis: @@ -16,4 +42,4 @@ management: cors: allowedOrigins: - - http://localhost:5173 + - http://localhost:5173 \ No newline at end of file diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index f9b84fd..bc6e639 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -4,6 +4,7 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} timeout: ${REDIS_TIMEOUT:3s} + kafka: bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} @@ -11,20 +12,27 @@ app: redis: cache: ttl: ${REDIS_CACHE_TTL:30m} + opensearch: scheme: ${OPENSEARCH_SCHEME:http} host: ${OPENSEARCH_HOST:localhost} port: ${OPENSEARCH_PORT:9200} + kafka: bootstrap-servers: - ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + group-id: ${KAFKA_GROUP_ID:assembly-prod} auto-offset-reset: ${KAFKA_AUTO_OFFSET_RESET:latest} + consumer: + agv-group-id: ${AGV_CONSUMER_GROUP_ID:main-agv-group} + logging: level: root: warn - com.aims.assembly: info + com.aims.backend: info + file: path: ${LOG_PATH:logs/prod} @@ -33,4 +41,4 @@ management: redis: enabled: true kafka: - enabled: false + enabled: false \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3de3c72..99bf11d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -19,11 +19,12 @@ spring: format_sql: true data: redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} + host: aims-dev-redis.80mdpk.ng.0001.apn2.cache.amazonaws.com + port: 6379 timeout: ${REDIS_TIMEOUT:3s} kafka: - bootstrap-servers: ${MSK_BOOTSTRAP_SERVERS} + bootstrap-servers: + - ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} properties: security.protocol: SASL_SSL @@ -52,6 +53,25 @@ management: enabled: false app: + alert: + image: + process-images: + PRESS: + danger-equipment: s3://event-image-858507113889-ap-northeast-2-an/press_1.png + danger-process: s3://event-image-858507113889-ap-northeast-2-an/press_2.png + warning: s3://event-image-858507113889-ap-northeast-2-an/press_3.png + BODY: + danger-equipment: s3://event-image-858507113889-ap-northeast-2-an/body_1.png + danger-process: s3://event-image-858507113889-ap-northeast-2-an/body_2.png + warning: s3://event-image-858507113889-ap-northeast-2-an/body_3.png + PAINT: + danger-equipment: s3://event-image-858507113889-ap-northeast-2-an/paint_1.png + danger-process: s3://event-image-858507113889-ap-northeast-2-an/paint_2.png + warning: s3://event-image-858507113889-ap-northeast-2-an/paint_3.png + ASSEMBLY: + danger-equipment: s3://event-image-858507113889-ap-northeast-2-an/assamble_1.png + danger-process: s3://event-image-858507113889-ap-northeast-2-an/assamble_2.png + warning: s3://event-image-858507113889-ap-northeast-2-an/assamble_3.png datasource: main: driver-class-name: ${MAIN_DB_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver} @@ -69,6 +89,14 @@ app: maximum-pool-size: ${SAMPLE_DB_MAXIMUM_POOL_SIZE:5} minimum-idle: ${SAMPLE_DB_MINIMUM_IDLE:1} pool-name: ${SAMPLE_DB_POOL_NAME:sample-db-pool} + agv: + dispatch-scheduler: + enabled: true + fixed-delay-ms: 1000 + + transport-scheduler: + enabled: true + fixed-delay-ms: 1000 redis: cache: ttl: ${REDIS_CACHE_TTL:10m} @@ -77,10 +105,17 @@ app: host: ${OPENSEARCH_HOST:localhost} port: ${OPENSEARCH_PORT:9200} kafka: - bootstrap-servers: ${MSK_BOOTSTRAP_SERVERS} + bootstrap-servers: + - ${KAFKA_BOOTSTRAP_SERVER_1} + - ${KAFKA_BOOTSTRAP_SERVER_2} + group-id: ${KAFKA_GROUP_ID:main-dev} auto-offset-reset: ${KAFKA_AUTO_OFFSET_RESET:earliest} listeners-enabled: ${KAFKA_LISTENERS_ENABLED:true} + analysis-topic: factory.manufacturing.analysis + + consumer: + agv-group-id: ${AGV_CONSUMER_GROUP_ID:main-agv-group} security-protocol: SASL_SSL sasl-mechanism: AWS_MSK_IAM @@ -95,3 +130,6 @@ jwt: expiration: access: ${JWT_ACCESS_EXPIRATION:3600000} refresh: ${JWT_REFRESH_EXPIRATION:1209600000} + +external: + assembly-url: ${ASSEMBLY_SERVICE_URL:http://localhost:8082} diff --git a/src/test/java/com/aims/backend/domain/alert/AlertEventTest.java b/src/test/java/com/aims/backend/domain/alert/AlertEventTest.java index e041b47..0615681 100644 --- a/src/test/java/com/aims/backend/domain/alert/AlertEventTest.java +++ b/src/test/java/com/aims/backend/domain/alert/AlertEventTest.java @@ -30,6 +30,8 @@ void notNeededActionSetsResolvedAt() { alertEvent.updateAction("user01", AlertActionStatus.NOT_NEEDED, "sensor noise"); assertThat(alertEvent.getActionStatus()).isEqualTo(AlertActionStatus.NOT_NEEDED); + assertThat(alertEvent.getActionBy()).isEqualTo("user01"); + assertThat(alertEvent.getReason()).isEqualTo("sensor noise"); assertThat(alertEvent.getResolvedAt()).isNotNull(); } diff --git a/src/test/java/com/aims/backend/repository/alert/AlertEventRepositoryTest.java b/src/test/java/com/aims/backend/repository/alert/AlertEventRepositoryTest.java index f5622e0..517868b 100644 --- a/src/test/java/com/aims/backend/repository/alert/AlertEventRepositoryTest.java +++ b/src/test/java/com/aims/backend/repository/alert/AlertEventRepositoryTest.java @@ -19,7 +19,7 @@ @SpringBootTest @Transactional -@Sql(statements = "CREATE TABLE IF NOT EXISTS alert_event (log_no VARCHAR(20) NOT NULL PRIMARY KEY, event_id VARCHAR(100) NOT NULL UNIQUE, alert_type VARCHAR(20) NOT NULL, process_code VARCHAR(20) NOT NULL, equipment_id BIGINT NULL, event_key VARCHAR(150) NOT NULL, risk_score DECIMAL(5,2) NULL, occurrence_score DECIMAL(6,4) NULL, detection_score DECIMAL(6,4) NULL, priority_score DECIMAL(10,2) NULL, severity VARCHAR(20) NULL, title VARCHAR(100) NOT NULL, contents VARCHAR(500) NOT NULL, action_by VARCHAR(50) NULL, action_status VARCHAR(20) NULL, reason VARCHAR(500) NULL, score_calculated_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, resolved_at TIMESTAMP NULL)") +@Sql(statements = "CREATE TABLE IF NOT EXISTS alert_event (log_no VARCHAR(20) NOT NULL PRIMARY KEY, event_id VARCHAR(100) NOT NULL UNIQUE, alert_type VARCHAR(20) NOT NULL, process_code VARCHAR(20) NOT NULL, equipment_id BIGINT NULL, event_key VARCHAR(150) NOT NULL, risk_score DECIMAL(5,2) NULL, occurrence_score DECIMAL(6,4) NULL, detection_score DECIMAL(6,4) NULL, priority_score DECIMAL(10,2) NULL, severity VARCHAR(20) NULL, title VARCHAR(100) NOT NULL, contents VARCHAR(500) NOT NULL, action_by VARCHAR(50) NULL, action_status VARCHAR(20) NULL, reason VARCHAR(500) NULL, score_calculated_at TIMESTAMP NULL, image_url VARCHAR(255) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, resolved_at TIMESTAMP NULL)") class AlertEventRepositoryTest { @Autowired @@ -68,4 +68,154 @@ void savesAndFindsAlertEventByEventId() { assertThat(saved.getScoreCalculatedAt()).isEqualTo(scoreCalculatedAt); }); } + + @Test + void aggregatesIncompleteScoresAndCompletedActionsWithinPeriod() { + LocalDateTime from = LocalDateTime.of(2026, 7, 6, 12, 0); + LocalDateTime to = LocalDateTime.of(2026, 7, 13, 12, 0); + + alertEventRepository.save(summaryEvent( + "summary-1", from.plusDays(1), "120.00", "50.00", "0.4000", AlertActionStatus.INCOMPLETE + )); + alertEventRepository.save(summaryEvent( + "summary-2", from.plusDays(2), "80.00", "30.00", "0.6000", AlertActionStatus.INCOMPLETE + )); + alertEventRepository.save(summaryEvent( + "summary-3", from.plusDays(3), "200.00", "90.00", "0.9000", AlertActionStatus.COMPLETED + )); + alertEventRepository.save(summaryEvent( + "summary-4", from.minusSeconds(1), "999.00", "99.00", "0.9000", AlertActionStatus.INCOMPLETE + )); + alertEventRepository.flush(); + setCreatedAt("AL-summary-1", from.plusDays(1)); + setCreatedAt("AL-summary-2", from.plusDays(2)); + setCreatedAt("AL-summary-3", from.plusDays(3)); + setCreatedAt("AL-summary-4", from.minusSeconds(1)); + entityManager.clear(); + + AlertEventRepository.PrioritySummaryProjection summary = + findPrioritySummary(from, to); + + assertThat(summary.getTotalCount()).isEqualTo(3); + assertThat(summary.getPriorityScoreSum()).isEqualByComparingTo(new BigDecimal("200.00")); + assertThat(summary.getPriorityScoreCount()).isEqualTo(2); + assertThat(summary.getRiskScoreSum()).isEqualByComparingTo(new BigDecimal("80.00")); + assertThat(summary.getRiskScoreCount()).isEqualTo(2); + assertThat(summary.getOccurrenceScoreSum()).isEqualByComparingTo(new BigDecimal("1.0000")); + assertThat(summary.getOccurrenceScoreCount()).isEqualTo(2); + assertThat(summary.getCompletedCount()).isEqualTo(1); + } + + @Test + void excludesEventFromAveragesAfterActionIsCompleted() { + LocalDateTime from = LocalDateTime.of(2026, 7, 6, 12, 0); + LocalDateTime to = LocalDateTime.of(2026, 7, 13, 12, 0); + AlertEvent first = summaryEvent( + "transition-1", from.plusDays(1), "120.00", "50.00", "0.4000", AlertActionStatus.INCOMPLETE + ); + AlertEvent second = summaryEvent( + "transition-2", from.plusDays(2), "80.00", "30.00", "0.6000", AlertActionStatus.INCOMPLETE + ); + alertEventRepository.saveAll(java.util.List.of(first, second)); + alertEventRepository.flush(); + setCreatedAt("AL-transition-1", from.plusDays(1)); + setCreatedAt("AL-transition-2", from.plusDays(2)); + entityManager.clear(); + + AlertEventRepository.PrioritySummaryProjection before = findPrioritySummary(from, to); + assertThat(before.getPriorityScoreSum()).isEqualByComparingTo("200.00"); + assertThat(before.getPriorityScoreCount()).isEqualTo(2); + assertThat(before.getCompletedCount()).isZero(); + + AlertEvent eventToComplete = alertEventRepository.findById(first.getLogNo()).orElseThrow(); + eventToComplete.updateAction("user01", AlertActionStatus.COMPLETED, "done"); + alertEventRepository.flush(); + entityManager.clear(); + + AlertEventRepository.PrioritySummaryProjection after = findPrioritySummary(from, to); + assertThat(after.getPriorityScoreSum()).isEqualByComparingTo("80.00"); + assertThat(after.getPriorityScoreCount()).isEqualTo(1); + assertThat(after.getCompletedCount()).isEqualTo(1); + assertThat(after.getTotalCount()).isEqualTo(2); + } + + @Test + void excludesCompletedNotNeededAndNullPriorityScoresFromPriorityAverage() { + LocalDateTime from = LocalDateTime.of(2026, 7, 6, 12, 0); + LocalDateTime to = LocalDateTime.of(2026, 7, 13, 12, 0); + alertEventRepository.save(summaryEvent( + "excluded-1", from.plusDays(1), "200.00", "90.00", "0.9000", AlertActionStatus.COMPLETED + )); + alertEventRepository.save(summaryEvent( + "excluded-2", from.plusDays(2), "300.00", "80.00", "0.8000", AlertActionStatus.NOT_NEEDED + )); + alertEventRepository.save(summaryEvent( + "excluded-3", from.plusDays(3), null, "40.00", "0.4000", AlertActionStatus.INCOMPLETE + )); + alertEventRepository.flush(); + setCreatedAt("AL-excluded-1", from.plusDays(1)); + setCreatedAt("AL-excluded-2", from.plusDays(2)); + setCreatedAt("AL-excluded-3", from.plusDays(3)); + entityManager.clear(); + + AlertEventRepository.PrioritySummaryProjection summary = findPrioritySummary(from, to); + + assertThat(summary.getTotalCount()).isEqualTo(3); + assertThat(summary.getPriorityScoreSum()).isZero(); + assertThat(summary.getPriorityScoreCount()).isZero(); + assertThat(summary.getRiskScoreSum()).isEqualByComparingTo("40.00"); + assertThat(summary.getRiskScoreCount()).isEqualTo(1); + assertThat(summary.getOccurrenceScoreSum()).isEqualByComparingTo("0.4000"); + assertThat(summary.getOccurrenceScoreCount()).isEqualTo(1); + assertThat(summary.getCompletedCount()).isEqualTo(1); + } + + private AlertEventRepository.PrioritySummaryProjection findPrioritySummary( + LocalDateTime from, + LocalDateTime to + ) { + return alertEventRepository.findPrioritySummary( + from, + to, + AlertActionStatus.INCOMPLETE, + AlertActionStatus.COMPLETED + ); + } + + private AlertEvent summaryEvent( + String suffix, + LocalDateTime createdAt, + String priorityScore, + String riskScore, + String occurrenceScore, + AlertActionStatus actionStatus + ) { + return AlertEvent.builder() + .logNo("AL-" + suffix) + .eventId("event-" + suffix) + .alertType(AlertType.PROCESS) + .processCode(ProcessCode.PAINT) + .eventKey("PROCESS:PAINT:" + suffix) + .riskScore(decimal(riskScore)) + .occurrenceScore(decimal(occurrenceScore)) + .detectionScore(BigDecimal.ZERO) + .priorityScore(decimal(priorityScore)) + .severity(AlertSeverity.CAUTION) + .title("summary test") + .contents("summary test") + .actionStatus(actionStatus) + .createdAt(createdAt) + .build(); + } + + private BigDecimal decimal(String value) { + return value == null ? null : new BigDecimal(value); + } + + private void setCreatedAt(String logNo, LocalDateTime createdAt) { + entityManager.createNativeQuery("UPDATE alert_event SET created_at = :createdAt WHERE log_no = :logNo") + .setParameter("createdAt", createdAt) + .setParameter("logNo", logNo) + .executeUpdate(); + } } diff --git a/src/test/java/com/aims/backend/service/alert/AlertPrioritySummaryServiceTest.java b/src/test/java/com/aims/backend/service/alert/AlertPrioritySummaryServiceTest.java new file mode 100644 index 0000000..9f97551 --- /dev/null +++ b/src/test/java/com/aims/backend/service/alert/AlertPrioritySummaryServiceTest.java @@ -0,0 +1,115 @@ +package com.aims.backend.service.alert; + +import com.aims.backend.domain.alert.AlertActionStatus; +import com.aims.backend.dto.alert.AlertPrioritySummaryResponse; +import com.aims.backend.repository.alert.AlertEventRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AlertPrioritySummaryServiceTest { + + @Mock + private AlertEventRepository alertEventRepository; + + @Mock + private AlertEventRepository.PrioritySummaryProjection projection; + + private AlertPrioritySummaryService service; + + @BeforeEach + void setUp() { + service = new AlertPrioritySummaryService(alertEventRepository); + } + + @Test + void calculatesPrioritySummary() { + when(alertEventRepository.findPrioritySummary( + any(LocalDateTime.class), + any(LocalDateTime.class), + eq(AlertActionStatus.INCOMPLETE), + eq(AlertActionStatus.COMPLETED) + )).thenReturn(projection); + when(projection.getTotalCount()).thenReturn(4L); + when(projection.getCompletedCount()).thenReturn(3L); + when(projection.getPriorityScoreSum()).thenReturn(new BigDecimal("748.504")); + when(projection.getPriorityScoreCount()).thenReturn(4L); + when(projection.getRiskScoreSum()).thenReturn(new BigDecimal("329.40")); + when(projection.getRiskScoreCount()).thenReturn(4L); + when(projection.getOccurrenceScoreSum()).thenReturn(new BigDecimal("2.7000")); + when(projection.getOccurrenceScoreCount()).thenReturn(4L); + + AlertPrioritySummaryResponse response = service.getPrioritySummary(7); + + assertThat(response.periodDays()).isEqualTo(7); + assertThat(response.averagePriorityScore()).isEqualByComparingTo(new BigDecimal("187.13")); + assertThat(response.averageRiskScore()).isEqualByComparingTo(new BigDecimal("82.4")); + assertThat(response.averageOccurrencePercentage()).isEqualByComparingTo(new BigDecimal("68")); + assertThat(response.actionCompletionRate()).isEqualByComparingTo(new BigDecimal("75")); + } + + @Test + void returnsZerosWhenThereAreNoEvents() { + when(alertEventRepository.findPrioritySummary( + any(LocalDateTime.class), + any(LocalDateTime.class), + eq(AlertActionStatus.INCOMPLETE), + eq(AlertActionStatus.COMPLETED) + )).thenReturn(projection); + when(projection.getTotalCount()).thenReturn(0L); + + AlertPrioritySummaryResponse response = service.getPrioritySummary(7); + + assertThat(response.averagePriorityScore()).isZero(); + assertThat(response.averageRiskScore()).isZero(); + assertThat(response.averageOccurrencePercentage()).isZero(); + assertThat(response.actionCompletionRate()).isZero(); + } + + @Test + void returnsZeroOnlyForMissingScoreAverages() { + when(alertEventRepository.findPrioritySummary( + any(LocalDateTime.class), + any(LocalDateTime.class), + eq(AlertActionStatus.INCOMPLETE), + eq(AlertActionStatus.COMPLETED) + )).thenReturn(projection); + when(projection.getTotalCount()).thenReturn(2L); + + AlertPrioritySummaryResponse response = service.getPrioritySummary(7); + + assertThat(response.averagePriorityScore()).isZero(); + assertThat(response.averageRiskScore()).isZero(); + assertThat(response.averageOccurrencePercentage()).isZero(); + } + + @Test + void clampsPercentagesToValidRange() { + when(alertEventRepository.findPrioritySummary( + any(LocalDateTime.class), + any(LocalDateTime.class), + eq(AlertActionStatus.INCOMPLETE), + eq(AlertActionStatus.COMPLETED) + )).thenReturn(projection); + when(projection.getTotalCount()).thenReturn(2L); + when(projection.getCompletedCount()).thenReturn(3L); + when(projection.getOccurrenceScoreSum()).thenReturn(new BigDecimal("2.4000")); + when(projection.getOccurrenceScoreCount()).thenReturn(2L); + + AlertPrioritySummaryResponse response = service.getPrioritySummary(7); + + assertThat(response.averageOccurrencePercentage()).isEqualByComparingTo(new BigDecimal("100")); + assertThat(response.actionCompletionRate()).isEqualByComparingTo(new BigDecimal("100")); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index b232b15..90b36a6 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -45,3 +45,6 @@ app: maximum-pool-size: 3 minimum-idle: 1 pool-name: test-sample-db-pool + +external: + assembly-url: http://localhost:8082