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 통계적 공정관리 적용)
+
+
+**ISO 표준 문서 및 통계적 공정관리(SPC) 원칙**에 따라 설비별 정상 데이터의 평균(μ)과 표준편차(σ)를 기반으로 동적 임계값을 생성하여 이상을 탐지합니다. (2σ 이내 정상, 2~3σ 경고, 3σ 초과 위험)
+
+
+### 1. 프레스 공정 (Press)
+
+
+- **적용 레퍼런스:** `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)
+
+
+
+- **적용 레퍼런스:** `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)
+
+
+
+- **적용 레퍼런스:** `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)
+
+
+- **작업/조립 순서 오류 (`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 기준으로 조회하는 진단용 엔드포인트입니다.
+
+
+## 🔧 기술 스택
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## 로컬 실행 및 테스트
```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