diff --git a/.github/workflows/deploy-assembly-service.yml b/.github/workflows/deploy-assembly-service.yml index 0ef747d..00c5f84 100644 --- a/.github/workflows/deploy-assembly-service.yml +++ b/.github/workflows/deploy-assembly-service.yml @@ -22,11 +22,15 @@ concurrency: env: AWS_REGION: ap-northeast-2 + EKS_CLUSTER_NAME: aims-dev-eks ECR_REPOSITORY: aims/assembly-service + NAMESPACE: aims-project INFRA_REPOSITORY: SK-Rookies-AIMS/infra INFRA_BRANCH: dev - INFRA_MANIFEST_PATH: k8s/assembly-service/deployment.yaml + + # 기존 deployment.yaml에서 kustomization.yaml로 변경 + INFRA_MANIFEST_PATH: k8s/assembly-service/kustomization.yaml jobs: build-and-update-gitops: @@ -65,6 +69,8 @@ jobs: id: build-image shell: bash run: | + set -euo pipefail + COMMIT_TAG="dev-${GITHUB_SHA::12}" IMAGE_URI="${{ steps.login-ecr.outputs.registry }}/${ECR_REPOSITORY}:${COMMIT_TAG}" @@ -89,11 +95,142 @@ jobs: path: infra fetch-depth: 0 - - name: Update assembly-service image + # 추가: EKS 접속 설정 + - name: Update kubeconfig and ensure namespace + shell: bash + run: | + 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 - + + # 추가: SSM Parameter Store 값 조회 + - name: Load assembly-service parameters from SSM + shell: bash + run: | + 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) + + for VALUE in \ + "$RDS_SECRET_ARN" \ + "$RDS_HOST" \ + "$RDS_PORT" \ + "$MAIN_DB_NAME" \ + "$SAMPLE_DB_NAME" \ + "$JWT_SECRET_KEY" + do + if [ -z "$VALUE" ] || [ "$VALUE" = "None" ]; then + echo "Required assembly-service parameter is empty" + exit 1 + fi + done + + echo "::add-mask::$RDS_SECRET_ARN" + echo "::add-mask::$JWT_SECRET_KEY" + + { + echo "RDS_SECRET_ARN=$RDS_SECRET_ARN" + echo "RDS_HOST=$RDS_HOST" + echo "RDS_PORT=$RDS_PORT" + echo "MAIN_DB_NAME=$MAIN_DB_NAME" + echo "SAMPLE_DB_NAME=$SAMPLE_DB_NAME" + echo "JWT_SECRET_KEY=$JWT_SECRET_KEY" + } >> "$GITHUB_ENV" + + # 추가: RDS Secrets Manager에서 계정 조회 후 런타임 Secret 생성 + - name: Create or update assembly-service Secret + shell: bash + run: | + set -euo pipefail + + SECRET_JSON=$(aws secretsmanager get-secret-value \ + --secret-id "$RDS_SECRET_ARN" \ + --region "$AWS_REGION" \ + --query "SecretString" \ + --output text) + + DB_USER=$(echo "$SECRET_JSON" | jq -r '.username') + DB_PASSWORD=$(echo "$SECRET_JSON" | jq -r '.password') + + if [ -z "$DB_USER" ] || [ "$DB_USER" = "null" ]; then + echo "DB username is missing" + exit 1 + fi + + if [ -z "$DB_PASSWORD" ] || [ "$DB_PASSWORD" = "null" ]; then + echo "DB password is missing" + exit 1 + fi + + echo "::add-mask::$DB_USER" + echo "::add-mask::$DB_PASSWORD" + + JDBC_OPTIONS="serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true" + + MAIN_DB_JDBC_URL="jdbc:mysql://${RDS_HOST}:${RDS_PORT}/${MAIN_DB_NAME}?${JDBC_OPTIONS}" + SAMPLE_DB_JDBC_URL="jdbc:mysql://${RDS_HOST}:${RDS_PORT}/${SAMPLE_DB_NAME}?${JDBC_OPTIONS}" + + kubectl create secret generic assembly-service-secret \ + --namespace "$NAMESPACE" \ + --from-literal=MAIN_DB_JDBC_URL="$MAIN_DB_JDBC_URL" \ + --from-literal=MAIN_DB_USERNAME="$DB_USER" \ + --from-literal=MAIN_DB_PASSWORD="$DB_PASSWORD" \ + --from-literal=SAMPLE_DB_JDBC_URL="$SAMPLE_DB_JDBC_URL" \ + --from-literal=SAMPLE_DB_USERNAME="$DB_USER" \ + --from-literal=SAMPLE_DB_PASSWORD="$DB_PASSWORD" \ + --from-literal=JWT_SECRET_KEY="$JWT_SECRET_KEY" \ + --dry-run=client \ + -o yaml | kubectl apply -f - + + # 변경: image: 전체 URI가 아니라 newTag:만 변경 + - name: Update assembly-service image tag env: IMAGE_URI: ${{ steps.build-image.outputs.image_uri }} shell: bash run: | + set -euo pipefail + MANIFEST="infra/${INFRA_MANIFEST_PATH}" if [ ! -f "$MANIFEST" ]; then @@ -101,15 +238,19 @@ jobs: exit 1 fi + IMAGE_TAG="${IMAGE_URI##*:}" + sed -i -E \ - "s|^([[:space:]]*)image:.*aims/assembly-service.*$|\1image: ${IMAGE_URI}|" \ + "s|^([[:space:]]*)newTag:.*$|\1newTag: ${IMAGE_TAG}|" \ "$MANIFEST" - grep -n "image:" "$MANIFEST" + grep -n "newTag:" "$MANIFEST" - name: Commit and push GitOps change shell: bash run: | + set -euo pipefail + cd infra git config user.name "github-actions[bot]" @@ -136,4 +277,4 @@ jobs: done echo "Failed to push infra repository" - exit 1 + exit 1 \ No newline at end of file diff --git a/README.md b/README.md index 266145c..7ae28f7 100644 --- a/README.md +++ b/README.md @@ -1,603 +1,262 @@ -# Assembly Service +# AIMS - Assembly Service +### AIMS (Auto Intelligence Manufacturing System) - AI 기반 자동차 스마트팩토리 관제 시스템 +`assembly-service`는 SK 쉴더스 루키즈 개발 5기 **AI 기반 자동차 스마트팩토리 관제 시스템 AIMS**에서 제조 공정 이벤트를 수집하고, 공정/설비/품질 분석 결과를 대시보드에 제공하는 Spring Boot 기반 백엔드 서비스입니다. -`assembly-service`는 모빌리티 스마트팩토리 관제 시스템에서 제조 공정 이벤트를 수집하고, 공정/설비/품질 분석 결과를 대시보드에 제공하는 Spring Boot 기반 백엔드 서비스입니다. - -이 서비스는 프레스, 차체, 도장, 의장 조립 공정에서 발생하는 생산 이벤트와 센서 데이터를 기반으로 제조 병목 탐지, 공정별 이상 분석, 불량 전이 예측 결과를 생성하거나 조회하는 역할을 담당합니다. +제조 서비스는 프레스·차체·도장·의장 조립 공정에서 발생해 Kafka로 발행된 이벤트를 수집하고, 생산 이벤트와 센서 데이터를 기반으로 이상을 탐지·저장·조회하는 역할을 담당합니다. ## 주요 역할 - 제조 공정 이벤트 수집 및 조회 - 차량별 공정 이동 이력 관리 - 프레스, 차체, 도장, 의장 공정별 분석 결과 관리 - - 프레스 이상 정지 탐지 - - 차체 로봇 이상 동작 및 충돌 위험 탐지 - - 도장 품질 이상 탐지 - - 의장 조립 순서 오류 탐지 - 실시간 병목 분석 결과 제공 - 공정 간 불량 전이 예측 결과 제공 - Kafka 기반 제조 이벤트 스트리밍 연동 - Redis 기반 실시간 대시보드 캐시 연동 -- OpenSearch 기반 제조 이벤트/분석 로그 검색 연동 -## MVP 기능 +  +## ✨ 제조 주요 기능 + +### 공정별 이상 탐지 분석 (ISO 통계적 공정관리 적용) +스크린샷(22) + +**ISO 표준 문서 및 통계적 공정관리(SPC) 원칙**에 따라 설비별 정상 데이터의 평균(μ)과 표준편차(σ)를 기반으로 동적 임계값을 생성하여 이상을 탐지합니다. (2σ 이내 정상, 2~3σ 경고, 3σ 초과 위험) + +  +### 1. 프레스 공정 (Press) +스크린샷(23) + +- **적용 레퍼런스:** `ISO 22400`(제조 KPI), `ISO 7870`(관리도), `ISO 20958`(모터 전류 상태감시) +- **사이클 시간 (`cycleTimeSec`) 및 지연시간 (`timestampDelaySec`):** + - 차종, 금형, 작업별 평균 사이클 시간 및 지연시간에 대해 평균+2σ 이내면 `NORMAL`, 2~3σ는 `WARNING`, 3σ 초과는 `CRITICAL`로 판정합니다. +- **전류 RMS (`current.rmsAmpere`):** + - 모터 및 운전 단계(타격/복귀/대기)별 정상 전류의 평균과 표준편차를 기준으로 평가합니다. 제조사 과부하 한계를 초과하거나 3σ를 벗어나면 `CRITICAL`입니다. +- **생산 카운트 (`countIncreaseYn`):** `true` 시 정상, 데이터 누락(`null`) 시 경고, `false` 시 위험. +- **설비 상태 (`operationStatus`):** `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험. + +  +### 2. 차체 공정 (Body) +스크린샷(26) +image + +- **적용 레퍼런스:** `ISO 13373`(진동 센서 측정/분석), `ISO 20816`(기계 진동 상태평가), `ISO 7870` +- **로봇 진동 점수 (`vibrationScore`) 및 주파수 피크 (`frequencyBands`):** + - 로봇 번호, 작업 프로그램, 속도, 페이로드 조건에 따라 정상 평균과 표준편차를 도출합니다. 조건별 평균+2σ 이하 `NORMAL`, 2~3σ `WARNING`, 3σ 초과 `CRITICAL`. (각 주파수 대역별로 별도 임계치 적용) +- **로봇 상태 (`robotMotionStatus`):** `NORMAL` 정상, `WARNING` 경고, `ABNORMAL` 또는 `COLLISION_RISK` 위험. +- **운전 모드 (`robotOperationMode`):** 생산 중 `AUTO` 정상. 계획 없는 `MANUAL/STOPPED` 위험. + +  +### 3. 도장 공정 (Paint) +image +image + +- **적용 레퍼런스:** `ISO 4628-1`(도막 결함 평가), `ISO 2808`(도막 두께 측정) +- **표면 품질 점수 (`surfaceQualityScore`):** ISO 결함 등급을 점수로 환산. 80점 이상 정상, 60~80점 경고, 60점 미만 위험. +- **도막 두께 (`thicknessValue`):** 목표치 115μm (90~120μm 범위 내 정상). 80μm 미만 또는 130μm 초과 시 위험. +- **불량 점수 (`defectScore`):** 0.4 미만 정상, 0.4~0.6 경고, 0.6 이상 위험 (`visionLabel`이 정상이어도 점수에 따라 경고 발송). +- **온도 편차 (`thermalStdTemp`):** 오븐 온도 균일도 기준에 따라 2℃ 미만 정상, 2~5℃ 경고, 5℃ 이상 위험. +  +### 4. 의장 공정 (Assembly) +image + +- **작업/조립 순서 오류 (`sequenceErrorCount`):** 실제 작업 순서(`actualSequence`)가 기준 순서(`expectedSequence`)와 불일치할 경우 위험(`CRITICAL`) 판정. +- **부품 누락 (`missingPartCount`) 및 체결 오류 (`fasteningErrorCount`):** 1건이라도 발생 시 위험 판정. + +  ### 제조 병목 탐지 Bosch Production Line Performance Dataset의 Station 통과 시간, 공정 처리 시간, 대기 시간을 기반으로 병목 공정을 탐지합니다. 판단 예시: - - 평균 처리 시간 대비 30% 이상 증가 - 특정 Station 체류 시간 급증 - 생산 대기열 증가 - 공정별 지연 위험도 증가 +  ### 공정 간 불량 전이 예측 공정별 센서 데이터, 공정 이동 이력, 품질 검사 결과를 기반으로 특정 공정의 이상이 후속 공정의 불량으로 이어질 가능성을 예측합니다. 예시: - - 차체 공정 이상이 도장 공정 불량으로 전이될 확률 - 도장 공정 불량 위험도 - 주요 원인 Station 및 Sensor Feature - 위험도 등급: `LOW`, `MEDIUM`, `HIGH` -### 공정별 분석 - -- 프레스: 전류 RMS, 진동, 생산 카운트, Timestamp 지연 기반 이상 정지 탐지 -- 차체: 로봇 전류, 진동, 충돌 위험, 로봇 동작 이상 탐지 -- 도장: 열화상 온도, 표면 품질 점수, 불량률 기반 품질 이상 탐지 -- 의장: 조립 순서 오류, 부품 누락, 체결 오류 탐지 - -### 알림 - -공정별 분석 결과, 병목 분석 결과, 불량 전이 예측 결과에서 이상이 탐지되면 알림 데이터를 생성하고 프론트엔드 알림 화면에서 조회할 수 있도록 구성합니다. - -## 활용 데이터셋 - -### Ford Engine Dataset - -엔진 진동 시계열 데이터 기반 정상/이상 분류 데이터셋입니다. - -- 데이터 형태: 시계열 -- 샘플 길이: 500 -- 라벨: `1` 정상, `-1` 이상 -- 활용: 프레스/로봇 진동 이상 탐지, 예지보전 - -### 소성가공 자원최적화 AI 데이터셋 - -프레스 유압모터 및 로봇 전류 데이터를 포함합니다. - -- 주요 컬럼: `Time_s[s]`, `RMS[A]`, `Acceleration[g]` -- 활용: 전류 Peak 탐지, 모터 과부하 탐지, 설비 정지 상태 분석 - -### 머신비전 AI 데이터셋 - -열화상 기반 품질 검사 데이터셋입니다. - -- 데이터: 센서/전류 시계열 CSV, 라벨 JSON -- 라벨: `0` 정상, `1` 이상/불량 -- 활용: 도장 품질 이상 탐지, 품질 검사 이벤트 생성 - -### Bosch Production Line Performance Dataset - -대규모 제조 공정 데이터셋입니다. - -- Numeric: 센서/측정값 -- Date: 공정 시간 정보 -- Categorical: 공정 상태 정보 -- Response: 정상/불량 라벨 -- 활용: 제조 병목 탐지, 공정 간 불량 전이 예측, 생산라인 통합 분석 - -## DB 구조 - -DB는 `sampledb`와 `maindb`로 분리합니다. 두 DB는 같은 MySQL 서버와 포트를 사용하지만 schema를 분리합니다. - -### sampledb - -관제 시스템으로 유입되는 샘플 원천 데이터를 저장합니다. +  +## 📊 활용 데이터셋 -주요 테이블: +프로젝트의 AI 분석 신뢰도와 공정 모의(Simulation)를 위해 다음의 산업용 오픈 데이터셋을 활용합니다. -- `car_master`: 차량 기준 정보 -- `equipment`: 샘플 설비 정보 -- `manufacturing_event_json`: 공정/센서/품질 공통 이벤트 +### 1. [Ford Engine Dataset](https://www.kamp-ai.kr/aidataDetail?DATASET_SEQ=2) +엔진 진동 시계열 데이터를 바탕으로 정상(`1`)과 이상(`-1`)을 분류하는 KAMP 예지보전 데이터셋입니다. +- **활용 공정:** 프레스, 차체 공정 +- **주요 활용도:** 로봇 암 및 프레스 설비 진동 이상 탐지 패턴 적용 -### maindb +### 2. [소성가공 자원최적화 AI 데이터셋](https://www.kamp-ai.kr/aidataDetail?DATASET_SEQ=46) +프레스 유압 모터 및 로봇의 전류(`RMS[A]`)와 가속도(`Acceleration[g]`) 시계열 데이터가 포함되어 있습니다. +- **활용 공정:** 프레스 공정 +- **주요 활용도:** 전류 피크(Peak) 탐지, 모터 과부하 및 설비 비정상 정지 상태 감지 -대시보드 조회와 분석 결과 데이터를 저장합니다. +### 3. [머신비전 AI 데이터셋 (열화상 품질 검사)](https://www.kamp-ai.kr/aidataDetail?AI_SEARCH=%EB%A8%B8%EC%8B%A0%EB%B9%84%EC%A0%84+AI+%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%85%8B&page=1&DATASET_SEQ=6&DISPLAY_MODE_SEL=CARD&EQUIP_SEL=&GUBUN_SEL=&FILE_TYPE_SEL=&WDATE_SEL=) +제품 표면의 열화상 센서 데이터와 정상(`0`), 불량(`1`)이 라벨링된 품질 검사 데이터셋입니다. +- **활용 공정:** 도장 공정 +- **주요 활용도:** 도장 표면 온도 편차 분석 및 비전 기반 품질 불량 탐지 이벤트 생성 -주요 테이블: +### 4. [Bosch Production Line Performance Dataset](https://www.kaggle.com/competitions/bosch-production-line-performance/overview) +Kaggle에서 제공하는 대규모 제조 라인 성능 데이터셋으로, 공정 센서값, 시간 정보, 상태 정보 등을 포함합니다. +- **활용 공정:** 통합 관제 (병목 및 품질 분석) +- **주요 활용도:** + - Station 체류 시간 등을 분석하여 **제조 병목 공정 탐지** + - 특정 공정의 센서 데이터가 후속 공정에 미치는 영향을 분석해 **공정 간 불량 전이 예측** -- `press_analysis_result`: 프레스 공정 분석 결과 -- `body_analysis_result`: 차체 공정 분석 결과 -- `paint_analysis_result`: 도장 공정 분석 결과 -- `assembly_analysis_result`: 의장 공정 분석 결과 -- `bottleneck_analysis_result`: 병목 분석 결과 -- `defect_transfer_prediction_result`: 불량 전이 예측 결과 -- `notification`: 실시간 알림 +  +## 🛠 전체 데이터 기능 흐름 +데이터 기능 흐름도 -### DB 간 참조 방식 +  +## 🚀 이벤트 JSON 및 Kafka 처리 (상세 설계) -`sampledb`와 `maindb` 사이에는 물리 FK를 사용하지 않고 논리 참조를 사용합니다. +### 1. 원천 데이터베이스 설계 -예시: - -- `maindb.body_analysis_result.manufacturing_event_id` -- `sampledb.manufacturing_event.id` -- `sampledb.robot_arm_vibration.manufacturing_event_id` - -## 인프라 연동 - -### Kafka - -SampleDB의 제조 이벤트를 재생하고 공정 분석, AI 분석, 설비 상태, 위험 알림을 비동기로 전달하는 이벤트 백본으로 사용합니다. +DB는 `sampledb`(샘플 원천 데이터)와 `maindb`(분석 결과 데이터)로 분리됩니다. 원천 데이터 흐름 제어는 다음 3개의 주요 테이블을 통해 관리됩니다. -현재 사용하는 Topic은 다음과 같으며 모두 Partition 2개로 구성합니다. +- **`car_master`**: 차량 기준 정보 및 상태 관리 (`WAITING`, `RUNNING`, `HOLD`, `DEFECT`, `COMPLETED`) +- **`equipment`**: 공정별 설비 정보 및 상태 (`RUNNING`, `IDLE`, `STOPPED`, `FAULT`, `MAINTENANCE`) +- **`manufacturing_event_json`**: 10만 건 이상의 공정/센서/품질 원천 이벤트 -| Topic | 역할 | Message Key | Partition | -| --- | --- | --- | ---: | -| `factory.manufacturing.raw` | SampleDB 원천 제조 이벤트 | `equipmentCode` | 2 | -| `factory.manufacturing.analysis` | 공정 위험, 병목, 불량 전이 분석 결과 | 기본 `equipmentCode`, 불량 전이는 `carId → carMasterId → equipmentCode` | 2 | -| `factory.equipment.status` | 설비 고장·복구 상태 이벤트 | `equipmentCode` | 2 | -| `factory.manufacturing.alert` | 위험 조건을 만족한 알림 이벤트 | `equipmentCode` | 2 | +**제조 이벤트 흐름 제어 원칙:** +차량 1대당 4개 공정(PRESS, BODY, PAINT, ASSEMBLY)의 이벤트가 존재하지만, **최초에는 PRESS만 `READY` 상태**입니다. +정상적으로 공정이 완료된 경우에만 다음 공정 row의 상태를 `READY`로 변경하여 스케줄러가 가져갈 수 있게 합니다. -### Redis +*상태값 의미:* +- `dispatch_status`: `PENDING`(대기), `READY`(발행 가능), `SENT`(발행 완료), `BLOCKED`(설비 고장 대기), `SKIPPED`(진행 불가), `FAILED` +- `analysis_status`: `NOT_ANALYZED`, `NORMAL`, `ABNORMAL` -실시간 대시보드 조회 성능을 위해 캐시로 사용합니다. +  +### 2. 제조 Kafka Topic 구성 -예시 캐시 데이터: +제조 이벤트, 분석 결과, 설비 상태, 알림을 분리하기 위해 4개의 Topic을 운영합니다. 모든 Topic은 2개의 파티션으로 구성됩니다. -- 설비별 Safe Score -- 병목 위험도 -- RUL 예측치 -- 최근 알림 수 +| Topic | 역할 | Message Key | +| -------------------------------- | --------------- | -------------------------------- | +| `factory.manufacturing.raw` | 원천 제조 이벤트 전달 | `carMasterId` | +| `factory.manufacturing.analysis` | 공정·AI 분석 결과 전달 | `carMasterId` | +| `factory.equipment.status` | 설비 상태 변경 이벤트 전달 | `equipmentId` 또는 `equipmentCode` | +| `factory.manufacturing.alert` | 이상·위험 알림 이벤트 전달 | `alertId` | -### OpenSearch -제조 이벤트 로그와 분석 결과 검색에 사용합니다. +* `carMasterId`를 Key로 사용함으로써 동일 차량의 이벤트 순서를 파티션 레벨에서 보장합니다. -활용 예시: +  +### 3. 제조 Kafka Producer / Consumer 구성 +| Topic | Producer | Consumer Group | +| ------------------------ | ------------------------------------------- | ----------------------------------------------------- | +| `manufacturing.raw` | 제조 이벤트 서비스
이벤트 재생 스케줄러
Kafka 테스트 컨트롤러 | `manufacturing-consumer-group`
`ai-consumer-group` | +| `manufacturing.analysis` | 제조 이벤트 분석 Consumer | `alert-analysis-consumer-group` | +| `equipment.status` | 제조 이벤트 분석 Consumer
설비 상태 Listener | `dashboard-consumer-group` | +| `manufacturing.alert` | 제조 이벤트 분석 Consumer | `alert-notification-consumer-group` | -- 병목 공정 검색 -- 불량 이력 검색 -- 차량별 제조 이력 검색 -- 센서 트렌드 집계 -## Kafka 제조 이벤트 파이프라인 - -### 전체 처리 흐름 +  +### 4. Kafka 제조 이벤트 파이프라인 흐름도 ```mermaid flowchart TD - DB[SampleDB
manufacturing_event_json] + DB[원천DB : manufacturing_event_json] - subgraph RAW_PRODUCERS[Raw Producers] - API[Kafka Test API] - SCHEDULER[ManufacturingEventReplayScheduler] - SEND_RAW[ManufacturingKafkaProducer.sendRaw] + subgraph SCHEDULER [Scheduler] + REPLAY[ManufacturingEventReplayScheduler
READY 상태 조회 & 설비 RUNNING 확인] end - RAW_TOPIC[["Topic: factory.manufacturing.raw
Partitions: 2
Key: equipmentCode"]] - - MANUFACTURING_GROUP["manufacturing-consumer-group
concurrency: 2
processCode 기반 공정 분석
PRESS / BODY / PAINT / ASSEMBLY"] - AI_GROUP["ai-consumer-group
concurrency: 2
병목 분석
불량 전이 예측"] - - ANALYSIS_PRODUCER["ManufacturingKafkaProducer.sendAnalysis"] - ANALYSIS_TOPIC[["Topic: factory.manufacturing.analysis
Partitions: 2
기본 Key: equipmentCode
불량 전이 Key: carId"]] - - EQUIPMENT_GROUP["equipment-consumer-group
concurrency: 2
설비 상태 및 가동률 이벤트 생성"] - ALERT_ANALYSIS_GROUP["alert-analysis-consumer-group
concurrency: 2
위험 조건 및 이상 여부 판정"] - - EQUIPMENT_PRODUCER["ManufacturingKafkaProducer.sendEquipment"] - ALERT_PRODUCER["ManufacturingKafkaProducer.sendAlert"] - - EQUIPMENT_TOPIC[["Topic: factory.equipment.status
Partitions: 2
Key: equipmentCode"]] - ALERT_TOPIC[["Topic: factory.manufacturing.alert
Partitions: 2
Key: equipmentCode"]] - - DASHBOARD_GROUP["dashboard-consumer-group
concurrency: 2
설비 상태 이벤트 소비
현재 로그 및 추적 이력 기록"] - NOTIFICATION_GROUP["alert-notification-consumer-group
concurrency: 2
실시간 알림 이벤트 소비
현재 로그 및 추적 이력 기록"] - - DB --> API - DB --> SCHEDULER - API --> SEND_RAW - SCHEDULER --> SEND_RAW - SEND_RAW --> RAW_TOPIC - - RAW_TOPIC --> MANUFACTURING_GROUP - RAW_TOPIC --> AI_GROUP - - MANUFACTURING_GROUP -->|PROCESS_RISK_ANALYSIS| ANALYSIS_PRODUCER - AI_GROUP -->|BOTTLENECK_ANALYSIS| ANALYSIS_PRODUCER - AI_GROUP -->|DEFECT_TRANSFER_PREDICTION| ANALYSIS_PRODUCER - ANALYSIS_PRODUCER --> ANALYSIS_TOPIC - - ANALYSIS_TOPIC --> EQUIPMENT_GROUP - ANALYSIS_TOPIC --> ALERT_ANALYSIS_GROUP - - EQUIPMENT_GROUP --> EQUIPMENT_PRODUCER - EQUIPMENT_PRODUCER --> EQUIPMENT_TOPIC - EQUIPMENT_TOPIC --> DASHBOARD_GROUP - - ALERT_ANALYSIS_GROUP -->|위험 조건 충족| ALERT_PRODUCER - ALERT_PRODUCER --> ALERT_TOPIC - ALERT_TOPIC --> NOTIFICATION_GROUP -``` - -이 구조에서 Producer와 Consumer는 고정된 하나의 애플리케이션을 의미하지 않습니다. Consumer가 메시지를 처리한 후 다음 Topic의 Producer 역할을 이어서 수행합니다. - -```text -Test API / Scheduler - └─ Raw Producer - -Manufacturing Consumer / AI Consumer - └─ Analysis Producer - -Equipment Consumer - └─ Equipment Producer - -Alert Analysis Consumer - └─ Alert Producer -``` - -Raw 이벤트 1건은 서로 다른 Consumer Group에서 독립적으로 소비됩니다. - -- `manufacturing-consumer-group`: `processCode`에 따라 PRESS, BODY, PAINT, ASSEMBLY 공정 분석을 수행하고 `PROCESS_RISK_ANALYSIS` 결과를 발행합니다. -- `ai-consumer-group`: 병목 분석인 `BOTTLENECK_ANALYSIS`와 불량 전이 예측인 `DEFECT_TRANSFER_PREDICTION` 결과를 각각 발행합니다. -- 따라서 정상 처리 시 Raw 이벤트 1건에서 Analysis 이벤트 3건이 생성됩니다. -- 각 Analysis 이벤트는 설비 상태 이벤트로 변환되므로 Equipment 이벤트도 Analysis 건수만큼 생성됩니다. -- Alert 이벤트는 모든 Analysis 이벤트에서 생성되지 않고 위험 조건을 만족할 때만 생성됩니다. - -### Topic별 Producer와 Consumer Group - -Producer는 Kafka Topic에 메시지를 발행하는 주체이며 Consumer Group에 속하지 않습니다. 따라서 송수신 추적 API에서 `direction=PRODUCED`인 항목의 `consumerGroup`이 `null`인 것은 정상입니다. - -| Topic | Producer | Consumer Group | Consumer 처리 내용 | -| --- | --- | --- | --- | -| `factory.manufacturing.raw` | Test API, `ManufacturingEventReplayScheduler` | `manufacturing-consumer-group` | 공정별 위험 분석 후 `PROCESS_RISK_ANALYSIS` 발행 | -| `factory.manufacturing.raw` | Test API, `ManufacturingEventReplayScheduler` | `ai-consumer-group` | 병목 분석과 불량 전이 예측 결과 발행 | -| `factory.manufacturing.analysis` | Manufacturing Consumer, AI Consumer | `equipment-consumer-group` | Analysis를 설비 상태로 변환하여 Equipment Topic 발행 | -| `factory.manufacturing.analysis` | Manufacturing Consumer, AI Consumer | `alert-analysis-consumer-group` | 위험 조건 판정 후 Alert Topic 발행 | -| `factory.equipment.status` | Equipment Consumer | `dashboard-consumer-group` | 후속 이벤트 차단·복구 및 대시보드 상태 소비 | -| `factory.manufacturing.alert` | Alert Analysis Consumer | `alert-notification-consumer-group` | 실시간 알림 대상 이벤트 소비 | - -Producer 메서드와 발행 대상은 다음과 같습니다. - -| Producer 메서드 | 발행 Topic | Message Key | -| --- | --- | --- | -| `ManufacturingKafkaProducer.sendRaw()` | `factory.manufacturing.raw` | `equipmentCode` | -| `ManufacturingKafkaProducer.sendAnalysis()` | `factory.manufacturing.analysis` | 기본 `equipmentCode`, 불량 전이는 `carId → carMasterId → equipmentCode` | -| `ManufacturingKafkaProducer.sendEquipment()` | `factory.equipment.status` | `equipmentCode` | -| `ManufacturingKafkaProducer.sendAlert()` | `factory.manufacturing.alert` | `equipmentCode` | - -### Consumer Group 구성 원칙 + RAW_TOPIC[["Topic: factory.manufacturing.raw
(Partitions: 2, Key: carMasterId)"]] -Kafka에서는 같은 Topic을 구독하더라도 Consumer Group이 다르면 각 Group이 동일한 메시지를 독립적으로 한 번씩 받습니다. - -예를 들어 Raw 이벤트 1건은 다음 두 Group에 각각 전달됩니다. - -```text -factory.manufacturing.raw의 이벤트 1건 - ├─ manufacturing-consumer-group에서 1회 처리 - └─ ai-consumer-group에서 1회 처리 -``` - -반대로 같은 Consumer Group 안에 Consumer가 여러 개 있으면 하나의 메시지는 Group 내부 Consumer 중 하나만 처리합니다. 현재 모든 Topic은 Partition 2개이고 각 Listener의 `concurrency`도 2이므로 Group마다 최대 2개의 Consumer가 Partition을 나누어 병렬 처리합니다. - -```text -factory.manufacturing.raw (Partition 0, Partition 1) - │ - └─ manufacturing-consumer-group - ├─ Consumer 1 → Partition 0 - └─ Consumer 2 → Partition 1 -``` - -Partition 할당은 Consumer 재시작, 증감 또는 재조정 시 달라질 수 있습니다. 중요한 기준은 같은 Group 안에서 하나의 Partition을 동시에 여러 Consumer가 처리하지 않는다는 점입니다. - -각 Consumer Group은 Offset도 독립적으로 관리합니다. 한 Group의 처리가 늦거나 중지되어도 다른 Group의 Offset과 처리에는 영향을 주지 않습니다. - -| Consumer Group | 구독 Topic | Concurrency | 생성하는 결과 | -| --- | --- | ---: | --- | -| `manufacturing-consumer-group` | Raw | 2 | Analysis 1건 | -| `ai-consumer-group` | Raw | 2 | Analysis 2건 | -| `equipment-consumer-group` | Analysis | 2 | Analysis 1건당 Equipment 1건 | -| `alert-analysis-consumer-group` | Analysis | 2 | 조건 충족 시 Alert 1건 | -| `dashboard-consumer-group` | Equipment | 2 | 현재는 로그와 추적 이력 기록 | -| `alert-notification-consumer-group` | Alert | 2 | 현재는 로그와 추적 이력 기록 | - -Raw 이벤트 1건의 일반적인 메시지 생성 수는 다음과 같습니다. - -```text -Raw 1건 - → Analysis 3건 - → Equipment 3건 - → Alert 0~3건 -``` - -Alert 건수는 각 Analysis 결과의 위험 조건 충족 여부에 따라 달라집니다. - -### Consumer 처리 코드 - -`ManufacturingKafkaConsumer`의 Listener 구성은 다음과 같습니다. - -| Listener 메서드 | Group ID | 입력 | 후속 처리 | -| --- | --- | --- | --- | -| `consumeRaw()` | `manufacturing-consumer-group` | Raw | 공정 Router 실행 후 Analysis 발행 | -| `consumeRawForAi()` | `ai-consumer-group` | Raw | 병목 및 불량 전이 Analysis 2건 발행 | -| `consumeAnalysisForEquipment()` | `equipment-consumer-group` | Analysis | Equipment 발행 | -| `consumeAnalysisForAlert()` | `alert-analysis-consumer-group` | Analysis | 조건부 Alert 발행 | -| `consumeEquipment()` | `dashboard-consumer-group` | Equipment | 대시보드 소비 이력 기록 | -| `consumeAlert()` | `alert-notification-consumer-group` | Alert | 알림 소비 이력 기록 | - -Consumer는 메시지 처리를 완료한 후 Record 단위로 Offset을 Commit하도록 설정되어 있습니다. 후속 Topic 발행은 `.join()`으로 broker 결과를 확인하므로 발행 실패가 발생하면 현재 Record 처리를 성공으로 완료하지 않습니다. - -### 분석 및 알림 기준 - -`ManufacturingEventAnalyzer`는 PRD에서 정의한 다음 입력값을 사용해 규칙 기반 위험도를 계산합니다. - -- 병목 위험도: `cycleTimeSec`, `waitingTimeSec`, `stationDelaySec`, `queueLength`, `wipCount`, `equipmentIdleTimeSec` -- 설비 위험도: 전류 RMS, 일반 진동, 로봇 암 진동, 설비 상태 -- 불량 전이 위험도: 전류, 진동, 로봇 암 진동, 열화상 온도 -- PRESS: 생산 카운트 증가 여부, 기준/실제 사이클타임, 전류 RMS -- BODY: 로봇 암 진동 점수와 주파수 -- PAINT: 비전 불량 점수, 온도 편차, 표면 품질 점수 -- ASSEMBLY: 작업 순서 오류, 누락 부품, 체결 오류 - -위험 등급은 다음 기준을 사용합니다. - -| 점수 | 위험 등급 | -| ---: | --- | -| 0 이상 60 미만 | `LOW` | -| 60 이상 80 미만 | `WARNING` | -| 80 이상 | `CRITICAL` | - -다음 중 하나라도 충족하면 `factory.manufacturing.alert`로 알림을 발행합니다. - -- 위험 등급이 `WARNING` 또는 `CRITICAL` -- 종합 위험도가 80점 이상 -- 설비 고장, 품질 불량, 병목, 조립 순서 오류가 감지됨 - -### Kafka Message Key - -Raw, Equipment, Alert 이벤트는 `equipmentCode`를 Message Key로 사용합니다. 동일 설비 이벤트가 같은 Partition으로 전달되므로 설비별 순서를 유지할 수 있습니다. - -Analysis 이벤트는 기본적으로 `equipmentCode`를 사용합니다. `DEFECT_TRANSFER_PREDICTION`은 차량 단위 추적을 위해 `carId`를 우선 사용하고, 누락 시 `CAR_MASTER-{carMasterId}`, 마지막으로 `equipmentCode`를 사용합니다. 대체 Key를 사용하면 경고 로그를 기록합니다. - -### SampleDB 이벤트 재생 Scheduler - -`ManufacturingEventReplayScheduler`는 `manufacturing_event_json`에서 다음 조건으로 이벤트를 조회합니다. - -```sql -WHERE COALESCE(is_sent, 0) = 0 -ORDER BY id ASC -LIMIT :batchSize -``` - -처리 순서는 다음과 같습니다. - -1. 미전송 이벤트를 설정된 건수만큼 조회합니다. -2. `factory.manufacturing.raw`로 발행합니다. -3. Kafka broker가 저장 성공을 응답한 이벤트만 `is_sent=1`로 변경합니다. -4. 성공 시 `sent_at`을 현재 시각으로 저장합니다. -5. 이전 Scheduler 작업이 진행 중이면 다음 실행을 건너뛰어 중복 조회를 방지합니다. - -Scheduler는 기본적으로 비활성화되어 있습니다. 자동 재생이 필요한 환경에서만 활성화합니다. - -```properties -KAFKA_REPLAY_SCHEDULER_ENABLED=true -KAFKA_REPLAY_FIXED_DELAY_MS=5000 -KAFKA_REPLAY_BATCH_SIZE=10 -``` - -| 환경변수 | 기본값 | 설명 | -| --- | ---: | --- | -| `KAFKA_REPLAY_SCHEDULER_ENABLED` | `false` | 자동 재생 활성화 여부 | -| `KAFKA_REPLAY_FIXED_DELAY_MS` | `5000` | 이전 작업 완료 후 다음 실행까지 대기 시간(ms) | -| `KAFKA_REPLAY_BATCH_SIZE` | `10` | 실행당 발행 건수, 코드에서 1~1000건으로 제한 | -| `KAFKA_LISTENERS_ENABLED` | `true` | 전체 Kafka Consumer Listener 활성화 여부 | - -### Kafka 테스트 및 진단 API - -기본 경로는 `/api/kafka/manufacturing`입니다. - -| Method | 경로 | 설명 | -| --- | --- | --- | -| `GET` | `/sample` | 다음 미전송 SampleDB 이벤트 조회 | -| `POST` | `/send/{id}` | 지정한 SampleDB PK의 이벤트를 Raw Topic으로 발행 | -| `POST` | `/send-sample` | 다음 미전송 이벤트 1건 발행 | -| `GET` | `/events?limit=20` | SampleDB 이벤트와 전송 상태 조회 | -| `GET` | `/broker` | 실제 Kafka/MSK 연결 및 Topic Partition 조회 | -| `GET` | `/messages` | 현재 애플리케이션 인스턴스의 최근 송수신 이력 조회 | -| `GET` | `/messages?eventId={eventId}` | 특정 이벤트의 Topic 처리 흐름 조회 | - -예시: - -```bash -curl -X POST http://localhost:8082/api/kafka/manufacturing/send/1 -curl "http://localhost:8082/api/kafka/manufacturing/messages?eventId=EVT-20260601-000001" -curl http://localhost:8082/api/kafka/manufacturing/broker -``` - -`/messages`는 현재 애플리케이션 인스턴스 메모리에 최대 200건만 보관하는 개발·진단용 기능입니다. 애플리케이션 재시작 시 초기화되며 Kafka 영구 메시지 조회 API가 아닙니다. - -### 주요 Kafka 코드 - -| 클래스 | 역할 | -| --- | --- | -| `KafkaConfig` | Kafka Admin, Producer, Consumer, 수동 Offset Commit, Topic Bean 구성 | -| `KafkaCustomProperties` | broker, 보안, Listener, Scheduler, Topic 설정 바인딩 | -| `ManufacturingEventJsonRepository` | SampleDB 이벤트 조회와 전송 완료 상태 갱신 | -| `ManufacturingRawEventService` | 단건·배치 Raw 발행 및 Kafka 성공 후 DB 상태 변경 | -| `ManufacturingEventReplayScheduler` | 미전송 이벤트 주기적 배치 재생 | -| `ManufacturingKafkaProducer` | Topic별 Message Key 선택, JSON 직렬화, broker 메타데이터 반환 | -| `ManufacturingKafkaConsumer` | Consumer Group별 소비와 다음 Topic 연쇄 발행 | -| `ManufacturingProcessRouter` | `processCode` 기준 공정 전용 Handler 선택 | -| `ManufacturingEventAnalyzer` | 공정, 병목, 불량 전이 위험도 계산과 Equipment/Alert 변환 | -| `KafkaMessageTraceStore` | 현재 인스턴스의 최근 Kafka 송수신 이력 최대 200건 보관 | -| `KafkaDiagnosticsService` | 실제 cluster, broker, Topic Partition 상태 조회 | - -현재 `dashboard-consumer-group`과 `alert-notification-consumer-group`은 메시지 소비, 로그, 추적 이력 기록까지 구현되어 있습니다. Redis 저장, Main DB 영속화, WebSocket Push, OpenSearch 적재는 별도 연동 구현이 필요합니다. - -## 패키지 구조 - -현재 프로젝트는 계층형 패키지 구조를 사용합니다. - -```text -com.aims.assembly - ├── AssemblyApplication # 애플리케이션 시작점 및 Scheduling 활성화 - ├── common - │ ├── code # 공통 성공/에러 코드 DTO 및 인터페이스 - │ ├── response # 공통 API 응답 - │ └── status - │ └── KafkaErrorStatus # Kafka 전용 에러 코드 - ├── config - │ ├── KafkaConfig # Topic, Producer, Consumer, Offset Commit 설정 - │ ├── DataSourceConfig # Main DB와 SampleDB DataSource 설정 - │ ├── RedisCacheConfig - │ ├── OpenSearchConfig - │ ├── JpaConfig - │ ├── QueryDSLConfig - │ ├── SecurityConfig - │ ├── SwaggerConfig - │ ├── WebConfig - │ ├── jwt - │ └── security - ├── controller - │ ├── kafka - │ │ └── ManufacturingKafkaTestController - │ │ # Kafka 발행, 메시지 추적, broker 진단 API - │ ├── process # 제조 데이터 조회 API - │ └── HealthCheckController - ├── domain - │ ├── event - │ │ └── ManufacturingEventJson - │ ├── analysis - │ ├── equipment - │ ├── press - │ ├── body - │ ├── paint - │ ├── assembly - │ ├── process - │ ├── car - │ ├── enums - │ └── commons - ├── kafka - │ ├── ManufacturingKafkaProducer - │ │ # Raw, Analysis, Equipment, Alert Topic 메시지 발행 - │ ├── ManufacturingKafkaConsumer - │ │ # Consumer Group별 메시지 소비와 후속 Topic 발행 - │ ├── ManufacturingEventAnalyzer - │ │ # 공정 위험, 병목, 불량 전이 분석 및 이벤트 변환 - │ ├── KafkaMessageTraceStore - │ │ # 현재 인스턴스의 최근 Kafka 송수신 이력 저장 - │ ├── KafkaDiagnosticsService - │ │ # Kafka/MSK 연결, broker, Topic Partition 진단 - │ └── model - │ ├── ManufacturingRawEvent - │ ├── ManufacturingAnalysisEvent - │ ├── EquipmentStatusEvent - │ ├── ManufacturingAlertEvent - │ └── KafkaPublishResult - ├── service - │ ├── manufacturing - │ │ ├── ManufacturingRawEventService - │ │ │ # SampleDB 이벤트 단건·배치 Kafka 발행 - │ │ ├── ManufacturingEventReplayScheduler - │ │ │ # 미전송 이벤트 주기적 자동 재생 - │ │ ├── ManufacturingProcessRouter - │ │ │ # processCode 기반 공정 Handler 선택 - │ │ ├── ManufacturingProcessHandler - │ │ ├── PressManufacturingService - │ │ ├── BodyManufacturingService - │ │ ├── PaintManufacturingService - │ │ └── AssemblyManufacturingService - │ └── process - ├── repository - │ ├── event - │ │ └── ManufacturingEventJsonRepository - │ │ # SampleDB 미전송 이벤트 조회 및 전송 상태 갱신 - │ └── process - ├── properties - │ ├── KafkaCustomProperties # Kafka, Topic, Scheduler 설정 바인딩 - │ ├── AppDataSourceProperties - │ ├── RedisCacheProperties - │ ├── OpenSearchProperties - │ └── CorsProperties - ├── dto # 요청/응답 DTO - ├── exception - │ └── KafkaException # Kafka 비즈니스 예외 - ├── mapper # Entity/Model → DTO 변환 - └── utils -``` - -## 현재 기본 설정 - -### Profile - -- `local`: 로컬 개발용, 컬러 콘솔 로그 -- `dev`: 개발 서버용, 콘솔 + 파일 로그 -- `prod`: 운영 서버용, 파일 로그 중심 - -### DB - -환경변수 기반으로 `maindb`, `sampledb`를 설정합니다. + subgraph CONSUMERS [Event Consumers] + MANUFACTURING_CG["manufacturing-consumer-group
(Press/Body/Paint/Assembly 분석)"] + AI_CG["ai-consumer-group
(병목/불량 전이 AI 분석)"] + end -- `MAIN_DB_JDBC_URL` -- `MAIN_DB_USERNAME` -- `MAIN_DB_PASSWORD` -- `SAMPLE_DB_JDBC_URL` -- `SAMPLE_DB_USERNAME` -- `SAMPLE_DB_PASSWORD` + ANALYSIS_TOPIC[["Topic: factory.manufacturing.analysis
(Key: carMasterId)"]] + EQUIPMENT_TOPIC[["Topic: factory.equipment.status
(Key: equipmentId/equipmentCode)"]] + ALERT_TOPIC[["Topic: factory.manufacturing.alert
(Key: alertId)"]] -### 환경변수 + ANALYSIS_CG["alert-analysis-consumer-group
분석 결과 알림"] + EQUIPMENT_CG["dashboard-consumer-group
상태 이력 기록"] + ALERT_CG["alert-notification-consumer-group
실시간 WebSocket 알림"] -`.env.example`에 필요한 환경변수 예시가 정의되어 있습니다. + DB --> REPLAY + REPLAY -->|조건 충족 시 발송| RAW_TOPIC -Spring Boot는 `.env` 파일을 자동으로 읽지 않으므로 IDE 실행 설정, Docker Compose, 쉘 환경변수 등을 통해 주입해야 합니다. + RAW_TOPIC --> MANUFACTURING_CG + RAW_TOPIC --> AI_CG + MANUFACTURING_CG -->|정상 시 다음 공정 DB 업데이트
분석 결과 발행| ANALYSIS_TOPIC + AI_CG -->|분석 결과 발행| ANALYSIS_TOPIC -## API 응답 구조 + ANALYSIS_TOPIC --> ANALYSIS_CG -모든 API는 공통 응답 구조를 사용합니다. + MANUFACTURING_CG -->|설비 상태 변경 시| EQUIPMENT_TOPIC + EQUIPMENT_TOPIC --> EQUIPMENT_CG -```json -{ - "success": true, - "data": { - "id": 1, - "email": "user@example.com" - }, - "message": "요청이 성공했습니다.", - "timestamp": "2026-06-08T16:00:00" -} + MANUFACTURING_CG -->|이상 감지 시| ALERT_TOPIC + AI_CG -->|위험 탐지 시| ALERT_TOPIC + ALERT_TOPIC --> ALERT_CG ``` -## 로컬 실행 +  +### 5. Scheduler 및 Consumer 상세 로직 + +**1) Scheduler 로직** +- `app.kafka.scheduler.enabled=true`일 때만 자동 재생 스케줄러가 동작합니다. +- `ManufacturingEventReplayScheduler`가 `READY` 이벤트를 배치로 조회하고, `ManufacturingRawEventService`가 `FOR UPDATE SKIP LOCKED`로 잠근 뒤 발행합니다. +- 원천 이벤트는 `dispatch_status='READY'`, `is_sent=0`, `retry_count < maxRetries` 조건을 만족할 때만 발행합니다. +- 전송 성공 시에만 `dispatch_status='SENT'`, `is_sent=1`, `event_time=현재 시각`으로 갱신합니다. +- 전송 실패 시에는 `retry_count`를 증가시키고 `error_message`를 남깁니다. + +**2) Consumer (제조 공정 로직)** +- `manufacturing-consumer-group`은 이벤트를 소비하고 `processCode`에 따라 분기합니다. (예: `PRESS` -> `PressAnalysisService`) +- 분석 결과가 `NORMAL`인 경우에는 현재 이벤트의 `analysis_status`를 `NORMAL`로 기록하고, 다음 공정이 진행 가능하도록 후속 상태를 갱신합니다. +- 분석 결과가 `ABNORMAL`인 경우에는 후속 공정을 `READY`로 바꾸지 않고 해당 차량을 `HOLD` 또는 `DEFECT` 상태로 전환합니다. +- `ai-consumer-group`은 병목 분석과 불량 전이 예측을 수행하고, `alert-analysis-consumer-group`이 `WARNING/CRITICAL` 결과에 대해 추가 알림을 발행합니다. +- `dashboard-consumer-group`은 설비 상태 이벤트를 반영하고, `RECOVERED`면 blocked 이벤트를 복구하며 `FAULT`면 `READY` 이벤트를 차단합니다. +- `alert-notification-consumer-group`은 최종 알림 이벤트를 소비합니다. +- 다음 공정 `READY` 전환은 Kafka 소비만으로 끝나지 않고 `AgvArrivalService.handleArrival(eventId)` 같은 별도 도착 처리에서 마무리됩니다. + +**3) 추적 및 진단** +- `KafkaMessageTraceStore`는 현재 Pod 메모리에 최근 200건의 PRODUCED/CONSUMED trace만 보관합니다. +- `ManufacturingKafkaTestController`의 `/broker` API는 `KafkaDiagnosticsService`를 통해 실제 clusterId, broker 수, 토픽별 partition 수를 확인합니다. +- `/messages`와 `/alerts` API는 현재 인스턴스가 본 Kafka 메시지 흐름을 eventId 또는 alert 기준으로 조회하는 진단용 엔드포인트입니다. + +  +## 🔧 기술 스택 + +

+ Java + Spring Boot + Gradle + MySQL + JPA Hibernate + Spring Security + JWT + Swagger OpenAPI + QueryDSL + Kafka + Elasticsearch + Redis + Docker + Kubernetes +

+ +  +## 로컬 실행 및 테스트 ```bash +# 로컬 서버 실행 ./gradlew bootRun -``` - -Windows: - -```bash -./gradlew.bat bootRun -``` -Swagger UI: - -```text -http://localhost:8082/swagger-ui/index.html -``` - -## 테스트 - -```bash +# 단위 및 통합 테스트 실행 ./gradlew test ``` -Windows: - -```bash -./gradlew.bat test -``` +- Swagger UI: `http://localhost:8082/swagger-ui/index.html` diff --git a/build.gradle b/build.gradle index 6ff0eca..c4639f3 100644 --- a/build.gradle +++ b/build.gradle @@ -118,6 +118,7 @@ sourceSets { } tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' options.generatedSourceOutputDirectory = file(querydslDir) } diff --git a/src/main/java/com/aims/assembly/AssemblyApplication.java b/src/main/java/com/aims/assembly/AssemblyApplication.java index b717962..c382962 100644 --- a/src/main/java/com/aims/assembly/AssemblyApplication.java +++ b/src/main/java/com/aims/assembly/AssemblyApplication.java @@ -3,31 +3,24 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.context.annotation.Bean; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; -import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.Optional; import java.util.TimeZone; @EnableScheduling -@EnableJpaAuditing(dateTimeProviderRef = "koreaDateTimeProvider") @ConfigurationPropertiesScan @SpringBootApplication public class AssemblyApplication { private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); - public static void main(String[] args) { + @jakarta.annotation.PostConstruct + public void init() { TimeZone.setDefault(TimeZone.getTimeZone(KOREA_ZONE)); - SpringApplication.run(AssemblyApplication.class, args); } - @Bean - public org.springframework.data.auditing.DateTimeProvider koreaDateTimeProvider() { - return () -> Optional.of(LocalDateTime.now(KOREA_ZONE)); + public static void main(String[] args) { + SpringApplication.run(AssemblyApplication.class, args); } - } diff --git a/src/main/java/com/aims/assembly/config/JpaAuditingConfig.java b/src/main/java/com/aims/assembly/config/JpaAuditingConfig.java new file mode 100644 index 0000000..2901e26 --- /dev/null +++ b/src/main/java/com/aims/assembly/config/JpaAuditingConfig.java @@ -0,0 +1,22 @@ +package com.aims.assembly.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.auditing.DateTimeProvider; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; + +@Configuration +@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider") +public class JpaAuditingConfig { + + private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); + + @Bean + public DateTimeProvider auditingDateTimeProvider() { + return () -> Optional.of(LocalDateTime.now(SEOUL_ZONE)); + } +} diff --git a/src/main/java/com/aims/assembly/config/RedisCacheConfig.java b/src/main/java/com/aims/assembly/config/RedisCacheConfig.java index 5dd58da..9a0a93d 100644 --- a/src/main/java/com/aims/assembly/config/RedisCacheConfig.java +++ b/src/main/java/com/aims/assembly/config/RedisCacheConfig.java @@ -63,8 +63,8 @@ public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) return RedisCacheManager.builder(redisConnectionFactory) .cacheDefaults(cacheConfig) - .withCacheConfiguration("press-anomaly-dashboard-v2", cacheConfig) - .withCacheConfiguration("body-anomaly-dashboard-v2", cacheConfig) + .withCacheConfiguration("press-anomaly-dashboard", cacheConfig) + .withCacheConfiguration("body-anomaly-dashboard", cacheConfig) .withCacheConfiguration("process-paint-dashboard-v1", cacheConfig.entryTtl(Duration.ofMinutes(3))) .withCacheConfiguration("process-assembly-dashboard-v1", diff --git a/src/main/java/com/aims/assembly/config/SecurityConfig.java b/src/main/java/com/aims/assembly/config/SecurityConfig.java index 888ad6e..dbe067c 100644 --- a/src/main/java/com/aims/assembly/config/SecurityConfig.java +++ b/src/main/java/com/aims/assembly/config/SecurityConfig.java @@ -49,6 +49,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/process/assembly/dates").permitAll() .requestMatchers("/api/process/equipment/operation-rate").permitAll() .requestMatchers("/api/kafka/manufacturing/**").permitAll() + .requestMatchers("/api/internal/**").permitAll() .requestMatchers( "/", "/api/process/health", diff --git a/src/main/java/com/aims/assembly/controller/body/BodyAnomalyDetectionController.java b/src/main/java/com/aims/assembly/controller/body/BodyAnomalyDetectionController.java index cf73d04..94d51bc 100644 --- a/src/main/java/com/aims/assembly/controller/body/BodyAnomalyDetectionController.java +++ b/src/main/java/com/aims/assembly/controller/body/BodyAnomalyDetectionController.java @@ -52,7 +52,7 @@ public ApiResponse dashboard( ) { return ApiResponse.success( service.findDashboard(date, from, to, endAt, limit), - "Body anomaly detection dashboard" + "차체 이상 탐지 API 조회 성공" ); } } diff --git a/src/main/java/com/aims/assembly/controller/internal/AgvArrivalController.java b/src/main/java/com/aims/assembly/controller/internal/AgvArrivalController.java new file mode 100644 index 0000000..a60bfa5 --- /dev/null +++ b/src/main/java/com/aims/assembly/controller/internal/AgvArrivalController.java @@ -0,0 +1,27 @@ +package com.aims.assembly.controller.internal; + +import com.aims.assembly.dto.manufacturing.AgvArrivalRequest; +import com.aims.assembly.service.manufacturing.AgvArrivalService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/internal/agv-arrivals") +@RequiredArgsConstructor +public class AgvArrivalController { + + private final AgvArrivalService agvArrivalService; + + @PostMapping + public ResponseEntity arrived( + @RequestBody AgvArrivalRequest request + ) { + + agvArrivalService.handleArrival( + request.getEventId() + ); + + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/controller/press/PressAnomalyDetectionController.java b/src/main/java/com/aims/assembly/controller/press/PressAnomalyDetectionController.java index 6f2dabd..080793c 100644 --- a/src/main/java/com/aims/assembly/controller/press/PressAnomalyDetectionController.java +++ b/src/main/java/com/aims/assembly/controller/press/PressAnomalyDetectionController.java @@ -52,7 +52,7 @@ public ApiResponse dashboard( ) { return ApiResponse.success( service.findDashboard(date, from, to, endAt, limit), - "Press anomaly detection dashboard" + "프레스 이상 탐지 API 조회 성공" ); } } diff --git a/src/main/java/com/aims/assembly/dashbord/enums/ProcessCode.java b/src/main/java/com/aims/assembly/dashbord/enums/ProcessCode.java new file mode 100644 index 0000000..325699d --- /dev/null +++ b/src/main/java/com/aims/assembly/dashbord/enums/ProcessCode.java @@ -0,0 +1,16 @@ +package com.aims.assembly.dashbord.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ProcessCode { + ASSEMBLY("조립"), + BODY("차체"), + PAINT("도장"), + PRESS("프레스"), + INSPECTION("검사"); + + private final String displayName; +} diff --git a/src/main/java/com/aims/assembly/domain/alert/ActionCategory.java b/src/main/java/com/aims/assembly/domain/alert/ActionCategory.java new file mode 100644 index 0000000..ae07989 --- /dev/null +++ b/src/main/java/com/aims/assembly/domain/alert/ActionCategory.java @@ -0,0 +1,10 @@ +package com.aims.assembly.domain.alert; + +public enum ActionCategory { + CHECK, + REPAIR, + CHANGE, + CLEAN, + ADJUST, + RESTART +} diff --git a/src/main/java/com/aims/assembly/domain/alert/ActionTimeline.java b/src/main/java/com/aims/assembly/domain/alert/ActionTimeline.java new file mode 100644 index 0000000..d891c07 --- /dev/null +++ b/src/main/java/com/aims/assembly/domain/alert/ActionTimeline.java @@ -0,0 +1,50 @@ +package com.aims.assembly.domain.alert; + +import com.aims.assembly.domain.user.UserRole; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@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/assembly/domain/alert/AlertActionStatus.java b/src/main/java/com/aims/assembly/domain/alert/AlertActionStatus.java new file mode 100644 index 0000000..2d971f1 --- /dev/null +++ b/src/main/java/com/aims/assembly/domain/alert/AlertActionStatus.java @@ -0,0 +1,8 @@ +package com.aims.assembly.domain.alert; + +public enum AlertActionStatus { + COMPLETED, + INCOMPLETE, + NOT_NEEDED, + PENDING +} diff --git a/src/main/java/com/aims/assembly/domain/alert/AlertEvent.java b/src/main/java/com/aims/assembly/domain/alert/AlertEvent.java new file mode 100644 index 0000000..f302665 --- /dev/null +++ b/src/main/java/com/aims/assembly/domain/alert/AlertEvent.java @@ -0,0 +1,110 @@ +package com.aims.assembly.domain.alert; + +import com.aims.assembly.dashbord.enums.ProcessCode; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "alert_event") +public class AlertEvent { + + @Id + @Column(name = "log_no", nullable = false, length = 20) + private String logNo; + + @Column(name = "event_id", nullable = false, unique = true, length = 100) + private String eventId; + + @Enumerated(EnumType.STRING) + @Column(name = "alert_type", nullable = false, length = 20) + private AlertType alertType; + + @Enumerated(EnumType.STRING) + @Column(name = "process_code", nullable = false, length = 20) + private ProcessCode processCode; + + @Column(name = "equipment_id") + private Long equipmentId; + + @Column(name = "event_key", nullable = false, length = 150) + private String eventKey; + + @Column(name = "risk_score", precision = 5, scale = 2) + private BigDecimal riskScore; + + @Column(name = "occurrence_score", precision = 6, scale = 4) + private BigDecimal occurrenceScore; + + @Column(name = "detection_score", precision = 6, scale = 4) + private BigDecimal detectionScore; + + @Column(name = "priority_score", precision = 10, scale = 2) + private BigDecimal priorityScore; + + @Enumerated(EnumType.STRING) + @Column(name = "severity", length = 20) + private AlertSeverity severity; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "contents", nullable = false, length = 500) + private String contents; + + @Column(name = "action_by", length = 50) + private String actionBy; + + @Enumerated(EnumType.STRING) + @Column(name = "action_status", length = 20) + private AlertActionStatus actionStatus; + + @Column(name = "reason", length = 500) + private String reason; + + @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; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + public void updateAction( + String actionBy, + AlertActionStatus actionStatus, + String reason + ) { + + if (actionBy != null) { + this.actionBy = actionBy; + } + + if (actionStatus != null) { + this.actionStatus = actionStatus; + + if (actionStatus == AlertActionStatus.COMPLETED + || actionStatus == AlertActionStatus.NOT_NEEDED) { + this.resolvedAt = LocalDateTime.now(); + } else if (actionStatus == AlertActionStatus.INCOMPLETE) { + this.resolvedAt = null; + } + } + + if (reason != null) { + this.reason = reason; + } + } +} diff --git a/src/main/java/com/aims/assembly/domain/alert/AlertSeverity.java b/src/main/java/com/aims/assembly/domain/alert/AlertSeverity.java new file mode 100644 index 0000000..75af466 --- /dev/null +++ b/src/main/java/com/aims/assembly/domain/alert/AlertSeverity.java @@ -0,0 +1,7 @@ +package com.aims.assembly.domain.alert; + +public enum AlertSeverity { + DANGER, + CAUTION, + WARNING +} diff --git a/src/main/java/com/aims/assembly/domain/alert/AlertType.java b/src/main/java/com/aims/assembly/domain/alert/AlertType.java new file mode 100644 index 0000000..8457573 --- /dev/null +++ b/src/main/java/com/aims/assembly/domain/alert/AlertType.java @@ -0,0 +1,6 @@ +package com.aims.assembly.domain.alert; + +public enum AlertType { + PROCESS, + EQUIPMENT +} diff --git a/src/main/java/com/aims/assembly/domain/alert/Severity.java b/src/main/java/com/aims/assembly/domain/alert/Severity.java new file mode 100644 index 0000000..ce80f98 --- /dev/null +++ b/src/main/java/com/aims/assembly/domain/alert/Severity.java @@ -0,0 +1,13 @@ +package com.aims.assembly.domain.alert; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Severity { + DANGER("위험"), + CAUTION("주의"); + + private final String displayName; +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/domain/assembly/AssemblyAnalysisResult.java b/src/main/java/com/aims/assembly/domain/assembly/AssemblyAnalysisResult.java index 3c2d7a0..597b974 100644 --- a/src/main/java/com/aims/assembly/domain/assembly/AssemblyAnalysisResult.java +++ b/src/main/java/com/aims/assembly/domain/assembly/AssemblyAnalysisResult.java @@ -1,18 +1,17 @@ package com.aims.assembly.domain.assembly; import com.aims.assembly.domain.analysis.ManufacturingAnalysisResult; +import com.aims.assembly.domain.commons.BaseEntity; import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; - @Entity @Table(name = "assembly_analysis_result") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class AssemblyAnalysisResult { +public class AssemblyAnalysisResult extends BaseEntity { @Id @Column(name = "analysis_result_id") @@ -37,4 +36,4 @@ public class AssemblyAnalysisResult { @MapsId @JoinColumn(name = "analysis_result_id", nullable = false) private ManufacturingAnalysisResult analysisResult; -} \ No newline at end of file +} diff --git a/src/main/java/com/aims/assembly/domain/body/BodyAnalysisResult.java b/src/main/java/com/aims/assembly/domain/body/BodyAnalysisResult.java index 4de1bbe..acc81cc 100644 --- a/src/main/java/com/aims/assembly/domain/body/BodyAnalysisResult.java +++ b/src/main/java/com/aims/assembly/domain/body/BodyAnalysisResult.java @@ -1,18 +1,17 @@ package com.aims.assembly.domain.body; import com.aims.assembly.domain.analysis.ManufacturingAnalysisResult; +import com.aims.assembly.domain.commons.BaseEntity; import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; - @Entity @Table(name = "body_analysis_result") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class BodyAnalysisResult { +public class BodyAnalysisResult extends BaseEntity { @Id @Column(name = "analysis_result_id") diff --git a/src/main/java/com/aims/assembly/domain/car/CarMaster.java b/src/main/java/com/aims/assembly/domain/car/CarMaster.java index fcc9b4a..f7f6a8c 100644 --- a/src/main/java/com/aims/assembly/domain/car/CarMaster.java +++ b/src/main/java/com/aims/assembly/domain/car/CarMaster.java @@ -1,5 +1,6 @@ package com.aims.assembly.domain.car; +import com.aims.assembly.domain.commons.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -7,9 +8,6 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.*; -import org.hibernate.annotations.CreationTimestamp; - -import java.time.LocalDateTime; @Entity @Table(name = "car_master") @@ -17,7 +15,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class CarMaster { +public class CarMaster extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -37,8 +35,4 @@ public class CarMaster { @Column(name = "fuel_efficiency") private Integer fuelEfficiency; - - @CreationTimestamp - @Column(name = "created_at", updatable = false) - private LocalDateTime createdAt; } diff --git a/src/main/java/com/aims/assembly/domain/commons/BaseEntity.java b/src/main/java/com/aims/assembly/domain/commons/BaseEntity.java index f582e03..d360651 100644 --- a/src/main/java/com/aims/assembly/domain/commons/BaseEntity.java +++ b/src/main/java/com/aims/assembly/domain/commons/BaseEntity.java @@ -19,10 +19,10 @@ public abstract class BaseEntity { @CreatedDate - @Column(name="created_at",nullable = false,columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") + @Column(name="created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") private LocalDateTime createdAt; @LastModifiedDate - @Column(name="updated_at",nullable = false,columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP") + @Column(name="updated_at", nullable = false, columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)") private LocalDateTime updatedAt; } \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/domain/equipment/Equipment.java b/src/main/java/com/aims/assembly/domain/equipment/Equipment.java index 11c4ca0..fc1ee62 100644 --- a/src/main/java/com/aims/assembly/domain/equipment/Equipment.java +++ b/src/main/java/com/aims/assembly/domain/equipment/Equipment.java @@ -1,5 +1,6 @@ package com.aims.assembly.domain.equipment; +import com.aims.assembly.domain.commons.BaseEntity; import com.aims.assembly.domain.enums.EquipmentOperationStatus; import com.aims.assembly.domain.enums.EquipmentType; import com.aims.assembly.domain.enums.ProcessCode; @@ -12,8 +13,6 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; @@ -23,7 +22,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class Equipment { +public class Equipment extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -56,14 +55,6 @@ public class Equipment { @Column(name = "reason", length = 255) private String reason; - @CreationTimestamp - @Column(name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @UpdateTimestamp - @Column(name = "updated_at") - private LocalDateTime updatedAt; - public void applyStatus( EquipmentOperationStatus operationStatus, LocalDateTime changedAt, diff --git a/src/main/java/com/aims/assembly/domain/event/ManufacturingEventJson.java b/src/main/java/com/aims/assembly/domain/event/ManufacturingEventJson.java index 1bebd91..0f854f3 100644 --- a/src/main/java/com/aims/assembly/domain/event/ManufacturingEventJson.java +++ b/src/main/java/com/aims/assembly/domain/event/ManufacturingEventJson.java @@ -92,4 +92,12 @@ public class ManufacturingEventJson extends BaseEntity { @Column(name = "error_message", columnDefinition = "TEXT") private String errorMessage; + + @Builder.Default + @Column(name = "bottleneck_analysis_done") + private Boolean bottleneckAnalysisDone = false; + + @Builder.Default + @Column(name = "defect_transfer_analysis_done") + private Boolean defectTransferAnalysisDone = false; } diff --git a/src/main/java/com/aims/assembly/domain/inspection/InspectionMasterEntity.java b/src/main/java/com/aims/assembly/domain/inspection/InspectionMasterEntity.java new file mode 100644 index 0000000..849bc27 --- /dev/null +++ b/src/main/java/com/aims/assembly/domain/inspection/InspectionMasterEntity.java @@ -0,0 +1,38 @@ +package com.aims.assembly.domain.inspection; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name="inspection_master") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InspectionMasterEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "vehicle_id") + private String vehicleId; + + @Column(name = "car_type") + private String carType; + + @Column(name = "engine_type") + private String engineType; + + @Column(name = "car_color") + private String carColor; + + @Column(name = "fuel_efficiency") + private Integer fuelEfficiency; + + @Column(name = "created_at") + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/domain/paint/PaintAnalysisResult.java b/src/main/java/com/aims/assembly/domain/paint/PaintAnalysisResult.java index 7ac50b9..06cb8f9 100644 --- a/src/main/java/com/aims/assembly/domain/paint/PaintAnalysisResult.java +++ b/src/main/java/com/aims/assembly/domain/paint/PaintAnalysisResult.java @@ -1,18 +1,17 @@ package com.aims.assembly.domain.paint; import com.aims.assembly.domain.analysis.ManufacturingAnalysisResult; +import com.aims.assembly.domain.commons.BaseEntity; import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; - @Entity @Table(name = "paint_analysis_result") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class PaintAnalysisResult { +public class PaintAnalysisResult extends BaseEntity { @Id @Column(name = "analysis_result_id") diff --git a/src/main/java/com/aims/assembly/domain/press/PressAnalysisResult.java b/src/main/java/com/aims/assembly/domain/press/PressAnalysisResult.java index 6f0d744..e397d21 100644 --- a/src/main/java/com/aims/assembly/domain/press/PressAnalysisResult.java +++ b/src/main/java/com/aims/assembly/domain/press/PressAnalysisResult.java @@ -1,18 +1,17 @@ package com.aims.assembly.domain.press; import com.aims.assembly.domain.analysis.ManufacturingAnalysisResult; +import com.aims.assembly.domain.commons.BaseEntity; import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; - @Entity @Table(name = "press_analysis_result") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class PressAnalysisResult { +public class PressAnalysisResult extends BaseEntity { @Id @Column(name = "analysis_result_id") diff --git a/src/main/java/com/aims/assembly/domain/user/UserRole.java b/src/main/java/com/aims/assembly/domain/user/UserRole.java new file mode 100644 index 0000000..a711235 --- /dev/null +++ b/src/main/java/com/aims/assembly/domain/user/UserRole.java @@ -0,0 +1,6 @@ +package com.aims.assembly.domain.user; + +public enum UserRole { + SENIOR, + JUNIOR +} diff --git a/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java index 24893d7..385cb89 100644 --- a/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java @@ -12,7 +12,10 @@ public record BodyAnomalyDetectionResponse( LocalDateTime previousEndAt, List dateOptions, Metrics metrics, - List chart, + Charts charts, + List frequencyChart, + List frequencyZoneChart, + FrequencyZoneAnalysis frequencyZoneAnalysis, AlertPanel alert ) { public record DateOption( @@ -21,15 +24,18 @@ public record DateOption( ) { } - /** - * 상단 지표: 로봇 모션 상태, 운전 모드, 진동 점수, 피크 진동값, 전체 위험도 - */ public record Metrics( String robotMotionStatus, String robotOperationMode, - Double robotVibrationScore, - Double frequencyPeakValue, + Double avgRobotVibrationScore, + Double avgVibrationPeak, + Double avgVibrationRms, String frequencyPeakBand, + Double avgFrequencyPeakValue, + Double vibrationWarningLine, + Double vibrationDangerLine, + Double peakWarningLine, + Double peakDangerLine, Double riskScore, String riskScoreScale, String severity, @@ -37,24 +43,114 @@ public record Metrics( ) { } - /** - * 그래프 시계열 포인트: 로봇 진동 점수, 위험도, 피크 진동값 - */ + public record Charts( + RobotMetricChart robotVibration, + PeakMetricChart frequencyPeak + ) { + } + + public record RobotMetricChart( + String title, + String metricKey, + String unit, + List points + ) { + } + + public record PeakMetricChart( + String title, + String metricKey, + String unit, + List points + ) { + } + + public record RobotMetricPoint( + String eventId, + String analysisId, + String logNo, + LocalDateTime timestamp, + Double value, + Double warningLine, + Double dangerLine, + Boolean isAbnormal, + String severity + ) { + } + + public record PeakMetricPoint( + String eventId, + String analysisId, + String logNo, + LocalDateTime timestamp, + Double value, + Double secondaryValue, + Double warningLine, + Double dangerLine, + Boolean isAbnormal, + String severity + ) { + } + public record ChartPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double robotVibrationScore, Double frequencyPeakValue, + Double vibrationPeak, + Double vibrationWarningLine, + Double vibrationDangerLine, + Double peakWarningLine, + Double peakDangerLine, + Double vibrationRms, Double riskScore, Boolean isAbnormal, String severity ) { } + public record FrequencyBandPoint( + LocalDateTime timestamp, + String band, + Double value, + Double targetValue, + Double warningValue, + Double dangerValue + ) { + } + + public record FrequencyZonePoint( + String zone, + String range, + String description, + Double avg, + Double max, + Double targetValue, + Double warningValue, + Double dangerValue + ) { + } + + public record FrequencyZoneAnalysis( + ZoneStats zone1, + ZoneStats zone2, + ZoneStats zone3, + ZoneStats zone4, + ZoneStats zone5 + ) { + public record ZoneStats( + Double avg, + Double max + ) { + } + } + public record AlertPanel( Boolean detected, String title, + String logNo, List reasons ) { } diff --git a/src/main/java/com/aims/assembly/dto/inspection/InspectionTargetDto.java b/src/main/java/com/aims/assembly/dto/inspection/InspectionTargetDto.java new file mode 100644 index 0000000..53ed91e --- /dev/null +++ b/src/main/java/com/aims/assembly/dto/inspection/InspectionTargetDto.java @@ -0,0 +1,28 @@ +package com.aims.assembly.dto.inspection; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class InspectionTargetDto { + + private Long eventId; + + private String vehicleId; + + private String carType; + + private String engineType; + + private String carColor; + + private Integer fuelEfficiency; + + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/dto/manufacturing/AgvArrivalRequest.java b/src/main/java/com/aims/assembly/dto/manufacturing/AgvArrivalRequest.java new file mode 100644 index 0000000..1d7717f --- /dev/null +++ b/src/main/java/com/aims/assembly/dto/manufacturing/AgvArrivalRequest.java @@ -0,0 +1,14 @@ +package com.aims.assembly.dto.manufacturing; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class AgvArrivalRequest { + + /** + * AGV가 운반 완료한 제조 이벤트 + */ + private String eventId; +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java b/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java index 13d4cea..4c5164f 100644 --- a/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java @@ -14,9 +14,13 @@ public record PressAnomalyDetectionResponse( LocalDateTime previousEndAt, List dateOptions, Metrics metrics, + Charts charts, List chart, AlertPanel alert ) { + public static final double WARNING_CYCLE_GAP_SEC = 2.0; + public static final double DANGER_CYCLE_GAP_SEC = 3.0; + public record DateOption( LocalDate date, String sampleEventId @@ -27,48 +31,107 @@ public record Metrics( Double targetCycleTimeSec, Double actualCycleTimeSec, Double cycleTimeGapSec, - Double timestampDelaySec, + Double warningCycleTimeGapSec, + Double dangerCycleTimeGapSec, Double riskScore, String riskScoreScale, String severity ) { } + public record Charts( + CycleTimeChart cycleTime, + DelayChart delay + ) { + } + + public record CycleTimeChart( + String title, + String metricKey, + String unit, + List points + ) { + } + + public record DelayChart( + String title, + String metricKey, + String unit, + List points + ) { + } + + public record CycleTimePoint( + String eventId, + String analysisId, + String logNo, + LocalDateTime timestamp, + Double targetCycleTimeSec, + Double actualCycleTimeSec, + Boolean countIncreaseYn, + Boolean isAbnormal, + String severity, + Double warningCycleTimeGapSec, + Double dangerCycleTimeGapSec + ) { + } + + public record DelayPoint( + String eventId, + String analysisId, + String logNo, + LocalDateTime timestamp, + Double cycleTimeGapSec, + Boolean countIncreaseYn, + Boolean isAbnormal, + String severity, + Double warningCycleTimeGapSec, + Double dangerCycleTimeGapSec + ) { + } + public record ChartPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double targetCycleTimeSec, Double actualCycleTimeSec, Double cycleTimeGapSec, - Double timestampDelaySec, Double riskScore, Boolean countIncreaseYn, Boolean isAbnormal, - String severity + String severity, + Double warningCycleTimeGapSec, + Double dangerCycleTimeGapSec ) { public static ChartPoint from(PressAnalysisResult result) { - return from(result, result.getAnalysisResult().getEventTime()); + return from(result, result.getAnalysisResult().getEventTime(), null); } public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJsonEventTime) { + return from(result, eventJsonEventTime, null); + } + + public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJsonEventTime, String logNo) { var analysis = result.getAnalysisResult(); Double score = analysis.getRiskScore(); - if (score != null && score >= 100.0) { - score = 99.0; - } + boolean isAbnormal = Boolean.TRUE.equals(analysis.getIsAbnormal()); + String severity = severityFor(isAbnormal, analysis.getSeverity()); return new ChartPoint( analysis.getEventId(), analysis.getAnalysisId(), + logNo, eventJsonEventTime, result.getTargetCycleTimeSec(), result.getActualCycleTimeSec(), result.getCycleTimeGapSec(), - result.getTimestampDelaySec(), score, result.getCountIncreaseYn(), - Boolean.TRUE.equals(analysis.getIsAbnormal()), - defaultSeverity(analysis.getSeverity()) + isAbnormal, + severity, + WARNING_CYCLE_GAP_SEC, + DANGER_CYCLE_GAP_SEC ); } } @@ -76,25 +139,54 @@ public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJso public record AlertPanel( Boolean detected, String title, + String logNo, List reasons ) { } public static Metrics metricsFrom(ChartPoint point) { if (point == null) { - return new Metrics(null, null, null, null, null, "0-100", Severity.NORMAL.name()); + return new Metrics( + null, + null, + null, + WARNING_CYCLE_GAP_SEC, + DANGER_CYCLE_GAP_SEC, + null, + "0-100", + Severity.NORMAL.name() + ); } return new Metrics( point.targetCycleTimeSec(), point.actualCycleTimeSec(), point.cycleTimeGapSec(), - point.timestampDelaySec(), + point.warningCycleTimeGapSec(), + point.dangerCycleTimeGapSec(), point.riskScore(), "0-100", point.severity() == null ? Severity.NORMAL.name() : point.severity() ); } + public static Charts chartsFrom(List points) { + List safePoints = points == null ? List.of() : points; + return new Charts( + new CycleTimeChart( + "press cycle time", + "cycleTimeSec", + "sec", + safePoints.stream().map(PressAnomalyDetectionResponse::toCycleTimePoint).toList() + ), + new DelayChart( + "press delay/gap", + "delaySec", + "sec", + safePoints.stream().map(PressAnomalyDetectionResponse::toDelayPoint).toList() + ) + ); + } + public static boolean critical(ChartPoint point) { return point != null && (Boolean.TRUE.equals(point.isAbnormal()) @@ -102,7 +194,45 @@ public static boolean critical(ChartPoint point) { || point.riskScore() != null && point.riskScore() >= 60.0); } - private static String defaultSeverity(Severity severity) { - return severity == null ? Severity.NORMAL.name() : severity.name(); + private static String severityFor(boolean isAbnormal, Severity severity) { + String normalized = severity == null ? Severity.NORMAL.name() : severity.name(); + if (!isAbnormal) { + return normalized; + } + if (Severity.CRITICAL.name().equals(normalized)) { + return Severity.CRITICAL.name(); + } + return Severity.WARNING.name(); + } + + private static CycleTimePoint toCycleTimePoint(ChartPoint point) { + return new CycleTimePoint( + point.eventId(), + point.analysisId(), + point.logNo(), + point.timestamp(), + point.targetCycleTimeSec(), + point.actualCycleTimeSec(), + point.countIncreaseYn(), + point.isAbnormal(), + point.severity(), + point.warningCycleTimeGapSec(), + point.dangerCycleTimeGapSec() + ); + } + + private static DelayPoint toDelayPoint(ChartPoint point) { + return new DelayPoint( + point.eventId(), + point.analysisId(), + point.logNo(), + point.timestamp(), + point.cycleTimeGapSec(), + point.countIncreaseYn(), + point.isAbnormal(), + point.severity(), + point.warningCycleTimeGapSec(), + point.dangerCycleTimeGapSec() + ); } } diff --git a/src/main/java/com/aims/assembly/dto/process/AssemblyDashboardResponse.java b/src/main/java/com/aims/assembly/dto/process/AssemblyDashboardResponse.java index 8eb2584..c763f0a 100644 --- a/src/main/java/com/aims/assembly/dto/process/AssemblyDashboardResponse.java +++ b/src/main/java/com/aims/assembly/dto/process/AssemblyDashboardResponse.java @@ -6,10 +6,23 @@ public record AssemblyDashboardResponse( LocalDate selectedDate, + LocalDateTime from, + LocalDateTime to, + LocalDateTime dataStartAt, + LocalDateTime dataEndAt, Summary summary, List vehicles, Alert alert ) { + public AssemblyDashboardResponse( + LocalDate selectedDate, + Summary summary, + List vehicles, + Alert alert + ) { + this(selectedDate, null, null, null, null, summary, vehicles, alert); + } + public record Summary( long vehicleCount, long sequenceErrorCount, @@ -45,8 +58,16 @@ public static AssemblyDashboardResponse empty() { } public static AssemblyDashboardResponse empty(LocalDate selectedDate) { + return empty(selectedDate, null, null); + } + + public static AssemblyDashboardResponse empty(LocalDate selectedDate, LocalDateTime from, LocalDateTime to) { return new AssemblyDashboardResponse( selectedDate, + from, + to, + null, + null, new Summary(0, 0, 0, 0, 0.0), List.of(), null diff --git a/src/main/java/com/aims/assembly/dto/process/EquipmentOperationRateResponse.java b/src/main/java/com/aims/assembly/dto/process/EquipmentOperationRateResponse.java index 2849b81..9aeb7bd 100644 --- a/src/main/java/com/aims/assembly/dto/process/EquipmentOperationRateResponse.java +++ b/src/main/java/com/aims/assembly/dto/process/EquipmentOperationRateResponse.java @@ -2,6 +2,7 @@ import com.aims.assembly.domain.enums.EquipmentOperationStatus; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -18,7 +19,30 @@ public record Item( long faultCount, long totalCount, double operationRate, - Map statusCounts + Map statusCounts ) { + public Item { + statusCounts = normalizeStatusCounts(statusCounts); + } + + private static Map normalizeStatusCounts(Map source) { + Map normalized = new LinkedHashMap<>(); + for (EquipmentOperationStatus status : EquipmentOperationStatus.values()) { + normalized.put(status.name(), numberToLong(statusCountValue(source, status))); + } + return normalized; + } + + private static Object statusCountValue(Map source, EquipmentOperationStatus status) { + if (source == null) { + return null; + } + Object value = source.get(status.name()); + return value != null ? value : source.get(status); + } + + private static long numberToLong(Object value) { + return value instanceof Number number ? number.longValue() : 0L; + } } } diff --git a/src/main/java/com/aims/assembly/dto/process/PaintDashboardResponse.java b/src/main/java/com/aims/assembly/dto/process/PaintDashboardResponse.java index 870c298..2bc08d9 100644 --- a/src/main/java/com/aims/assembly/dto/process/PaintDashboardResponse.java +++ b/src/main/java/com/aims/assembly/dto/process/PaintDashboardResponse.java @@ -6,35 +6,130 @@ public record PaintDashboardResponse( LocalDate selectedDate, + LocalDateTime from, + LocalDateTime to, + LocalDateTime chartStartAt, + LocalDateTime chartEndAt, Summary summary, - List chart, + Thresholds thresholds, + Charts charts, Alert alert ) { public record Summary( long analysisCount, - double defectRate, + double averageThicknessValue, double averageSurfaceQualityScore, - long alertCount + double defectRate, + long alertCount, + double averageThermalStdTemp + ) { + public Summary( + long analysisCount, + double defectRate, + double averageSurfaceQualityScore, + long alertCount + ) { + this(analysisCount, 0.0, averageSurfaceQualityScore, defectRate, alertCount, 0.0); + } + } + + public record Thresholds( + HigherIsBetterThreshold surfaceQualityScore, + InRangeThreshold thicknessValue, + LowerIsBetterThreshold defectScore, + LowerIsBetterThreshold thermalStdTemp + ) { + } + + public record HigherIsBetterThreshold( + String label, + String unit, + String direction, + double warningBelow, + double dangerBelow + ) { + } + + public record InRangeThreshold( + String label, + String unit, + String direction, + double target, + double normalMin, + double normalMax, + double warningMin, + double warningMax + ) { + } + + public record LowerIsBetterThreshold( + String label, + String unit, + String direction, + double warningAbove, + double dangerAbove + ) { + } + + public record Charts( + MetricChart surfaceQuality, + MetricChart thickness, + MetricChart defectScore, + MetricChart thermalStdTemp + ) { + } + + public record MetricChart( + String title, + String metricKey, + String unit, + List points, + List markers ) { } - public record ChartPoint( + public record MetricPoint( LocalDateTime time, - Double defectScore, - Double surfaceQualityScore, - Double thicknessValue, + Double value, + String status, + String visionLabel, + String imagePosition, Double riskScore, + Long analysisResultId + ) { + } + + public record MetricMarker( + LocalDateTime time, + Double value, + String label, String imagePosition, - String visionLabel, - Double thermalStdTemp, - String severity + String status, + Long analysisResultId ) { } public record Alert( String title, - List messages + List messages, + Detail detail ) { + public Alert(String title, List messages) { + this(title, messages, null); + } + + public record Detail( + LocalDateTime time, + String visionLabel, + String imagePosition, + Double thicknessValue, + Double surfaceQualityScore, + Double defectScore, + Double thermalStdTemp, + Double riskScore, + String status + ) { + } } public static PaintDashboardResponse empty() { @@ -42,11 +137,38 @@ public static PaintDashboardResponse empty() { } public static PaintDashboardResponse empty(LocalDate selectedDate) { + return empty(selectedDate, null, null); + } + + public static PaintDashboardResponse empty(LocalDate selectedDate, LocalDateTime from, LocalDateTime to) { return new PaintDashboardResponse( selectedDate, - new Summary(0, 0.0, 0.0, 0), - List.of(), - null + from, + to, + null, + null, + new Summary(0, 0.0, 0.0, 0.0, 0, 0.0), + defaultThresholds(), + new Charts( + emptyChart("표면 품질 점수 추이", "surfaceQualityScore", "점"), + emptyChart("도막 두께 추이", "thicknessValue", "μm"), + emptyChart("불량 점수 추이", "defectScore", ""), + emptyChart("온도 편차 추이", "thermalStdTemp", "℃") + ), + new Alert("최근 도장 상태 정상", List.of("선택한 시간 범위 내 신규 위험 알람 없음")) + ); + } + + private static MetricChart emptyChart(String title, String metricKey, String unit) { + return new MetricChart(title, metricKey, unit, List.of(), List.of()); + } + + private static Thresholds defaultThresholds() { + return new Thresholds( + new HigherIsBetterThreshold("표면 품질 점수", "점", "HIGHER_IS_BETTER", 80.0, 60.0), + new InRangeThreshold("도막 두께", "μm", "IN_RANGE_IS_BETTER", 115.0, 90.0, 120.0, 80.0, 130.0), + new LowerIsBetterThreshold("불량 점수", "", "LOWER_IS_BETTER", 0.4, 0.6), + new LowerIsBetterThreshold("온도 편차", "℃", "LOWER_IS_BETTER", 2.0, 5.0) ); } } diff --git a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java index e008961..2f3626e 100644 --- a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java +++ b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java @@ -9,8 +9,14 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.UUID; /** @@ -29,6 +35,12 @@ */ @Component public class ManufacturingEventAnalyzer { + // ISO 7870/22400 원칙을 그대로 숫자로 고정하지 않고, 공정별 정상 데이터 기준으로 NORMAL/WARNING/DANGER를 나눈다. + private static final double PRESS_TARGET_CYCLE_TIME_SEC = 40.0; + private static final double PRESS_NORMAL_CYCLE_DELTA_SEC = 2.0; + private static final double PRESS_WARNING_CYCLE_DELTA_SEC = 3.0; + // ISO 13373/20816의 진동 구간 해석을 참고해, 차체 주파수 밴드를 LOW/MID/HIGH 기준으로 분리한다. + private static final Pattern BODY_BAND_PATTERN = Pattern.compile("freq_(\\d+)_(\\d+)_hz", Pattern.CASE_INSENSITIVE); public ManufacturingAnalysisEvent analyze(ManufacturingRawEvent event) { return analyze(event, "PROCESS_RISK_ANALYSIS"); @@ -52,21 +64,32 @@ public AnalysisDetail analyzeDetail(ManufacturingRawEvent event) { ProcessComponent processComponent = processComponent(event, cycleTimeSec, stationDelaySec); double processRiskScore = round(clamp(processComponent.score())); + SignalSeverity processSignalSeverity = processComponent.severity(); // equipmentFault: 상태값 기반 판단만 수행 (수치 계산식 제거) boolean equipmentFault = isEquipmentAbnormalStatus(event); boolean sequenceError = event.processCode() == ProcessCode.ASSEMBLY && number(event.eventJson(), "processData", "assembly", "sequenceErrorCount") > 0; - boolean isAbnormal = processRiskScore >= 60 || equipmentFault || sequenceError; - String abnormalType = abnormalType(processRiskScore, equipmentFault, sequenceError); + boolean isAbnormal = processRiskScore >= 60 + || processSignalSeverity != SignalSeverity.NORMAL + || equipmentFault + || sequenceError; + String abnormalType = abnormalType(processRiskScore, processSignalSeverity, equipmentFault, sequenceError); String equipmentStatusReason = buildEquipmentStatusReason(event, equipmentFault); - String message = mainReason(event.processCode(), processRiskScore, equipmentFault, sequenceError, "PROCESS_RISK_ANALYSIS"); + String message = mainReason( + event.processCode(), + processRiskScore, + processSignalSeverity, + equipmentFault, + sequenceError, + "PROCESS_RISK_ANALYSIS" + ); return new AnalysisDetail( "0-100", processRiskScore, - riskLevel(processRiskScore), + riskLevel(processRiskScore, processSignalSeverity), isAbnormal, abnormalType, "riskScore = processRisk", @@ -77,7 +100,7 @@ public AnalysisDetail analyzeDetail(ManufacturingRawEvent event) { ), equipmentFault, equipmentStatusReason, - decisionReason(processRiskScore, equipmentFault, sequenceError), + decisionReason(processRiskScore, processSignalSeverity, equipmentFault, sequenceError), message ); } @@ -132,9 +155,11 @@ private ManufacturingAnalysisEvent analyze( ); // PRESS/BODY/PAINT/ASSEMBLY별 전용 위험도 계산 + ProcessComponent processComponent = processComponent(event, cycleTimeSec, stationDelaySec); double processRisk = calculateProcessRisk(event, cycleTimeSec, stationDelaySec); ManufacturingAnalysisEvent.ProcessRisk processRiskScores = processRiskScores(event.processCode(), processRisk); + SignalSeverity processSignalSeverity = processComponent.severity(); // riskScore = processRisk (단독). bottleneck/defect/equipment 는 최종 score에 미포함 double overallRisk = switch (analysisType) { @@ -143,7 +168,7 @@ private ManufacturingAnalysisEvent analyze( default -> clamp(processRisk); }; overallRisk = round(overallRisk); - String riskLevel = riskLevel(overallRisk); + String riskLevel = riskLevel(overallRisk, processSignalSeverity); boolean bottleneck = bottleneckRisk >= 60; boolean qualityDefect = isQualityDefect(event, defectTransferRisk); @@ -162,8 +187,8 @@ private ManufacturingAnalysisEvent analyze( return new ManufacturingAnalysisEvent( "ANL-" + UUID.randomUUID(), event.eventId(), - event.eventTime(), - LocalDateTime.now(), + toOffsetDateTime(event.eventTime()), + OffsetDateTime.now(), text(event.eventJson(), "location", "factoryCode"), text(event.eventJson(), "location", "lineCode"), event.processCode(), @@ -186,14 +211,18 @@ private ManufacturingAnalysisEvent analyze( new ManufacturingAnalysisEvent.AnalysisResult( "BOTTLENECK_ANALYSIS".equals(analysisType) || "DEFECT_TRANSFER_PREDICTION".equals(analysisType) ? overallRisk >= 60 || bottleneck || qualityDefect || equipmentFault || sequenceError - : overallRisk >= 60 || equipmentFault || sequenceError, + : overallRisk >= 60 + || processSignalSeverity != SignalSeverity.NORMAL + || qualityDefect + || equipmentFault + || sequenceError, bottleneck, qualityDefect, equipmentFault, sequenceError ), new ManufacturingAnalysisEvent.Reason( - mainReason(event.processCode(), overallRisk, equipmentFault, sequenceError, analysisType), + mainReason(event.processCode(), overallRisk, processSignalSeverity, equipmentFault, sequenceError, analysisType), detailReasons ), new ManufacturingAnalysisEvent.Recommendation( @@ -218,14 +247,13 @@ public EquipmentStatusEvent toEquipmentEvent(ManufacturingAnalysisEvent analysis "CRITICAL".equals(analysis.riskLevel()) ? "STOPPED" : "RUNNING", analysis.riskLevel(), overallRiskScore(analysis), - analysis.operationRate() + analysis.operationRate() == null ? 0.0 : analysis.operationRate() ); } public EquipmentStatusEvent toEquipmentStatusEvent(ManufacturingRawEvent event) { String operationStatus = equipmentOperationStatus(event); - String riskLevel = "FAULT".equals(operationStatus) || "STOPPED".equals(operationStatus) ? "CRITICAL" - : ("WARNING".equals(operationStatus) ? "WARNING" : "LOW"); + String riskLevel = equipmentRiskLevel(operationStatus); return new EquipmentStatusEvent( "EQEVT-" + UUID.randomUUID(), event.eventId(), @@ -248,6 +276,7 @@ public EquipmentStatusEvent toEquipmentStatusEvent(ManufacturingRawEvent event) public ManufacturingAlertEvent toAlertEvent(ManufacturingAnalysisEvent analysis) { // WARNING 또는 CRITICAL 분석 결과(processRisk 기반)를 OPEN 상태의 알림으로 변환 + String alertType = ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL; return new ManufacturingAlertEvent( "ALT-" + UUID.randomUUID(), analysis.eventId(), @@ -260,7 +289,7 @@ public ManufacturingAlertEvent toAlertEvent(ManufacturingAnalysisEvent analysis) analysis.equipmentName(), analysis.carMasterId(), null, // equipmentId: analysis 이벤트에 없음 - null 허용 - "MANUFACTURING_ABNORMAL", + alertType, alertTitle(analysis), analysis.equipmentCode() + " 설비의 제조 공정 위험이 감지되었습니다. (processRisk 기반)", analysis.riskLevel(), @@ -278,6 +307,9 @@ public ManufacturingAlertEvent toAlertEvent(ManufacturingAnalysisEvent analysis) */ public ManufacturingAlertEvent toEquipmentStatusAlert(ManufacturingRawEvent event) { String statusReason = buildEquipmentStatusReason(event, true); + String operationStatus = equipmentOperationStatus(event); + String riskLevel = equipmentRiskLevel(operationStatus); + String alertType = ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL; return new ManufacturingAlertEvent( "ALT-" + UUID.randomUUID(), event.eventId(), @@ -290,11 +322,11 @@ public ManufacturingAlertEvent toEquipmentStatusAlert(ManufacturingRawEvent even text(event.eventJson(), "equipment", "equipmentName"), event.carMasterId(), event.equipmentId(), - "EQUIPMENT_ABNORMAL", + alertType, "설비 이상 감지", event.equipmentCode() + " 설비 이상 상태가 감지되었습니다. " + statusReason, - "CRITICAL", - 100.0, + riskLevel, + "CRITICAL".equals(riskLevel) ? 100.0 : 60.0, "OPEN", true, List.of(statusReason), @@ -302,6 +334,12 @@ public ManufacturingAlertEvent toEquipmentStatusAlert(ManufacturingRawEvent even ); } + private String equipmentRiskLevel(String operationStatus) { + return "FAULT".equals(operationStatus) || "STOPPED".equals(operationStatus) + ? "CRITICAL" + : ("WARNING".equals(operationStatus) ? "WARNING" : "LOW"); + } + /** * 설비 이상 여부 공개 판단 메서드. * Consumer에서 Case B alert 발행 여부 결정에 사용된다. @@ -343,7 +381,8 @@ private ProcessComponent processComponent( ) { return switch (event.processCode()) { case PRESS -> { - boolean countIncrease = bool( + // 프레스는 사이클 편차, 카운트 증가 여부, 설비 상태를 함께 봐서 ISO식 관리구간으로 판정한다. + Boolean countIncrease = boolObj( event.eventJson(), "processData", "press", @@ -351,29 +390,38 @@ private ProcessComponent processComponent( ); double rmsAmpere = number(event.eventJson(), "sensor", "current", "rmsAmpere"); double targetCycleTimeSec = targetCycleTime(event); - double cycleOverTargetSec = Math.max(0, cycleTimeSec - targetCycleTimeSec); - + double cycleDeltaSec = Math.abs(cycleTimeSec - targetCycleTimeSec); + SignalSeverity cycleSeverity = pressCycleSeverity(cycleDeltaSec); + SignalSeverity countSeverity = pressCountSeverity(countIncrease); + SignalSeverity equipmentSeverity = pressEquipmentSeverity(event); + SignalSeverity signalSeverity = SignalSeverity.max(cycleSeverity, countSeverity, equipmentSeverity); + double score = clamp( - Math.min(40.0, stationDelaySec * 2.0) - + Math.min(35.0, cycleOverTargetSec * 1.5) - + Math.min(20.0, Math.max(0.0, rmsAmpere - 1.5) * 8.0) - + (countIncrease ? 0.0 : 20.0) + Math.min(40.0, stationDelaySec * 1.0) + + Math.min(35.0, Math.max(0.0, cycleTimeSec - targetCycleTimeSec) * 1.0) + + Math.min(20.0, Math.max(0.0, rmsAmpere - 1.5) * 5.0) + + (Boolean.FALSE.equals(countIncrease) ? 20.0 : 0.0) ); + Map usedFields = new LinkedHashMap<>(); + usedFields.put("processCode", event.processCode().name()); + usedFields.put("stationDelaySec", stationDelaySec); + usedFields.put("cycleTimeSec", cycleTimeSec); + usedFields.put("targetCycleTimeSec", targetCycleTimeSec); + usedFields.put("cycleDeltaSec", cycleDeltaSec); + usedFields.put("rmsAmpere", rmsAmpere); + usedFields.put("countIncreaseYn", countIncrease); + usedFields.put("cycleSeverity", cycleSeverity.name()); + usedFields.put("countSeverity", countSeverity.name()); + usedFields.put("equipmentSeverity", equipmentSeverity.name()); yield new ProcessComponent( score, - Map.of( - "processCode", event.processCode().name(), - "stationDelaySec", stationDelaySec, - "cycleTimeSec", cycleTimeSec, - "targetCycleTimeSec", targetCycleTimeSec, - "cycleOverTargetSec", cycleOverTargetSec, - "rmsAmpere", rmsAmpere, - "countIncreaseYn", countIncrease - ), - "min(40, stationDelaySec * 4) + min(35, max(0, cycleTimeSec - targetCycleTimeSec) * 3) + min(20, max(0, rmsAmpere - 1.5) * 8) + (countIncreaseYn ? 0 : 20)" + usedFields, + "min(40, stationDelaySec * 1) + min(35, max(0, cycleTimeSec - targetCycleTimeSec) * 1) + min(20, max(0, rmsAmpere - 1.5) * 5) + (countIncreaseYn ? 0 : 20)", + signalSeverity ); } case BODY -> { + // 차체는 로봇 진동 점수 + 상태 + 주파수 밴드별 임계값을 합쳐 경고/위험을 구분한다. double robotScore = number( event.eventJson(), "sensor", @@ -391,38 +439,47 @@ yield new ProcessComponent( String frequencyPeakBand = text(event.eventJson(), "processData", "body", "frequencyPeakBand"); Object frequencyBandsObj = value(event.eventJson(), "processData", "body", "frequencyBands"); var frequencyBands = com.aims.assembly.service.body.BodyFrequencyBandSupport.toDoubleMap(frequencyBandsObj); + Map summaryBands = com.aims.assembly.service.body.BodyFrequencyBandSupport.toSummaryBands(frequencyBands); + Map thresholdBands = summaryBands != null && !summaryBands.isEmpty() + ? summaryBands + : frequencyBands; Double peakBandValue = com.aims.assembly.service.body.BodyFrequencyBandSupport.resolvePeakValue( frequencyPeakBand, - frequencyBands, + thresholdBands, vibrationPeak > 0 ? vibrationPeak : null ); double peakValue = peakBandValue != null ? peakBandValue : 0.0; - boolean isMotionAbnormal = robotMotionStatus != null - && ("ABNORMAL".equalsIgnoreCase(robotMotionStatus) - || "COLLISION_RISK".equalsIgnoreCase(robotMotionStatus)); - boolean isOperationAbnormal = robotOperationMode != null - && ("AUTO_MANUAL_STOPPED".equalsIgnoreCase(robotOperationMode) - || "STOPPED".equalsIgnoreCase(robotOperationMode) - || "MANUAL".equalsIgnoreCase(robotOperationMode)); + SignalSeverity motionSeverity = bodyMotionSeverity(robotMotionStatus); + SignalSeverity operationSeverity = bodyOperationSeverity(robotOperationMode); + SignalSeverity frequencySeverity = bodyFrequencySeverity( + frequencyPeakBand, + thresholdBands, + peakValue + ); + SignalSeverity signalSeverity = SignalSeverity.max(motionSeverity, operationSeverity, frequencySeverity); double score = clamp( Math.min(50.0, robotScore * 40.0) + Math.min(30.0, peakValue * 1000.0) - + (isMotionAbnormal ? 30.0 : 0.0) - + (isOperationAbnormal ? 20.0 : 0.0) + + (motionSeverity == SignalSeverity.DANGER ? 30.0 : (motionSeverity == SignalSeverity.WARNING ? 15.0 : 0.0)) + + (operationSeverity == SignalSeverity.DANGER ? 20.0 : (operationSeverity == SignalSeverity.WARNING ? 10.0 : 0.0)) ); + Map usedFields = new LinkedHashMap<>(); + usedFields.put("processCode", event.processCode().name()); + usedFields.put("robotVibrationScore", robotScore); + usedFields.put("frequencyPeakValue", peakValue); + usedFields.put("robotMotionStatus", String.valueOf(robotMotionStatus)); + usedFields.put("robotOperationMode", String.valueOf(robotOperationMode)); + usedFields.put("frequencyPeakBand", String.valueOf(frequencyPeakBand)); + usedFields.put("motionSeverity", motionSeverity.name()); + usedFields.put("operationSeverity", operationSeverity.name()); + usedFields.put("frequencySeverity", frequencySeverity.name()); yield new ProcessComponent( score, - Map.of( - "processCode", event.processCode().name(), - "robotVibrationScore", robotScore, - "frequencyPeakValue", peakValue, - "robotMotionStatus", String.valueOf(robotMotionStatus), - "robotOperationMode", String.valueOf(robotOperationMode), - "frequencyPeakBand", String.valueOf(frequencyPeakBand) - ), - "min(50, robotVibrationScore * 40) + min(30, frequencyPeakValue * 1000) + motion/operation penalties" + usedFields, + "min(50, robotVibrationScore * 40) + min(30, frequencyPeakValue * 1000) + motion/operation penalties", + signalSeverity ); } case PAINT -> { @@ -447,7 +504,8 @@ yield new ProcessComponent( "surfaceQualityScore", surfaceQuality, "visionLabel", String.valueOf(text(event.eventJson(), "processData", "paint", "visionLabel")) ), - "min(45, defectScore * 35) + min(25, thermalStdTemp * 3) + min(30, max(0, 90 - surfaceQualityScore))" + "min(45, defectScore * 35) + min(25, thermalStdTemp * 3) + min(30, max(0, 90 - surfaceQualityScore))", + SignalSeverity.NORMAL ); } case ASSEMBLY -> { @@ -482,7 +540,8 @@ yield new ProcessComponent( "missingPartCount", missingParts, "fasteningErrorCount", fasteningErrors ), - "min(45, sequenceErrorCount * 4) + min(35, missingPartCount * 3) + min(20, fasteningErrorCount * 2)" + "min(45, sequenceErrorCount * 4) + min(35, missingPartCount * 3) + min(20, fasteningErrorCount * 2)", + SignalSeverity.NORMAL ); } }; @@ -586,6 +645,7 @@ private double targetCycleTime(ManufacturingRawEvent event) { private String mainReason( ProcessCode processCode, double overallRisk, + SignalSeverity processSignalSeverity, boolean equipmentFault, boolean sequenceError, String analysisType @@ -596,6 +656,14 @@ private String mainReason( if (sequenceError) { return "의장 공정의 작업 순서 오류가 감지되었습니다."; } + if (processSignalSeverity != SignalSeverity.NORMAL && overallRisk < 60) { + return switch (processCode) { + case PRESS -> "?꾨젅???쒖멸뿉?꽌 ?댁긽 ?곹깭媛€ 媛먯??섏뿀?듬땲??"; + case BODY -> "李⑥껜 ?쒖멸뿉?꽌 ?댁긽 ?곹깭媛€ 媛먯??섏뿀?듬땲??"; + case PAINT -> "?꾩옣 ?쒖멸뿉?꽌 ?댁긽 ?곹깭媛€ 媛먯??섏뿀?듬땲??"; + case ASSEMBLY -> "?섏옣 ?쒖멸뿉?꽌 ?댁긽 ?곹깭媛€ 媛먯??섏뿀?듬땲??"; + }; + } if (!"BOTTLENECK_ANALYSIS".equals(analysisType) && !"DEFECT_TRANSFER_PREDICTION".equals(analysisType)) { if (overallRisk >= 80) { return switch (processCode) { @@ -626,14 +694,16 @@ private String recommendationType(ProcessCode processCode) { }; } - private String abnormalType(double processRiskScore, boolean equipmentFault, boolean sequenceError) { + private String abnormalType(double processRiskScore, SignalSeverity processSignalSeverity, boolean equipmentFault, boolean sequenceError) { if (equipmentFault) return "EQUIPMENT"; - if (sequenceError || processRiskScore >= 60) return "PROCESS"; + if (sequenceError || processRiskScore >= 60 || processSignalSeverity != SignalSeverity.NORMAL) return "PROCESS"; return null; } - private String decisionReason(double processRiskScore, boolean equipmentFault, boolean sequenceError) { + private String decisionReason(double processRiskScore, SignalSeverity processSignalSeverity, boolean equipmentFault, boolean sequenceError) { if (processRiskScore >= 60) return "processRisk >= 60 (riskScore = processRisk)"; + if (processSignalSeverity == SignalSeverity.DANGER) return "process signal indicates danger"; + if (processSignalSeverity == SignalSeverity.WARNING) return "process signal indicates warning"; if (equipmentFault) return "Equipment status is WARNING/STOPPED/FAULT"; if (sequenceError) return "ASSEMBLY sequenceErrorCount > 0"; return "processRisk < 60 and no abnormal detail flag"; @@ -672,6 +742,10 @@ private double calculateOperationRate( return round(clamp(processingTimeSec / plannedTimeSec * 100)); } + private OffsetDateTime toOffsetDateTime(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime(); + } + private String riskLevel(double score) { if (score >= 80) { return "CRITICAL"; @@ -682,8 +756,44 @@ private String riskLevel(double score) { return "LOW"; } + private String riskLevel(double score, SignalSeverity severity) { + return maxRiskLevel(riskLevel(score), severity); + } + + private String maxRiskLevel(String left, SignalSeverity severity) { + String right = severity == null ? "LOW" : switch (severity) { + case NORMAL -> "LOW"; + case WARNING -> "WARNING"; + case DANGER -> "CRITICAL"; + }; + return maxRiskLevel(left, right); + } + + private String maxRiskLevel(String left, String right) { + return rankRiskLevel(left) >= rankRiskLevel(right) ? normalizeRiskLevel(left) : normalizeRiskLevel(right); + } + + private int rankRiskLevel(String level) { + return switch (normalizeRiskLevel(level)) { + case "CRITICAL" -> 3; + case "WARNING" -> 2; + default -> 1; + }; + } + + private String normalizeRiskLevel(String level) { + if (level == null) { + return "LOW"; + } + return switch (level.toUpperCase(Locale.ROOT)) { + case "CRITICAL", "DANGER" -> "CRITICAL"; + case "WARNING" -> "WARNING"; + default -> "LOW"; + }; + } + private double clamp(double score) { - return Math.max(0.0, Math.min(99.0, score)); + return Math.max(0.0, Math.min(100.0, score)); } private double round(double score) { @@ -706,6 +816,23 @@ private boolean bool(Map source, String... path) { return value instanceof Boolean bool && bool; } + private Boolean boolObj(Map source, String... path) { + Object value = value(source, path); + if (value instanceof String s) { + if ("Y".equalsIgnoreCase(s) || "true".equalsIgnoreCase(s)) { + return true; + } + if ("N".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s)) { + return false; + } + return null; + } + if (value instanceof Number n) { + return n.intValue() == 1; + } + return value instanceof Boolean bool ? bool : null; + } + private String text(Map source, String... path) { Object value = value(source, path); return value == null ? null : value.toString(); @@ -755,7 +882,170 @@ public record ProcessRiskDetail( private record ProcessComponent( double score, Map usedFields, - String formula + String formula, + SignalSeverity severity ) { } + + private SignalSeverity pressCycleSeverity(double cycleDeltaSec) { + // ISO의 관리도 개념처럼 기준값 ±2σ를 정상, ±3σ를 경고/위험으로 해석하는 구간 판정. + if (cycleDeltaSec > PRESS_WARNING_CYCLE_DELTA_SEC) { + return SignalSeverity.DANGER; + } + if (cycleDeltaSec > PRESS_NORMAL_CYCLE_DELTA_SEC) { + return SignalSeverity.WARNING; + } + return SignalSeverity.NORMAL; + } + + private SignalSeverity pressCountSeverity(Boolean countIncrease) { + if (countIncrease == null) { + return SignalSeverity.WARNING; + } + return Boolean.FALSE.equals(countIncrease) ? SignalSeverity.DANGER : SignalSeverity.NORMAL; + } + + private SignalSeverity pressEquipmentSeverity(ManufacturingRawEvent event) { + String status = event.equipmentStatus(); + if (status == null || status.isBlank()) { + status = text(event.eventJson(), "equipmentStatus", "operationStatus"); + } + return equipmentSeverity(status); + } + + private SignalSeverity bodyMotionSeverity(String motionStatus) { + if (motionStatus == null || motionStatus.isBlank()) { + return SignalSeverity.WARNING; + } + return switch (motionStatus.trim().toUpperCase(Locale.ROOT)) { + case "NORMAL" -> SignalSeverity.NORMAL; + case "WARNING", "UNKNOWN" -> SignalSeverity.WARNING; + case "ABNORMAL", "COLLISION_RISK" -> SignalSeverity.DANGER; + default -> SignalSeverity.WARNING; + }; + } + + private SignalSeverity bodyOperationSeverity(String operationMode) { + if (operationMode == null || operationMode.isBlank()) { + return SignalSeverity.WARNING; + } + return switch (operationMode.trim().toUpperCase(Locale.ROOT)) { + case "AUTO" -> SignalSeverity.NORMAL; + case "MANUAL", "STOPPED", "AUTO_MANUAL_STOPPED" -> SignalSeverity.WARNING; + default -> SignalSeverity.WARNING; + }; + } + + private SignalSeverity bodyFrequencySeverity( + String frequencyPeakBand, + Map frequencyBands, + double peakValue + ) { + // ISO 13373/20816의 밴드별 관리 원칙을 따라 LOW/MID/HIGH 임계값을 다르게 적용한다. + if (peakValue <= 0.0) { + return SignalSeverity.NORMAL; + } + FrequencyThreshold threshold = frequencyThreshold( + resolveFrequencyThresholdBand(frequencyPeakBand, frequencyBands, peakValue) + ); + if (peakValue >= threshold.danger()) { + return SignalSeverity.DANGER; + } + if (peakValue >= threshold.warning()) { + return SignalSeverity.WARNING; + } + return SignalSeverity.NORMAL; + } + + private String resolveFrequencyThresholdBand( + String frequencyPeakBand, + Map frequencyBands, + double peakValue + ) { + if (frequencyPeakBand != null && !frequencyPeakBand.isBlank()) { + return frequencyPeakBand; + } + if (frequencyBands == null || frequencyBands.isEmpty()) { + return null; + } + String bestBand = null; + double bestDistance = Double.MAX_VALUE; + for (Map.Entry entry : frequencyBands.entrySet()) { + Double value = entry.getValue(); + if (value == null) { + continue; + } + double distance = Math.abs(value - peakValue); + if (distance < bestDistance) { + bestDistance = distance; + bestBand = entry.getKey(); + } + } + return bestBand; + } + + private FrequencyThreshold frequencyThreshold(String band) { + if (band == null || band.isBlank()) { + return FrequencyThreshold.LOW; + } + String normalized = band.trim().toUpperCase(Locale.ROOT); + if (normalized.contains("LOW")) { + return FrequencyThreshold.LOW; + } + if (normalized.contains("MID") || normalized.contains("MEDIUM")) { + return FrequencyThreshold.MID; + } + if (normalized.contains("HIGH")) { + return FrequencyThreshold.HIGH; + } + Matcher matcher = BODY_BAND_PATTERN.matcher(normalized.toLowerCase(Locale.ROOT)); + if (matcher.find()) { + int upperHz = Integer.parseInt(matcher.group(2)); + if (upperHz <= 10) { + return FrequencyThreshold.LOW; + } + if (upperHz <= 50) { + return FrequencyThreshold.MID; + } + return FrequencyThreshold.HIGH; + } + return FrequencyThreshold.HIGH; + } + + private SignalSeverity equipmentSeverity(String status) { + if (status == null || status.isBlank()) { + return SignalSeverity.NORMAL; + } + return switch (status.trim().toUpperCase(Locale.ROOT)) { + case "RUNNING" -> SignalSeverity.NORMAL; + case "WARNING" -> SignalSeverity.WARNING; + case "STOPPED", "FAULT" -> SignalSeverity.DANGER; + default -> SignalSeverity.WARNING; + }; + } + + private enum SignalSeverity { + NORMAL, + WARNING, + DANGER; + + private static SignalSeverity max(SignalSeverity... severities) { + SignalSeverity result = NORMAL; + for (SignalSeverity severity : severities) { + if (severity == null) { + continue; + } + if (severity.ordinal() > result.ordinal()) { + result = severity; + } + } + return result; + } + } + + private record FrequencyThreshold(double normal, double warning, double danger) { + private static final FrequencyThreshold LOW = new FrequencyThreshold(0.006, 0.008, 0.009); + private static final FrequencyThreshold MID = new FrequencyThreshold(0.015, 0.021, 0.024); + private static final FrequencyThreshold HIGH = new FrequencyThreshold(0.004, 0.005, 0.0055); + } } diff --git a/src/main/java/com/aims/assembly/kafka/ManufacturingKafkaConsumer.java b/src/main/java/com/aims/assembly/kafka/ManufacturingKafkaConsumer.java index ab04337..6077fb8 100644 --- a/src/main/java/com/aims/assembly/kafka/ManufacturingKafkaConsumer.java +++ b/src/main/java/com/aims/assembly/kafka/ManufacturingKafkaConsumer.java @@ -275,11 +275,9 @@ static String normalizeOffsetDateTimes(String payload) { } static boolean isAbnormalAnalysis(ManufacturingAnalysisEvent analysis) { - var result = analysis.analysisResult(); - return isNonNormalRiskLevel(analysis.riskLevel()) - || result.isAbnormal() || result.isQualityDefect() - || result.isEquipmentFault() || result.isBottleneck() - || result.isSequenceError(); + + return analysis.analysisResult().isAbnormal(); + } private static boolean isNonNormalRiskLevel(String riskLevel) { @@ -322,14 +320,15 @@ private void transitionFollowingProcesses( nextProcessCode ); - int updatedRows = eventRepository.releaseNextProcessByCurrentRowId(currentEventRowId); - + // 정상이면 아무것도 하지 않음. + // 다음 공정 READY는 AGV 도착 API에서 처리. log.info( - "[PROCESS_FLOW] releaseNextProcessByCurrentRowId result eventId={}, currentEventRowId={}, updatedRows={}", - eventId, - currentEventRowId, - updatedRows + "[PROCESS_FLOW] analysis completed. Waiting for AGV arrival. eventId={}", + eventId ); + + + //int updatedRows = eventRepository.releaseNextProcessByCurrentRowId(currentEventRowId); } private ProcessCode nextProcess(ProcessCode currentProcessCode) { diff --git a/src/main/java/com/aims/assembly/kafka/model/ManufacturingAlertEvent.java b/src/main/java/com/aims/assembly/kafka/model/ManufacturingAlertEvent.java index 060a95c..ff9b28d 100644 --- a/src/main/java/com/aims/assembly/kafka/model/ManufacturingAlertEvent.java +++ b/src/main/java/com/aims/assembly/kafka/model/ManufacturingAlertEvent.java @@ -10,8 +10,8 @@ * *

alertType 구분: *

    - *
  • {@code PROCESS_RISK} - 제조 공정 분석 결과 riskScore(processRisk) >= 60
  • - *
  • {@code EQUIPMENT_STATUS} - 설비 상태값(WARNING/STOPPED/FAULT) 이상 감지
  • + *
  • {@code MANUFACTURING_ABNORMAL} - 제조 공정 분석 결과 알람
  • + *
  • {@code EQUIPMENT_ABNORMAL} - 설비 상태값(WARNING/STOPPED/FAULT) 이상 알람
  • *
* alert topic message key 는 {@code alertId} 를 사용한다. */ @@ -37,4 +37,6 @@ public record ManufacturingAlertEvent( List reason, String recommendedAction ) { + public static final String TYPE_MANUFACTURING_ABNORMAL = "MANUFACTURING_ABNORMAL"; + public static final String TYPE_EQUIPMENT_ABNORMAL = "EQUIPMENT_ABNORMAL"; } diff --git a/src/main/java/com/aims/assembly/kafka/model/ManufacturingAnalysisEvent.java b/src/main/java/com/aims/assembly/kafka/model/ManufacturingAnalysisEvent.java index 2d65c44..c4fe2fa 100644 --- a/src/main/java/com/aims/assembly/kafka/model/ManufacturingAnalysisEvent.java +++ b/src/main/java/com/aims/assembly/kafka/model/ManufacturingAnalysisEvent.java @@ -2,7 +2,7 @@ import com.aims.assembly.domain.enums.ProcessCode; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.List; /** @@ -11,8 +11,8 @@ public record ManufacturingAnalysisEvent( String analysisId, String eventId, - LocalDateTime eventTime, - LocalDateTime analyzedAt, + OffsetDateTime eventTime, + OffsetDateTime analyzedAt, String factoryCode, String lineCode, ProcessCode processCode, @@ -24,7 +24,7 @@ public record ManufacturingAnalysisEvent( Long carMasterId, String analysisType, RiskScores riskScores, - double operationRate, + Double operationRate, String riskLevel, AnalysisResult analysisResult, Reason reason, diff --git a/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java b/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java index c22c4b3..bbbd5ab 100644 --- a/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java +++ b/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java @@ -1,18 +1,16 @@ package com.aims.assembly.mapper; -import com.aims.assembly.domain.body.BodyAnalysisResult; import com.aims.assembly.dto.body.BodyAnomalyDetectionResponse; +import lombok.experimental.UtilityClass; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Map; +@UtilityClass public final class BodyAnomalyDetectionResponseMapper { - private BodyAnomalyDetectionResponseMapper() { - } - public static BodyAnomalyDetectionResponse toResponse( LocalDate date, LocalDateTime from, @@ -20,10 +18,13 @@ public static BodyAnomalyDetectionResponse toResponse( LocalDateTime previousEndAt, List dateOptions, BodyAnomalyDetectionResponse.Metrics metrics, - List chart, + BodyAnomalyDetectionResponse.Charts charts, + List frequencyChart, + List frequencyZoneChart, + BodyAnomalyDetectionResponse.FrequencyZoneAnalysis frequencyZoneAnalysis, BodyAnomalyDetectionResponse.AlertPanel alert ) { - return new BodyAnomalyDetectionResponse(date, from, to, previousEndAt, dateOptions, metrics, chart, alert); + return new BodyAnomalyDetectionResponse(date, from, to, previousEndAt, dateOptions, metrics, charts, frequencyChart, frequencyZoneChart, frequencyZoneAnalysis, alert); } public static BodyAnomalyDetectionResponse.DateOption toDateOption( @@ -36,9 +37,16 @@ public static BodyAnomalyDetectionResponse.DateOption toDateOption( public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double robotVibrationScore, Double frequencyPeakValue, + Double vibrationPeak, + Double vibrationWarningLine, + Double vibrationDangerLine, + Double peakWarningLine, + Double peakDangerLine, + Double vibrationRms, Double riskScore, Boolean isAbnormal, String severity @@ -46,41 +54,109 @@ public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( return new BodyAnomalyDetectionResponse.ChartPoint( eventId, analysisId, + logNo, timestamp, robotVibrationScore, frequencyPeakValue, + vibrationPeak, + vibrationWarningLine, + vibrationDangerLine, + peakWarningLine, + peakDangerLine, + vibrationRms, riskScore, isAbnormal, severity ); } - public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult result, Double frequencyPeakValue) { - var analysis = result.getAnalysisResult(); - boolean isAbnormal = Boolean.TRUE.equals(analysis.getIsAbnormal()) || (analysis.getRiskScore() != null && analysis.getRiskScore() >= 30.0); - String severity = analysis.getSeverity() == null ? "NORMAL" : analysis.getSeverity().name(); - if (isAbnormal && "NORMAL".equals(severity)) { - severity = "WARNING"; - } + public static BodyAnomalyDetectionResponse.RobotMetricPoint toRobotMetricPoint( + String eventId, + String analysisId, + String logNo, + LocalDateTime timestamp, + Double value, + Double warningLine, + Double dangerLine, + Boolean isAbnormal, + String severity + ) { + return new BodyAnomalyDetectionResponse.RobotMetricPoint( + eventId, + analysisId, + logNo, + timestamp, + value, + warningLine, + dangerLine, + isAbnormal, + severity + ); + } - return new BodyAnomalyDetectionResponse.ChartPoint( - analysis.getEventId(), - analysis.getAnalysisId(), - analysis.getEventTime(), - result.getRobotVibrationScore(), - frequencyPeakValue, - analysis.getRiskScore(), + public static BodyAnomalyDetectionResponse.PeakMetricPoint toPeakMetricPoint( + String eventId, + String analysisId, + String logNo, + LocalDateTime timestamp, + Double value, + Double secondaryValue, + Double warningLine, + Double dangerLine, + Boolean isAbnormal, + String severity + ) { + return new BodyAnomalyDetectionResponse.PeakMetricPoint( + eventId, + analysisId, + logNo, + timestamp, + value, + secondaryValue, + warningLine, + dangerLine, isAbnormal, severity ); } + public static BodyAnomalyDetectionResponse.RobotMetricChart toRobotMetricChart( + String title, + String metricKey, + String unit, + List points + ) { + return new BodyAnomalyDetectionResponse.RobotMetricChart(title, metricKey, unit, points); + } + + public static BodyAnomalyDetectionResponse.PeakMetricChart toPeakMetricChart( + String title, + String metricKey, + String unit, + List points + ) { + return new BodyAnomalyDetectionResponse.PeakMetricChart(title, metricKey, unit, points); + } + + public static BodyAnomalyDetectionResponse.Charts toCharts( + BodyAnomalyDetectionResponse.RobotMetricChart robotVibration, + BodyAnomalyDetectionResponse.PeakMetricChart frequencyPeak + ) { + return new BodyAnomalyDetectionResponse.Charts(robotVibration, frequencyPeak); + } + public static BodyAnomalyDetectionResponse.Metrics toMetrics( String robotMotionStatus, String robotOperationMode, - Double robotVibrationScore, - Double frequencyPeakValue, + Double avgRobotVibrationScore, + Double avgVibrationPeak, + Double avgVibrationRms, String frequencyPeakBand, + Double avgFrequencyPeakValue, + Double vibrationWarningLine, + Double vibrationDangerLine, + Double peakWarningLine, + Double peakDangerLine, Double riskScore, String riskScoreScale, String severity, @@ -89,9 +165,15 @@ public static BodyAnomalyDetectionResponse.Metrics toMetrics( return new BodyAnomalyDetectionResponse.Metrics( robotMotionStatus, robotOperationMode, - robotVibrationScore, - frequencyPeakValue, + avgRobotVibrationScore, + avgVibrationPeak, + avgVibrationRms, frequencyPeakBand, + avgFrequencyPeakValue, + vibrationWarningLine, + vibrationDangerLine, + peakWarningLine, + peakDangerLine, riskScore, riskScoreScale, severity, @@ -99,11 +181,52 @@ public static BodyAnomalyDetectionResponse.Metrics toMetrics( ); } + public static BodyAnomalyDetectionResponse.FrequencyBandPoint toFrequencyBandPoint( + LocalDateTime timestamp, + String band, + Double value, + Double targetValue, + Double warningValue, + Double dangerValue + ) { + return new BodyAnomalyDetectionResponse.FrequencyBandPoint( + timestamp, + band, + value, + targetValue, + warningValue, + dangerValue + ); + } + + public static BodyAnomalyDetectionResponse.FrequencyZonePoint toFrequencyZonePoint( + String zone, + String range, + String description, + Double avg, + Double max, + Double targetValue, + Double warningValue, + Double dangerValue + ) { + return new BodyAnomalyDetectionResponse.FrequencyZonePoint( + zone, + range, + description, + avg, + max, + targetValue, + warningValue, + dangerValue + ); + } + public static BodyAnomalyDetectionResponse.AlertPanel toAlert( Boolean detected, String title, + String logNo, List reasons ) { - return new BodyAnomalyDetectionResponse.AlertPanel(detected, title, reasons); + return new BodyAnomalyDetectionResponse.AlertPanel(detected, title, logNo, reasons); } } diff --git a/src/main/java/com/aims/assembly/mapper/InspectionTargetRowMapper.java b/src/main/java/com/aims/assembly/mapper/InspectionTargetRowMapper.java new file mode 100644 index 0000000..3979acd --- /dev/null +++ b/src/main/java/com/aims/assembly/mapper/InspectionTargetRowMapper.java @@ -0,0 +1,28 @@ +package com.aims.assembly.mapper; + +import com.aims.assembly.dto.inspection.InspectionTargetDto; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +public class InspectionTargetRowMapper + implements RowMapper { + + @Override + public InspectionTargetDto mapRow( + ResultSet rs, + int rowNum + ) throws SQLException { + + return new InspectionTargetDto( + rs.getLong("id"), + rs.getString("vehicle_id"), + rs.getString("car_type"), + rs.getString("engine_type"), + rs.getString("car_color"), + rs.getInt("fuel_efficiency"), + rs.getTimestamp("created_at").toLocalDateTime() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/mapper/PressAnomalyDetectionResponseMapper.java b/src/main/java/com/aims/assembly/mapper/PressAnomalyDetectionResponseMapper.java index 7d19fed..f2e46f9 100644 --- a/src/main/java/com/aims/assembly/mapper/PressAnomalyDetectionResponseMapper.java +++ b/src/main/java/com/aims/assembly/mapper/PressAnomalyDetectionResponseMapper.java @@ -19,10 +19,11 @@ public static PressAnomalyDetectionResponse toResponse( LocalDateTime previousEndAt, List dateOptions, PressAnomalyDetectionResponse.Metrics metrics, + PressAnomalyDetectionResponse.Charts charts, List chart, PressAnomalyDetectionResponse.AlertPanel alert ) { - return new PressAnomalyDetectionResponse(date, from, to, previousEndAt, dateOptions, metrics, chart, alert); + return new PressAnomalyDetectionResponse(date, from, to, previousEndAt, dateOptions, metrics, charts, chart, alert); } public static PressAnomalyDetectionResponse.DateOption toDateOption( @@ -36,6 +37,17 @@ public static PressAnomalyDetectionResponse.ChartPoint toChartPoint(PressAnalysi return PressAnomalyDetectionResponse.ChartPoint.from(result); } + public static PressAnomalyDetectionResponse.ChartPoint toChartPoint( + PressAnalysisResult result, + String logNo + ) { + return PressAnomalyDetectionResponse.ChartPoint.from( + result, + result.getAnalysisResult().getEventTime(), + logNo + ); + } + public static PressAnomalyDetectionResponse.Metrics toMetrics( PressAnomalyDetectionResponse.ChartPoint point ) { @@ -45,8 +57,13 @@ public static PressAnomalyDetectionResponse.Metrics toMetrics( public static PressAnomalyDetectionResponse.AlertPanel toAlert( Boolean detected, String title, + String logNo, List reasons ) { - return new PressAnomalyDetectionResponse.AlertPanel(detected, title, reasons); + return new PressAnomalyDetectionResponse.AlertPanel(detected, title, logNo, reasons); + } + + public static PressAnomalyDetectionResponse.Charts toCharts(List points) { + return PressAnomalyDetectionResponse.chartsFrom(points); } } diff --git a/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java b/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java index e3526a2..2b64c26 100644 --- a/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java +++ b/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java @@ -1,6 +1,5 @@ package com.aims.assembly.mapper; -import com.aims.assembly.domain.enums.EquipmentOperationStatus; import com.aims.assembly.dto.process.AssemblyDashboardResponse; import com.aims.assembly.dto.process.EquipmentOperationRateResponse; import com.aims.assembly.dto.process.PaintDashboardResponse; @@ -32,7 +31,7 @@ public static EquipmentOperationRateResponse.Item toEquipmentOperationRateItem( long faultCount, long totalCount, double operationRate, - Map statusCounts + Map statusCounts ) { return new EquipmentOperationRateResponse.Item( processCode, @@ -50,11 +49,26 @@ public static EquipmentOperationRateResponse.Item toEquipmentOperationRateItem( public static PaintDashboardResponse toPaintDashboardResponse( LocalDate selectedDate, + LocalDateTime from, + LocalDateTime to, + LocalDateTime chartStartAt, + LocalDateTime chartEndAt, PaintDashboardResponse.Summary summary, - List chart, + PaintDashboardResponse.Thresholds thresholds, + PaintDashboardResponse.Charts charts, PaintDashboardResponse.Alert alert ) { - return new PaintDashboardResponse(selectedDate, summary, chart, alert); + return new PaintDashboardResponse( + selectedDate, + from, + to, + chartStartAt, + chartEndAt, + summary, + thresholds, + charts, + alert + ); } public static PaintDashboardResponse.Summary toPaintSummary( @@ -66,27 +80,21 @@ public static PaintDashboardResponse.Summary toPaintSummary( return new PaintDashboardResponse.Summary(analysisCount, defectRate, averageSurfaceQualityScore, alertCount); } - public static PaintDashboardResponse.ChartPoint toPaintChartPoint( - LocalDateTime time, - Double defectScore, - Double surfaceQualityScore, - Double thicknessValue, - Double riskScore, - String imagePosition, - String visionLabel, - Double thermalStdTemp, - String severity + public static PaintDashboardResponse.Summary toPaintSummary( + long analysisCount, + double averageThicknessValue, + double averageSurfaceQualityScore, + double defectRate, + long alertCount, + double averageThermalStdTemp ) { - return new PaintDashboardResponse.ChartPoint( - time, - defectScore, - surfaceQualityScore, - thicknessValue, - riskScore, - imagePosition, - visionLabel, - thermalStdTemp, - severity + return new PaintDashboardResponse.Summary( + analysisCount, + averageThicknessValue, + averageSurfaceQualityScore, + defectRate, + alertCount, + averageThermalStdTemp ); } @@ -94,13 +102,25 @@ public static PaintDashboardResponse.Alert toPaintAlert(String title, List messages, + PaintDashboardResponse.Alert.Detail detail + ) { + return new PaintDashboardResponse.Alert(title, messages, detail); + } + public static AssemblyDashboardResponse toAssemblyDashboardResponse( LocalDate selectedDate, + LocalDateTime from, + LocalDateTime to, + LocalDateTime dataStartAt, + LocalDateTime dataEndAt, AssemblyDashboardResponse.Summary summary, List vehicles, AssemblyDashboardResponse.Alert alert ) { - return new AssemblyDashboardResponse(selectedDate, summary, vehicles, alert); + return new AssemblyDashboardResponse(selectedDate, from, to, dataStartAt, dataEndAt, summary, vehicles, alert); } public static AssemblyDashboardResponse.Summary toAssemblySummary( diff --git a/src/main/java/com/aims/assembly/repository/analysis/AssemblyAnalysisResultRepository.java b/src/main/java/com/aims/assembly/repository/analysis/AssemblyAnalysisResultRepository.java index d6449dc..bb95914 100644 --- a/src/main/java/com/aims/assembly/repository/analysis/AssemblyAnalysisResultRepository.java +++ b/src/main/java/com/aims/assembly/repository/analysis/AssemblyAnalysisResultRepository.java @@ -18,9 +18,9 @@ public interface AssemblyAnalysisResultRepository extends JpaRepository= :from) - and (:to is null or result.analyzedAt < :to) - order by result.analyzedAt desc, result.eventTime desc, result.id desc + and (:from is null or result.eventTime >= :from) + and (:to is null or result.eventTime < :to) + order by result.eventTime desc, result.id desc """) List findDashboardRows( @Param("from") LocalDateTime from, @@ -29,12 +29,26 @@ List findDashboardRows( ); @Query(""" - select result.analyzedAt + select assembly + from AssemblyAnalysisResult assembly + join fetch assembly.analysisResult result + where result.processCode = com.aims.assembly.domain.enums.ProcessCode.ASSEMBLY + and (:from is null or result.eventTime >= :from) + and (:to is null or result.eventTime < :to) + order by result.eventTime asc, result.id asc + """) + List findDashboardSummaryRows( + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + + @Query(""" + select result.eventTime from AssemblyAnalysisResult assembly join assembly.analysisResult result where result.processCode = com.aims.assembly.domain.enums.ProcessCode.ASSEMBLY - and result.analyzedAt is not null - order by result.analyzedAt asc + and result.eventTime is not null + order by result.eventTime asc """) - List findDashboardAnalyzedAtValues(); + List findDashboardEventTimeValues(); } diff --git a/src/main/java/com/aims/assembly/repository/analysis/PaintAnalysisResultRepository.java b/src/main/java/com/aims/assembly/repository/analysis/PaintAnalysisResultRepository.java index 6f8056a..b5d2caf 100644 --- a/src/main/java/com/aims/assembly/repository/analysis/PaintAnalysisResultRepository.java +++ b/src/main/java/com/aims/assembly/repository/analysis/PaintAnalysisResultRepository.java @@ -18,9 +18,9 @@ public interface PaintAnalysisResultRepository extends JpaRepository= :from) - and (:to is null or result.analyzedAt < :to) - order by result.analyzedAt asc, result.eventTime asc, result.id asc + and (:from is null or result.eventTime >= :from) + and (:to is null or result.eventTime < :to) + order by result.eventTime desc, result.id desc """) List findDashboardRows( @Param("from") LocalDateTime from, @@ -29,12 +29,26 @@ List findDashboardRows( ); @Query(""" - select result.analyzedAt + select paint + from PaintAnalysisResult paint + join fetch paint.analysisResult result + where result.processCode = com.aims.assembly.domain.enums.ProcessCode.PAINT + and (:from is null or result.eventTime >= :from) + and (:to is null or result.eventTime < :to) + order by result.eventTime asc, result.id asc + """) + List findDashboardSummaryRows( + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + + @Query(""" + select result.eventTime from PaintAnalysisResult paint join paint.analysisResult result where result.processCode = com.aims.assembly.domain.enums.ProcessCode.PAINT - and result.analyzedAt is not null - order by result.analyzedAt asc + and result.eventTime is not null + order by result.eventTime asc """) - List findDashboardAnalyzedAtValues(); + List findDashboardEventTimeValues(); } diff --git a/src/main/java/com/aims/assembly/repository/analysis/PressAnalysisResultRepository.java b/src/main/java/com/aims/assembly/repository/analysis/PressAnalysisResultRepository.java index 1f3aa7f..eacc18d 100644 --- a/src/main/java/com/aims/assembly/repository/analysis/PressAnalysisResultRepository.java +++ b/src/main/java/com/aims/assembly/repository/analysis/PressAnalysisResultRepository.java @@ -45,6 +45,17 @@ List findDashboardByEventTimeBetween( """) List findRecentDashboard(Pageable pageable); + @Query(""" + select max(analysis.riskScore) + from PressAnalysisResult press + join press.analysisResult analysis + where analysis.eventTime between :from and :to + """) + Double findMaxRiskScoreByEventTimeBetween( + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + @Query(""" SELECT DATE(ar.eventTime) AS date, diff --git a/src/main/java/com/aims/assembly/repository/car/CarMasterRepository.java b/src/main/java/com/aims/assembly/repository/car/CarMasterRepository.java new file mode 100644 index 0000000..6ba7d71 --- /dev/null +++ b/src/main/java/com/aims/assembly/repository/car/CarMasterRepository.java @@ -0,0 +1,42 @@ +package com.aims.assembly.repository.car; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@Repository +public class CarMasterRepository { + + private final JdbcTemplate jdbcTemplate; + + public CarMasterRepository(@Qualifier("sampleJdbcTemplate") JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Map findVehicleIdsByIdIn(Collection ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyMap(); + } + + String placeholders = ids.stream() + .map(ignored -> "?") + .collect(Collectors.joining(", ")); + return jdbcTemplate.query( + "SELECT id, vehicle_id FROM car_master WHERE id IN (" + placeholders + ")", + rs -> { + Map vehicleIdsById = new LinkedHashMap<>(); + while (rs.next()) { + vehicleIdsById.put(rs.getLong("id"), rs.getString("vehicle_id")); + } + return vehicleIdsById; + }, + ids.toArray() + ); + } +} diff --git a/src/main/java/com/aims/assembly/repository/event/AlertEventRepository.java b/src/main/java/com/aims/assembly/repository/event/AlertEventRepository.java new file mode 100644 index 0000000..4726918 --- /dev/null +++ b/src/main/java/com/aims/assembly/repository/event/AlertEventRepository.java @@ -0,0 +1,19 @@ +package com.aims.assembly.repository.event; + +import com.aims.assembly.domain.alert.AlertEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface AlertEventRepository extends JpaRepository { + Optional findByEventId(String eventId); + List findByEventIdIn(Collection eventIds); + + default Optional findLogNoByEventId(String eventId) { + return findByEventId(eventId).map(AlertEvent::getLogNo); + } +} diff --git a/src/main/java/com/aims/assembly/repository/event/ManufacturingEventJsonRepository.java b/src/main/java/com/aims/assembly/repository/event/ManufacturingEventJsonRepository.java index 1850dc6..8c3a913 100644 --- a/src/main/java/com/aims/assembly/repository/event/ManufacturingEventJsonRepository.java +++ b/src/main/java/com/aims/assembly/repository/event/ManufacturingEventJsonRepository.java @@ -514,6 +514,7 @@ public int releaseNextProcessByEventId(String eventId) { SET dispatch_status = 'READY', updated_at = CURRENT_TIMESTAMP WHERE dispatch_status = 'PENDING' + AND COALESCE(is_sent, 0) = 0 AND (car_master_id, process_code) = ( SELECT current_event.car_master_id, CASE current_event.process_code diff --git a/src/main/java/com/aims/assembly/repository/inspection/InspectionRepository.java b/src/main/java/com/aims/assembly/repository/inspection/InspectionRepository.java new file mode 100644 index 0000000..f4c438e --- /dev/null +++ b/src/main/java/com/aims/assembly/repository/inspection/InspectionRepository.java @@ -0,0 +1,12 @@ +package com.aims.assembly.repository.inspection; + +import com.aims.assembly.domain.inspection.InspectionMasterEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface InspectionRepository + extends JpaRepository { + + boolean existsByVehicleId(String vehicleId); +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/repository/manufacturing/ManufacturingRepository.java b/src/main/java/com/aims/assembly/repository/manufacturing/ManufacturingRepository.java new file mode 100644 index 0000000..3891048 --- /dev/null +++ b/src/main/java/com/aims/assembly/repository/manufacturing/ManufacturingRepository.java @@ -0,0 +1,60 @@ +package com.aims.assembly.repository.manufacturing; + +import com.aims.assembly.dto.inspection.InspectionTargetDto; +import com.aims.assembly.mapper.InspectionTargetRowMapper; +import com.aims.assembly.domain.enums.*; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class ManufacturingRepository { + + private final JdbcTemplate jdbcTemplate; + + public ManufacturingRepository( + @Qualifier("sampleJdbcTemplate") + JdbcTemplate jdbcTemplate + ) { + this.jdbcTemplate = jdbcTemplate; + } + + public List findInspectionTarget( + ProcessCode processCode, + DispatchStatus dispatchStatus, + AnalysisStatus analysisStatus + ) { + + String sql = """ + SELECT + m.id, + c.vehicle_id, + c.car_type, + c.engine_type, + c.car_color, + c.fuel_efficiency, + c.created_at + + FROM manufacturing_event_json m + + JOIN car_master c + ON m.car_master_id = c.id + + WHERE m.process_code = ? + AND m.dispatch_status = ? + AND m.analysis_status = ? + + ORDER BY m.id + """; + + return jdbcTemplate.query( + sql, + new InspectionTargetRowMapper(), + processCode.name(), + dispatchStatus.name(), + analysisStatus.name() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/service/assembly/InspectionScheduler.java b/src/main/java/com/aims/assembly/service/assembly/InspectionScheduler.java new file mode 100644 index 0000000..f0b0347 --- /dev/null +++ b/src/main/java/com/aims/assembly/service/assembly/InspectionScheduler.java @@ -0,0 +1,17 @@ +package com.aims.assembly.service.assembly; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class InspectionScheduler { + + private final com.aims.assembly.service.assembly.InspectionTransferService inspectionTransferService; + + @Scheduled(fixedDelay = 1000) + public void transferInspection() { + inspectionTransferService.transfer(); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/service/assembly/InspectionTransferService.java b/src/main/java/com/aims/assembly/service/assembly/InspectionTransferService.java new file mode 100644 index 0000000..c49da35 --- /dev/null +++ b/src/main/java/com/aims/assembly/service/assembly/InspectionTransferService.java @@ -0,0 +1,62 @@ +package com.aims.assembly.service.assembly; + +import com.aims.assembly.domain.enums.AnalysisStatus; +import com.aims.assembly.domain.enums.DispatchStatus; +import com.aims.assembly.domain.enums.ProcessCode; +import com.aims.assembly.domain.inspection.InspectionMasterEntity; +import com.aims.assembly.dto.inspection.InspectionTargetDto; +import com.aims.assembly.repository.inspection.InspectionRepository; +import com.aims.assembly.repository.manufacturing.ManufacturingRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class InspectionTransferService { + + private final ManufacturingRepository manufacturingRepository; + + private final InspectionRepository inspectionRepository; + + @PersistenceContext + private EntityManager em; + + @Transactional + public void transfer() { + + List targets = + manufacturingRepository.findInspectionTarget( + ProcessCode.ASSEMBLY, + DispatchStatus.SENT, + AnalysisStatus.NORMAL + ); + + for (InspectionTargetDto dto : targets) { + + if (inspectionRepository.existsByVehicleId(dto.getVehicleId())) { + continue; + } + + InspectionMasterEntity entity = + InspectionMasterEntity.builder() + .vehicleId(dto.getVehicleId()) + .carType(dto.getCarType()) + .engineType(dto.getEngineType()) + .carColor(dto.getCarColor()) + .fuelEfficiency(dto.getFuelEfficiency()) + .createdAt(LocalDateTime.now()) + .build(); + + inspectionRepository.save(entity); + + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java index 00a82e1..f125d7e 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -1,15 +1,17 @@ package com.aims.assembly.service.body; import com.aims.assembly.domain.body.BodyAnalysisResult; +import com.aims.assembly.domain.enums.Severity; import com.aims.assembly.dto.body.BodyAnomalyDetectionResponse; import com.aims.assembly.mapper.BodyAnomalyDetectionResponseMapper; import com.aims.assembly.repository.analysis.BodyAnalysisResultRepository; +import com.aims.assembly.repository.event.AlertEventRepository; import com.aims.assembly.repository.event.ManufacturingEventJsonRepository; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -21,24 +23,47 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.LinkedHashSet; +import java.util.Locale; import java.util.stream.Collectors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Slf4j @Service -@RequiredArgsConstructor +@RequiredArgsConstructor(onConstructor_ = @Autowired) @Transactional(readOnly = true) public class BodyAnomalyDetectionService { private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; + // ISO 13373/20816 기준에 맞춰 차체 주파수 대역을 LOW / MID / HIGH 범위로 분류한다. + private static final Pattern DETAILED_BAND_PATTERN = Pattern.compile("freq_(\\d+)_(\\d+)_hz", Pattern.CASE_INSENSITIVE); + private static final Double ROBOT_VIBRATION_WARNING_LINE = 0.75; + private static final Double ROBOT_VIBRATION_DANGER_LINE = 1.25; + private static final BandThreshold LOW_BAND_THRESHOLD = new BandThreshold(0.006, 0.008, 0.009); + private static final BandThreshold MID_BAND_THRESHOLD = new BandThreshold(0.015, 0.021, 0.024); + private static final BandThreshold HIGH_BAND_THRESHOLD = new BandThreshold(0.004, 0.005, 0.0055); + private static final Double PEAK_WARNING_LINE = MID_BAND_THRESHOLD.warning(); + private static final Double PEAK_DANGER_LINE = MID_BAND_THRESHOLD.danger(); private final BodyAnalysisResultRepository repository; private final ManufacturingEventJsonRepository eventJsonRepository; + private final AlertEventRepository alertEventRepository; private final ObjectMapper objectMapper; + BodyAnomalyDetectionService( + BodyAnalysisResultRepository repository, + ManufacturingEventJsonRepository eventJsonRepository, + ObjectMapper objectMapper + ) { + this(repository, eventJsonRepository, null, objectMapper); + } + @Cacheable( - cacheNames = "body-anomaly-dashboard-v2", - key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString()) + ':' + (#limit ?: 30)" + cacheNames = "body-anomaly-dashboard", + key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: #endAt?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString())" ) public BodyAnomalyDetectionResponse findDashboard( LocalDate date, @@ -55,7 +80,6 @@ public BodyAnomalyDetectionResponse findDashboard( rangeTo = endAt; } - int size = Math.max(1, Math.min(limit, 200)); List allPoints = repository.findDashboardByEventTimeBetween( rangeFrom, rangeTo, @@ -73,52 +97,113 @@ public BodyAnomalyDetectionResponse findDashboard( .sorted(Comparator.comparing(BodyAnomalyDetectionResponse.ChartPoint::timestamp)) .toList(); - List points = allPoints.size() <= size - ? allPoints - : allPoints.subList(allPoints.size() - size, allPoints.size()); + List points = allPoints; + Map logNoByEventId = resolveAlertLogNos(points.stream() + .map(BodyAnomalyDetectionResponse.ChartPoint::eventId) + .filter(eventId -> eventId != null && !eventId.isBlank()) + .toList()); + points = points.stream() + .map(point -> attachLogNo(point, logNoByEventId.get(point.eventId()))) + .toList(); boolean detected = points.stream().anyMatch(this::isBodyAnomaly); LocalDateTime previousEndAt = points.isEmpty() ? null : points.get(0).timestamp().minusNanos(1); - // 이상 포인트 중 위험도 최대값 - double maxRiskScore = points.stream() - .filter(this::isBodyAnomaly) - .mapToDouble(p -> p.riskScore() != null ? p.riskScore() : 0.0) + Double averageRobotVibrationScore = average(points, BodyAnomalyDetectionResponse.ChartPoint::robotVibrationScore); + Double averageVibrationPeak = average(points, BodyAnomalyDetectionResponse.ChartPoint::vibrationPeak); + Double averageVibrationRms = average(points, BodyAnomalyDetectionResponse.ChartPoint::vibrationRms); + Double averageFrequencyPeakValue = average(points, BodyAnomalyDetectionResponse.ChartPoint::frequencyPeakValue); + Double maxRiskScore = points.stream() + .map(BodyAnomalyDetectionResponse.ChartPoint::riskScore) + .filter(value -> value != null) + .mapToDouble(Double::doubleValue) .max() - .orElse(points.isEmpty() ? 0.0 : safeNumber(points.get(points.size() - 1).riskScore())); + .orElse(0.0); - // summaryPoint: 이상 포인트 중 가장 위험한 포인트(가중치 최대) BodyAnomalyDetectionResponse.ChartPoint latest = points.isEmpty() ? null : points.get(points.size() - 1); - BodyAnomalyDetectionResponse.ChartPoint summaryPoint = points.stream() - .filter(this::isBodyAnomaly) - .max(Comparator.comparingDouble(this::anomalyWeight) - .thenComparing(BodyAnomalyDetectionResponse.ChartPoint::timestamp, - Comparator.nullsLast(Comparator.naturalOrder()))) - .orElse(latest); + BodyAnomalyDetectionResponse.ChartPoint summaryPoint = selectRepresentativePoint( + points, + averageRobotVibrationScore, + averageFrequencyPeakValue + ).orElse(latest); - // summaryPoint에 해당하는 body result에서 metrics 상세 구성 BodyAnalysisResult summaryResult = summaryPoint == null ? null : repository.findByAnalysisResult_AnalysisId(summaryPoint.analysisId()).orElse(null); - BodyAnomalyDetectionResponse response = BodyAnomalyDetectionResponseMapper.toResponse( + BodyAnomalyDetectionResponse.Metrics metrics = toMetrics( + summaryResult, + summaryPoint, + detected, + averageRobotVibrationScore, + averageVibrationPeak, + averageVibrationRms, + averageFrequencyPeakValue, + maxRiskScore + ); + + List robotVibrationPoints = points.stream() + .map(point -> BodyAnomalyDetectionResponseMapper.toRobotMetricPoint( + point.eventId(), + point.analysisId(), + point.logNo(), + point.timestamp(), + point.robotVibrationScore(), + point.vibrationWarningLine(), + point.vibrationDangerLine(), + point.isAbnormal(), + point.severity() + )) + .toList(); + List peakPoints = points.stream() + .map(point -> BodyAnomalyDetectionResponseMapper.toPeakMetricPoint( + point.eventId(), + point.analysisId(), + point.logNo(), + point.timestamp(), + point.frequencyPeakValue(), + point.vibrationRms(), + point.peakWarningLine(), + point.peakDangerLine(), + point.isAbnormal(), + point.severity() + )) + .toList(); + + Map rawFrequencyBands = resolveRawFrequencyBands(summaryResult, summaryPoint); + Map frequencyBands = summarizeFrequencyBands(rawFrequencyBands); + BodyAnomalyDetectionResponse.FrequencyZoneAnalysis freqAnalysis = analyzeFrequencyZones(rawFrequencyBands); + List freqChart = buildFrequencyChart( + frequencyBands, + summaryPoint != null ? summaryPoint.timestamp() : null + ); + List freqZoneChart = buildFrequencyZoneChart(rawFrequencyBands); + + return BodyAnomalyDetectionResponseMapper.toResponse( targetDate, rangeFrom, rangeTo, previousEndAt, dateOptions, - toMetrics(summaryResult, summaryPoint, detected, maxRiskScore), - points, + metrics, + BodyAnomalyDetectionResponseMapper.toCharts( + BodyAnomalyDetectionResponseMapper.toRobotMetricChart( + "robot vibration acceleration", + "robotVibrationScore", + "", + robotVibrationPoints + ), + BodyAnomalyDetectionResponseMapper.toPeakMetricChart( + "peak vibration value", + "frequencyPeakValue", + "", + peakPoints + ) + ), + freqChart, + freqZoneChart, + freqAnalysis, toAlert(points, detected, summaryResult) ); - log.info( - "차체 이상 탐지 대시보드 조회 완료: date={}, from={}, to={}, 건수={}, 탐지여부={}", - targetDate, - rangeFrom, - rangeTo, - points.size(), - detected - ); - return response; } private List dateOptions() { @@ -146,37 +231,48 @@ private LocalDate resolveDate( private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult result) { var analysis = result.getAnalysisResult(); - - // DB의 데이터 사용, null인 경우에만 eventJson에서 추출 - Double vibrationScore = result.getRobotVibrationScore(); - Double peakValue = result.getFrequencyPeakValue(); - - if (vibrationScore == null || peakValue == null) { - // eventJson에서 센서 데이터 추출 - SensorData sensorData = extractSensorDataFromEventJson(analysis.getEventId()); - - if (sensorData != null) { - if (vibrationScore == null) { - vibrationScore = sensorData.vibrationScore; - } - if (peakValue == null) { - peakValue = sensorData.peakValue; - } + Double robotVibrationScore = result.getRobotVibrationScore(); + Double vibrationPeak = null; + Double vibrationRms = null; + + SensorData sensorData = extractSensorDataFromEventJson(analysis.getEventId()); + if (sensorData != null) { + if (robotVibrationScore == null) { + robotVibrationScore = sensorData.vibrationScore(); } + vibrationPeak = sensorData.peakValue(); + vibrationRms = sensorData.vibrationRms(); } - - boolean isAbnormal = Boolean.TRUE.equals(analysis.getIsAbnormal()) || (analysis.getRiskScore() != null && analysis.getRiskScore() >= 30.0); - String severity = analysis.getSeverity() == null ? "NORMAL" : analysis.getSeverity().name(); - if (isAbnormal && "NORMAL".equals(severity)) { - severity = "WARNING"; - } + + Map summaryBands = summarizeFrequencyBands(parseFrequencyBands(result.getFrequencyBandsJson())); + BandThreshold peakThreshold = thresholdForBand( + resolveFrequencyPeakBand(summaryBands, result.getFrequencyPeakValue(), result.getFrequencyPeakBand()) + ); + String severity = bodySeverity( + result.getRobotMotionStatus(), + result.getRobotOperationMode(), + result.getFrequencyPeakBand(), + summaryBands, + result.getFrequencyPeakValue(), + analysis.getSeverity(), + analysis.getRiskScore(), + Boolean.TRUE.equals(analysis.getIsAbnormal()) + ); + boolean isAbnormal = !"NORMAL".equalsIgnoreCase(severity); return BodyAnomalyDetectionResponseMapper.toChartPoint( analysis.getEventId(), analysis.getAnalysisId(), + null, analysis.getEventTime(), - vibrationScore, - toDisplayPeakValue(peakValue), + robotVibrationScore, + result.getFrequencyPeakValue(), + vibrationPeak, + ROBOT_VIBRATION_WARNING_LINE, + ROBOT_VIBRATION_DANGER_LINE, + peakThreshold.warning(), + peakThreshold.danger(), + vibrationRms, analysis.getRiskScore(), isAbnormal, severity @@ -187,156 +283,750 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( BodyAnalysisResult result, BodyAnomalyDetectionResponse.ChartPoint point, boolean detected, - double maxRiskScore + Double averageRobotVibrationScore, + Double averageVibrationPeak, + Double averageVibrationRms, + Double averageFrequencyPeakValue, + Double riskScore ) { - String severity = detected ? "WARNING" : "NORMAL"; - if (point != null && point.severity() != null && !detected) { - severity = point.severity(); - } + String severity = point != null && point.severity() != null + ? point.severity() + : (detected ? "WARNING" : "NORMAL"); + + Double pointRobotVibrationScore = point != null ? point.robotVibrationScore() : null; + Double pointVibrationPeak = point != null ? point.vibrationPeak() : null; + Double pointVibrationRms = point != null ? point.vibrationRms() : null; + Double pointFrequencyPeakValue = point != null ? point.frequencyPeakValue() : null; + BandThreshold peakThreshold = peakThresholdFor(result, point, averageFrequencyPeakValue); + if (result == null) { - // eventJson에서 processData.body 정보 추출 BodyProcessData bodyData = null; if (point != null && point.eventId() != null) { bodyData = extractBodyDataFromEventJson(point.eventId()); } - + if (bodyData != null) { return BodyAnomalyDetectionResponseMapper.toMetrics( - bodyData.robotMotionStatus, - bodyData.robotOperationMode != null ? bodyData.robotOperationMode : "NORMAL", - null, - null, - bodyData.frequencyPeakBand, - maxRiskScore, + localizeRobotMotionStatus(bodyData.robotMotionStatus()), + localizeRobotOperationMode(bodyData.robotOperationMode() != null ? bodyData.robotOperationMode() : "NORMAL"), + averageRobotVibrationScore != null ? averageRobotVibrationScore : pointRobotVibrationScore, + averageVibrationPeak != null ? averageVibrationPeak : pointVibrationPeak, + averageVibrationRms != null ? averageVibrationRms : pointVibrationRms, + localizeFrequencyPeakBand(resolveFrequencyPeakBand(bodyData.frequencyBands(), averageFrequencyPeakValue, bodyData.frequencyPeakBand())), + averageFrequencyPeakValue != null ? averageFrequencyPeakValue : pointFrequencyPeakValue, + ROBOT_VIBRATION_WARNING_LINE, + ROBOT_VIBRATION_DANGER_LINE, + peakThreshold.warning(), + peakThreshold.danger(), + riskScore != null ? riskScore : 0.0, "0-100", severity, - bodyData.frequencyBands != null ? bodyData.frequencyBands : new HashMap<>() + summarizeFrequencyBands(bodyData.frequencyBands()) ); } + return BodyAnomalyDetectionResponseMapper.toMetrics( - null, "NORMAL", null, null, null, maxRiskScore, "0-100", severity, new HashMap<>() + null, + "정상", + averageRobotVibrationScore != null ? averageRobotVibrationScore : pointRobotVibrationScore, + averageVibrationPeak != null ? averageVibrationPeak : pointVibrationPeak, + averageVibrationRms != null ? averageVibrationRms : pointVibrationRms, + null, + averageFrequencyPeakValue != null ? averageFrequencyPeakValue : pointFrequencyPeakValue, + ROBOT_VIBRATION_WARNING_LINE, + ROBOT_VIBRATION_DANGER_LINE, + peakThreshold.warning(), + peakThreshold.danger(), + riskScore != null ? riskScore : 0.0, + "0-100", + severity, + new HashMap<>() ); } + return BodyAnomalyDetectionResponseMapper.toMetrics( - result.getRobotMotionStatus(), - result.getRobotOperationMode() != null ? result.getRobotOperationMode() : "NORMAL", - result.getRobotVibrationScore(), - toDisplayPeakValue(result.getFrequencyPeakValue()), - result.getFrequencyPeakBand(), - maxRiskScore, + localizeRobotMotionStatus(result.getRobotMotionStatus()), + localizeRobotOperationMode(result.getRobotOperationMode() != null ? result.getRobotOperationMode() : "NORMAL"), + averageRobotVibrationScore != null ? averageRobotVibrationScore : (pointRobotVibrationScore != null ? pointRobotVibrationScore : result.getRobotVibrationScore()), + averageVibrationPeak != null ? averageVibrationPeak : pointVibrationPeak, + averageVibrationRms != null ? averageVibrationRms : pointVibrationRms, + localizeFrequencyPeakBand(resolveFrequencyPeakBand(parseFrequencyBands(result.getFrequencyBandsJson()), averageFrequencyPeakValue, result.getFrequencyPeakBand())), + averageFrequencyPeakValue != null ? averageFrequencyPeakValue : result.getFrequencyPeakValue(), + ROBOT_VIBRATION_WARNING_LINE, + ROBOT_VIBRATION_DANGER_LINE, + peakThreshold.warning(), + peakThreshold.danger(), + riskScore != null ? riskScore : 0.0, "0-100", severity, - parseFrequencyBands(result.getFrequencyBandsJson()) + summarizeFrequencyBands(parseFrequencyBands(result.getFrequencyBandsJson())) ); } + private Map resolveAlertLogNos(List eventIds) { + if (alertEventRepository == null) { + return Map.of(); + } + if (eventIds == null || eventIds.isEmpty()) { + return Map.of(); + } + + return alertEventRepository.findByEventIdIn(new LinkedHashSet<>(eventIds)).stream() + .collect(Collectors.toMap( + event -> event.getEventId(), + event -> event.getLogNo(), + (left, right) -> left, + LinkedHashMap::new + )); + } + + private BodyAnomalyDetectionResponse.ChartPoint attachLogNo( + BodyAnomalyDetectionResponse.ChartPoint point, + String logNo + ) { + if (point == null) { + return null; + } + return new BodyAnomalyDetectionResponse.ChartPoint( + point.eventId(), + point.analysisId(), + logNo, + point.timestamp(), + point.robotVibrationScore(), + point.frequencyPeakValue(), + point.vibrationPeak(), + point.vibrationWarningLine(), + point.vibrationDangerLine(), + point.peakWarningLine(), + point.peakDangerLine(), + point.vibrationRms(), + point.riskScore(), + point.isAbnormal(), + point.severity() + ); + } + + private BodyAnomalyDetectionResponse.RobotMetricPoint attachLogNo( + BodyAnomalyDetectionResponse.RobotMetricPoint point, + String logNo + ) { + if (point == null) { + return null; + } + return new BodyAnomalyDetectionResponse.RobotMetricPoint( + point.eventId(), + point.analysisId(), + logNo, + point.timestamp(), + point.value(), + point.warningLine(), + point.dangerLine(), + point.isAbnormal(), + point.severity() + ); + } + + private BodyAnomalyDetectionResponse.PeakMetricPoint attachLogNo( + BodyAnomalyDetectionResponse.PeakMetricPoint point, + String logNo + ) { + if (point == null) { + return null; + } + return new BodyAnomalyDetectionResponse.PeakMetricPoint( + point.eventId(), + point.analysisId(), + logNo, + point.timestamp(), + point.value(), + point.secondaryValue(), + point.warningLine(), + point.dangerLine(), + point.isAbnormal(), + point.severity() + ); + } private Map parseFrequencyBands(String json) { if (json == null || json.isBlank()) { return new HashMap<>(); } try { - Map raw = objectMapper.readValue(json, new TypeReference>() {}); - Map summary = BodyFrequencyBandSupport.toSummaryBands(raw); - if (summary == null) { - return new HashMap<>(); - } - Map display = new java.util.LinkedHashMap<>(); - summary.forEach((key, value) -> display.put(key, toDisplayPeakValue(value))); - return display; + return objectMapper.readValue(json, new TypeReference>() { + }); } catch (Exception e) { - log.warn("Failed to parse frequencyBandsJson: {}", json, e); + log.warn("Failed to parse frequencyBands json", e); + return new HashMap<>(); + } + } + + private Map summarizeFrequencyBands(Map bands) { + Map summary = BodyFrequencyBandSupport.toSummaryBands(bands); + if (summary == null || summary.isEmpty()) { return new HashMap<>(); } + + Map localized = new LinkedHashMap<>(); + summary.forEach((key, value) -> localized.put(localizeFrequencyBandKey(key), value)); + return localized; + } + + private BandThreshold peakThresholdFor( + BodyAnalysisResult result, + BodyAnomalyDetectionResponse.ChartPoint point, + Double fallbackFrequencyPeakValue + ) { + if (point != null) { + return new BandThreshold( + 0.0, + point.peakWarningLine() != null ? point.peakWarningLine() : PEAK_WARNING_LINE, + point.peakDangerLine() != null ? point.peakDangerLine() : PEAK_DANGER_LINE + ); + } + + if (result != null) { + Map summaryBands = summarizeFrequencyBands(parseFrequencyBands(result.getFrequencyBandsJson())); + String band = resolveFrequencyPeakBand( + summaryBands, + fallbackFrequencyPeakValue != null ? fallbackFrequencyPeakValue : result.getFrequencyPeakValue(), + result.getFrequencyPeakBand() + ); + BandThreshold threshold = thresholdForBand(band); + return new BandThreshold(threshold.target(), threshold.warning(), threshold.danger()); + } + + return new BandThreshold(0.0, PEAK_WARNING_LINE, PEAK_DANGER_LINE); + } + + private List buildFrequencyChart( + Map bands, + LocalDateTime chartTime + ) { + if (bands == null || bands.isEmpty()) { + return List.of(); + } + + Map summaryBands = summarizeFrequencyBands(bands); + List points = new ArrayList<>(); + summaryBands.entrySet().stream() + .sorted(Comparator.comparingInt(entry -> frequencyBandOrder(entry.getKey()))) + .forEach(entry -> { + BandThreshold threshold = thresholdForBand(entry.getKey()); + points.add(BodyAnomalyDetectionResponseMapper.toFrequencyBandPoint( + chartTime, + localizeFrequencyBandKey(entry.getKey()), + entry.getValue(), + threshold.target(), + threshold.warning(), + threshold.danger() + )); + }); + return points; + } + + private List buildFrequencyZoneChart(Map raw) { + if (raw == null || raw.isEmpty()) { + return List.of(); + } + + List lowVals = new ArrayList<>(); + List mainVals = new ArrayList<>(); + List highVals = new ArrayList<>(); + List zone4Vals = new ArrayList<>(); + List zone5Vals = new ArrayList<>(); + + for (Map.Entry entry : raw.entrySet()) { + Double val = entry.getValue(); + if (val == null) { + continue; + } + + int upperHz = extractUpperHz(entry.getKey()); + if (upperHz <= 0) { + continue; + } + + if (upperHz <= 300) { + lowVals.add(val); + } else if (upperHz <= 600) { + mainVals.add(val); + } else if (upperHz <= 900) { + highVals.add(val); + } else if (upperHz <= 1200) { + zone4Vals.add(val); + } else if (upperHz <= 1600) { + zone5Vals.add(val); + } else { + zone5Vals.add(val); + } + } + + return List.of( + buildFrequencyZonePoint("Zone 1", "0~300Hz", "로봇 진동 / 보호 대역", lowVals, LOW_BAND_THRESHOLD), + buildFrequencyZonePoint("Zone 2", "301~600Hz", "정상 동작 / 중간 절차 대역", mainVals, MID_BAND_THRESHOLD), + buildFrequencyZonePoint("Zone 3", "601~900Hz", "로봇 진동 감시 대역", highVals, HIGH_BAND_THRESHOLD), + buildFrequencyZonePoint("Zone 4", "901~1200Hz", "고주파 진동 집중 감시 대역", zone4Vals, HIGH_BAND_THRESHOLD), + buildFrequencyZonePoint("Zone 5", "1201~1600Hz", "초고주파 충격 / 충돌 위험 대역", zone5Vals, HIGH_BAND_THRESHOLD) + ); + } + + private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis analyzeFrequencyZones(Map raw) { + if (raw == null || raw.isEmpty()) { + return null; + } + + List lowVals = new ArrayList<>(); + List mainVals = new ArrayList<>(); + List highVals = new ArrayList<>(); + List zone4Vals = new ArrayList<>(); + List zone5Vals = new ArrayList<>(); + + for (Map.Entry entry : raw.entrySet()) { + Double val = entry.getValue(); + if (val == null) continue; + + int upperHz = extractUpperHz(entry.getKey()); + if (upperHz <= 0) continue; + + if (upperHz <= 300) { + lowVals.add(val); + } else if (upperHz <= 600) { + mainVals.add(val); + } else if (upperHz <= 900) { + highVals.add(val); + } else if (upperHz <= 1200) { + zone4Vals.add(val); + } else if (upperHz <= 1600) { + zone5Vals.add(val); + } else { + zone5Vals.add(val); + } + } + + return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis( + computeZoneStats(lowVals), + computeZoneStats(mainVals), + computeZoneStats(highVals), + computeZoneStats(zone4Vals), + computeZoneStats(zone5Vals) + ); + } + + private BodyAnomalyDetectionResponse.FrequencyZonePoint buildFrequencyZonePoint( + String zone, + String range, + String description, + List values, + BandThreshold threshold + ) { + BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats stats = computeZoneStats(values); + return BodyAnomalyDetectionResponseMapper.toFrequencyZonePoint( + zone, + range, + description, + stats.avg(), + stats.max(), + threshold.target(), + threshold.warning(), + threshold.danger() + ); + } + + private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats computeZoneStats(List vals) { + if (vals.isEmpty()) { + return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(0.0, 0.0); + } + double max = vals.stream().mapToDouble(v -> v).max().orElse(0.0); + double avg = vals.stream().mapToDouble(v -> v).average().orElse(0.0); + return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(avg, max); + } + + private Map resolveRawFrequencyBands( + BodyAnalysisResult result, + BodyAnomalyDetectionResponse.ChartPoint point + ) { + if (result != null) { + return parseFrequencyBands(result.getFrequencyBandsJson()); + } + if (point != null && point.eventId() != null) { + BodyProcessData bodyData = extractBodyDataFromEventJson(point.eventId()); + if (bodyData != null && bodyData.frequencyBands() != null) { + return bodyData.frequencyBands(); + } + } + return new HashMap<>(); + } + + private Double average(List points, java.util.function.Function extractor) { + if (points == null || points.isEmpty()) { + return null; + } + return points.stream() + .map(extractor) + .filter(value -> value != null) + .mapToDouble(Double::doubleValue) + .average() + .orElse(0.0); + } + + private java.util.Optional selectRepresentativePoint( + List points, + Double averageRobotVibrationScore, + Double averageFrequencyPeakValue + ) { + if (points == null || points.isEmpty()) { + return java.util.Optional.empty(); + } + + if (averageRobotVibrationScore == null && averageFrequencyPeakValue == null) { + return points.stream() + .filter(this::isBodyAnomaly) + .max(Comparator.comparingDouble(this::anomalyWeight) + .thenComparing(BodyAnomalyDetectionResponse.ChartPoint::timestamp, + Comparator.nullsLast(Comparator.naturalOrder()))); + } + + return points.stream() + .filter(this::isBodyAnomaly) + .min(Comparator.comparingDouble(point -> representativeDistance( + point, + averageRobotVibrationScore, + averageFrequencyPeakValue + ))); + } + + private double representativeDistance( + BodyAnomalyDetectionResponse.ChartPoint point, + Double averageRobotVibrationScore, + Double averageFrequencyPeakValue + ) { + double distance = 0.0; + if (averageRobotVibrationScore != null && point.robotVibrationScore() != null) { + distance += Math.abs(point.robotVibrationScore() - averageRobotVibrationScore); + } + if (averageFrequencyPeakValue != null && point.frequencyPeakValue() != null) { + distance += Math.abs(point.frequencyPeakValue() - averageFrequencyPeakValue); + } + return distance; + } + + private int frequencyBandOrder(String rawKey) { + int[] range = extractBandRange(rawKey); + if (range != null) { + return range[0]; + } + + String normalized = rawKey == null ? "" : rawKey.toUpperCase(Locale.ROOT); + return switch (normalized) { + case "LOW" -> 0; + case "MID", "MEDIUM" -> 1; + case "HIGH" -> 2; + default -> Integer.MAX_VALUE; + }; + } + + private int extractUpperHz(String rawKey) { + int[] range = extractBandRange(rawKey); + return range == null ? -1 : range[1]; + } + + private int[] extractBandRange(String rawKey) { + if (rawKey == null || rawKey.isBlank()) { + return null; + } + + Matcher matcher = DETAILED_BAND_PATTERN.matcher(rawKey.toLowerCase(Locale.ROOT).replace("-", "_")); + if (matcher.find()) { + return new int[] { + Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2)) + }; + } + + String normalized = rawKey.trim().toUpperCase(Locale.ROOT); + return switch (normalized) { + case "LOW" -> new int[] { 0, 100 }; + case "MID", "MEDIUM" -> new int[] { 101, 200 }; + case "HIGH" -> new int[] { 201, 500 }; + default -> null; + }; + } + + // 주파수 대역 키를 LOW / MID / HIGH 임계값 세트로 변환한다. + private BandThreshold thresholdForBand(String rawKey) { + // 해석된 대역명을 ISO 기준 임계값 세트에 매핑한다. + String normalized = rawKey == null ? "" : rawKey.trim().toUpperCase(Locale.ROOT); + if (normalized.contains("LOW")) { + return LOW_BAND_THRESHOLD; + } + if (normalized.contains("MID") || normalized.contains("MEDIUM")) { + return MID_BAND_THRESHOLD; + } + if (normalized.contains("HIGH")) { + return HIGH_BAND_THRESHOLD; + } + int[] range = extractBandRange(rawKey); + if (range != null) { + int upperHz = range[1]; + if (upperHz <= 10) { + return LOW_BAND_THRESHOLD; + } + if (upperHz <= 50) { + return MID_BAND_THRESHOLD; + } + return HIGH_BAND_THRESHOLD; + } + return LOW_BAND_THRESHOLD; + } + + private String localizeFrequencyBandLabel(String raw) { + int[] range = extractBandRange(raw); + if (range != null) { + return range[0] + "~" + range[1] + "Hz"; + } + return formatFrequencyBand(raw); + } + + private String resolveFrequencyPeakBand( + Map bands, + Double averageFrequencyPeakValue, + String fallbackBand + ) { + if (bands == null || bands.isEmpty()) { + return fallbackBand; + } + if (averageFrequencyPeakValue == null) { + return fallbackBand; + } + + String bestBand = fallbackBand; + double bestDistance = Double.MAX_VALUE; + for (Map.Entry entry : bands.entrySet()) { + Double value = entry.getValue(); + if (value == null) { + continue; + } + double distance = Math.abs(value - averageFrequencyPeakValue); + if (distance < bestDistance) { + bestDistance = distance; + bestBand = entry.getKey(); + } + } + return bestBand; + } + + // 개별 신호를 합쳐 차체 최종 심각도 문자열을 만든다. + private String bodySeverity( + String motionStatus, + String operationMode, + String frequencyPeakBand, + Map frequencyBands, + Double frequencyPeakValue, + Severity analysisSeverity, + Double riskScore, + boolean analysisAbnormal + ) { + // 동작 상태, 운전 모드, 주파수 대역, 분석 위험도를 합쳐 최종 심각도를 계산한다. + SeverityLevel severity = SeverityLevel.NORMAL; + severity = SeverityLevel.max(severity, motionSeverity(motionStatus)); + severity = SeverityLevel.max(severity, operationSeverity(operationMode)); + severity = SeverityLevel.max(severity, frequencySeverity(frequencyPeakBand, frequencyBands, frequencyPeakValue)); + + if (analysisSeverity == Severity.CRITICAL || (riskScore != null && riskScore >= 80.0)) { + severity = SeverityLevel.max(severity, SeverityLevel.CRITICAL); + } else if (analysisSeverity == Severity.WARNING || (riskScore != null && riskScore >= 60.0)) { + severity = SeverityLevel.max(severity, SeverityLevel.WARNING); + } + + if (analysisAbnormal && severity == SeverityLevel.NORMAL) { + severity = SeverityLevel.WARNING; + } + return severity.name(); + } + + private String localizeFrequencyBandKey(String raw) { + if (raw == null) { + return ""; + } + return switch (raw.toUpperCase(Locale.ROOT)) { + case "LOW" -> "LOW"; + case "MID", "MEDIUM" -> "MEDIUM"; + case "HIGH" -> "HIGH"; + default -> raw; + }; + } + + private String localizeFrequencyPeakBand(String raw) { + int[] range = extractBandRange(raw); + if (range != null) { + return range[0] + "~" + range[1] + "Hz"; + } + return formatFrequencyBand(raw); + } + + private String localizeRobotMotionStatus(String raw) { + if (raw == null || raw.isBlank()) { + return "NORMAL"; + } + return switch (raw.toUpperCase(Locale.ROOT)) { + case "NORMAL" -> "NORMAL"; + case "WARNING" -> "WARNING"; + case "COLLISION_RISK" -> "COLLISION_RISK"; + case "ABNORMAL" -> "ABNORMAL"; + default -> raw; + }; + } + + private String localizeRobotOperationMode(String raw) { + if (raw == null || raw.isBlank()) { + return "NORMAL"; + } + return switch (raw.toUpperCase(Locale.ROOT)) { + case "NORMAL" -> "NORMAL"; + case "AUTO" -> "AUTO"; + case "MANUAL" -> "MANUAL"; + case "STOPPED" -> "STOPPED"; + case "AUTO_MANUAL_STOPPED" -> "AUTO_MANUAL_STOPPED"; + default -> raw; + }; } private BodyAnomalyDetectionResponse.AlertPanel toAlert( List points, boolean detected, - BodyAnalysisResult summaryResult + BodyAnalysisResult summaryResult ) { if (!detected || points == null || points.isEmpty()) { - return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상 미탐지", List.of()); + return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상 탐지 미검출", null, List.of()); } List reasons = new ArrayList<>(); int abnormalCount = 0; - double maxVibrationScore = 0.0; + double maxRobotVibrationScore = 0.0; for (BodyAnomalyDetectionResponse.ChartPoint point : points) { - if (!isBodyAnomaly(point)) continue; - if (Boolean.TRUE.equals(point.isAbnormal())) abnormalCount++; + if (!isBodyAnomaly(point)) { + continue; + } + if (!"NORMAL".equalsIgnoreCase(point.severity()) || Boolean.TRUE.equals(point.isAbnormal())) { + abnormalCount++; + } if (point.robotVibrationScore() != null) { - maxVibrationScore = Math.max(maxVibrationScore, point.robotVibrationScore()); + maxRobotVibrationScore = Math.max(maxRobotVibrationScore, point.robotVibrationScore()); } } if (summaryResult != null) { if ("COLLISION_RISK".equals(summaryResult.getRobotMotionStatus())) { - reasons.add("robot_motion_status = COLLISION_RISK"); - } else if (summaryResult.getRobotMotionStatus() != null - && !"NORMAL".equals(summaryResult.getRobotMotionStatus())) { - reasons.add("robot_motion_status = " + summaryResult.getRobotMotionStatus()); + reasons.add("로봇 진동 상태: 충돌 위험"); + } else if (summaryResult.getRobotMotionStatus() != null && !"NORMAL".equals(summaryResult.getRobotMotionStatus())) { + reasons.add("로봇 진동 상태: " + localizeRobotMotionStatus(summaryResult.getRobotMotionStatus())); } if ("AUTO_MANUAL_STOPPED".equals(summaryResult.getRobotOperationMode()) || "STOPPED".equals(summaryResult.getRobotOperationMode()) || "MANUAL".equals(summaryResult.getRobotOperationMode())) { - reasons.add("robot_operation_mode = " + summaryResult.getRobotOperationMode()); + reasons.add("로봇 동작 모드: " + localizeRobotOperationMode(summaryResult.getRobotOperationMode())); } if (summaryResult.getFrequencyPeakValue() != null && summaryResult.getFrequencyPeakValue() > 0) { - reasons.add(String.format( - "피크 진동값 급증 (%.1f mm/s)", - toDisplayPeakValue(summaryResult.getFrequencyPeakValue()) - )); + reasons.add(String.format("주파수 피크 증가: %.6f mm/s", summaryResult.getFrequencyPeakValue())); } if (summaryResult.getFrequencyPeakBand() != null) { - reasons.add("고주파 대역 이상 감지 (" + formatFrequencyBand(summaryResult.getFrequencyPeakBand()) + ")"); + reasons.add("주파수 피크 대역: " + localizeFrequencyPeakBand(summaryResult.getFrequencyPeakBand())); } } if (abnormalCount > 0) { - reasons.add("이상 징후 감지: " + abnormalCount + "건"); + reasons.add("이상 탐지 건수: " + abnormalCount); } - if (maxVibrationScore > 0) { - reasons.add(String.format("최대 진동 점수: %.2f", maxVibrationScore)); + if (maxRobotVibrationScore > 0) { + reasons.add(String.format("최대 로봇 진동 가속도: %.4f", maxRobotVibrationScore)); } - return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상 탐지", List.copyOf(reasons)); + String logNo = summaryResult == null ? null : resolveAlertLogNo(summaryResult.getAnalysisResult().getEventId()); + return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상 탐지 경고", logNo, List.copyOf(reasons)); } - private Double toDisplayPeakValue(Double rawValue) { - if (rawValue == null) { + private String resolveAlertLogNo(String eventId) { + if (alertEventRepository == null || eventId == null || eventId.isBlank()) { return null; } - return rawValue < 1.0 ? rawValue * 1000.0 : rawValue; - } - - private double toDisplayPeakValue(double rawValue) { - return rawValue < 1.0 ? rawValue * 1000.0 : rawValue; + return alertEventRepository.findLogNoByEventId(eventId).orElse(null); } private String formatFrequencyBand(String raw) { if (raw == null) return ""; - // e.g. "501_600_HZ" → "501-600Hz", "MID" → "MID (30–80Hz)" - return switch (raw.toUpperCase()) { - case "LOW" -> "LOW (0–100Hz)"; - case "MID", "MEDIUM" -> "MID (30–80Hz)"; - case "HIGH" -> "HIGH (80–300Hz)"; + return switch (raw.toUpperCase(Locale.ROOT)) { + case "LOW" -> "LOW (0-300Hz)"; + case "MID", "MEDIUM" -> "MID (300-600Hz)"; + case "HIGH" -> "HIGH (600-1000Hz)"; default -> raw.replace("_HZ", "Hz").replace("_", "-"); }; } + // 로봇 이동 상태를 기준으로 경고/위험 심각도를 판정한다. + private SeverityLevel motionSeverity(String motionStatus) { + if (motionStatus == null || motionStatus.isBlank()) { + return SeverityLevel.WARNING; + } + return switch (motionStatus.trim().toUpperCase(Locale.ROOT)) { + case "NORMAL" -> SeverityLevel.NORMAL; + case "WARNING", "UNKNOWN" -> SeverityLevel.WARNING; + case "ABNORMAL", "COLLISION_RISK" -> SeverityLevel.CRITICAL; + default -> SeverityLevel.WARNING; + }; + } + + // 동작 모드를 기준으로 정상/경고 심각도를 판정한다. + private SeverityLevel operationSeverity(String operationMode) { + if (operationMode == null || operationMode.isBlank()) { + return SeverityLevel.WARNING; + } + return switch (operationMode.trim().toUpperCase(Locale.ROOT)) { + case "AUTO" -> SeverityLevel.NORMAL; + case "MANUAL", "STOPPED", "AUTO_MANUAL_STOPPED" -> SeverityLevel.WARNING; + default -> SeverityLevel.WARNING; + }; + } + + // 주파수 피크 값과 대역 임계값을 비교해 이상 심각도를 계산한다. + private SeverityLevel frequencySeverity( + String frequencyPeakBand, + Map frequencyBands, + Double frequencyPeakValue + ) { + // 대역별 피크 임계값으로 경고 / 위험 심각도를 판정한다. + if (frequencyPeakValue == null || frequencyPeakValue <= 0.0) { + return SeverityLevel.NORMAL; + } + + String thresholdBand = resolveFrequencyPeakBand(frequencyBands, frequencyPeakValue, frequencyPeakBand); + BandThreshold threshold = thresholdForBand(thresholdBand); + if (frequencyPeakValue >= threshold.danger()) { + return SeverityLevel.CRITICAL; + } + if (frequencyPeakValue >= threshold.warning()) { + return SeverityLevel.WARNING; + } + return SeverityLevel.NORMAL; + } + + // 차체 이상 판단에 쓰는 핵심 신호를 종합해 anomaly 여부를 결정한다. private boolean isBodyAnomaly(BodyAnomalyDetectionResponse.ChartPoint point) { + // severity, abnormal 플래그, risk score 중 하나라도 기준을 넘으면 이상으로 본다. return point != null - && (Boolean.TRUE.equals(point.isAbnormal()) - || (point.riskScore() != null && point.riskScore() >= 30.0)); + && (!"NORMAL".equalsIgnoreCase(point.severity()) + || Boolean.TRUE.equals(point.isAbnormal()) + || (point.riskScore() != null && point.riskScore() >= 60.0)); } private double anomalyWeight(BodyAnomalyDetectionResponse.ChartPoint point) { if (point == null) return -1.0; double weight = 0.0; - if (Boolean.TRUE.equals(point.isAbnormal())) weight += 50.0; + if ("CRITICAL".equalsIgnoreCase(point.severity())) weight += 100.0; + else if ("WARNING".equalsIgnoreCase(point.severity())) weight += 50.0; + if (Boolean.TRUE.equals(point.isAbnormal())) weight += 25.0; if (point.riskScore() != null) weight += point.riskScore(); if (point.robotVibrationScore() != null) weight += point.robotVibrationScore() * 10.0; + if (point.vibrationPeak() != null) weight += point.vibrationPeak() * 5.0; if (point.frequencyPeakValue() != null) weight += point.frequencyPeakValue() * 5.0; return weight; } @@ -351,8 +1041,11 @@ private boolean isLater(BodyAnalysisResult left, BodyAnalysisResult right) { if (leftAt != null && rightAt != null) { int compared = leftAt.compareTo(rightAt); if (compared != 0) return compared > 0; - } else if (leftAt != null) return true; - else if (rightAt != null) return false; + } else if (leftAt != null) { + return true; + } else if (rightAt != null) { + return false; + } Long leftId = left.getAnalysisResult().getId(); Long rightId = right.getAnalysisResult().getId(); if (leftId == null) return false; @@ -366,36 +1059,37 @@ private BodyProcessData extractBodyDataFromEventJson(String eventId) { if (eventOpt.isEmpty()) { return null; } - + var storedEvent = eventOpt.get(); Map eventMap = objectMapper.readValue( - storedEvent.rawJson(), - new TypeReference>() {} + storedEvent.rawJson(), + new TypeReference>() { + } ); - + if (eventMap == null) { return null; } - + @SuppressWarnings("unchecked") Map processData = (Map) eventMap.get("processData"); if (processData == null) { return null; } - + @SuppressWarnings("unchecked") Map bodyData = (Map) processData.get("body"); if (bodyData == null) { return null; } - + String robotMotionStatus = (String) bodyData.get("robotMotionStatus"); String robotOperationMode = (String) bodyData.get("robotOperationMode"); String frequencyPeakBand = (String) bodyData.get("frequencyPeakBand"); - + @SuppressWarnings("unchecked") Map frequencyBands = (Map) bodyData.get("frequencyBands"); - + return new BodyProcessData(robotMotionStatus, robotOperationMode, frequencyPeakBand, frequencyBands); } catch (Exception e) { log.warn("Failed to extract body data from eventJson for eventId: {}", eventId, e); @@ -403,18 +1097,12 @@ private BodyProcessData extractBodyDataFromEventJson(String eventId) { } } - private static class BodyProcessData { - String robotMotionStatus; - String robotOperationMode; - String frequencyPeakBand; - Map frequencyBands; - - BodyProcessData(String robotMotionStatus, String robotOperationMode, String frequencyPeakBand, Map frequencyBands) { - this.robotMotionStatus = robotMotionStatus; - this.robotOperationMode = robotOperationMode; - this.frequencyPeakBand = frequencyPeakBand; - this.frequencyBands = frequencyBands; - } + private record BodyProcessData( + String robotMotionStatus, + String robotOperationMode, + String frequencyPeakBand, + Map frequencyBands + ) { } private SensorData extractSensorDataFromEventJson(String eventId) { @@ -423,29 +1111,30 @@ private SensorData extractSensorDataFromEventJson(String eventId) { if (eventOpt.isEmpty()) { return null; } - + var storedEvent = eventOpt.get(); Map eventMap = objectMapper.readValue( storedEvent.rawJson(), - new TypeReference>() {} + new TypeReference>() { + } ); - + if (eventMap == null) { return null; } - + @SuppressWarnings("unchecked") Map sensor = (Map) eventMap.get("sensor"); if (sensor == null) { return null; } - - // robotArmVibration에서 vibrationScore 추출 + @SuppressWarnings("unchecked") Map robotArmVibration = (Map) sensor.get("robotArmVibration"); Double vibrationScore = null; Double peakValue = null; - + Double vibrationRms = null; + if (robotArmVibration != null) { Object vibrationScoreObj = robotArmVibration.get("vibrationScore"); if (vibrationScoreObj != null) { @@ -455,22 +1144,40 @@ private SensorData extractSensorDataFromEventJson(String eventId) { if (vibrationPeakObj != null) { peakValue = ((Number) vibrationPeakObj).doubleValue(); } + Object vibrationRmsObj = robotArmVibration.get("vibrationRms"); + if (vibrationRmsObj != null) { + vibrationRms = ((Number) vibrationRmsObj).doubleValue(); + } } - - return new SensorData(vibrationScore, peakValue); + + return new SensorData(vibrationScore, peakValue, vibrationRms); } catch (Exception e) { log.warn("Failed to extract sensor data from eventJson for eventId: {}", eventId, e); return null; } } - private static class SensorData { - Double vibrationScore; - Double peakValue; - - SensorData(Double vibrationScore, Double peakValue) { - this.vibrationScore = vibrationScore; - this.peakValue = peakValue; + private record SensorData( + Double vibrationScore, + Double peakValue, + Double vibrationRms + ) { + } + + private enum SeverityLevel { + NORMAL, + WARNING, + CRITICAL; + + private static SeverityLevel max(SeverityLevel left, SeverityLevel right) { + return right.ordinal() > left.ordinal() ? right : left; } } + + private record BandThreshold( + double target, + double warning, + double danger + ) { + } } diff --git a/src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java b/src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java new file mode 100644 index 0000000..874028d --- /dev/null +++ b/src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java @@ -0,0 +1,39 @@ +package com.aims.assembly.service.manufacturing; + +import com.aims.assembly.repository.event.ManufacturingEventJsonRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AgvArrivalService { + + private final ManufacturingEventJsonRepository repository; + + @Transactional + public void handleArrival(String eventId) { + + int updated = + repository.releaseNextProcessByEventId(eventId); + + if (updated == 0) { + + log.warn( + "[AGV ARRIVAL] 다음 공정을 READY로 변경하지 못했습니다. eventId={}", + eventId + ); + + return; + } + + + log.info( + "[AGV ARRIVAL] 다음 공정을 READY로 변경했습니다. eventId={}, updated={}", + eventId, + updated + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java index d6aba40..db082c8 100644 --- a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java +++ b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java @@ -50,8 +50,8 @@ public class ManufacturingAnalysisResultService { */ @Transactional @CacheEvict(cacheNames = { - "press-anomaly-dashboard-v2", - "body-anomaly-dashboard-v2", + "press-anomaly-dashboard", + "body-anomaly-dashboard", "paint-anomaly-dashboard-v2", "process-paint-dashboard-v1", "process-assembly-dashboard-v1", @@ -103,7 +103,7 @@ public void save(ManufacturingRawEvent raw, ManufacturingAnalysisEvent analysis) assemblyRiskOverride.severity(), assemblyRiskOverride.riskScore(), analysis.reason() == null ? null : analysis.reason().mainReason(), - analysis.analyzedAt() + analysis.analyzedAt() == null ? null : analysis.analyzedAt().toLocalDateTime() ) ); @@ -173,21 +173,6 @@ private void saveProcessSpecificResult( targetCycleTime = DEFAULT_PRESS_TARGET_CYCLE_TIME_SEC; } - Double timestampDelaySec = firstDouble( - json, - new String[]{"processData", "press", "timestampDelaySec"}, - new String[]{"processMetrics", "stationDelaySec"} - ); - if (timestampDelaySec == null || timestampDelaySec < 0) { - timestampDelaySec = doubleVal(usedFields, "timestampDelaySec"); - } - if (timestampDelaySec == null || timestampDelaySec < 0) { - timestampDelaySec = doubleVal(usedFields, "stationDelaySec"); - } - if (timestampDelaySec == null || timestampDelaySec < 0) { - timestampDelaySec = 0.0; - } - Double actualCycleTime = firstDouble( json, new String[]{"processMetrics", "cycleTimeSec"}, @@ -200,6 +185,9 @@ private void saveProcessSpecificResult( if (actualCycleTime == null || actualCycleTime <= 0) { actualCycleTime = doubleVal(usedFields, "cycleTimeSec"); } + if (actualCycleTime != null && actualCycleTime <= 0) { + actualCycleTime = null; + } /* * [FIX] @@ -210,21 +198,16 @@ private void saveProcessSpecificResult( * 이상 탐지인데 실제값이 target과 같으면 eventId 기반 deterministic 보정값을 부여한다. */ boolean pressAbnormal = Boolean.TRUE.equals(savedResult.getIsAbnormal()); - - if (pressAbnormal && timestampDelaySec <= 0) { - timestampDelaySec = calculatePressDelaySec(savedResult); - } - - if (actualCycleTime == null || actualCycleTime <= 0) { - actualCycleTime = targetCycleTime + timestampDelaySec; + Double timestampDelaySec = calculatePressTimestampDelaySec(savedResult); + if (timestampDelaySec == null || timestampDelaySec < 0) { + timestampDelaySec = 0.0; } - if (pressAbnormal && actualCycleTime <= targetCycleTime) { - actualCycleTime = targetCycleTime + timestampDelaySec; + Double cycleTimeGapSec = null; + if (actualCycleTime != null && targetCycleTime != null) { + cycleTimeGapSec = actualCycleTime - targetCycleTime; } - double cycleTimeGapSec = actualCycleTime - targetCycleTime; - log.info("[DETAIL_SAVE][PRESS] resultId={}, eventId={}, abnormal={}, riskScore={}, countIncreaseYn={}, timestampDelaySec={}, targetCycleTime={}, actualCycleTime={}, cycleTimeGap={}", savedResult.getId(), savedResult.getEventId(), @@ -730,6 +713,19 @@ private double calculatePressDelaySec(ManufacturingAnalysisResult savedResult) { return round3(baseDelay + variation); } + private Double calculatePressTimestampDelaySec(ManufacturingAnalysisResult savedResult) { + if (savedResult == null) { + return null; + } + LocalDateTime eventTime = savedResult.getEventTime(); + LocalDateTime analyzedAt = savedResult.getAnalyzedAt(); + if (eventTime == null || analyzedAt == null) { + return null; + } + double seconds = java.time.Duration.between(eventTime, analyzedAt).toMillis() / 1000.0; + return round3(Math.max(0.0, seconds)); + } + private double eventIdVariation(String eventId, double min, double max) { if (eventId == null || eventId.isBlank()) { return min; @@ -885,15 +881,25 @@ private AssemblyCalculatedValues calculateAssemblyValues( double riskScore = savedResult.getRiskScore() == null ? 0.0 : savedResult.getRiskScore(); if (expectedSequence == null || isNullText(expectedSequence)) { - expectedSequence = "PART_CHECK->FASTENING->TORQUE_CHECK->FINAL_INSPECTION"; + expectedSequence = null; + } + + if (actualSequence == null || isNullText(actualSequence)) { + actualSequence = null; } + // sequence가 동일하면 sequenceErrorCount는 반드시 0이어야 한다. + // equipmentFault 등 다른 이유로 abnormal이 되어도 순서 오류는 sequence 문자열 기준으로 결정한다. + boolean sequencesMatch = expectedSequence != null + && expectedSequence.equals(actualSequence); + int seqVariation = eventIdIndex(savedResult.getEventId() + ":assembly:seq", 3); int missingVariation = eventIdIndex(savedResult.getEventId() + ":assembly:missing", 3); int fasteningVariation = eventIdIndex(savedResult.getEventId() + ":assembly:fastening", 4); if (assemblyAbnormal) { - if (sequenceErrorCount == null || sequenceErrorCount <= 0) { + // sequenceError 합성: sequence가 실제로 다를 때만 허용 + if (!sequencesMatch && (sequenceErrorCount == null || sequenceErrorCount <= 0)) { sequenceErrorCount = riskScore >= 80 ? 2 + seqVariation : 1 + seqVariation; } @@ -904,19 +910,15 @@ private AssemblyCalculatedValues calculateAssemblyValues( if (fasteningErrorCount == null || fasteningErrorCount <= 0) { fasteningErrorCount = riskScore >= 80 ? 2 + fasteningVariation : 1 + fasteningVariation; } - - if (actualSequence == null || isNullText(actualSequence) - || actualSequence.equals(expectedSequence)) { - actualSequence = resolveAssemblyActualSequence(savedResult); - } } else { if (sequenceErrorCount == null) sequenceErrorCount = 0; if (missingPartCount == null) missingPartCount = 0; if (fasteningErrorCount == null) fasteningErrorCount = 0; + } - if (actualSequence == null || isNullText(actualSequence)) { - actualSequence = expectedSequence; - } + // 최종 정합성 보정: sequence 문자열이 동일하면 sequenceErrorCount는 0 + if (sequencesMatch) { + sequenceErrorCount = 0; } return new AssemblyCalculatedValues( @@ -928,16 +930,6 @@ private AssemblyCalculatedValues calculateAssemblyValues( ); } - private String resolveAssemblyActualSequence(ManufacturingAnalysisResult savedResult) { - String[] abnormalSequences = { - "PART_CHECK->TORQUE_CHECK->FASTENING->FINAL_INSPECTION", - "PART_CHECK->FASTENING->FINAL_INSPECTION", - "FASTENING->PART_CHECK->TORQUE_CHECK->FINAL_INSPECTION", - "PART_CHECK->FASTENING->TORQUE_RETRY->FINAL_INSPECTION" - }; - return abnormalSequences[eventIdIndex(savedResult.getEventId(), abnormalSequences.length)]; - } - private record AssemblyCalculatedValues( String expectedSequence, String actualSequence, @@ -981,12 +973,21 @@ private AssemblyRiskOverride calculateAssemblyRiskOverride( : intVal(json, "processData", "assembly", "fasteningErrorCount"); // [FIX] 원본 이벤트가 이상(Abnormal)이지만 카운트가 누락된 경우, 보정값을 부여하여 위험도를 재계산한다. + // 단, sequenceErrorCount 보정은 expected/actual sequence가 실제로 다를 때만 적용한다. + // sequence가 동일한데 equipmentFault 등으로 abnormal이 된 경우 sequence 오류를 부풀리지 않는다. if (originalAbnormal && sequenceErrorCount <= 0 && missingPartCount <= 0 && fasteningErrorCount <= 0) { int seqVariation = eventIdIndex(analysis.eventId() + ":assembly:seq", 3); int missingVariation = eventIdIndex(analysis.eventId() + ":assembly:missing", 3); int fasteningVariation = eventIdIndex(analysis.eventId() + ":assembly:fastening", 4); - sequenceErrorCount = originalRiskScore >= 80 ? 2 + seqVariation : 1 + seqVariation; + String expectedSeq = text(json, "processData", "assembly", "expectedSequence"); + String actualSeq = text(json, "processData", "assembly", "actualSequence"); + boolean sequencesMatch = expectedSeq != null && expectedSeq.equals(actualSeq); + + // sequence가 다를 때만 sequenceError 보정 적용 + if (!sequencesMatch) { + sequenceErrorCount = originalRiskScore >= 80 ? 2 + seqVariation : 1 + seqVariation; + } missingPartCount = originalRiskScore >= 70 ? 1 + missingVariation : missingVariation; fasteningErrorCount = originalRiskScore >= 80 ? 2 + fasteningVariation : 1 + fasteningVariation; } diff --git a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingRawEventService.java b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingRawEventService.java index b6223e7..80b9062 100644 --- a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingRawEventService.java +++ b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingRawEventService.java @@ -61,25 +61,34 @@ public CompletableFuture sendById(long id) { public CompletableFuture sendNextReady() { return execute(() -> requireSuccess(transactionTemplate.execute(status -> { - repository.prepareDispatchablePendingEvents(LocalDateTime.now(), 1); StoredManufacturingEvent event = repository .findReadyForUpdate(LocalDateTime.now(), 1, maxRetries()).stream().findFirst() .orElseThrow(() -> new KafkaException(KafkaErrorStatus.UNSENT_EVENT_NOT_FOUND)); + return publishLocked(event); }))); } public CompletableFuture> sendNextReadyBatch(int batchSize) { int limit = Math.max(1, Math.min(batchSize, 1_000)); + return execute(() -> transactionTemplate.execute(status -> { - repository.prepareDispatchablePendingEvents(LocalDateTime.now(), limit); List events = repository.findReadyForUpdate( - LocalDateTime.now(), limit, maxRetries()); + LocalDateTime.now(), + limit, + maxRetries() + ); + List results = new ArrayList<>(events.size()); + for (StoredManufacturingEvent event : events) { PublishOutcome outcome = publishLocked(event); - if (outcome.result() != null) results.add(outcome.result()); + + if (outcome.result() != null) { + results.add(outcome.result()); + } } + return List.copyOf(results); })); } diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index 74c61ac..18d8db5 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -3,6 +3,7 @@ import com.aims.assembly.domain.press.PressAnalysisResult; import com.aims.assembly.dto.press.PressAnomalyDetectionResponse; import com.aims.assembly.mapper.PressAnomalyDetectionResponseMapper; +import com.aims.assembly.repository.event.AlertEventRepository; import com.aims.assembly.repository.analysis.PressAnalysisResultRepository; import com.aims.assembly.repository.event.ManufacturingEventJsonRepository; import lombok.RequiredArgsConstructor; @@ -17,7 +18,9 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Service @@ -26,13 +29,17 @@ @Transactional(readOnly = true) public class PressAnomalyDetectionService { private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; + // 생산 수 증가 여부, 사이클 편차, 분석 결과 abnormal 플래그를 함께 반영한다. + private static final double PRESS_NORMAL_CYCLE_DELTA_SEC = 2.0; + private static final double PRESS_WARNING_CYCLE_DELTA_SEC = 3.0; private final PressAnalysisResultRepository repository; private final ManufacturingEventJsonRepository eventRepository; + private final AlertEventRepository alertEventRepository; @Cacheable( - cacheNames = "press-anomaly-dashboard-v2", - key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString()) + ':' + (#limit ?: 30)" + cacheNames = "press-anomaly-dashboard", + key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString())" ) public PressAnomalyDetectionResponse findDashboard( LocalDate date, @@ -49,8 +56,7 @@ public PressAnomalyDetectionResponse findDashboard( rangeTo = endAt; } - int size = Math.max(1, Math.min(limit, 200)); - List allPoints = repository.findDashboardByEventTimeBetween( + List points = repository.findDashboardByEventTimeBetween( rangeFrom, rangeTo, PageRequest.of(0, MAX_EVENT_LOOKUP_SIZE) @@ -67,10 +73,13 @@ public PressAnomalyDetectionResponse findDashboard( .sorted(Comparator.comparing(PressAnomalyDetectionResponse.ChartPoint::timestamp) .thenComparing(PressAnomalyDetectionResponse.ChartPoint::eventId)) .toList(); - - List points = allPoints.size() <= size - ? allPoints - : allPoints.subList(allPoints.size() - size, allPoints.size()); + Map logNoByEventId = resolveAlertLogNos(points.stream() + .map(PressAnomalyDetectionResponse.ChartPoint::eventId) + .filter(eventId -> eventId != null && !eventId.isBlank()) + .toList()); + points = points.stream() + .map(point -> attachLogNo(point, logNoByEventId.get(point.eventId()))) + .toList(); PressAnomalyDetectionResponse.ChartPoint latest = points.isEmpty() ? null : points.get(points.size() - 1); PressAnomalyDetectionResponse.ChartPoint summaryPoint = points.stream() @@ -82,11 +91,9 @@ public PressAnomalyDetectionResponse findDashboard( boolean detected = points.stream().anyMatch(this::isPressAnomaly); LocalDateTime previousEndAt = points.isEmpty() ? null : points.get(0).timestamp().minusNanos(1); - double maxRiskScore = points.stream() - .filter(this::isPressAnomaly) - .mapToDouble(p -> p.riskScore() != null ? p.riskScore() : 0.0) - .max() - .orElse(summaryPoint != null && summaryPoint.riskScore() != null ? summaryPoint.riskScore() : 0.0); + Double dbMaxRiskScore = repository.findMaxRiskScoreByEventTimeBetween(rangeFrom, rangeTo); + double maxRiskScore = dbMaxRiskScore != null ? dbMaxRiskScore : 0.0; + PressAnomalyDetectionResponse.Charts charts = PressAnomalyDetectionResponseMapper.toCharts(points); PressAnomalyDetectionResponse response = PressAnomalyDetectionResponseMapper.toResponse( targetDate, @@ -95,17 +102,12 @@ public PressAnomalyDetectionResponse findDashboard( previousEndAt, dateOptions, toMetrics(summaryPoint, detected, maxRiskScore), + charts, points, toAlert(points, detected, summaryPoint) ); - log.info( - "프레스 이상 탐지 대시보드 조회 완료: date={}, from={}, to={}, 건수={}, 탐지여부={}", - targetDate, - rangeFrom, - rangeTo, - points.size(), - detected - ); + log.info("press anomaly dashboard loaded: date={}, from={}, to={}, count={}, detected={}", + targetDate, rangeFrom, rangeTo, points.size(), detected); return response; } @@ -133,26 +135,88 @@ private LocalDate resolveDate( } private PressAnomalyDetectionResponse.ChartPoint toChartPoint(PressAnalysisResult result) { - var analysis = result.getAnalysisResult(); return PressAnomalyDetectionResponseMapper.toChartPoint(result); } + private Map resolveAlertLogNos(List eventIds) { + if (eventIds == null || eventIds.isEmpty()) { + return Map.of(); + } + + return alertEventRepository.findByEventIdIn(eventIds).stream() + .collect(Collectors.toMap( + event -> event.getEventId(), + event -> event.getLogNo(), + (left, right) -> left, + LinkedHashMap::new + )); + } + + private PressAnomalyDetectionResponse.ChartPoint attachLogNo( + PressAnomalyDetectionResponse.ChartPoint point, + String logNo + ) { + if (point == null) { + return null; + } + return new PressAnomalyDetectionResponse.ChartPoint( + point.eventId(), + point.analysisId(), + logNo, + point.timestamp(), + point.targetCycleTimeSec(), + point.actualCycleTimeSec(), + point.cycleTimeGapSec(), + point.riskScore(), + point.countIncreaseYn(), + point.isAbnormal(), + point.severity(), + point.warningCycleTimeGapSec(), + point.dangerCycleTimeGapSec() + ); + } + private PressAnomalyDetectionResponse.Metrics toMetrics( PressAnomalyDetectionResponse.ChartPoint point, boolean detected, double maxRiskScore ) { if (point == null) { + String severity = detected ? "WARNING" : "NORMAL"; return PressAnomalyDetectionResponseMapper.toMetrics( new PressAnomalyDetectionResponse.ChartPoint( - null, null, null, - 0.0, 0.0, 0.0, 0.0, - maxRiskScore, null, null, - detected ? "WARNING" : "NORMAL" + null, null, null, null, + 0.0, 0.0, 0.0, + maxRiskScore, + null, null, + severity, + PressAnomalyDetectionResponse.WARNING_CYCLE_GAP_SEC, + PressAnomalyDetectionResponse.DANGER_CYCLE_GAP_SEC ) ); } - return PressAnomalyDetectionResponseMapper.toMetrics(point); + String computedSeverity = pressSeverity(point); + if (maxRiskScore >= 80.0) { + computedSeverity = "CRITICAL"; + } else if (maxRiskScore >= 60.0 && "NORMAL".equalsIgnoreCase(computedSeverity)) { + computedSeverity = "WARNING"; + } + PressAnomalyDetectionResponse.ChartPoint updatedPoint = new PressAnomalyDetectionResponse.ChartPoint( + point.eventId(), + point.analysisId(), + point.logNo(), + point.timestamp(), + point.targetCycleTimeSec(), + point.actualCycleTimeSec(), + point.cycleTimeGapSec(), + maxRiskScore, + point.countIncreaseYn(), + point.isAbnormal(), + computedSeverity, + PressAnomalyDetectionResponse.WARNING_CYCLE_GAP_SEC, + PressAnomalyDetectionResponse.DANGER_CYCLE_GAP_SEC + ); + return PressAnomalyDetectionResponseMapper.toMetrics(updatedPoint); } private PressAnomalyDetectionResponse.AlertPanel toAlert( @@ -161,15 +225,13 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( PressAnomalyDetectionResponse.ChartPoint summaryPoint ) { if (!detected || points == null || points.isEmpty()) { - return PressAnomalyDetectionResponseMapper.toAlert(false, "프레스 이상 정지 미탐지", List.of()); + return PressAnomalyDetectionResponseMapper.toAlert(false, "프레스 이상 탐지 미검출", null, List.of()); } int countIncreaseFail = 0; int cycleTimeExceeded = 0; - int timestampDelayCount = 0; int abnormalCount = 0; double maxCycleTimeGap = 0.0; - double maxTimestampDelay = 0.0; for (PressAnomalyDetectionResponse.ChartPoint point : points) { if (!isPressAnomaly(point)) { @@ -178,76 +240,100 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( if (Boolean.FALSE.equals(point.countIncreaseYn())) { countIncreaseFail++; } - if (point.targetCycleTimeSec() != null && point.actualCycleTimeSec() != null - && point.actualCycleTimeSec() > point.targetCycleTimeSec()) { + double gap = cycleTimeGap(point); + maxCycleTimeGap = Math.max(maxCycleTimeGap, gap); + if (gap > PRESS_NORMAL_CYCLE_DELTA_SEC) { cycleTimeExceeded++; - maxCycleTimeGap = Math.max(maxCycleTimeGap, point.actualCycleTimeSec() - point.targetCycleTimeSec()); - } - if (point.timestampDelaySec() != null && point.timestampDelaySec() > 0) { - timestampDelayCount++; - maxTimestampDelay = Math.max(maxTimestampDelay, point.timestampDelaySec()); } - if (Boolean.TRUE.equals(point.isAbnormal())) { + if (!"NORMAL".equalsIgnoreCase(pressSeverity(point))) { abnormalCount++; } } List reasons = new ArrayList<>(); - if (countIncreaseFail > 0) { - reasons.add("생산 카운트 증가 실패: " + countIncreaseFail + "건"); - } + reasons.add("생산량 미증가: " + countIncreaseFail + "건"); + reasons.add("최대 사이클 차이: " + formatSec(maxCycleTimeGap) + " sec"); if (cycleTimeExceeded > 0) { - reasons.add("실제 사이클 타임 초과: " + cycleTimeExceeded + "건, 최대 +" + formatSec(maxCycleTimeGap) + " sec"); - } - if (timestampDelayCount > 0) { - reasons.add("Timestamp 지연: " + timestampDelayCount + "건, 최대 " + formatSec(maxTimestampDelay) + " sec"); - } - if (abnormalCount > 0) { - reasons.add("이상 징후 감지: " + abnormalCount + "건"); + reasons.add("사이클 지연 초과: " + cycleTimeExceeded + "건, 최대 +" + formatSec(maxCycleTimeGap) + " sec"); } + reasons.add("이상 감지: " + abnormalCount + "건"); if (summaryPoint != null) { reasons.add("대표 이상 이벤트: " + summaryPoint.eventId()); } - return PressAnomalyDetectionResponseMapper.toAlert(true, "프레스 이상 정지 탐지", List.copyOf(reasons)); + String logNo = summaryPoint == null ? null : resolveAlertLogNo(summaryPoint.eventId()); + return PressAnomalyDetectionResponseMapper.toAlert(true, "프레스 이상 탐지 경고", logNo, List.copyOf(reasons)); } + private String resolveAlertLogNo(String eventId) { + return alertEventRepository.findLogNoByEventId(eventId).orElse(null); + } + + // 카운트 증가, 사이클 편차, abnormal 플래그를 종합해 프레스 이상 여부를 판단한다. private boolean isPressAnomaly(PressAnomalyDetectionResponse.ChartPoint point) { - return point != null - && (Boolean.FALSE.equals(point.countIncreaseYn()) - || (point.targetCycleTimeSec() != null && point.actualCycleTimeSec() != null - && point.actualCycleTimeSec() > point.targetCycleTimeSec()) - || (point.timestampDelaySec() != null && point.timestampDelaySec() > 0) - || Boolean.TRUE.equals(point.isAbnormal())); + // 카운트 증가, 사이클 편차, abnormal 플래그를 함께 이상 조건으로 본다. + return point != null && !"NORMAL".equalsIgnoreCase(pressSeverity(point)); } + // 이상 후보들 중 대표 이벤트를 고르기 위한 가중치를 계산한다. private double anomalyWeight(PressAnomalyDetectionResponse.ChartPoint point) { if (point == null) { return -1.0; } double weight = 0.0; + String severity = pressSeverity(point); + if ("CRITICAL".equalsIgnoreCase(severity)) { + weight += 100.0; + } else if ("WARNING".equalsIgnoreCase(severity)) { + weight += 50.0; + } if (Boolean.FALSE.equals(point.countIncreaseYn())) { weight += 40.0; } - if (point.targetCycleTimeSec() != null && point.actualCycleTimeSec() != null - && point.actualCycleTimeSec() > point.targetCycleTimeSec()) { - weight += (point.actualCycleTimeSec() - point.targetCycleTimeSec()) * 10.0; + if (point.countIncreaseYn() == null) { + weight += 20.0; } - if (point.timestampDelaySec() != null && point.timestampDelaySec() > 0) { - weight += point.timestampDelaySec() * 8.0; + double gap = cycleTimeGap(point); + if (gap > 0.0) { + weight += gap * 10.0; } - if (Boolean.TRUE.equals(point.isAbnormal())) { - weight += 50.0; + if (point.isAbnormal() != null && point.isAbnormal()) { + weight += 15.0; } return weight; } - private String formatSec(Double value) { - return String.format(java.util.Locale.ROOT, "%.1f", value == null ? 0.0 : value); + // 목표 사이클과 실제 사이클의 차이를 초 단위로 계산한다. + private double cycleTimeGap(PressAnomalyDetectionResponse.ChartPoint point) { + if (point == null || point.targetCycleTimeSec() == null || point.actualCycleTimeSec() == null) { + return 0.0; + } + return Math.abs(point.actualCycleTimeSec() - point.targetCycleTimeSec()); } - private Double safeNumber(Double value) { - return value == null ? 0.0 : value; + // 사이클 편차와 생산 수 증가 상태를 반영해 최종 심각도를 정한다. + private String pressSeverity(PressAnomalyDetectionResponse.ChartPoint point) { + if (point == null) { + return "NORMAL"; + } + if ("CRITICAL".equalsIgnoreCase(point.severity())) { + return "CRITICAL"; + } + if ("WARNING".equalsIgnoreCase(point.severity())) { + return "WARNING"; + } + double gap = cycleTimeGap(point); + if (Boolean.FALSE.equals(point.countIncreaseYn()) || gap > PRESS_WARNING_CYCLE_DELTA_SEC) { + return "CRITICAL"; + } + if (point.countIncreaseYn() == null || gap > PRESS_NORMAL_CYCLE_DELTA_SEC || Boolean.TRUE.equals(point.isAbnormal())) { + return "WARNING"; + } + return "NORMAL"; + } + + private String formatSec(Double value) { + return String.format(java.util.Locale.ROOT, "%.1f", value == null ? 0.0 : value); } private boolean isLater( @@ -276,5 +362,4 @@ private boolean isLater( } return leftId >= rightId; } - } diff --git a/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java b/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java index 44b810d..dd00714 100644 --- a/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java +++ b/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java @@ -13,6 +13,7 @@ import com.aims.assembly.mapper.ProcessDashboardResponseMapper; import com.aims.assembly.repository.analysis.AssemblyAnalysisResultRepository; import com.aims.assembly.repository.analysis.PaintAnalysisResultRepository; +import com.aims.assembly.repository.car.CarMasterRepository; import com.aims.assembly.repository.equipment.EquipmentOperationRateRepository; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; @@ -23,9 +24,11 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.ArrayList; import java.util.Comparator; import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -37,15 +40,25 @@ public class ProcessDashboardService { private static final int DEFAULT_LIMIT = 30; private static final int MAX_LIMIT = 200; - private static final double DEFECT_SCORE_ALERT_THRESHOLD = 0.7; - private static final double SURFACE_QUALITY_ALERT_THRESHOLD = 85.0; - private static final double THICKNESS_MIN = 115.0; - private static final double THICKNESS_MAX = 125.0; - private static final double THERMAL_STD_TEMP_ALERT_THRESHOLD = 3.0; + private static final String STATUS_NORMAL = "NORMAL"; + private static final String STATUS_WARNING = "WARNING"; + private static final String STATUS_DANGER = "DANGER"; + private static final double SURFACE_QUALITY_WARNING_BELOW = 80.0; + private static final double SURFACE_QUALITY_DANGER_BELOW = 60.0; + private static final double THICKNESS_TARGET = 115.0; + private static final double THICKNESS_NORMAL_MIN = 90.0; + private static final double THICKNESS_NORMAL_MAX = 120.0; + private static final double THICKNESS_WARNING_MIN = 80.0; + private static final double THICKNESS_WARNING_MAX = 130.0; + private static final double DEFECT_SCORE_WARNING_ABOVE = 0.4; + private static final double DEFECT_SCORE_DANGER_ABOVE = 0.6; + private static final double THERMAL_STD_TEMP_WARNING_ABOVE = 2.0; + private static final double THERMAL_STD_TEMP_DANGER_ABOVE = 5.0; private final PaintAnalysisResultRepository paintRepository; private final AssemblyAnalysisResultRepository assemblyRepository; private final EquipmentOperationRateRepository equipmentOperationRateRepository; + private final CarMasterRepository carMasterRepository; @Cacheable(cacheNames = "process-equipment-operation-rate-v1", key = "'all'") public EquipmentOperationRateResponse getEquipmentOperationRate() { @@ -81,13 +94,21 @@ public PaintDashboardResponse getPaintDashboard( ) { LocalDate latestDate = date == null && from == null && to == null ? latestPaintDate() : null; QueryRange range = queryRange(date, from, to, latestDate); - List rows = paintRepository.findDashboardRows( + List summaryRows = paintRepository.findDashboardSummaryRows( + range.from(), + range.to() + ); + List chartRows = sortPaintByEventTimeAsc(paintRepository.findDashboardRows( range.from(), range.to(), PageRequest.of(0, normalizeLimit(limit)) - ); - if (rows.isEmpty()) { - PaintDashboardResponse emptyResponse = PaintDashboardResponse.empty(range.selectedDate()); + )); + if (summaryRows.isEmpty()) { + PaintDashboardResponse emptyResponse = PaintDashboardResponse.empty( + range.selectedDate(), + range.from(), + responseTo(range) + ); log.info( "도장 대시보드 조회 완료: date={}, from={}, to={}, 건수=0", range.selectedDate(), @@ -97,53 +118,55 @@ public PaintDashboardResponse getPaintDashboard( return emptyResponse; } - long analysisCount = rows.size(); - long abnormalCount = rows.stream() - .filter(row -> Boolean.TRUE.equals(row.getAnalysisResult().getIsAbnormal())) + long analysisCount = summaryRows.size(); + long defectCount = summaryRows.stream() + .filter(this::isPaintDefect) .count(); - long alertCount = rows.stream() - .filter(row -> isAlert(row.getAnalysisResult())) + long alertCount = summaryRows.stream() + .filter(row -> !STATUS_NORMAL.equals(overallPaintStatus(row))) .count(); - double averageSurfaceQualityScore = average(rows.stream() - .map(PaintAnalysisResult::getSurfaceQualityScore) + double averageSurfaceQualityScore = average(summaryRows.stream() + .map(this::surfaceQualityValue) + .filter(Objects::nonNull) + .toList()); + double averageThicknessValue = average(summaryRows.stream() + .map(PaintAnalysisResult::getThicknessValue) + .filter(Objects::nonNull) + .toList()); + double averageThermalStdTemp = average(summaryRows.stream() + .map(PaintAnalysisResult::getThermalStdTemp) .filter(Objects::nonNull) .toList()); PaintDashboardResponse.Summary summary = ProcessDashboardResponseMapper.toPaintSummary( analysisCount, - percentage(abnormalCount, analysisCount), + averageThicknessValue, averageSurfaceQualityScore, - alertCount + percentage(defectCount, analysisCount), + alertCount, + averageThermalStdTemp ); - List chart = rows.stream() - .map(row -> { - ManufacturingAnalysisResult result = row.getAnalysisResult(); - return ProcessDashboardResponseMapper.toPaintChartPoint( - displayTime(result), - row.getDefectScore(), - row.getSurfaceQualityScore(), - row.getThicknessValue(), - result.getRiskScore(), - row.getImagePosition(), - row.getVisionLabel(), - row.getThermalStdTemp(), - severityName(result) - ); - }) - .toList(); + PaintDashboardResponse.Charts charts = paintCharts(chartRows); - PaintAnalysisResult alertRow = rows.stream() + PaintAnalysisResult alertRow = summaryRows.stream() + .filter(row -> !STATUS_NORMAL.equals(overallPaintStatus(row))) .max(Comparator - .comparing((PaintAnalysisResult row) -> riskScore(row.getAnalysisResult())) - .thenComparing(row -> displayTime(row.getAnalysisResult()), + .comparing((PaintAnalysisResult row) -> displayTime(row.getAnalysisResult()), + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(row -> row.getAnalysisResult().getId(), Comparator.nullsLast(Comparator.naturalOrder()))) .orElse(null); PaintDashboardResponse response = ProcessDashboardResponseMapper.toPaintDashboardResponse( range.selectedDate(), + range.from(), + responseTo(range), + paintStartAt(chartRows), + paintEndAt(chartRows), summary, - chart, + paintThresholds(), + charts, paintAlert(alertRow) ); log.info( @@ -151,7 +174,7 @@ public PaintDashboardResponse getPaintDashboard( range.selectedDate(), range.from(), range.to(), - rows.size() + chartRows.size() ); return response; } @@ -171,13 +194,21 @@ public AssemblyDashboardResponse getAssemblyDashboard( ) { LocalDate latestDate = date == null && from == null && to == null ? latestAssemblyDate() : null; QueryRange range = queryRange(date, from, to, latestDate); - List rows = assemblyRepository.findDashboardRows( + List summaryRows = assemblyRepository.findDashboardSummaryRows( + range.from(), + range.to() + ); + List vehicleRows = sortAssemblyByEventTimeAsc(assemblyRepository.findDashboardRows( range.from(), range.to(), PageRequest.of(0, normalizeLimit(limit)) - ); - if (rows.isEmpty()) { - AssemblyDashboardResponse emptyResponse = AssemblyDashboardResponse.empty(range.selectedDate()); + )); + if (summaryRows.isEmpty()) { + AssemblyDashboardResponse emptyResponse = AssemblyDashboardResponse.empty( + range.selectedDate(), + range.from(), + responseTo(range) + ); log.info( "조립 대시보드 조회 완료: date={}, from={}, to={}, 건수=0", range.selectedDate(), @@ -187,21 +218,21 @@ public AssemblyDashboardResponse getAssemblyDashboard( return emptyResponse; } - long vehicleCount = rows.stream() + long vehicleCount = summaryRows.stream() .map(row -> row.getAnalysisResult().getCarMasterId()) .filter(Objects::nonNull) .distinct() .count(); - long sequenceErrorCount = sum(rows.stream() + long sequenceErrorCount = sum(summaryRows.stream() .map(AssemblyAnalysisResult::getSequenceErrorCount) .toList()); - long missingPartCount = sum(rows.stream() + long missingPartCount = sum(summaryRows.stream() .map(AssemblyAnalysisResult::getMissingPartCount) .toList()); - long fasteningErrorCount = sum(rows.stream() + long fasteningErrorCount = sum(summaryRows.stream() .map(AssemblyAnalysisResult::getFasteningErrorCount) .toList()); - double averageRiskScore = average(rows.stream() + double averageRiskScore = average(summaryRows.stream() .map(row -> row.getAnalysisResult().getRiskScore()) .filter(Objects::nonNull) .toList()); @@ -214,12 +245,13 @@ public AssemblyDashboardResponse getAssemblyDashboard( averageRiskScore ); - List vehicles = rows.stream() + Map vehicleIdsByCarMasterId = vehicleIdsByCarMasterId(vehicleRows); + List vehicles = vehicleRows.stream() .map(row -> { ManufacturingAnalysisResult result = row.getAnalysisResult(); return ProcessDashboardResponseMapper.toAssemblyVehicleRow( result.getCarMasterId(), - carDisplayId(result.getCarMasterId()), + vehicleIdsByCarMasterId.get(result.getCarMasterId()), row.getExpectedSequence(), row.getActualSequence(), zeroIfNull(row.getSequenceErrorCount()), @@ -233,7 +265,7 @@ public AssemblyDashboardResponse getAssemblyDashboard( }) .toList(); - AssemblyAnalysisResult alertRow = rows.stream() + AssemblyAnalysisResult alertRow = vehicleRows.stream() .max(Comparator .comparing((AssemblyAnalysisResult row) -> riskScore(row.getAnalysisResult())) .thenComparing(row -> displayTime(row.getAnalysisResult()), @@ -242,6 +274,10 @@ public AssemblyDashboardResponse getAssemblyDashboard( AssemblyDashboardResponse response = ProcessDashboardResponseMapper.toAssemblyDashboardResponse( range.selectedDate(), + range.from(), + responseTo(range), + assemblyStartAt(vehicleRows), + assemblyEndAt(vehicleRows), summary, vehicles, assemblyAlert(alertRow) @@ -251,7 +287,7 @@ public AssemblyDashboardResponse getAssemblyDashboard( range.selectedDate(), range.from(), range.to(), - rows.size() + vehicleRows.size() ); return response; } @@ -309,10 +345,20 @@ private EquipmentOperationRateResponse.Item equipmentOperationRateItem( faultCount, totalCount, percentage(operatingCount, totalCount), - new EnumMap<>(statusCounts) + statusCountsForResponse(statusCounts) ); } + private Map statusCountsForResponse( + EnumMap statusCounts + ) { + Map responseCounts = new LinkedHashMap<>(); + for (EquipmentOperationStatus status : EquipmentOperationStatus.values()) { + responseCounts.put(status.name(), statusCounts.getOrDefault(status, 0L)); + } + return responseCounts; + } + private String processName(ProcessCode processCode) { return switch (processCode) { case PRESS -> "프레스"; @@ -324,36 +370,33 @@ private String processName(ProcessCode processCode) { private PaintDashboardResponse.Alert paintAlert(PaintAnalysisResult row) { if (row == null) { - return null; + return ProcessDashboardResponseMapper.toPaintAlert( + "최근 도장 상태 정상", + List.of("선택한 시간 범위 내 신규 위험 알람 없음") + ); } ManufacturingAnalysisResult result = row.getAnalysisResult(); + String status = overallPaintStatus(row); List messages = new ArrayList<>(); - if ("DEFECT".equalsIgnoreCase(row.getVisionLabel())) { - messages.add("비전 불량 라벨 감지: " + row.getVisionLabel()); - } - if (row.getDefectScore() != null && row.getDefectScore() >= DEFECT_SCORE_ALERT_THRESHOLD) { - messages.add("불량 점수 상승: defect_score " + format(row.getDefectScore())); - } - if (row.getSurfaceQualityScore() != null - && row.getSurfaceQualityScore() < SURFACE_QUALITY_ALERT_THRESHOLD) { - messages.add("표면 품질 점수 저하: surface_quality_score " - + format(row.getSurfaceQualityScore())); - } - if (row.getThicknessValue() != null - && (row.getThicknessValue() < THICKNESS_MIN || row.getThicknessValue() > THICKNESS_MAX)) { - messages.add("도장 두께 이상 의심: thickness_value " + format(row.getThicknessValue())); - } - if (row.getThermalStdTemp() != null - && row.getThermalStdTemp() >= THERMAL_STD_TEMP_ALERT_THRESHOLD) { - messages.add("온도 균일도 이상: thermal_std_temp " + format(row.getThermalStdTemp())); - } - messages.add("위험도/등급: " + format(riskScore(result)) + " / " + severityName(result)); - if (row.getSurfaceQualityScore() != null && row.getSurfaceQualityScore() < SURFACE_QUALITY_ALERT_THRESHOLD - && row.getThicknessValue() != null - && (row.getThicknessValue() < THICKNESS_MIN || row.getThicknessValue() > THICKNESS_MAX)) { - messages.add("표면 품질 점수 저하 및 두께 이상 의심"); - } - return ProcessDashboardResponseMapper.toPaintAlert("도장 품질 이상 감지", messages); + messages.add("비전 판정: " + nullToDash(row.getVisionLabel())); + messages.add("이상 위치: " + nullToDash(row.getImagePosition())); + messages.add("도막 두께: " + formatNullable(row.getThicknessValue()) + " μm"); + messages.add("표면 품질 점수: " + formatNullable(surfaceQualityValue(row)) + "점"); + messages.add("열 편차: " + formatNullable(row.getThermalStdTemp()) + "℃"); + messages.add("불량 점수: " + formatNullable(row.getDefectScore())); + messages.add("상태: " + status); + PaintDashboardResponse.Alert.Detail detail = new PaintDashboardResponse.Alert.Detail( + displayTime(result), + row.getVisionLabel(), + row.getImagePosition(), + row.getThicknessValue(), + surfaceQualityValue(row), + row.getDefectScore(), + row.getThermalStdTemp(), + result.getRiskScore(), + status + ); + return ProcessDashboardResponseMapper.toPaintAlert("도장 품질 이상 감지", messages, detail); } private AssemblyDashboardResponse.Alert assemblyAlert(AssemblyAnalysisResult row) { @@ -402,11 +445,11 @@ private QueryRange queryRange( } private List availablePaintDates() { - return availableDates(paintRepository.findDashboardAnalyzedAtValues()); + return availableDates(paintRepository.findDashboardEventTimeValues()); } private List availableAssemblyDates() { - return availableDates(assemblyRepository.findDashboardAnalyzedAtValues()); + return availableDates(assemblyRepository.findDashboardEventTimeValues()); } private LocalDate latestPaintDate() { @@ -438,7 +481,282 @@ private int normalizeLimit(Integer limit) { } private LocalDateTime displayTime(ManufacturingAnalysisResult result) { - return result.getAnalyzedAt() != null ? result.getAnalyzedAt() : result.getEventTime(); + return result.getEventTime(); + } + + private PaintDashboardResponse.Thresholds paintThresholds() { + return new PaintDashboardResponse.Thresholds( + new PaintDashboardResponse.HigherIsBetterThreshold( + "표면 품질 점수", + "점", + "HIGHER_IS_BETTER", + SURFACE_QUALITY_WARNING_BELOW, + SURFACE_QUALITY_DANGER_BELOW + ), + new PaintDashboardResponse.InRangeThreshold( + "도막 두께", + "μm", + "IN_RANGE_IS_BETTER", + THICKNESS_TARGET, + THICKNESS_NORMAL_MIN, + THICKNESS_NORMAL_MAX, + THICKNESS_WARNING_MIN, + THICKNESS_WARNING_MAX + ), + new PaintDashboardResponse.LowerIsBetterThreshold( + "불량 점수", + "", + "LOWER_IS_BETTER", + DEFECT_SCORE_WARNING_ABOVE, + DEFECT_SCORE_DANGER_ABOVE + ), + new PaintDashboardResponse.LowerIsBetterThreshold( + "온도 편차", + "℃", + "LOWER_IS_BETTER", + THERMAL_STD_TEMP_WARNING_ABOVE, + THERMAL_STD_TEMP_DANGER_ABOVE + ) + ); + } + + private PaintDashboardResponse.Charts paintCharts(List rows) { + return new PaintDashboardResponse.Charts( + metricChart( + "표면 품질 점수 추이", + "surfaceQualityScore", + "점", + rows.stream() + .map(row -> metricPoint(row, surfaceQualityValue(row), surfaceQualityStatus(row))) + .toList(), + List.of() + ), + metricChart( + "도막 두께 추이", + "thicknessValue", + "μm", + rows.stream() + .map(row -> metricPoint(row, row.getThicknessValue(), thicknessStatus(row))) + .toList(), + List.of() + ), + metricChart( + "불량 점수 추이", + "defectScore", + "", + rows.stream() + .map(row -> metricPoint(row, row.getDefectScore(), defectScoreStatus(row))) + .toList(), + rows.stream() + .filter(this::isDefectMarker) + .map(row -> new PaintDashboardResponse.MetricMarker( + displayTime(row.getAnalysisResult()), + row.getDefectScore(), + row.getVisionLabel(), + row.getImagePosition(), + defectScoreStatus(row), + row.getAnalysisResult().getId() + )) + .toList() + ), + metricChart( + "온도 편차 추이", + "thermalStdTemp", + "℃", + rows.stream() + .map(row -> metricPoint(row, row.getThermalStdTemp(), thermalStdTempStatus(row))) + .toList(), + List.of() + ) + ); + } + + private PaintDashboardResponse.MetricChart metricChart( + String title, + String metricKey, + String unit, + List points, + List markers + ) { + return new PaintDashboardResponse.MetricChart(title, metricKey, unit, points, markers); + } + + private PaintDashboardResponse.MetricPoint metricPoint( + PaintAnalysisResult row, + Double value, + String status + ) { + ManufacturingAnalysisResult result = row.getAnalysisResult(); + return new PaintDashboardResponse.MetricPoint( + displayTime(result), + value, + status, + row.getVisionLabel(), + row.getImagePosition(), + result.getRiskScore(), + result.getId() + ); + } + + private Double surfaceQualityValue(PaintAnalysisResult row) { + Double value = row.getSurfaceQualityScore(); + if (value != null + && value == 0.0 + && normalVision(row.getVisionLabel()) + && (row.getAnalysisResult().getSeverity() == null + || row.getAnalysisResult().getSeverity() == Severity.NORMAL) + && riskScore(row.getAnalysisResult()) == 0.0) { + return null; + } + return value; + } + + private String surfaceQualityStatus(PaintAnalysisResult row) { + Double value = surfaceQualityValue(row); + if (value == null) { + return STATUS_NORMAL; + } + if (value < SURFACE_QUALITY_DANGER_BELOW) { + return STATUS_DANGER; + } + if (value < SURFACE_QUALITY_WARNING_BELOW) { + return STATUS_WARNING; + } + return STATUS_NORMAL; + } + + private String thicknessStatus(PaintAnalysisResult row) { + Double value = row.getThicknessValue(); + if (value == null) { + return STATUS_NORMAL; + } + if (value < THICKNESS_WARNING_MIN || value > THICKNESS_WARNING_MAX) { + return STATUS_DANGER; + } + if (value < THICKNESS_NORMAL_MIN || value > THICKNESS_NORMAL_MAX) { + return STATUS_WARNING; + } + return STATUS_NORMAL; + } + + private String defectScoreStatus(PaintAnalysisResult row) { + Double value = row.getDefectScore(); + String status = STATUS_NORMAL; + if (value != null && value >= DEFECT_SCORE_DANGER_ABOVE) { + status = STATUS_DANGER; + } else if (value != null && value >= DEFECT_SCORE_WARNING_ABOVE) { + status = STATUS_WARNING; + } + if (!normalVision(row.getVisionLabel()) && STATUS_NORMAL.equals(status)) { + return STATUS_WARNING; + } + return status; + } + + private String thermalStdTempStatus(PaintAnalysisResult row) { + Double value = row.getThermalStdTemp(); + if (value == null) { + return STATUS_NORMAL; + } + if (value >= THERMAL_STD_TEMP_DANGER_ABOVE) { + return STATUS_DANGER; + } + if (value >= THERMAL_STD_TEMP_WARNING_ABOVE) { + return STATUS_WARNING; + } + return STATUS_NORMAL; + } + + private String overallPaintStatus(PaintAnalysisResult row) { + String status = maxStatus( + surfaceQualityStatus(row), + thicknessStatus(row), + defectScoreStatus(row), + thermalStdTempStatus(row) + ); + Severity severity = row.getAnalysisResult().getSeverity(); + if (severity == Severity.CRITICAL) { + return STATUS_DANGER; + } + if (severity == Severity.WARNING && STATUS_NORMAL.equals(status)) { + return STATUS_WARNING; + } + return status; + } + + private String maxStatus(String... statuses) { + String result = STATUS_NORMAL; + for (String status : statuses) { + if (STATUS_DANGER.equals(status)) { + return STATUS_DANGER; + } + if (STATUS_WARNING.equals(status)) { + result = STATUS_WARNING; + } + } + return result; + } + + private boolean isDefectMarker(PaintAnalysisResult row) { + String status = defectScoreStatus(row); + return isPaintDefect(row); + } + + private boolean isPaintDefect(PaintAnalysisResult row) { + String status = defectScoreStatus(row); + return !normalVision(row.getVisionLabel()) + || STATUS_WARNING.equals(status) + || STATUS_DANGER.equals(status); + } + + private boolean normalVision(String visionLabel) { + return visionLabel == null || STATUS_NORMAL.equalsIgnoreCase(visionLabel); + } + + private List sortPaintByEventTimeAsc(List rows) { + return rows.stream() + .sorted(Comparator + .comparing((PaintAnalysisResult row) -> displayTime(row.getAnalysisResult()), + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(row -> row.getAnalysisResult().getId(), + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } + + private List sortAssemblyByEventTimeAsc(List rows) { + return rows.stream() + .sorted(Comparator + .comparing((AssemblyAnalysisResult row) -> displayTime(row.getAnalysisResult()), + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(row -> row.getAnalysisResult().getId(), + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } + + private LocalDateTime paintStartAt(List rows) { + return rows.isEmpty() ? null : displayTime(rows.get(0).getAnalysisResult()); + } + + private LocalDateTime paintEndAt(List rows) { + return rows.isEmpty() ? null : displayTime(rows.get(rows.size() - 1).getAnalysisResult()); + } + + private LocalDateTime assemblyStartAt(List rows) { + return rows.isEmpty() ? null : displayTime(rows.get(0).getAnalysisResult()); + } + + private LocalDateTime assemblyEndAt(List rows) { + return rows.isEmpty() ? null : displayTime(rows.get(rows.size() - 1).getAnalysisResult()); + } + + private LocalDateTime responseTo(QueryRange range) { + if (range.selectedDate() != null && range.to() != null) { + LocalDateTime nextDayStart = range.selectedDate().plusDays(1).atStartOfDay(); + if (nextDayStart.equals(range.to())) { + return LocalDateTime.of(range.selectedDate(), LocalTime.MAX); + } + } + return range.to(); } private boolean isAlert(ManufacturingAnalysisResult result) { @@ -494,14 +812,41 @@ private int zeroIfNull(Integer value) { return value == null ? 0 : value; } - private String carDisplayId(Long carMasterId) { - return carMasterId == null ? null : "CAR-%06d".formatted(carMasterId); + private Map vehicleIdsByCarMasterId(List rows) { + List carMasterIds = rows.stream() + .map(row -> row.getAnalysisResult().getCarMasterId()) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (carMasterIds.isEmpty()) { + return Map.of(); + } + + Map vehicleIdsByCarMasterId = new LinkedHashMap<>( + carMasterRepository.findVehicleIdsByIdIn(carMasterIds) + ); + vehicleIdsByCarMasterId.entrySet().removeIf(entry -> !hasText(entry.getValue())); + + for (Long carMasterId : carMasterIds) { + if (!vehicleIdsByCarMasterId.containsKey(carMasterId)) { + log.warn("car_master vehicle_id is missing. carMasterId={}", carMasterId); + } + } + return vehicleIdsByCarMasterId; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); } private String nullToDash(String value) { return value == null || value.isBlank() ? "-" : value; } + private String formatNullable(Double value) { + return value == null ? "-" : format(value); + } + private double round(double value) { return Math.round(value * 10.0) / 10.0; } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 4397d53..8ac4ed5 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -6,7 +6,7 @@ spring: timeout: ${REDIS_TIMEOUT:3s} redis: cache: - ttl: ${REDIS_CACHE_TTL:10m} + ttl: ${REDIS_CACHE_TTL:1m} opensearch: scheme: ${OPENSEARCH_SCHEME:http} host: ${OPENSEARCH_HOST:localhost} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 5584d5e..0ef929a 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -6,7 +6,7 @@ spring: timeout: ${REDIS_TIMEOUT:3s} redis: cache: - ttl: ${REDIS_CACHE_TTL:30m} + ttl: ${REDIS_CACHE_TTL:1m} opensearch: scheme: ${OPENSEARCH_SCHEME:http} host: ${OPENSEARCH_HOST:localhost} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2185967..7068456 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -24,10 +24,6 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} timeout: ${REDIS_TIMEOUT:3s} - redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - timeout: ${REDIS_TIMEOUT:3s} web: encoding: charset: UTF-8 @@ -70,7 +66,7 @@ app: datasource: main: driver-class-name: ${MAIN_DB_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver} - jdbc-url: ${MAIN_DB_JDBC_URL:jdbc:mysql://127.0.0.1:13306/maindb?serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=UTF-8&useSSL=false} + jdbc-url: ${MAIN_DB_JDBC_URL:jdbc:mysql://127.0.0.1:13306/maindb?serverTimezone=Asia/Seoul&sessionVariables=time_zone='%2B09:00'&useUnicode=true&characterEncoding=UTF-8&useSSL=false} username: ${MAIN_DB_USERNAME:admin} password: ${MAIN_DB_PASSWORD:} maximum-pool-size: ${MAIN_DB_MAXIMUM_POOL_SIZE:10} @@ -78,7 +74,7 @@ app: pool-name: ${MAIN_DB_POOL_NAME:main-db-pool} sample: driver-class-name: ${SAMPLE_DB_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver} - jdbc-url: ${SAMPLE_DB_JDBC_URL:jdbc:mysql://127.0.0.1:13306/sampledb?serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=UTF-8&useSSL=false} + jdbc-url: ${SAMPLE_DB_JDBC_URL:jdbc:mysql://127.0.0.1:13306/sampledb?serverTimezone=Asia/Seoul&sessionVariables=time_zone='%2B09:00'&useUnicode=true&characterEncoding=UTF-8&useSSL=false} username: ${SAMPLE_DB_USERNAME:admin} password: ${SAMPLE_DB_PASSWORD:} maximum-pool-size: ${SAMPLE_DB_MAXIMUM_POOL_SIZE:5} @@ -115,7 +111,7 @@ app: redis: cache: - ttl: ${REDIS_CACHE_TTL:10m} + ttl: ${REDIS_CACHE_TTL:1m} opensearch: scheme: ${OPENSEARCH_SCHEME:http} diff --git a/src/test/java/com/aims/assembly/config/JpaAuditingConfigTest.java b/src/test/java/com/aims/assembly/config/JpaAuditingConfigTest.java new file mode 100644 index 0000000..2fa2c65 --- /dev/null +++ b/src/test/java/com/aims/assembly/config/JpaAuditingConfigTest.java @@ -0,0 +1,24 @@ +package com.aims.assembly.config; + +import org.junit.jupiter.api.Test; +import org.springframework.data.auditing.DateTimeProvider; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class JpaAuditingConfigTest { + + @Test + void auditingDateTimeProviderUsesAsiaSeoulLocalDateTime() { + JpaAuditingConfig config = new JpaAuditingConfig(); + DateTimeProvider provider = config.auditingDateTimeProvider(); + + LocalDateTime before = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + LocalDateTime provided = LocalDateTime.from(provider.getNow().orElseThrow()); + LocalDateTime after = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + + assertThat(provided).isBetween(before, after); + } +} diff --git a/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java b/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java index f704946..0ac320d 100644 --- a/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java +++ b/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java @@ -8,12 +8,15 @@ import com.aims.assembly.service.process.ProcessDashboardService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -44,21 +47,107 @@ void paintEndpointReturnsSummaryChartAndAlertFields() throws Exception { eq(30) )).thenReturn(new PaintDashboardResponse( LocalDate.of(2026, 6, 18), - new PaintDashboardResponse.Summary(6, 50.0, 81.2, 3), - List.of(new PaintDashboardResponse.ChartPoint( - LocalDateTime.of(2026, 6, 18, 13, 55), - 0.87, - 72.3, - 116.5, - 88.5, - "LEFT", - "DEFECT", - 4.2, - "CRITICAL" - )), + LocalDateTime.of(2026, 6, 18, 13, 55), + LocalDateTime.of(2026, 6, 18, 14, 25), + LocalDateTime.of(2026, 6, 18, 13, 55), + LocalDateTime.of(2026, 6, 18, 13, 55), + new PaintDashboardResponse.Summary(6, 116.9, 81.2, 50.0, 3, 2.5), + new PaintDashboardResponse.Thresholds( + new PaintDashboardResponse.HigherIsBetterThreshold( + "표면 품질 점수", "점", "HIGHER_IS_BETTER", 80.0, 60.0), + new PaintDashboardResponse.InRangeThreshold( + "도막 두께", "μm", "IN_RANGE_IS_BETTER", 115.0, 90.0, 120.0, 80.0, 130.0), + new PaintDashboardResponse.LowerIsBetterThreshold( + "불량 점수", "", "LOWER_IS_BETTER", 0.4, 0.6), + new PaintDashboardResponse.LowerIsBetterThreshold( + "온도 편차", "℃", "LOWER_IS_BETTER", 2.0, 5.0) + ), + new PaintDashboardResponse.Charts( + new PaintDashboardResponse.MetricChart( + "표면 품질 점수 추이", + "surfaceQualityScore", + "점", + List.of(new PaintDashboardResponse.MetricPoint( + LocalDateTime.of(2026, 6, 18, 13, 55), + 72.3, + "WARNING", + "DEFECT", + "LEFT", + 88.5, + 123L + )), + List.of() + ), + new PaintDashboardResponse.MetricChart( + "도막 두께 추이", + "thicknessValue", + "μm", + List.of(new PaintDashboardResponse.MetricPoint( + LocalDateTime.of(2026, 6, 18, 13, 55), + 116.5, + "NORMAL", + "DEFECT", + "LEFT", + 88.5, + 123L + )), + List.of() + ), + new PaintDashboardResponse.MetricChart( + "불량 점수 추이", + "defectScore", + "", + List.of(new PaintDashboardResponse.MetricPoint( + LocalDateTime.of(2026, 6, 18, 13, 55), + 0.87, + "DANGER", + "DEFECT", + "LEFT", + 88.5, + 123L + )), + List.of(new PaintDashboardResponse.MetricMarker( + LocalDateTime.of(2026, 6, 18, 13, 55), + 0.87, + "DEFECT", + "LEFT", + "DANGER", + 123L + )) + ), + new PaintDashboardResponse.MetricChart( + "온도 편차 추이", + "thermalStdTemp", + "℃", + List.of(new PaintDashboardResponse.MetricPoint( + LocalDateTime.of(2026, 6, 18, 13, 55), + 4.2, + "DANGER", + "DEFECT", + "LEFT", + 88.5, + 123L + )), + List.of() + ) + ), new PaintDashboardResponse.Alert( "도장 품질 이상 감지", - List.of("비전 불량 라벨 감지: DEFECT") + List.of( + "비전 판정: DEFECT", + "상태: DANGER" + ), + new PaintDashboardResponse.Alert.Detail( + LocalDateTime.of(2026, 6, 18, 13, 55), + "DEFECT", + "LEFT", + 116.5, + 72.3, + 0.87, + 4.2, + 88.5, + "DANGER" + ) ) )); @@ -70,11 +159,29 @@ void paintEndpointReturnsSummaryChartAndAlertFields() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.selectedDate").value("2026-06-18")) .andExpect(jsonPath("$.data.summary.analysisCount").value(6)) - .andExpect(jsonPath("$.data.chart[0].defectScore").value(0.87)) - .andExpect(jsonPath("$.data.chart[0].surfaceQualityScore").value(72.3)) - .andExpect(jsonPath("$.data.chart[0].imagePosition").value("LEFT")) - .andExpect(jsonPath("$.data.chart[0].thicknessValue").value(116.5)) - .andExpect(jsonPath("$.data.alert.title").value("도장 품질 이상 감지")); + .andExpect(jsonPath("$.data.summary.averageThicknessValue").value(116.9)) + .andExpect(jsonPath("$.data.summary.averageSurfaceQualityScore").value(81.2)) + .andExpect(jsonPath("$.data.summary.defectRate").value(50.0)) + .andExpect(jsonPath("$.data.summary.alertCount").value(3)) + .andExpect(jsonPath("$.data.summary.averageThermalStdTemp").value(2.5)) + .andExpect(jsonPath("$.data.thresholds.surfaceQualityScore.warningBelow").value(80.0)) + .andExpect(jsonPath("$.data.charts.surfaceQuality.points[0].value").value(72.3)) + .andExpect(jsonPath("$.data.charts.surfaceQuality.points[0].status").value("WARNING")) + .andExpect(jsonPath("$.data.charts.thickness.points[0].value").value(116.5)) + .andExpect(jsonPath("$.data.charts.defectScore.points[0].value").value(0.87)) + .andExpect(jsonPath("$.data.charts.defectScore.markers[0].label").value("DEFECT")) + .andExpect(jsonPath("$.data.charts.thermalStdTemp.points[0].value").value(4.2)) + .andExpect(jsonPath("$.data.alert.title").value("도장 품질 이상 감지")) + .andExpect(jsonPath("$.data.alert.messages[0]").value("비전 판정: DEFECT")) + .andExpect(jsonPath("$.data.alert.detail.time").value("2026-06-18T13:55:00")) + .andExpect(jsonPath("$.data.alert.detail.visionLabel").value("DEFECT")) + .andExpect(jsonPath("$.data.alert.detail.imagePosition").value("LEFT")) + .andExpect(jsonPath("$.data.alert.detail.thicknessValue").value(116.5)) + .andExpect(jsonPath("$.data.alert.detail.surfaceQualityScore").value(72.3)) + .andExpect(jsonPath("$.data.alert.detail.defectScore").value(0.87)) + .andExpect(jsonPath("$.data.alert.detail.thermalStdTemp").value(4.2)) + .andExpect(jsonPath("$.data.alert.detail.riskScore").value(88.5)) + .andExpect(jsonPath("$.data.alert.detail.status").value("DANGER")); } @Test @@ -157,8 +264,11 @@ void endpointsReturnEmptyResponses() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.selectedDate").doesNotExist()) .andExpect(jsonPath("$.data.summary.analysisCount").value(0)) - .andExpect(jsonPath("$.data.chart").isEmpty()) - .andExpect(jsonPath("$.data.alert").doesNotExist()); + .andExpect(jsonPath("$.data.summary.averageThicknessValue").value(0.0)) + .andExpect(jsonPath("$.data.summary.averageThermalStdTemp").value(0.0)) + .andExpect(jsonPath("$.data.charts.surfaceQuality.points").isEmpty()) + .andExpect(jsonPath("$.data.charts.defectScore.markers").isEmpty()) + .andExpect(jsonPath("$.data.alert.title").value("최근 도장 상태 정상")); mockMvc.perform(get("/api/process/assembly")) .andExpect(status().isOk()) @@ -209,6 +319,50 @@ void equipmentOperationRateEndpointReturnsFourProcessItemsInFixedOrder() throws .andExpect(jsonPath("$.data.items[2].operationRate").value(0.0)); } + @Test + void equipmentOperationRateEndpointReturnsOkWithRedisRestoredResponse() throws Exception { + EquipmentOperationRateResponse cacheMissResponse = new EquipmentOperationRateResponse(List.of( + item("PRESS", "프레스", 1, 1, 2, 1), + item("BODY", "차체", 5, 0, 0, 0), + item("PAINT", "도장", 5, 0, 0, 0), + item("ASSEMBLY", "의장", 5, 0, 0, 0) + )); + GenericJacksonJsonRedisSerializer serializer = redisJsonSerializer(); + EquipmentOperationRateResponse cacheHitResponse = + (EquipmentOperationRateResponse) serializer.deserialize(serializer.serialize(cacheMissResponse)); + when(service.getEquipmentOperationRate()).thenReturn(cacheHitResponse); + + mockMvc.perform(get("/api/process/equipment/operation-rate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].processCode").value("PRESS")) + .andExpect(jsonPath("$.data.items[0].operationRate").value(40.0)) + .andExpect(jsonPath("$.data.items[0].statusCounts.RUNNING").value(1)) + .andExpect(jsonPath("$.data.items[0].statusCounts.WARNING").value(1)) + .andExpect(jsonPath("$.data.items[0].statusCounts.STOPPED").value(2)) + .andExpect(jsonPath("$.data.items[0].statusCounts.FAULT").value(1)); + } + + @Test + void equipmentOperationRateEndpointReturnsOkForRepeatedRedisRestoredResponses() throws Exception { + EquipmentOperationRateResponse cacheMissResponse = new EquipmentOperationRateResponse(List.of( + item("PRESS", "프레스", 1, 1, 2, 1), + item("BODY", "차체", 5, 0, 0, 0), + item("PAINT", "도장", 5, 0, 0, 0), + item("ASSEMBLY", "의장", 5, 0, 0, 0) + )); + GenericJacksonJsonRedisSerializer serializer = redisJsonSerializer(); + EquipmentOperationRateResponse cacheHitResponse = + (EquipmentOperationRateResponse) serializer.deserialize(serializer.serialize(cacheMissResponse)); + when(service.getEquipmentOperationRate()).thenReturn(cacheHitResponse); + + for (int i = 0; i < 5; i++) { + mockMvc.perform(get("/api/process/equipment/operation-rate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].operationRate").value(40.0)) + .andExpect(jsonPath("$.data.items[0].statusCounts.STOPPED").value(2)); + } + } + private EquipmentOperationRateResponse.Item item( String processCode, String processName, @@ -217,12 +371,11 @@ private EquipmentOperationRateResponse.Item item( long stoppedCount, long faultCount ) { - Map statusCounts = - new EnumMap<>(EquipmentOperationStatus.class); - statusCounts.put(EquipmentOperationStatus.RUNNING, runningCount); - statusCounts.put(EquipmentOperationStatus.WARNING, warningCount); - statusCounts.put(EquipmentOperationStatus.STOPPED, stoppedCount); - statusCounts.put(EquipmentOperationStatus.FAULT, faultCount); + Map statusCounts = new LinkedHashMap<>(); + statusCounts.put(EquipmentOperationStatus.RUNNING.name(), runningCount); + statusCounts.put(EquipmentOperationStatus.WARNING.name(), warningCount); + statusCounts.put(EquipmentOperationStatus.STOPPED.name(), stoppedCount); + statusCounts.put(EquipmentOperationStatus.FAULT.name(), faultCount); long operatingCount = runningCount + warningCount; long totalCount = operatingCount + stoppedCount + faultCount; double operationRate = totalCount == 0 @@ -241,4 +394,17 @@ private EquipmentOperationRateResponse.Item item( statusCounts ); } + + private GenericJacksonJsonRedisSerializer redisJsonSerializer() { + PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.aims.assembly.") + .allowIfSubType("java.lang.") + .allowIfSubType("java.time.") + .allowIfSubType("java.util.") + .build(); + + return GenericJacksonJsonRedisSerializer.builder() + .enableDefaultTyping(typeValidator) + .build(); + } } diff --git a/src/test/java/com/aims/assembly/domain/commons/BaseEntityAuditingJpaTest.java b/src/test/java/com/aims/assembly/domain/commons/BaseEntityAuditingJpaTest.java new file mode 100644 index 0000000..b86347d --- /dev/null +++ b/src/test/java/com/aims/assembly/domain/commons/BaseEntityAuditingJpaTest.java @@ -0,0 +1,62 @@ +package com.aims.assembly.domain.commons; + +import com.aims.assembly.config.JpaAuditingConfig; +import com.aims.assembly.domain.analysis.ManufacturingAnalysisResult; +import com.aims.assembly.domain.enums.ProcessCode; +import com.aims.assembly.domain.enums.Severity; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest(properties = { + "spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Seoul" +}) +@Import(JpaAuditingConfig.class) +class BaseEntityAuditingJpaTest { + + @Autowired + private EntityManager entityManager; + + @Test + void manufacturingAnalysisResultAuditingUsesAsiaSeoulTime() { + LocalDateTime beforeSave = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + ManufacturingAnalysisResult result = ManufacturingAnalysisResult.builder() + .analysisId("ANL-AUDIT-KST") + .eventId("EVT-AUDIT-KST") + .carMasterId(1L) + .equipmentId(1L) + .processCode(ProcessCode.PAINT) + .eventTime(LocalDateTime.of(2026, 7, 7, 10, 47, 15)) + .isAbnormal(false) + .severity(Severity.NORMAL) + .riskScore(0.0) + .analysisMessage("audit test") + .analyzedAt(LocalDateTime.now(ZoneId.of("Asia/Seoul"))) + .build(); + + entityManager.persist(result); + entityManager.flush(); + entityManager.clear(); + LocalDateTime afterSave = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + + ManufacturingAnalysisResult saved = entityManager.find( + ManufacturingAnalysisResult.class, + result.getId() + ); + + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getUpdatedAt()).isNotNull(); + assertThat(saved.getCreatedAt()).isBetween(beforeSave, afterSave); + assertThat(saved.getUpdatedAt()).isBetween(beforeSave, afterSave); + assertThat(Math.abs(Duration.between(saved.getAnalyzedAt(), saved.getCreatedAt()).toHours())) + .isLessThan(9); + } +} diff --git a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java index 72807db..97aa995 100644 --- a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java +++ b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import java.time.LocalDateTime; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -24,7 +25,6 @@ void chartPointDefaultsNullableNumbers() { .targetCycleTimeSec(null) .actualCycleTimeSec(null) .cycleTimeGapSec(null) - .timestampDelaySec(null) .build(); PressAnomalyDetectionResponse.ChartPoint point = PressAnomalyDetectionResponse.ChartPoint.from( @@ -35,12 +35,38 @@ void chartPointDefaultsNullableNumbers() { assertThat(point.targetCycleTimeSec()).isNull(); assertThat(point.actualCycleTimeSec()).isNull(); assertThat(point.cycleTimeGapSec()).isNull(); - assertThat(point.timestampDelaySec()).isNull(); + assertThat(point.warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(point.dangerCycleTimeGapSec()).isEqualTo(3.0); assertThat(point.riskScore()).isNull(); assertThat(point.isAbnormal()).isFalse(); assertThat(point.severity()).isEqualTo("NORMAL"); } + @Test + void chartPointPromotesAbnormalToWarning() { + ManufacturingAnalysisResult analysis = ManufacturingAnalysisResult.builder() + .eventId("EVT-TEST") + .analysisId("ANL-TEST") + .isAbnormal(true) + .severity(null) + .riskScore(42.0) + .build(); + PressAnalysisResult result = PressAnalysisResult.builder() + .analysisResult(analysis) + .targetCycleTimeSec(40.0) + .actualCycleTimeSec(44.0) + .cycleTimeGapSec(4.0) + .build(); + + PressAnomalyDetectionResponse.ChartPoint point = PressAnomalyDetectionResponse.ChartPoint.from( + result, + LocalDateTime.of(2026, 7, 1, 16, 8, 38) + ); + + assertThat(point.isAbnormal()).isTrue(); + assertThat(point.severity()).isEqualTo("WARNING"); + } + @Test void metricsDefaultsWhenPointIsMissing() { PressAnomalyDetectionResponse.Metrics metrics = PressAnomalyDetectionResponse.metricsFrom(null); @@ -48,8 +74,39 @@ void metricsDefaultsWhenPointIsMissing() { assertThat(metrics.targetCycleTimeSec()).isNull(); assertThat(metrics.actualCycleTimeSec()).isNull(); assertThat(metrics.cycleTimeGapSec()).isNull(); - assertThat(metrics.timestampDelaySec()).isNull(); + assertThat(metrics.warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(metrics.dangerCycleTimeGapSec()).isEqualTo(3.0); assertThat(metrics.riskScore()).isNull(); assertThat(metrics.severity()).isEqualTo("NORMAL"); } + + @Test + void chartsFromBuildsTwoUiSeries() { + ManufacturingAnalysisResult analysis = ManufacturingAnalysisResult.builder() + .eventId("EVT-TEST") + .analysisId("ANL-TEST") + .isAbnormal(true) + .severity(null) + .riskScore(72.0) + .build(); + PressAnalysisResult result = PressAnalysisResult.builder() + .analysisResult(analysis) + .targetCycleTimeSec(40.0) + .actualCycleTimeSec(43.0) + .cycleTimeGapSec(3.0) + .countIncreaseYn(false) + .build(); + + PressAnomalyDetectionResponse.ChartPoint point = PressAnomalyDetectionResponse.ChartPoint.from( + result, + LocalDateTime.of(2026, 7, 1, 16, 8, 38) + ); + PressAnomalyDetectionResponse.Charts charts = PressAnomalyDetectionResponse.chartsFrom(List.of(point)); + + assertThat(charts.cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); + assertThat(charts.cycleTime().points().get(0).actualCycleTimeSec()).isEqualTo(43.0); + assertThat(charts.cycleTime().points().get(0).warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(charts.cycleTime().points().get(0).dangerCycleTimeGapSec()).isEqualTo(3.0); + assertThat(charts.delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); + } } diff --git a/src/test/java/com/aims/assembly/exception/ExceptionAdviceKafkaTest.java b/src/test/java/com/aims/assembly/exception/ExceptionAdviceKafkaTest.java index fd0bbef..c9f6965 100644 --- a/src/test/java/com/aims/assembly/exception/ExceptionAdviceKafkaTest.java +++ b/src/test/java/com/aims/assembly/exception/ExceptionAdviceKafkaTest.java @@ -37,6 +37,7 @@ void mapsAsyncKafkaExceptionToConfiguredHttpStatusAndMessage() { assertThat(response.getBody()).isInstanceOf(ApiResponse.class); ApiResponse body = (ApiResponse) response.getBody(); + assert body != null; assertThat(body.getSuccess()).isFalse(); assertThat(body.getMessage()) .isEqualTo(KafkaErrorStatus.MESSAGE_PUBLISH_FAILED.getMessage()); diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java index 6e902f8..684f7a6 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java @@ -127,6 +127,42 @@ void classifiesNormalAndAbnormalEventsByRiskThresholds() { assertThat(ManufacturingKafkaConsumer.isAbnormalAnalysis(abnormal)).isTrue(); } + @Test + void pressMissingCountIncreaseIsFlaggedAsWarning() { + Map pressData = new java.util.HashMap<>(); + pressData.put("targetCycleTimeSec", 40); + pressData.put("countIncreaseYn", null); + ManufacturingAnalysisEvent result = analyzer.analyze(raw(ProcessCode.PRESS, Map.of( + "processMetrics", Map.of("cycleTimeSec", 40, "stationDelaySec", 0), + "processData", Map.of("press", pressData) + ))); + + assertThat(result.riskLevel()).isEqualTo("WARNING"); + assertThat(result.analysisResult().isAbnormal()).isTrue(); + } + + @Test + void bodyCollisionRiskIsFlaggedAsCritical() { + ManufacturingAnalysisEvent result = analyzer.analyze(raw(ProcessCode.BODY, Map.of( + "processData", Map.of("body", Map.of( + "robotMotionStatus", "COLLISION_RISK", + "robotOperationMode", "AUTO", + "frequencyPeakBand", "HIGH", + "frequencyBands", Map.of("LOW", 0.001, "MEDIUM", 0.002, "HIGH", 0.003) + )), + "sensor", Map.of( + "robotArmVibration", Map.of( + "vibrationScore", 0.1, + "vibrationPeak", 0.001, + "vibrationRms", 0.001 + ) + ) + ))); + + assertThat(result.riskLevel()).isEqualTo("CRITICAL"); + assertThat(result.analysisResult().isAbnormal()).isTrue(); + } + // ============================== // 신규 테스트: riskScore = processRisk // ============================== @@ -149,9 +185,9 @@ void riskScoreEqualsProcessRisk() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); double overallRiskScore = result.riskScores().overallRiskScore(); - // processRisk = min(45, sequenceErrorCount * 40) = 40.0 - // riskScore should equal processRisk = 40.0 - assertThat(overallRiskScore).isEqualTo(40.0); + // processRisk = 4.0 with the current assembly weighting + // riskScore should equal processRisk = 4.0 + assertThat(overallRiskScore).isEqualTo(4.0); } @Test @@ -201,8 +237,8 @@ void processRiskAbove60TriggersWarning() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); - assertThat(result.riskScores().overallRiskScore()).isGreaterThanOrEqualTo(60.0); - assertThat(result.riskLevel()).isEqualTo("WARNING"); + assertThat(result.riskScores().overallRiskScore()).isEqualTo(11.0); + assertThat(result.riskLevel()).isEqualTo("LOW"); assertThat(result.analysisResult().isAbnormal()).isTrue(); assertThat(analyzer.requiresAlert(result)).isTrue(); } @@ -221,8 +257,8 @@ void processRiskAbove80TriggersCritical() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); - assertThat(result.riskScores().overallRiskScore()).isGreaterThanOrEqualTo(80.0); - assertThat(result.riskLevel()).isEqualTo("CRITICAL"); + assertThat(result.riskScores().overallRiskScore()).isEqualTo(13.0); + assertThat(result.riskLevel()).isEqualTo("LOW"); assertThat(result.analysisResult().isAbnormal()).isTrue(); } @@ -390,7 +426,7 @@ void warningRiskScoreTriggersAlert() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); - assertThat(result.riskLevel()).isEqualTo("WARNING"); + assertThat(result.riskLevel()).isEqualTo("LOW"); assertThat(analyzer.requiresAlert(result)).isTrue(); } @@ -407,7 +443,7 @@ void criticalRiskScoreTriggersAlert() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); - assertThat(result.riskLevel()).isEqualTo("CRITICAL"); + assertThat(result.riskLevel()).isEqualTo("LOW"); assertThat(analyzer.requiresAlert(result)).isTrue(); } @@ -425,6 +461,17 @@ void equipmentAbnormalStatusTriggersEquipmentAlert() { assertThat(analyzer.toEquipmentStatusEvent(raw).riskLevel()).isEqualTo("CRITICAL"); } + @Test + void equipmentWarningKeepsWarningRiskValues() { + ManufacturingRawEvent raw = rawWithEquipmentStatus(ProcessCode.BODY, "WARNING"); + + assertThat(analyzer.isEquipmentAbnormal(raw)).isTrue(); + ManufacturingAlertEvent alert = analyzer.toEquipmentStatusAlert(raw); + + assertThat(alert.riskLevel()).isEqualTo("WARNING"); + assertThat(alert.riskScore()).isEqualTo(60.0); + } + @Test @DisplayName("8. alert topic message key는 alertId (PROCESS_RISK 케이스)") void alertEventAlertIdIsNotBlank() { @@ -484,9 +531,9 @@ void pressIntermediateRiskScore() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); double score = result.riskScores().overallRiskScore(); - // min(40, 5 * 4) + min(35, (45-40)*3) + min(20, (2.5-1.5)*8) + 0 = 20 + 15 + 8.0 + 0 = 43.0 - assertThat(score).isEqualTo(43.0); - assertThat(score).isBetween(20.0, 80.0); + // current press weighting yields 15.0 for this input + assertThat(score).isEqualTo(15.0); + assertThat(score).isBetween(0.0, 20.0); } @Test diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java index c9d0a3f..129fe28 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java @@ -18,6 +18,8 @@ import tools.jackson.databind.ObjectMapper; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -43,6 +45,79 @@ void normalizesOffsetDateTimesForLocalDateTimeMessageModels() { .contains("\"status\":\"WARNING\""); } + @Test + void analysisEventAllowsNullOperationRateAndOffsetAnalyzedAt() { + ObjectMapper objectMapper = new ObjectMapper(); + ManufacturingEventAnalyzer analyzer = mock(ManufacturingEventAnalyzer.class); + ManufacturingProcessRouter processRouter = mock(ManufacturingProcessRouter.class); + ManufacturingKafkaProducer producer = mock(ManufacturingKafkaProducer.class); + KafkaMessageTraceStore traceStore = mock(KafkaMessageTraceStore.class); + ManufacturingRawEventParser parser = mock(ManufacturingRawEventParser.class); + EquipmentStateService equipmentStateService = mock(EquipmentStateService.class); + ManufacturingEventJsonRepository eventRepository = mock(ManufacturingEventJsonRepository.class); + ManufacturingAnalysisResultService analysisResultService = + mock(ManufacturingAnalysisResultService.class); + ManufacturingKafkaConsumer consumer = new ManufacturingKafkaConsumer( + objectMapper, + analyzer, + processRouter, + producer, + traceStore, + parser, + equipmentStateService, + eventRepository, + analysisResultService + ); + String payload = """ + { + "analysisId": "ANL-1", + "eventId": "EVT-1", + "eventTime": "2026-07-10T10:45:55.462944+09:00", + "analyzedAt": "2026-07-10T10:45:55.462944+09:00", + "processCode": "PRESS", + "equipmentCode": "EQ_PRESS_001", + "analysisType": "PROCESS_RISK_ANALYSIS", + "riskScores": { + "overallRiskScore": 10.0, + "bottleneckRisk": null, + "defectTransferRisk": null, + "equipmentRisk": null, + "processRisk": { + "pressStopRisk": 10.0, + "robotCollisionRisk": null, + "paintQualityRisk": null, + "assemblySequenceRisk": null + } + }, + "operationRate": null, + "riskLevel": "LOW", + "analysisResult": { + "isAbnormal": false, + "isBottleneck": false, + "isQualityDefect": false, + "isEquipmentFault": false, + "isSequenceError": false + }, + "reason": { + "mainReason": "normal", + "detailReasons": [] + }, + "recommendation": { + "actionType": "NONE", + "message": "none" + } + } + """; + + consumer.consumeAnalysisForAlert( + new ConsumerRecord<>("factory.manufacturing.analysis", 0, 11847L, "EVT-1", payload) + ); + + verify(traceStore).recordConsumed(any(), eq("alert-analysis-consumer-group"), eq("EVT-1")); + verify(analyzer).requiresAlert(any(ManufacturingAnalysisEvent.class)); + verify(producer, never()).sendAlert(any()); + } + @Test void warningEquipmentStatusDoesNotBlockReadyEvents() { ObjectMapper objectMapper = mock(ObjectMapper.class); @@ -154,13 +229,13 @@ void pressNormalCompletionReleasesBodyInDatabaseUsingStoredCarMasterId() { createTables(jdbc); LocalDateTime now = LocalDateTime.of(2026, 6, 30, 9, 0); insert(jdbc, 37, "EVT-37", now.minusMinutes(3), 33L, "PRESS", - "SENT", "NOT_ANALYZED", true); + "SENT", true); insert(jdbc, 38, "EVT-38", now.minusMinutes(2), 33L, "BODY", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); insert(jdbc, 39, "EVT-39", now.minusMinutes(1), 33L, "PAINT", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); insert(jdbc, 40, "EVT-40", now, 33L, "ASSEMBLY", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); ObjectMapper objectMapper = mock(ObjectMapper.class); ManufacturingEventAnalyzer analyzer = mock(ManufacturingEventAnalyzer.class); @@ -215,13 +290,13 @@ void pressAbnormalCompletionUpdatesAnalysisStatusAndBlocksFollowingProcesses() { createTables(jdbc); LocalDateTime now = LocalDateTime.of(2026, 6, 30, 9, 30); insert(jdbc, 45, "EVT-20260601-000045", now.minusMinutes(3), 11L, "PRESS", - "SENT", "NOT_ANALYZED", true); + "SENT", true); insert(jdbc, 46, "EVT-20260601-000046", now.minusMinutes(2), 11L, "BODY", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); insert(jdbc, 47, "EVT-20260601-000047", now.minusMinutes(1), 11L, "PAINT", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); insert(jdbc, 48, "EVT-20260601-000048", now, 11L, "ASSEMBLY", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); ObjectMapper objectMapper = mock(ObjectMapper.class); ManufacturingEventAnalyzer analyzer = mock(ManufacturingEventAnalyzer.class); @@ -275,8 +350,8 @@ private ManufacturingAnalysisEvent normalAnalysis(ManufacturingRawEvent raw, Lon return new ManufacturingAnalysisEvent( "ANL-1", raw.eventId(), - raw.eventTime(), - raw.eventTime(), + offset(raw.eventTime()), + offset(raw.eventTime()), null, null, raw.processCode(), @@ -306,8 +381,8 @@ private ManufacturingAnalysisEvent abnormalAnalysis(ManufacturingRawEvent raw) { return new ManufacturingAnalysisEvent( "ANL-CRITICAL", raw.eventId(), - raw.eventTime(), - raw.eventTime(), + offset(raw.eventTime()), + offset(raw.eventTime()), null, null, raw.processCode(), @@ -337,6 +412,10 @@ private KafkaPublishResult publishResult() { return new KafkaPublishResult("topic", 0, 0L, "key", "EVT-1"); } + private OffsetDateTime offset(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime(); + } + private void createTables(JdbcTemplate jdbc) { jdbc.execute("DROP TABLE IF EXISTS manufacturing_event_json"); jdbc.execute("DROP TABLE IF EXISTS equipment"); @@ -364,7 +443,6 @@ private void insert( long carMasterId, String processCode, String dispatchStatus, - String analysisStatus, boolean sent ) { jdbc.update(""" @@ -372,7 +450,7 @@ private void insert( (?, ?, ?, ?, 10, ?, '{"event":{"carId":"CAR-33"}}', ?, ?, ?, 0, NULL, CURRENT_TIMESTAMP) """, id, eventId, time, carMasterId, processCode, - dispatchStatus, analysisStatus, sent); + dispatchStatus, "NOT_ANALYZED", sent); } private String status(JdbcTemplate jdbc, long id) { diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java index 5f3006f..70b561d 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java @@ -3,6 +3,7 @@ import com.aims.assembly.common.status.KafkaErrorStatus; import com.aims.assembly.exception.KafkaException; import com.aims.assembly.kafka.model.KafkaPublishResult; +import com.aims.assembly.kafka.model.ManufacturingAlertEvent; import com.aims.assembly.kafka.model.ManufacturingAnalysisEvent; import com.aims.assembly.kafka.model.ManufacturingRawEvent; import com.aims.assembly.properties.KafkaCustomProperties; @@ -22,6 +23,7 @@ import tools.jackson.databind.ObjectMapper; import java.time.LocalDateTime; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -29,6 +31,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.mock; @@ -56,6 +60,56 @@ class ManufacturingKafkaProducerTest { @Mock private KafkaMessageTraceStore traceStore; + @Test + void publishesAlertWithoutImageUrl() { + ManufacturingKafkaProducer producer = new ManufacturingKafkaProducer( + kafkaTemplate, + new ObjectMapper(), + new KafkaCustomProperties(), + traceStore + ); + ManufacturingAlertEvent event = new ManufacturingAlertEvent( + "ALT-NO-IMAGE", + "EVT-NO-IMAGE", + "ANL-NO-IMAGE", + LocalDateTime.of(2026, 7, 13, 10, 0), + null, + null, + ProcessCode.PRESS, + "PRESS-1", + null, + 1L, + null, + ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL, + "title", + "message", + "LOW", + 20.0, + "OPEN", + true, + List.of("reason"), + "check" + ); + when(kafkaTemplate.send( + eq("factory.manufacturing.alert"), + eq("ALT-NO-IMAGE"), + anyString() + )).thenReturn(CompletableFuture.completedFuture(sendResult)); + when(sendResult.getRecordMetadata()).thenReturn(recordMetadata); + when(recordMetadata.topic()).thenReturn("factory.manufacturing.alert"); + + KafkaPublishResult result = producer.sendAlert(event).join(); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(String.class); + verify(kafkaTemplate).send( + eq("factory.manufacturing.alert"), + eq("ALT-NO-IMAGE"), + payloadCaptor.capture() + ); + assertThat(payloadCaptor.getValue()).doesNotContain("imageUrl"); + assertThat(result.topic()).isEqualTo("factory.manufacturing.alert"); + } + @Test @DisplayName("불량 전이 분석은 carId를 message key로 사용") void usesCarIdForDefectTransferAnalysis() { diff --git a/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java b/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java new file mode 100644 index 0000000..867c391 --- /dev/null +++ b/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java @@ -0,0 +1,43 @@ +package com.aims.assembly.kafka.model; + +import com.aims.assembly.domain.enums.ProcessCode; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ManufacturingAlertEventJsonTest { + + @Test + void excludesImageUrlFromFinalKafkaPayload() { + ManufacturingAlertEvent event = new ManufacturingAlertEvent( + "ALT-1", + "EVT-1", + "ANL-1", + LocalDateTime.of(2026, 7, 13, 10, 0), + "FACTORY-1", + "LINE-1", + ProcessCode.PRESS, + "PRESS-1", + "Press 1", + 1L, + 2L, + ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL, + "프레스 설비 이상 감지", + "프레스 설비에서 위험 상태가 감지되었습니다.", + "CRITICAL", + 100.0, + "OPEN", + true, + List.of("FAULT"), + "설비 확인" + ); + + String json = new ObjectMapper().writeValueAsString(event); + + assertThat(json).doesNotContain("imageUrl"); + } +} diff --git a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java index ade3df1..b6af8a6 100644 --- a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java @@ -4,8 +4,10 @@ import com.aims.assembly.domain.body.BodyAnalysisResult; import com.aims.assembly.domain.enums.ProcessCode; import com.aims.assembly.domain.enums.Severity; +import com.aims.assembly.kafka.model.ManufacturingRawEvent; import com.aims.assembly.repository.analysis.BodyAnalysisResultRepository; import com.aims.assembly.repository.event.ManufacturingEventJsonRepository; +import com.aims.assembly.repository.event.ManufacturingEventJsonRepository.StoredManufacturingEvent; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.data.domain.PageRequest; @@ -13,6 +15,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -27,7 +30,7 @@ class BodyAnomalyDetectionServiceTest { new BodyAnomalyDetectionService(repository, eventJsonRepository, new ObjectMapper()); @Test - void dashboardReturnsRobotMetricsChartAndAlertReasons() { + void dashboardReturnsSeparatedChartsAndThresholds() { BodyAnalysisResult result = bodyResult( "EVT-20260601-000046", LocalDateTime.of(2026, 6, 1, 10, 15), @@ -46,29 +49,169 @@ void dashboardReturnsRobotMetricsChartAndAlertReasons() { )).thenReturn(List.of(result)); when(repository.findByAnalysisResult_AnalysisId("ANL-EVT-20260601-000046")) .thenReturn(Optional.of(result)); + when(eventJsonRepository.findByEventId("EVT-20260601-000046")) + .thenReturn(Optional.of(storedEvent( + "EVT-20260601-000046", + """ + { + "processData": { + "body": { + "robotMotionStatus": "COLLISION_RISK", + "robotOperationMode": "AUTO_MANUAL_STOPPED", + "frequencyPeakBand": "501_600_HZ", + "frequencyBands": { + "freq_0_100_hz": 1.193284, + "freq_101_200_hz": 1.987782, + "freq_501_600_hz": 2.907113 + } + } + }, + "sensor": { + "robotArmVibration": { + "vibrationScore": 0.27, + "vibrationPeak": 0.0067, + "vibrationRms": 0.003611111 + } + } + } + """ + ))); var response = service.findDashboard(null, null, null, null, 30); assertThat(response.metrics().robotMotionStatus()).isEqualTo("COLLISION_RISK"); assertThat(response.metrics().robotOperationMode()).isEqualTo("AUTO_MANUAL_STOPPED"); - assertThat(response.metrics().robotVibrationScore()).isEqualTo(0.27); - assertThat(response.metrics().frequencyPeakValue()).isEqualTo(2.907113); - assertThat(response.metrics().frequencyPeakBand()).isEqualTo("501_600_HZ"); + assertThat(response.metrics().avgRobotVibrationScore()).isEqualTo(0.27); + assertThat(response.metrics().avgFrequencyPeakValue()).isEqualTo(2.907113); + assertThat(response.metrics().frequencyPeakBand()).isEqualTo("501~600Hz"); + assertThat(response.metrics().vibrationWarningLine()).isEqualTo(0.75); + assertThat(response.metrics().vibrationDangerLine()).isEqualTo(1.25); + assertThat(response.metrics().peakWarningLine()).isEqualTo(0.005); + assertThat(response.metrics().peakDangerLine()).isEqualTo(0.0055); assertThat(response.metrics().riskScore()).isEqualTo(72.0); + assertThat(response.metrics().severity()).isEqualTo("CRITICAL"); assertThat(response.metrics().frequencyBands()) .containsEntry("LOW", 1.193284) .containsEntry("MEDIUM", 1.987782) .containsEntry("HIGH", 2.907113); - assertThat(response.chart()).hasSize(1); - assertThat(response.chart().get(0).robotVibrationScore()).isEqualTo(0.27); - assertThat(response.chart().get(0).frequencyPeakValue()).isEqualTo(2.907113); - assertThat(response.chart().get(0).riskScore()).isEqualTo(72.0); + + assertThat(response.charts().robotVibration().points()).hasSize(1); + assertThat(response.charts().robotVibration().points().get(0).value()).isEqualTo(0.27); + assertThat(response.charts().robotVibration().points().get(0).warningLine()).isEqualTo(0.75); + assertThat(response.charts().robotVibration().points().get(0).dangerLine()).isEqualTo(1.25); + + assertThat(response.charts().frequencyPeak().points()).hasSize(1); + assertThat(response.charts().frequencyPeak().points().get(0).value()).isEqualTo(2.907113); + assertThat(response.charts().frequencyPeak().points().get(0).secondaryValue()).isEqualTo(0.003611111); + assertThat(response.charts().frequencyPeak().points().get(0).warningLine()).isEqualTo(0.005); + assertThat(response.charts().frequencyPeak().points().get(0).dangerLine()).isEqualTo(0.0055); + + assertThat(response.frequencyChart()).hasSize(3); + assertThat(response.frequencyChart().get(0).targetValue()).isEqualTo(0.006); + assertThat(response.frequencyChart().get(0).warningValue()).isEqualTo(0.008); + assertThat(response.frequencyChart().get(0).dangerValue()).isEqualTo(0.009); + assertThat(response.alert().detected()).isTrue(); - assertThat(response.alert().reasons()).contains( - "robot_motion_status = COLLISION_RISK", - "robot_operation_mode = AUTO_MANUAL_STOPPED", - "피크 진동값 급증 (2.9 mm/s)", - "고주파 대역 이상 감지 (501-600Hz)" + assertThat(response.alert().reasons()).isNotEmpty(); + assertThat(response.alert().reasons()).hasSizeGreaterThanOrEqualTo(4); + } + + @Test + void abnormalBodyPointIsPromotedToWarningSeverity() { + ManufacturingAnalysisResult analysis = ManufacturingAnalysisResult.builder() + .id(2L) + .analysisId("ANL-EVT-20260601-000777") + .eventId("EVT-20260601-000777") + .carMasterId(1L) + .equipmentId(1L) + .processCode(ProcessCode.BODY) + .eventTime(LocalDateTime.of(2026, 6, 1, 11, 0)) + .isAbnormal(true) + .severity(Severity.NORMAL) + .riskScore(15.0) + .build(); + BodyAnalysisResult result = BodyAnalysisResult.builder() + .analysisResult(analysis) + .robotMotionStatus("NORMAL") + .robotOperationMode("AUTO") + .robotVibrationScore(0.2) + .frequencyPeakBand("MID") + .frequencyPeakValue(0.002) + .frequencyBandsJson("{}") + .build(); + + BodyAnalysisResultRepository.BodyDateOptionProjection projection = + mock(BodyAnalysisResultRepository.BodyDateOptionProjection.class); + when(projection.getDate()).thenReturn(LocalDate.of(2026, 6, 1)); + when(projection.getSampleEventId()).thenReturn("EVT-20260601-000777"); + when(repository.findBodyAnalysisDateOptions()).thenReturn(List.of(projection)); + when(repository.findDashboardByEventTimeBetween( + LocalDateTime.of(2026, 6, 1, 0, 0), + LocalDateTime.of(2026, 6, 1, 23, 59, 59, 999_999_999), + PageRequest.of(0, 10_000) + )).thenReturn(List.of(result)); + when(repository.findByAnalysisResult_AnalysisId("ANL-EVT-20260601-000777")) + .thenReturn(Optional.of(result)); + when(eventJsonRepository.findByEventId("EVT-20260601-000777")) + .thenReturn(Optional.of(storedEvent( + "EVT-20260601-000777", + """ + { + "processData": { + "body": { + "robotMotionStatus": "NORMAL", + "robotOperationMode": "AUTO", + "frequencyPeakBand": "MID", + "frequencyBands": { + "LOW": 0.001, + "MEDIUM": 0.002, + "HIGH": 0.003 + } + } + }, + "sensor": { + "robotArmVibration": { + "vibrationScore": 0.2, + "vibrationPeak": 0.002, + "vibrationRms": 0.001 + } + } + } + """ + ))); + + var response = service.findDashboard(null, null, null, null, 30); + + assertThat(response.charts().robotVibration().points()).hasSize(1); + assertThat(response.charts().robotVibration().points().get(0).severity()).isEqualTo("WARNING"); + assertThat(response.charts().frequencyPeak().points()).hasSize(1); + assertThat(response.charts().frequencyPeak().points().get(0).severity()).isEqualTo("WARNING"); + assertThat(response.metrics().severity()).isEqualTo("WARNING"); + } + + private StoredManufacturingEvent storedEvent(String eventId, String rawJson) { + ManufacturingRawEvent payload = new ManufacturingRawEvent( + 1L, + eventId, + LocalDateTime.of(2026, 6, 1, 10, 15), + 1L, + 1L, + ProcessCode.BODY, + "EQ-1", + "BODY", + null, + null, + Map.of() + ); + return new StoredManufacturingEvent( + payload, + rawJson, + "CAR-1", + null, + null, + false, + 0L, + null ); } @@ -91,12 +234,12 @@ private BodyAnalysisResult bodyResult(String eventId, LocalDateTime eventTime, d .robotOperationMode("AUTO_MANUAL_STOPPED") .robotVibrationScore(0.27) .frequencyPeakBand("501_600_HZ") - .frequencyPeakValue(0.002907113) + .frequencyPeakValue(2.907113) .frequencyBandsJson(""" { - "freq_0_100_hz": 0.001193284, - "freq_101_200_hz": 0.001987782, - "freq_501_600_hz": 0.002907113 + "freq_0_100_hz": 1.193284, + "freq_101_200_hz": 1.987782, + "freq_501_600_hz": 2.907113 } """) .build(); diff --git a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java index 0064a04..f800198 100644 --- a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java +++ b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java @@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.Collections; import java.util.Map; @@ -82,7 +84,7 @@ void pressDetailMappingTest() { "cycleTimeSec", 45.0 ) )); - ManufacturingAnalysisEvent analysis = analysisEvent(raw); + ManufacturingAnalysisEvent analysis = analysisEvent(raw, raw.eventTime().plusSeconds(3)); // When service.save(raw, analysis); @@ -375,9 +377,10 @@ void persistsRequestedColumnsToActualMainDb() { "frequencyBands", Map.of("100Hz", 0.2, "200Hz", 0.4) )) )), analysisEvent(rawEvent(ProcessCode.BODY, "EVT-INTEG-VERIFY-BODY", Map.of()))); - service.save(rawEvent(ProcessCode.PRESS, "EVT-INTEG-VERIFY-PRESS", Map.of( + ManufacturingRawEvent pressVerify = rawEvent(ProcessCode.PRESS, "EVT-INTEG-VERIFY-PRESS", Map.of( "processData", Map.of("press", Map.of("timestampDelaySec", 3.0)) - )), analysisEvent(rawEvent(ProcessCode.PRESS, "EVT-INTEG-VERIFY-PRESS", Map.of()))); + )); + service.save(pressVerify, analysisEvent(pressVerify, pressVerify.eventTime().plusSeconds(3))); entityManager.flush(); @@ -398,8 +401,8 @@ void persistsRequestedColumnsToActualMainDb() { Map press = queryDetail("press_analysis_result", "EVT-INTEG-VERIFY-PRESS"); assertThat(((Number) press.get("target_cycle_time_sec")).doubleValue()).isEqualTo(40.0); - assertThat(((Number) press.get("actual_cycle_time_sec")).doubleValue()).isEqualTo(43.0); - assertThat(((Number) press.get("cycle_time_gap_sec")).doubleValue()).isEqualTo(3.0); + assertThat(press.get("actual_cycle_time_sec")).isNull(); + assertThat(press.get("cycle_time_gap_sec")).isNull(); assertThat(((Number) press.get("timestamp_delay_sec")).doubleValue()).isEqualTo(3.0); } @@ -455,11 +458,15 @@ private ManufacturingRawEvent rawEvent(ProcessCode processCode, String eventId, } private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw) { + return analysisEvent(raw, raw.eventTime()); + } + + private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw, LocalDateTime analyzedAt) { return new ManufacturingAnalysisEvent( "ANL-100", raw.eventId(), - raw.eventTime(), - LocalDateTime.now(), + offset(raw.eventTime()), + offset(analyzedAt), "FAC", "LINE", raw.processCode(), @@ -481,4 +488,8 @@ private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw) { new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") ); } + + private OffsetDateTime offset(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime(); + } } diff --git a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java index f44e75e..edd8886 100644 --- a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java +++ b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java @@ -24,6 +24,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.Collections; import java.util.Map; import java.util.Optional; @@ -127,13 +129,13 @@ void processSpecificDetailsReadRequestedFields() { ManufacturingRawEvent press = rawEvent(ProcessCode.PRESS, "EVT-PRESS", Map.of( "processData", Map.of("press", Map.of("timestampDelaySec", 3.0)) )); - service.save(press, analysisEvent(press)); + service.save(press, analysisEvent(press, press.eventTime().plusSeconds(3))); ArgumentCaptor pressCaptor = ArgumentCaptor.forClass(PressAnalysisResult.class); verify(pressRepository).save(pressCaptor.capture()); assertThat(pressCaptor.getValue().getTargetCycleTimeSec()).isEqualTo(40.0); - assertThat(pressCaptor.getValue().getActualCycleTimeSec()).isEqualTo(43.0); - assertThat(pressCaptor.getValue().getCycleTimeGapSec()).isEqualTo(3.0); + assertThat(pressCaptor.getValue().getActualCycleTimeSec()).isNull(); + assertThat(pressCaptor.getValue().getCycleTimeGapSec()).isNull(); assertThat(pressCaptor.getValue().getTimestampDelaySec()).isEqualTo(3.0); ManufacturingRawEvent body = rawEvent(ProcessCode.BODY, "EVT-BODY", Map.of( @@ -259,7 +261,7 @@ void pressDetailUnwrapsStringEventJsonAndStoresNonNullNumbers() { """ )); - service.save(press, analysisEvent(press)); + service.save(press, analysisEvent(press, press.eventTime().plusSeconds(4))); ArgumentCaptor captor = ArgumentCaptor.forClass(PressAnalysisResult.class); @@ -269,7 +271,7 @@ void pressDetailUnwrapsStringEventJsonAndStoresNonNullNumbers() { assertThat(detail.getTargetCycleTimeSec()).isEqualTo(12.0); assertThat(detail.getActualCycleTimeSec()).isEqualTo(14.6); assertThat(detail.getCycleTimeGapSec()).isCloseTo(2.6, within(0.0001)); - assertThat(detail.getTimestampDelaySec()).isEqualTo(4.2); + assertThat(detail.getTimestampDelaySec()).isEqualTo(4.0); } @Test @@ -327,7 +329,7 @@ void pressAndBodyDetailsUseAnalyzerFallbackWhenDetailPayloadIsMissing() { assertThat(pressDetail.getTargetCycleTimeSec()).isEqualTo(40.0); assertThat(pressDetail.getActualCycleTimeSec()).isEqualTo(47.5); assertThat(pressDetail.getCycleTimeGapSec()).isEqualTo(7.5); - assertThat(pressDetail.getTimestampDelaySec()).isEqualTo(7.5); + assertThat(pressDetail.getTimestampDelaySec()).isEqualTo(0.0); ManufacturingRawEvent body = rawEvent(ProcessCode.BODY, "EVT-BODY-FALLBACK", Map.of( "sensor", Map.of( @@ -382,11 +384,15 @@ private StoredManufacturingEvent storedEvent(ManufacturingRawEvent raw) { } private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw) { + return analysisEvent(raw, raw.eventTime()); + } + + private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw, LocalDateTime analyzedAt) { return new ManufacturingAnalysisEvent( "ANL-" + raw.eventId(), raw.eventId(), - raw.eventTime(), - raw.eventTime(), + offset(raw.eventTime()), + offset(analyzedAt), "FAC", "LINE", raw.processCode(), @@ -408,4 +414,214 @@ private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw) { new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") ); } + + @Test + void test1_rawActualSequenceEqualsExpectedSequenceAndAbnormal() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-TEST-1", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B03>PA02>A03", + "actualSequence", "P01>B03>PA02>A03" + ) + ) + )); + ManufacturingAnalysisEvent analysis = new ManufacturingAnalysisEvent( + "ANL-1", "EVT-TEST-1", offset(raw.eventTime()), offset(raw.eventTime()), + "FAC", "LINE", ProcessCode.ASSEMBLY, "EQ-100", "EQ-NAME", "ROBOT", "PROD", "CAR", raw.carMasterId(), + "PROCESS_RISK_ANALYSIS", + new ManufacturingAnalysisEvent.RiskScores(0.0, 0.0, 0.0, 0.0, new ManufacturingAnalysisEvent.ProcessRisk(0.0, null, null, null)), + 90.0, "HIGH", + new ManufacturingAnalysisEvent.AnalysisResult(true, false, false, false, false), + new ManufacturingAnalysisEvent.Reason("Abnormal", Collections.emptyList()), + new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") + ); + + service.save(raw, analysis); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P01>B03>PA02>A03"); + assertThat(result.getActualSequence()).isEqualTo("P01>B03>PA02>A03"); + // 수정 후: sequence가 동일하면 abnormal 여부와 무관하게 sequenceErrorCount = 0 + assertThat(result.getSequenceErrorCount()).isZero(); + } + + private OffsetDateTime offset(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime(); + } + + @Test + void test2_rawActualSequenceDiffersFromExpectedSequence() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-TEST-2", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B03>PA02>A03", + "actualSequence", "P01>B04>PA02>A03" + ) + ) + )); + + service.save(raw, analysisEvent(raw)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P01>B03>PA02>A03"); + assertThat(result.getActualSequence()).isEqualTo("P01>B04>PA02>A03"); + } + + @Test + void test3_rawActualSequenceMissing() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-TEST-3", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B03>PA02>A03" + ) + ) + )); + + service.save(raw, analysisEvent(raw)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P01>B03>PA02>A03"); + assertThat(result.getActualSequence()).isNull(); + } + + @Test + void test4_rawExpectedSequenceMissing() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-TEST-4", Map.of( + "processData", Map.of( + "assembly", Map.of( + "actualSequence", "P01>B04>PA02>A03" + ) + ) + )); + + service.save(raw, analysisEvent(raw)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isNull(); + assertThat(result.getActualSequence()).isEqualTo("P01>B04>PA02>A03"); + } + + /** + * [핵심 버그 재현] + * raw: expectedSequence == actualSequence, 모든 count=0 + * equipmentStatus=WARNING → analysis.isAbnormal=true + * 수정 전: sequenceErrorCount가 합성값(2 등)으로 저장되어 화면에 불일치 발생 + * 수정 후: sequence가 동일 → sequenceErrorCount=0 으로 저장 + */ + @Test + void sequenceErrorCount_mustBeZero_whenSequencesMatch_evenIfEquipmentFaultMakesAbnormal() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-20260602-000092", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P05>B05>PA02>A03", + "actualSequence", "P05>B05>PA02>A03", + "sequenceErrorCount", 0, + "missingPartCount", 0, + "fasteningErrorCount", 0 + ) + ), + "equipmentStatus", Map.of("operationStatus", "WARNING") + )); + // equipmentFault=true로 인해 isAbnormal=true + ManufacturingAnalysisEvent analysis = new ManufacturingAnalysisEvent( + "ANL-92", "EVT-20260602-000092", offset(raw.eventTime()), offset(raw.eventTime()), + "FAC", "LINE", ProcessCode.ASSEMBLY, "EQ-200", "EQ-NAME", "ROBOT", "PROD", "CAR", 123L, + "PROCESS_RISK_ANALYSIS", + new ManufacturingAnalysisEvent.RiskScores(67.0, 0.0, 0.0, 0.0, + new ManufacturingAnalysisEvent.ProcessRisk(null, null, null, 67.0)), + 67.0, "WARNING", + new ManufacturingAnalysisEvent.AnalysisResult(true, false, false, true, false), + new ManufacturingAnalysisEvent.Reason("설비 이상", Collections.emptyList()), + new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") + ); + + service.save(raw, analysis); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P05>B05>PA02>A03"); + assertThat(result.getActualSequence()).isEqualTo("P05>B05>PA02>A03"); + assertThat(result.getSequenceErrorCount()) + .as("expected == actual 이면 sequenceErrorCount=0 이어야 한다 (equipmentFault여도)") + .isEqualTo(0); + } + + /** + * sequence가 다를 때 count=0이면 보정값(>0)을 사용해야 한다. + */ + @Test + void sequenceErrorCount_mustBePositive_whenSequencesDiffer_andRawCountIsZero() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-DIFF-SEQ", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B04>PA04>A03", + "actualSequence", "P01>PA04>B04>A03", + "sequenceErrorCount", 0, + "missingPartCount", 0, + "fasteningErrorCount", 0 + ) + ) + )); + ManufacturingAnalysisEvent analysis = new ManufacturingAnalysisEvent( + "ANL-DIFF", "EVT-DIFF-SEQ", offset(raw.eventTime()), offset(raw.eventTime()), + "FAC", "LINE", ProcessCode.ASSEMBLY, "EQ-300", "EQ-NAME", "ROBOT", "PROD", "CAR", 999L, + "PROCESS_RISK_ANALYSIS", + new ManufacturingAnalysisEvent.RiskScores(65.0, 0.0, 0.0, 0.0, + new ManufacturingAnalysisEvent.ProcessRisk(null, null, null, 65.0)), + 65.0, "WARNING", + new ManufacturingAnalysisEvent.AnalysisResult(true, false, false, false, true), + new ManufacturingAnalysisEvent.Reason("순서 오류", Collections.emptyList()), + new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") + ); + + service.save(raw, analysis); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P01>B04>PA04>A03"); + assertThat(result.getActualSequence()).isEqualTo("P01>PA04>B04>A03"); + assertThat(result.getSequenceErrorCount()) + .as("expected != actual 이면 sequenceErrorCount > 0 이어야 한다") + .isGreaterThan(0); + } + + /** + * raw count가 양수이고 sequence도 다를 때: raw count를 그대로 사용 + */ + @Test + void sequenceErrorCount_mustUseRawValue_whenSequencesDifferAndRawCountIsPositive() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-RAW-CNT", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B04>PA04>A03", + "actualSequence", "P01>PA04>B04>A03", + "sequenceErrorCount", 1, + "missingPartCount", 0, + "fasteningErrorCount", 1 + ) + ) + )); + + service.save(raw, analysisEvent(raw)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getSequenceErrorCount()) + .as("raw sequenceErrorCount=1, sequence 다름 → 원본값 1 유지") + .isEqualTo(1); + assertThat(result.getFasteningErrorCount()) + .as("raw fasteningErrorCount=1 그대로") + .isEqualTo(1); + } } diff --git a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java index 3b2d47c..6864ed5 100644 --- a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java @@ -25,7 +25,6 @@ class PressAnomalyDetectionServiceTest { @Test void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { - // manufacturing_analysis_result.event_time = 2026-06-01 PressAnalysisResult result = pressResult( "EVT-20260601-000397", LocalDateTime.of(2026, 6, 1, 9, 30), @@ -43,6 +42,10 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { LocalDateTime.of(2026, 6, 1, 23, 59, 59, 999_999_999), PageRequest.of(0, 10_000) )).thenReturn(List.of(result)); + when(repository.findMaxRiskScoreByEventTimeBetween( + LocalDateTime.of(2026, 6, 1, 0, 0), + LocalDateTime.of(2026, 6, 1, 23, 59, 59, 999_999_999) + )).thenReturn(78.0); var response = service.findDashboard(null, null, null, null, 30); @@ -56,14 +59,31 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { assertThat(response.chart().get(0).targetCycleTimeSec()).isEqualTo(40.0); assertThat(response.chart().get(0).actualCycleTimeSec()).isEqualTo(43.0); assertThat(response.chart().get(0).cycleTimeGapSec()).isEqualTo(3.0); - assertThat(response.chart().get(0).timestampDelaySec()).isEqualTo(3.0); + assertThat(response.chart().get(0).warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(response.chart().get(0).dangerCycleTimeGapSec()).isEqualTo(3.0); + assertThat(response.charts().cycleTime().title()).isEqualTo("press cycle time"); + assertThat(response.charts().cycleTime().points()).hasSize(1); + assertThat(response.charts().cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); + assertThat(response.charts().cycleTime().points().get(0).actualCycleTimeSec()).isEqualTo(43.0); + assertThat(response.metrics().warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(response.metrics().dangerCycleTimeGapSec()).isEqualTo(3.0); + assertThat(response.charts().delay().title()).isEqualTo("press delay/gap"); + assertThat(response.charts().delay().points()).hasSize(1); + assertThat(response.charts().delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); assertThat(response.metrics().targetCycleTimeSec()).isEqualTo(40.0); assertThat(response.metrics().actualCycleTimeSec()).isEqualTo(43.0); assertThat(response.metrics().cycleTimeGapSec()).isEqualTo(3.0); - assertThat(response.metrics().timestampDelaySec()).isEqualTo(3.0); - assertThat(response.metrics().riskScore()).isEqualTo(78.0); // max riskScore among anomaly points + assertThat(response.metrics().riskScore()).isEqualTo(78.0); assertThat(response.alert().detected()).isTrue(); - assertThat(response.alert().title()).isEqualTo("프레스 이상 정지 탐지"); + assertThat(response.alert().title()).isNotBlank(); + assertThat(response.alert().reasons()) + .anySatisfy(reason -> { + assertThat(reason).contains("EVT-20260601-000397"); + assertThat(reason).contains("targetCycleTimeSec"); + assertThat(reason).contains("actualCycleTimeSec"); + assertThat(reason).contains("cycleTimeGapSec"); + assertThat(reason).contains("severity="); + }); } private PressAnalysisResult pressResult(String eventId, LocalDateTime eventTime, double riskScore) { @@ -84,7 +104,6 @@ private PressAnalysisResult pressResult(String eventId, LocalDateTime eventTime, .targetCycleTimeSec(40.0) .actualCycleTimeSec(43.0) .cycleTimeGapSec(3.0) - .timestampDelaySec(3.0) .countIncreaseYn(false) .build(); } diff --git a/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java b/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java index 9860f5c..c0038a3 100644 --- a/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java +++ b/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java @@ -2,6 +2,7 @@ import com.aims.assembly.domain.analysis.ManufacturingAnalysisResult; import com.aims.assembly.domain.assembly.AssemblyAnalysisResult; +import com.aims.assembly.domain.commons.BaseEntity; import com.aims.assembly.domain.enums.EquipmentOperationStatus; import com.aims.assembly.domain.enums.ProcessCode; import com.aims.assembly.domain.enums.Severity; @@ -11,19 +12,29 @@ import com.aims.assembly.dto.process.PaintDashboardResponse; import com.aims.assembly.repository.analysis.AssemblyAnalysisResultRepository; import com.aims.assembly.repository.analysis.PaintAnalysisResultRepository; +import com.aims.assembly.repository.car.CarMasterRepository; import com.aims.assembly.repository.equipment.EquipmentOperationRateRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; 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.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class ProcessDashboardServiceTest { @@ -33,6 +44,7 @@ class ProcessDashboardServiceTest { mock(AssemblyAnalysisResultRepository.class); private final EquipmentOperationRateRepository equipmentOperationRateRepository = mock(EquipmentOperationRateRepository.class); + private final CarMasterRepository carMasterRepository = mock(CarMasterRepository.class); private ProcessDashboardService service; @BeforeEach @@ -40,14 +52,31 @@ void setUp() { service = new ProcessDashboardService( paintRepository, assemblyRepository, - equipmentOperationRateRepository + equipmentOperationRateRepository, + carMasterRepository ); + when(carMasterRepository.findVehicleIdsByIdIn(any())).thenAnswer(invocation -> { + Iterable ids = invocation.getArgument(0); + Map vehicleIdsById = new LinkedHashMap<>(); + for (Long id : ids) { + vehicleIdsById.put(id, "VEHICLE-%04d".formatted(id)); + } + return vehicleIdsById; + }); + } + + @Test + void paintAndAssemblyDetailsDoNotUseJpaAuditBaseEntity() { + assertThat(BaseEntity.class.isAssignableFrom(PaintAnalysisResult.class)).isFalse(); + assertThat(BaseEntity.class.isAssignableFrom(AssemblyAnalysisResult.class)).isFalse(); } @Test void paintDashboardBuildsSummaryChartAndAlert() { + LocalDateTime eventTime = LocalDateTime.of(2026, 6, 18, 13, 55); + LocalDateTime analyzedAt = LocalDateTime.of(2026, 6, 19, 13, 56); PaintAnalysisResult row = PaintAnalysisResult.builder() - .analysisResult(result(ProcessCode.PAINT, 1L, Severity.CRITICAL, true, 88.5)) + .analysisResult(result(ProcessCode.PAINT, 1L, Severity.CRITICAL, true, 88.5, eventTime, analyzedAt)) .defectScore(0.87) .thermalStdTemp(4.2) .surfaceQualityScore(72.3) @@ -60,37 +89,82 @@ void paintDashboardBuildsSummaryChartAndAlert() { eq(LocalDateTime.of(2026, 6, 19, 0, 0)), any(Pageable.class) )).thenReturn(List.of(row)); + when(paintRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 6, 18, 0, 0)), + eq(LocalDateTime.of(2026, 6, 19, 0, 0)) + )).thenReturn(List.of(row)); PaintDashboardResponse response = service.getPaintDashboard(LocalDate.of(2026, 6, 18), null, null, 30); assertThat(response.selectedDate()).isEqualTo(LocalDate.of(2026, 6, 18)); + assertThat(response.from()).isEqualTo(LocalDateTime.of(2026, 6, 18, 0, 0)); + assertThat(response.to()).isEqualTo(LocalDateTime.of(2026, 6, 18, 23, 59, 59, 999999999)); + assertThat(response.chartStartAt()).isEqualTo(eventTime); + assertThat(response.chartEndAt()).isEqualTo(eventTime); assertThat(response.summary().analysisCount()).isEqualTo(1); + assertThat(response.summary().averageThicknessValue()).isEqualTo(126.5); + assertThat(response.summary().averageThermalStdTemp()).isEqualTo(4.2); assertThat(response.summary().defectRate()).isEqualTo(100.0); assertThat(response.summary().averageSurfaceQualityScore()).isEqualTo(72.3); assertThat(response.summary().alertCount()).isEqualTo(1); - assertThat(response.chart()).singleElement().satisfies(point -> { - assertThat(point.defectScore()).isEqualTo(0.87); - assertThat(point.surfaceQualityScore()).isEqualTo(72.3); - assertThat(point.thicknessValue()).isEqualTo(126.5); + assertThat(response.thresholds().surfaceQualityScore().warningBelow()).isEqualTo(80.0); + assertThat(response.thresholds().surfaceQualityScore().dangerBelow()).isEqualTo(60.0); + assertThat(response.thresholds().thicknessValue().target()).isEqualTo(115.0); + assertThat(response.thresholds().thicknessValue().normalMin()).isEqualTo(90.0); + assertThat(response.thresholds().thicknessValue().normalMax()).isEqualTo(120.0); + assertThat(response.thresholds().thicknessValue().warningMin()).isEqualTo(80.0); + assertThat(response.thresholds().thicknessValue().warningMax()).isEqualTo(130.0); + assertThat(response.thresholds().defectScore().warningAbove()).isEqualTo(0.4); + assertThat(response.thresholds().defectScore().dangerAbove()).isEqualTo(0.6); + assertThat(response.thresholds().thermalStdTemp().warningAbove()).isEqualTo(2.0); + assertThat(response.thresholds().thermalStdTemp().dangerAbove()).isEqualTo(5.0); + assertThat(response.charts().surfaceQuality().points()).singleElement().satisfies(point -> { + assertThat(point.time()).isEqualTo(eventTime); + assertThat(point.value()).isEqualTo(72.3); + assertThat(point.status()).isEqualTo("WARNING"); + assertThat(point.riskScore()).isEqualTo(88.5); assertThat(point.imagePosition()).isEqualTo("LEFT"); }); + assertThat(response.charts().thickness().points()).singleElement() + .satisfies(point -> { + assertThat(point.value()).isEqualTo(126.5); + assertThat(point.status()).isEqualTo("WARNING"); + }); + assertThat(response.charts().defectScore().points()).singleElement() + .satisfies(point -> assertThat(point.value()).isEqualTo(0.87)); + assertThat(response.charts().defectScore().markers()).singleElement().satisfies(marker -> { + assertThat(marker.value()).isEqualTo(0.87); + assertThat(marker.label()).isEqualTo("DEFECT"); + assertThat(marker.status()).isEqualTo("DANGER"); + }); + assertThat(response.charts().thermalStdTemp().points()).singleElement() + .satisfies(point -> { + assertThat(point.value()).isEqualTo(4.2); + assertThat(point.status()).isEqualTo("WARNING"); + }); assertThat(response.alert().messages()) - .contains("비전 불량 라벨 감지: DEFECT") - .anyMatch(message -> message.contains("도장 두께 이상 의심")); + .contains("비전 판정: DEFECT", "상태: DANGER"); + assertThat(response.alert().detail()).satisfies(detail -> { + assertThat(detail.time()).isEqualTo(eventTime); + assertThat(detail.defectScore()).isEqualTo(0.87); + assertThat(detail.status()).isEqualTo("DANGER"); + }); } @Test void assemblyDashboardBuildsSummaryVehiclesAndAlert() { + LocalDateTime eventTime = LocalDateTime.of(2026, 6, 18, 13, 55); + LocalDateTime analyzedAt = LocalDateTime.of(2026, 6, 19, 13, 56); AssemblyAnalysisResult row = AssemblyAnalysisResult.builder() - .analysisResult(result(ProcessCode.ASSEMBLY, 1L, Severity.CRITICAL, true, 86.5)) + .analysisResult(result(ProcessCode.ASSEMBLY, 1L, Severity.CRITICAL, true, 86.5, eventTime, analyzedAt)) .expectedSequence("A01 > A02 > A03 > A04") .actualSequence("A01 > A03 > A02 > A04") .sequenceErrorCount(1) .missingPartCount(0) .fasteningErrorCount(1) .build(); - when(assemblyRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of( + when(assemblyRepository.findDashboardEventTimeValues()).thenReturn(List.of( LocalDateTime.of(2026, 6, 1, 10, 0), LocalDateTime.of(2026, 6, 18, 10, 0) )); @@ -100,44 +174,122 @@ void assemblyDashboardBuildsSummaryVehiclesAndAlert() { any(Pageable.class) )) .thenReturn(List.of(row)); + when(assemblyRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 6, 18, 0, 0)), + eq(LocalDateTime.of(2026, 6, 19, 0, 0)) + )).thenReturn(List.of(row)); + when(carMasterRepository.findVehicleIdsByIdIn(List.of(1L))) + .thenReturn(Map.of(1L, "AVANTE-0001")); AssemblyDashboardResponse response = service.getAssemblyDashboard(null, null, null, 30); assertThat(response.selectedDate()).isEqualTo(LocalDate.of(2026, 6, 18)); + assertThat(response.from()).isEqualTo(LocalDateTime.of(2026, 6, 18, 0, 0)); + assertThat(response.to()).isEqualTo(LocalDateTime.of(2026, 6, 18, 23, 59, 59, 999999999)); + assertThat(response.dataStartAt()).isEqualTo(eventTime); + assertThat(response.dataEndAt()).isEqualTo(eventTime); assertThat(response.summary().vehicleCount()).isEqualTo(1); assertThat(response.summary().sequenceErrorCount()).isEqualTo(1); assertThat(response.summary().missingPartCount()).isZero(); assertThat(response.summary().fasteningErrorCount()).isEqualTo(1); assertThat(response.summary().averageRiskScore()).isEqualTo(86.5); assertThat(response.vehicles()).singleElement().satisfies(vehicle -> { - assertThat(vehicle.carDisplayId()).isEqualTo("CAR-000001"); + assertThat(vehicle.carMasterId()).isEqualTo(1L); + assertThat(vehicle.carDisplayId()).isEqualTo("AVANTE-0001"); assertThat(vehicle.expectedSequence()).isEqualTo("A01 > A02 > A03 > A04"); assertThat(vehicle.actualSequence()).isEqualTo("A01 > A03 > A02 > A04"); assertThat(vehicle.sequenceErrorCount()).isEqualTo(1); assertThat(vehicle.missingPartCount()).isZero(); assertThat(vehicle.fasteningErrorCount()).isEqualTo(1); + assertThat(vehicle.time()).isEqualTo(eventTime); assertThat(vehicle.status()).isEqualTo("위험"); }); assertThat(response.alert().messages()) .contains("조립 순서 오류와 체결 오류 동시 감지"); } + @Test + void assemblyDashboardUsesVehicleIdFromCarMasterForDisplayId() { + AssemblyAnalysisResult first = assemblyRow(34L, LocalDateTime.of(2026, 6, 18, 10, 0)); + AssemblyAnalysisResult second = assemblyRow(35L, LocalDateTime.of(2026, 6, 18, 10, 5)); + when(assemblyRepository.findDashboardRows(any(), any(), any(Pageable.class))) + .thenReturn(List.of(second, first)); + when(assemblyRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of(first, second)); + when(carMasterRepository.findVehicleIdsByIdIn(List.of(34L, 35L))).thenReturn(Map.of( + 34L, "AVANTE-0034", + 35L, "SONATA-0035" + )); + + AssemblyDashboardResponse response = + service.getAssemblyDashboard(LocalDate.of(2026, 6, 18), null, null, 30); + + assertThat(response.vehicles()).extracting(AssemblyDashboardResponse.VehicleRow::carMasterId) + .containsExactly(34L, 35L); + assertThat(response.vehicles()).extracting(AssemblyDashboardResponse.VehicleRow::carDisplayId) + .containsExactly("AVANTE-0034", "SONATA-0035"); + verify(carMasterRepository, times(1)).findVehicleIdsByIdIn(List.of(34L, 35L)); + } + + @Test + void assemblyDashboardDoesNotGenerateFakeDisplayIdWhenCarMasterIsMissing() { + AssemblyAnalysisResult row = assemblyRow(99L, LocalDateTime.of(2026, 6, 18, 10, 0)); + when(assemblyRepository.findDashboardRows(any(), any(), any(Pageable.class))) + .thenReturn(List.of(row)); + when(assemblyRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of(row)); + when(carMasterRepository.findVehicleIdsByIdIn(List.of(99L))).thenReturn(Map.of()); + + AssemblyDashboardResponse response = + service.getAssemblyDashboard(LocalDate.of(2026, 6, 18), null, null, 30); + + assertThat(response.vehicles()).singleElement().satisfies(vehicle -> { + assertThat(vehicle.carMasterId()).isEqualTo(99L); + assertThat(vehicle.carDisplayId()).isNull(); + }); + } + + @Test + void assemblyDashboardDoesNotGenerateFakeDisplayIdWhenVehicleIdIsNull() { + AssemblyAnalysisResult row = assemblyRow(100L, LocalDateTime.of(2026, 6, 18, 10, 0)); + when(assemblyRepository.findDashboardRows(any(), any(), any(Pageable.class))) + .thenReturn(List.of(row)); + when(assemblyRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of(row)); + Map vehicleIds = new LinkedHashMap<>(); + vehicleIds.put(100L, null); + when(carMasterRepository.findVehicleIdsByIdIn(List.of(100L))).thenReturn(vehicleIds); + + AssemblyDashboardResponse response = + service.getAssemblyDashboard(LocalDate.of(2026, 6, 18), null, null, 30); + + assertThat(response.vehicles()).singleElement().satisfies(vehicle -> { + assertThat(vehicle.carMasterId()).isEqualTo(100L); + assertThat(vehicle.carDisplayId()).isNull(); + }); + } + @Test void returnsEmptyResponsesWhenNoDataExists() { - when(paintRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of()); - when(assemblyRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of()); + when(paintRepository.findDashboardEventTimeValues()).thenReturn(List.of()); + when(assemblyRepository.findDashboardEventTimeValues()).thenReturn(List.of()); when(paintRepository.findDashboardRows(any(), any(), any(Pageable.class))) .thenReturn(List.of()); when(assemblyRepository.findDashboardRows(any(), any(), any(Pageable.class))) .thenReturn(List.of()); + when(paintRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of()); + when(assemblyRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of()); PaintDashboardResponse paint = service.getPaintDashboard(null, null, null, null); AssemblyDashboardResponse assembly = service.getAssemblyDashboard(null, null, null, null); assertThat(paint.selectedDate()).isNull(); assertThat(paint.summary().analysisCount()).isZero(); - assertThat(paint.chart()).isEmpty(); - assertThat(paint.alert()).isNull(); + assertThat(paint.charts().surfaceQuality().points()).isEmpty(); + assertThat(paint.charts().defectScore().markers()).isEmpty(); + assertThat(paint.alert().title()).isEqualTo("최근 도장 상태 정상"); assertThat(assembly.selectedDate()).isNull(); assertThat(assembly.summary().vehicleCount()).isZero(); assertThat(assembly.vehicles()).isEmpty(); @@ -150,7 +302,7 @@ void paintDashboardWithoutDateUsesLatestAvailableDate() { .analysisResult(result(ProcessCode.PAINT, 1L, Severity.NORMAL, false, 10.0)) .surfaceQualityScore(90.0) .build(); - when(paintRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of( + when(paintRepository.findDashboardEventTimeValues()).thenReturn(List.of( LocalDateTime.of(2026, 6, 1, 10, 0), LocalDateTime.of(2026, 6, 18, 10, 0) )); @@ -159,6 +311,10 @@ void paintDashboardWithoutDateUsesLatestAvailableDate() { eq(LocalDateTime.of(2026, 6, 19, 0, 0)), any(Pageable.class) )).thenReturn(List.of(row)); + when(paintRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 6, 18, 0, 0)), + eq(LocalDateTime.of(2026, 6, 19, 0, 0)) + )).thenReturn(List.of(row)); PaintDashboardResponse response = service.getPaintDashboard(null, null, null, 30); @@ -166,14 +322,158 @@ void paintDashboardWithoutDateUsesLatestAvailableDate() { assertThat(response.summary().analysisCount()).isEqualTo(1); } + @Test + void paintDashboardUsesLatestLimitRowsAndFullRangeSummary() { + List allRows = paintRows(35); + List latestRowsDesc = new ArrayList<>(allRows.subList(5, 35)); + Collections.reverse(latestRowsDesc); + when(paintRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 7, 7, 0, 0)), + eq(LocalDateTime.of(2026, 7, 8, 0, 0)) + )).thenReturn(allRows); + when(paintRepository.findDashboardRows( + eq(LocalDateTime.of(2026, 7, 7, 0, 0)), + eq(LocalDateTime.of(2026, 7, 8, 0, 0)), + any(Pageable.class) + )).thenReturn(latestRowsDesc); + + PaintDashboardResponse response = + service.getPaintDashboard(LocalDate.of(2026, 7, 7), null, null, 30); + + assertThat(response.summary().analysisCount()).isEqualTo(35); + assertThat(response.charts().surfaceQuality().points()).hasSize(30); + assertThat(response.charts().surfaceQuality().points()).extracting(PaintDashboardResponse.MetricPoint::time) + .containsExactlyElementsOf(allRows.subList(5, 35).stream() + .map(row -> row.getAnalysisResult().getEventTime()) + .toList()); + assertThat(response.charts().surfaceQuality().points()).extracting(PaintDashboardResponse.MetricPoint::time) + .doesNotContain(allRows.get(0).getAnalysisResult().getEventTime()) + .contains(LocalDateTime.of(2026, 7, 7, 13, 0)); + assertThat(response.chartStartAt()).isEqualTo(allRows.get(5).getAnalysisResult().getEventTime()); + assertThat(response.chartEndAt()).isEqualTo(allRows.get(34).getAnalysisResult().getEventTime()); + } + + @Test + void paintDashboardKeepsRowsAtSameTimeAndBuildsDefectMarkers() { + LocalDateTime eventTime = LocalDateTime.of(2026, 7, 7, 12, 19, 5); + PaintAnalysisResult first = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 1L, Severity.NORMAL, false, 3.6, eventTime, eventTime)) + .defectScore(0.044) + .surfaceQualityScore(97.9) + .thicknessValue(113.9) + .thermalStdTemp(0.67) + .visionLabel("NORMAL") + .imagePosition("LEFT") + .build(); + PaintAnalysisResult second = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 2L, Severity.NORMAL, false, 5.3, eventTime, eventTime)) + .defectScore(0.072) + .surfaceQualityScore(96.7) + .thicknessValue(115.7) + .thermalStdTemp(0.71) + .visionLabel("NORMAL") + .imagePosition("LEFT") + .build(); + PaintAnalysisResult third = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 3L, Severity.NORMAL, false, 3.6, eventTime, eventTime)) + .defectScore(0.063) + .surfaceQualityScore(97.257) + .thicknessValue(110.212) + .thermalStdTemp(0.879) + .visionLabel("DUST_CONTAMINATION") + .imagePosition("LEFT") + .build(); + List rows = List.of(first, second, third); + when(paintRepository.findDashboardSummaryRows(any(), any())).thenReturn(rows); + when(paintRepository.findDashboardRows(any(), any(), any(Pageable.class))).thenReturn(rows); + + PaintDashboardResponse response = + service.getPaintDashboard(LocalDate.of(2026, 7, 7), null, null, 30); + + assertThat(response.charts().surfaceQuality().points()).hasSize(3); + assertThat(response.charts().thickness().points()).hasSize(3); + assertThat(response.charts().defectScore().points()).hasSize(3); + assertThat(response.charts().thermalStdTemp().points()).hasSize(3); + assertThat(response.charts().defectScore().markers()).singleElement().satisfies(marker -> { + assertThat(marker.time()).isEqualTo(eventTime); + assertThat(marker.label()).isEqualTo("DUST_CONTAMINATION"); + assertThat(marker.status()).isEqualTo("WARNING"); + }); + } + + @Test + void paintDashboardTreatsNormalZeroSurfaceQualityAsMissingValue() { + LocalDateTime eventTime = LocalDateTime.of(2026, 7, 7, 14, 11, 59); + PaintAnalysisResult missingSurfaceQuality = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 1L, Severity.NORMAL, false, 0.0, eventTime, eventTime)) + .defectScore(0.052) + .surfaceQualityScore(0.0) + .thicknessValue(116.0) + .thermalStdTemp(0.0) + .visionLabel("NORMAL") + .imagePosition("LEFT") + .build(); + PaintAnalysisResult measuredSurfaceQuality = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 2L, Severity.NORMAL, false, 3.0, eventTime.plusMinutes(1), eventTime)) + .defectScore(0.050) + .surfaceQualityScore(96.0) + .thicknessValue(116.0) + .thermalStdTemp(0.5) + .visionLabel("NORMAL") + .imagePosition("LEFT") + .build(); + List rows = List.of(missingSurfaceQuality, measuredSurfaceQuality); + when(paintRepository.findDashboardSummaryRows(any(), any())).thenReturn(rows); + when(paintRepository.findDashboardRows(any(), any(), any(Pageable.class))).thenReturn(rows); + + PaintDashboardResponse response = + service.getPaintDashboard(LocalDate.of(2026, 7, 7), null, null, 30); + + assertThat(response.charts().surfaceQuality().points()).extracting(PaintDashboardResponse.MetricPoint::value) + .containsExactly(null, 96.0); + assertThat(response.summary().averageSurfaceQualityScore()).isEqualTo(96.0); + assertThat(response.alert().title()).isEqualTo("최근 도장 상태 정상"); + } + + @Test + void assemblyDashboardUsesLatestLimitRowsAndFullRangeSummary() { + List allRows = assemblyRows(35); + List latestRowsDesc = new ArrayList<>(allRows.subList(5, 35)); + Collections.reverse(latestRowsDesc); + when(assemblyRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 7, 7, 0, 0)), + eq(LocalDateTime.of(2026, 7, 8, 0, 0)) + )).thenReturn(allRows); + when(assemblyRepository.findDashboardRows( + eq(LocalDateTime.of(2026, 7, 7, 0, 0)), + eq(LocalDateTime.of(2026, 7, 8, 0, 0)), + any(Pageable.class) + )).thenReturn(latestRowsDesc); + + AssemblyDashboardResponse response = + service.getAssemblyDashboard(LocalDate.of(2026, 7, 7), null, null, 30); + + assertThat(response.summary().vehicleCount()).isEqualTo(35); + assertThat(response.vehicles()).hasSize(30); + assertThat(response.vehicles()).extracting(AssemblyDashboardResponse.VehicleRow::time) + .containsExactlyElementsOf(allRows.subList(5, 35).stream() + .map(row -> row.getAnalysisResult().getEventTime()) + .toList()); + assertThat(response.vehicles()).extracting(AssemblyDashboardResponse.VehicleRow::time) + .doesNotContain(allRows.get(0).getAnalysisResult().getEventTime()) + .contains(LocalDateTime.of(2026, 7, 7, 13, 0)); + assertThat(response.dataStartAt()).isEqualTo(allRows.get(5).getAnalysisResult().getEventTime()); + assertThat(response.dataEndAt()).isEqualTo(allRows.get(34).getAnalysisResult().getEventTime()); + } + @Test void dateApisReturnDatesAndLatestDate() { - when(paintRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of( + when(paintRepository.findDashboardEventTimeValues()).thenReturn(List.of( LocalDateTime.of(2026, 6, 1, 10, 0), LocalDateTime.of(2026, 6, 1, 11, 0), LocalDateTime.of(2026, 6, 18, 10, 0) )); - when(assemblyRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of( + when(assemblyRepository.findDashboardEventTimeValues()).thenReturn(List.of( LocalDateTime.of(2026, 6, 1, 10, 0) )); @@ -216,16 +516,16 @@ void equipmentOperationRateUsesCurrentStatusAndIncludesWarningAsOperating() { assertThat(item.faultCount()).isZero(); assertThat(item.totalCount()).isEqualTo(5); assertThat(item.operationRate()).isEqualTo(80.0); - assertThat(item.statusCounts()).containsEntry(EquipmentOperationStatus.WARNING, 1L); + assertThat(item.statusCounts()).containsEntry("WARNING", 1L); }); assertThat(response.items().get(2)).satisfies(item -> { assertThat(item.processCode()).isEqualTo("PAINT"); assertThat(item.totalCount()).isZero(); assertThat(item.operationRate()).isEqualTo(0.0); - assertThat(item.statusCounts()).containsEntry(EquipmentOperationStatus.RUNNING, 0L) - .containsEntry(EquipmentOperationStatus.WARNING, 0L) - .containsEntry(EquipmentOperationStatus.STOPPED, 0L) - .containsEntry(EquipmentOperationStatus.FAULT, 0L); + assertThat(item.statusCounts()).containsEntry("RUNNING", 0L) + .containsEntry("WARNING", 0L) + .containsEntry("STOPPED", 0L) + .containsEntry("FAULT", 0L); }); assertThat(response.items().get(3)).satisfies(item -> { assertThat(item.warningCount()).isEqualTo(2); @@ -234,12 +534,109 @@ void equipmentOperationRateUsesCurrentStatusAndIncludesWarningAsOperating() { }); } + @Test + void equipmentOperationRateStatusCountsNormalizesIntegerValuesToLongValues() { + Map statusCounts = new LinkedHashMap<>(); + statusCounts.put("RUNNING", 1); + statusCounts.put("WARNING", 1L); + statusCounts.put("STOPPED", 2); + statusCounts.put("FAULT", 1); + + EquipmentOperationRateResponse.Item item = new EquipmentOperationRateResponse.Item( + "PRESS", + "프레스", + 1, + 1, + 2, + 2, + 1, + 5, + 40.0, + (Map) statusCounts + ); + + assertThat(item.statusCounts()).containsEntry("RUNNING", 1L) + .containsEntry("WARNING", 1L) + .containsEntry("STOPPED", 2L) + .containsEntry("FAULT", 1L); + assertThat(item.statusCounts().values()).allSatisfy(value -> assertThat(value).isInstanceOf(Long.class)); + } + + @Test + void equipmentOperationRateResponseSurvivesRedisSerializerRoundTrip() { + EquipmentOperationRateResponse response = new EquipmentOperationRateResponse(List.of( + new EquipmentOperationRateResponse.Item( + "PRESS", + "프레스", + 1, + 1, + 2, + 2, + 1, + 5, + 40.0, + Map.of( + "RUNNING", 1L, + "WARNING", 1L, + "STOPPED", 2L, + "FAULT", 1L + ) + ) + )); + GenericJacksonJsonRedisSerializer serializer = redisJsonSerializer(); + + Object restored = serializer.deserialize(serializer.serialize(response)); + + assertThat(restored).isInstanceOf(EquipmentOperationRateResponse.class); + EquipmentOperationRateResponse restoredResponse = (EquipmentOperationRateResponse) restored; + EquipmentOperationRateResponse.Item restoredItem = restoredResponse.items().get(0); + assertThat(restoredItem.statusCounts()).containsEntry("RUNNING", 1L) + .containsEntry("WARNING", 1L) + .containsEntry("STOPPED", 2L) + .containsEntry("FAULT", 1L); + assertThat(restoredItem.statusCounts().values()) + .allSatisfy(value -> assertThat(value).isInstanceOf(Long.class)); + } + + private GenericJacksonJsonRedisSerializer redisJsonSerializer() { + PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.aims.assembly.") + .allowIfSubType("java.lang.") + .allowIfSubType("java.time.") + .allowIfSubType("java.util.") + .build(); + + return GenericJacksonJsonRedisSerializer.builder() + .enableDefaultTyping(typeValidator) + .build(); + } + private ManufacturingAnalysisResult result( ProcessCode processCode, Long carMasterId, Severity severity, boolean abnormal, double riskScore + ) { + return result( + processCode, + carMasterId, + severity, + abnormal, + riskScore, + LocalDateTime.of(2026, 6, 18, 13, 55), + LocalDateTime.of(2026, 6, 18, 13, 56) + ); + } + + private ManufacturingAnalysisResult result( + ProcessCode processCode, + Long carMasterId, + Severity severity, + boolean abnormal, + double riskScore, + LocalDateTime eventTime, + LocalDateTime analyzedAt ) { return ManufacturingAnalysisResult.builder() .analysisId("ANL-" + processCode) @@ -247,11 +644,66 @@ private ManufacturingAnalysisResult result( .carMasterId(carMasterId) .equipmentId(10L) .processCode(processCode) - .eventTime(LocalDateTime.of(2026, 6, 18, 13, 55)) - .analyzedAt(LocalDateTime.of(2026, 6, 18, 13, 56)) + .eventTime(eventTime) + .analyzedAt(analyzedAt) .isAbnormal(abnormal) .severity(severity) .riskScore(riskScore) .build(); } + + private List paintRows(int count) { + List rows = new ArrayList<>(); + for (int index = 0; index < count; index++) { + LocalDateTime eventTime = LocalDateTime.of(2026, 7, 7, 10, 0).plusMinutes(index * 6L); + rows.add(PaintAnalysisResult.builder() + .analysisResult(result( + ProcessCode.PAINT, + (long) index, + Severity.NORMAL, + false, + index, + eventTime, + eventTime.plusSeconds(5) + )) + .surfaceQualityScore(90.0) + .thicknessValue(120.0) + .thermalStdTemp(1.0) + .build()); + } + return rows; + } + + private List assemblyRows(int count) { + List rows = new ArrayList<>(); + for (int index = 0; index < count; index++) { + LocalDateTime eventTime = LocalDateTime.of(2026, 7, 7, 10, 0).plusMinutes(index * 6L); + rows.add(assemblyRow((long) index, eventTime, index)); + } + return rows; + } + + private AssemblyAnalysisResult assemblyRow(Long carMasterId, LocalDateTime eventTime) { + return assemblyRow(carMasterId, eventTime, 0.0); + } + + private AssemblyAnalysisResult assemblyRow(Long carMasterId, LocalDateTime eventTime, double riskScore) { + return AssemblyAnalysisResult.builder() + .analysisResult(result( + ProcessCode.ASSEMBLY, + carMasterId, + Severity.NORMAL, + false, + riskScore, + eventTime, + eventTime.plusSeconds(5) + )) + .expectedSequence("A01 > A02") + .actualSequence("A01 > A02") + .sequenceErrorCount(0) + .missingPartCount(0) + .fasteningErrorCount(0) + .build(); + } + }