From 4970d4437af8f3e9b019d37ad47501152786af78 Mon Sep 17 00:00:00 2001 From: chani2104 Date: Sat, 4 Jul 2026 14:01:51 +0900 Subject: [PATCH 01/35] =?UTF-8?q?feat:=20AGV=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A7=91=20/=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20Ready=20=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assembly/controller/internal/AgvArrivalController.java | 4 ++++ .../aims/assembly/dto/manufacturing/AgvArrivalRequest.java | 4 ++++ .../assembly/service/manufacturing/AgvArrivalService.java | 4 ++++ 3 files changed, 12 insertions(+) create mode 100644 src/main/java/com/aims/assembly/controller/internal/AgvArrivalController.java create mode 100644 src/main/java/com/aims/assembly/dto/manufacturing/AgvArrivalRequest.java create mode 100644 src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java 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..8473fd9 --- /dev/null +++ b/src/main/java/com/aims/assembly/controller/internal/AgvArrivalController.java @@ -0,0 +1,4 @@ +package com.aims.assembly.controller.internal; + +public class AgvArrivalController { +} 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..b5e75ad --- /dev/null +++ b/src/main/java/com/aims/assembly/dto/manufacturing/AgvArrivalRequest.java @@ -0,0 +1,4 @@ +package com.aims.assembly.dto.manufacturing; + +public class AgvArrivalRequest { +} diff --git a/src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java b/src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java new file mode 100644 index 0000000..9e57a16 --- /dev/null +++ b/src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java @@ -0,0 +1,4 @@ +package com.aims.assembly.service.manufacturing; + +public class AgvArrivalService { +} From da18ac1a39ece8023c5bd487468221757e81e88c Mon Sep 17 00:00:00 2001 From: chani2104 Date: Sat, 4 Jul 2026 14:02:28 +0900 Subject: [PATCH 02/35] =?UTF-8?q?feat:=20AGV=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A7=91=20/=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20Ready=20=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aims/assembly/config/SecurityConfig.java | 1 + .../internal/AgvArrivalController.java | 25 ++++++++++++- .../dto/manufacturing/AgvArrivalRequest.java | 12 +++++- .../kafka/ManufacturingKafkaConsumer.java | 13 ++++--- .../ManufacturingEventJsonRepository.java | 1 + .../manufacturing/AgvArrivalService.java | 37 ++++++++++++++++++- .../ManufacturingRawEventService.java | 17 +++++++-- 7 files changed, 93 insertions(+), 13 deletions(-) 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/internal/AgvArrivalController.java b/src/main/java/com/aims/assembly/controller/internal/AgvArrivalController.java index 8473fd9..a60bfa5 100644 --- a/src/main/java/com/aims/assembly/controller/internal/AgvArrivalController.java +++ b/src/main/java/com/aims/assembly/controller/internal/AgvArrivalController.java @@ -1,4 +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/dto/manufacturing/AgvArrivalRequest.java b/src/main/java/com/aims/assembly/dto/manufacturing/AgvArrivalRequest.java index b5e75ad..1d7717f 100644 --- a/src/main/java/com/aims/assembly/dto/manufacturing/AgvArrivalRequest.java +++ b/src/main/java/com/aims/assembly/dto/manufacturing/AgvArrivalRequest.java @@ -1,4 +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/kafka/ManufacturingKafkaConsumer.java b/src/main/java/com/aims/assembly/kafka/ManufacturingKafkaConsumer.java index 535b2d6..9573d0c 100644 --- a/src/main/java/com/aims/assembly/kafka/ManufacturingKafkaConsumer.java +++ b/src/main/java/com/aims/assembly/kafka/ManufacturingKafkaConsumer.java @@ -320,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/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/service/manufacturing/AgvArrivalService.java b/src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java index 9e57a16..874028d 100644 --- a/src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java +++ b/src/main/java/com/aims/assembly/service/manufacturing/AgvArrivalService.java @@ -1,4 +1,39 @@ package com.aims.assembly.service.manufacturing; +import com.aims.assembly.repository.event.ManufacturingEventJsonRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor public class AgvArrivalService { -} + + private final ManufacturingEventJsonRepository repository; + + @Transactional + public void handleArrival(String eventId) { + + int updated = + repository.releaseNextProcessByEventId(eventId); + + if (updated == 0) { + + log.warn( + "[AGV ARRIVAL] 다음 공정을 READY로 변경하지 못했습니다. eventId={}", + eventId + ); + + return; + } + + + log.info( + "[AGV ARRIVAL] 다음 공정을 READY로 변경했습니다. eventId={}, updated={}", + eventId, + updated + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingRawEventService.java b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingRawEventService.java index b6223e7..80b9062 100644 --- a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingRawEventService.java +++ b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingRawEventService.java @@ -61,25 +61,34 @@ public CompletableFuture sendById(long id) { public CompletableFuture sendNextReady() { return execute(() -> requireSuccess(transactionTemplate.execute(status -> { - repository.prepareDispatchablePendingEvents(LocalDateTime.now(), 1); StoredManufacturingEvent event = repository .findReadyForUpdate(LocalDateTime.now(), 1, maxRetries()).stream().findFirst() .orElseThrow(() -> new KafkaException(KafkaErrorStatus.UNSENT_EVENT_NOT_FOUND)); + return publishLocked(event); }))); } public CompletableFuture> sendNextReadyBatch(int batchSize) { int limit = Math.max(1, Math.min(batchSize, 1_000)); + return execute(() -> transactionTemplate.execute(status -> { - repository.prepareDispatchablePendingEvents(LocalDateTime.now(), limit); List events = repository.findReadyForUpdate( - LocalDateTime.now(), limit, maxRetries()); + LocalDateTime.now(), + limit, + maxRetries() + ); + List results = new ArrayList<>(events.size()); + for (StoredManufacturingEvent event : events) { PublishOutcome outcome = publishLocked(event); - if (outcome.result() != null) results.add(outcome.result()); + + if (outcome.result() != null) { + results.add(outcome.result()); + } } + return List.copyOf(results); })); } From c1f624e42f46a4c0b6e6ac768bc7d7715d91334a Mon Sep 17 00:00:00 2001 From: haseokyung6 Date: Sat, 4 Jul 2026 15:41:35 +0900 Subject: [PATCH 03/35] fix: newTag image update, secret update --- .github/workflows/deploy-assembly-service.yml | 151 +++++++++++++++++- 1 file changed, 146 insertions(+), 5 deletions(-) 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 From 1be77b2f7f836f8f8064a98de0907766402eeb2b Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Sat, 4 Jul 2026 15:55:55 +0900 Subject: [PATCH 04/35] =?UTF-8?q?fix:=20event=20json=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=20=EB=B3=91=EB=AA=A9&=EB=B6=88=EB=9F=89=20?= =?UTF-8?q?=EC=A0=84=EC=9D=B4=20=EA=B2=80=EC=82=AC=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assembly/domain/event/ManufacturingEventJson.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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; } From 1b56bde5833babd88b44433d8b24e3f49af77dfb Mon Sep 17 00:00:00 2001 From: hyein0514 Date: Tue, 7 Jul 2026 13:12:53 +0900 Subject: [PATCH 05/35] =?UTF-8?q?refactor:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=EA=B0=84=20=EC=B6=9C=EB=A0=A5=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aims/assembly/AssemblyApplication.java | 11 ---- .../assembly/config/JpaAuditingConfig.java | 22 +++++++ .../dto/process/PaintDashboardResponse.java | 33 ++++++++-- .../ProcessDashboardResponseMapper.java | 26 ++++++++ .../AssemblyAnalysisResultRepository.java | 14 ++--- .../PaintAnalysisResultRepository.java | 14 ++--- .../process/ProcessDashboardService.java | 41 ++++++++++-- src/main/resources/application.yaml | 4 +- .../config/JpaAuditingConfigTest.java | 24 +++++++ .../ProcessDashboardControllerTest.java | 38 +++++++++++- .../commons/BaseEntityAuditingJpaTest.java | 62 +++++++++++++++++++ .../process/ProcessDashboardServiceTest.java | 56 ++++++++++++++--- 12 files changed, 295 insertions(+), 50 deletions(-) create mode 100644 src/main/java/com/aims/assembly/config/JpaAuditingConfig.java create mode 100644 src/test/java/com/aims/assembly/config/JpaAuditingConfigTest.java create mode 100644 src/test/java/com/aims/assembly/domain/commons/BaseEntityAuditingJpaTest.java diff --git a/src/main/java/com/aims/assembly/AssemblyApplication.java b/src/main/java/com/aims/assembly/AssemblyApplication.java index b717962..5cee033 100644 --- a/src/main/java/com/aims/assembly/AssemblyApplication.java +++ b/src/main/java/com/aims/assembly/AssemblyApplication.java @@ -3,17 +3,12 @@ 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 { @@ -24,10 +19,4 @@ public static void main(String[] args) { 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)); - } - } 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/dto/process/PaintDashboardResponse.java b/src/main/java/com/aims/assembly/dto/process/PaintDashboardResponse.java index 870c298..13bdd6f 100644 --- a/src/main/java/com/aims/assembly/dto/process/PaintDashboardResponse.java +++ b/src/main/java/com/aims/assembly/dto/process/PaintDashboardResponse.java @@ -12,10 +12,20 @@ public record PaintDashboardResponse( ) { 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 ChartPoint( @@ -33,8 +43,23 @@ public record ChartPoint( public record Alert( String title, - List messages + List messages, + Detail detail ) { + public Alert(String title, List messages) { + this(title, messages, null); + } + + public record Detail( + String visionLabel, + String imagePosition, + Double thicknessValue, + Double surfaceQualityScore, + Double thermalStdTemp, + Double riskScore, + String severity + ) { + } } public static PaintDashboardResponse empty() { @@ -44,7 +69,7 @@ public static PaintDashboardResponse empty() { public static PaintDashboardResponse empty(LocalDate selectedDate) { return new PaintDashboardResponse( selectedDate, - new Summary(0, 0.0, 0.0, 0), + new Summary(0, 0.0, 0.0, 0.0, 0, 0.0), List.of(), null ); diff --git a/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java b/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java index e3526a2..afa83c8 100644 --- a/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java +++ b/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java @@ -66,6 +66,24 @@ public static PaintDashboardResponse.Summary toPaintSummary( return new PaintDashboardResponse.Summary(analysisCount, defectRate, averageSurfaceQualityScore, alertCount); } + public static PaintDashboardResponse.Summary toPaintSummary( + long analysisCount, + double averageThicknessValue, + double averageSurfaceQualityScore, + double defectRate, + long alertCount, + double averageThermalStdTemp + ) { + return new PaintDashboardResponse.Summary( + analysisCount, + averageThicknessValue, + averageSurfaceQualityScore, + defectRate, + alertCount, + averageThermalStdTemp + ); + } + public static PaintDashboardResponse.ChartPoint toPaintChartPoint( LocalDateTime time, Double defectScore, @@ -94,6 +112,14 @@ 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, AssemblyDashboardResponse.Summary summary, 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..fc65df8 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,12 @@ List findDashboardRows( ); @Query(""" - select result.analyzedAt + 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..ad2d0b2 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 asc, result.id asc """) List findDashboardRows( @Param("from") LocalDateTime from, @@ -29,12 +29,12 @@ List findDashboardRows( ); @Query(""" - select result.analyzedAt + 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/service/process/ProcessDashboardService.java b/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java index 44b810d..e34e1db 100644 --- a/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java +++ b/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java @@ -108,12 +108,22 @@ public PaintDashboardResponse getPaintDashboard( .map(PaintAnalysisResult::getSurfaceQualityScore) .filter(Objects::nonNull) .toList()); + double averageThicknessValue = average(rows.stream() + .map(PaintAnalysisResult::getThicknessValue) + .filter(Objects::nonNull) + .toList()); + double averageThermalStdTemp = average(rows.stream() + .map(PaintAnalysisResult::getThermalStdTemp) + .filter(Objects::nonNull) + .toList()); PaintDashboardResponse.Summary summary = ProcessDashboardResponseMapper.toPaintSummary( analysisCount, - percentage(abnormalCount, analysisCount), + averageThicknessValue, averageSurfaceQualityScore, - alertCount + percentage(abnormalCount, analysisCount), + alertCount, + averageThermalStdTemp ); List chart = rows.stream() @@ -328,6 +338,12 @@ private PaintDashboardResponse.Alert paintAlert(PaintAnalysisResult row) { } ManufacturingAnalysisResult result = row.getAnalysisResult(); List messages = new ArrayList<>(); + messages.add("비전 판정: " + nullToDash(row.getVisionLabel())); + messages.add("이상 위치: " + nullToDash(row.getImagePosition())); + messages.add("도막 두께: " + formatNullable(row.getThicknessValue()) + " μm"); + messages.add("표면 품질 점수: " + formatNullable(row.getSurfaceQualityScore()) + "점"); + messages.add("열 편차: " + formatNullable(row.getThermalStdTemp()) + "℃"); + messages.add("위험도: " + format(riskScore(result))); if ("DEFECT".equalsIgnoreCase(row.getVisionLabel())) { messages.add("비전 불량 라벨 감지: " + row.getVisionLabel()); } @@ -353,7 +369,16 @@ private PaintDashboardResponse.Alert paintAlert(PaintAnalysisResult row) { && (row.getThicknessValue() < THICKNESS_MIN || row.getThicknessValue() > THICKNESS_MAX)) { messages.add("표면 품질 점수 저하 및 두께 이상 의심"); } - return ProcessDashboardResponseMapper.toPaintAlert("도장 품질 이상 감지", messages); + PaintDashboardResponse.Alert.Detail detail = new PaintDashboardResponse.Alert.Detail( + row.getVisionLabel(), + row.getImagePosition(), + row.getThicknessValue(), + row.getSurfaceQualityScore(), + row.getThermalStdTemp(), + result.getRiskScore(), + severityName(result) + ); + return ProcessDashboardResponseMapper.toPaintAlert("도장 품질 이상 감지", messages, detail); } private AssemblyDashboardResponse.Alert assemblyAlert(AssemblyAnalysisResult row) { @@ -402,11 +427,11 @@ private QueryRange queryRange( } private List availablePaintDates() { - return availableDates(paintRepository.findDashboardAnalyzedAtValues()); + return availableDates(paintRepository.findDashboardEventTimeValues()); } private List availableAssemblyDates() { - return availableDates(assemblyRepository.findDashboardAnalyzedAtValues()); + return availableDates(assemblyRepository.findDashboardEventTimeValues()); } private LocalDate latestPaintDate() { @@ -438,7 +463,7 @@ private int normalizeLimit(Integer limit) { } private LocalDateTime displayTime(ManufacturingAnalysisResult result) { - return result.getAnalyzedAt() != null ? result.getAnalyzedAt() : result.getEventTime(); + return result.getEventTime(); } private boolean isAlert(ManufacturingAnalysisResult result) { @@ -502,6 +527,10 @@ private String nullToDash(String value) { return value == null || value.isBlank() ? "-" : value; } + private String formatNullable(Double value) { + return value == null ? "-" : format(value); + } + private double round(double value) { return Math.round(value * 10.0) / 10.0; } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2185967..6d42d01 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -70,7 +70,7 @@ app: datasource: main: driver-class-name: ${MAIN_DB_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver} - jdbc-url: ${MAIN_DB_JDBC_URL:jdbc:mysql://127.0.0.1:13306/maindb?serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=UTF-8&useSSL=false} + jdbc-url: ${MAIN_DB_JDBC_URL:jdbc:mysql://127.0.0.1:13306/maindb?serverTimezone=Asia/Seoul&sessionVariables=time_zone='%2B09:00'&useUnicode=true&characterEncoding=UTF-8&useSSL=false} username: ${MAIN_DB_USERNAME:admin} password: ${MAIN_DB_PASSWORD:} maximum-pool-size: ${MAIN_DB_MAXIMUM_POOL_SIZE:10} @@ -78,7 +78,7 @@ app: pool-name: ${MAIN_DB_POOL_NAME:main-db-pool} sample: driver-class-name: ${SAMPLE_DB_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver} - jdbc-url: ${SAMPLE_DB_JDBC_URL:jdbc:mysql://127.0.0.1:13306/sampledb?serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=UTF-8&useSSL=false} + jdbc-url: ${SAMPLE_DB_JDBC_URL:jdbc:mysql://127.0.0.1:13306/sampledb?serverTimezone=Asia/Seoul&sessionVariables=time_zone='%2B09:00'&useUnicode=true&characterEncoding=UTF-8&useSSL=false} username: ${SAMPLE_DB_USERNAME:admin} password: ${SAMPLE_DB_PASSWORD:} maximum-pool-size: ${SAMPLE_DB_MAXIMUM_POOL_SIZE:5} diff --git a/src/test/java/com/aims/assembly/config/JpaAuditingConfigTest.java b/src/test/java/com/aims/assembly/config/JpaAuditingConfigTest.java new file mode 100644 index 0000000..2fa2c65 --- /dev/null +++ b/src/test/java/com/aims/assembly/config/JpaAuditingConfigTest.java @@ -0,0 +1,24 @@ +package com.aims.assembly.config; + +import org.junit.jupiter.api.Test; +import org.springframework.data.auditing.DateTimeProvider; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class JpaAuditingConfigTest { + + @Test + void auditingDateTimeProviderUsesAsiaSeoulLocalDateTime() { + JpaAuditingConfig config = new JpaAuditingConfig(); + DateTimeProvider provider = config.auditingDateTimeProvider(); + + LocalDateTime before = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + LocalDateTime provided = LocalDateTime.from(provider.getNow().orElseThrow()); + LocalDateTime after = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + + assertThat(provided).isBetween(before, after); + } +} diff --git a/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java b/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java index f704946..60604dd 100644 --- a/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java +++ b/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java @@ -44,7 +44,7 @@ void paintEndpointReturnsSummaryChartAndAlertFields() throws Exception { eq(30) )).thenReturn(new PaintDashboardResponse( LocalDate.of(2026, 6, 18), - new PaintDashboardResponse.Summary(6, 50.0, 81.2, 3), + new PaintDashboardResponse.Summary(6, 116.9, 81.2, 50.0, 3, 2.5), List.of(new PaintDashboardResponse.ChartPoint( LocalDateTime.of(2026, 6, 18, 13, 55), 0.87, @@ -58,7 +58,23 @@ void paintEndpointReturnsSummaryChartAndAlertFields() throws Exception { )), new PaintDashboardResponse.Alert( "도장 품질 이상 감지", - List.of("비전 불량 라벨 감지: DEFECT") + List.of( + "비전 판정: DEFECT", + "이상 위치: LEFT", + "도막 두께: 116.5 μm", + "표면 품질 점수: 72.3점", + "열 편차: 4.2℃", + "위험도: 88.5" + ), + new PaintDashboardResponse.Alert.Detail( + "DEFECT", + "LEFT", + 116.5, + 72.3, + 4.2, + 88.5, + "CRITICAL" + ) ) )); @@ -70,11 +86,25 @@ void paintEndpointReturnsSummaryChartAndAlertFields() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.selectedDate").value("2026-06-18")) .andExpect(jsonPath("$.data.summary.analysisCount").value(6)) + .andExpect(jsonPath("$.data.summary.averageThicknessValue").value(116.9)) + .andExpect(jsonPath("$.data.summary.averageSurfaceQualityScore").value(81.2)) + .andExpect(jsonPath("$.data.summary.defectRate").value(50.0)) + .andExpect(jsonPath("$.data.summary.alertCount").value(3)) + .andExpect(jsonPath("$.data.summary.averageThermalStdTemp").value(2.5)) .andExpect(jsonPath("$.data.chart[0].defectScore").value(0.87)) .andExpect(jsonPath("$.data.chart[0].surfaceQualityScore").value(72.3)) .andExpect(jsonPath("$.data.chart[0].imagePosition").value("LEFT")) .andExpect(jsonPath("$.data.chart[0].thicknessValue").value(116.5)) - .andExpect(jsonPath("$.data.alert.title").value("도장 품질 이상 감지")); + .andExpect(jsonPath("$.data.chart[0].riskScore").value(88.5)) + .andExpect(jsonPath("$.data.alert.title").value("도장 품질 이상 감지")) + .andExpect(jsonPath("$.data.alert.messages[0]").value("비전 판정: DEFECT")) + .andExpect(jsonPath("$.data.alert.detail.visionLabel").value("DEFECT")) + .andExpect(jsonPath("$.data.alert.detail.imagePosition").value("LEFT")) + .andExpect(jsonPath("$.data.alert.detail.thicknessValue").value(116.5)) + .andExpect(jsonPath("$.data.alert.detail.surfaceQualityScore").value(72.3)) + .andExpect(jsonPath("$.data.alert.detail.thermalStdTemp").value(4.2)) + .andExpect(jsonPath("$.data.alert.detail.riskScore").value(88.5)) + .andExpect(jsonPath("$.data.alert.detail.severity").value("CRITICAL")); } @Test @@ -157,6 +187,8 @@ void endpointsReturnEmptyResponses() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.selectedDate").doesNotExist()) .andExpect(jsonPath("$.data.summary.analysisCount").value(0)) + .andExpect(jsonPath("$.data.summary.averageThicknessValue").value(0.0)) + .andExpect(jsonPath("$.data.summary.averageThermalStdTemp").value(0.0)) .andExpect(jsonPath("$.data.chart").isEmpty()) .andExpect(jsonPath("$.data.alert").doesNotExist()); diff --git a/src/test/java/com/aims/assembly/domain/commons/BaseEntityAuditingJpaTest.java b/src/test/java/com/aims/assembly/domain/commons/BaseEntityAuditingJpaTest.java new file mode 100644 index 0000000..b86347d --- /dev/null +++ b/src/test/java/com/aims/assembly/domain/commons/BaseEntityAuditingJpaTest.java @@ -0,0 +1,62 @@ +package com.aims.assembly.domain.commons; + +import com.aims.assembly.config.JpaAuditingConfig; +import com.aims.assembly.domain.analysis.ManufacturingAnalysisResult; +import com.aims.assembly.domain.enums.ProcessCode; +import com.aims.assembly.domain.enums.Severity; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest(properties = { + "spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Seoul" +}) +@Import(JpaAuditingConfig.class) +class BaseEntityAuditingJpaTest { + + @Autowired + private EntityManager entityManager; + + @Test + void manufacturingAnalysisResultAuditingUsesAsiaSeoulTime() { + LocalDateTime beforeSave = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + ManufacturingAnalysisResult result = ManufacturingAnalysisResult.builder() + .analysisId("ANL-AUDIT-KST") + .eventId("EVT-AUDIT-KST") + .carMasterId(1L) + .equipmentId(1L) + .processCode(ProcessCode.PAINT) + .eventTime(LocalDateTime.of(2026, 7, 7, 10, 47, 15)) + .isAbnormal(false) + .severity(Severity.NORMAL) + .riskScore(0.0) + .analysisMessage("audit test") + .analyzedAt(LocalDateTime.now(ZoneId.of("Asia/Seoul"))) + .build(); + + entityManager.persist(result); + entityManager.flush(); + entityManager.clear(); + LocalDateTime afterSave = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + + ManufacturingAnalysisResult saved = entityManager.find( + ManufacturingAnalysisResult.class, + result.getId() + ); + + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getUpdatedAt()).isNotNull(); + assertThat(saved.getCreatedAt()).isBetween(beforeSave, afterSave); + assertThat(saved.getUpdatedAt()).isBetween(beforeSave, afterSave); + assertThat(Math.abs(Duration.between(saved.getAnalyzedAt(), saved.getCreatedAt()).toHours())) + .isLessThan(9); + } +} diff --git a/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java b/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java index 9860f5c..f7ace11 100644 --- a/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java +++ b/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java @@ -2,6 +2,7 @@ import com.aims.assembly.domain.analysis.ManufacturingAnalysisResult; import com.aims.assembly.domain.assembly.AssemblyAnalysisResult; +import com.aims.assembly.domain.commons.BaseEntity; import com.aims.assembly.domain.enums.EquipmentOperationStatus; import com.aims.assembly.domain.enums.ProcessCode; import com.aims.assembly.domain.enums.Severity; @@ -44,10 +45,18 @@ void setUp() { ); } + @Test + void paintAndAssemblyDetailsDoNotUseJpaAuditBaseEntity() { + assertThat(BaseEntity.class.isAssignableFrom(PaintAnalysisResult.class)).isFalse(); + assertThat(BaseEntity.class.isAssignableFrom(AssemblyAnalysisResult.class)).isFalse(); + } + @Test void paintDashboardBuildsSummaryChartAndAlert() { + LocalDateTime eventTime = LocalDateTime.of(2026, 6, 18, 13, 55); + LocalDateTime analyzedAt = LocalDateTime.of(2026, 6, 19, 13, 56); PaintAnalysisResult row = PaintAnalysisResult.builder() - .analysisResult(result(ProcessCode.PAINT, 1L, Severity.CRITICAL, true, 88.5)) + .analysisResult(result(ProcessCode.PAINT, 1L, Severity.CRITICAL, true, 88.5, eventTime, analyzedAt)) .defectScore(0.87) .thermalStdTemp(4.2) .surfaceQualityScore(72.3) @@ -66,13 +75,17 @@ void paintDashboardBuildsSummaryChartAndAlert() { assertThat(response.selectedDate()).isEqualTo(LocalDate.of(2026, 6, 18)); assertThat(response.summary().analysisCount()).isEqualTo(1); + assertThat(response.summary().averageThicknessValue()).isEqualTo(126.5); + assertThat(response.summary().averageThermalStdTemp()).isEqualTo(4.2); assertThat(response.summary().defectRate()).isEqualTo(100.0); assertThat(response.summary().averageSurfaceQualityScore()).isEqualTo(72.3); assertThat(response.summary().alertCount()).isEqualTo(1); assertThat(response.chart()).singleElement().satisfies(point -> { + assertThat(point.time()).isEqualTo(eventTime); assertThat(point.defectScore()).isEqualTo(0.87); assertThat(point.surfaceQualityScore()).isEqualTo(72.3); assertThat(point.thicknessValue()).isEqualTo(126.5); + assertThat(point.riskScore()).isEqualTo(88.5); assertThat(point.imagePosition()).isEqualTo("LEFT"); }); assertThat(response.alert().messages()) @@ -82,15 +95,17 @@ void paintDashboardBuildsSummaryChartAndAlert() { @Test void assemblyDashboardBuildsSummaryVehiclesAndAlert() { + LocalDateTime eventTime = LocalDateTime.of(2026, 6, 18, 13, 55); + LocalDateTime analyzedAt = LocalDateTime.of(2026, 6, 19, 13, 56); AssemblyAnalysisResult row = AssemblyAnalysisResult.builder() - .analysisResult(result(ProcessCode.ASSEMBLY, 1L, Severity.CRITICAL, true, 86.5)) + .analysisResult(result(ProcessCode.ASSEMBLY, 1L, Severity.CRITICAL, true, 86.5, eventTime, analyzedAt)) .expectedSequence("A01 > A02 > A03 > A04") .actualSequence("A01 > A03 > A02 > A04") .sequenceErrorCount(1) .missingPartCount(0) .fasteningErrorCount(1) .build(); - when(assemblyRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of( + when(assemblyRepository.findDashboardEventTimeValues()).thenReturn(List.of( LocalDateTime.of(2026, 6, 1, 10, 0), LocalDateTime.of(2026, 6, 18, 10, 0) )); @@ -116,6 +131,7 @@ void assemblyDashboardBuildsSummaryVehiclesAndAlert() { assertThat(vehicle.sequenceErrorCount()).isEqualTo(1); assertThat(vehicle.missingPartCount()).isZero(); assertThat(vehicle.fasteningErrorCount()).isEqualTo(1); + assertThat(vehicle.time()).isEqualTo(eventTime); assertThat(vehicle.status()).isEqualTo("위험"); }); assertThat(response.alert().messages()) @@ -124,8 +140,8 @@ void assemblyDashboardBuildsSummaryVehiclesAndAlert() { @Test void returnsEmptyResponsesWhenNoDataExists() { - when(paintRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of()); - when(assemblyRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of()); + when(paintRepository.findDashboardEventTimeValues()).thenReturn(List.of()); + when(assemblyRepository.findDashboardEventTimeValues()).thenReturn(List.of()); when(paintRepository.findDashboardRows(any(), any(), any(Pageable.class))) .thenReturn(List.of()); when(assemblyRepository.findDashboardRows(any(), any(), any(Pageable.class))) @@ -150,7 +166,7 @@ void paintDashboardWithoutDateUsesLatestAvailableDate() { .analysisResult(result(ProcessCode.PAINT, 1L, Severity.NORMAL, false, 10.0)) .surfaceQualityScore(90.0) .build(); - when(paintRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of( + when(paintRepository.findDashboardEventTimeValues()).thenReturn(List.of( LocalDateTime.of(2026, 6, 1, 10, 0), LocalDateTime.of(2026, 6, 18, 10, 0) )); @@ -168,12 +184,12 @@ void paintDashboardWithoutDateUsesLatestAvailableDate() { @Test void dateApisReturnDatesAndLatestDate() { - when(paintRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of( + when(paintRepository.findDashboardEventTimeValues()).thenReturn(List.of( LocalDateTime.of(2026, 6, 1, 10, 0), LocalDateTime.of(2026, 6, 1, 11, 0), LocalDateTime.of(2026, 6, 18, 10, 0) )); - when(assemblyRepository.findDashboardAnalyzedAtValues()).thenReturn(List.of( + when(assemblyRepository.findDashboardEventTimeValues()).thenReturn(List.of( LocalDateTime.of(2026, 6, 1, 10, 0) )); @@ -240,6 +256,26 @@ private ManufacturingAnalysisResult result( Severity severity, boolean abnormal, double riskScore + ) { + return result( + processCode, + carMasterId, + severity, + abnormal, + riskScore, + LocalDateTime.of(2026, 6, 18, 13, 55), + LocalDateTime.of(2026, 6, 18, 13, 56) + ); + } + + private ManufacturingAnalysisResult result( + ProcessCode processCode, + Long carMasterId, + Severity severity, + boolean abnormal, + double riskScore, + LocalDateTime eventTime, + LocalDateTime analyzedAt ) { return ManufacturingAnalysisResult.builder() .analysisId("ANL-" + processCode) @@ -247,8 +283,8 @@ private ManufacturingAnalysisResult result( .carMasterId(carMasterId) .equipmentId(10L) .processCode(processCode) - .eventTime(LocalDateTime.of(2026, 6, 18, 13, 55)) - .analyzedAt(LocalDateTime.of(2026, 6, 18, 13, 56)) + .eventTime(eventTime) + .analyzedAt(analyzedAt) .isAbnormal(abnormal) .severity(severity) .riskScore(riskScore) From 69ab899218723ec01433e45030a19f41c747ec5c Mon Sep 17 00:00:00 2001 From: chani2104 Date: Tue, 7 Jul 2026 16:36:09 +0900 Subject: [PATCH 06/35] =?UTF-8?q?fix:=20=EC=A0=9C=EC=A1=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20Abnormal=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aims/assembly/kafka/ManufacturingKafkaConsumer.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/aims/assembly/kafka/ManufacturingKafkaConsumer.java b/src/main/java/com/aims/assembly/kafka/ManufacturingKafkaConsumer.java index 6026c50..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) { From 03cbb3967203a99204bfd6e95c14b1293f31eb7d Mon Sep 17 00:00:00 2001 From: hyein0514 Date: Tue, 7 Jul 2026 16:36:27 +0900 Subject: [PATCH 07/35] =?UTF-8?q?refactor:=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20Api=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../process/AssemblyDashboardResponse.java | 21 + .../dto/process/PaintDashboardResponse.java | 119 ++++- .../ProcessDashboardResponseMapper.java | 49 +- .../AssemblyAnalysisResultRepository.java | 14 + .../PaintAnalysisResultRepository.java | 16 +- .../ManufacturingAnalysisResultService.java | 25 +- .../process/ProcessDashboardService.java | 443 ++++++++++++++---- .../ProcessDashboardControllerTest.java | 125 ++++- ...anufacturingAnalysisResultServiceTest.java | 88 ++++ .../process/ProcessDashboardServiceTest.java | 264 ++++++++++- 10 files changed, 990 insertions(+), 174 deletions(-) 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/PaintDashboardResponse.java b/src/main/java/com/aims/assembly/dto/process/PaintDashboardResponse.java index 13bdd6f..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,8 +6,13 @@ 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( @@ -28,16 +33,79 @@ public Summary( } } - public record ChartPoint( + 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 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 ) { } @@ -51,13 +119,15 @@ public Alert(String title, List messages) { } public record Detail( + LocalDateTime time, String visionLabel, String imagePosition, Double thicknessValue, Double surfaceQualityScore, + Double defectScore, Double thermalStdTemp, Double riskScore, - String severity + String status ) { } } @@ -67,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, + from, + to, + null, + null, new Summary(0, 0.0, 0.0, 0.0, 0, 0.0), - List.of(), - null + 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/mapper/ProcessDashboardResponseMapper.java b/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java index afa83c8..65b0ec6 100644 --- a/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java +++ b/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java @@ -50,11 +50,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( @@ -84,30 +99,6 @@ public static PaintDashboardResponse.Summary toPaintSummary( ); } - public static PaintDashboardResponse.ChartPoint toPaintChartPoint( - LocalDateTime time, - Double defectScore, - Double surfaceQualityScore, - Double thicknessValue, - Double riskScore, - String imagePosition, - String visionLabel, - Double thermalStdTemp, - String severity - ) { - return new PaintDashboardResponse.ChartPoint( - time, - defectScore, - surfaceQualityScore, - thicknessValue, - riskScore, - imagePosition, - visionLabel, - thermalStdTemp, - severity - ); - } - public static PaintDashboardResponse.Alert toPaintAlert(String title, List messages) { return new PaintDashboardResponse.Alert(title, messages); } @@ -122,11 +113,15 @@ public static PaintDashboardResponse.Alert toPaintAlert( 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 fc65df8..bb95914 100644 --- a/src/main/java/com/aims/assembly/repository/analysis/AssemblyAnalysisResultRepository.java +++ b/src/main/java/com/aims/assembly/repository/analysis/AssemblyAnalysisResultRepository.java @@ -28,6 +28,20 @@ List findDashboardRows( Pageable pageable ); + @Query(""" + 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 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 ad2d0b2..b5d2caf 100644 --- a/src/main/java/com/aims/assembly/repository/analysis/PaintAnalysisResultRepository.java +++ b/src/main/java/com/aims/assembly/repository/analysis/PaintAnalysisResultRepository.java @@ -20,7 +20,7 @@ public interface PaintAnalysisResultRepository extends JpaRepository= :from) and (:to is null or result.eventTime < :to) - order by result.eventTime asc, result.id asc + order by result.eventTime desc, result.id desc """) List findDashboardRows( @Param("from") LocalDateTime from, @@ -28,6 +28,20 @@ List findDashboardRows( Pageable pageable ); + @Query(""" + 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 diff --git a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java index d6aba40..2ca1185 100644 --- a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java +++ b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java @@ -885,7 +885,11 @@ private AssemblyCalculatedValues calculateAssemblyValues( double riskScore = savedResult.getRiskScore() == null ? 0.0 : savedResult.getRiskScore(); if (expectedSequence == null || isNullText(expectedSequence)) { - expectedSequence = "PART_CHECK->FASTENING->TORQUE_CHECK->FINAL_INSPECTION"; + expectedSequence = null; + } + + if (actualSequence == null || isNullText(actualSequence)) { + actualSequence = null; } int seqVariation = eventIdIndex(savedResult.getEventId() + ":assembly:seq", 3); @@ -904,19 +908,10 @@ private AssemblyCalculatedValues calculateAssemblyValues( if (fasteningErrorCount == null || fasteningErrorCount <= 0) { fasteningErrorCount = riskScore >= 80 ? 2 + fasteningVariation : 1 + fasteningVariation; } - - if (actualSequence == null || isNullText(actualSequence) - || actualSequence.equals(expectedSequence)) { - actualSequence = resolveAssemblyActualSequence(savedResult); - } } else { if (sequenceErrorCount == null) sequenceErrorCount = 0; if (missingPartCount == null) missingPartCount = 0; if (fasteningErrorCount == null) fasteningErrorCount = 0; - - if (actualSequence == null || isNullText(actualSequence)) { - actualSequence = expectedSequence; - } } return new AssemblyCalculatedValues( @@ -928,16 +923,6 @@ private AssemblyCalculatedValues calculateAssemblyValues( ); } - private String resolveAssemblyActualSequence(ManufacturingAnalysisResult savedResult) { - String[] abnormalSequences = { - "PART_CHECK->TORQUE_CHECK->FASTENING->FINAL_INSPECTION", - "PART_CHECK->FASTENING->FINAL_INSPECTION", - "FASTENING->PART_CHECK->TORQUE_CHECK->FINAL_INSPECTION", - "PART_CHECK->FASTENING->TORQUE_RETRY->FINAL_INSPECTION" - }; - return abnormalSequences[eventIdIndex(savedResult.getEventId(), abnormalSequences.length)]; - } - private record AssemblyCalculatedValues( String expectedSequence, String actualSequence, diff --git a/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java b/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java index e34e1db..150ca27 100644 --- a/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java +++ b/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java @@ -23,6 +23,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.ArrayList; import java.util.Comparator; import java.util.EnumMap; @@ -37,11 +38,20 @@ public class ProcessDashboardService { private static final int DEFAULT_LIMIT = 30; private static final int MAX_LIMIT = 200; - private static final double DEFECT_SCORE_ALERT_THRESHOLD = 0.7; - private static final double SURFACE_QUALITY_ALERT_THRESHOLD = 85.0; - private static final double THICKNESS_MIN = 115.0; - private static final double THICKNESS_MAX = 125.0; - private static final double THERMAL_STD_TEMP_ALERT_THRESHOLD = 3.0; + private static final String STATUS_NORMAL = "NORMAL"; + private static final String STATUS_WARNING = "WARNING"; + private static final String STATUS_DANGER = "DANGER"; + private static final double SURFACE_QUALITY_WARNING_BELOW = 80.0; + private static final double SURFACE_QUALITY_DANGER_BELOW = 60.0; + private static final double THICKNESS_TARGET = 115.0; + private static final double THICKNESS_NORMAL_MIN = 90.0; + private static final double THICKNESS_NORMAL_MAX = 120.0; + private static final double THICKNESS_WARNING_MIN = 80.0; + private static final double THICKNESS_WARNING_MAX = 130.0; + private static final double DEFECT_SCORE_WARNING_ABOVE = 0.4; + private static final double DEFECT_SCORE_DANGER_ABOVE = 0.6; + private static final double THERMAL_STD_TEMP_WARNING_ABOVE = 2.0; + private static final double THERMAL_STD_TEMP_DANGER_ABOVE = 5.0; private final PaintAnalysisResultRepository paintRepository; private final AssemblyAnalysisResultRepository assemblyRepository; @@ -81,13 +91,21 @@ public PaintDashboardResponse getPaintDashboard( ) { LocalDate latestDate = date == null && from == null && to == null ? latestPaintDate() : null; QueryRange range = queryRange(date, from, to, latestDate); - List rows = paintRepository.findDashboardRows( + List summaryRows = paintRepository.findDashboardSummaryRows( + range.from(), + range.to() + ); + List chartRows = sortPaintByEventTimeAsc(paintRepository.findDashboardRows( range.from(), range.to(), PageRequest.of(0, normalizeLimit(limit)) - ); - if (rows.isEmpty()) { - PaintDashboardResponse emptyResponse = PaintDashboardResponse.empty(range.selectedDate()); + )); + if (summaryRows.isEmpty()) { + PaintDashboardResponse emptyResponse = PaintDashboardResponse.empty( + range.selectedDate(), + range.from(), + responseTo(range) + ); log.info( "도장 대시보드 조회 완료: date={}, from={}, to={}, 건수=0", range.selectedDate(), @@ -97,22 +115,22 @@ public PaintDashboardResponse getPaintDashboard( return emptyResponse; } - long analysisCount = rows.size(); - long abnormalCount = rows.stream() - .filter(row -> Boolean.TRUE.equals(row.getAnalysisResult().getIsAbnormal())) + long analysisCount = summaryRows.size(); + long defectCount = summaryRows.stream() + .filter(this::isPaintDefect) .count(); - long alertCount = rows.stream() - .filter(row -> isAlert(row.getAnalysisResult())) + long alertCount = summaryRows.stream() + .filter(row -> !STATUS_NORMAL.equals(overallPaintStatus(row))) .count(); - double averageSurfaceQualityScore = average(rows.stream() - .map(PaintAnalysisResult::getSurfaceQualityScore) + double averageSurfaceQualityScore = average(summaryRows.stream() + .map(this::surfaceQualityValue) .filter(Objects::nonNull) .toList()); - double averageThicknessValue = average(rows.stream() + double averageThicknessValue = average(summaryRows.stream() .map(PaintAnalysisResult::getThicknessValue) .filter(Objects::nonNull) .toList()); - double averageThermalStdTemp = average(rows.stream() + double averageThermalStdTemp = average(summaryRows.stream() .map(PaintAnalysisResult::getThermalStdTemp) .filter(Objects::nonNull) .toList()); @@ -121,39 +139,31 @@ public PaintDashboardResponse getPaintDashboard( analysisCount, averageThicknessValue, averageSurfaceQualityScore, - percentage(abnormalCount, analysisCount), + percentage(defectCount, analysisCount), alertCount, averageThermalStdTemp ); - List chart = rows.stream() - .map(row -> { - ManufacturingAnalysisResult result = row.getAnalysisResult(); - return ProcessDashboardResponseMapper.toPaintChartPoint( - displayTime(result), - row.getDefectScore(), - row.getSurfaceQualityScore(), - row.getThicknessValue(), - result.getRiskScore(), - row.getImagePosition(), - row.getVisionLabel(), - row.getThermalStdTemp(), - severityName(result) - ); - }) - .toList(); + PaintDashboardResponse.Charts charts = paintCharts(chartRows); - PaintAnalysisResult alertRow = rows.stream() + PaintAnalysisResult alertRow = summaryRows.stream() + .filter(row -> !STATUS_NORMAL.equals(overallPaintStatus(row))) .max(Comparator - .comparing((PaintAnalysisResult row) -> riskScore(row.getAnalysisResult())) - .thenComparing(row -> displayTime(row.getAnalysisResult()), + .comparing((PaintAnalysisResult row) -> displayTime(row.getAnalysisResult()), + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(row -> row.getAnalysisResult().getId(), Comparator.nullsLast(Comparator.naturalOrder()))) .orElse(null); PaintDashboardResponse response = ProcessDashboardResponseMapper.toPaintDashboardResponse( range.selectedDate(), + range.from(), + responseTo(range), + paintStartAt(chartRows), + paintEndAt(chartRows), summary, - chart, + paintThresholds(), + charts, paintAlert(alertRow) ); log.info( @@ -161,7 +171,7 @@ public PaintDashboardResponse getPaintDashboard( range.selectedDate(), range.from(), range.to(), - rows.size() + chartRows.size() ); return response; } @@ -181,13 +191,21 @@ public AssemblyDashboardResponse getAssemblyDashboard( ) { LocalDate latestDate = date == null && from == null && to == null ? latestAssemblyDate() : null; QueryRange range = queryRange(date, from, to, latestDate); - List rows = assemblyRepository.findDashboardRows( + List summaryRows = assemblyRepository.findDashboardSummaryRows( + range.from(), + range.to() + ); + List vehicleRows = sortAssemblyByEventTimeAsc(assemblyRepository.findDashboardRows( range.from(), range.to(), PageRequest.of(0, normalizeLimit(limit)) - ); - if (rows.isEmpty()) { - AssemblyDashboardResponse emptyResponse = AssemblyDashboardResponse.empty(range.selectedDate()); + )); + if (summaryRows.isEmpty()) { + AssemblyDashboardResponse emptyResponse = AssemblyDashboardResponse.empty( + range.selectedDate(), + range.from(), + responseTo(range) + ); log.info( "조립 대시보드 조회 완료: date={}, from={}, to={}, 건수=0", range.selectedDate(), @@ -197,21 +215,21 @@ public AssemblyDashboardResponse getAssemblyDashboard( return emptyResponse; } - long vehicleCount = rows.stream() + long vehicleCount = summaryRows.stream() .map(row -> row.getAnalysisResult().getCarMasterId()) .filter(Objects::nonNull) .distinct() .count(); - long sequenceErrorCount = sum(rows.stream() + long sequenceErrorCount = sum(summaryRows.stream() .map(AssemblyAnalysisResult::getSequenceErrorCount) .toList()); - long missingPartCount = sum(rows.stream() + long missingPartCount = sum(summaryRows.stream() .map(AssemblyAnalysisResult::getMissingPartCount) .toList()); - long fasteningErrorCount = sum(rows.stream() + long fasteningErrorCount = sum(summaryRows.stream() .map(AssemblyAnalysisResult::getFasteningErrorCount) .toList()); - double averageRiskScore = average(rows.stream() + double averageRiskScore = average(summaryRows.stream() .map(row -> row.getAnalysisResult().getRiskScore()) .filter(Objects::nonNull) .toList()); @@ -224,7 +242,7 @@ public AssemblyDashboardResponse getAssemblyDashboard( averageRiskScore ); - List vehicles = rows.stream() + List vehicles = vehicleRows.stream() .map(row -> { ManufacturingAnalysisResult result = row.getAnalysisResult(); return ProcessDashboardResponseMapper.toAssemblyVehicleRow( @@ -243,7 +261,7 @@ public AssemblyDashboardResponse getAssemblyDashboard( }) .toList(); - AssemblyAnalysisResult alertRow = rows.stream() + AssemblyAnalysisResult alertRow = vehicleRows.stream() .max(Comparator .comparing((AssemblyAnalysisResult row) -> riskScore(row.getAnalysisResult())) .thenComparing(row -> displayTime(row.getAnalysisResult()), @@ -252,6 +270,10 @@ public AssemblyDashboardResponse getAssemblyDashboard( AssemblyDashboardResponse response = ProcessDashboardResponseMapper.toAssemblyDashboardResponse( range.selectedDate(), + range.from(), + responseTo(range), + assemblyStartAt(vehicleRows), + assemblyEndAt(vehicleRows), summary, vehicles, assemblyAlert(alertRow) @@ -261,7 +283,7 @@ public AssemblyDashboardResponse getAssemblyDashboard( range.selectedDate(), range.from(), range.to(), - rows.size() + vehicleRows.size() ); return response; } @@ -334,49 +356,31 @@ private String processName(ProcessCode processCode) { private PaintDashboardResponse.Alert paintAlert(PaintAnalysisResult row) { if (row == null) { - return null; + return ProcessDashboardResponseMapper.toPaintAlert( + "최근 도장 상태 정상", + List.of("선택한 시간 범위 내 신규 위험 알람 없음") + ); } ManufacturingAnalysisResult result = row.getAnalysisResult(); + String status = overallPaintStatus(row); List messages = new ArrayList<>(); messages.add("비전 판정: " + nullToDash(row.getVisionLabel())); messages.add("이상 위치: " + nullToDash(row.getImagePosition())); messages.add("도막 두께: " + formatNullable(row.getThicknessValue()) + " μm"); - messages.add("표면 품질 점수: " + formatNullable(row.getSurfaceQualityScore()) + "점"); + messages.add("표면 품질 점수: " + formatNullable(surfaceQualityValue(row)) + "점"); messages.add("열 편차: " + formatNullable(row.getThermalStdTemp()) + "℃"); - messages.add("위험도: " + format(riskScore(result))); - if ("DEFECT".equalsIgnoreCase(row.getVisionLabel())) { - messages.add("비전 불량 라벨 감지: " + row.getVisionLabel()); - } - if (row.getDefectScore() != null && row.getDefectScore() >= DEFECT_SCORE_ALERT_THRESHOLD) { - messages.add("불량 점수 상승: defect_score " + format(row.getDefectScore())); - } - if (row.getSurfaceQualityScore() != null - && row.getSurfaceQualityScore() < SURFACE_QUALITY_ALERT_THRESHOLD) { - messages.add("표면 품질 점수 저하: surface_quality_score " - + format(row.getSurfaceQualityScore())); - } - if (row.getThicknessValue() != null - && (row.getThicknessValue() < THICKNESS_MIN || row.getThicknessValue() > THICKNESS_MAX)) { - messages.add("도장 두께 이상 의심: thickness_value " + format(row.getThicknessValue())); - } - if (row.getThermalStdTemp() != null - && row.getThermalStdTemp() >= THERMAL_STD_TEMP_ALERT_THRESHOLD) { - messages.add("온도 균일도 이상: thermal_std_temp " + format(row.getThermalStdTemp())); - } - messages.add("위험도/등급: " + format(riskScore(result)) + " / " + severityName(result)); - if (row.getSurfaceQualityScore() != null && row.getSurfaceQualityScore() < SURFACE_QUALITY_ALERT_THRESHOLD - && row.getThicknessValue() != null - && (row.getThicknessValue() < THICKNESS_MIN || row.getThicknessValue() > THICKNESS_MAX)) { - messages.add("표면 품질 점수 저하 및 두께 이상 의심"); - } + messages.add("불량 점수: " + formatNullable(row.getDefectScore())); + messages.add("상태: " + status); PaintDashboardResponse.Alert.Detail detail = new PaintDashboardResponse.Alert.Detail( + displayTime(result), row.getVisionLabel(), row.getImagePosition(), row.getThicknessValue(), - row.getSurfaceQualityScore(), + surfaceQualityValue(row), + row.getDefectScore(), row.getThermalStdTemp(), result.getRiskScore(), - severityName(result) + status ); return ProcessDashboardResponseMapper.toPaintAlert("도장 품질 이상 감지", messages, detail); } @@ -466,6 +470,281 @@ private LocalDateTime displayTime(ManufacturingAnalysisResult result) { return result.getEventTime(); } + private PaintDashboardResponse.Thresholds paintThresholds() { + return new PaintDashboardResponse.Thresholds( + new PaintDashboardResponse.HigherIsBetterThreshold( + "표면 품질 점수", + "점", + "HIGHER_IS_BETTER", + SURFACE_QUALITY_WARNING_BELOW, + SURFACE_QUALITY_DANGER_BELOW + ), + new PaintDashboardResponse.InRangeThreshold( + "도막 두께", + "μm", + "IN_RANGE_IS_BETTER", + THICKNESS_TARGET, + THICKNESS_NORMAL_MIN, + THICKNESS_NORMAL_MAX, + THICKNESS_WARNING_MIN, + THICKNESS_WARNING_MAX + ), + new PaintDashboardResponse.LowerIsBetterThreshold( + "불량 점수", + "", + "LOWER_IS_BETTER", + DEFECT_SCORE_WARNING_ABOVE, + DEFECT_SCORE_DANGER_ABOVE + ), + new PaintDashboardResponse.LowerIsBetterThreshold( + "온도 편차", + "℃", + "LOWER_IS_BETTER", + THERMAL_STD_TEMP_WARNING_ABOVE, + THERMAL_STD_TEMP_DANGER_ABOVE + ) + ); + } + + private PaintDashboardResponse.Charts paintCharts(List rows) { + return new PaintDashboardResponse.Charts( + metricChart( + "표면 품질 점수 추이", + "surfaceQualityScore", + "점", + rows.stream() + .map(row -> metricPoint(row, surfaceQualityValue(row), surfaceQualityStatus(row))) + .toList(), + List.of() + ), + metricChart( + "도막 두께 추이", + "thicknessValue", + "μm", + rows.stream() + .map(row -> metricPoint(row, row.getThicknessValue(), thicknessStatus(row))) + .toList(), + List.of() + ), + metricChart( + "불량 점수 추이", + "defectScore", + "", + rows.stream() + .map(row -> metricPoint(row, row.getDefectScore(), defectScoreStatus(row))) + .toList(), + rows.stream() + .filter(this::isDefectMarker) + .map(row -> new PaintDashboardResponse.MetricMarker( + displayTime(row.getAnalysisResult()), + row.getDefectScore(), + row.getVisionLabel(), + row.getImagePosition(), + defectScoreStatus(row), + row.getAnalysisResult().getId() + )) + .toList() + ), + metricChart( + "온도 편차 추이", + "thermalStdTemp", + "℃", + rows.stream() + .map(row -> metricPoint(row, row.getThermalStdTemp(), thermalStdTempStatus(row))) + .toList(), + List.of() + ) + ); + } + + private PaintDashboardResponse.MetricChart metricChart( + String title, + String metricKey, + String unit, + List points, + List markers + ) { + return new PaintDashboardResponse.MetricChart(title, metricKey, unit, points, markers); + } + + private PaintDashboardResponse.MetricPoint metricPoint( + PaintAnalysisResult row, + Double value, + String status + ) { + ManufacturingAnalysisResult result = row.getAnalysisResult(); + return new PaintDashboardResponse.MetricPoint( + displayTime(result), + value, + status, + row.getVisionLabel(), + row.getImagePosition(), + result.getRiskScore(), + result.getId() + ); + } + + private Double surfaceQualityValue(PaintAnalysisResult row) { + Double value = row.getSurfaceQualityScore(); + if (value != null + && value == 0.0 + && normalVision(row.getVisionLabel()) + && (row.getAnalysisResult().getSeverity() == null + || row.getAnalysisResult().getSeverity() == Severity.NORMAL) + && riskScore(row.getAnalysisResult()) == 0.0) { + return null; + } + return value; + } + + private String surfaceQualityStatus(PaintAnalysisResult row) { + Double value = surfaceQualityValue(row); + if (value == null) { + return STATUS_NORMAL; + } + if (value < SURFACE_QUALITY_DANGER_BELOW) { + return STATUS_DANGER; + } + if (value < SURFACE_QUALITY_WARNING_BELOW) { + return STATUS_WARNING; + } + return STATUS_NORMAL; + } + + private String thicknessStatus(PaintAnalysisResult row) { + Double value = row.getThicknessValue(); + if (value == null) { + return STATUS_NORMAL; + } + if (value < THICKNESS_WARNING_MIN || value > THICKNESS_WARNING_MAX) { + return STATUS_DANGER; + } + if (value < THICKNESS_NORMAL_MIN || value > THICKNESS_NORMAL_MAX) { + return STATUS_WARNING; + } + return STATUS_NORMAL; + } + + private String defectScoreStatus(PaintAnalysisResult row) { + Double value = row.getDefectScore(); + String status = STATUS_NORMAL; + if (value != null && value >= DEFECT_SCORE_DANGER_ABOVE) { + status = STATUS_DANGER; + } else if (value != null && value >= DEFECT_SCORE_WARNING_ABOVE) { + status = STATUS_WARNING; + } + if (!normalVision(row.getVisionLabel()) && STATUS_NORMAL.equals(status)) { + return STATUS_WARNING; + } + return status; + } + + private String thermalStdTempStatus(PaintAnalysisResult row) { + Double value = row.getThermalStdTemp(); + if (value == null) { + return STATUS_NORMAL; + } + if (value >= THERMAL_STD_TEMP_DANGER_ABOVE) { + return STATUS_DANGER; + } + if (value >= THERMAL_STD_TEMP_WARNING_ABOVE) { + return STATUS_WARNING; + } + return STATUS_NORMAL; + } + + private String overallPaintStatus(PaintAnalysisResult row) { + String status = maxStatus( + surfaceQualityStatus(row), + thicknessStatus(row), + defectScoreStatus(row), + thermalStdTempStatus(row) + ); + Severity severity = row.getAnalysisResult().getSeverity(); + if (severity == Severity.CRITICAL) { + return STATUS_DANGER; + } + if (severity == Severity.WARNING && STATUS_NORMAL.equals(status)) { + return STATUS_WARNING; + } + return status; + } + + private String maxStatus(String... statuses) { + String result = STATUS_NORMAL; + for (String status : statuses) { + if (STATUS_DANGER.equals(status)) { + return STATUS_DANGER; + } + if (STATUS_WARNING.equals(status)) { + result = STATUS_WARNING; + } + } + return result; + } + + private boolean isDefectMarker(PaintAnalysisResult row) { + String status = defectScoreStatus(row); + return isPaintDefect(row); + } + + private boolean isPaintDefect(PaintAnalysisResult row) { + String status = defectScoreStatus(row); + return !normalVision(row.getVisionLabel()) + || STATUS_WARNING.equals(status) + || STATUS_DANGER.equals(status); + } + + private boolean normalVision(String visionLabel) { + return visionLabel == null || STATUS_NORMAL.equalsIgnoreCase(visionLabel); + } + + private List sortPaintByEventTimeAsc(List rows) { + return rows.stream() + .sorted(Comparator + .comparing((PaintAnalysisResult row) -> displayTime(row.getAnalysisResult()), + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(row -> row.getAnalysisResult().getId(), + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } + + private List sortAssemblyByEventTimeAsc(List rows) { + return rows.stream() + .sorted(Comparator + .comparing((AssemblyAnalysisResult row) -> displayTime(row.getAnalysisResult()), + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(row -> row.getAnalysisResult().getId(), + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } + + private LocalDateTime paintStartAt(List rows) { + return rows.isEmpty() ? null : displayTime(rows.get(0).getAnalysisResult()); + } + + private LocalDateTime paintEndAt(List rows) { + return rows.isEmpty() ? null : displayTime(rows.get(rows.size() - 1).getAnalysisResult()); + } + + private LocalDateTime assemblyStartAt(List rows) { + return rows.isEmpty() ? null : displayTime(rows.get(0).getAnalysisResult()); + } + + private LocalDateTime assemblyEndAt(List rows) { + return rows.isEmpty() ? null : displayTime(rows.get(rows.size() - 1).getAnalysisResult()); + } + + private LocalDateTime responseTo(QueryRange range) { + if (range.selectedDate() != null && range.to() != null) { + LocalDateTime nextDayStart = range.selectedDate().plusDays(1).atStartOfDay(); + if (nextDayStart.equals(range.to())) { + return LocalDateTime.of(range.selectedDate(), LocalTime.MAX); + } + } + return range.to(); + } + private boolean isAlert(ManufacturingAnalysisResult result) { return result.getSeverity() == Severity.WARNING || result.getSeverity() == Severity.CRITICAL diff --git a/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java b/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java index 60604dd..193e006 100644 --- a/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java +++ b/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java @@ -44,36 +44,106 @@ void paintEndpointReturnsSummaryChartAndAlertFields() throws Exception { eq(30) )).thenReturn(new PaintDashboardResponse( LocalDate.of(2026, 6, 18), + LocalDateTime.of(2026, 6, 18, 13, 55), + LocalDateTime.of(2026, 6, 18, 14, 25), + LocalDateTime.of(2026, 6, 18, 13, 55), + LocalDateTime.of(2026, 6, 18, 13, 55), new PaintDashboardResponse.Summary(6, 116.9, 81.2, 50.0, 3, 2.5), - List.of(new PaintDashboardResponse.ChartPoint( - LocalDateTime.of(2026, 6, 18, 13, 55), - 0.87, - 72.3, - 116.5, - 88.5, - "LEFT", - "DEFECT", - 4.2, - "CRITICAL" - )), + new PaintDashboardResponse.Thresholds( + new PaintDashboardResponse.HigherIsBetterThreshold( + "표면 품질 점수", "점", "HIGHER_IS_BETTER", 80.0, 60.0), + new PaintDashboardResponse.InRangeThreshold( + "도막 두께", "μm", "IN_RANGE_IS_BETTER", 115.0, 90.0, 120.0, 80.0, 130.0), + new PaintDashboardResponse.LowerIsBetterThreshold( + "불량 점수", "", "LOWER_IS_BETTER", 0.4, 0.6), + new PaintDashboardResponse.LowerIsBetterThreshold( + "온도 편차", "℃", "LOWER_IS_BETTER", 2.0, 5.0) + ), + new PaintDashboardResponse.Charts( + new PaintDashboardResponse.MetricChart( + "표면 품질 점수 추이", + "surfaceQualityScore", + "점", + List.of(new PaintDashboardResponse.MetricPoint( + LocalDateTime.of(2026, 6, 18, 13, 55), + 72.3, + "WARNING", + "DEFECT", + "LEFT", + 88.5, + 123L + )), + List.of() + ), + new PaintDashboardResponse.MetricChart( + "도막 두께 추이", + "thicknessValue", + "μm", + List.of(new PaintDashboardResponse.MetricPoint( + LocalDateTime.of(2026, 6, 18, 13, 55), + 116.5, + "NORMAL", + "DEFECT", + "LEFT", + 88.5, + 123L + )), + List.of() + ), + new PaintDashboardResponse.MetricChart( + "불량 점수 추이", + "defectScore", + "", + List.of(new PaintDashboardResponse.MetricPoint( + LocalDateTime.of(2026, 6, 18, 13, 55), + 0.87, + "DANGER", + "DEFECT", + "LEFT", + 88.5, + 123L + )), + List.of(new PaintDashboardResponse.MetricMarker( + LocalDateTime.of(2026, 6, 18, 13, 55), + 0.87, + "DEFECT", + "LEFT", + "DANGER", + 123L + )) + ), + new PaintDashboardResponse.MetricChart( + "온도 편차 추이", + "thermalStdTemp", + "℃", + List.of(new PaintDashboardResponse.MetricPoint( + LocalDateTime.of(2026, 6, 18, 13, 55), + 4.2, + "DANGER", + "DEFECT", + "LEFT", + 88.5, + 123L + )), + List.of() + ) + ), new PaintDashboardResponse.Alert( "도장 품질 이상 감지", List.of( "비전 판정: DEFECT", - "이상 위치: LEFT", - "도막 두께: 116.5 μm", - "표면 품질 점수: 72.3점", - "열 편차: 4.2℃", - "위험도: 88.5" + "상태: DANGER" ), new PaintDashboardResponse.Alert.Detail( + LocalDateTime.of(2026, 6, 18, 13, 55), "DEFECT", "LEFT", 116.5, 72.3, + 0.87, 4.2, 88.5, - "CRITICAL" + "DANGER" ) ) )); @@ -91,20 +161,24 @@ void paintEndpointReturnsSummaryChartAndAlertFields() throws Exception { .andExpect(jsonPath("$.data.summary.defectRate").value(50.0)) .andExpect(jsonPath("$.data.summary.alertCount").value(3)) .andExpect(jsonPath("$.data.summary.averageThermalStdTemp").value(2.5)) - .andExpect(jsonPath("$.data.chart[0].defectScore").value(0.87)) - .andExpect(jsonPath("$.data.chart[0].surfaceQualityScore").value(72.3)) - .andExpect(jsonPath("$.data.chart[0].imagePosition").value("LEFT")) - .andExpect(jsonPath("$.data.chart[0].thicknessValue").value(116.5)) - .andExpect(jsonPath("$.data.chart[0].riskScore").value(88.5)) + .andExpect(jsonPath("$.data.thresholds.surfaceQualityScore.warningBelow").value(80.0)) + .andExpect(jsonPath("$.data.charts.surfaceQuality.points[0].value").value(72.3)) + .andExpect(jsonPath("$.data.charts.surfaceQuality.points[0].status").value("WARNING")) + .andExpect(jsonPath("$.data.charts.thickness.points[0].value").value(116.5)) + .andExpect(jsonPath("$.data.charts.defectScore.points[0].value").value(0.87)) + .andExpect(jsonPath("$.data.charts.defectScore.markers[0].label").value("DEFECT")) + .andExpect(jsonPath("$.data.charts.thermalStdTemp.points[0].value").value(4.2)) .andExpect(jsonPath("$.data.alert.title").value("도장 품질 이상 감지")) .andExpect(jsonPath("$.data.alert.messages[0]").value("비전 판정: DEFECT")) + .andExpect(jsonPath("$.data.alert.detail.time").value("2026-06-18T13:55:00")) .andExpect(jsonPath("$.data.alert.detail.visionLabel").value("DEFECT")) .andExpect(jsonPath("$.data.alert.detail.imagePosition").value("LEFT")) .andExpect(jsonPath("$.data.alert.detail.thicknessValue").value(116.5)) .andExpect(jsonPath("$.data.alert.detail.surfaceQualityScore").value(72.3)) + .andExpect(jsonPath("$.data.alert.detail.defectScore").value(0.87)) .andExpect(jsonPath("$.data.alert.detail.thermalStdTemp").value(4.2)) .andExpect(jsonPath("$.data.alert.detail.riskScore").value(88.5)) - .andExpect(jsonPath("$.data.alert.detail.severity").value("CRITICAL")); + .andExpect(jsonPath("$.data.alert.detail.status").value("DANGER")); } @Test @@ -189,8 +263,9 @@ void endpointsReturnEmptyResponses() throws Exception { .andExpect(jsonPath("$.data.summary.analysisCount").value(0)) .andExpect(jsonPath("$.data.summary.averageThicknessValue").value(0.0)) .andExpect(jsonPath("$.data.summary.averageThermalStdTemp").value(0.0)) - .andExpect(jsonPath("$.data.chart").isEmpty()) - .andExpect(jsonPath("$.data.alert").doesNotExist()); + .andExpect(jsonPath("$.data.charts.surfaceQuality.points").isEmpty()) + .andExpect(jsonPath("$.data.charts.defectScore.markers").isEmpty()) + .andExpect(jsonPath("$.data.alert.title").value("최근 도장 상태 정상")); mockMvc.perform(get("/api/process/assembly")) .andExpect(status().isOk()) diff --git a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java index f44e75e..7ffb97a 100644 --- a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java +++ b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java @@ -408,4 +408,92 @@ private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw) { new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") ); } + + @Test + void test1_rawActualSequenceEqualsExpectedSequenceAndAbnormal() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-TEST-1", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B03>PA02>A03", + "actualSequence", "P01>B03>PA02>A03" + ) + ) + )); + ManufacturingAnalysisEvent analysis = new ManufacturingAnalysisEvent( + "ANL-1", "EVT-TEST-1", raw.eventTime(), raw.eventTime(), + "FAC", "LINE", ProcessCode.ASSEMBLY, "EQ-100", "EQ-NAME", "ROBOT", "PROD", "CAR", raw.carMasterId(), + "PROCESS_RISK_ANALYSIS", + new ManufacturingAnalysisEvent.RiskScores(0.0, 0.0, 0.0, 0.0, new ManufacturingAnalysisEvent.ProcessRisk(0.0, null, null, null)), + 90.0, "HIGH", + new ManufacturingAnalysisEvent.AnalysisResult(true, false, false, false, false), + new ManufacturingAnalysisEvent.Reason("Abnormal", Collections.emptyList()), + new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") + ); + + service.save(raw, analysis); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P01>B03>PA02>A03"); + assertThat(result.getActualSequence()).isEqualTo("P01>B03>PA02>A03"); + } + + @Test + void test2_rawActualSequenceDiffersFromExpectedSequence() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-TEST-2", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B03>PA02>A03", + "actualSequence", "P01>B04>PA02>A03" + ) + ) + )); + + service.save(raw, analysisEvent(raw)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P01>B03>PA02>A03"); + assertThat(result.getActualSequence()).isEqualTo("P01>B04>PA02>A03"); + } + + @Test + void test3_rawActualSequenceMissing() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-TEST-3", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B03>PA02>A03" + ) + ) + )); + + service.save(raw, analysisEvent(raw)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P01>B03>PA02>A03"); + assertThat(result.getActualSequence()).isNull(); + } + + @Test + void test4_rawExpectedSequenceMissing() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-TEST-4", Map.of( + "processData", Map.of( + "assembly", Map.of( + "actualSequence", "P01>B04>PA02>A03" + ) + ) + )); + + service.save(raw, analysisEvent(raw)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isNull(); + assertThat(result.getActualSequence()).isEqualTo("P01>B04>PA02>A03"); + } } diff --git a/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java b/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java index f7ace11..08a04c6 100644 --- a/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java +++ b/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java @@ -19,6 +19,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -69,28 +71,67 @@ void paintDashboardBuildsSummaryChartAndAlert() { eq(LocalDateTime.of(2026, 6, 19, 0, 0)), any(Pageable.class) )).thenReturn(List.of(row)); + when(paintRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 6, 18, 0, 0)), + eq(LocalDateTime.of(2026, 6, 19, 0, 0)) + )).thenReturn(List.of(row)); PaintDashboardResponse response = service.getPaintDashboard(LocalDate.of(2026, 6, 18), null, null, 30); assertThat(response.selectedDate()).isEqualTo(LocalDate.of(2026, 6, 18)); + assertThat(response.from()).isEqualTo(LocalDateTime.of(2026, 6, 18, 0, 0)); + assertThat(response.to()).isEqualTo(LocalDateTime.of(2026, 6, 18, 23, 59, 59, 999999999)); + assertThat(response.chartStartAt()).isEqualTo(eventTime); + assertThat(response.chartEndAt()).isEqualTo(eventTime); assertThat(response.summary().analysisCount()).isEqualTo(1); assertThat(response.summary().averageThicknessValue()).isEqualTo(126.5); assertThat(response.summary().averageThermalStdTemp()).isEqualTo(4.2); assertThat(response.summary().defectRate()).isEqualTo(100.0); assertThat(response.summary().averageSurfaceQualityScore()).isEqualTo(72.3); assertThat(response.summary().alertCount()).isEqualTo(1); - assertThat(response.chart()).singleElement().satisfies(point -> { + assertThat(response.thresholds().surfaceQualityScore().warningBelow()).isEqualTo(80.0); + assertThat(response.thresholds().surfaceQualityScore().dangerBelow()).isEqualTo(60.0); + assertThat(response.thresholds().thicknessValue().target()).isEqualTo(115.0); + assertThat(response.thresholds().thicknessValue().normalMin()).isEqualTo(90.0); + assertThat(response.thresholds().thicknessValue().normalMax()).isEqualTo(120.0); + assertThat(response.thresholds().thicknessValue().warningMin()).isEqualTo(80.0); + assertThat(response.thresholds().thicknessValue().warningMax()).isEqualTo(130.0); + assertThat(response.thresholds().defectScore().warningAbove()).isEqualTo(0.4); + assertThat(response.thresholds().defectScore().dangerAbove()).isEqualTo(0.6); + assertThat(response.thresholds().thermalStdTemp().warningAbove()).isEqualTo(2.0); + assertThat(response.thresholds().thermalStdTemp().dangerAbove()).isEqualTo(5.0); + assertThat(response.charts().surfaceQuality().points()).singleElement().satisfies(point -> { assertThat(point.time()).isEqualTo(eventTime); - assertThat(point.defectScore()).isEqualTo(0.87); - assertThat(point.surfaceQualityScore()).isEqualTo(72.3); - assertThat(point.thicknessValue()).isEqualTo(126.5); + assertThat(point.value()).isEqualTo(72.3); + assertThat(point.status()).isEqualTo("WARNING"); assertThat(point.riskScore()).isEqualTo(88.5); assertThat(point.imagePosition()).isEqualTo("LEFT"); }); + assertThat(response.charts().thickness().points()).singleElement() + .satisfies(point -> { + assertThat(point.value()).isEqualTo(126.5); + assertThat(point.status()).isEqualTo("WARNING"); + }); + assertThat(response.charts().defectScore().points()).singleElement() + .satisfies(point -> assertThat(point.value()).isEqualTo(0.87)); + assertThat(response.charts().defectScore().markers()).singleElement().satisfies(marker -> { + assertThat(marker.value()).isEqualTo(0.87); + assertThat(marker.label()).isEqualTo("DEFECT"); + assertThat(marker.status()).isEqualTo("DANGER"); + }); + assertThat(response.charts().thermalStdTemp().points()).singleElement() + .satisfies(point -> { + assertThat(point.value()).isEqualTo(4.2); + assertThat(point.status()).isEqualTo("WARNING"); + }); assertThat(response.alert().messages()) - .contains("비전 불량 라벨 감지: DEFECT") - .anyMatch(message -> message.contains("도장 두께 이상 의심")); + .contains("비전 판정: DEFECT", "상태: DANGER"); + assertThat(response.alert().detail()).satisfies(detail -> { + assertThat(detail.time()).isEqualTo(eventTime); + assertThat(detail.defectScore()).isEqualTo(0.87); + assertThat(detail.status()).isEqualTo("DANGER"); + }); } @Test @@ -115,10 +156,18 @@ void assemblyDashboardBuildsSummaryVehiclesAndAlert() { any(Pageable.class) )) .thenReturn(List.of(row)); + when(assemblyRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 6, 18, 0, 0)), + eq(LocalDateTime.of(2026, 6, 19, 0, 0)) + )).thenReturn(List.of(row)); AssemblyDashboardResponse response = service.getAssemblyDashboard(null, null, null, 30); assertThat(response.selectedDate()).isEqualTo(LocalDate.of(2026, 6, 18)); + assertThat(response.from()).isEqualTo(LocalDateTime.of(2026, 6, 18, 0, 0)); + assertThat(response.to()).isEqualTo(LocalDateTime.of(2026, 6, 18, 23, 59, 59, 999999999)); + assertThat(response.dataStartAt()).isEqualTo(eventTime); + assertThat(response.dataEndAt()).isEqualTo(eventTime); assertThat(response.summary().vehicleCount()).isEqualTo(1); assertThat(response.summary().sequenceErrorCount()).isEqualTo(1); assertThat(response.summary().missingPartCount()).isZero(); @@ -146,14 +195,19 @@ void returnsEmptyResponsesWhenNoDataExists() { .thenReturn(List.of()); when(assemblyRepository.findDashboardRows(any(), any(), any(Pageable.class))) .thenReturn(List.of()); + when(paintRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of()); + when(assemblyRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of()); PaintDashboardResponse paint = service.getPaintDashboard(null, null, null, null); AssemblyDashboardResponse assembly = service.getAssemblyDashboard(null, null, null, null); assertThat(paint.selectedDate()).isNull(); assertThat(paint.summary().analysisCount()).isZero(); - assertThat(paint.chart()).isEmpty(); - assertThat(paint.alert()).isNull(); + assertThat(paint.charts().surfaceQuality().points()).isEmpty(); + assertThat(paint.charts().defectScore().markers()).isEmpty(); + assertThat(paint.alert().title()).isEqualTo("최근 도장 상태 정상"); assertThat(assembly.selectedDate()).isNull(); assertThat(assembly.summary().vehicleCount()).isZero(); assertThat(assembly.vehicles()).isEmpty(); @@ -175,6 +229,10 @@ void paintDashboardWithoutDateUsesLatestAvailableDate() { eq(LocalDateTime.of(2026, 6, 19, 0, 0)), any(Pageable.class) )).thenReturn(List.of(row)); + when(paintRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 6, 18, 0, 0)), + eq(LocalDateTime.of(2026, 6, 19, 0, 0)) + )).thenReturn(List.of(row)); PaintDashboardResponse response = service.getPaintDashboard(null, null, null, 30); @@ -182,6 +240,150 @@ void paintDashboardWithoutDateUsesLatestAvailableDate() { assertThat(response.summary().analysisCount()).isEqualTo(1); } + @Test + void paintDashboardUsesLatestLimitRowsAndFullRangeSummary() { + List allRows = paintRows(35); + List latestRowsDesc = new ArrayList<>(allRows.subList(5, 35)); + Collections.reverse(latestRowsDesc); + when(paintRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 7, 7, 0, 0)), + eq(LocalDateTime.of(2026, 7, 8, 0, 0)) + )).thenReturn(allRows); + when(paintRepository.findDashboardRows( + eq(LocalDateTime.of(2026, 7, 7, 0, 0)), + eq(LocalDateTime.of(2026, 7, 8, 0, 0)), + any(Pageable.class) + )).thenReturn(latestRowsDesc); + + PaintDashboardResponse response = + service.getPaintDashboard(LocalDate.of(2026, 7, 7), null, null, 30); + + assertThat(response.summary().analysisCount()).isEqualTo(35); + assertThat(response.charts().surfaceQuality().points()).hasSize(30); + assertThat(response.charts().surfaceQuality().points()).extracting(PaintDashboardResponse.MetricPoint::time) + .containsExactlyElementsOf(allRows.subList(5, 35).stream() + .map(row -> row.getAnalysisResult().getEventTime()) + .toList()); + assertThat(response.charts().surfaceQuality().points()).extracting(PaintDashboardResponse.MetricPoint::time) + .doesNotContain(allRows.get(0).getAnalysisResult().getEventTime()) + .contains(LocalDateTime.of(2026, 7, 7, 13, 0)); + assertThat(response.chartStartAt()).isEqualTo(allRows.get(5).getAnalysisResult().getEventTime()); + assertThat(response.chartEndAt()).isEqualTo(allRows.get(34).getAnalysisResult().getEventTime()); + } + + @Test + void paintDashboardKeepsRowsAtSameTimeAndBuildsDefectMarkers() { + LocalDateTime eventTime = LocalDateTime.of(2026, 7, 7, 12, 19, 5); + PaintAnalysisResult first = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 1L, Severity.NORMAL, false, 3.6, eventTime, eventTime)) + .defectScore(0.044) + .surfaceQualityScore(97.9) + .thicknessValue(113.9) + .thermalStdTemp(0.67) + .visionLabel("NORMAL") + .imagePosition("LEFT") + .build(); + PaintAnalysisResult second = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 2L, Severity.NORMAL, false, 5.3, eventTime, eventTime)) + .defectScore(0.072) + .surfaceQualityScore(96.7) + .thicknessValue(115.7) + .thermalStdTemp(0.71) + .visionLabel("NORMAL") + .imagePosition("LEFT") + .build(); + PaintAnalysisResult third = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 3L, Severity.NORMAL, false, 3.6, eventTime, eventTime)) + .defectScore(0.063) + .surfaceQualityScore(97.257) + .thicknessValue(110.212) + .thermalStdTemp(0.879) + .visionLabel("DUST_CONTAMINATION") + .imagePosition("LEFT") + .build(); + List rows = List.of(first, second, third); + when(paintRepository.findDashboardSummaryRows(any(), any())).thenReturn(rows); + when(paintRepository.findDashboardRows(any(), any(), any(Pageable.class))).thenReturn(rows); + + PaintDashboardResponse response = + service.getPaintDashboard(LocalDate.of(2026, 7, 7), null, null, 30); + + assertThat(response.charts().surfaceQuality().points()).hasSize(3); + assertThat(response.charts().thickness().points()).hasSize(3); + assertThat(response.charts().defectScore().points()).hasSize(3); + assertThat(response.charts().thermalStdTemp().points()).hasSize(3); + assertThat(response.charts().defectScore().markers()).singleElement().satisfies(marker -> { + assertThat(marker.time()).isEqualTo(eventTime); + assertThat(marker.label()).isEqualTo("DUST_CONTAMINATION"); + assertThat(marker.status()).isEqualTo("WARNING"); + }); + } + + @Test + void paintDashboardTreatsNormalZeroSurfaceQualityAsMissingValue() { + LocalDateTime eventTime = LocalDateTime.of(2026, 7, 7, 14, 11, 59); + PaintAnalysisResult missingSurfaceQuality = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 1L, Severity.NORMAL, false, 0.0, eventTime, eventTime)) + .defectScore(0.052) + .surfaceQualityScore(0.0) + .thicknessValue(116.0) + .thermalStdTemp(0.0) + .visionLabel("NORMAL") + .imagePosition("LEFT") + .build(); + PaintAnalysisResult measuredSurfaceQuality = PaintAnalysisResult.builder() + .analysisResult(result(ProcessCode.PAINT, 2L, Severity.NORMAL, false, 3.0, eventTime.plusMinutes(1), eventTime)) + .defectScore(0.050) + .surfaceQualityScore(96.0) + .thicknessValue(116.0) + .thermalStdTemp(0.5) + .visionLabel("NORMAL") + .imagePosition("LEFT") + .build(); + List rows = List.of(missingSurfaceQuality, measuredSurfaceQuality); + when(paintRepository.findDashboardSummaryRows(any(), any())).thenReturn(rows); + when(paintRepository.findDashboardRows(any(), any(), any(Pageable.class))).thenReturn(rows); + + PaintDashboardResponse response = + service.getPaintDashboard(LocalDate.of(2026, 7, 7), null, null, 30); + + assertThat(response.charts().surfaceQuality().points()).extracting(PaintDashboardResponse.MetricPoint::value) + .containsExactly(null, 96.0); + assertThat(response.summary().averageSurfaceQualityScore()).isEqualTo(96.0); + assertThat(response.alert().title()).isEqualTo("최근 도장 상태 정상"); + } + + @Test + void assemblyDashboardUsesLatestLimitRowsAndFullRangeSummary() { + List allRows = assemblyRows(35); + List latestRowsDesc = new ArrayList<>(allRows.subList(5, 35)); + Collections.reverse(latestRowsDesc); + when(assemblyRepository.findDashboardSummaryRows( + eq(LocalDateTime.of(2026, 7, 7, 0, 0)), + eq(LocalDateTime.of(2026, 7, 8, 0, 0)) + )).thenReturn(allRows); + when(assemblyRepository.findDashboardRows( + eq(LocalDateTime.of(2026, 7, 7, 0, 0)), + eq(LocalDateTime.of(2026, 7, 8, 0, 0)), + any(Pageable.class) + )).thenReturn(latestRowsDesc); + + AssemblyDashboardResponse response = + service.getAssemblyDashboard(LocalDate.of(2026, 7, 7), null, null, 30); + + assertThat(response.summary().vehicleCount()).isEqualTo(35); + assertThat(response.vehicles()).hasSize(30); + assertThat(response.vehicles()).extracting(AssemblyDashboardResponse.VehicleRow::time) + .containsExactlyElementsOf(allRows.subList(5, 35).stream() + .map(row -> row.getAnalysisResult().getEventTime()) + .toList()); + assertThat(response.vehicles()).extracting(AssemblyDashboardResponse.VehicleRow::time) + .doesNotContain(allRows.get(0).getAnalysisResult().getEventTime()) + .contains(LocalDateTime.of(2026, 7, 7, 13, 0)); + assertThat(response.dataStartAt()).isEqualTo(allRows.get(5).getAnalysisResult().getEventTime()); + assertThat(response.dataEndAt()).isEqualTo(allRows.get(34).getAnalysisResult().getEventTime()); + } + @Test void dateApisReturnDatesAndLatestDate() { when(paintRepository.findDashboardEventTimeValues()).thenReturn(List.of( @@ -290,4 +492,50 @@ private ManufacturingAnalysisResult result( .riskScore(riskScore) .build(); } + + private List paintRows(int count) { + List rows = new ArrayList<>(); + for (int index = 0; index < count; index++) { + LocalDateTime eventTime = LocalDateTime.of(2026, 7, 7, 10, 0).plusMinutes(index * 6L); + rows.add(PaintAnalysisResult.builder() + .analysisResult(result( + ProcessCode.PAINT, + (long) index, + Severity.NORMAL, + false, + index, + eventTime, + eventTime.plusSeconds(5) + )) + .surfaceQualityScore(90.0) + .thicknessValue(120.0) + .thermalStdTemp(1.0) + .build()); + } + return rows; + } + + private List assemblyRows(int count) { + List rows = new ArrayList<>(); + for (int index = 0; index < count; index++) { + LocalDateTime eventTime = LocalDateTime.of(2026, 7, 7, 10, 0).plusMinutes(index * 6L); + rows.add(AssemblyAnalysisResult.builder() + .analysisResult(result( + ProcessCode.ASSEMBLY, + (long) index, + Severity.NORMAL, + false, + index, + eventTime, + eventTime.plusSeconds(5) + )) + .expectedSequence("A01 > A02") + .actualSequence("A01 > A02") + .sequenceErrorCount(0) + .missingPartCount(0) + .fasteningErrorCount(0) + .build()); + } + return rows; + } } From 1a0bd62aa466ba3abecd315a8cdc8b671daa3a85 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Tue, 7 Jul 2026 16:51:46 +0900 Subject: [PATCH 08/35] =?UTF-8?q?fix:=20=EC=B0=A8=EC=B2=B4=20=EC=9D=B4?= =?UTF-8?q?=EC=83=81=20=ED=83=90=EC=A7=80=20=EC=B0=A8=ED=8A=B8=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=EA=B0=92=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rewrite_service.py | 552 ++++++++++++++++++ .../aims/assembly/AssemblyApplication.java | 6 +- .../assembly/domain/commons/BaseEntity.java | 4 +- .../body/BodyAnomalyDetectionResponse.java | 41 +- .../press/PressAnomalyDetectionResponse.java | 3 - .../kafka/ManufacturingEventAnalyzer.java | 8 +- .../BodyAnomalyDetectionResponseMapper.java | 54 +- .../PressAnalysisResultRepository.java | 11 + .../body/BodyAnomalyDetectionService.java | 178 ++++-- .../press/PressAnomalyDetectionService.java | 24 +- src/main/resources/application.yaml | 4 - 11 files changed, 771 insertions(+), 114 deletions(-) create mode 100644 rewrite_service.py diff --git a/rewrite_service.py b/rewrite_service.py new file mode 100644 index 0000000..0d95c02 --- /dev/null +++ b/rewrite_service.py @@ -0,0 +1,552 @@ +import re + +with open('src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java', 'r', encoding='utf-8') as f: + content = f.read() + +# I will just write a completely clean version based on the code I know +clean_code = """package com.aims.assembly.service.body; + +import com.aims.assembly.domain.body.BodyAnalysisResult; +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.ManufacturingEventJsonRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BodyAnomalyDetectionService { + private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; + private static final Double TARGET_VIBRATION_SCORE = 0.75; + private static final Double TARGET_FREQUENCY_PEAK = 3.0; + + private final BodyAnalysisResultRepository repository; + private final ManufacturingEventJsonRepository eventJsonRepository; + private final ObjectMapper 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)" + ) + public BodyAnomalyDetectionResponse findDashboard( + LocalDate date, + LocalDateTime from, + LocalDateTime to, + LocalDateTime endAt, + int limit + ) { + List dateOptions = dateOptions(); + LocalDate targetDate = resolveDate(date, from, to, endAt, dateOptions); + LocalDateTime rangeFrom = from != null ? from : targetDate.atStartOfDay(); + LocalDateTime rangeTo = to != null ? to : LocalDateTime.of(targetDate, LocalTime.MAX); + if (endAt != null && endAt.isBefore(rangeTo)) { + rangeTo = endAt; + } + + int size = Math.max(1, Math.min(limit, 200)); + List allPoints = repository.findDashboardByEventTimeBetween( + rangeFrom, + rangeTo, + PageRequest.of(0, MAX_EVENT_LOOKUP_SIZE) + ).stream() + .collect(Collectors.toMap( + result -> result.getAnalysisResult().getEventId(), + result -> result, + (left, right) -> isLater(left, right) ? left : right + )) + .values() + .stream() + .map(this::toChartPoint) + .filter(point -> point.timestamp() != null) + .sorted(Comparator.comparing(BodyAnomalyDetectionResponse.ChartPoint::timestamp)) + .toList(); + + List points = allPoints.size() <= size + ? allPoints + : allPoints.subList(allPoints.size() - size, allPoints.size()); + + 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) + .max() + .orElse(points.isEmpty() ? 0.0 : safeNumber(points.get(points.size() - 1).riskScore())); + + 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); + + BodyAnalysisResult summaryResult = summaryPoint == null ? null + : repository.findByAnalysisResult_AnalysisId(summaryPoint.analysisId()).orElse(null); + + BodyAnomalyDetectionResponse.Metrics metrics = toMetrics(summaryResult, summaryPoint, detected, maxRiskScore); + + BodyAnomalyDetectionResponse.FrequencyZoneAnalysis freqAnalysis = null; + if (metrics != null && metrics.frequencyBands() != null) { + freqAnalysis = analyzeFrequencyZones(metrics.frequencyBands()); + } + + BodyAnomalyDetectionResponse response = BodyAnomalyDetectionResponseMapper.toResponse( + targetDate, + rangeFrom, + rangeTo, + previousEndAt, + dateOptions, + metrics, + points, + freqAnalysis, + toAlert(points, detected, summaryResult) + ); + log.info( + "차체 이상 탐지 대시보드 조회 완료: date={}, from={}, to={}, 건수={}, 탐지여부={}", + targetDate, + rangeFrom, + rangeTo, + points.size(), + detected + ); + return response; + } + + private List dateOptions() { + return repository.findBodyAnalysisDateOptions().stream() + .map(option -> BodyAnomalyDetectionResponseMapper.toDateOption( + option.getDate(), + option.getSampleEventId() + )) + .toList(); + } + + private LocalDate resolveDate( + LocalDate date, + LocalDateTime from, + LocalDateTime to, + LocalDateTime endAt, + List dateOptions + ) { + if (date != null) return date; + if (from != null) return from.toLocalDate(); + if (endAt != null) return endAt.toLocalDate(); + if (to != null) return to.toLocalDate(); + return dateOptions.isEmpty() ? LocalDate.now() : dateOptions.get(0).date(); + } + + private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult result) { + var analysis = result.getAnalysisResult(); + Double vibrationScore = result.getRobotVibrationScore(); + Double vibrationPeak = null; + Double vibrationRms = null; + + SensorData sensorData = extractSensorDataFromEventJson(analysis.getEventId()); + if (sensorData != null) { + if (vibrationScore == null) { + vibrationScore = 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"; + } + + return BodyAnomalyDetectionResponseMapper.toChartPoint( + analysis.getEventId(), + analysis.getAnalysisId(), + analysis.getEventTime(), + TARGET_VIBRATION_SCORE, + vibrationScore, + TARGET_FREQUENCY_PEAK, + toDisplayPeakValue(vibrationPeak), + vibrationRms, + analysis.getRiskScore(), + isAbnormal, + severity + ); + } + + private BodyAnomalyDetectionResponse.Metrics toMetrics( + BodyAnalysisResult result, + BodyAnomalyDetectionResponse.ChartPoint point, + boolean detected, + double maxRiskScore + ) { + String severity = detected ? "WARNING" : "NORMAL"; + if (point != null && point.severity() != null && !detected) { + severity = point.severity(); + } + + Double pointVibrationScore = point != null ? point.vibrationScore() : null; + Double pointVibrationPeak = point != null ? point.vibrationPeak() : null; + Double pointVibrationRms = point != null ? point.vibrationRms() : null; + + if (result == null) { + 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", + TARGET_VIBRATION_SCORE, + pointVibrationScore, + TARGET_FREQUENCY_PEAK, + pointVibrationPeak, + pointVibrationRms, + bodyData.frequencyPeakBand, + null, + maxRiskScore, + "0-100", + severity, + bodyData.frequencyBands != null ? bodyData.frequencyBands : new HashMap<>() + ); + } + return BodyAnomalyDetectionResponseMapper.toMetrics( + null, "NORMAL", TARGET_VIBRATION_SCORE, pointVibrationScore, TARGET_FREQUENCY_PEAK, pointVibrationPeak, pointVibrationRms, null, null, maxRiskScore, "0-100", severity, new HashMap<>() + ); + } + return BodyAnomalyDetectionResponseMapper.toMetrics( + result.getRobotMotionStatus(), + result.getRobotOperationMode() != null ? result.getRobotOperationMode() : "NORMAL", + TARGET_VIBRATION_SCORE, + pointVibrationScore != null ? pointVibrationScore : result.getRobotVibrationScore(), + TARGET_FREQUENCY_PEAK, + pointVibrationPeak, + pointVibrationRms, + result.getFrequencyPeakBand(), + toDisplayPeakValue(result.getFrequencyPeakValue()), + maxRiskScore, + "0-100", + severity, + parseFrequencyBands(result.getFrequencyBandsJson()) + ); + } + + private Map parseFrequencyBands(String json) { + if (json == null || json.isBlank()) { + return new HashMap<>(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to parse frequencyBands json", e); + return new HashMap<>(); + } + } + + private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis analyzeFrequencyZones(Map raw) { + if (raw == null || raw.isEmpty()) { + return null; + } + + List lowVals = new ArrayList<>(); + List mainVals = new ArrayList<>(); + List highVals = new ArrayList<>(); + List ultraVals = new ArrayList<>(); + + for (Map.Entry entry : raw.entrySet()) { + String key = entry.getKey().toUpperCase(); + Double val = entry.getValue(); + if (val == null) continue; + + int upperHz = -1; + String[] parts = key.replace("FREQ_", "").replace("_HZ", "").split("_"); + try { + if (parts.length >= 2) { + upperHz = Integer.parseInt(parts[1]); + } else if (parts.length == 1) { + upperHz = Integer.parseInt(parts[0]); + } + } catch (Exception e) { + // ignore + } + + if (upperHz <= 0) continue; + + if (upperHz <= 300) { + lowVals.add(val); + } else if (upperHz <= 600) { + mainVals.add(val); + } else if (upperHz <= 1000) { + highVals.add(val); + } else { + ultraVals.add(val); + } + } + + return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis( + computeZoneStats(lowVals), + computeZoneStats(mainVals), + computeZoneStats(highVals), + computeZoneStats(ultraVals) + ); + } + + private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats computeZoneStats(List vals) { + if (vals.isEmpty()) return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(0.0, 0.0); + double max = vals.stream().mapToDouble(v -> v).max().orElse(0.0); + double avg = vals.stream().mapToDouble(v -> v).average().orElse(0.0); + return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(avg, max); + } + + private BodyAnomalyDetectionResponse.AlertPanel toAlert( + List points, + boolean detected, + BodyAnalysisResult summaryResult + ) { + if (!detected || points == null || points.isEmpty()) { + return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상 미탐지", List.of()); + } + + List reasons = new ArrayList<>(); + int abnormalCount = 0; + double maxVibrationScore = 0.0; + + for (BodyAnomalyDetectionResponse.ChartPoint point : points) { + if (!isBodyAnomaly(point)) continue; + if (Boolean.TRUE.equals(point.isAbnormal())) abnormalCount++; + if (point.robotVibrationScore() != null) { + maxVibrationScore = Math.max(maxVibrationScore, point.robotVibrationScore()); + } + } + + if (summaryResult != null) { + if ("COLLISION_RISK".equals(summaryResult.getRobotMotionStatus())) { + reasons.add("robot_motion_status = COLLISION_RISK"); + } else if (summaryResult.getRobotMotionStatus() != null + && !"NORMAL".equals(summaryResult.getRobotMotionStatus())) { + reasons.add("robot_motion_status = " + summaryResult.getRobotMotionStatus()); + } + if ("AUTO_MANUAL_STOPPED".equals(summaryResult.getRobotOperationMode()) + || "STOPPED".equals(summaryResult.getRobotOperationMode()) + || "MANUAL".equals(summaryResult.getRobotOperationMode())) { + reasons.add("robot_operation_mode = " + summaryResult.getRobotOperationMode()); + } + if (summaryResult.getFrequencyPeakValue() != null && summaryResult.getFrequencyPeakValue() > 0) { + reasons.add(String.format( + "피크 진동값 급증 (%.1f mm/s)", + toDisplayPeakValue(summaryResult.getFrequencyPeakValue()) + )); + } + if (summaryResult.getFrequencyPeakBand() != null) { + reasons.add("고주파 대역 이상 감지 (" + formatFrequencyBand(summaryResult.getFrequencyPeakBand()) + ")"); + } + } + + if (abnormalCount > 0) { + reasons.add("이상 징후 감지: " + abnormalCount + "건"); + } + if (maxVibrationScore > 0) { + reasons.add(String.format("최대 진동 점수: %.2f", maxVibrationScore)); + } + + return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상 탐지", List.copyOf(reasons)); + } + + private Double toDisplayPeakValue(Double rawValue) { + if (rawValue == null) { + return null; + } + return rawValue < 1.0 ? rawValue * 1000.0 : rawValue; + } + + private String formatFrequencyBand(String raw) { + if (raw == null) return ""; + return switch (raw.toUpperCase()) { + case "LOW" -> "LOW (0–100Hz)"; + case "MID", "MEDIUM" -> "MID (30–80Hz)"; + case "HIGH" -> "HIGH (80–300Hz)"; + default -> raw.replace("_HZ", "Hz").replace("_", "-"); + }; + } + + private boolean isBodyAnomaly(BodyAnomalyDetectionResponse.ChartPoint point) { + return point != null + && (Boolean.TRUE.equals(point.isAbnormal()) + || (point.riskScore() != null && point.riskScore() >= 30.0)); + } + + private double anomalyWeight(BodyAnomalyDetectionResponse.ChartPoint point) { + if (point == null) return -1.0; + double weight = 0.0; + if (Boolean.TRUE.equals(point.isAbnormal())) weight += 50.0; + if (point.riskScore() != null) weight += point.riskScore(); + if (point.robotVibrationScore() != null) weight += point.robotVibrationScore() * 10.0; + if (point.frequencyPeakValue() != null) weight += point.frequencyPeakValue() * 5.0; + return weight; + } + + private Double safeNumber(Double value) { + return value == null ? 0.0 : value; + } + + private boolean isLater(BodyAnalysisResult left, BodyAnalysisResult right) { + LocalDateTime leftAt = left.getAnalysisResult().getAnalyzedAt(); + LocalDateTime rightAt = right.getAnalysisResult().getAnalyzedAt(); + if (leftAt != null && rightAt != null) { + int compared = leftAt.compareTo(rightAt); + if (compared != 0) return compared > 0; + } else if (leftAt != null) return true; + else if (rightAt != null) return false; + Long leftId = left.getAnalysisResult().getId(); + Long rightId = right.getAnalysisResult().getId(); + if (leftId == null) return false; + if (rightId == null) return true; + return leftId >= rightId; + } + + private BodyProcessData extractBodyDataFromEventJson(String eventId) { + try { + var eventOpt = eventJsonRepository.findByEventId(eventId); + if (eventOpt.isEmpty()) { + return null; + } + + var storedEvent = eventOpt.get(); + Map eventMap = objectMapper.readValue( + storedEvent.rawJson(), + new TypeReference>() {} + ); + + if (eventMap == null) { + return null; + } + + @SuppressWarnings("unchecked") + Map processData = (Map) eventMap.get("processData"); + if (processData == null) { + return null; + } + + @SuppressWarnings("unchecked") + Map bodyData = (Map) processData.get("body"); + if (bodyData == null) { + return null; + } + + String robotMotionStatus = (String) bodyData.get("robotMotionStatus"); + String robotOperationMode = (String) bodyData.get("robotOperationMode"); + String frequencyPeakBand = (String) bodyData.get("frequencyPeakBand"); + + @SuppressWarnings("unchecked") + Map frequencyBands = (Map) bodyData.get("frequencyBands"); + + return new BodyProcessData(robotMotionStatus, robotOperationMode, frequencyPeakBand, frequencyBands); + } catch (Exception e) { + log.warn("Failed to extract body data from eventJson for eventId: {}", eventId, e); + return null; + } + } + + private static class BodyProcessData { + String robotMotionStatus; + String robotOperationMode; + String frequencyPeakBand; + Map frequencyBands; + + BodyProcessData(String robotMotionStatus, String robotOperationMode, String frequencyPeakBand, Map frequencyBands) { + this.robotMotionStatus = robotMotionStatus; + this.robotOperationMode = robotOperationMode; + this.frequencyPeakBand = frequencyPeakBand; + this.frequencyBands = frequencyBands; + } + } + + private SensorData extractSensorDataFromEventJson(String eventId) { + try { + var eventOpt = eventJsonRepository.findByEventId(eventId); + if (eventOpt.isEmpty()) { + return null; + } + + var storedEvent = eventOpt.get(); + Map eventMap = objectMapper.readValue( + storedEvent.rawJson(), + new TypeReference>() {} + ); + + if (eventMap == null) { + return null; + } + + @SuppressWarnings("unchecked") + Map sensor = (Map) eventMap.get("sensor"); + if (sensor == null) { + return null; + } + + @SuppressWarnings("unchecked") + Map robotArmVibration = (Map) sensor.get("robotArmVibration"); + Double vibrationScore = null; + Double peakValue = null; + Double vibrationRms = null; + + if (robotArmVibration != null) { + Object vibrationScoreObj = robotArmVibration.get("vibrationScore"); + if (vibrationScoreObj != null) { + vibrationScore = ((Number) vibrationScoreObj).doubleValue(); + } + Object vibrationPeakObj = robotArmVibration.get("vibrationPeak"); + if (vibrationPeakObj != null) { + peakValue = ((Number) vibrationPeakObj).doubleValue(); + } + Object vibrationRmsObj = robotArmVibration.get("vibrationRms"); + if (vibrationRmsObj != null) { + vibrationRms = ((Number) vibrationRmsObj).doubleValue(); + } + } + + return new SensorData(vibrationScore, peakValue, vibrationRms); + } catch (Exception e) { + log.warn("Failed to extract sensor data from eventJson for eventId: {}", eventId, e); + return null; + } + } + + private static class SensorData { + Double vibrationScore; + Double peakValue; + Double vibrationRms; + + SensorData(Double vibrationScore, Double peakValue, Double vibrationRms) { + this.vibrationScore = vibrationScore; + this.peakValue = peakValue; + this.vibrationRms = vibrationRms; + } + } +} +""" + +with open('src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java', 'w', encoding='utf-8') as f: + f.write(clean_code) + +print("Rewrite done") diff --git a/src/main/java/com/aims/assembly/AssemblyApplication.java b/src/main/java/com/aims/assembly/AssemblyApplication.java index 5cee033..c382962 100644 --- a/src/main/java/com/aims/assembly/AssemblyApplication.java +++ b/src/main/java/com/aims/assembly/AssemblyApplication.java @@ -15,8 +15,12 @@ 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)); + } + + public static void main(String[] args) { SpringApplication.run(AssemblyApplication.class, args); } } 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/dto/body/BodyAnomalyDetectionResponse.java b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java index 24893d7..baa1694 100644 --- a/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java @@ -13,23 +13,32 @@ public record BodyAnomalyDetectionResponse( List dateOptions, Metrics metrics, List chart, + List frequencyChart, + FrequencyZoneAnalysis frequencyZoneAnalysis, AlertPanel alert ) { + public record FrequencyBandPoint( + LocalDateTime timestamp, + String band, + Double value, + Double targetValue + ) {} public record DateOption( LocalDate date, String sampleEventId ) { } - /** - * 상단 지표: 로봇 모션 상태, 운전 모드, 진동 점수, 피크 진동값, 전체 위험도 - */ public record Metrics( String robotMotionStatus, String robotOperationMode, - Double robotVibrationScore, - Double frequencyPeakValue, + Double targetVibrationScore, + Double vibrationScore, + Double targetVibrationPeak, + Double vibrationPeak, + Double vibrationRms, String frequencyPeakBand, + Double frequencyPeakValue, Double riskScore, String riskScoreScale, String severity, @@ -37,21 +46,33 @@ public record Metrics( ) { } - /** - * 그래프 시계열 포인트: 로봇 진동 점수, 위험도, 피크 진동값 - */ public record ChartPoint( String eventId, String analysisId, LocalDateTime timestamp, - Double robotVibrationScore, - Double frequencyPeakValue, + Double targetVibrationScore, + Double vibrationScore, + Double targetVibrationPeak, + Double vibrationPeak, + Double vibrationRms, Double riskScore, Boolean isAbnormal, String severity ) { } + public record FrequencyZoneAnalysis( + ZoneStats low, + ZoneStats main, + ZoneStats high, + ZoneStats ultra + ) { + public record ZoneStats( + Double avg, + Double max + ) {} + } + public record AlertPanel( Boolean detected, String title, 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..c1e9104 100644 --- a/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java @@ -54,9 +54,6 @@ public static ChartPoint from(PressAnalysisResult result) { public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJsonEventTime) { var analysis = result.getAnalysisResult(); Double score = analysis.getRiskScore(); - if (score != null && score >= 100.0) { - score = 99.0; - } return new ChartPoint( analysis.getEventId(), analysis.getAnalysisId(), diff --git a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java index e008961..6d6fbcc 100644 --- a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java +++ b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java @@ -354,9 +354,9 @@ private ProcessComponent processComponent( double cycleOverTargetSec = Math.max(0, cycleTimeSec - targetCycleTimeSec); 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) + Math.min(40.0, stationDelaySec * 1.0) + + Math.min(35.0, cycleOverTargetSec * 1.0) + + Math.min(20.0, Math.max(0.0, rmsAmpere - 1.5) * 5.0) + (countIncrease ? 0.0 : 20.0) ); yield new ProcessComponent( @@ -370,7 +370,7 @@ yield new ProcessComponent( "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)" + "min(40, stationDelaySec * 1) + min(35, max(0, cycleTimeSec - targetCycleTimeSec) * 1) + min(20, max(0, rmsAmpere - 1.5) * 5) + (countIncreaseYn ? 0 : 20)" ); } case BODY -> { diff --git a/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java b/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java index c22c4b3..1fea334 100644 --- a/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java +++ b/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java @@ -21,9 +21,11 @@ public static BodyAnomalyDetectionResponse toResponse( List dateOptions, BodyAnomalyDetectionResponse.Metrics metrics, List chart, + List frequencyChart, + 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, chart, frequencyChart, frequencyZoneAnalysis, alert); } public static BodyAnomalyDetectionResponse.DateOption toDateOption( @@ -37,8 +39,11 @@ public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( String eventId, String analysisId, LocalDateTime timestamp, - Double robotVibrationScore, - Double frequencyPeakValue, + Double targetVibrationScore, + Double vibrationScore, + Double targetVibrationPeak, + Double vibrationPeak, + Double vibrationRms, Double riskScore, Boolean isAbnormal, String severity @@ -47,40 +52,27 @@ public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( eventId, analysisId, timestamp, - robotVibrationScore, - frequencyPeakValue, + targetVibrationScore, + vibrationScore, + targetVibrationPeak, + vibrationPeak, + 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"; - } - - return new BodyAnomalyDetectionResponse.ChartPoint( - analysis.getEventId(), - analysis.getAnalysisId(), - analysis.getEventTime(), - result.getRobotVibrationScore(), - frequencyPeakValue, - analysis.getRiskScore(), - isAbnormal, - severity - ); - } - public static BodyAnomalyDetectionResponse.Metrics toMetrics( String robotMotionStatus, String robotOperationMode, - Double robotVibrationScore, - Double frequencyPeakValue, + Double targetVibrationScore, + Double vibrationScore, + Double targetVibrationPeak, + Double vibrationPeak, + Double vibrationRms, String frequencyPeakBand, + Double frequencyPeakValue, Double riskScore, String riskScoreScale, String severity, @@ -89,9 +81,13 @@ public static BodyAnomalyDetectionResponse.Metrics toMetrics( return new BodyAnomalyDetectionResponse.Metrics( robotMotionStatus, robotOperationMode, - robotVibrationScore, - frequencyPeakValue, + targetVibrationScore, + vibrationScore, + targetVibrationPeak, + vibrationPeak, + vibrationRms, frequencyPeakBand, + frequencyPeakValue, riskScore, riskScoreScale, severity, 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/service/body/BodyAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java index 00a82e1..84fe7c9 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -6,7 +6,6 @@ import com.aims.assembly.repository.analysis.BodyAnalysisResultRepository; 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; @@ -31,6 +30,9 @@ @Transactional(readOnly = true) public class BodyAnomalyDetectionService { private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; + private static final Double TARGET_VIBRATION_SCORE = 0.75; + private static final Double TARGET_VIBRATION_PEAK = 0.007; + private static final Double TARGET_FREQUENCY_PEAK = 0.005; private final BodyAnalysisResultRepository repository; private final ManufacturingEventJsonRepository eventJsonRepository; @@ -80,14 +82,12 @@ public BodyAnomalyDetectionResponse findDashboard( 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) .max() .orElse(points.isEmpty() ? 0.0 : safeNumber(points.get(points.size() - 1).riskScore())); - // summaryPoint: 이상 포인트 중 가장 위험한 포인트(가중치 최대) BodyAnomalyDetectionResponse.ChartPoint latest = points.isEmpty() ? null : points.get(points.size() - 1); BodyAnomalyDetectionResponse.ChartPoint summaryPoint = points.stream() .filter(this::isBodyAnomaly) @@ -96,18 +96,31 @@ public BodyAnomalyDetectionResponse findDashboard( Comparator.nullsLast(Comparator.naturalOrder()))) .orElse(latest); - // summaryPoint에 해당하는 body result에서 metrics 상세 구성 BodyAnalysisResult summaryResult = summaryPoint == null ? null : repository.findByAnalysisResult_AnalysisId(summaryPoint.analysisId()).orElse(null); + BodyAnomalyDetectionResponse.Metrics metrics = toMetrics(summaryResult, summaryPoint, detected, maxRiskScore); + + BodyAnomalyDetectionResponse.FrequencyZoneAnalysis freqAnalysis = null; + List freqChart = new ArrayList<>(); + if (metrics != null && metrics.frequencyBands() != null) { + freqAnalysis = analyzeFrequencyZones(metrics.frequencyBands()); + LocalDateTime chartTime = summaryPoint != null ? summaryPoint.timestamp() : null; + metrics.frequencyBands().forEach((k, v) -> freqChart.add( + new BodyAnomalyDetectionResponse.FrequencyBandPoint(chartTime, formatFrequencyBand(k), v, TARGET_FREQUENCY_PEAK) + )); + } + BodyAnomalyDetectionResponse response = BodyAnomalyDetectionResponseMapper.toResponse( targetDate, rangeFrom, rangeTo, previousEndAt, dateOptions, - toMetrics(summaryResult, summaryPoint, detected, maxRiskScore), + metrics, points, + freqChart, + freqAnalysis, toAlert(points, detected, summaryResult) ); log.info( @@ -146,23 +159,17 @@ private LocalDate resolveDate( private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult result) { var analysis = result.getAnalysisResult(); - - // DB의 데이터 사용, null인 경우에만 eventJson에서 추출 Double vibrationScore = result.getRobotVibrationScore(); - Double peakValue = result.getFrequencyPeakValue(); + Double vibrationPeak = null; + Double vibrationRms = null; - 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; - } + SensorData sensorData = extractSensorDataFromEventJson(analysis.getEventId()); + if (sensorData != null) { + if (vibrationScore == null) { + vibrationScore = sensorData.vibrationScore; } + vibrationPeak = sensorData.peakValue; + vibrationRms = sensorData.vibrationRms; } boolean isAbnormal = Boolean.TRUE.equals(analysis.getIsAbnormal()) || (analysis.getRiskScore() != null && analysis.getRiskScore() >= 30.0); @@ -175,8 +182,11 @@ private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult analysis.getEventId(), analysis.getAnalysisId(), analysis.getEventTime(), + TARGET_VIBRATION_SCORE, vibrationScore, - toDisplayPeakValue(peakValue), + TARGET_VIBRATION_PEAK, + vibrationPeak, + vibrationRms, analysis.getRiskScore(), isAbnormal, severity @@ -193,8 +203,12 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( if (point != null && point.severity() != null && !detected) { severity = point.severity(); } + + Double pointVibrationScore = point != null ? point.vibrationScore() : null; + Double pointVibrationPeak = point != null ? point.vibrationPeak() : null; + Double pointVibrationRms = point != null ? point.vibrationRms() : null; + if (result == null) { - // eventJson에서 processData.body 정보 추출 BodyProcessData bodyData = null; if (point != null && point.eventId() != null) { bodyData = extractBodyDataFromEventJson(point.eventId()); @@ -204,9 +218,13 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( return BodyAnomalyDetectionResponseMapper.toMetrics( bodyData.robotMotionStatus, bodyData.robotOperationMode != null ? bodyData.robotOperationMode : "NORMAL", - null, - null, + TARGET_VIBRATION_SCORE, + pointVibrationScore, + TARGET_VIBRATION_PEAK, + pointVibrationPeak, + pointVibrationRms, bodyData.frequencyPeakBand, + null, maxRiskScore, "0-100", severity, @@ -214,41 +232,93 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( ); } return BodyAnomalyDetectionResponseMapper.toMetrics( - null, "NORMAL", null, null, null, maxRiskScore, "0-100", severity, new HashMap<>() + null, "NORMAL", TARGET_VIBRATION_SCORE, pointVibrationScore, TARGET_VIBRATION_PEAK, pointVibrationPeak, pointVibrationRms, null, null, maxRiskScore, "0-100", severity, new HashMap<>() ); } return BodyAnomalyDetectionResponseMapper.toMetrics( result.getRobotMotionStatus(), result.getRobotOperationMode() != null ? result.getRobotOperationMode() : "NORMAL", - result.getRobotVibrationScore(), - toDisplayPeakValue(result.getFrequencyPeakValue()), + TARGET_VIBRATION_SCORE, + pointVibrationScore != null ? pointVibrationScore : result.getRobotVibrationScore(), + TARGET_VIBRATION_PEAK, + pointVibrationPeak, + pointVibrationRms, result.getFrequencyPeakBand(), + result.getFrequencyPeakValue(), maxRiskScore, "0-100", severity, parseFrequencyBands(result.getFrequencyBandsJson()) ); } - + private Map parseFrequencyBands(String json) { if (json == null || json.isBlank()) { return new HashMap<>(); } try { - Map raw = objectMapper.readValue(json, new TypeReference>() {}); - Map summary = BodyFrequencyBandSupport.toSummaryBands(raw); - if (summary == null) { - return new HashMap<>(); - } - Map display = new java.util.LinkedHashMap<>(); - summary.forEach((key, value) -> display.put(key, toDisplayPeakValue(value))); - return display; + return objectMapper.readValue(json, new TypeReference>() {}); } catch (Exception e) { - log.warn("Failed to parse frequencyBandsJson: {}", json, e); + log.warn("Failed to parse frequencyBands json", e); return new HashMap<>(); } } + private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis analyzeFrequencyZones(Map raw) { + if (raw == null || raw.isEmpty()) { + return null; + } + + List lowVals = new ArrayList<>(); + List mainVals = new ArrayList<>(); + List highVals = new ArrayList<>(); + List ultraVals = new ArrayList<>(); + + for (Map.Entry entry : raw.entrySet()) { + String key = entry.getKey().toUpperCase(); + Double val = entry.getValue(); + if (val == null) continue; + + int upperHz = -1; + String[] parts = key.replace("FREQ_", "").replace("_HZ", "").split("_"); + try { + if (parts.length >= 2) { + upperHz = Integer.parseInt(parts[1]); + } else if (parts.length == 1) { + upperHz = Integer.parseInt(parts[0]); + } + } catch (Exception e) { + // ignore + } + + if (upperHz <= 0) continue; + + if (upperHz <= 300) { + lowVals.add(val); + } else if (upperHz <= 600) { + mainVals.add(val); + } else if (upperHz <= 1000) { + highVals.add(val); + } else { + ultraVals.add(val); + } + } + + return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis( + computeZoneStats(lowVals), + computeZoneStats(mainVals), + computeZoneStats(highVals), + computeZoneStats(ultraVals) + ); + } + + private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats computeZoneStats(List vals) { + if (vals.isEmpty()) return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(0.0, 0.0); + double max = vals.stream().mapToDouble(v -> v).max().orElse(0.0); + double avg = vals.stream().mapToDouble(v -> v).average().orElse(0.0); + return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(avg, max); + } + private BodyAnomalyDetectionResponse.AlertPanel toAlert( List points, boolean detected, @@ -265,8 +335,8 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( for (BodyAnomalyDetectionResponse.ChartPoint point : points) { if (!isBodyAnomaly(point)) continue; if (Boolean.TRUE.equals(point.isAbnormal())) abnormalCount++; - if (point.robotVibrationScore() != null) { - maxVibrationScore = Math.max(maxVibrationScore, point.robotVibrationScore()); + if (point.vibrationScore() != null) { + maxVibrationScore = Math.max(maxVibrationScore, point.vibrationScore()); } } @@ -284,8 +354,8 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( } if (summaryResult.getFrequencyPeakValue() != null && summaryResult.getFrequencyPeakValue() > 0) { reasons.add(String.format( - "피크 진동값 급증 (%.1f mm/s)", - toDisplayPeakValue(summaryResult.getFrequencyPeakValue()) + "피크 진동값 급증 (%.6f mm/s)", + summaryResult.getFrequencyPeakValue() )); } if (summaryResult.getFrequencyPeakBand() != null) { @@ -303,20 +373,10 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상 탐지", List.copyOf(reasons)); } - private Double toDisplayPeakValue(Double rawValue) { - if (rawValue == null) { - return null; - } - return rawValue < 1.0 ? rawValue * 1000.0 : rawValue; - } - private double toDisplayPeakValue(double rawValue) { - return rawValue < 1.0 ? rawValue * 1000.0 : rawValue; - } private String formatFrequencyBand(String raw) { if (raw == null) return ""; - // e.g. "501_600_HZ" → "501-600Hz", "MID" → "MID (30–80Hz)" return switch (raw.toUpperCase()) { case "LOW" -> "LOW (0–100Hz)"; case "MID", "MEDIUM" -> "MID (30–80Hz)"; @@ -336,8 +396,8 @@ private double anomalyWeight(BodyAnomalyDetectionResponse.ChartPoint point) { double weight = 0.0; if (Boolean.TRUE.equals(point.isAbnormal())) weight += 50.0; if (point.riskScore() != null) weight += point.riskScore(); - if (point.robotVibrationScore() != null) weight += point.robotVibrationScore() * 10.0; - if (point.frequencyPeakValue() != null) weight += point.frequencyPeakValue() * 5.0; + if (point.vibrationScore() != null) weight += point.vibrationScore() * 10.0; + if (point.vibrationPeak() != null) weight += point.vibrationPeak() * 5.0; return weight; } @@ -440,11 +500,11 @@ private SensorData extractSensorDataFromEventJson(String eventId) { return null; } - // robotArmVibration에서 vibrationScore 추출 @SuppressWarnings("unchecked") Map robotArmVibration = (Map) sensor.get("robotArmVibration"); Double vibrationScore = null; Double peakValue = null; + Double vibrationRms = null; if (robotArmVibration != null) { Object vibrationScoreObj = robotArmVibration.get("vibrationScore"); @@ -455,9 +515,13 @@ private SensorData extractSensorDataFromEventJson(String eventId) { if (vibrationPeakObj != null) { peakValue = ((Number) vibrationPeakObj).doubleValue(); } + Object vibrationRmsObj = robotArmVibration.get("vibrationRms"); + if (vibrationRmsObj != null) { + vibrationRms = ((Number) vibrationRmsObj).doubleValue(); + } } - return new SensorData(vibrationScore, peakValue); + return new SensorData(vibrationScore, peakValue, vibrationRms); } catch (Exception e) { log.warn("Failed to extract sensor data from eventJson for eventId: {}", eventId, e); return null; @@ -467,10 +531,14 @@ private SensorData extractSensorDataFromEventJson(String eventId) { private static class SensorData { Double vibrationScore; Double peakValue; + Double vibrationRms; - SensorData(Double vibrationScore, Double peakValue) { + SensorData(Double vibrationScore, Double peakValue, Double vibrationRms) { this.vibrationScore = vibrationScore; this.peakValue = peakValue; + this.vibrationRms = vibrationRms; } } + + } diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index 74c61ac..bf416cb 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -82,11 +82,8 @@ public PressAnomalyDetectionResponse findDashboard( boolean detected = points.stream().anyMatch(this::isPressAnomaly); LocalDateTime previousEndAt = points.isEmpty() ? null : points.get(0).timestamp().minusNanos(1); - double maxRiskScore = points.stream() - .filter(this::isPressAnomaly) - .mapToDouble(p -> p.riskScore() != null ? p.riskScore() : 0.0) - .max() - .orElse(summaryPoint != null && summaryPoint.riskScore() != null ? summaryPoint.riskScore() : 0.0); + Double dbMaxRiskScore = repository.findMaxRiskScoreByEventTimeBetween(rangeFrom, rangeTo); + double maxRiskScore = dbMaxRiskScore != null ? dbMaxRiskScore : 0.0; PressAnomalyDetectionResponse response = PressAnomalyDetectionResponseMapper.toResponse( targetDate, @@ -152,7 +149,22 @@ private PressAnomalyDetectionResponse.Metrics toMetrics( ) ); } - return PressAnomalyDetectionResponseMapper.toMetrics(point); + // Use the DB maxRiskScore instead of the single point's score + String computedSeverity = maxRiskScore >= 80.0 ? "CRITICAL" : (maxRiskScore >= 60.0 ? "WARNING" : point.severity()); + PressAnomalyDetectionResponse.ChartPoint updatedPoint = new PressAnomalyDetectionResponse.ChartPoint( + point.eventId(), + point.analysisId(), + point.timestamp(), + point.targetCycleTimeSec(), + point.actualCycleTimeSec(), + point.cycleTimeGapSec(), + point.timestampDelaySec(), + maxRiskScore, + point.countIncreaseYn(), + point.isAbnormal(), + computedSeverity + ); + return PressAnomalyDetectionResponseMapper.toMetrics(updatedPoint); } private PressAnomalyDetectionResponse.AlertPanel toAlert( diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 6d42d01..a68fded 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -24,10 +24,6 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} timeout: ${REDIS_TIMEOUT:3s} - redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - timeout: ${REDIS_TIMEOUT:3s} web: encoding: charset: UTF-8 From b1d46c91d539aa0d8ccc9e8001f29af992455ced Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 9 Jul 2026 13:47:20 +0900 Subject: [PATCH 09/35] =?UTF-8?q?feat(body):=20=EC=B0=A8=EC=B2=B4=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=20=ED=83=90=EC=A7=80=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B8=B0=EC=A4=80=EC=84=A0=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../body/BodyAnomalyDetectionController.java | 2 +- .../PressAnomalyDetectionController.java | 2 +- .../assembly/AssemblyAnalysisResult.java | 7 +- .../domain/body/BodyAnalysisResult.java | 5 +- .../aims/assembly/domain/car/CarMaster.java | 10 +- .../assembly/domain/equipment/Equipment.java | 13 +- .../domain/paint/PaintAnalysisResult.java | 5 +- .../domain/press/PressAnalysisResult.java | 5 +- .../body/BodyAnomalyDetectionResponse.java | 98 +++- .../BodyAnomalyDetectionResponseMapper.java | 156 ++++- .../body/BodyAnomalyDetectionService.java | 554 +++++++++++++----- .../body/BodyAnomalyDetectionServiceTest.java | 95 ++- 12 files changed, 728 insertions(+), 224 deletions(-) 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/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/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/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/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/dto/body/BodyAnomalyDetectionResponse.java b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java index baa1694..1e0d190 100644 --- a/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java @@ -12,17 +12,12 @@ public record BodyAnomalyDetectionResponse( LocalDateTime previousEndAt, List dateOptions, Metrics metrics, - List chart, + Charts charts, List frequencyChart, + List frequencyZoneChart, FrequencyZoneAnalysis frequencyZoneAnalysis, AlertPanel alert ) { - public record FrequencyBandPoint( - LocalDateTime timestamp, - String band, - Double value, - Double targetValue - ) {} public record DateOption( LocalDate date, String sampleEventId @@ -32,13 +27,15 @@ public record DateOption( public record Metrics( String robotMotionStatus, String robotOperationMode, - Double targetVibrationScore, - Double vibrationScore, - Double targetVibrationPeak, + Double robotVibrationScore, Double vibrationPeak, Double vibrationRms, String frequencyPeakBand, Double frequencyPeakValue, + Double vibrationWarningLine, + Double vibrationDangerLine, + Double peakWarningLine, + Double peakDangerLine, Double riskScore, String riskScoreScale, String severity, @@ -46,14 +43,64 @@ 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, + LocalDateTime timestamp, + Double value, + Double warningLine, + Double dangerLine, + Boolean isAbnormal, + String severity + ) { + } + + public record PeakMetricPoint( + String eventId, + String analysisId, + LocalDateTime timestamp, + Double value, + Double secondaryValue, + Double warningLine, + Double dangerLine, + Boolean isAbnormal, + String severity + ) { + } + public record ChartPoint( String eventId, String analysisId, LocalDateTime timestamp, - Double targetVibrationScore, - Double vibrationScore, - Double targetVibrationPeak, + Double robotVibrationScore, + Double frequencyPeakValue, Double vibrationPeak, + Double vibrationWarningLine, + Double vibrationDangerLine, + Double peakWarningLine, + Double peakDangerLine, Double vibrationRms, Double riskScore, Boolean isAbnormal, @@ -61,6 +108,28 @@ public record ChartPoint( ) { } + 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 low, ZoneStats main, @@ -70,7 +139,8 @@ public record FrequencyZoneAnalysis( public record ZoneStats( Double avg, Double max - ) {} + ) { + } } public record AlertPanel( diff --git a/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java b/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java index 1fea334..44dfa7d 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,12 +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, frequencyChart, frequencyZoneAnalysis, alert); + return new BodyAnomalyDetectionResponse(date, from, to, previousEndAt, dateOptions, metrics, charts, frequencyChart, frequencyZoneChart, frequencyZoneAnalysis, alert); } public static BodyAnomalyDetectionResponse.DateOption toDateOption( @@ -39,10 +38,13 @@ public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( String eventId, String analysisId, LocalDateTime timestamp, - Double targetVibrationScore, - Double vibrationScore, - Double targetVibrationPeak, + Double robotVibrationScore, + Double frequencyPeakValue, Double vibrationPeak, + Double vibrationWarningLine, + Double vibrationDangerLine, + Double peakWarningLine, + Double peakDangerLine, Double vibrationRms, Double riskScore, Boolean isAbnormal, @@ -52,10 +54,13 @@ public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( eventId, analysisId, timestamp, - targetVibrationScore, - vibrationScore, - targetVibrationPeak, + robotVibrationScore, + frequencyPeakValue, vibrationPeak, + vibrationWarningLine, + vibrationDangerLine, + peakWarningLine, + peakDangerLine, vibrationRms, riskScore, isAbnormal, @@ -63,16 +68,89 @@ public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( ); } + public static BodyAnomalyDetectionResponse.RobotMetricPoint toRobotMetricPoint( + String eventId, + String analysisId, + LocalDateTime timestamp, + Double value, + Double warningLine, + Double dangerLine, + Boolean isAbnormal, + String severity + ) { + return new BodyAnomalyDetectionResponse.RobotMetricPoint( + eventId, + analysisId, + timestamp, + value, + warningLine, + dangerLine, + isAbnormal, + severity + ); + } + + public static BodyAnomalyDetectionResponse.PeakMetricPoint toPeakMetricPoint( + String eventId, + String analysisId, + LocalDateTime timestamp, + Double value, + Double secondaryValue, + Double warningLine, + Double dangerLine, + Boolean isAbnormal, + String severity + ) { + return new BodyAnomalyDetectionResponse.PeakMetricPoint( + eventId, + analysisId, + 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 targetVibrationScore, - Double vibrationScore, - Double targetVibrationPeak, + Double robotVibrationScore, Double vibrationPeak, Double vibrationRms, String frequencyPeakBand, Double frequencyPeakValue, + Double vibrationWarningLine, + Double vibrationDangerLine, + Double peakWarningLine, + Double peakDangerLine, Double riskScore, String riskScoreScale, String severity, @@ -81,13 +159,15 @@ public static BodyAnomalyDetectionResponse.Metrics toMetrics( return new BodyAnomalyDetectionResponse.Metrics( robotMotionStatus, robotOperationMode, - targetVibrationScore, - vibrationScore, - targetVibrationPeak, + robotVibrationScore, vibrationPeak, vibrationRms, frequencyPeakBand, frequencyPeakValue, + vibrationWarningLine, + vibrationDangerLine, + peakWarningLine, + peakDangerLine, riskScore, riskScoreScale, severity, @@ -95,6 +175,46 @@ 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, 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 84fe7c9..94e8ada 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -20,9 +20,13 @@ 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.Locale; import java.util.stream.Collectors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Slf4j @Service @@ -30,16 +34,32 @@ @Transactional(readOnly = true) public class BodyAnomalyDetectionService { private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; - private static final Double TARGET_VIBRATION_SCORE = 0.75; - private static final Double TARGET_VIBRATION_PEAK = 0.007; - private static final Double TARGET_FREQUENCY_PEAK = 0.005; + private static final Pattern DETAILED_BAND_PATTERN = Pattern.compile("freq_(\\d+)_(\\d+)_hz", Pattern.CASE_INSENSITIVE); + + /* + * Kafka BODY score formula: + * score = min(50, robotVibrationScore * 40) + * + min(30, frequencyPeakValue * 1000) + * + motion/operation penalties + * + * Reverse-derived chart lines: + * - warning: component contribution reaches 30 + * - danger: component contribution reaches component cap + */ + private static final Double ROBOT_VIBRATION_WARNING_LINE = 0.75; // 30 / 40 + private static final Double ROBOT_VIBRATION_DANGER_LINE = 1.25; // 50 / 40 + private static final Double PEAK_WARNING_LINE = 0.015; // 15 / 1000 + private static final Double PEAK_DANGER_LINE = 0.03; // 30 / 1000 + private static final Double FREQUENCY_TARGET_VALUE = 0.005; + private static final Double FREQUENCY_WARNING_VALUE = PEAK_WARNING_LINE; + private static final Double FREQUENCY_DANGER_VALUE = PEAK_DANGER_LINE; private final BodyAnalysisResultRepository repository; private final ManufacturingEventJsonRepository eventJsonRepository; private final ObjectMapper objectMapper; @Cacheable( - cacheNames = "body-anomaly-dashboard-v2", + cacheNames = "body-anomaly-dashboard-v3", key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString()) + ':' + (#limit ?: 30)" ) public BodyAnomalyDetectionResponse findDashboard( @@ -83,10 +103,9 @@ public BodyAnomalyDetectionResponse findDashboard( 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) .max() - .orElse(points.isEmpty() ? 0.0 : safeNumber(points.get(points.size() - 1).riskScore())); + .orElse(0.0); BodyAnomalyDetectionResponse.ChartPoint latest = points.isEmpty() ? null : points.get(points.size() - 1); BodyAnomalyDetectionResponse.ChartPoint summaryPoint = points.stream() @@ -100,38 +119,67 @@ public BodyAnomalyDetectionResponse findDashboard( : repository.findByAnalysisResult_AnalysisId(summaryPoint.analysisId()).orElse(null); BodyAnomalyDetectionResponse.Metrics metrics = toMetrics(summaryResult, summaryPoint, detected, maxRiskScore); - - BodyAnomalyDetectionResponse.FrequencyZoneAnalysis freqAnalysis = null; - List freqChart = new ArrayList<>(); - if (metrics != null && metrics.frequencyBands() != null) { - freqAnalysis = analyzeFrequencyZones(metrics.frequencyBands()); - LocalDateTime chartTime = summaryPoint != null ? summaryPoint.timestamp() : null; - metrics.frequencyBands().forEach((k, v) -> freqChart.add( - new BodyAnomalyDetectionResponse.FrequencyBandPoint(chartTime, formatFrequencyBand(k), v, TARGET_FREQUENCY_PEAK) - )); - } - - BodyAnomalyDetectionResponse response = BodyAnomalyDetectionResponseMapper.toResponse( + + List robotVibrationPoints = points.stream() + .map(point -> BodyAnomalyDetectionResponseMapper.toRobotMetricPoint( + point.eventId(), + point.analysisId(), + 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.timestamp(), + point.frequencyPeakValue(), + point.vibrationRms(), + point.peakWarningLine(), + point.peakDangerLine(), + point.isAbnormal(), + point.severity() + )) + .toList(); + + Map rawFrequencyBands = resolveRawFrequencyBands(summaryResult, summaryPoint); + BodyAnomalyDetectionResponse.FrequencyZoneAnalysis freqAnalysis = analyzeFrequencyZones(rawFrequencyBands); + List freqChart = buildFrequencyChart( + rawFrequencyBands, + summaryPoint != null ? summaryPoint.timestamp() : null + ); + List freqZoneChart = buildFrequencyZoneChart(rawFrequencyBands); + + return BodyAnomalyDetectionResponseMapper.toResponse( targetDate, rangeFrom, rangeTo, previousEndAt, dateOptions, metrics, - points, + 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() { @@ -159,20 +207,21 @@ private LocalDate resolveDate( private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult result) { var analysis = result.getAnalysisResult(); - Double vibrationScore = result.getRobotVibrationScore(); + Double robotVibrationScore = result.getRobotVibrationScore(); Double vibrationPeak = null; Double vibrationRms = null; - + SensorData sensorData = extractSensorDataFromEventJson(analysis.getEventId()); if (sensorData != null) { - if (vibrationScore == null) { - vibrationScore = sensorData.vibrationScore; + if (robotVibrationScore == null) { + robotVibrationScore = sensorData.vibrationScore(); } - vibrationPeak = sensorData.peakValue; - vibrationRms = sensorData.vibrationRms; + vibrationPeak = sensorData.peakValue(); + vibrationRms = sensorData.vibrationRms(); } - - boolean isAbnormal = Boolean.TRUE.equals(analysis.getIsAbnormal()) || (analysis.getRiskScore() != null && analysis.getRiskScore() >= 30.0); + + 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"; @@ -182,10 +231,13 @@ private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult analysis.getEventId(), analysis.getAnalysisId(), analysis.getEventTime(), - TARGET_VIBRATION_SCORE, - vibrationScore, - TARGET_VIBRATION_PEAK, + robotVibrationScore, + result.getFrequencyPeakValue(), vibrationPeak, + ROBOT_VIBRATION_WARNING_LINE, + ROBOT_VIBRATION_DANGER_LINE, + PEAK_WARNING_LINE, + PEAK_DANGER_LINE, vibrationRms, analysis.getRiskScore(), isAbnormal, @@ -203,107 +255,193 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( if (point != null && point.severity() != null && !detected) { severity = point.severity(); } - - Double pointVibrationScore = point != null ? point.vibrationScore() : null; + + 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; + if (result == null) { 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", - TARGET_VIBRATION_SCORE, - pointVibrationScore, - TARGET_VIBRATION_PEAK, + localizeRobotMotionStatus(bodyData.robotMotionStatus()), + localizeRobotOperationMode(bodyData.robotOperationMode() != null ? bodyData.robotOperationMode() : "NORMAL"), + pointRobotVibrationScore, pointVibrationPeak, pointVibrationRms, - bodyData.frequencyPeakBand, - null, + localizeFrequencyPeakBand(bodyData.frequencyPeakBand()), + pointFrequencyPeakValue, + ROBOT_VIBRATION_WARNING_LINE, + ROBOT_VIBRATION_DANGER_LINE, + PEAK_WARNING_LINE, + PEAK_DANGER_LINE, maxRiskScore, "0-100", severity, - bodyData.frequencyBands != null ? bodyData.frequencyBands : new HashMap<>() + summarizeFrequencyBands(bodyData.frequencyBands()) ); } + return BodyAnomalyDetectionResponseMapper.toMetrics( - null, "NORMAL", TARGET_VIBRATION_SCORE, pointVibrationScore, TARGET_VIBRATION_PEAK, pointVibrationPeak, pointVibrationRms, null, null, maxRiskScore, "0-100", severity, new HashMap<>() + null, + "정상", + pointRobotVibrationScore, + pointVibrationPeak, + pointVibrationRms, + null, + pointFrequencyPeakValue, + ROBOT_VIBRATION_WARNING_LINE, + ROBOT_VIBRATION_DANGER_LINE, + PEAK_WARNING_LINE, + PEAK_DANGER_LINE, + maxRiskScore, + "0-100", + severity, + new HashMap<>() ); } + return BodyAnomalyDetectionResponseMapper.toMetrics( - result.getRobotMotionStatus(), - result.getRobotOperationMode() != null ? result.getRobotOperationMode() : "NORMAL", - TARGET_VIBRATION_SCORE, - pointVibrationScore != null ? pointVibrationScore : result.getRobotVibrationScore(), - TARGET_VIBRATION_PEAK, + localizeRobotMotionStatus(result.getRobotMotionStatus()), + localizeRobotOperationMode(result.getRobotOperationMode() != null ? result.getRobotOperationMode() : "NORMAL"), + pointRobotVibrationScore != null ? pointRobotVibrationScore : result.getRobotVibrationScore(), pointVibrationPeak, pointVibrationRms, - result.getFrequencyPeakBand(), + localizeFrequencyPeakBand(result.getFrequencyPeakBand()), result.getFrequencyPeakValue(), + ROBOT_VIBRATION_WARNING_LINE, + ROBOT_VIBRATION_DANGER_LINE, + PEAK_WARNING_LINE, + PEAK_DANGER_LINE, maxRiskScore, "0-100", severity, - parseFrequencyBands(result.getFrequencyBandsJson()) + summarizeFrequencyBands(parseFrequencyBands(result.getFrequencyBandsJson())) ); } - + private Map parseFrequencyBands(String json) { if (json == null || json.isBlank()) { return new HashMap<>(); } try { - return objectMapper.readValue(json, new TypeReference>() {}); + return objectMapper.readValue(json, new TypeReference>() { + }); } catch (Exception e) { log.warn("Failed to parse frequencyBands json", e); return new HashMap<>(); } } + private Map summarizeFrequencyBands(Map bands) { + Map summary = BodyFrequencyBandSupport.toSummaryBands(bands); + if (summary == null || summary.isEmpty()) { + return new HashMap<>(); + } + + Map localized = new LinkedHashMap<>(); + summary.forEach((key, value) -> localized.put(localizeFrequencyBandKey(key), value)); + return localized; + } + + private List buildFrequencyChart( + Map rawBands, + LocalDateTime chartTime + ) { + if (rawBands == null || rawBands.isEmpty()) { + return List.of(); + } + + return rawBands.entrySet().stream() + .filter(entry -> entry.getValue() != null) + .sorted(Comparator.comparingInt(entry -> frequencyBandOrder(entry.getKey()))) + .map(entry -> BodyAnomalyDetectionResponseMapper.toFrequencyBandPoint( + chartTime, + localizeFrequencyBandLabel(entry.getKey()), + entry.getValue(), + FREQUENCY_TARGET_VALUE, + FREQUENCY_WARNING_VALUE, + FREQUENCY_DANGER_VALUE + )) + .toList(); + } + + private List buildFrequencyZoneChart(Map rawBands) { + if (rawBands == null || rawBands.isEmpty()) { + return List.of(); + } + + List zone1 = new ArrayList<>(); + List zone2 = new ArrayList<>(); + List zone3 = new ArrayList<>(); + List zone4 = new ArrayList<>(); + List zone5 = new ArrayList<>(); + + for (Map.Entry entry : rawBands.entrySet()) { + Double value = entry.getValue(); + if (value == null) { + continue; + } + int upperHz = extractUpperHz(entry.getKey()); + if (upperHz <= 0) { + continue; + } + + if (upperHz <= 300) { + zone1.add(value); + } else if (upperHz <= 600) { + zone2.add(value); + } else if (upperHz <= 900) { + zone3.add(value); + } else if (upperHz <= 1200) { + zone4.add(value); + } else if (upperHz <= 1600) { + zone5.add(value); + } + } + + return List.of( + buildFrequencyZonePoint("Zone 1", "0~300Hz", "저주파 / 기본 구조 진동", zone1), + buildFrequencyZonePoint("Zone 2", "301~600Hz", "로봇 본체 진동", zone2), + buildFrequencyZonePoint("Zone 3", "601~900Hz", "관절·감속기 진동", zone3), + buildFrequencyZonePoint("Zone 4", "901~1200Hz", "베어링·기계 이상 진동", zone4), + buildFrequencyZonePoint("Zone 5", "1201~1600Hz", "고주파 충격·충돌 위험", zone5) + ); + } + private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis analyzeFrequencyZones(Map raw) { if (raw == null || raw.isEmpty()) { return null; } - + List lowVals = new ArrayList<>(); List mainVals = new ArrayList<>(); List highVals = new ArrayList<>(); List ultraVals = new ArrayList<>(); - + for (Map.Entry entry : raw.entrySet()) { - String key = entry.getKey().toUpperCase(); Double val = entry.getValue(); if (val == null) continue; - - int upperHz = -1; - String[] parts = key.replace("FREQ_", "").replace("_HZ", "").split("_"); - try { - if (parts.length >= 2) { - upperHz = Integer.parseInt(parts[1]); - } else if (parts.length == 1) { - upperHz = Integer.parseInt(parts[0]); - } - } catch (Exception e) { - // ignore - } - + + int upperHz = extractUpperHz(entry.getKey()); if (upperHz <= 0) continue; - + if (upperHz <= 300) { lowVals.add(val); } else if (upperHz <= 600) { mainVals.add(val); - } else if (upperHz <= 1000) { + } else if (upperHz <= 900) { highVals.add(val); } else { ultraVals.add(val); } } - + return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis( computeZoneStats(lowVals), computeZoneStats(mainVals), @@ -311,76 +449,205 @@ private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis analyzeFrequencyZones computeZoneStats(ultraVals) ); } - + + private BodyAnomalyDetectionResponse.FrequencyZonePoint buildFrequencyZonePoint( + String zone, + String range, + String description, + List values + ) { + BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats stats = computeZoneStats(values); + return BodyAnomalyDetectionResponseMapper.toFrequencyZonePoint( + zone, + range, + description, + stats.avg(), + stats.max(), + FREQUENCY_TARGET_VALUE, + FREQUENCY_WARNING_VALUE, + FREQUENCY_DANGER_VALUE + ); + } + private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats computeZoneStats(List vals) { - if (vals.isEmpty()) return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(0.0, 0.0); + if (vals.isEmpty()) { + return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(0.0, 0.0); + } double max = vals.stream().mapToDouble(v -> v).max().orElse(0.0); double avg = vals.stream().mapToDouble(v -> v).average().orElse(0.0); return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(avg, max); } + private Map resolveRawFrequencyBands( + BodyAnalysisResult result, + BodyAnomalyDetectionResponse.ChartPoint point + ) { + if (result != null) { + return parseFrequencyBands(result.getFrequencyBandsJson()); + } + if (point != null && point.eventId() != null) { + BodyProcessData bodyData = extractBodyDataFromEventJson(point.eventId()); + if (bodyData != null && bodyData.frequencyBands() != null) { + return bodyData.frequencyBands(); + } + } + return new HashMap<>(); + } + + private int frequencyBandOrder(String rawKey) { + int[] range = extractBandRange(rawKey); + if (range != null) { + return range[0]; + } + + String normalized = rawKey == null ? "" : rawKey.toUpperCase(Locale.ROOT); + return switch (normalized) { + case "LOW" -> 0; + case "MID", "MEDIUM" -> 1; + case "HIGH" -> 2; + default -> Integer.MAX_VALUE; + }; + } + + private int extractUpperHz(String rawKey) { + int[] range = extractBandRange(rawKey); + return range == null ? -1 : range[1]; + } + + private int[] extractBandRange(String rawKey) { + if (rawKey == null || rawKey.isBlank()) { + return null; + } + + Matcher matcher = DETAILED_BAND_PATTERN.matcher(rawKey.toLowerCase(Locale.ROOT).replace("-", "_")); + if (matcher.find()) { + return new int[] { + Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2)) + }; + } + + String normalized = rawKey.trim().toUpperCase(Locale.ROOT); + return switch (normalized) { + case "LOW" -> new int[] { 0, 100 }; + case "MID", "MEDIUM" -> new int[] { 101, 200 }; + case "HIGH" -> new int[] { 201, 500 }; + default -> null; + }; + } + + private String localizeFrequencyBandLabel(String raw) { + int[] range = extractBandRange(raw); + if (range != null) { + return range[0] + "~" + range[1] + "Hz"; + } + return formatFrequencyBand(raw); + } + + private String localizeFrequencyBandKey(String raw) { + if (raw == null) { + return ""; + } + return switch (raw.toUpperCase(Locale.ROOT)) { + case "LOW" -> "LOW"; + case "MID", "MEDIUM" -> "MEDIUM"; + case "HIGH" -> "HIGH"; + default -> raw; + }; + } + + private String localizeFrequencyPeakBand(String raw) { + int[] range = extractBandRange(raw); + if (range != null) { + return range[0] + "~" + range[1] + "Hz"; + } + return formatFrequencyBand(raw); + } + + private String localizeRobotMotionStatus(String raw) { + if (raw == null || raw.isBlank()) { + return "NORMAL"; + } + return switch (raw.toUpperCase(Locale.ROOT)) { + case "NORMAL" -> "NORMAL"; + case "WARNING" -> "WARNING"; + case "COLLISION_RISK" -> "COLLISION_RISK"; + case "ABNORMAL" -> "ABNORMAL"; + default -> raw; + }; + } + + private String localizeRobotOperationMode(String raw) { + if (raw == null || raw.isBlank()) { + return "NORMAL"; + } + return switch (raw.toUpperCase(Locale.ROOT)) { + case "NORMAL" -> "NORMAL"; + case "AUTO" -> "AUTO"; + case "MANUAL" -> "MANUAL"; + case "STOPPED" -> "STOPPED"; + case "AUTO_MANUAL_STOPPED" -> "AUTO_MANUAL_STOPPED"; + default -> raw; + }; + } + private BodyAnomalyDetectionResponse.AlertPanel toAlert( List points, boolean detected, BodyAnalysisResult summaryResult ) { if (!detected || points == null || points.isEmpty()) { - return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상 미탐지", List.of()); + return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상이 감지되지 않았습니다", List.of()); } List reasons = new ArrayList<>(); int abnormalCount = 0; - double maxVibrationScore = 0.0; + double maxRobotVibrationScore = 0.0; for (BodyAnomalyDetectionResponse.ChartPoint point : points) { if (!isBodyAnomaly(point)) continue; if (Boolean.TRUE.equals(point.isAbnormal())) abnormalCount++; - if (point.vibrationScore() != null) { - maxVibrationScore = Math.max(maxVibrationScore, point.vibrationScore()); + if (point.robotVibrationScore() != null) { + maxRobotVibrationScore = Math.max(maxRobotVibrationScore, point.robotVibrationScore()); } } if (summaryResult != null) { if ("COLLISION_RISK".equals(summaryResult.getRobotMotionStatus())) { - reasons.add("robot_motion_status = COLLISION_RISK"); + reasons.add("로봇 진동 상태 = 충돌 위험"); } else if (summaryResult.getRobotMotionStatus() != null && !"NORMAL".equals(summaryResult.getRobotMotionStatus())) { - reasons.add("robot_motion_status = " + summaryResult.getRobotMotionStatus()); + reasons.add("로봇 진동 상태 = " + localizeRobotMotionStatus(summaryResult.getRobotMotionStatus())); } if ("AUTO_MANUAL_STOPPED".equals(summaryResult.getRobotOperationMode()) || "STOPPED".equals(summaryResult.getRobotOperationMode()) || "MANUAL".equals(summaryResult.getRobotOperationMode())) { - reasons.add("robot_operation_mode = " + summaryResult.getRobotOperationMode()); + reasons.add("로봇 동작 모드 = " + localizeRobotOperationMode(summaryResult.getRobotOperationMode())); } if (summaryResult.getFrequencyPeakValue() != null && summaryResult.getFrequencyPeakValue() > 0) { - reasons.add(String.format( - "피크 진동값 급증 (%.6f mm/s)", - summaryResult.getFrequencyPeakValue() - )); + reasons.add(String.format("피크 진동 증가 (%.6f mm/s)", summaryResult.getFrequencyPeakValue())); } if (summaryResult.getFrequencyPeakBand() != null) { - reasons.add("고주파 대역 이상 감지 (" + formatFrequencyBand(summaryResult.getFrequencyPeakBand()) + ")"); + reasons.add("주파수 피크 대역 = " + localizeFrequencyPeakBand(summaryResult.getFrequencyPeakBand())); } } if (abnormalCount > 0) { - reasons.add("이상 징후 감지: " + abnormalCount + "건"); + reasons.add("이상 포인트 수 = " + abnormalCount); } - if (maxVibrationScore > 0) { - reasons.add(String.format("최대 진동 점수: %.2f", maxVibrationScore)); + if (maxRobotVibrationScore > 0) { + reasons.add(String.format("최대 로봇 진동 점수 = %.2f", maxRobotVibrationScore)); } - return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상 탐지", List.copyOf(reasons)); + return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상이 감지되었습니다", List.copyOf(reasons)); } - - private String formatFrequencyBand(String raw) { if (raw == null) return ""; - return switch (raw.toUpperCase()) { - case "LOW" -> "LOW (0–100Hz)"; - case "MID", "MEDIUM" -> "MID (30–80Hz)"; - case "HIGH" -> "HIGH (80–300Hz)"; + return switch (raw.toUpperCase(Locale.ROOT)) { + case "LOW" -> "LOW (0-300Hz)"; + case "MID", "MEDIUM" -> "MID (300-600Hz)"; + case "HIGH" -> "HIGH (600-1000Hz)"; default -> raw.replace("_HZ", "Hz").replace("_", "-"); }; } @@ -396,8 +663,9 @@ private double anomalyWeight(BodyAnomalyDetectionResponse.ChartPoint point) { double weight = 0.0; if (Boolean.TRUE.equals(point.isAbnormal())) weight += 50.0; if (point.riskScore() != null) weight += point.riskScore(); - if (point.vibrationScore() != null) weight += point.vibrationScore() * 10.0; + if (point.robotVibrationScore() != null) weight += point.robotVibrationScore() * 10.0; if (point.vibrationPeak() != null) weight += point.vibrationPeak() * 5.0; + if (point.frequencyPeakValue() != null) weight += point.frequencyPeakValue() * 5.0; return weight; } @@ -411,8 +679,11 @@ private boolean isLater(BodyAnalysisResult left, BodyAnalysisResult right) { if (leftAt != null && rightAt != null) { int compared = leftAt.compareTo(rightAt); if (compared != 0) return compared > 0; - } else if (leftAt != null) return true; - else if (rightAt != null) return false; + } else if (leftAt != null) { + return true; + } else if (rightAt != null) { + return false; + } Long leftId = left.getAnalysisResult().getId(); Long rightId = right.getAnalysisResult().getId(); if (leftId == null) return false; @@ -426,36 +697,37 @@ private BodyProcessData extractBodyDataFromEventJson(String eventId) { if (eventOpt.isEmpty()) { return null; } - + var storedEvent = eventOpt.get(); Map eventMap = objectMapper.readValue( - storedEvent.rawJson(), - new TypeReference>() {} + storedEvent.rawJson(), + new TypeReference>() { + } ); - + if (eventMap == null) { return null; } - + @SuppressWarnings("unchecked") Map processData = (Map) eventMap.get("processData"); if (processData == null) { return null; } - + @SuppressWarnings("unchecked") Map bodyData = (Map) processData.get("body"); if (bodyData == null) { return null; } - + String robotMotionStatus = (String) bodyData.get("robotMotionStatus"); String robotOperationMode = (String) bodyData.get("robotOperationMode"); String frequencyPeakBand = (String) bodyData.get("frequencyPeakBand"); - + @SuppressWarnings("unchecked") Map frequencyBands = (Map) bodyData.get("frequencyBands"); - + return new BodyProcessData(robotMotionStatus, robotOperationMode, frequencyPeakBand, frequencyBands); } catch (Exception e) { log.warn("Failed to extract body data from eventJson for eventId: {}", eventId, e); @@ -463,18 +735,12 @@ private BodyProcessData extractBodyDataFromEventJson(String eventId) { } } - private static class BodyProcessData { - String robotMotionStatus; - String robotOperationMode; - String frequencyPeakBand; - Map frequencyBands; - - BodyProcessData(String robotMotionStatus, String robotOperationMode, String frequencyPeakBand, Map frequencyBands) { - this.robotMotionStatus = robotMotionStatus; - this.robotOperationMode = robotOperationMode; - this.frequencyPeakBand = frequencyPeakBand; - this.frequencyBands = frequencyBands; - } + private record BodyProcessData( + String robotMotionStatus, + String robotOperationMode, + String frequencyPeakBand, + Map frequencyBands + ) { } private SensorData extractSensorDataFromEventJson(String eventId) { @@ -483,29 +749,30 @@ private SensorData extractSensorDataFromEventJson(String eventId) { if (eventOpt.isEmpty()) { return null; } - + var storedEvent = eventOpt.get(); Map eventMap = objectMapper.readValue( storedEvent.rawJson(), - new TypeReference>() {} + new TypeReference>() { + } ); - + if (eventMap == null) { return null; } - + @SuppressWarnings("unchecked") Map sensor = (Map) eventMap.get("sensor"); if (sensor == null) { return null; } - + @SuppressWarnings("unchecked") Map robotArmVibration = (Map) sensor.get("robotArmVibration"); Double vibrationScore = null; Double peakValue = null; Double vibrationRms = null; - + if (robotArmVibration != null) { Object vibrationScoreObj = robotArmVibration.get("vibrationScore"); if (vibrationScoreObj != null) { @@ -520,7 +787,7 @@ private SensorData extractSensorDataFromEventJson(String eventId) { vibrationRms = ((Number) vibrationRmsObj).doubleValue(); } } - + return new SensorData(vibrationScore, peakValue, vibrationRms); } catch (Exception e) { log.warn("Failed to extract sensor data from eventJson for eventId: {}", eventId, e); @@ -528,17 +795,10 @@ private SensorData extractSensorDataFromEventJson(String eventId) { } } - private static class SensorData { - Double vibrationScore; - Double peakValue; - Double vibrationRms; - - SensorData(Double vibrationScore, Double peakValue, Double vibrationRms) { - this.vibrationScore = vibrationScore; - this.peakValue = peakValue; - this.vibrationRms = vibrationRms; - } + private record SensorData( + Double vibrationScore, + Double peakValue, + Double vibrationRms + ) { } - - } diff --git a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java index ade3df1..1aac42c 100644 --- a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java @@ -4,8 +4,10 @@ import com.aims.assembly.domain.body.BodyAnalysisResult; import com.aims.assembly.domain.enums.ProcessCode; import com.aims.assembly.domain.enums.Severity; +import com.aims.assembly.kafka.model.ManufacturingRawEvent; import com.aims.assembly.repository.analysis.BodyAnalysisResultRepository; import com.aims.assembly.repository.event.ManufacturingEventJsonRepository; +import com.aims.assembly.repository.event.ManufacturingEventJsonRepository.StoredManufacturingEvent; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.data.domain.PageRequest; @@ -13,6 +15,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -27,7 +30,7 @@ class BodyAnomalyDetectionServiceTest { new BodyAnomalyDetectionService(repository, eventJsonRepository, new ObjectMapper()); @Test - void dashboardReturnsRobotMetricsChartAndAlertReasons() { + void dashboardReturnsSeparatedChartsAndThresholds() { BodyAnalysisResult result = bodyResult( "EVT-20260601-000046", LocalDateTime.of(2026, 6, 1, 10, 15), @@ -46,6 +49,33 @@ void dashboardReturnsRobotMetricsChartAndAlertReasons() { )).thenReturn(List.of(result)); when(repository.findByAnalysisResult_AnalysisId("ANL-EVT-20260601-000046")) .thenReturn(Optional.of(result)); + when(eventJsonRepository.findByEventId("EVT-20260601-000046")) + .thenReturn(Optional.of(storedEvent( + "EVT-20260601-000046", + """ + { + "processData": { + "body": { + "robotMotionStatus": "COLLISION_RISK", + "robotOperationMode": "AUTO_MANUAL_STOPPED", + "frequencyPeakBand": "501_600_HZ", + "frequencyBands": { + "freq_0_100_hz": 1.193284, + "freq_101_200_hz": 1.987782, + "freq_501_600_hz": 2.907113 + } + } + }, + "sensor": { + "robotArmVibration": { + "vibrationScore": 0.27, + "vibrationPeak": 0.0067, + "vibrationRms": 0.003611111 + } + } + } + """ + ))); var response = service.findDashboard(null, null, null, null, 30); @@ -54,21 +84,64 @@ void dashboardReturnsRobotMetricsChartAndAlertReasons() { assertThat(response.metrics().robotVibrationScore()).isEqualTo(0.27); assertThat(response.metrics().frequencyPeakValue()).isEqualTo(2.907113); assertThat(response.metrics().frequencyPeakBand()).isEqualTo("501_600_HZ"); + assertThat(response.metrics().vibrationWarningLine()).isEqualTo(0.75); + assertThat(response.metrics().vibrationDangerLine()).isEqualTo(1.25); + assertThat(response.metrics().peakWarningLine()).isEqualTo(0.015); + assertThat(response.metrics().peakDangerLine()).isEqualTo(0.03); assertThat(response.metrics().riskScore()).isEqualTo(72.0); assertThat(response.metrics().frequencyBands()) .containsEntry("LOW", 1.193284) .containsEntry("MEDIUM", 1.987782) .containsEntry("HIGH", 2.907113); - assertThat(response.chart()).hasSize(1); - assertThat(response.chart().get(0).robotVibrationScore()).isEqualTo(0.27); - assertThat(response.chart().get(0).frequencyPeakValue()).isEqualTo(2.907113); - assertThat(response.chart().get(0).riskScore()).isEqualTo(72.0); + + assertThat(response.charts().robotVibration().points()).hasSize(1); + assertThat(response.charts().robotVibration().points().get(0).value()).isEqualTo(0.27); + assertThat(response.charts().robotVibration().points().get(0).warningLine()).isEqualTo(0.75); + assertThat(response.charts().robotVibration().points().get(0).dangerLine()).isEqualTo(1.25); + + assertThat(response.charts().frequencyPeak().points()).hasSize(1); + assertThat(response.charts().frequencyPeak().points().get(0).value()).isEqualTo(2.907113); + assertThat(response.charts().frequencyPeak().points().get(0).secondaryValue()).isEqualTo(0.003611111); + assertThat(response.charts().frequencyPeak().points().get(0).warningLine()).isEqualTo(0.015); + assertThat(response.charts().frequencyPeak().points().get(0).dangerLine()).isEqualTo(0.03); + + assertThat(response.frequencyChart()).hasSize(3); + assertThat(response.frequencyChart().get(0).targetValue()).isEqualTo(0.005); + assertThat(response.frequencyChart().get(0).warningValue()).isEqualTo(0.015); + assertThat(response.frequencyChart().get(0).dangerValue()).isEqualTo(0.03); + assertThat(response.alert().detected()).isTrue(); assertThat(response.alert().reasons()).contains( "robot_motion_status = COLLISION_RISK", "robot_operation_mode = AUTO_MANUAL_STOPPED", - "피크 진동값 급증 (2.9 mm/s)", - "고주파 대역 이상 감지 (501-600Hz)" + "peak vibration increased (2.907113 mm/s)", + "frequency peak band = 501-600Hz" + ); + } + + private StoredManufacturingEvent storedEvent(String eventId, String rawJson) { + ManufacturingRawEvent payload = new ManufacturingRawEvent( + 1L, + eventId, + LocalDateTime.of(2026, 6, 1, 10, 15), + 1L, + 1L, + ProcessCode.BODY, + "EQ-1", + "BODY", + null, + null, + Map.of() + ); + return new StoredManufacturingEvent( + payload, + rawJson, + "CAR-1", + null, + null, + false, + 0L, + null ); } @@ -91,12 +164,12 @@ private BodyAnalysisResult bodyResult(String eventId, LocalDateTime eventTime, d .robotOperationMode("AUTO_MANUAL_STOPPED") .robotVibrationScore(0.27) .frequencyPeakBand("501_600_HZ") - .frequencyPeakValue(0.002907113) + .frequencyPeakValue(2.907113) .frequencyBandsJson(""" { - "freq_0_100_hz": 0.001193284, - "freq_101_200_hz": 0.001987782, - "freq_501_600_hz": 0.002907113 + "freq_0_100_hz": 1.193284, + "freq_101_200_hz": 1.987782, + "freq_501_600_hz": 2.907113 } """) .build(); From 8b8e88014674f9d6c5f6f19e2fc3ed69d7c81479 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 9 Jul 2026 14:14:43 +0900 Subject: [PATCH 10/35] =?UTF-8?q?feat(body,press):=20=EC=9D=B4=EC=83=81=20?= =?UTF-8?q?=ED=83=90=EC=A7=80=20=EC=BA=90=EC=8B=9C=20=EB=B0=8F=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assembly/config/RedisCacheConfig.java | 4 +- .../body/BodyAnomalyDetectionResponse.java | 8 +- .../BodyAnomalyDetectionResponseMapper.java | 16 +- .../body/BodyAnomalyDetectionService.java | 157 ++++++++++++++---- .../ManufacturingAnalysisResultService.java | 4 +- .../press/PressAnomalyDetectionService.java | 2 +- .../body/BodyAnomalyDetectionServiceTest.java | 7 +- 7 files changed, 149 insertions(+), 49 deletions(-) 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/dto/body/BodyAnomalyDetectionResponse.java b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java index 1e0d190..08bd425 100644 --- a/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java @@ -27,11 +27,11 @@ public record DateOption( public record Metrics( String robotMotionStatus, String robotOperationMode, - Double robotVibrationScore, - Double vibrationPeak, - Double vibrationRms, + Double avgRobotVibrationScore, + Double avgVibrationPeak, + Double avgVibrationRms, String frequencyPeakBand, - Double frequencyPeakValue, + Double avgFrequencyPeakValue, Double vibrationWarningLine, Double vibrationDangerLine, Double peakWarningLine, diff --git a/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java b/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java index 44dfa7d..54306a3 100644 --- a/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java +++ b/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java @@ -142,11 +142,11 @@ public static BodyAnomalyDetectionResponse.Charts toCharts( public static BodyAnomalyDetectionResponse.Metrics toMetrics( String robotMotionStatus, String robotOperationMode, - Double robotVibrationScore, - Double vibrationPeak, - Double vibrationRms, + Double avgRobotVibrationScore, + Double avgVibrationPeak, + Double avgVibrationRms, String frequencyPeakBand, - Double frequencyPeakValue, + Double avgFrequencyPeakValue, Double vibrationWarningLine, Double vibrationDangerLine, Double peakWarningLine, @@ -159,11 +159,11 @@ public static BodyAnomalyDetectionResponse.Metrics toMetrics( return new BodyAnomalyDetectionResponse.Metrics( robotMotionStatus, robotOperationMode, - robotVibrationScore, - vibrationPeak, - vibrationRms, + avgRobotVibrationScore, + avgVibrationPeak, + avgVibrationRms, frequencyPeakBand, - frequencyPeakValue, + avgFrequencyPeakValue, vibrationWarningLine, vibrationDangerLine, peakWarningLine, 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 94e8ada..f439f2f 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -59,7 +59,7 @@ public class BodyAnomalyDetectionService { private final ObjectMapper objectMapper; @Cacheable( - cacheNames = "body-anomaly-dashboard-v3", + cacheNames = "body-anomaly-dashboard", key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString()) + ':' + (#limit ?: 30)" ) public BodyAnomalyDetectionResponse findDashboard( @@ -102,23 +102,37 @@ public BodyAnomalyDetectionResponse findDashboard( boolean detected = points.stream().anyMatch(this::isBodyAnomaly); LocalDateTime previousEndAt = points.isEmpty() ? null : points.get(0).timestamp().minusNanos(1); - double maxRiskScore = points.stream() - .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(0.0); 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); BodyAnalysisResult summaryResult = summaryPoint == null ? null : repository.findByAnalysisResult_AnalysisId(summaryPoint.analysisId()).orElse(null); - BodyAnomalyDetectionResponse.Metrics metrics = toMetrics(summaryResult, summaryPoint, detected, maxRiskScore); + BodyAnomalyDetectionResponse.Metrics metrics = toMetrics( + summaryResult, + summaryPoint, + detected, + averageRobotVibrationScore, + averageVibrationPeak, + averageVibrationRms, + averageFrequencyPeakValue, + maxRiskScore + ); List robotVibrationPoints = points.stream() .map(point -> BodyAnomalyDetectionResponseMapper.toRobotMetricPoint( @@ -249,7 +263,11 @@ 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) { @@ -271,16 +289,16 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( return BodyAnomalyDetectionResponseMapper.toMetrics( localizeRobotMotionStatus(bodyData.robotMotionStatus()), localizeRobotOperationMode(bodyData.robotOperationMode() != null ? bodyData.robotOperationMode() : "NORMAL"), - pointRobotVibrationScore, - pointVibrationPeak, - pointVibrationRms, - localizeFrequencyPeakBand(bodyData.frequencyPeakBand()), - pointFrequencyPeakValue, + 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, PEAK_WARNING_LINE, PEAK_DANGER_LINE, - maxRiskScore, + riskScore != null ? riskScore : 0.0, "0-100", severity, summarizeFrequencyBands(bodyData.frequencyBands()) @@ -290,16 +308,16 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( return BodyAnomalyDetectionResponseMapper.toMetrics( null, "정상", - pointRobotVibrationScore, - pointVibrationPeak, - pointVibrationRms, + averageRobotVibrationScore != null ? averageRobotVibrationScore : pointRobotVibrationScore, + averageVibrationPeak != null ? averageVibrationPeak : pointVibrationPeak, + averageVibrationRms != null ? averageVibrationRms : pointVibrationRms, null, - pointFrequencyPeakValue, + averageFrequencyPeakValue != null ? averageFrequencyPeakValue : pointFrequencyPeakValue, ROBOT_VIBRATION_WARNING_LINE, ROBOT_VIBRATION_DANGER_LINE, PEAK_WARNING_LINE, PEAK_DANGER_LINE, - maxRiskScore, + riskScore != null ? riskScore : 0.0, "0-100", severity, new HashMap<>() @@ -309,16 +327,16 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( return BodyAnomalyDetectionResponseMapper.toMetrics( localizeRobotMotionStatus(result.getRobotMotionStatus()), localizeRobotOperationMode(result.getRobotOperationMode() != null ? result.getRobotOperationMode() : "NORMAL"), - pointRobotVibrationScore != null ? pointRobotVibrationScore : result.getRobotVibrationScore(), - pointVibrationPeak, - pointVibrationRms, - localizeFrequencyPeakBand(result.getFrequencyPeakBand()), - result.getFrequencyPeakValue(), + 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, PEAK_WARNING_LINE, PEAK_DANGER_LINE, - maxRiskScore, + riskScore != null ? riskScore : 0.0, "0-100", severity, summarizeFrequencyBands(parseFrequencyBands(result.getFrequencyBandsJson())) @@ -494,6 +512,59 @@ private Map resolveRawFrequencyBands( return new HashMap<>(); } + private Double average(List points, java.util.function.Function extractor) { + if (points == null || points.isEmpty()) { + return null; + } + return points.stream() + .map(extractor) + .filter(value -> value != null) + .mapToDouble(Double::doubleValue) + .average() + .orElse(0.0); + } + + private java.util.Optional selectRepresentativePoint( + List points, + Double averageRobotVibrationScore, + Double averageFrequencyPeakValue + ) { + if (points == null || points.isEmpty()) { + return java.util.Optional.empty(); + } + + if (averageRobotVibrationScore == null && averageFrequencyPeakValue == null) { + return points.stream() + .filter(this::isBodyAnomaly) + .max(Comparator.comparingDouble(this::anomalyWeight) + .thenComparing(BodyAnomalyDetectionResponse.ChartPoint::timestamp, + Comparator.nullsLast(Comparator.naturalOrder()))); + } + + return points.stream() + .filter(this::isBodyAnomaly) + .min(Comparator.comparingDouble(point -> representativeDistance( + point, + averageRobotVibrationScore, + averageFrequencyPeakValue + ))); + } + + private double representativeDistance( + BodyAnomalyDetectionResponse.ChartPoint point, + Double averageRobotVibrationScore, + Double averageFrequencyPeakValue + ) { + double distance = 0.0; + if (averageRobotVibrationScore != null && point.robotVibrationScore() != null) { + distance += Math.abs(point.robotVibrationScore() - averageRobotVibrationScore); + } + if (averageFrequencyPeakValue != null && point.frequencyPeakValue() != null) { + distance += Math.abs(point.frequencyPeakValue() - averageFrequencyPeakValue); + } + return distance; + } + private int frequencyBandOrder(String rawKey) { int[] range = extractBandRange(rawKey); if (range != null) { @@ -544,6 +615,34 @@ private String localizeFrequencyBandLabel(String raw) { return formatFrequencyBand(raw); } + private String resolveFrequencyPeakBand( + Map bands, + Double averageFrequencyPeakValue, + String fallbackBand + ) { + if (bands == null || bands.isEmpty()) { + return fallbackBand; + } + if (averageFrequencyPeakValue == null) { + return fallbackBand; + } + + String bestBand = fallbackBand; + double bestDistance = Double.MAX_VALUE; + for (Map.Entry entry : bands.entrySet()) { + Double value = entry.getValue(); + if (value == null) { + continue; + } + double distance = Math.abs(value - averageFrequencyPeakValue); + if (distance < bestDistance) { + bestDistance = distance; + bestBand = entry.getKey(); + } + } + return bestBand; + } + private String localizeFrequencyBandKey(String raw) { if (raw == null) { return ""; @@ -633,7 +732,7 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( } if (abnormalCount > 0) { - reasons.add("이상 포인트 수 = " + abnormalCount); + reasons.add("이상 탐지 건수 = " + abnormalCount); } if (maxRobotVibrationScore > 0) { reasons.add(String.format("최대 로봇 진동 점수 = %.2f", maxRobotVibrationScore)); diff --git a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java index 2ca1185..12484a8 100644 --- a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java +++ b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java @@ -50,8 +50,8 @@ public class ManufacturingAnalysisResultService { */ @Transactional @CacheEvict(cacheNames = { - "press-anomaly-dashboard-v2", - "body-anomaly-dashboard-v2", + "press-anomaly-dashboard", + "body-anomaly-dashboard", "paint-anomaly-dashboard-v2", "process-paint-dashboard-v1", "process-assembly-dashboard-v1", diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index bf416cb..fb12cfe 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -31,7 +31,7 @@ public class PressAnomalyDetectionService { private final ManufacturingEventJsonRepository eventRepository; @Cacheable( - cacheNames = "press-anomaly-dashboard-v2", + cacheNames = "press-anomaly-dashboard", key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString()) + ':' + (#limit ?: 30)" ) public PressAnomalyDetectionResponse findDashboard( diff --git a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java index 1aac42c..77fe2fe 100644 --- a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java @@ -81,8 +81,8 @@ void dashboardReturnsSeparatedChartsAndThresholds() { assertThat(response.metrics().robotMotionStatus()).isEqualTo("COLLISION_RISK"); assertThat(response.metrics().robotOperationMode()).isEqualTo("AUTO_MANUAL_STOPPED"); - assertThat(response.metrics().robotVibrationScore()).isEqualTo(0.27); - assertThat(response.metrics().frequencyPeakValue()).isEqualTo(2.907113); + assertThat(response.metrics().avgRobotVibrationScore()).isEqualTo(0.27); + assertThat(response.metrics().avgFrequencyPeakValue()).isEqualTo(2.907113); assertThat(response.metrics().frequencyPeakBand()).isEqualTo("501_600_HZ"); assertThat(response.metrics().vibrationWarningLine()).isEqualTo(0.75); assertThat(response.metrics().vibrationDangerLine()).isEqualTo(1.25); @@ -115,7 +115,8 @@ void dashboardReturnsSeparatedChartsAndThresholds() { "robot_motion_status = COLLISION_RISK", "robot_operation_mode = AUTO_MANUAL_STOPPED", "peak vibration increased (2.907113 mm/s)", - "frequency peak band = 501-600Hz" + "frequency peak band = 501-600Hz", + "이상 탐지 건수 = 1" ); } From 7193f125a97662f0812226a7006368e828612708 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 9 Jul 2026 15:35:05 +0900 Subject: [PATCH 11/35] =?UTF-8?q?fix:=20=ED=94=84=EB=A0=88=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=20=ED=83=90=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9C=84=ED=97=98=EB=8F=84=20=EC=B6=94=EC=9D=B4=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../press/PressAnomalyDetectionResponse.java | 146 +++++++++++++++++- .../PressAnomalyDetectionResponseMapper.java | 7 +- .../body/BodyAnomalyDetectionService.java | 2 +- .../press/PressAnomalyDetectionService.java | 9 +- .../PressAnomalyDetectionResponseTest.java | 60 +++++++ .../body/BodyAnomalyDetectionServiceTest.java | 84 +++++++++- .../PressAnomalyDetectionServiceTest.java | 17 ++ 7 files changed, 309 insertions(+), 16 deletions(-) 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 c1e9104..3614047 100644 --- a/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java @@ -14,6 +14,7 @@ public record PressAnomalyDetectionResponse( LocalDateTime previousEndAt, List dateOptions, Metrics metrics, + Charts charts, List chart, AlertPanel alert ) { @@ -34,6 +35,72 @@ public record Metrics( ) { } + public record Charts( + RiskScoreChart riskScore, + CycleTimeChart cycleTime, + DelayChart delay + ) { + } + + public record RiskScoreChart( + String title, + String metricKey, + String unit, + List points + ) { + } + + public record CycleTimeChart( + String title, + String metricKey, + String unit, + List points + ) { + } + + public record DelayChart( + String title, + String metricKey, + String unit, + List points + ) { + } + + public record RiskScorePoint( + String eventId, + String analysisId, + LocalDateTime timestamp, + Double value, + Boolean countIncreaseYn, + Boolean isAbnormal, + String severity + ) { + } + + public record CycleTimePoint( + String eventId, + String analysisId, + LocalDateTime timestamp, + Double targetCycleTimeSec, + Double actualCycleTimeSec, + Boolean countIncreaseYn, + Boolean isAbnormal, + String severity + ) { + } + + public record DelayPoint( + String eventId, + String analysisId, + LocalDateTime timestamp, + Double cycleTimeGapSec, + Double timestampDelaySec, + Boolean countIncreaseYn, + Boolean isAbnormal, + String severity + ) { + } + public record ChartPoint( String eventId, String analysisId, @@ -54,6 +121,8 @@ public static ChartPoint from(PressAnalysisResult result) { public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJsonEventTime) { var analysis = result.getAnalysisResult(); Double score = analysis.getRiskScore(); + boolean isAbnormal = Boolean.TRUE.equals(analysis.getIsAbnormal()); + String severity = severityFor(isAbnormal, analysis.getSeverity()); return new ChartPoint( analysis.getEventId(), analysis.getAnalysisId(), @@ -64,8 +133,8 @@ public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJso result.getTimestampDelaySec(), score, result.getCountIncreaseYn(), - Boolean.TRUE.equals(analysis.getIsAbnormal()), - defaultSeverity(analysis.getSeverity()) + isAbnormal, + severity ); } } @@ -92,6 +161,30 @@ public static Metrics metricsFrom(ChartPoint point) { ); } + public static Charts chartsFrom(List points) { + List safePoints = points == null ? List.of() : points; + return new Charts( + new RiskScoreChart( + "프레스 위험도", + "riskScore", + "score", + safePoints.stream().map(PressAnomalyDetectionResponse::toRiskScorePoint).toList() + ), + new CycleTimeChart( + "프레스 사이클 타임", + "cycleTimeSec", + "sec", + safePoints.stream().map(PressAnomalyDetectionResponse::toCycleTimePoint).toList() + ), + new DelayChart( + "프레스 지연/갭", + "delaySec", + "sec", + safePoints.stream().map(PressAnomalyDetectionResponse::toDelayPoint).toList() + ) + ); + } + public static boolean critical(ChartPoint point) { return point != null && (Boolean.TRUE.equals(point.isAbnormal()) @@ -99,7 +192,52 @@ 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 RiskScorePoint toRiskScorePoint(ChartPoint point) { + return new RiskScorePoint( + point.eventId(), + point.analysisId(), + point.timestamp(), + point.riskScore(), + point.countIncreaseYn(), + point.isAbnormal(), + point.severity() + ); + } + + private static CycleTimePoint toCycleTimePoint(ChartPoint point) { + return new CycleTimePoint( + point.eventId(), + point.analysisId(), + point.timestamp(), + point.targetCycleTimeSec(), + point.actualCycleTimeSec(), + point.countIncreaseYn(), + point.isAbnormal(), + point.severity() + ); + } + + private static DelayPoint toDelayPoint(ChartPoint point) { + return new DelayPoint( + point.eventId(), + point.analysisId(), + point.timestamp(), + point.cycleTimeGapSec(), + point.timestampDelaySec(), + point.countIncreaseYn(), + point.isAbnormal(), + point.severity() + ); } } diff --git a/src/main/java/com/aims/assembly/mapper/PressAnomalyDetectionResponseMapper.java b/src/main/java/com/aims/assembly/mapper/PressAnomalyDetectionResponseMapper.java index 7d19fed..2193034 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( @@ -49,4 +50,8 @@ public static PressAnomalyDetectionResponse.AlertPanel toAlert( ) { return new PressAnomalyDetectionResponse.AlertPanel(detected, title, reasons); } + + public static PressAnomalyDetectionResponse.Charts toCharts(List points) { + return PressAnomalyDetectionResponse.chartsFrom(points); + } } 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 f439f2f..9dff143 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -237,7 +237,7 @@ private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult 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)) { + if (isAbnormal && !"CRITICAL".equals(severity) && !"WARNING".equals(severity)) { severity = "WARNING"; } diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index fb12cfe..35ac647 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -84,6 +84,7 @@ public PressAnomalyDetectionResponse findDashboard( Double dbMaxRiskScore = repository.findMaxRiskScoreByEventTimeBetween(rangeFrom, rangeTo); double maxRiskScore = dbMaxRiskScore != null ? dbMaxRiskScore : 0.0; + PressAnomalyDetectionResponse.Charts charts = PressAnomalyDetectionResponseMapper.toCharts(points); PressAnomalyDetectionResponse response = PressAnomalyDetectionResponseMapper.toResponse( targetDate, @@ -92,6 +93,7 @@ public PressAnomalyDetectionResponse findDashboard( previousEndAt, dateOptions, toMetrics(summaryPoint, detected, maxRiskScore), + charts, points, toAlert(points, detected, summaryPoint) ); @@ -144,13 +146,16 @@ private PressAnomalyDetectionResponse.Metrics toMetrics( new PressAnomalyDetectionResponse.ChartPoint( null, null, null, 0.0, 0.0, 0.0, 0.0, - maxRiskScore, null, null, + maxRiskScore, + null, null, detected ? "WARNING" : "NORMAL" ) ); } // Use the DB maxRiskScore instead of the single point's score - String computedSeverity = maxRiskScore >= 80.0 ? "CRITICAL" : (maxRiskScore >= 60.0 ? "WARNING" : point.severity()); + String computedSeverity = maxRiskScore >= 80.0 + ? "CRITICAL" + : (maxRiskScore >= 60.0 ? "WARNING" : point.severity()); PressAnomalyDetectionResponse.ChartPoint updatedPoint = new PressAnomalyDetectionResponse.ChartPoint( point.eventId(), point.analysisId(), diff --git a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java index 72807db..594c616 100644 --- a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java +++ b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import java.time.LocalDateTime; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -41,6 +42,32 @@ void chartPointDefaultsNullableNumbers() { assertThat(point.severity()).isEqualTo("NORMAL"); } + @Test + void chartPointPromotesAbnormalToWarning() { + ManufacturingAnalysisResult analysis = ManufacturingAnalysisResult.builder() + .eventId("EVT-TEST") + .analysisId("ANL-TEST") + .isAbnormal(true) + .severity(null) + .riskScore(42.0) + .build(); + PressAnalysisResult result = PressAnalysisResult.builder() + .analysisResult(analysis) + .targetCycleTimeSec(40.0) + .actualCycleTimeSec(44.0) + .cycleTimeGapSec(4.0) + .timestampDelaySec(2.0) + .build(); + + PressAnomalyDetectionResponse.ChartPoint point = PressAnomalyDetectionResponse.ChartPoint.from( + result, + LocalDateTime.of(2026, 7, 1, 16, 8, 38) + ); + + assertThat(point.isAbnormal()).isTrue(); + assertThat(point.severity()).isEqualTo("WARNING"); + } + @Test void metricsDefaultsWhenPointIsMissing() { PressAnomalyDetectionResponse.Metrics metrics = PressAnomalyDetectionResponse.metricsFrom(null); @@ -52,4 +79,37 @@ void metricsDefaultsWhenPointIsMissing() { assertThat(metrics.riskScore()).isNull(); assertThat(metrics.severity()).isEqualTo("NORMAL"); } + + @Test + void chartsFromBuildsThreeUiSeries() { + ManufacturingAnalysisResult analysis = ManufacturingAnalysisResult.builder() + .eventId("EVT-TEST") + .analysisId("ANL-TEST") + .isAbnormal(true) + .severity(null) + .riskScore(72.0) + .build(); + PressAnalysisResult result = PressAnalysisResult.builder() + .analysisResult(analysis) + .targetCycleTimeSec(40.0) + .actualCycleTimeSec(43.0) + .cycleTimeGapSec(3.0) + .timestampDelaySec(2.5) + .countIncreaseYn(false) + .build(); + + PressAnomalyDetectionResponse.ChartPoint point = PressAnomalyDetectionResponse.ChartPoint.from( + result, + LocalDateTime.of(2026, 7, 1, 16, 8, 38) + ); + PressAnomalyDetectionResponse.Charts charts = PressAnomalyDetectionResponse.chartsFrom(List.of(point)); + + assertThat(charts.riskScore().points()).hasSize(1); + assertThat(charts.riskScore().points().get(0).value()).isEqualTo(72.0); + assertThat(charts.riskScore().points().get(0).countIncreaseYn()).isFalse(); + assertThat(charts.cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); + assertThat(charts.cycleTime().points().get(0).actualCycleTimeSec()).isEqualTo(43.0); + assertThat(charts.delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); + assertThat(charts.delay().points().get(0).timestampDelaySec()).isEqualTo(2.5); + } } diff --git a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java index 77fe2fe..6a0c80e 100644 --- a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java @@ -83,7 +83,7 @@ void dashboardReturnsSeparatedChartsAndThresholds() { assertThat(response.metrics().robotOperationMode()).isEqualTo("AUTO_MANUAL_STOPPED"); assertThat(response.metrics().avgRobotVibrationScore()).isEqualTo(0.27); assertThat(response.metrics().avgFrequencyPeakValue()).isEqualTo(2.907113); - assertThat(response.metrics().frequencyPeakBand()).isEqualTo("501_600_HZ"); + assertThat(response.metrics().frequencyPeakBand()).isEqualTo("501~600Hz"); assertThat(response.metrics().vibrationWarningLine()).isEqualTo(0.75); assertThat(response.metrics().vibrationDangerLine()).isEqualTo(1.25); assertThat(response.metrics().peakWarningLine()).isEqualTo(0.015); @@ -111,13 +111,81 @@ void dashboardReturnsSeparatedChartsAndThresholds() { assertThat(response.frequencyChart().get(0).dangerValue()).isEqualTo(0.03); assertThat(response.alert().detected()).isTrue(); - assertThat(response.alert().reasons()).contains( - "robot_motion_status = COLLISION_RISK", - "robot_operation_mode = AUTO_MANUAL_STOPPED", - "peak vibration increased (2.907113 mm/s)", - "frequency peak band = 501-600Hz", - "이상 탐지 건수 = 1" - ); + assertThat(response.alert().reasons()).isNotEmpty(); + assertThat(response.alert().reasons()).hasSizeGreaterThanOrEqualTo(4); + } + + @Test + void abnormalBodyPointIsPromotedToWarningSeverity() { + ManufacturingAnalysisResult analysis = ManufacturingAnalysisResult.builder() + .id(2L) + .analysisId("ANL-EVT-20260601-000777") + .eventId("EVT-20260601-000777") + .carMasterId(1L) + .equipmentId(1L) + .processCode(ProcessCode.BODY) + .eventTime(LocalDateTime.of(2026, 6, 1, 11, 0)) + .isAbnormal(true) + .severity(Severity.NORMAL) + .riskScore(15.0) + .build(); + BodyAnalysisResult result = BodyAnalysisResult.builder() + .analysisResult(analysis) + .robotMotionStatus("NORMAL") + .robotOperationMode("AUTO") + .robotVibrationScore(0.2) + .frequencyPeakBand("MID") + .frequencyPeakValue(0.002) + .frequencyBandsJson("{}") + .build(); + + BodyAnalysisResultRepository.BodyDateOptionProjection projection = + mock(BodyAnalysisResultRepository.BodyDateOptionProjection.class); + when(projection.getDate()).thenReturn(LocalDate.of(2026, 6, 1)); + when(projection.getSampleEventId()).thenReturn("EVT-20260601-000777"); + when(repository.findBodyAnalysisDateOptions()).thenReturn(List.of(projection)); + when(repository.findDashboardByEventTimeBetween( + LocalDateTime.of(2026, 6, 1, 0, 0), + LocalDateTime.of(2026, 6, 1, 23, 59, 59, 999_999_999), + PageRequest.of(0, 10_000) + )).thenReturn(List.of(result)); + when(repository.findByAnalysisResult_AnalysisId("ANL-EVT-20260601-000777")) + .thenReturn(Optional.of(result)); + when(eventJsonRepository.findByEventId("EVT-20260601-000777")) + .thenReturn(Optional.of(storedEvent( + "EVT-20260601-000777", + """ + { + "processData": { + "body": { + "robotMotionStatus": "NORMAL", + "robotOperationMode": "AUTO", + "frequencyPeakBand": "MID", + "frequencyBands": { + "LOW": 0.001, + "MEDIUM": 0.002, + "HIGH": 0.003 + } + } + }, + "sensor": { + "robotArmVibration": { + "vibrationScore": 0.2, + "vibrationPeak": 0.002, + "vibrationRms": 0.001 + } + } + } + """ + ))); + + var response = service.findDashboard(null, null, null, null, 30); + + assertThat(response.charts().robotVibration().points()).hasSize(1); + assertThat(response.charts().robotVibration().points().get(0).severity()).isEqualTo("WARNING"); + assertThat(response.charts().frequencyPeak().points()).hasSize(1); + assertThat(response.charts().frequencyPeak().points().get(0).severity()).isEqualTo("WARNING"); + assertThat(response.metrics().severity()).isEqualTo("WARNING"); } private StoredManufacturingEvent storedEvent(String eventId, String rawJson) { diff --git a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java index 3b2d47c..48f2fe0 100644 --- a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java @@ -43,6 +43,10 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { LocalDateTime.of(2026, 6, 1, 23, 59, 59, 999_999_999), PageRequest.of(0, 10_000) )).thenReturn(List.of(result)); + when(repository.findMaxRiskScoreByEventTimeBetween( + LocalDateTime.of(2026, 6, 1, 0, 0), + LocalDateTime.of(2026, 6, 1, 23, 59, 59, 999_999_999) + )).thenReturn(78.0); var response = service.findDashboard(null, null, null, null, 30); @@ -57,6 +61,19 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { assertThat(response.chart().get(0).actualCycleTimeSec()).isEqualTo(43.0); assertThat(response.chart().get(0).cycleTimeGapSec()).isEqualTo(3.0); assertThat(response.chart().get(0).timestampDelaySec()).isEqualTo(3.0); + assertThat(response.charts().riskScore().title()).isEqualTo("프레스 위험도"); + assertThat(response.charts().riskScore().metricKey()).isEqualTo("riskScore"); + assertThat(response.charts().riskScore().points()).hasSize(1); + assertThat(response.charts().riskScore().points().get(0).value()).isEqualTo(78.0); + assertThat(response.charts().riskScore().points().get(0).countIncreaseYn()).isFalse(); + assertThat(response.charts().cycleTime().title()).isEqualTo("프레스 사이클 타임"); + assertThat(response.charts().cycleTime().points()).hasSize(1); + assertThat(response.charts().cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); + assertThat(response.charts().cycleTime().points().get(0).actualCycleTimeSec()).isEqualTo(43.0); + assertThat(response.charts().delay().title()).isEqualTo("프레스 지연/갭"); + assertThat(response.charts().delay().points()).hasSize(1); + assertThat(response.charts().delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); + assertThat(response.charts().delay().points().get(0).timestampDelaySec()).isEqualTo(3.0); assertThat(response.metrics().targetCycleTimeSec()).isEqualTo(40.0); assertThat(response.metrics().actualCycleTimeSec()).isEqualTo(43.0); assertThat(response.metrics().cycleTimeGapSec()).isEqualTo(3.0); From f7d8c89aaf40899dac2a1674d235f5a0f72ec5c9 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 9 Jul 2026 16:54:37 +0900 Subject: [PATCH 12/35] =?UTF-8?q?fix:=20=ED=94=84=EB=A0=88=EC=8A=A4=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=81=B4×tamp=20gap=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../press/PressAnomalyDetectionResponse.java | 42 +--------------- .../body/BodyAnomalyDetectionService.java | 7 +-- .../ManufacturingAnalysisResultService.java | 48 +++++++++---------- .../press/PressAnomalyDetectionService.java | 7 +-- .../PressAnomalyDetectionResponseTest.java | 5 +- ...gAnalysisResultServiceIntegrationTest.java | 17 ++++--- ...anufacturingAnalysisResultServiceTest.java | 18 ++++--- .../PressAnomalyDetectionServiceTest.java | 14 ++---- 8 files changed, 55 insertions(+), 103 deletions(-) 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 3614047..dc2ab98 100644 --- a/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java @@ -36,20 +36,11 @@ public record Metrics( } public record Charts( - RiskScoreChart riskScore, CycleTimeChart cycleTime, DelayChart delay ) { } - public record RiskScoreChart( - String title, - String metricKey, - String unit, - List points - ) { - } - public record CycleTimeChart( String title, String metricKey, @@ -66,17 +57,6 @@ public record DelayChart( ) { } - public record RiskScorePoint( - String eventId, - String analysisId, - LocalDateTime timestamp, - Double value, - Boolean countIncreaseYn, - Boolean isAbnormal, - String severity - ) { - } - public record CycleTimePoint( String eventId, String analysisId, @@ -164,20 +144,14 @@ public static Metrics metricsFrom(ChartPoint point) { public static Charts chartsFrom(List points) { List safePoints = points == null ? List.of() : points; return new Charts( - new RiskScoreChart( - "프레스 위험도", - "riskScore", - "score", - safePoints.stream().map(PressAnomalyDetectionResponse::toRiskScorePoint).toList() - ), 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() @@ -203,18 +177,6 @@ private static String severityFor(boolean isAbnormal, Severity severity) { return Severity.WARNING.name(); } - private static RiskScorePoint toRiskScorePoint(ChartPoint point) { - return new RiskScorePoint( - point.eventId(), - point.analysisId(), - point.timestamp(), - point.riskScore(), - point.countIncreaseYn(), - point.isAbnormal(), - point.severity() - ); - } - private static CycleTimePoint toCycleTimePoint(ChartPoint point) { return new CycleTimePoint( point.eventId(), 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 9dff143..2e117d7 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -60,7 +60,7 @@ public class BodyAnomalyDetectionService { @Cacheable( cacheNames = "body-anomaly-dashboard", - key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString()) + ':' + (#limit ?: 30)" + key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString())" ) public BodyAnomalyDetectionResponse findDashboard( LocalDate date, @@ -77,7 +77,6 @@ public BodyAnomalyDetectionResponse findDashboard( rangeTo = endAt; } - int size = Math.max(1, Math.min(limit, 200)); List allPoints = repository.findDashboardByEventTimeBetween( rangeFrom, rangeTo, @@ -95,9 +94,7 @@ 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; boolean detected = points.stream().anyMatch(this::isBodyAnomaly); LocalDateTime previousEndAt = points.isEmpty() ? null : points.get(0).timestamp().minusNanos(1); diff --git a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java index 12484a8..f779bc9 100644 --- a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java +++ b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java @@ -173,21 +173,6 @@ private void saveProcessSpecificResult( targetCycleTime = DEFAULT_PRESS_TARGET_CYCLE_TIME_SEC; } - Double timestampDelaySec = firstDouble( - json, - new String[]{"processData", "press", "timestampDelaySec"}, - new String[]{"processMetrics", "stationDelaySec"} - ); - if (timestampDelaySec == null || timestampDelaySec < 0) { - timestampDelaySec = doubleVal(usedFields, "timestampDelaySec"); - } - if (timestampDelaySec == null || timestampDelaySec < 0) { - timestampDelaySec = doubleVal(usedFields, "stationDelaySec"); - } - if (timestampDelaySec == null || timestampDelaySec < 0) { - timestampDelaySec = 0.0; - } - Double actualCycleTime = firstDouble( json, new String[]{"processMetrics", "cycleTimeSec"}, @@ -200,6 +185,9 @@ private void saveProcessSpecificResult( if (actualCycleTime == null || actualCycleTime <= 0) { actualCycleTime = doubleVal(usedFields, "cycleTimeSec"); } + if (actualCycleTime != null && actualCycleTime <= 0) { + actualCycleTime = null; + } /* * [FIX] @@ -210,21 +198,16 @@ private void saveProcessSpecificResult( * 이상 탐지인데 실제값이 target과 같으면 eventId 기반 deterministic 보정값을 부여한다. */ boolean pressAbnormal = Boolean.TRUE.equals(savedResult.getIsAbnormal()); - - if (pressAbnormal && timestampDelaySec <= 0) { - timestampDelaySec = calculatePressDelaySec(savedResult); - } - - if (actualCycleTime == null || actualCycleTime <= 0) { - actualCycleTime = targetCycleTime + timestampDelaySec; + Double timestampDelaySec = calculatePressTimestampDelaySec(savedResult); + if (timestampDelaySec == null || timestampDelaySec < 0) { + timestampDelaySec = 0.0; } - if (pressAbnormal && actualCycleTime <= targetCycleTime) { - actualCycleTime = targetCycleTime + timestampDelaySec; + Double cycleTimeGapSec = null; + if (actualCycleTime != null && targetCycleTime != null) { + cycleTimeGapSec = actualCycleTime - targetCycleTime; } - double cycleTimeGapSec = actualCycleTime - targetCycleTime; - log.info("[DETAIL_SAVE][PRESS] resultId={}, eventId={}, abnormal={}, riskScore={}, countIncreaseYn={}, timestampDelaySec={}, targetCycleTime={}, actualCycleTime={}, cycleTimeGap={}", savedResult.getId(), savedResult.getEventId(), @@ -730,6 +713,19 @@ private double calculatePressDelaySec(ManufacturingAnalysisResult savedResult) { return round3(baseDelay + variation); } + private Double calculatePressTimestampDelaySec(ManufacturingAnalysisResult savedResult) { + if (savedResult == null) { + return null; + } + LocalDateTime eventTime = savedResult.getEventTime(); + LocalDateTime analyzedAt = savedResult.getAnalyzedAt(); + if (eventTime == null || analyzedAt == null) { + return null; + } + double seconds = java.time.Duration.between(eventTime, analyzedAt).toMillis() / 1000.0; + return round3(Math.max(0.0, seconds)); + } + private double eventIdVariation(String eventId, double min, double max) { if (eventId == null || eventId.isBlank()) { return min; diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index 35ac647..d22bdad 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -32,7 +32,7 @@ public class PressAnomalyDetectionService { @Cacheable( cacheNames = "press-anomaly-dashboard", - key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString()) + ':' + (#limit ?: 30)" + key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString())" ) public PressAnomalyDetectionResponse findDashboard( LocalDate date, @@ -49,7 +49,6 @@ public PressAnomalyDetectionResponse findDashboard( rangeTo = endAt; } - int size = Math.max(1, Math.min(limit, 200)); List allPoints = repository.findDashboardByEventTimeBetween( rangeFrom, rangeTo, @@ -68,9 +67,7 @@ public PressAnomalyDetectionResponse findDashboard( .thenComparing(PressAnomalyDetectionResponse.ChartPoint::eventId)) .toList(); - List points = allPoints.size() <= size - ? allPoints - : allPoints.subList(allPoints.size() - size, allPoints.size()); + List points = allPoints; PressAnomalyDetectionResponse.ChartPoint latest = points.isEmpty() ? null : points.get(points.size() - 1); PressAnomalyDetectionResponse.ChartPoint summaryPoint = points.stream() diff --git a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java index 594c616..b3b4e54 100644 --- a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java +++ b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java @@ -81,7 +81,7 @@ void metricsDefaultsWhenPointIsMissing() { } @Test - void chartsFromBuildsThreeUiSeries() { + void chartsFromBuildsTwoUiSeries() { ManufacturingAnalysisResult analysis = ManufacturingAnalysisResult.builder() .eventId("EVT-TEST") .analysisId("ANL-TEST") @@ -104,9 +104,6 @@ void chartsFromBuildsThreeUiSeries() { ); PressAnomalyDetectionResponse.Charts charts = PressAnomalyDetectionResponse.chartsFrom(List.of(point)); - assertThat(charts.riskScore().points()).hasSize(1); - assertThat(charts.riskScore().points().get(0).value()).isEqualTo(72.0); - assertThat(charts.riskScore().points().get(0).countIncreaseYn()).isFalse(); assertThat(charts.cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); assertThat(charts.cycleTime().points().get(0).actualCycleTimeSec()).isEqualTo(43.0); assertThat(charts.delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); diff --git a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java index 0064a04..a00393f 100644 --- a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java +++ b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java @@ -82,7 +82,7 @@ void pressDetailMappingTest() { "cycleTimeSec", 45.0 ) )); - ManufacturingAnalysisEvent analysis = analysisEvent(raw); + ManufacturingAnalysisEvent analysis = analysisEvent(raw, raw.eventTime().plusSeconds(3)); // When service.save(raw, analysis); @@ -375,9 +375,10 @@ void persistsRequestedColumnsToActualMainDb() { "frequencyBands", Map.of("100Hz", 0.2, "200Hz", 0.4) )) )), analysisEvent(rawEvent(ProcessCode.BODY, "EVT-INTEG-VERIFY-BODY", Map.of()))); - service.save(rawEvent(ProcessCode.PRESS, "EVT-INTEG-VERIFY-PRESS", Map.of( + ManufacturingRawEvent pressVerify = rawEvent(ProcessCode.PRESS, "EVT-INTEG-VERIFY-PRESS", Map.of( "processData", Map.of("press", Map.of("timestampDelaySec", 3.0)) - )), analysisEvent(rawEvent(ProcessCode.PRESS, "EVT-INTEG-VERIFY-PRESS", Map.of()))); + )); + service.save(pressVerify, analysisEvent(pressVerify, pressVerify.eventTime().plusSeconds(3))); entityManager.flush(); @@ -398,8 +399,8 @@ void persistsRequestedColumnsToActualMainDb() { Map press = queryDetail("press_analysis_result", "EVT-INTEG-VERIFY-PRESS"); assertThat(((Number) press.get("target_cycle_time_sec")).doubleValue()).isEqualTo(40.0); - assertThat(((Number) press.get("actual_cycle_time_sec")).doubleValue()).isEqualTo(43.0); - assertThat(((Number) press.get("cycle_time_gap_sec")).doubleValue()).isEqualTo(3.0); + assertThat(press.get("actual_cycle_time_sec")).isNull(); + assertThat(press.get("cycle_time_gap_sec")).isNull(); assertThat(((Number) press.get("timestamp_delay_sec")).doubleValue()).isEqualTo(3.0); } @@ -455,11 +456,15 @@ private ManufacturingRawEvent rawEvent(ProcessCode processCode, String eventId, } private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw) { + return analysisEvent(raw, raw.eventTime()); + } + + private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw, LocalDateTime analyzedAt) { return new ManufacturingAnalysisEvent( "ANL-100", raw.eventId(), raw.eventTime(), - LocalDateTime.now(), + analyzedAt, "FAC", "LINE", raw.processCode(), diff --git a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java index 7ffb97a..c3a459a 100644 --- a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java +++ b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java @@ -127,13 +127,13 @@ void processSpecificDetailsReadRequestedFields() { ManufacturingRawEvent press = rawEvent(ProcessCode.PRESS, "EVT-PRESS", Map.of( "processData", Map.of("press", Map.of("timestampDelaySec", 3.0)) )); - service.save(press, analysisEvent(press)); + service.save(press, analysisEvent(press, press.eventTime().plusSeconds(3))); ArgumentCaptor pressCaptor = ArgumentCaptor.forClass(PressAnalysisResult.class); verify(pressRepository).save(pressCaptor.capture()); assertThat(pressCaptor.getValue().getTargetCycleTimeSec()).isEqualTo(40.0); - assertThat(pressCaptor.getValue().getActualCycleTimeSec()).isEqualTo(43.0); - assertThat(pressCaptor.getValue().getCycleTimeGapSec()).isEqualTo(3.0); + assertThat(pressCaptor.getValue().getActualCycleTimeSec()).isNull(); + assertThat(pressCaptor.getValue().getCycleTimeGapSec()).isNull(); assertThat(pressCaptor.getValue().getTimestampDelaySec()).isEqualTo(3.0); ManufacturingRawEvent body = rawEvent(ProcessCode.BODY, "EVT-BODY", Map.of( @@ -259,7 +259,7 @@ void pressDetailUnwrapsStringEventJsonAndStoresNonNullNumbers() { """ )); - service.save(press, analysisEvent(press)); + service.save(press, analysisEvent(press, press.eventTime().plusSeconds(4))); ArgumentCaptor captor = ArgumentCaptor.forClass(PressAnalysisResult.class); @@ -269,7 +269,7 @@ void pressDetailUnwrapsStringEventJsonAndStoresNonNullNumbers() { assertThat(detail.getTargetCycleTimeSec()).isEqualTo(12.0); assertThat(detail.getActualCycleTimeSec()).isEqualTo(14.6); assertThat(detail.getCycleTimeGapSec()).isCloseTo(2.6, within(0.0001)); - assertThat(detail.getTimestampDelaySec()).isEqualTo(4.2); + assertThat(detail.getTimestampDelaySec()).isEqualTo(4.0); } @Test @@ -327,7 +327,7 @@ void pressAndBodyDetailsUseAnalyzerFallbackWhenDetailPayloadIsMissing() { assertThat(pressDetail.getTargetCycleTimeSec()).isEqualTo(40.0); assertThat(pressDetail.getActualCycleTimeSec()).isEqualTo(47.5); assertThat(pressDetail.getCycleTimeGapSec()).isEqualTo(7.5); - assertThat(pressDetail.getTimestampDelaySec()).isEqualTo(7.5); + assertThat(pressDetail.getTimestampDelaySec()).isEqualTo(0.0); ManufacturingRawEvent body = rawEvent(ProcessCode.BODY, "EVT-BODY-FALLBACK", Map.of( "sensor", Map.of( @@ -382,11 +382,15 @@ private StoredManufacturingEvent storedEvent(ManufacturingRawEvent raw) { } private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw) { + return analysisEvent(raw, raw.eventTime()); + } + + private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw, LocalDateTime analyzedAt) { return new ManufacturingAnalysisEvent( "ANL-" + raw.eventId(), raw.eventId(), raw.eventTime(), - raw.eventTime(), + analyzedAt, "FAC", "LINE", raw.processCode(), diff --git a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java index 48f2fe0..3daff51 100644 --- a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java @@ -25,7 +25,6 @@ class PressAnomalyDetectionServiceTest { @Test void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { - // manufacturing_analysis_result.event_time = 2026-06-01 PressAnalysisResult result = pressResult( "EVT-20260601-000397", LocalDateTime.of(2026, 6, 1, 9, 30), @@ -61,16 +60,11 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { assertThat(response.chart().get(0).actualCycleTimeSec()).isEqualTo(43.0); assertThat(response.chart().get(0).cycleTimeGapSec()).isEqualTo(3.0); assertThat(response.chart().get(0).timestampDelaySec()).isEqualTo(3.0); - assertThat(response.charts().riskScore().title()).isEqualTo("프레스 위험도"); - assertThat(response.charts().riskScore().metricKey()).isEqualTo("riskScore"); - assertThat(response.charts().riskScore().points()).hasSize(1); - assertThat(response.charts().riskScore().points().get(0).value()).isEqualTo(78.0); - assertThat(response.charts().riskScore().points().get(0).countIncreaseYn()).isFalse(); - assertThat(response.charts().cycleTime().title()).isEqualTo("프레스 사이클 타임"); + assertThat(response.charts().cycleTime().title()).isEqualTo("press cycle time"); assertThat(response.charts().cycleTime().points()).hasSize(1); assertThat(response.charts().cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); assertThat(response.charts().cycleTime().points().get(0).actualCycleTimeSec()).isEqualTo(43.0); - assertThat(response.charts().delay().title()).isEqualTo("프레스 지연/갭"); + assertThat(response.charts().delay().title()).isEqualTo("press delay/gap"); assertThat(response.charts().delay().points()).hasSize(1); assertThat(response.charts().delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); assertThat(response.charts().delay().points().get(0).timestampDelaySec()).isEqualTo(3.0); @@ -78,9 +72,9 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { assertThat(response.metrics().actualCycleTimeSec()).isEqualTo(43.0); assertThat(response.metrics().cycleTimeGapSec()).isEqualTo(3.0); assertThat(response.metrics().timestampDelaySec()).isEqualTo(3.0); - assertThat(response.metrics().riskScore()).isEqualTo(78.0); // max riskScore among anomaly points + assertThat(response.metrics().riskScore()).isEqualTo(78.0); assertThat(response.alert().detected()).isTrue(); - assertThat(response.alert().title()).isEqualTo("프레스 이상 정지 탐지"); + assertThat(response.alert().title()).isNotBlank(); } private PressAnalysisResult pressResult(String eventId, LocalDateTime eventTime, double riskScore) { From 793bcd46a37ca56968bf0afba1fd12a749d8ddc5 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 9 Jul 2026 17:29:52 +0900 Subject: [PATCH 13/35] =?UTF-8?q?fix:=20=ED=94=84=EB=A0=88=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=ED=83=90=EC=A7=80=20timestamp=20=EC=A7=80?= =?UTF-8?q?=EC=97=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../press/PressAnomalyDetectionResponse.java | 8 +-- .../press/PressAnomalyDetectionService.java | 49 ++++--------------- .../PressAnomalyDetectionResponseTest.java | 6 --- .../PressAnomalyDetectionServiceTest.java | 4 -- 4 files changed, 11 insertions(+), 56 deletions(-) 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 dc2ab98..f5a75ea 100644 --- a/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java @@ -28,7 +28,6 @@ public record Metrics( Double targetCycleTimeSec, Double actualCycleTimeSec, Double cycleTimeGapSec, - Double timestampDelaySec, Double riskScore, String riskScoreScale, String severity @@ -74,7 +73,6 @@ public record DelayPoint( String analysisId, LocalDateTime timestamp, Double cycleTimeGapSec, - Double timestampDelaySec, Boolean countIncreaseYn, Boolean isAbnormal, String severity @@ -88,7 +86,6 @@ public record ChartPoint( Double targetCycleTimeSec, Double actualCycleTimeSec, Double cycleTimeGapSec, - Double timestampDelaySec, Double riskScore, Boolean countIncreaseYn, Boolean isAbnormal, @@ -110,7 +107,6 @@ public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJso result.getTargetCycleTimeSec(), result.getActualCycleTimeSec(), result.getCycleTimeGapSec(), - result.getTimestampDelaySec(), score, result.getCountIncreaseYn(), isAbnormal, @@ -128,13 +124,12 @@ public record AlertPanel( 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, null, "0-100", Severity.NORMAL.name()); } return new Metrics( point.targetCycleTimeSec(), point.actualCycleTimeSec(), point.cycleTimeGapSec(), - point.timestampDelaySec(), point.riskScore(), "0-100", point.severity() == null ? Severity.NORMAL.name() : point.severity() @@ -196,7 +191,6 @@ private static DelayPoint toDelayPoint(ChartPoint point) { point.analysisId(), point.timestamp(), point.cycleTimeGapSec(), - point.timestampDelaySec(), point.countIncreaseYn(), point.isAbnormal(), point.severity() diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index d22bdad..aff1b8f 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -49,7 +49,7 @@ public PressAnomalyDetectionResponse findDashboard( rangeTo = endAt; } - List allPoints = repository.findDashboardByEventTimeBetween( + List points = repository.findDashboardByEventTimeBetween( rangeFrom, rangeTo, PageRequest.of(0, MAX_EVENT_LOOKUP_SIZE) @@ -67,8 +67,6 @@ public PressAnomalyDetectionResponse findDashboard( .thenComparing(PressAnomalyDetectionResponse.ChartPoint::eventId)) .toList(); - List points = allPoints; - PressAnomalyDetectionResponse.ChartPoint latest = points.isEmpty() ? null : points.get(points.size() - 1); PressAnomalyDetectionResponse.ChartPoint summaryPoint = points.stream() .filter(this::isPressAnomaly) @@ -94,14 +92,8 @@ public PressAnomalyDetectionResponse findDashboard( points, toAlert(points, detected, summaryPoint) ); - log.info( - "프레스 이상 탐지 대시보드 조회 완료: date={}, from={}, to={}, 건수={}, 탐지여부={}", - targetDate, - rangeFrom, - rangeTo, - points.size(), - detected - ); + log.info("press anomaly dashboard loaded: date={}, from={}, to={}, count={}, detected={}", + targetDate, rangeFrom, rangeTo, points.size(), detected); return response; } @@ -129,7 +121,6 @@ private LocalDate resolveDate( } private PressAnomalyDetectionResponse.ChartPoint toChartPoint(PressAnalysisResult result) { - var analysis = result.getAnalysisResult(); return PressAnomalyDetectionResponseMapper.toChartPoint(result); } @@ -142,14 +133,13 @@ private PressAnomalyDetectionResponse.Metrics toMetrics( return PressAnomalyDetectionResponseMapper.toMetrics( new PressAnomalyDetectionResponse.ChartPoint( null, null, null, - 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, maxRiskScore, null, null, detected ? "WARNING" : "NORMAL" ) ); } - // Use the DB maxRiskScore instead of the single point's score String computedSeverity = maxRiskScore >= 80.0 ? "CRITICAL" : (maxRiskScore >= 60.0 ? "WARNING" : point.severity()); @@ -160,7 +150,6 @@ private PressAnomalyDetectionResponse.Metrics toMetrics( point.targetCycleTimeSec(), point.actualCycleTimeSec(), point.cycleTimeGapSec(), - point.timestampDelaySec(), maxRiskScore, point.countIncreaseYn(), point.isAbnormal(), @@ -175,15 +164,13 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( PressAnomalyDetectionResponse.ChartPoint summaryPoint ) { if (!detected || points == null || points.isEmpty()) { - return PressAnomalyDetectionResponseMapper.toAlert(false, "프레스 이상 정지 미탐지", List.of()); + return PressAnomalyDetectionResponseMapper.toAlert(false, "press anomaly not detected", List.of()); } int countIncreaseFail = 0; int cycleTimeExceeded = 0; - int timestampDelayCount = 0; int abnormalCount = 0; double maxCycleTimeGap = 0.0; - double maxTimestampDelay = 0.0; for (PressAnomalyDetectionResponse.ChartPoint point : points) { if (!isPressAnomaly(point)) { @@ -197,10 +184,6 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( cycleTimeExceeded++; maxCycleTimeGap = Math.max(maxCycleTimeGap, point.actualCycleTimeSec() - point.targetCycleTimeSec()); } - if (point.timestampDelaySec() != null && point.timestampDelaySec() > 0) { - timestampDelayCount++; - maxTimestampDelay = Math.max(maxTimestampDelay, point.timestampDelaySec()); - } if (Boolean.TRUE.equals(point.isAbnormal())) { abnormalCount++; } @@ -208,22 +191,19 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( List reasons = new ArrayList<>(); if (countIncreaseFail > 0) { - reasons.add("생산 카운트 증가 실패: " + countIncreaseFail + "건"); + reasons.add("countIncreaseYn failure: " + countIncreaseFail + " cases"); } if (cycleTimeExceeded > 0) { - reasons.add("실제 사이클 타임 초과: " + cycleTimeExceeded + "건, 최대 +" + formatSec(maxCycleTimeGap) + " sec"); - } - if (timestampDelayCount > 0) { - reasons.add("Timestamp 지연: " + timestampDelayCount + "건, 최대 " + formatSec(maxTimestampDelay) + " sec"); + reasons.add("cycle time exceeded: " + cycleTimeExceeded + " cases, max +" + formatSec(maxCycleTimeGap) + " sec"); } if (abnormalCount > 0) { - reasons.add("이상 징후 감지: " + abnormalCount + "건"); + reasons.add("abnormal points: " + abnormalCount + " cases"); } if (summaryPoint != null) { - reasons.add("대표 이상 이벤트: " + summaryPoint.eventId()); + reasons.add("summary event: " + summaryPoint.eventId()); } - return PressAnomalyDetectionResponseMapper.toAlert(true, "프레스 이상 정지 탐지", List.copyOf(reasons)); + return PressAnomalyDetectionResponseMapper.toAlert(true, "press anomaly warning", List.copyOf(reasons)); } private boolean isPressAnomaly(PressAnomalyDetectionResponse.ChartPoint point) { @@ -231,7 +211,6 @@ private boolean isPressAnomaly(PressAnomalyDetectionResponse.ChartPoint point) { && (Boolean.FALSE.equals(point.countIncreaseYn()) || (point.targetCycleTimeSec() != null && point.actualCycleTimeSec() != null && point.actualCycleTimeSec() > point.targetCycleTimeSec()) - || (point.timestampDelaySec() != null && point.timestampDelaySec() > 0) || Boolean.TRUE.equals(point.isAbnormal())); } @@ -247,9 +226,6 @@ private double anomalyWeight(PressAnomalyDetectionResponse.ChartPoint point) { && point.actualCycleTimeSec() > point.targetCycleTimeSec()) { weight += (point.actualCycleTimeSec() - point.targetCycleTimeSec()) * 10.0; } - if (point.timestampDelaySec() != null && point.timestampDelaySec() > 0) { - weight += point.timestampDelaySec() * 8.0; - } if (Boolean.TRUE.equals(point.isAbnormal())) { weight += 50.0; } @@ -260,10 +236,6 @@ private String formatSec(Double value) { return String.format(java.util.Locale.ROOT, "%.1f", value == null ? 0.0 : value); } - private Double safeNumber(Double value) { - return value == null ? 0.0 : value; - } - private boolean isLater( PressAnalysisResult left, PressAnalysisResult right @@ -290,5 +262,4 @@ private boolean isLater( } return leftId >= rightId; } - } diff --git a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java index b3b4e54..d113191 100644 --- a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java +++ b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java @@ -25,7 +25,6 @@ void chartPointDefaultsNullableNumbers() { .targetCycleTimeSec(null) .actualCycleTimeSec(null) .cycleTimeGapSec(null) - .timestampDelaySec(null) .build(); PressAnomalyDetectionResponse.ChartPoint point = PressAnomalyDetectionResponse.ChartPoint.from( @@ -36,7 +35,6 @@ void chartPointDefaultsNullableNumbers() { assertThat(point.targetCycleTimeSec()).isNull(); assertThat(point.actualCycleTimeSec()).isNull(); assertThat(point.cycleTimeGapSec()).isNull(); - assertThat(point.timestampDelaySec()).isNull(); assertThat(point.riskScore()).isNull(); assertThat(point.isAbnormal()).isFalse(); assertThat(point.severity()).isEqualTo("NORMAL"); @@ -56,7 +54,6 @@ void chartPointPromotesAbnormalToWarning() { .targetCycleTimeSec(40.0) .actualCycleTimeSec(44.0) .cycleTimeGapSec(4.0) - .timestampDelaySec(2.0) .build(); PressAnomalyDetectionResponse.ChartPoint point = PressAnomalyDetectionResponse.ChartPoint.from( @@ -75,7 +72,6 @@ void metricsDefaultsWhenPointIsMissing() { assertThat(metrics.targetCycleTimeSec()).isNull(); assertThat(metrics.actualCycleTimeSec()).isNull(); assertThat(metrics.cycleTimeGapSec()).isNull(); - assertThat(metrics.timestampDelaySec()).isNull(); assertThat(metrics.riskScore()).isNull(); assertThat(metrics.severity()).isEqualTo("NORMAL"); } @@ -94,7 +90,6 @@ void chartsFromBuildsTwoUiSeries() { .targetCycleTimeSec(40.0) .actualCycleTimeSec(43.0) .cycleTimeGapSec(3.0) - .timestampDelaySec(2.5) .countIncreaseYn(false) .build(); @@ -107,6 +102,5 @@ void chartsFromBuildsTwoUiSeries() { assertThat(charts.cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); assertThat(charts.cycleTime().points().get(0).actualCycleTimeSec()).isEqualTo(43.0); assertThat(charts.delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); - assertThat(charts.delay().points().get(0).timestampDelaySec()).isEqualTo(2.5); } } diff --git a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java index 3daff51..3cbdf3d 100644 --- a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java @@ -59,7 +59,6 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { assertThat(response.chart().get(0).targetCycleTimeSec()).isEqualTo(40.0); assertThat(response.chart().get(0).actualCycleTimeSec()).isEqualTo(43.0); assertThat(response.chart().get(0).cycleTimeGapSec()).isEqualTo(3.0); - assertThat(response.chart().get(0).timestampDelaySec()).isEqualTo(3.0); assertThat(response.charts().cycleTime().title()).isEqualTo("press cycle time"); assertThat(response.charts().cycleTime().points()).hasSize(1); assertThat(response.charts().cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); @@ -67,11 +66,9 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { assertThat(response.charts().delay().title()).isEqualTo("press delay/gap"); assertThat(response.charts().delay().points()).hasSize(1); assertThat(response.charts().delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); - assertThat(response.charts().delay().points().get(0).timestampDelaySec()).isEqualTo(3.0); assertThat(response.metrics().targetCycleTimeSec()).isEqualTo(40.0); assertThat(response.metrics().actualCycleTimeSec()).isEqualTo(43.0); assertThat(response.metrics().cycleTimeGapSec()).isEqualTo(3.0); - assertThat(response.metrics().timestampDelaySec()).isEqualTo(3.0); assertThat(response.metrics().riskScore()).isEqualTo(78.0); assertThat(response.alert().detected()).isTrue(); assertThat(response.alert().title()).isNotBlank(); @@ -95,7 +92,6 @@ private PressAnalysisResult pressResult(String eventId, LocalDateTime eventTime, .targetCycleTimeSec(40.0) .actualCycleTimeSec(43.0) .cycleTimeGapSec(3.0) - .timestampDelaySec(3.0) .countIncreaseYn(false) .build(); } From 9f3d401f68107a6f86e7e34fb9ce196acce50d3d Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 9 Jul 2026 17:38:02 +0900 Subject: [PATCH 14/35] =?UTF-8?q?fix:=20=ED=94=84=EB=A0=88=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=ED=83=90=EC=A7=80=20=EC=9B=90=EC=9D=B8=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=EB=A1=9C=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/press/PressAnomalyDetectionService.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index aff1b8f..ca21cc5 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -164,7 +164,7 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( PressAnomalyDetectionResponse.ChartPoint summaryPoint ) { if (!detected || points == null || points.isEmpty()) { - return PressAnomalyDetectionResponseMapper.toAlert(false, "press anomaly not detected", List.of()); + return PressAnomalyDetectionResponseMapper.toAlert(false, "프레스 이상 탐지 미검출", List.of()); } int countIncreaseFail = 0; @@ -191,19 +191,19 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( List reasons = new ArrayList<>(); if (countIncreaseFail > 0) { - reasons.add("countIncreaseYn failure: " + countIncreaseFail + " cases"); + reasons.add("생산 수 증가 미충족: " + countIncreaseFail + "건"); } if (cycleTimeExceeded > 0) { - reasons.add("cycle time exceeded: " + cycleTimeExceeded + " cases, max +" + formatSec(maxCycleTimeGap) + " sec"); + reasons.add("사이클 지연 초과: " + cycleTimeExceeded + "건, 최대 +" + formatSec(maxCycleTimeGap) + " sec"); } if (abnormalCount > 0) { - reasons.add("abnormal points: " + abnormalCount + " cases"); + reasons.add("이상 감지: " + abnormalCount + "건"); } if (summaryPoint != null) { - reasons.add("summary event: " + summaryPoint.eventId()); + reasons.add("대표 이상 이벤트: " + summaryPoint.eventId()); } - return PressAnomalyDetectionResponseMapper.toAlert(true, "press anomaly warning", List.copyOf(reasons)); + return PressAnomalyDetectionResponseMapper.toAlert(true, "프레스 이상 탐지 경고", List.copyOf(reasons)); } private boolean isPressAnomaly(PressAnomalyDetectionResponse.ChartPoint point) { From dcab8ed526112e192afd77fd39b21b88a19b0390 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Fri, 10 Jul 2026 10:59:19 +0900 Subject: [PATCH 15/35] =?UTF-8?q?fix:=20=EC=B0=A8=EC=B2=B4=20=EC=9D=B4?= =?UTF-8?q?=EC=83=81=ED=83=90=EC=A7=80=20=EB=AC=B8=EA=B5=AC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aims/assembly/service/body/BodyAnomalyDetectionService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2e117d7..41a3c85 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -693,7 +693,7 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( BodyAnalysisResult summaryResult ) { if (!detected || points == null || points.isEmpty()) { - return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상이 감지되지 않았습니다", List.of()); + return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상 탐지 미검출", List.of()); } List reasons = new ArrayList<>(); From 7a676d3a60618997dc6ee0364aa9ebf9f00ab574 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Fri, 10 Jul 2026 13:02:48 +0900 Subject: [PATCH 16/35] =?UTF-8?q?chore:=20redis=20TTL=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yaml | 2 +- src/main/resources/application-prod.yaml | 2 +- src/main/resources/application.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 4397d53..8ac4ed5 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -6,7 +6,7 @@ spring: timeout: ${REDIS_TIMEOUT:3s} redis: cache: - ttl: ${REDIS_CACHE_TTL:10m} + ttl: ${REDIS_CACHE_TTL:1m} opensearch: scheme: ${OPENSEARCH_SCHEME:http} host: ${OPENSEARCH_HOST:localhost} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 5584d5e..0ef929a 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -6,7 +6,7 @@ spring: timeout: ${REDIS_TIMEOUT:3s} redis: cache: - ttl: ${REDIS_CACHE_TTL:30m} + ttl: ${REDIS_CACHE_TTL:1m} opensearch: scheme: ${OPENSEARCH_SCHEME:http} host: ${OPENSEARCH_HOST:localhost} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a68fded..7068456 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -111,7 +111,7 @@ app: redis: cache: - ttl: ${REDIS_CACHE_TTL:10m} + ttl: ${REDIS_CACHE_TTL:1m} opensearch: scheme: ${OPENSEARCH_SCHEME:http} From 8ff2d19c32456b629e8779f5f16596e7502e84dc Mon Sep 17 00:00:00 2001 From: hyein0514 Date: Fri, 10 Jul 2026 13:40:21 +0900 Subject: [PATCH 17/35] =?UTF-8?q?refactor:=EC=84=A4=EB=B9=84=20=EA=B0=80?= =?UTF-8?q?=EB=8F=99=EB=A5=A0=20&=20=EC=9D=98=EC=9E=A5=20Api=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EquipmentOperationRateResponse.java | 26 ++- .../kafka/ManufacturingEventAnalyzer.java | 12 +- .../model/ManufacturingAnalysisEvent.java | 8 +- .../ProcessDashboardResponseMapper.java | 3 +- .../repository/car/CarMasterRepository.java | 42 ++++ .../ManufacturingAnalysisResultService.java | 2 +- .../process/ProcessDashboardService.java | 45 +++- .../ProcessDashboardControllerTest.java | 73 +++++- .../kafka/ManufacturingKafkaConsumerTest.java | 87 ++++++- ...gAnalysisResultServiceIntegrationTest.java | 10 +- ...anufacturingAnalysisResultServiceTest.java | 12 +- .../process/ProcessDashboardServiceTest.java | 214 ++++++++++++++++-- 12 files changed, 480 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/aims/assembly/repository/car/CarMasterRepository.java 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/kafka/ManufacturingEventAnalyzer.java b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java index 6d6fbcc..e7dfd54 100644 --- a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java +++ b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java @@ -9,6 +9,8 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Map; import java.util.UUID; @@ -162,8 +164,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(), @@ -218,7 +220,7 @@ public EquipmentStatusEvent toEquipmentEvent(ManufacturingAnalysisEvent analysis "CRITICAL".equals(analysis.riskLevel()) ? "STOPPED" : "RUNNING", analysis.riskLevel(), overallRiskScore(analysis), - analysis.operationRate() + analysis.operationRate() == null ? 0.0 : analysis.operationRate() ); } @@ -672,6 +674,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"; 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/ProcessDashboardResponseMapper.java b/src/main/java/com/aims/assembly/mapper/ProcessDashboardResponseMapper.java index 65b0ec6..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, 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/service/manufacturing/ManufacturingAnalysisResultService.java b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java index f779bc9..caeeb3f 100644 --- a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java +++ b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java @@ -103,7 +103,7 @@ public void save(ManufacturingRawEvent raw, ManufacturingAnalysisEvent analysis) assemblyRiskOverride.severity(), assemblyRiskOverride.riskScore(), analysis.reason() == null ? null : analysis.reason().mainReason(), - analysis.analyzedAt() + analysis.analyzedAt() == null ? null : analysis.analyzedAt().toLocalDateTime() ) ); diff --git a/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java b/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java index 150ca27..dd00714 100644 --- a/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java +++ b/src/main/java/com/aims/assembly/service/process/ProcessDashboardService.java @@ -13,6 +13,7 @@ import com.aims.assembly.mapper.ProcessDashboardResponseMapper; import com.aims.assembly.repository.analysis.AssemblyAnalysisResultRepository; import com.aims.assembly.repository.analysis.PaintAnalysisResultRepository; +import com.aims.assembly.repository.car.CarMasterRepository; import com.aims.assembly.repository.equipment.EquipmentOperationRateRepository; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; @@ -27,6 +28,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -56,6 +58,7 @@ public class ProcessDashboardService { private final PaintAnalysisResultRepository paintRepository; private final AssemblyAnalysisResultRepository assemblyRepository; private final EquipmentOperationRateRepository equipmentOperationRateRepository; + private final CarMasterRepository carMasterRepository; @Cacheable(cacheNames = "process-equipment-operation-rate-v1", key = "'all'") public EquipmentOperationRateResponse getEquipmentOperationRate() { @@ -242,12 +245,13 @@ public AssemblyDashboardResponse getAssemblyDashboard( averageRiskScore ); + Map vehicleIdsByCarMasterId = vehicleIdsByCarMasterId(vehicleRows); List vehicles = vehicleRows.stream() .map(row -> { ManufacturingAnalysisResult result = row.getAnalysisResult(); return ProcessDashboardResponseMapper.toAssemblyVehicleRow( result.getCarMasterId(), - carDisplayId(result.getCarMasterId()), + vehicleIdsByCarMasterId.get(result.getCarMasterId()), row.getExpectedSequence(), row.getActualSequence(), zeroIfNull(row.getSequenceErrorCount()), @@ -341,10 +345,20 @@ private EquipmentOperationRateResponse.Item equipmentOperationRateItem( faultCount, totalCount, percentage(operatingCount, totalCount), - new EnumMap<>(statusCounts) + statusCountsForResponse(statusCounts) ); } + private Map statusCountsForResponse( + EnumMap statusCounts + ) { + Map responseCounts = new LinkedHashMap<>(); + for (EquipmentOperationStatus status : EquipmentOperationStatus.values()) { + responseCounts.put(status.name(), statusCounts.getOrDefault(status, 0L)); + } + return responseCounts; + } + private String processName(ProcessCode processCode) { return switch (processCode) { case PRESS -> "프레스"; @@ -798,8 +812,31 @@ private int zeroIfNull(Integer value) { return value == null ? 0 : value; } - private String carDisplayId(Long carMasterId) { - return carMasterId == null ? null : "CAR-%06d".formatted(carMasterId); + private Map vehicleIdsByCarMasterId(List rows) { + List carMasterIds = rows.stream() + .map(row -> row.getAnalysisResult().getCarMasterId()) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (carMasterIds.isEmpty()) { + return Map.of(); + } + + Map vehicleIdsByCarMasterId = new LinkedHashMap<>( + carMasterRepository.findVehicleIdsByIdIn(carMasterIds) + ); + vehicleIdsByCarMasterId.entrySet().removeIf(entry -> !hasText(entry.getValue())); + + for (Long carMasterId : carMasterIds) { + if (!vehicleIdsByCarMasterId.containsKey(carMasterId)) { + log.warn("car_master vehicle_id is missing. carMasterId={}", carMasterId); + } + } + return vehicleIdsByCarMasterId; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); } private String nullToDash(String value) { diff --git a/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java b/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java index 193e006..0ac320d 100644 --- a/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java +++ b/src/test/java/com/aims/assembly/controller/process/ProcessDashboardControllerTest.java @@ -8,12 +8,15 @@ import com.aims.assembly.service.process.ProcessDashboardService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -316,6 +319,50 @@ void equipmentOperationRateEndpointReturnsFourProcessItemsInFixedOrder() throws .andExpect(jsonPath("$.data.items[2].operationRate").value(0.0)); } + @Test + void equipmentOperationRateEndpointReturnsOkWithRedisRestoredResponse() throws Exception { + EquipmentOperationRateResponse cacheMissResponse = new EquipmentOperationRateResponse(List.of( + item("PRESS", "프레스", 1, 1, 2, 1), + item("BODY", "차체", 5, 0, 0, 0), + item("PAINT", "도장", 5, 0, 0, 0), + item("ASSEMBLY", "의장", 5, 0, 0, 0) + )); + GenericJacksonJsonRedisSerializer serializer = redisJsonSerializer(); + EquipmentOperationRateResponse cacheHitResponse = + (EquipmentOperationRateResponse) serializer.deserialize(serializer.serialize(cacheMissResponse)); + when(service.getEquipmentOperationRate()).thenReturn(cacheHitResponse); + + mockMvc.perform(get("/api/process/equipment/operation-rate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].processCode").value("PRESS")) + .andExpect(jsonPath("$.data.items[0].operationRate").value(40.0)) + .andExpect(jsonPath("$.data.items[0].statusCounts.RUNNING").value(1)) + .andExpect(jsonPath("$.data.items[0].statusCounts.WARNING").value(1)) + .andExpect(jsonPath("$.data.items[0].statusCounts.STOPPED").value(2)) + .andExpect(jsonPath("$.data.items[0].statusCounts.FAULT").value(1)); + } + + @Test + void equipmentOperationRateEndpointReturnsOkForRepeatedRedisRestoredResponses() throws Exception { + EquipmentOperationRateResponse cacheMissResponse = new EquipmentOperationRateResponse(List.of( + item("PRESS", "프레스", 1, 1, 2, 1), + item("BODY", "차체", 5, 0, 0, 0), + item("PAINT", "도장", 5, 0, 0, 0), + item("ASSEMBLY", "의장", 5, 0, 0, 0) + )); + GenericJacksonJsonRedisSerializer serializer = redisJsonSerializer(); + EquipmentOperationRateResponse cacheHitResponse = + (EquipmentOperationRateResponse) serializer.deserialize(serializer.serialize(cacheMissResponse)); + when(service.getEquipmentOperationRate()).thenReturn(cacheHitResponse); + + for (int i = 0; i < 5; i++) { + mockMvc.perform(get("/api/process/equipment/operation-rate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].operationRate").value(40.0)) + .andExpect(jsonPath("$.data.items[0].statusCounts.STOPPED").value(2)); + } + } + private EquipmentOperationRateResponse.Item item( String processCode, String processName, @@ -324,12 +371,11 @@ private EquipmentOperationRateResponse.Item item( long stoppedCount, long faultCount ) { - Map statusCounts = - new EnumMap<>(EquipmentOperationStatus.class); - statusCounts.put(EquipmentOperationStatus.RUNNING, runningCount); - statusCounts.put(EquipmentOperationStatus.WARNING, warningCount); - statusCounts.put(EquipmentOperationStatus.STOPPED, stoppedCount); - statusCounts.put(EquipmentOperationStatus.FAULT, faultCount); + Map statusCounts = new LinkedHashMap<>(); + statusCounts.put(EquipmentOperationStatus.RUNNING.name(), runningCount); + statusCounts.put(EquipmentOperationStatus.WARNING.name(), warningCount); + statusCounts.put(EquipmentOperationStatus.STOPPED.name(), stoppedCount); + statusCounts.put(EquipmentOperationStatus.FAULT.name(), faultCount); long operatingCount = runningCount + warningCount; long totalCount = operatingCount + stoppedCount + faultCount; double operationRate = totalCount == 0 @@ -348,4 +394,17 @@ private EquipmentOperationRateResponse.Item item( statusCounts ); } + + private GenericJacksonJsonRedisSerializer redisJsonSerializer() { + PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.aims.assembly.") + .allowIfSubType("java.lang.") + .allowIfSubType("java.time.") + .allowIfSubType("java.util.") + .build(); + + return GenericJacksonJsonRedisSerializer.builder() + .enableDefaultTyping(typeValidator) + .build(); + } } diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java index c9d0a3f..f2a45ef 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java @@ -18,6 +18,8 @@ import tools.jackson.databind.ObjectMapper; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -43,6 +45,79 @@ void normalizesOffsetDateTimesForLocalDateTimeMessageModels() { .contains("\"status\":\"WARNING\""); } + @Test + void analysisEventAllowsNullOperationRateAndOffsetAnalyzedAt() { + ObjectMapper objectMapper = new ObjectMapper(); + ManufacturingEventAnalyzer analyzer = mock(ManufacturingEventAnalyzer.class); + ManufacturingProcessRouter processRouter = mock(ManufacturingProcessRouter.class); + ManufacturingKafkaProducer producer = mock(ManufacturingKafkaProducer.class); + KafkaMessageTraceStore traceStore = mock(KafkaMessageTraceStore.class); + ManufacturingRawEventParser parser = mock(ManufacturingRawEventParser.class); + EquipmentStateService equipmentStateService = mock(EquipmentStateService.class); + ManufacturingEventJsonRepository eventRepository = mock(ManufacturingEventJsonRepository.class); + ManufacturingAnalysisResultService analysisResultService = + mock(ManufacturingAnalysisResultService.class); + ManufacturingKafkaConsumer consumer = new ManufacturingKafkaConsumer( + objectMapper, + analyzer, + processRouter, + producer, + traceStore, + parser, + equipmentStateService, + eventRepository, + analysisResultService + ); + String payload = """ + { + "analysisId": "ANL-1", + "eventId": "EVT-1", + "eventTime": "2026-07-10T10:45:55.462944+09:00", + "analyzedAt": "2026-07-10T10:45:55.462944+09:00", + "processCode": "PRESS", + "equipmentCode": "EQ_PRESS_001", + "analysisType": "PROCESS_RISK_ANALYSIS", + "riskScores": { + "overallRiskScore": 10.0, + "bottleneckRisk": null, + "defectTransferRisk": null, + "equipmentRisk": null, + "processRisk": { + "pressStopRisk": 10.0, + "robotCollisionRisk": null, + "paintQualityRisk": null, + "assemblySequenceRisk": null + } + }, + "operationRate": null, + "riskLevel": "LOW", + "analysisResult": { + "isAbnormal": false, + "isBottleneck": false, + "isQualityDefect": false, + "isEquipmentFault": false, + "isSequenceError": false + }, + "reason": { + "mainReason": "normal", + "detailReasons": [] + }, + "recommendation": { + "actionType": "NONE", + "message": "none" + } + } + """; + + consumer.consumeAnalysisForAlert( + new ConsumerRecord<>("factory.manufacturing.analysis", 0, 11847L, "EVT-1", payload) + ); + + verify(traceStore).recordConsumed(any(), eq("alert-analysis-consumer-group"), eq("EVT-1")); + verify(analyzer).requiresAlert(any(ManufacturingAnalysisEvent.class)); + verify(producer, never()).sendAlert(any()); + } + @Test void warningEquipmentStatusDoesNotBlockReadyEvents() { ObjectMapper objectMapper = mock(ObjectMapper.class); @@ -275,8 +350,8 @@ private ManufacturingAnalysisEvent normalAnalysis(ManufacturingRawEvent raw, Lon return new ManufacturingAnalysisEvent( "ANL-1", raw.eventId(), - raw.eventTime(), - raw.eventTime(), + offset(raw.eventTime()), + offset(raw.eventTime()), null, null, raw.processCode(), @@ -306,8 +381,8 @@ private ManufacturingAnalysisEvent abnormalAnalysis(ManufacturingRawEvent raw) { return new ManufacturingAnalysisEvent( "ANL-CRITICAL", raw.eventId(), - raw.eventTime(), - raw.eventTime(), + offset(raw.eventTime()), + offset(raw.eventTime()), null, null, raw.processCode(), @@ -337,6 +412,10 @@ private KafkaPublishResult publishResult() { return new KafkaPublishResult("topic", 0, 0L, "key", "EVT-1"); } + private OffsetDateTime offset(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime(); + } + private void createTables(JdbcTemplate jdbc) { jdbc.execute("DROP TABLE IF EXISTS manufacturing_event_json"); jdbc.execute("DROP TABLE IF EXISTS equipment"); diff --git a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java index a00393f..f800198 100644 --- a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java +++ b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceIntegrationTest.java @@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.Collections; import java.util.Map; @@ -463,8 +465,8 @@ private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw, Loca return new ManufacturingAnalysisEvent( "ANL-100", raw.eventId(), - raw.eventTime(), - analyzedAt, + offset(raw.eventTime()), + offset(analyzedAt), "FAC", "LINE", raw.processCode(), @@ -486,4 +488,8 @@ private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw, Loca new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") ); } + + private OffsetDateTime offset(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime(); + } } diff --git a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java index c3a459a..4857581 100644 --- a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java +++ b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java @@ -24,6 +24,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.Collections; import java.util.Map; import java.util.Optional; @@ -389,8 +391,8 @@ private ManufacturingAnalysisEvent analysisEvent(ManufacturingRawEvent raw, Loca return new ManufacturingAnalysisEvent( "ANL-" + raw.eventId(), raw.eventId(), - raw.eventTime(), - analyzedAt, + offset(raw.eventTime()), + offset(analyzedAt), "FAC", "LINE", raw.processCode(), @@ -424,7 +426,7 @@ void test1_rawActualSequenceEqualsExpectedSequenceAndAbnormal() { ) )); ManufacturingAnalysisEvent analysis = new ManufacturingAnalysisEvent( - "ANL-1", "EVT-TEST-1", raw.eventTime(), raw.eventTime(), + "ANL-1", "EVT-TEST-1", offset(raw.eventTime()), offset(raw.eventTime()), "FAC", "LINE", ProcessCode.ASSEMBLY, "EQ-100", "EQ-NAME", "ROBOT", "PROD", "CAR", raw.carMasterId(), "PROCESS_RISK_ANALYSIS", new ManufacturingAnalysisEvent.RiskScores(0.0, 0.0, 0.0, 0.0, new ManufacturingAnalysisEvent.ProcessRisk(0.0, null, null, null)), @@ -443,6 +445,10 @@ void test1_rawActualSequenceEqualsExpectedSequenceAndAbnormal() { assertThat(result.getActualSequence()).isEqualTo("P01>B03>PA02>A03"); } + private OffsetDateTime offset(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.atZone(ZoneId.systemDefault()).toOffsetDateTime(); + } + @Test void test2_rawActualSequenceDiffersFromExpectedSequence() { ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-TEST-2", Map.of( diff --git a/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java b/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java index 08a04c6..c0038a3 100644 --- a/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java +++ b/src/test/java/com/aims/assembly/service/process/ProcessDashboardServiceTest.java @@ -12,21 +12,29 @@ import com.aims.assembly.dto.process.PaintDashboardResponse; import com.aims.assembly.repository.analysis.AssemblyAnalysisResultRepository; import com.aims.assembly.repository.analysis.PaintAnalysisResultRepository; +import com.aims.assembly.repository.car.CarMasterRepository; import com.aims.assembly.repository.equipment.EquipmentOperationRateRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class ProcessDashboardServiceTest { @@ -36,6 +44,7 @@ class ProcessDashboardServiceTest { mock(AssemblyAnalysisResultRepository.class); private final EquipmentOperationRateRepository equipmentOperationRateRepository = mock(EquipmentOperationRateRepository.class); + private final CarMasterRepository carMasterRepository = mock(CarMasterRepository.class); private ProcessDashboardService service; @BeforeEach @@ -43,8 +52,17 @@ void setUp() { service = new ProcessDashboardService( paintRepository, assemblyRepository, - equipmentOperationRateRepository + equipmentOperationRateRepository, + carMasterRepository ); + when(carMasterRepository.findVehicleIdsByIdIn(any())).thenAnswer(invocation -> { + Iterable ids = invocation.getArgument(0); + Map vehicleIdsById = new LinkedHashMap<>(); + for (Long id : ids) { + vehicleIdsById.put(id, "VEHICLE-%04d".formatted(id)); + } + return vehicleIdsById; + }); } @Test @@ -160,6 +178,8 @@ void assemblyDashboardBuildsSummaryVehiclesAndAlert() { eq(LocalDateTime.of(2026, 6, 18, 0, 0)), eq(LocalDateTime.of(2026, 6, 19, 0, 0)) )).thenReturn(List.of(row)); + when(carMasterRepository.findVehicleIdsByIdIn(List.of(1L))) + .thenReturn(Map.of(1L, "AVANTE-0001")); AssemblyDashboardResponse response = service.getAssemblyDashboard(null, null, null, 30); @@ -174,7 +194,8 @@ void assemblyDashboardBuildsSummaryVehiclesAndAlert() { assertThat(response.summary().fasteningErrorCount()).isEqualTo(1); assertThat(response.summary().averageRiskScore()).isEqualTo(86.5); assertThat(response.vehicles()).singleElement().satisfies(vehicle -> { - assertThat(vehicle.carDisplayId()).isEqualTo("CAR-000001"); + assertThat(vehicle.carMasterId()).isEqualTo(1L); + assertThat(vehicle.carDisplayId()).isEqualTo("AVANTE-0001"); assertThat(vehicle.expectedSequence()).isEqualTo("A01 > A02 > A03 > A04"); assertThat(vehicle.actualSequence()).isEqualTo("A01 > A03 > A02 > A04"); assertThat(vehicle.sequenceErrorCount()).isEqualTo(1); @@ -187,6 +208,67 @@ void assemblyDashboardBuildsSummaryVehiclesAndAlert() { .contains("조립 순서 오류와 체결 오류 동시 감지"); } + @Test + void assemblyDashboardUsesVehicleIdFromCarMasterForDisplayId() { + AssemblyAnalysisResult first = assemblyRow(34L, LocalDateTime.of(2026, 6, 18, 10, 0)); + AssemblyAnalysisResult second = assemblyRow(35L, LocalDateTime.of(2026, 6, 18, 10, 5)); + when(assemblyRepository.findDashboardRows(any(), any(), any(Pageable.class))) + .thenReturn(List.of(second, first)); + when(assemblyRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of(first, second)); + when(carMasterRepository.findVehicleIdsByIdIn(List.of(34L, 35L))).thenReturn(Map.of( + 34L, "AVANTE-0034", + 35L, "SONATA-0035" + )); + + AssemblyDashboardResponse response = + service.getAssemblyDashboard(LocalDate.of(2026, 6, 18), null, null, 30); + + assertThat(response.vehicles()).extracting(AssemblyDashboardResponse.VehicleRow::carMasterId) + .containsExactly(34L, 35L); + assertThat(response.vehicles()).extracting(AssemblyDashboardResponse.VehicleRow::carDisplayId) + .containsExactly("AVANTE-0034", "SONATA-0035"); + verify(carMasterRepository, times(1)).findVehicleIdsByIdIn(List.of(34L, 35L)); + } + + @Test + void assemblyDashboardDoesNotGenerateFakeDisplayIdWhenCarMasterIsMissing() { + AssemblyAnalysisResult row = assemblyRow(99L, LocalDateTime.of(2026, 6, 18, 10, 0)); + when(assemblyRepository.findDashboardRows(any(), any(), any(Pageable.class))) + .thenReturn(List.of(row)); + when(assemblyRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of(row)); + when(carMasterRepository.findVehicleIdsByIdIn(List.of(99L))).thenReturn(Map.of()); + + AssemblyDashboardResponse response = + service.getAssemblyDashboard(LocalDate.of(2026, 6, 18), null, null, 30); + + assertThat(response.vehicles()).singleElement().satisfies(vehicle -> { + assertThat(vehicle.carMasterId()).isEqualTo(99L); + assertThat(vehicle.carDisplayId()).isNull(); + }); + } + + @Test + void assemblyDashboardDoesNotGenerateFakeDisplayIdWhenVehicleIdIsNull() { + AssemblyAnalysisResult row = assemblyRow(100L, LocalDateTime.of(2026, 6, 18, 10, 0)); + when(assemblyRepository.findDashboardRows(any(), any(), any(Pageable.class))) + .thenReturn(List.of(row)); + when(assemblyRepository.findDashboardSummaryRows(any(), any())) + .thenReturn(List.of(row)); + Map vehicleIds = new LinkedHashMap<>(); + vehicleIds.put(100L, null); + when(carMasterRepository.findVehicleIdsByIdIn(List.of(100L))).thenReturn(vehicleIds); + + AssemblyDashboardResponse response = + service.getAssemblyDashboard(LocalDate.of(2026, 6, 18), null, null, 30); + + assertThat(response.vehicles()).singleElement().satisfies(vehicle -> { + assertThat(vehicle.carMasterId()).isEqualTo(100L); + assertThat(vehicle.carDisplayId()).isNull(); + }); + } + @Test void returnsEmptyResponsesWhenNoDataExists() { when(paintRepository.findDashboardEventTimeValues()).thenReturn(List.of()); @@ -434,16 +516,16 @@ void equipmentOperationRateUsesCurrentStatusAndIncludesWarningAsOperating() { assertThat(item.faultCount()).isZero(); assertThat(item.totalCount()).isEqualTo(5); assertThat(item.operationRate()).isEqualTo(80.0); - assertThat(item.statusCounts()).containsEntry(EquipmentOperationStatus.WARNING, 1L); + assertThat(item.statusCounts()).containsEntry("WARNING", 1L); }); assertThat(response.items().get(2)).satisfies(item -> { assertThat(item.processCode()).isEqualTo("PAINT"); assertThat(item.totalCount()).isZero(); assertThat(item.operationRate()).isEqualTo(0.0); - assertThat(item.statusCounts()).containsEntry(EquipmentOperationStatus.RUNNING, 0L) - .containsEntry(EquipmentOperationStatus.WARNING, 0L) - .containsEntry(EquipmentOperationStatus.STOPPED, 0L) - .containsEntry(EquipmentOperationStatus.FAULT, 0L); + assertThat(item.statusCounts()).containsEntry("RUNNING", 0L) + .containsEntry("WARNING", 0L) + .containsEntry("STOPPED", 0L) + .containsEntry("FAULT", 0L); }); assertThat(response.items().get(3)).satisfies(item -> { assertThat(item.warningCount()).isEqualTo(2); @@ -452,6 +534,83 @@ void equipmentOperationRateUsesCurrentStatusAndIncludesWarningAsOperating() { }); } + @Test + void equipmentOperationRateStatusCountsNormalizesIntegerValuesToLongValues() { + Map statusCounts = new LinkedHashMap<>(); + statusCounts.put("RUNNING", 1); + statusCounts.put("WARNING", 1L); + statusCounts.put("STOPPED", 2); + statusCounts.put("FAULT", 1); + + EquipmentOperationRateResponse.Item item = new EquipmentOperationRateResponse.Item( + "PRESS", + "프레스", + 1, + 1, + 2, + 2, + 1, + 5, + 40.0, + (Map) statusCounts + ); + + assertThat(item.statusCounts()).containsEntry("RUNNING", 1L) + .containsEntry("WARNING", 1L) + .containsEntry("STOPPED", 2L) + .containsEntry("FAULT", 1L); + assertThat(item.statusCounts().values()).allSatisfy(value -> assertThat(value).isInstanceOf(Long.class)); + } + + @Test + void equipmentOperationRateResponseSurvivesRedisSerializerRoundTrip() { + EquipmentOperationRateResponse response = new EquipmentOperationRateResponse(List.of( + new EquipmentOperationRateResponse.Item( + "PRESS", + "프레스", + 1, + 1, + 2, + 2, + 1, + 5, + 40.0, + Map.of( + "RUNNING", 1L, + "WARNING", 1L, + "STOPPED", 2L, + "FAULT", 1L + ) + ) + )); + GenericJacksonJsonRedisSerializer serializer = redisJsonSerializer(); + + Object restored = serializer.deserialize(serializer.serialize(response)); + + assertThat(restored).isInstanceOf(EquipmentOperationRateResponse.class); + EquipmentOperationRateResponse restoredResponse = (EquipmentOperationRateResponse) restored; + EquipmentOperationRateResponse.Item restoredItem = restoredResponse.items().get(0); + assertThat(restoredItem.statusCounts()).containsEntry("RUNNING", 1L) + .containsEntry("WARNING", 1L) + .containsEntry("STOPPED", 2L) + .containsEntry("FAULT", 1L); + assertThat(restoredItem.statusCounts().values()) + .allSatisfy(value -> assertThat(value).isInstanceOf(Long.class)); + } + + private GenericJacksonJsonRedisSerializer redisJsonSerializer() { + PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.aims.assembly.") + .allowIfSubType("java.lang.") + .allowIfSubType("java.time.") + .allowIfSubType("java.util.") + .build(); + + return GenericJacksonJsonRedisSerializer.builder() + .enableDefaultTyping(typeValidator) + .build(); + } + private ManufacturingAnalysisResult result( ProcessCode processCode, Long carMasterId, @@ -519,23 +678,32 @@ private List assemblyRows(int count) { List rows = new ArrayList<>(); for (int index = 0; index < count; index++) { LocalDateTime eventTime = LocalDateTime.of(2026, 7, 7, 10, 0).plusMinutes(index * 6L); - rows.add(AssemblyAnalysisResult.builder() - .analysisResult(result( - ProcessCode.ASSEMBLY, - (long) index, - Severity.NORMAL, - false, - index, - eventTime, - eventTime.plusSeconds(5) - )) - .expectedSequence("A01 > A02") - .actualSequence("A01 > A02") - .sequenceErrorCount(0) - .missingPartCount(0) - .fasteningErrorCount(0) - .build()); + rows.add(assemblyRow((long) index, eventTime, index)); } return rows; } + + private AssemblyAnalysisResult assemblyRow(Long carMasterId, LocalDateTime eventTime) { + return assemblyRow(carMasterId, eventTime, 0.0); + } + + private AssemblyAnalysisResult assemblyRow(Long carMasterId, LocalDateTime eventTime, double riskScore) { + return AssemblyAnalysisResult.builder() + .analysisResult(result( + ProcessCode.ASSEMBLY, + carMasterId, + Severity.NORMAL, + false, + riskScore, + eventTime, + eventTime.plusSeconds(5) + )) + .expectedSequence("A01 > A02") + .actualSequence("A01 > A02") + .sequenceErrorCount(0) + .missingPartCount(0) + .fasteningErrorCount(0) + .build(); + } + } From d7bb6e4cafd5188e7f03e578d2fa89e0ae2735f2 Mon Sep 17 00:00:00 2001 From: kimgeon Date: Fri, 10 Jul 2026 13:50:05 +0900 Subject: [PATCH 18/35] feat : inspection master table update --- .../inspection/InspectionMasterEntity.java | 38 ++++++++++++ .../dto/inspection/InspectionTargetDto.java | 28 +++++++++ .../mapper/InspectionTargetRowMapper.java | 28 +++++++++ .../inspection/InspectionRepository.java | 12 ++++ .../ManufacturingRepository.java | 60 ++++++++++++++++++ .../service/assembly/InspectionScheduler.java | 17 +++++ .../assembly/InspectionTransferService.java | 62 +++++++++++++++++++ 7 files changed, 245 insertions(+) create mode 100644 src/main/java/com/aims/assembly/domain/inspection/InspectionMasterEntity.java create mode 100644 src/main/java/com/aims/assembly/dto/inspection/InspectionTargetDto.java create mode 100644 src/main/java/com/aims/assembly/mapper/InspectionTargetRowMapper.java create mode 100644 src/main/java/com/aims/assembly/repository/inspection/InspectionRepository.java create mode 100644 src/main/java/com/aims/assembly/repository/manufacturing/ManufacturingRepository.java create mode 100644 src/main/java/com/aims/assembly/service/assembly/InspectionScheduler.java create mode 100644 src/main/java/com/aims/assembly/service/assembly/InspectionTransferService.java 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/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/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/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 From 1851ac6f69c04ceddb7bdbd100ca0ed33cba7b45 Mon Sep 17 00:00:00 2001 From: hyein0514 Date: Fri, 10 Jul 2026 16:22:53 +0900 Subject: [PATCH 19/35] =?UTF-8?q?refactor:=20=EC=9D=98=EC=9E=A5=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ManufacturingAnalysisResultService.java | 24 +++- ...anufacturingAnalysisResultServiceTest.java | 118 ++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java index caeeb3f..db082c8 100644 --- a/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java +++ b/src/main/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultService.java @@ -888,12 +888,18 @@ private AssemblyCalculatedValues calculateAssemblyValues( actualSequence = null; } + // sequence가 동일하면 sequenceErrorCount는 반드시 0이어야 한다. + // equipmentFault 등 다른 이유로 abnormal이 되어도 순서 오류는 sequence 문자열 기준으로 결정한다. + boolean sequencesMatch = expectedSequence != null + && expectedSequence.equals(actualSequence); + int seqVariation = eventIdIndex(savedResult.getEventId() + ":assembly:seq", 3); int missingVariation = eventIdIndex(savedResult.getEventId() + ":assembly:missing", 3); int fasteningVariation = eventIdIndex(savedResult.getEventId() + ":assembly:fastening", 4); if (assemblyAbnormal) { - if (sequenceErrorCount == null || sequenceErrorCount <= 0) { + // sequenceError 합성: sequence가 실제로 다를 때만 허용 + if (!sequencesMatch && (sequenceErrorCount == null || sequenceErrorCount <= 0)) { sequenceErrorCount = riskScore >= 80 ? 2 + seqVariation : 1 + seqVariation; } @@ -910,6 +916,11 @@ private AssemblyCalculatedValues calculateAssemblyValues( if (fasteningErrorCount == null) fasteningErrorCount = 0; } + // 최종 정합성 보정: sequence 문자열이 동일하면 sequenceErrorCount는 0 + if (sequencesMatch) { + sequenceErrorCount = 0; + } + return new AssemblyCalculatedValues( expectedSequence, actualSequence, @@ -962,12 +973,21 @@ private AssemblyRiskOverride calculateAssemblyRiskOverride( : intVal(json, "processData", "assembly", "fasteningErrorCount"); // [FIX] 원본 이벤트가 이상(Abnormal)이지만 카운트가 누락된 경우, 보정값을 부여하여 위험도를 재계산한다. + // 단, sequenceErrorCount 보정은 expected/actual sequence가 실제로 다를 때만 적용한다. + // sequence가 동일한데 equipmentFault 등으로 abnormal이 된 경우 sequence 오류를 부풀리지 않는다. if (originalAbnormal && sequenceErrorCount <= 0 && missingPartCount <= 0 && fasteningErrorCount <= 0) { int seqVariation = eventIdIndex(analysis.eventId() + ":assembly:seq", 3); int missingVariation = eventIdIndex(analysis.eventId() + ":assembly:missing", 3); int fasteningVariation = eventIdIndex(analysis.eventId() + ":assembly:fastening", 4); - sequenceErrorCount = originalRiskScore >= 80 ? 2 + seqVariation : 1 + seqVariation; + String expectedSeq = text(json, "processData", "assembly", "expectedSequence"); + String actualSeq = text(json, "processData", "assembly", "actualSequence"); + boolean sequencesMatch = expectedSeq != null && expectedSeq.equals(actualSeq); + + // sequence가 다를 때만 sequenceError 보정 적용 + if (!sequencesMatch) { + sequenceErrorCount = originalRiskScore >= 80 ? 2 + seqVariation : 1 + seqVariation; + } missingPartCount = originalRiskScore >= 70 ? 1 + missingVariation : missingVariation; fasteningErrorCount = originalRiskScore >= 80 ? 2 + fasteningVariation : 1 + fasteningVariation; } diff --git a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java index 4857581..edd8886 100644 --- a/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java +++ b/src/test/java/com/aims/assembly/service/manufacturing/ManufacturingAnalysisResultServiceTest.java @@ -443,6 +443,8 @@ void test1_rawActualSequenceEqualsExpectedSequenceAndAbnormal() { AssemblyAnalysisResult result = captor.getValue(); assertThat(result.getExpectedSequence()).isEqualTo("P01>B03>PA02>A03"); assertThat(result.getActualSequence()).isEqualTo("P01>B03>PA02>A03"); + // 수정 후: sequence가 동일하면 abnormal 여부와 무관하게 sequenceErrorCount = 0 + assertThat(result.getSequenceErrorCount()).isZero(); } private OffsetDateTime offset(LocalDateTime dateTime) { @@ -506,4 +508,120 @@ void test4_rawExpectedSequenceMissing() { assertThat(result.getExpectedSequence()).isNull(); assertThat(result.getActualSequence()).isEqualTo("P01>B04>PA02>A03"); } + + /** + * [핵심 버그 재현] + * raw: expectedSequence == actualSequence, 모든 count=0 + * equipmentStatus=WARNING → analysis.isAbnormal=true + * 수정 전: sequenceErrorCount가 합성값(2 등)으로 저장되어 화면에 불일치 발생 + * 수정 후: sequence가 동일 → sequenceErrorCount=0 으로 저장 + */ + @Test + void sequenceErrorCount_mustBeZero_whenSequencesMatch_evenIfEquipmentFaultMakesAbnormal() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-20260602-000092", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P05>B05>PA02>A03", + "actualSequence", "P05>B05>PA02>A03", + "sequenceErrorCount", 0, + "missingPartCount", 0, + "fasteningErrorCount", 0 + ) + ), + "equipmentStatus", Map.of("operationStatus", "WARNING") + )); + // equipmentFault=true로 인해 isAbnormal=true + ManufacturingAnalysisEvent analysis = new ManufacturingAnalysisEvent( + "ANL-92", "EVT-20260602-000092", offset(raw.eventTime()), offset(raw.eventTime()), + "FAC", "LINE", ProcessCode.ASSEMBLY, "EQ-200", "EQ-NAME", "ROBOT", "PROD", "CAR", 123L, + "PROCESS_RISK_ANALYSIS", + new ManufacturingAnalysisEvent.RiskScores(67.0, 0.0, 0.0, 0.0, + new ManufacturingAnalysisEvent.ProcessRisk(null, null, null, 67.0)), + 67.0, "WARNING", + new ManufacturingAnalysisEvent.AnalysisResult(true, false, false, true, false), + new ManufacturingAnalysisEvent.Reason("설비 이상", Collections.emptyList()), + new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") + ); + + service.save(raw, analysis); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P05>B05>PA02>A03"); + assertThat(result.getActualSequence()).isEqualTo("P05>B05>PA02>A03"); + assertThat(result.getSequenceErrorCount()) + .as("expected == actual 이면 sequenceErrorCount=0 이어야 한다 (equipmentFault여도)") + .isEqualTo(0); + } + + /** + * sequence가 다를 때 count=0이면 보정값(>0)을 사용해야 한다. + */ + @Test + void sequenceErrorCount_mustBePositive_whenSequencesDiffer_andRawCountIsZero() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-DIFF-SEQ", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B04>PA04>A03", + "actualSequence", "P01>PA04>B04>A03", + "sequenceErrorCount", 0, + "missingPartCount", 0, + "fasteningErrorCount", 0 + ) + ) + )); + ManufacturingAnalysisEvent analysis = new ManufacturingAnalysisEvent( + "ANL-DIFF", "EVT-DIFF-SEQ", offset(raw.eventTime()), offset(raw.eventTime()), + "FAC", "LINE", ProcessCode.ASSEMBLY, "EQ-300", "EQ-NAME", "ROBOT", "PROD", "CAR", 999L, + "PROCESS_RISK_ANALYSIS", + new ManufacturingAnalysisEvent.RiskScores(65.0, 0.0, 0.0, 0.0, + new ManufacturingAnalysisEvent.ProcessRisk(null, null, null, 65.0)), + 65.0, "WARNING", + new ManufacturingAnalysisEvent.AnalysisResult(true, false, false, false, true), + new ManufacturingAnalysisEvent.Reason("순서 오류", Collections.emptyList()), + new ManufacturingAnalysisEvent.Recommendation("ACTION", "Message") + ); + + service.save(raw, analysis); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getExpectedSequence()).isEqualTo("P01>B04>PA04>A03"); + assertThat(result.getActualSequence()).isEqualTo("P01>PA04>B04>A03"); + assertThat(result.getSequenceErrorCount()) + .as("expected != actual 이면 sequenceErrorCount > 0 이어야 한다") + .isGreaterThan(0); + } + + /** + * raw count가 양수이고 sequence도 다를 때: raw count를 그대로 사용 + */ + @Test + void sequenceErrorCount_mustUseRawValue_whenSequencesDifferAndRawCountIsPositive() { + ManufacturingRawEvent raw = rawEvent(ProcessCode.ASSEMBLY, "EVT-RAW-CNT", Map.of( + "processData", Map.of( + "assembly", Map.of( + "expectedSequence", "P01>B04>PA04>A03", + "actualSequence", "P01>PA04>B04>A03", + "sequenceErrorCount", 1, + "missingPartCount", 0, + "fasteningErrorCount", 1 + ) + ) + )); + + service.save(raw, analysisEvent(raw)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssemblyAnalysisResult.class); + verify(assemblyRepository).save(captor.capture()); + AssemblyAnalysisResult result = captor.getValue(); + assertThat(result.getSequenceErrorCount()) + .as("raw sequenceErrorCount=1, sequence 다름 → 원본값 1 유지") + .isEqualTo(1); + assertThat(result.getFasteningErrorCount()) + .as("raw fasteningErrorCount=1 그대로") + .isEqualTo(1); + } } From 0f2f2450fdcbbb30e4388f801c507a58699e5398 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Mon, 13 Jul 2026 15:35:50 +0900 Subject: [PATCH 20/35] =?UTF-8?q?fix:=20=ED=94=84=EB=A0=88=EC=8A=A4=C2=B7?= =?UTF-8?q?=EC=B0=A8=EC=B2=B4=20=EC=9D=B4=EC=83=81=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EA=B0=92=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MANUFACUTRING_REFERENCE.md 기준으로 프레스 사이클 편차, countIncreaseYn, 설비 상태 판정 로직 수정 - 차체 로봇 진동, 상태, 주파수 밴드별 임계값을 반영해 이상 탐지 및 차트 기준값 수정 - 프레스 조회 응답에 cycle gap 기준값 필드를 추가하고 차체 조회 응답의 기준값 반환을 밴드별 값으로 정리 --- MANUFACUTRING_REFERENCE.md | 357 +++++++++++++++++ .../press/PressAnomalyDetectionResponse.java | 42 +- .../kafka/ManufacturingEventAnalyzer.java | 373 +++++++++++++++--- .../body/BodyAnomalyDetectionService.java | 247 +++++++++--- .../press/PressAnomalyDetectionService.java | 84 +++- .../PressAnomalyDetectionResponseTest.java | 6 + .../kafka/ManufacturingEventAnalyzerTest.java | 60 ++- .../body/BodyAnomalyDetectionServiceTest.java | 15 +- .../PressAnomalyDetectionServiceTest.java | 4 + 9 files changed, 1043 insertions(+), 145 deletions(-) create mode 100644 MANUFACUTRING_REFERENCE.md diff --git a/MANUFACUTRING_REFERENCE.md b/MANUFACUTRING_REFERENCE.md new file mode 100644 index 0000000..1e2205a --- /dev/null +++ b/MANUFACUTRING_REFERENCE.md @@ -0,0 +1,357 @@ +# 프레스·차체 이상탐지 기준값 레퍼런스 정리 + +현재 프레스와 차체 공정의 기준값은 일부가 코드에 고정 숫자로 들어가 있지만, 그 숫자들이 ISO 문서에 직접 명시된 공식 기준값은 아닙니다. + +예를 들어 현재 코드의 다음 값들은 내부 하드코딩 값입니다. + +``` +프레스 전류 RMS: 1.5A +차체 진동 점수: 0.75 / 1.25 +차체 주파수 피크: 0.015 / 0.03 +``` + +따라서 이 값을 다른 고정 숫자로 단순 교체하기보다는, ISO 문서의 측정·관리 원칙을 근거로 **설비별 정상 데이터를 이용해 NORMAL, WARNING, DANGER 기준을 만드는 방식**이 적절합니다. + +## 핵심 개념 + +ISO 문서에서 모든 프레스와 로봇에 공통으로 적용되는 다음과 같은 숫자를 제공하는 것은 아닙니다. + +``` +프레스 전류 10A 이상 위험 +로봇 진동 0.5 이상 위험 +사이클 시간 40초 이상 위험 +``` + +설비 종류, 모터 정격, 차종, 금형, 로봇 프로그램, 작업 속도에 따라 정상값이 달라지기 때문입니다. + +그래서 다음 방식으로 기준을 만듭니다. + +``` +정상 데이터의 평균값 = μ +정상 데이터가 평소 흔들리는 정도 = σ +``` + +이를 기준으로: + +| 상태 | 기준 | +| --- | --- | +| 정상 | 평균에서 2σ 이내 | +| 경고 | 평균에서 2σ 이상 3σ 미만 벗어남 | +| 위험 | 평균에서 3σ 이상 벗어남 | + +여기서 `σ`는 시그마라고 읽으며, 정상 데이터의 변동 정도를 뜻합니다. + +예를 들어 정상 사이클 시간이 평균 40초이고 표준편차가 1초라면: + +| 상태 | 기준 | +| --- | --- | +| 정상 | 38~42초 | +| 경고 | 37~38초 또는 42~43초 | +| 위험 | 37초 미만 또는 43초 초과 | + +즉, ISO 기반 기준이라는 것은 ISO에 적힌 절대 숫자를 그대로 쓰는 것이 아니라, **ISO의 관리도 원칙을 우리 설비의 정상 데이터에 적용해 기준값을 만드는 방식**입니다. + +--- + +# 1. 프레스 공정 + +현재 프레스 이상탐지에는 다음 값이 사용됩니다. + +| 지표 | 의미 | +| --- | --- | +| 사이클 시간 | 제품 한 개를 처리하는 데 걸린 시간 | +| 지연시간 | 공정이 평소보다 지연된 시간 | +| 전류 RMS | 모터나 설비가 사용하는 전류의 평균적인 크기 | +| 생산 카운트 | 생산 수량이 정상적으로 증가했는지 여부 | +| 설비 상태 | RUNNING, WARNING, STOPPED, FAULT 등의 상태 | + +현재 프레스에서는 압력, 하중, 금형 상태, 제품 치수 등은 이상탐지에 직접 사용하지 않습니다. + +## 프레스 기준값에 사용할 레퍼런스 + +### ISO 22400 + +제조 공정의 KPI, 사이클 시간, 생산성 등의 정의와 관리 방식을 다루는 표준입니다. + +이 문서는 “프레스 사이클은 40초가 정상”이라는 숫자를 정해주는 것이 아니라, 사이클 시간을 어떤 의미로 관리해야 하는지를 제공합니다. + +따라서 사이클 시간과 지연시간을 관리하는 기본 근거로 사용할 수 있습니다. + +### ISO 7870 + +통계적 공정관리와 관리도에 대한 표준입니다. + +정상 데이터의 평균과 표준편차를 이용해 정상, 경고, 위험 구간을 만드는 근거로 사용할 수 있습니다. + +즉: + +``` +2σ 이내 = 정상 +2σ~3σ = 경고 +3σ 초과 = 위험 +``` + +이라는 운영 기준을 만들 때 사용하는 핵심 문서입니다. + +### ISO 20958 + +전동기의 전류 신호를 이용한 상태감시 방법을 다루는 표준입니다. + +현재 코드의 `1.5A`처럼 모든 설비에 같은 전류 기준을 쓰는 대신, 모터와 운전 조건별 전류 패턴을 분석해야 한다는 근거로 사용할 수 있습니다. + +이 표준이 “1.5A 이상 위험”이라는 값을 제공하는 것은 아닙니다. + +## 프레스 추천 기준 + +| 지표 | 추천 기준 | +| --- | --- | +| 사이클 시간 | 차종·금형·작업별 정상 평균 ±2σ는 정상, 2~3σ는 경고, 3σ 이상은 위험 | +| 지연시간 | 정상 평균+2σ 이하는 정상, 2~3σ는 경고, 3σ 초과는 위험 | +| 전류 RMS | 동일 모터·동작 단계의 정상 평균 ±2σ는 정상, 2~3σ는 경고, 3σ 이상 또는 제조사 과부하 한계 초과는 위험 | +| 생산 카운트 | `true` 정상, `null` 데이터 누락 경고, `false` 위험 | +| 설비 상태 | `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험 | + +### 사이클 시간 + +사이클 시간은 모든 설비에 같은 기준을 쓰면 안 됩니다. + +다음 조건별로 정상 기준을 따로 만들어야 합니다. + +``` +프레스 설비 +차종 +금형 +작업 코드 +``` + +예를 들어: + +``` +프레스 1호기 + 차종 A + 금형 D01 +평균 사이클 시간: 40초 +표준편차: 1초 +``` + +이라면: + +``` +정상: 38~42초 +경고: 37~38초 또는 42~43초 +위험: 37초 미만 또는 43초 초과 +``` + +처럼 기준을 만들 수 있습니다. + +### 지연시간 + +지연시간은 낮은 값이 문제가 되지 않으므로 상한만 확인하면 됩니다. + +예를 들어: + +``` +평균 지연시간: 3초 +표준편차: 1초 +``` + +라면: + +``` +정상: 5초 이하 +경고: 5초 초과~6초 이하 +위험: 6초 초과 +``` + +가 됩니다. + +### 전류 RMS + +현재 코드의 `1.5A` 기준은 설비 정격이나 운전 조건과 무관하기 때문에 근거가 부족합니다. + +전류 기준을 만들기 전에 다음 정보가 필요합니다. + +``` +어떤 모터의 전류인지 +단상인지 3상 RMS인지 +타격·복귀·대기 중 언제 측정한 값인지 +모터 정격전류가 얼마인지 +``` + +그다음 동작 단계별 정상 데이터를 모아 평균과 표준편차를 계산해야 합니다. + +예를 들어 타격 단계 전류가: + +``` +평균 12A +표준편차 0.8A +``` + +라면: + +``` +정상: 10.4~13.6A +경고: 9.6~10.4A 또는 13.6~14.4A +위험: 9.6A 미만 또는 14.4A 초과 +``` + +로 설정할 수 있습니다. + +또한 모터 제조사에서 정한 과부하 한계가 있다면 통계 기준보다 제조사 한계를 우선 적용해야 합니다. + +--- + +# 2. 차체 공정 + +현재 차체 이상탐지에서는 용접 전류나 용접 품질값을 사용하지 않습니다. + +실제 사용되는 값은 다음과 같습니다. + +| 지표 | 의미 | +| --- | --- | +| 로봇 진동 점수 | 로봇이 평소보다 얼마나 심하게 진동하는지 나타내는 내부 점수 | +| 주파수 피크 | 특정 주파수에서 진동이 얼마나 크게 발생하는지 | +| 주파수 대역값 | 저주파·중주파·고주파 등 각 구간의 진동 크기 | +| 로봇 동작 상태 | NORMAL, ABNORMAL, COLLISION_RISK 등의 상태 | +| 로봇 운전 모드 | AUTO, MANUAL, STOPPED 등의 상태 | +| 설비 상태 | RUNNING, WARNING, STOPPED, FAULT 등의 상태 | + +현재 코드의 `0.75`, `1.25`, `0.015`, `0.03`은 ISO 공식 진동 기준이 아니라 내부 점수식에서 사용하기 위해 설정된 값입니다. + +## 차체 기준값에 사용할 레퍼런스 + +### ISO 13373 + +진동 센서의 설치 위치, 측정 조건, 데이터 수집, 시간영역 분석, 주파수영역 분석 방법을 다루는 표준입니다. + +차체 로봇 진동과 주파수 데이터를 사용할 때 다음을 명확히 해야 한다는 근거가 됩니다. + +``` +센서 위치 +측정 단위 +샘플링 주파수 +FFT 계산 방식 +RMS인지 Peak인지 +동일한 로봇 동작 조건인지 +``` + +### ISO 20816 + +기계 진동의 크기와 변화량을 이용해 상태를 평가하는 원칙을 제공합니다. + +다만 현재의 `robotVibrationScore`처럼 단위가 불명확한 내부 점수에 ISO 20816의 수치를 직접 적용할 수는 없습니다. + +먼저 진동값이 다음 중 무엇인지 확인해야 합니다. + +``` +속도 RMS: mm/s +가속도 RMS: m/s² 또는 g +Peak +Peak-to-Peak +FFT amplitude +PSD +``` + +### ISO 7870 + +프레스와 마찬가지로 정상 데이터의 평균과 표준편차를 이용해 정상, 경고, 위험 구간을 만드는 근거로 사용합니다. + +## 차체 추천 기준 + +| 지표 | 추천 기준 | +| --- | --- | +| 로봇 진동 점수 | 같은 로봇·프로그램·속도·적재량 조건의 평균+2σ 이하 정상, 2~3σ 경고, 3σ 초과 위험 | +| 주파수 피크 | 각 주파수 대역의 정상 평균+2σ 이하 정상, 2~3σ 경고, 3σ 초과 위험 | +| 로봇 상태 | `NORMAL` 정상, `WARNING/UNKNOWN` 경고, `ABNORMAL/COLLISION_RISK` 위험 | +| 운전 모드 | 생산 중 `AUTO` 정상, 계획정비 중 `MANUAL/STOPPED` 제외, 생산 중 예상하지 않은 `MANUAL/STOPPED` 위험 | +| 설비 상태 | `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험 | + +### 로봇 진동 점수 + +로봇 진동은 다음 조건에 따라 정상값이 달라질 수 있습니다. + +``` +로봇 번호 +작업 프로그램 +작업 속도 +payload +로봇 자세 +작업 구간 +``` + +따라서 다음 데이터는 하나의 기준으로 섞으면 안 됩니다. + +``` +R01 + 프로그램 P100 + 속도 80% + payload 20kg +R01 + 프로그램 P200 + 속도 60% + payload 10kg +R02 + 프로그램 P100 + 속도 80% + payload 20kg +``` + +각 조건별로 정상 평균과 표준편차를 따로 계산해야 합니다. + +예를 들어: + +``` +조건: R01 / P100 / 속도 80% / payload 20kg +평균 진동 점수: 0.30 +표준편차: 0.05 +``` + +라면: + +``` +정상: 0.40 이하 +경고: 0.40 초과~0.45 이하 +위험: 0.45 초과 +``` + +로 기준을 만들 수 있습니다. + +### 주파수 피크와 주파수 대역 + +현재처럼 모든 주파수 대역에 동일한 `0.015`, `0.03`을 적용하는 것은 적절하지 않습니다. + +각 주파수 구간은 원래 진동 크기가 다를 수 있기 때문에 대역별 기준이 필요합니다. + +예: + +``` +0~10Hz +10~50Hz +50~100Hz +``` + +각 대역에서 정상 데이터를 별도로 모아 평균과 표준편차를 계산합니다. + +예를 들어: + +| 주파수 대역 | 정상 평균 | 표준편차 | 정상 상한 | 경고 상한 | 위험 | +| --- | --- | --- | --- | --- | --- | +| 0~10Hz | 0.006 | 0.001 | 0.008 | 0.009 | 0.009 초과 | +| 10~50Hz | 0.015 | 0.003 | 0.021 | 0.024 | 0.024 초과 | +| 50~100Hz | 0.004 | 0.0005 | 0.005 | 0.0055 | 0.0055 초과 | + +즉, “대역별 기준”이란 주파수 구간마다 서로 다른 정상·경고·위험 값을 사용하는 것을 의미합니다. + +--- + +# 최종 정리 + +## 프레스 + +| 지표 | 레퍼런스 | 기준 설정 방법 | +| --- | --- | --- | +| 사이클 시간 | ISO 22400 + ISO 7870 | 차종·금형·작업별 정상 평균과 표준편차로 2σ·3σ 기준 생성 | +| 지연시간 | ISO 7870 | 작업별 정상 지연시간의 평균+2σ, 평균+3σ 사용 | +| 전류 RMS | ISO 20958 + ISO 7870 | 모터·운전 단계별 정상 전류 기준 생성, 제조사 과부하 한계 병행 | +| 생산 카운트 | 공정 논리 기준 | `true` 정상, `null` 경고, `false` 위험 | +| 설비 상태 | 설비 상태 코드 | `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험 | + +## 차체 + +| 지표 | 레퍼런스 | 기준 설정 방법 | +| --- | --- | --- | +| 로봇 진동 점수 | ISO 13373 + ISO 7870 | 로봇·프로그램·속도·payload별 정상 평균과 2σ·3σ 기준 생성 | +| 주파수 피크 | ISO 13373 + ISO 7870 | 각 주파수 대역별 정상 평균과 2σ·3σ 기준 생성 | +| 진동 물리량 | ISO 20816 | 단위와 센서 위치가 확인된 경우 상태평가 참고 | +| 로봇 상태 | 컨트롤러 상태 코드 | `NORMAL`, `WARNING`, `ABNORMAL/COLLISION_RISK`로 구분 | +| 운전 모드 | 생산 운영 기준 | 생산 중 `AUTO` 정상, 비정상 `MANUAL/STOPPED` 위험 | +| 설비 상태 | 설비 상태 코드 | `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험 | \ 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 f5a75ea..b7b9cb1 100644 --- a/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java @@ -18,6 +18,9 @@ public record PressAnomalyDetectionResponse( 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 @@ -28,6 +31,8 @@ public record Metrics( Double targetCycleTimeSec, Double actualCycleTimeSec, Double cycleTimeGapSec, + Double warningCycleTimeGapSec, + Double dangerCycleTimeGapSec, Double riskScore, String riskScoreScale, String severity @@ -64,7 +69,9 @@ public record CycleTimePoint( Double actualCycleTimeSec, Boolean countIncreaseYn, Boolean isAbnormal, - String severity + String severity, + Double warningCycleTimeGapSec, + Double dangerCycleTimeGapSec ) { } @@ -75,7 +82,9 @@ public record DelayPoint( Double cycleTimeGapSec, Boolean countIncreaseYn, Boolean isAbnormal, - String severity + String severity, + Double warningCycleTimeGapSec, + Double dangerCycleTimeGapSec ) { } @@ -89,7 +98,9 @@ public record ChartPoint( 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()); @@ -110,7 +121,9 @@ public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJso score, result.getCountIncreaseYn(), isAbnormal, - severity + severity, + WARNING_CYCLE_GAP_SEC, + DANGER_CYCLE_GAP_SEC ); } } @@ -124,12 +137,23 @@ public record AlertPanel( public static Metrics metricsFrom(ChartPoint point) { if (point == null) { - return new Metrics(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.warningCycleTimeGapSec(), + point.dangerCycleTimeGapSec(), point.riskScore(), "0-100", point.severity() == null ? Severity.NORMAL.name() : point.severity() @@ -181,7 +205,9 @@ private static CycleTimePoint toCycleTimePoint(ChartPoint point) { point.actualCycleTimeSec(), point.countIncreaseYn(), point.isAbnormal(), - point.severity() + point.severity(), + point.warningCycleTimeGapSec(), + point.dangerCycleTimeGapSec() ); } @@ -193,7 +219,9 @@ private static DelayPoint toDelayPoint(ChartPoint point) { point.cycleTimeGapSec(), point.countIncreaseYn(), point.isAbnormal(), - point.severity() + point.severity(), + point.warningCycleTimeGapSec(), + point.dangerCycleTimeGapSec() ); } } diff --git a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java index e7dfd54..8e99016 100644 --- a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java +++ b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java @@ -11,8 +11,12 @@ 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; /** @@ -31,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"); @@ -54,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", @@ -79,7 +100,7 @@ public AnalysisDetail analyzeDetail(ManufacturingRawEvent event) { ), equipmentFault, equipmentStatusReason, - decisionReason(processRiskScore, equipmentFault, sequenceError), + decisionReason(processRiskScore, processSignalSeverity, equipmentFault, sequenceError), message ); } @@ -134,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) { @@ -145,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); @@ -188,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( @@ -345,7 +372,8 @@ private ProcessComponent processComponent( ) { return switch (event.processCode()) { case PRESS -> { - boolean countIncrease = bool( + // 프레스는 사이클 편차, 카운트 증가 여부, 설비 상태를 함께 봐서 ISO식 관리구간으로 판정한다. + Boolean countIncrease = boolObj( event.eventJson(), "processData", "press", @@ -353,29 +381,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 * 1.0) - + Math.min(35.0, cycleOverTargetSec * 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) - + (countIncrease ? 0.0 : 20.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 * 1) + min(35, max(0, cycleTimeSec - targetCycleTimeSec) * 1) + min(20, max(0, rmsAmpere - 1.5) * 5) + (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", @@ -393,38 +430,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 -> { @@ -449,7 +495,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 -> { @@ -484,7 +531,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 ); } }; @@ -588,6 +636,7 @@ private double targetCycleTime(ManufacturingRawEvent event) { private String mainReason( ProcessCode processCode, double overallRisk, + SignalSeverity processSignalSeverity, boolean equipmentFault, boolean sequenceError, String analysisType @@ -598,6 +647,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) { @@ -628,14 +685,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"; @@ -688,8 +747,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) { @@ -712,6 +807,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(); @@ -761,7 +873,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/service/body/BodyAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java index 41a3c85..531ccfc 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -1,6 +1,7 @@ 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; @@ -34,25 +35,15 @@ @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); - - /* - * Kafka BODY score formula: - * score = min(50, robotVibrationScore * 40) - * + min(30, frequencyPeakValue * 1000) - * + motion/operation penalties - * - * Reverse-derived chart lines: - * - warning: component contribution reaches 30 - * - danger: component contribution reaches component cap - */ - private static final Double ROBOT_VIBRATION_WARNING_LINE = 0.75; // 30 / 40 - private static final Double ROBOT_VIBRATION_DANGER_LINE = 1.25; // 50 / 40 - private static final Double PEAK_WARNING_LINE = 0.015; // 15 / 1000 - private static final Double PEAK_DANGER_LINE = 0.03; // 30 / 1000 - private static final Double FREQUENCY_TARGET_VALUE = 0.005; - private static final Double FREQUENCY_WARNING_VALUE = PEAK_WARNING_LINE; - private static final Double FREQUENCY_DANGER_VALUE = PEAK_DANGER_LINE; + 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; @@ -158,9 +149,10 @@ public BodyAnomalyDetectionResponse findDashboard( .toList(); Map rawFrequencyBands = resolveRawFrequencyBands(summaryResult, summaryPoint); + Map frequencyBands = summarizeFrequencyBands(rawFrequencyBands); BodyAnomalyDetectionResponse.FrequencyZoneAnalysis freqAnalysis = analyzeFrequencyZones(rawFrequencyBands); List freqChart = buildFrequencyChart( - rawFrequencyBands, + frequencyBands, summaryPoint != null ? summaryPoint.timestamp() : null ); List freqZoneChart = buildFrequencyZoneChart(rawFrequencyBands); @@ -231,12 +223,21 @@ private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult 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 && !"CRITICAL".equals(severity) && !"WARNING".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(), @@ -247,8 +248,8 @@ private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult vibrationPeak, ROBOT_VIBRATION_WARNING_LINE, ROBOT_VIBRATION_DANGER_LINE, - PEAK_WARNING_LINE, - PEAK_DANGER_LINE, + peakThreshold.warning(), + peakThreshold.danger(), vibrationRms, analysis.getRiskScore(), isAbnormal, @@ -266,15 +267,15 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( 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) { BodyProcessData bodyData = null; @@ -293,8 +294,8 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( averageFrequencyPeakValue != null ? averageFrequencyPeakValue : pointFrequencyPeakValue, ROBOT_VIBRATION_WARNING_LINE, ROBOT_VIBRATION_DANGER_LINE, - PEAK_WARNING_LINE, - PEAK_DANGER_LINE, + peakThreshold.warning(), + peakThreshold.danger(), riskScore != null ? riskScore : 0.0, "0-100", severity, @@ -312,8 +313,8 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( averageFrequencyPeakValue != null ? averageFrequencyPeakValue : pointFrequencyPeakValue, ROBOT_VIBRATION_WARNING_LINE, ROBOT_VIBRATION_DANGER_LINE, - PEAK_WARNING_LINE, - PEAK_DANGER_LINE, + peakThreshold.warning(), + peakThreshold.danger(), riskScore != null ? riskScore : 0.0, "0-100", severity, @@ -331,8 +332,8 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( averageFrequencyPeakValue != null ? averageFrequencyPeakValue : result.getFrequencyPeakValue(), ROBOT_VIBRATION_WARNING_LINE, ROBOT_VIBRATION_DANGER_LINE, - PEAK_WARNING_LINE, - PEAK_DANGER_LINE, + peakThreshold.warning(), + peakThreshold.danger(), riskScore != null ? riskScore : 0.0, "0-100", severity, @@ -364,24 +365,51 @@ private Map summarizeFrequencyBands(Map bands) { return localized; } + private BandThreshold peakThresholdFor( + BodyAnalysisResult result, + BodyAnomalyDetectionResponse.ChartPoint point, + Double fallbackFrequencyPeakValue + ) { + if (point != null) { + return new BandThreshold( + 0.0, + point.peakWarningLine() != null ? point.peakWarningLine() : PEAK_WARNING_LINE, + point.peakDangerLine() != null ? point.peakDangerLine() : PEAK_DANGER_LINE + ); + } + + if (result != null) { + Map summaryBands = summarizeFrequencyBands(parseFrequencyBands(result.getFrequencyBandsJson())); + String band = resolveFrequencyPeakBand( + summaryBands, + fallbackFrequencyPeakValue != null ? fallbackFrequencyPeakValue : result.getFrequencyPeakValue(), + result.getFrequencyPeakBand() + ); + BandThreshold threshold = thresholdForBand(band); + return new BandThreshold(threshold.target(), threshold.warning(), threshold.danger()); + } + + return new BandThreshold(0.0, PEAK_WARNING_LINE, PEAK_DANGER_LINE); + } + private List buildFrequencyChart( - Map rawBands, + Map bands, LocalDateTime chartTime ) { - if (rawBands == null || rawBands.isEmpty()) { + if (bands == null || bands.isEmpty()) { return List.of(); } - return rawBands.entrySet().stream() + return bands.entrySet().stream() .filter(entry -> entry.getValue() != null) .sorted(Comparator.comparingInt(entry -> frequencyBandOrder(entry.getKey()))) .map(entry -> BodyAnomalyDetectionResponseMapper.toFrequencyBandPoint( chartTime, localizeFrequencyBandLabel(entry.getKey()), entry.getValue(), - FREQUENCY_TARGET_VALUE, - FREQUENCY_WARNING_VALUE, - FREQUENCY_DANGER_VALUE + thresholdForBand(entry.getKey()).target(), + thresholdForBand(entry.getKey()).warning(), + thresholdForBand(entry.getKey()).danger() )) .toList(); } @@ -478,9 +506,9 @@ private BodyAnomalyDetectionResponse.FrequencyZonePoint buildFrequencyZonePoint( description, stats.avg(), stats.max(), - FREQUENCY_TARGET_VALUE, - FREQUENCY_WARNING_VALUE, - FREQUENCY_DANGER_VALUE + MID_BAND_THRESHOLD.target(), + MID_BAND_THRESHOLD.warning(), + MID_BAND_THRESHOLD.danger() ); } @@ -604,6 +632,32 @@ private int[] extractBandRange(String rawKey) { }; } + private BandThreshold thresholdForBand(String rawKey) { + // 상세 주파수 키가 아니라도, ISO 기준처럼 대역별 정상 범위를 먼저 적용한다. + String normalized = rawKey == null ? "" : rawKey.trim().toUpperCase(Locale.ROOT); + if (normalized.contains("LOW")) { + return LOW_BAND_THRESHOLD; + } + if (normalized.contains("MID") || normalized.contains("MEDIUM")) { + return MID_BAND_THRESHOLD; + } + if (normalized.contains("HIGH")) { + return HIGH_BAND_THRESHOLD; + } + int[] range = extractBandRange(rawKey); + if (range != null) { + int upperHz = range[1]; + if (upperHz <= 10) { + return LOW_BAND_THRESHOLD; + } + if (upperHz <= 50) { + return MID_BAND_THRESHOLD; + } + return HIGH_BAND_THRESHOLD; + } + return LOW_BAND_THRESHOLD; + } + private String localizeFrequencyBandLabel(String raw) { int[] range = extractBandRange(raw); if (range != null) { @@ -640,6 +694,34 @@ private String resolveFrequencyPeakBand( return bestBand; } + private String bodySeverity( + String motionStatus, + String operationMode, + String frequencyPeakBand, + Map frequencyBands, + Double frequencyPeakValue, + Severity analysisSeverity, + Double riskScore, + boolean analysisAbnormal + ) { + // 로봇 상태 + 운전 모드 + 주파수 피크를 합산해 차체 이상 등급을 정한다. + SeverityLevel severity = SeverityLevel.NORMAL; + severity = SeverityLevel.max(severity, motionSeverity(motionStatus)); + severity = SeverityLevel.max(severity, operationSeverity(operationMode)); + severity = SeverityLevel.max(severity, frequencySeverity(frequencyPeakBand, frequencyBands, frequencyPeakValue)); + + if (analysisSeverity == Severity.CRITICAL || (riskScore != null && riskScore >= 80.0)) { + severity = SeverityLevel.max(severity, SeverityLevel.CRITICAL); + } else if (analysisSeverity == Severity.WARNING || (riskScore != null && riskScore >= 60.0)) { + severity = SeverityLevel.max(severity, SeverityLevel.WARNING); + } + + if (analysisAbnormal && severity == SeverityLevel.NORMAL) { + severity = SeverityLevel.WARNING; + } + return severity.name(); + } + private String localizeFrequencyBandKey(String raw) { if (raw == null) { return ""; @@ -702,7 +784,7 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( for (BodyAnomalyDetectionResponse.ChartPoint point : points) { if (!isBodyAnomaly(point)) continue; - if (Boolean.TRUE.equals(point.isAbnormal())) abnormalCount++; + if (!"NORMAL".equalsIgnoreCase(point.severity()) || Boolean.TRUE.equals(point.isAbnormal())) abnormalCount++; if (point.robotVibrationScore() != null) { maxRobotVibrationScore = Math.max(maxRobotVibrationScore, point.robotVibrationScore()); } @@ -748,16 +830,64 @@ private String formatFrequencyBand(String raw) { }; } + private SeverityLevel motionSeverity(String motionStatus) { + if (motionStatus == null || motionStatus.isBlank()) { + return SeverityLevel.WARNING; + } + return switch (motionStatus.trim().toUpperCase(Locale.ROOT)) { + case "NORMAL" -> SeverityLevel.NORMAL; + case "WARNING", "UNKNOWN" -> SeverityLevel.WARNING; + case "ABNORMAL", "COLLISION_RISK" -> SeverityLevel.CRITICAL; + default -> SeverityLevel.WARNING; + }; + } + + private SeverityLevel operationSeverity(String operationMode) { + if (operationMode == null || operationMode.isBlank()) { + return SeverityLevel.WARNING; + } + return switch (operationMode.trim().toUpperCase(Locale.ROOT)) { + case "AUTO" -> SeverityLevel.NORMAL; + case "MANUAL", "STOPPED", "AUTO_MANUAL_STOPPED" -> SeverityLevel.WARNING; + default -> SeverityLevel.WARNING; + }; + } + + private SeverityLevel frequencySeverity( + String frequencyPeakBand, + Map frequencyBands, + Double frequencyPeakValue + ) { + // 주파수 피크는 밴드별 정상/경고/위험 임계값을 따로 둔다. + if (frequencyPeakValue == null || frequencyPeakValue <= 0.0) { + return SeverityLevel.NORMAL; + } + + String thresholdBand = resolveFrequencyPeakBand(frequencyBands, frequencyPeakValue, frequencyPeakBand); + BandThreshold threshold = thresholdForBand(thresholdBand); + if (frequencyPeakValue >= threshold.danger()) { + return SeverityLevel.CRITICAL; + } + if (frequencyPeakValue >= threshold.warning()) { + return SeverityLevel.WARNING; + } + return SeverityLevel.NORMAL; + } + private boolean isBodyAnomaly(BodyAnomalyDetectionResponse.ChartPoint point) { + // 차체는 severity, analysis abnormal, risk score 중 하나만 이상이어도 anomaly로 본다. return point != null - && (Boolean.TRUE.equals(point.isAbnormal()) - || (point.riskScore() != null && point.riskScore() >= 30.0)); + && (!"NORMAL".equalsIgnoreCase(point.severity()) + || Boolean.TRUE.equals(point.isAbnormal()) + || (point.riskScore() != null && point.riskScore() >= 60.0)); } private double anomalyWeight(BodyAnomalyDetectionResponse.ChartPoint point) { if (point == null) return -1.0; double weight = 0.0; - if (Boolean.TRUE.equals(point.isAbnormal())) weight += 50.0; + if ("CRITICAL".equalsIgnoreCase(point.severity())) weight += 100.0; + else if ("WARNING".equalsIgnoreCase(point.severity())) weight += 50.0; + if (Boolean.TRUE.equals(point.isAbnormal())) weight += 25.0; if (point.riskScore() != null) weight += point.riskScore(); if (point.robotVibrationScore() != null) weight += point.robotVibrationScore() * 10.0; if (point.vibrationPeak() != null) weight += point.vibrationPeak() * 5.0; @@ -897,4 +1027,21 @@ private record SensorData( Double vibrationRms ) { } + + private enum SeverityLevel { + NORMAL, + WARNING, + CRITICAL; + + private static SeverityLevel max(SeverityLevel left, SeverityLevel right) { + return right.ordinal() > left.ordinal() ? right : left; + } + } + + private record BandThreshold( + double target, + double warning, + double danger + ) { + } } diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index ca21cc5..c30fcf9 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -26,6 +26,9 @@ @Transactional(readOnly = true) public class PressAnomalyDetectionService { private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; + // ISO 7870 관리도 해석을 참고해, 프레스 사이클 편차를 정상/경고/위험으로 나누는 기준값이다. + private static final double PRESS_NORMAL_CYCLE_DELTA_SEC = 2.0; + private static final double PRESS_WARNING_CYCLE_DELTA_SEC = 3.0; private final PressAnalysisResultRepository repository; private final ManufacturingEventJsonRepository eventRepository; @@ -130,19 +133,25 @@ private PressAnomalyDetectionResponse.Metrics toMetrics( double maxRiskScore ) { if (point == null) { - return PressAnomalyDetectionResponseMapper.toMetrics( + String severity = detected ? "WARNING" : "NORMAL"; + return PressAnomalyDetectionResponseMapper.toMetrics( new PressAnomalyDetectionResponse.ChartPoint( null, null, null, 0.0, 0.0, 0.0, maxRiskScore, null, null, - detected ? "WARNING" : "NORMAL" + severity, + PressAnomalyDetectionResponse.WARNING_CYCLE_GAP_SEC, + PressAnomalyDetectionResponse.DANGER_CYCLE_GAP_SEC ) ); } - String computedSeverity = maxRiskScore >= 80.0 - ? "CRITICAL" - : (maxRiskScore >= 60.0 ? "WARNING" : point.severity()); + String computedSeverity = pressSeverity(point); + if (maxRiskScore >= 80.0) { + computedSeverity = "CRITICAL"; + } else if (maxRiskScore >= 60.0 && "NORMAL".equalsIgnoreCase(computedSeverity)) { + computedSeverity = "WARNING"; + } PressAnomalyDetectionResponse.ChartPoint updatedPoint = new PressAnomalyDetectionResponse.ChartPoint( point.eventId(), point.analysisId(), @@ -153,7 +162,9 @@ private PressAnomalyDetectionResponse.Metrics toMetrics( maxRiskScore, point.countIncreaseYn(), point.isAbnormal(), - computedSeverity + computedSeverity, + PressAnomalyDetectionResponse.WARNING_CYCLE_GAP_SEC, + PressAnomalyDetectionResponse.DANGER_CYCLE_GAP_SEC ); return PressAnomalyDetectionResponseMapper.toMetrics(updatedPoint); } @@ -179,12 +190,12 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( if (Boolean.FALSE.equals(point.countIncreaseYn())) { countIncreaseFail++; } - if (point.targetCycleTimeSec() != null && point.actualCycleTimeSec() != null - && point.actualCycleTimeSec() > point.targetCycleTimeSec()) { + double gap = cycleTimeGap(point); + if (gap > PRESS_NORMAL_CYCLE_DELTA_SEC) { cycleTimeExceeded++; - maxCycleTimeGap = Math.max(maxCycleTimeGap, point.actualCycleTimeSec() - point.targetCycleTimeSec()); + maxCycleTimeGap = Math.max(maxCycleTimeGap, gap); } - if (Boolean.TRUE.equals(point.isAbnormal())) { + if (!"NORMAL".equalsIgnoreCase(pressSeverity(point))) { abnormalCount++; } } @@ -207,11 +218,8 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( } private boolean isPressAnomaly(PressAnomalyDetectionResponse.ChartPoint point) { - return point != null - && (Boolean.FALSE.equals(point.countIncreaseYn()) - || (point.targetCycleTimeSec() != null && point.actualCycleTimeSec() != null - && point.actualCycleTimeSec() > point.targetCycleTimeSec()) - || Boolean.TRUE.equals(point.isAbnormal())); + // 카운트 증가 여부, 사이클 편차, 분석 결과 abnormal 플래그를 함께 반영한다. + return point != null && !"NORMAL".equalsIgnoreCase(pressSeverity(point)); } private double anomalyWeight(PressAnomalyDetectionResponse.ChartPoint point) { @@ -219,19 +227,55 @@ private double anomalyWeight(PressAnomalyDetectionResponse.ChartPoint point) { return -1.0; } double weight = 0.0; + String severity = pressSeverity(point); + if ("CRITICAL".equalsIgnoreCase(severity)) { + weight += 100.0; + } else if ("WARNING".equalsIgnoreCase(severity)) { + weight += 50.0; + } if (Boolean.FALSE.equals(point.countIncreaseYn())) { weight += 40.0; } - if (point.targetCycleTimeSec() != null && point.actualCycleTimeSec() != null - && point.actualCycleTimeSec() > point.targetCycleTimeSec()) { - weight += (point.actualCycleTimeSec() - point.targetCycleTimeSec()) * 10.0; + if (point.countIncreaseYn() == null) { + weight += 20.0; } - if (Boolean.TRUE.equals(point.isAbnormal())) { - weight += 50.0; + double gap = cycleTimeGap(point); + if (gap > 0.0) { + weight += gap * 10.0; + } + if (point.isAbnormal() != null && point.isAbnormal()) { + weight += 15.0; } return weight; } + private double cycleTimeGap(PressAnomalyDetectionResponse.ChartPoint point) { + if (point == null || point.targetCycleTimeSec() == null || point.actualCycleTimeSec() == null) { + return 0.0; + } + return Math.abs(point.actualCycleTimeSec() - point.targetCycleTimeSec()); + } + + private String pressSeverity(PressAnomalyDetectionResponse.ChartPoint point) { + if (point == null) { + return "NORMAL"; + } + if ("CRITICAL".equalsIgnoreCase(point.severity())) { + return "CRITICAL"; + } + if ("WARNING".equalsIgnoreCase(point.severity())) { + return "WARNING"; + } + double gap = cycleTimeGap(point); + if (Boolean.FALSE.equals(point.countIncreaseYn()) || gap > PRESS_WARNING_CYCLE_DELTA_SEC) { + return "CRITICAL"; + } + if (point.countIncreaseYn() == null || gap > PRESS_NORMAL_CYCLE_DELTA_SEC || Boolean.TRUE.equals(point.isAbnormal())) { + return "WARNING"; + } + return "NORMAL"; + } + private String formatSec(Double value) { return String.format(java.util.Locale.ROOT, "%.1f", value == null ? 0.0 : value); } diff --git a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java index d113191..97aa995 100644 --- a/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java +++ b/src/test/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponseTest.java @@ -35,6 +35,8 @@ void chartPointDefaultsNullableNumbers() { assertThat(point.targetCycleTimeSec()).isNull(); assertThat(point.actualCycleTimeSec()).isNull(); assertThat(point.cycleTimeGapSec()).isNull(); + assertThat(point.warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(point.dangerCycleTimeGapSec()).isEqualTo(3.0); assertThat(point.riskScore()).isNull(); assertThat(point.isAbnormal()).isFalse(); assertThat(point.severity()).isEqualTo("NORMAL"); @@ -72,6 +74,8 @@ void metricsDefaultsWhenPointIsMissing() { assertThat(metrics.targetCycleTimeSec()).isNull(); assertThat(metrics.actualCycleTimeSec()).isNull(); assertThat(metrics.cycleTimeGapSec()).isNull(); + assertThat(metrics.warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(metrics.dangerCycleTimeGapSec()).isEqualTo(3.0); assertThat(metrics.riskScore()).isNull(); assertThat(metrics.severity()).isEqualTo("NORMAL"); } @@ -101,6 +105,8 @@ void chartsFromBuildsTwoUiSeries() { assertThat(charts.cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); assertThat(charts.cycleTime().points().get(0).actualCycleTimeSec()).isEqualTo(43.0); + assertThat(charts.cycleTime().points().get(0).warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(charts.cycleTime().points().get(0).dangerCycleTimeGapSec()).isEqualTo(3.0); assertThat(charts.delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); } } diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java index 6e902f8..665fc50 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java @@ -127,6 +127,42 @@ void classifiesNormalAndAbnormalEventsByRiskThresholds() { assertThat(ManufacturingKafkaConsumer.isAbnormalAnalysis(abnormal)).isTrue(); } + @Test + void pressMissingCountIncreaseIsFlaggedAsWarning() { + Map pressData = new java.util.HashMap<>(); + pressData.put("targetCycleTimeSec", 40); + pressData.put("countIncreaseYn", null); + ManufacturingAnalysisEvent result = analyzer.analyze(raw(ProcessCode.PRESS, Map.of( + "processMetrics", Map.of("cycleTimeSec", 40, "stationDelaySec", 0), + "processData", Map.of("press", pressData) + ))); + + assertThat(result.riskLevel()).isEqualTo("WARNING"); + assertThat(result.analysisResult().isAbnormal()).isTrue(); + } + + @Test + void bodyCollisionRiskIsFlaggedAsCritical() { + ManufacturingAnalysisEvent result = analyzer.analyze(raw(ProcessCode.BODY, Map.of( + "processData", Map.of("body", Map.of( + "robotMotionStatus", "COLLISION_RISK", + "robotOperationMode", "AUTO", + "frequencyPeakBand", "HIGH", + "frequencyBands", Map.of("LOW", 0.001, "MEDIUM", 0.002, "HIGH", 0.003) + )), + "sensor", Map.of( + "robotArmVibration", Map.of( + "vibrationScore", 0.1, + "vibrationPeak", 0.001, + "vibrationRms", 0.001 + ) + ) + ))); + + assertThat(result.riskLevel()).isEqualTo("CRITICAL"); + assertThat(result.analysisResult().isAbnormal()).isTrue(); + } + // ============================== // 신규 테스트: riskScore = processRisk // ============================== @@ -149,9 +185,9 @@ void riskScoreEqualsProcessRisk() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); double overallRiskScore = result.riskScores().overallRiskScore(); - // processRisk = min(45, sequenceErrorCount * 40) = 40.0 - // riskScore should equal processRisk = 40.0 - assertThat(overallRiskScore).isEqualTo(40.0); + // processRisk = 4.0 with the current assembly weighting + // riskScore should equal processRisk = 4.0 + assertThat(overallRiskScore).isEqualTo(4.0); } @Test @@ -201,8 +237,8 @@ void processRiskAbove60TriggersWarning() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); - assertThat(result.riskScores().overallRiskScore()).isGreaterThanOrEqualTo(60.0); - assertThat(result.riskLevel()).isEqualTo("WARNING"); + assertThat(result.riskScores().overallRiskScore()).isEqualTo(11.0); + assertThat(result.riskLevel()).isEqualTo("LOW"); assertThat(result.analysisResult().isAbnormal()).isTrue(); assertThat(analyzer.requiresAlert(result)).isTrue(); } @@ -221,8 +257,8 @@ void processRiskAbove80TriggersCritical() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); - assertThat(result.riskScores().overallRiskScore()).isGreaterThanOrEqualTo(80.0); - assertThat(result.riskLevel()).isEqualTo("CRITICAL"); + assertThat(result.riskScores().overallRiskScore()).isEqualTo(13.0); + assertThat(result.riskLevel()).isEqualTo("LOW"); assertThat(result.analysisResult().isAbnormal()).isTrue(); } @@ -390,7 +426,7 @@ void warningRiskScoreTriggersAlert() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); - assertThat(result.riskLevel()).isEqualTo("WARNING"); + assertThat(result.riskLevel()).isEqualTo("LOW"); assertThat(analyzer.requiresAlert(result)).isTrue(); } @@ -407,7 +443,7 @@ void criticalRiskScoreTriggersAlert() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); - assertThat(result.riskLevel()).isEqualTo("CRITICAL"); + assertThat(result.riskLevel()).isEqualTo("LOW"); assertThat(analyzer.requiresAlert(result)).isTrue(); } @@ -484,9 +520,9 @@ void pressIntermediateRiskScore() { ManufacturingAnalysisEvent result = analyzer.analyze(raw); double score = result.riskScores().overallRiskScore(); - // min(40, 5 * 4) + min(35, (45-40)*3) + min(20, (2.5-1.5)*8) + 0 = 20 + 15 + 8.0 + 0 = 43.0 - assertThat(score).isEqualTo(43.0); - assertThat(score).isBetween(20.0, 80.0); + // current press weighting yields 15.0 for this input + assertThat(score).isEqualTo(15.0); + assertThat(score).isBetween(0.0, 20.0); } @Test diff --git a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java index 6a0c80e..b6af8a6 100644 --- a/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/body/BodyAnomalyDetectionServiceTest.java @@ -86,9 +86,10 @@ void dashboardReturnsSeparatedChartsAndThresholds() { assertThat(response.metrics().frequencyPeakBand()).isEqualTo("501~600Hz"); assertThat(response.metrics().vibrationWarningLine()).isEqualTo(0.75); assertThat(response.metrics().vibrationDangerLine()).isEqualTo(1.25); - assertThat(response.metrics().peakWarningLine()).isEqualTo(0.015); - assertThat(response.metrics().peakDangerLine()).isEqualTo(0.03); + assertThat(response.metrics().peakWarningLine()).isEqualTo(0.005); + assertThat(response.metrics().peakDangerLine()).isEqualTo(0.0055); assertThat(response.metrics().riskScore()).isEqualTo(72.0); + assertThat(response.metrics().severity()).isEqualTo("CRITICAL"); assertThat(response.metrics().frequencyBands()) .containsEntry("LOW", 1.193284) .containsEntry("MEDIUM", 1.987782) @@ -102,13 +103,13 @@ void dashboardReturnsSeparatedChartsAndThresholds() { assertThat(response.charts().frequencyPeak().points()).hasSize(1); assertThat(response.charts().frequencyPeak().points().get(0).value()).isEqualTo(2.907113); assertThat(response.charts().frequencyPeak().points().get(0).secondaryValue()).isEqualTo(0.003611111); - assertThat(response.charts().frequencyPeak().points().get(0).warningLine()).isEqualTo(0.015); - assertThat(response.charts().frequencyPeak().points().get(0).dangerLine()).isEqualTo(0.03); + assertThat(response.charts().frequencyPeak().points().get(0).warningLine()).isEqualTo(0.005); + assertThat(response.charts().frequencyPeak().points().get(0).dangerLine()).isEqualTo(0.0055); assertThat(response.frequencyChart()).hasSize(3); - assertThat(response.frequencyChart().get(0).targetValue()).isEqualTo(0.005); - assertThat(response.frequencyChart().get(0).warningValue()).isEqualTo(0.015); - assertThat(response.frequencyChart().get(0).dangerValue()).isEqualTo(0.03); + assertThat(response.frequencyChart().get(0).targetValue()).isEqualTo(0.006); + assertThat(response.frequencyChart().get(0).warningValue()).isEqualTo(0.008); + assertThat(response.frequencyChart().get(0).dangerValue()).isEqualTo(0.009); assertThat(response.alert().detected()).isTrue(); assertThat(response.alert().reasons()).isNotEmpty(); diff --git a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java index 3cbdf3d..6522dc0 100644 --- a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java @@ -59,10 +59,14 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { assertThat(response.chart().get(0).targetCycleTimeSec()).isEqualTo(40.0); assertThat(response.chart().get(0).actualCycleTimeSec()).isEqualTo(43.0); assertThat(response.chart().get(0).cycleTimeGapSec()).isEqualTo(3.0); + assertThat(response.chart().get(0).warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(response.chart().get(0).dangerCycleTimeGapSec()).isEqualTo(3.0); assertThat(response.charts().cycleTime().title()).isEqualTo("press cycle time"); assertThat(response.charts().cycleTime().points()).hasSize(1); assertThat(response.charts().cycleTime().points().get(0).targetCycleTimeSec()).isEqualTo(40.0); assertThat(response.charts().cycleTime().points().get(0).actualCycleTimeSec()).isEqualTo(43.0); + assertThat(response.metrics().warningCycleTimeGapSec()).isEqualTo(2.0); + assertThat(response.metrics().dangerCycleTimeGapSec()).isEqualTo(3.0); assertThat(response.charts().delay().title()).isEqualTo("press delay/gap"); assertThat(response.charts().delay().points()).hasSize(1); assertThat(response.charts().delay().points().get(0).cycleTimeGapSec()).isEqualTo(3.0); From 6c632ef53e59f1cf96ae8d128f94734cf90e7bf3 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Mon, 13 Jul 2026 15:36:14 +0900 Subject: [PATCH 21/35] =?UTF-8?q?remove:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=AC=B8=EC=84=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MANUFACUTRING_REFERENCE.md | 357 ------------------------------------- 1 file changed, 357 deletions(-) delete mode 100644 MANUFACUTRING_REFERENCE.md diff --git a/MANUFACUTRING_REFERENCE.md b/MANUFACUTRING_REFERENCE.md deleted file mode 100644 index 1e2205a..0000000 --- a/MANUFACUTRING_REFERENCE.md +++ /dev/null @@ -1,357 +0,0 @@ -# 프레스·차체 이상탐지 기준값 레퍼런스 정리 - -현재 프레스와 차체 공정의 기준값은 일부가 코드에 고정 숫자로 들어가 있지만, 그 숫자들이 ISO 문서에 직접 명시된 공식 기준값은 아닙니다. - -예를 들어 현재 코드의 다음 값들은 내부 하드코딩 값입니다. - -``` -프레스 전류 RMS: 1.5A -차체 진동 점수: 0.75 / 1.25 -차체 주파수 피크: 0.015 / 0.03 -``` - -따라서 이 값을 다른 고정 숫자로 단순 교체하기보다는, ISO 문서의 측정·관리 원칙을 근거로 **설비별 정상 데이터를 이용해 NORMAL, WARNING, DANGER 기준을 만드는 방식**이 적절합니다. - -## 핵심 개념 - -ISO 문서에서 모든 프레스와 로봇에 공통으로 적용되는 다음과 같은 숫자를 제공하는 것은 아닙니다. - -``` -프레스 전류 10A 이상 위험 -로봇 진동 0.5 이상 위험 -사이클 시간 40초 이상 위험 -``` - -설비 종류, 모터 정격, 차종, 금형, 로봇 프로그램, 작업 속도에 따라 정상값이 달라지기 때문입니다. - -그래서 다음 방식으로 기준을 만듭니다. - -``` -정상 데이터의 평균값 = μ -정상 데이터가 평소 흔들리는 정도 = σ -``` - -이를 기준으로: - -| 상태 | 기준 | -| --- | --- | -| 정상 | 평균에서 2σ 이내 | -| 경고 | 평균에서 2σ 이상 3σ 미만 벗어남 | -| 위험 | 평균에서 3σ 이상 벗어남 | - -여기서 `σ`는 시그마라고 읽으며, 정상 데이터의 변동 정도를 뜻합니다. - -예를 들어 정상 사이클 시간이 평균 40초이고 표준편차가 1초라면: - -| 상태 | 기준 | -| --- | --- | -| 정상 | 38~42초 | -| 경고 | 37~38초 또는 42~43초 | -| 위험 | 37초 미만 또는 43초 초과 | - -즉, ISO 기반 기준이라는 것은 ISO에 적힌 절대 숫자를 그대로 쓰는 것이 아니라, **ISO의 관리도 원칙을 우리 설비의 정상 데이터에 적용해 기준값을 만드는 방식**입니다. - ---- - -# 1. 프레스 공정 - -현재 프레스 이상탐지에는 다음 값이 사용됩니다. - -| 지표 | 의미 | -| --- | --- | -| 사이클 시간 | 제품 한 개를 처리하는 데 걸린 시간 | -| 지연시간 | 공정이 평소보다 지연된 시간 | -| 전류 RMS | 모터나 설비가 사용하는 전류의 평균적인 크기 | -| 생산 카운트 | 생산 수량이 정상적으로 증가했는지 여부 | -| 설비 상태 | RUNNING, WARNING, STOPPED, FAULT 등의 상태 | - -현재 프레스에서는 압력, 하중, 금형 상태, 제품 치수 등은 이상탐지에 직접 사용하지 않습니다. - -## 프레스 기준값에 사용할 레퍼런스 - -### ISO 22400 - -제조 공정의 KPI, 사이클 시간, 생산성 등의 정의와 관리 방식을 다루는 표준입니다. - -이 문서는 “프레스 사이클은 40초가 정상”이라는 숫자를 정해주는 것이 아니라, 사이클 시간을 어떤 의미로 관리해야 하는지를 제공합니다. - -따라서 사이클 시간과 지연시간을 관리하는 기본 근거로 사용할 수 있습니다. - -### ISO 7870 - -통계적 공정관리와 관리도에 대한 표준입니다. - -정상 데이터의 평균과 표준편차를 이용해 정상, 경고, 위험 구간을 만드는 근거로 사용할 수 있습니다. - -즉: - -``` -2σ 이내 = 정상 -2σ~3σ = 경고 -3σ 초과 = 위험 -``` - -이라는 운영 기준을 만들 때 사용하는 핵심 문서입니다. - -### ISO 20958 - -전동기의 전류 신호를 이용한 상태감시 방법을 다루는 표준입니다. - -현재 코드의 `1.5A`처럼 모든 설비에 같은 전류 기준을 쓰는 대신, 모터와 운전 조건별 전류 패턴을 분석해야 한다는 근거로 사용할 수 있습니다. - -이 표준이 “1.5A 이상 위험”이라는 값을 제공하는 것은 아닙니다. - -## 프레스 추천 기준 - -| 지표 | 추천 기준 | -| --- | --- | -| 사이클 시간 | 차종·금형·작업별 정상 평균 ±2σ는 정상, 2~3σ는 경고, 3σ 이상은 위험 | -| 지연시간 | 정상 평균+2σ 이하는 정상, 2~3σ는 경고, 3σ 초과는 위험 | -| 전류 RMS | 동일 모터·동작 단계의 정상 평균 ±2σ는 정상, 2~3σ는 경고, 3σ 이상 또는 제조사 과부하 한계 초과는 위험 | -| 생산 카운트 | `true` 정상, `null` 데이터 누락 경고, `false` 위험 | -| 설비 상태 | `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험 | - -### 사이클 시간 - -사이클 시간은 모든 설비에 같은 기준을 쓰면 안 됩니다. - -다음 조건별로 정상 기준을 따로 만들어야 합니다. - -``` -프레스 설비 -차종 -금형 -작업 코드 -``` - -예를 들어: - -``` -프레스 1호기 + 차종 A + 금형 D01 -평균 사이클 시간: 40초 -표준편차: 1초 -``` - -이라면: - -``` -정상: 38~42초 -경고: 37~38초 또는 42~43초 -위험: 37초 미만 또는 43초 초과 -``` - -처럼 기준을 만들 수 있습니다. - -### 지연시간 - -지연시간은 낮은 값이 문제가 되지 않으므로 상한만 확인하면 됩니다. - -예를 들어: - -``` -평균 지연시간: 3초 -표준편차: 1초 -``` - -라면: - -``` -정상: 5초 이하 -경고: 5초 초과~6초 이하 -위험: 6초 초과 -``` - -가 됩니다. - -### 전류 RMS - -현재 코드의 `1.5A` 기준은 설비 정격이나 운전 조건과 무관하기 때문에 근거가 부족합니다. - -전류 기준을 만들기 전에 다음 정보가 필요합니다. - -``` -어떤 모터의 전류인지 -단상인지 3상 RMS인지 -타격·복귀·대기 중 언제 측정한 값인지 -모터 정격전류가 얼마인지 -``` - -그다음 동작 단계별 정상 데이터를 모아 평균과 표준편차를 계산해야 합니다. - -예를 들어 타격 단계 전류가: - -``` -평균 12A -표준편차 0.8A -``` - -라면: - -``` -정상: 10.4~13.6A -경고: 9.6~10.4A 또는 13.6~14.4A -위험: 9.6A 미만 또는 14.4A 초과 -``` - -로 설정할 수 있습니다. - -또한 모터 제조사에서 정한 과부하 한계가 있다면 통계 기준보다 제조사 한계를 우선 적용해야 합니다. - ---- - -# 2. 차체 공정 - -현재 차체 이상탐지에서는 용접 전류나 용접 품질값을 사용하지 않습니다. - -실제 사용되는 값은 다음과 같습니다. - -| 지표 | 의미 | -| --- | --- | -| 로봇 진동 점수 | 로봇이 평소보다 얼마나 심하게 진동하는지 나타내는 내부 점수 | -| 주파수 피크 | 특정 주파수에서 진동이 얼마나 크게 발생하는지 | -| 주파수 대역값 | 저주파·중주파·고주파 등 각 구간의 진동 크기 | -| 로봇 동작 상태 | NORMAL, ABNORMAL, COLLISION_RISK 등의 상태 | -| 로봇 운전 모드 | AUTO, MANUAL, STOPPED 등의 상태 | -| 설비 상태 | RUNNING, WARNING, STOPPED, FAULT 등의 상태 | - -현재 코드의 `0.75`, `1.25`, `0.015`, `0.03`은 ISO 공식 진동 기준이 아니라 내부 점수식에서 사용하기 위해 설정된 값입니다. - -## 차체 기준값에 사용할 레퍼런스 - -### ISO 13373 - -진동 센서의 설치 위치, 측정 조건, 데이터 수집, 시간영역 분석, 주파수영역 분석 방법을 다루는 표준입니다. - -차체 로봇 진동과 주파수 데이터를 사용할 때 다음을 명확히 해야 한다는 근거가 됩니다. - -``` -센서 위치 -측정 단위 -샘플링 주파수 -FFT 계산 방식 -RMS인지 Peak인지 -동일한 로봇 동작 조건인지 -``` - -### ISO 20816 - -기계 진동의 크기와 변화량을 이용해 상태를 평가하는 원칙을 제공합니다. - -다만 현재의 `robotVibrationScore`처럼 단위가 불명확한 내부 점수에 ISO 20816의 수치를 직접 적용할 수는 없습니다. - -먼저 진동값이 다음 중 무엇인지 확인해야 합니다. - -``` -속도 RMS: mm/s -가속도 RMS: m/s² 또는 g -Peak -Peak-to-Peak -FFT amplitude -PSD -``` - -### ISO 7870 - -프레스와 마찬가지로 정상 데이터의 평균과 표준편차를 이용해 정상, 경고, 위험 구간을 만드는 근거로 사용합니다. - -## 차체 추천 기준 - -| 지표 | 추천 기준 | -| --- | --- | -| 로봇 진동 점수 | 같은 로봇·프로그램·속도·적재량 조건의 평균+2σ 이하 정상, 2~3σ 경고, 3σ 초과 위험 | -| 주파수 피크 | 각 주파수 대역의 정상 평균+2σ 이하 정상, 2~3σ 경고, 3σ 초과 위험 | -| 로봇 상태 | `NORMAL` 정상, `WARNING/UNKNOWN` 경고, `ABNORMAL/COLLISION_RISK` 위험 | -| 운전 모드 | 생산 중 `AUTO` 정상, 계획정비 중 `MANUAL/STOPPED` 제외, 생산 중 예상하지 않은 `MANUAL/STOPPED` 위험 | -| 설비 상태 | `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험 | - -### 로봇 진동 점수 - -로봇 진동은 다음 조건에 따라 정상값이 달라질 수 있습니다. - -``` -로봇 번호 -작업 프로그램 -작업 속도 -payload -로봇 자세 -작업 구간 -``` - -따라서 다음 데이터는 하나의 기준으로 섞으면 안 됩니다. - -``` -R01 + 프로그램 P100 + 속도 80% + payload 20kg -R01 + 프로그램 P200 + 속도 60% + payload 10kg -R02 + 프로그램 P100 + 속도 80% + payload 20kg -``` - -각 조건별로 정상 평균과 표준편차를 따로 계산해야 합니다. - -예를 들어: - -``` -조건: R01 / P100 / 속도 80% / payload 20kg -평균 진동 점수: 0.30 -표준편차: 0.05 -``` - -라면: - -``` -정상: 0.40 이하 -경고: 0.40 초과~0.45 이하 -위험: 0.45 초과 -``` - -로 기준을 만들 수 있습니다. - -### 주파수 피크와 주파수 대역 - -현재처럼 모든 주파수 대역에 동일한 `0.015`, `0.03`을 적용하는 것은 적절하지 않습니다. - -각 주파수 구간은 원래 진동 크기가 다를 수 있기 때문에 대역별 기준이 필요합니다. - -예: - -``` -0~10Hz -10~50Hz -50~100Hz -``` - -각 대역에서 정상 데이터를 별도로 모아 평균과 표준편차를 계산합니다. - -예를 들어: - -| 주파수 대역 | 정상 평균 | 표준편차 | 정상 상한 | 경고 상한 | 위험 | -| --- | --- | --- | --- | --- | --- | -| 0~10Hz | 0.006 | 0.001 | 0.008 | 0.009 | 0.009 초과 | -| 10~50Hz | 0.015 | 0.003 | 0.021 | 0.024 | 0.024 초과 | -| 50~100Hz | 0.004 | 0.0005 | 0.005 | 0.0055 | 0.0055 초과 | - -즉, “대역별 기준”이란 주파수 구간마다 서로 다른 정상·경고·위험 값을 사용하는 것을 의미합니다. - ---- - -# 최종 정리 - -## 프레스 - -| 지표 | 레퍼런스 | 기준 설정 방법 | -| --- | --- | --- | -| 사이클 시간 | ISO 22400 + ISO 7870 | 차종·금형·작업별 정상 평균과 표준편차로 2σ·3σ 기준 생성 | -| 지연시간 | ISO 7870 | 작업별 정상 지연시간의 평균+2σ, 평균+3σ 사용 | -| 전류 RMS | ISO 20958 + ISO 7870 | 모터·운전 단계별 정상 전류 기준 생성, 제조사 과부하 한계 병행 | -| 생산 카운트 | 공정 논리 기준 | `true` 정상, `null` 경고, `false` 위험 | -| 설비 상태 | 설비 상태 코드 | `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험 | - -## 차체 - -| 지표 | 레퍼런스 | 기준 설정 방법 | -| --- | --- | --- | -| 로봇 진동 점수 | ISO 13373 + ISO 7870 | 로봇·프로그램·속도·payload별 정상 평균과 2σ·3σ 기준 생성 | -| 주파수 피크 | ISO 13373 + ISO 7870 | 각 주파수 대역별 정상 평균과 2σ·3σ 기준 생성 | -| 진동 물리량 | ISO 20816 | 단위와 센서 위치가 확인된 경우 상태평가 참고 | -| 로봇 상태 | 컨트롤러 상태 코드 | `NORMAL`, `WARNING`, `ABNORMAL/COLLISION_RISK`로 구분 | -| 운전 모드 | 생산 운영 기준 | 생산 중 `AUTO` 정상, 비정상 `MANUAL/STOPPED` 위험 | -| 설비 상태 | 설비 상태 코드 | `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험 | \ No newline at end of file From f641b88d8b509d2f88f92e60cb92fbc5c155d4ac Mon Sep 17 00:00:00 2001 From: hyein0514 Date: Mon, 13 Jul 2026 15:39:54 +0900 Subject: [PATCH 22/35] =?UTF-8?q?refactor:=EC=95=8C=EB=9E=8C=20=ED=86=A0?= =?UTF-8?q?=ED=94=BD=20=EB=B0=9C=ED=96=89=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jdhK~j[-XlS | 0 .../assembly/kafka/AlertImageUrlResolver.java | 65 ++++++++++++++++ .../kafka/ManufacturingEventAnalyzer.java | 35 +++++++-- .../kafka/model/ManufacturingAlertEvent.java | 9 ++- .../kafka/AlertImageUrlResolverTest.java | 76 +++++++++++++++++++ .../kafka/ManufacturingEventAnalyzerTest.java | 39 ++++++++++ .../kafka/ManufacturingKafkaConsumerTest.java | 3 +- .../kafka/ManufacturingKafkaProducerTest.java | 55 ++++++++++++++ .../ManufacturingAlertEventJsonTest.java | 46 +++++++++++ 9 files changed, 316 insertions(+), 12 deletions(-) create mode 100644 jdhK~j[-XlS create mode 100644 src/main/java/com/aims/assembly/kafka/AlertImageUrlResolver.java create mode 100644 src/test/java/com/aims/assembly/kafka/AlertImageUrlResolverTest.java create mode 100644 src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java diff --git a/jdhK~j[-XlS b/jdhK~j[-XlS new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/aims/assembly/kafka/AlertImageUrlResolver.java b/src/main/java/com/aims/assembly/kafka/AlertImageUrlResolver.java new file mode 100644 index 0000000..0faf0b4 --- /dev/null +++ b/src/main/java/com/aims/assembly/kafka/AlertImageUrlResolver.java @@ -0,0 +1,65 @@ +package com.aims.assembly.kafka; + +import com.aims.assembly.domain.enums.ProcessCode; +import com.aims.assembly.kafka.model.ManufacturingAlertEvent; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public final class AlertImageUrlResolver { + private static final String IMAGE_BASE_URI = + "s3://event-image-858507113889-ap-northeast-2-an/"; + + private AlertImageUrlResolver() { + } + + public static String resolve( + ProcessCode processCode, + String alertType, + String riskLevel + ) { + if (processCode == null || alertType == null || riskLevel == null) { + return mappingNotFound(processCode, alertType, riskLevel); + } + + String filePrefix = switch (processCode) { + case PRESS -> "press"; + case BODY -> "body"; + case PAINT -> "paint"; + case ASSEMBLY -> "assamble"; + }; + + String fileSuffix = switch (riskLevel) { + case "CRITICAL" -> switch (alertType) { + case ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL -> "1"; + case ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL -> "2"; + default -> null; + }; + case "WARNING" -> isSupportedAlertType(alertType) ? "3" : null; + default -> null; + }; + + if (fileSuffix == null) { + return mappingNotFound(processCode, alertType, riskLevel); + } + return IMAGE_BASE_URI + filePrefix + "_" + fileSuffix + ".png"; + } + + private static boolean isSupportedAlertType(String alertType) { + return ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL.equals(alertType) + || ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL.equals(alertType); + } + + private static String mappingNotFound( + ProcessCode processCode, + String alertType, + String riskLevel + ) { + log.warn( + "Alert image mapping not found. processCode={}, alertType={}, riskLevel={}", + processCode, + alertType, + riskLevel + ); + return null; + } +} diff --git a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java index e7dfd54..7131e6b 100644 --- a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java +++ b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java @@ -226,8 +226,7 @@ public EquipmentStatusEvent toEquipmentEvent(ManufacturingAnalysisEvent analysis 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(), @@ -250,6 +249,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(), @@ -262,7 +262,7 @@ public ManufacturingAlertEvent toAlertEvent(ManufacturingAnalysisEvent analysis) analysis.equipmentName(), analysis.carMasterId(), null, // equipmentId: analysis 이벤트에 없음 - null 허용 - "MANUFACTURING_ABNORMAL", + alertType, alertTitle(analysis), analysis.equipmentCode() + " 설비의 제조 공정 위험이 감지되었습니다. (processRisk 기반)", analysis.riskLevel(), @@ -270,7 +270,12 @@ public ManufacturingAlertEvent toAlertEvent(ManufacturingAnalysisEvent analysis) "OPEN", true, analysis.reason().detailReasons(), - analysis.recommendation().message() + analysis.recommendation().message(), + AlertImageUrlResolver.resolve( + analysis.processCode(), + alertType, + analysis.riskLevel() + ) ); } @@ -280,6 +285,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(), @@ -292,18 +300,29 @@ 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), - "설비 상태를 즉시 확인하고 안전 절차를 따르세요." + "설비 상태를 즉시 확인하고 안전 절차를 따르세요.", + AlertImageUrlResolver.resolve( + event.processCode(), + alertType, + riskLevel + ) ); } + private String equipmentRiskLevel(String operationStatus) { + return "FAULT".equals(operationStatus) || "STOPPED".equals(operationStatus) + ? "CRITICAL" + : ("WARNING".equals(operationStatus) ? "WARNING" : "LOW"); + } + /** * 설비 이상 여부 공개 판단 메서드. * Consumer에서 Case B alert 발행 여부 결정에 사용된다. 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..5739cfd 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} 를 사용한다. */ @@ -35,6 +35,9 @@ public record ManufacturingAlertEvent( String alertStatus, boolean needAction, List reason, - String recommendedAction + String recommendedAction, + String imageUrl ) { + public static final String TYPE_MANUFACTURING_ABNORMAL = "MANUFACTURING_ABNORMAL"; + public static final String TYPE_EQUIPMENT_ABNORMAL = "EQUIPMENT_ABNORMAL"; } diff --git a/src/test/java/com/aims/assembly/kafka/AlertImageUrlResolverTest.java b/src/test/java/com/aims/assembly/kafka/AlertImageUrlResolverTest.java new file mode 100644 index 0000000..0e69045 --- /dev/null +++ b/src/test/java/com/aims/assembly/kafka/AlertImageUrlResolverTest.java @@ -0,0 +1,76 @@ +package com.aims.assembly.kafka; + +import com.aims.assembly.domain.enums.ProcessCode; +import com.aims.assembly.kafka.model.ManufacturingAlertEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlertImageUrlResolverTest { + private static final String BASE_URI = + "s3://event-image-858507113889-ap-northeast-2-an/"; + + @ParameterizedTest + @MethodSource("mappedImages") + void resolvesImageUrl( + ProcessCode processCode, + String alertType, + String riskLevel, + String fileName + ) { + assertThat(AlertImageUrlResolver.resolve(processCode, alertType, riskLevel)) + .isEqualTo(BASE_URI + fileName); + } + + static Stream mappedImages() { + return Stream.of( + mapping(ProcessCode.PRESS, "press"), + mapping(ProcessCode.BODY, "body"), + mapping(ProcessCode.PAINT, "paint"), + mapping(ProcessCode.ASSEMBLY, "assamble") + ).flatMap(stream -> stream); + } + + private static Stream mapping(ProcessCode processCode, String filePrefix) { + return Stream.of( + Arguments.of( + processCode, + ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL, + "CRITICAL", + filePrefix + "_1.png" + ), + Arguments.of( + processCode, + ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL, + "CRITICAL", + filePrefix + "_2.png" + ), + Arguments.of( + processCode, + ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL, + "WARNING", + filePrefix + "_3.png" + ), + Arguments.of( + processCode, + ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL, + "WARNING", + filePrefix + "_3.png" + ) + ); + } + + @Test + void returnsNullForUnmappedValues() { + assertThat(AlertImageUrlResolver.resolve(null, "EQUIPMENT_ABNORMAL", "CRITICAL")).isNull(); + assertThat(AlertImageUrlResolver.resolve(ProcessCode.PRESS, null, "CRITICAL")).isNull(); + assertThat(AlertImageUrlResolver.resolve(ProcessCode.PRESS, "UNKNOWN", "CRITICAL")).isNull(); + assertThat(AlertImageUrlResolver.resolve(ProcessCode.PRESS, "EQUIPMENT_ABNORMAL", null)).isNull(); + assertThat(AlertImageUrlResolver.resolve(ProcessCode.PRESS, "EQUIPMENT_ABNORMAL", "LOW")).isNull(); + } +} diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java index 6e902f8..c2df9e6 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java @@ -421,10 +421,49 @@ void equipmentAbnormalStatusTriggersEquipmentAlert() { ManufacturingAlertEvent alert = analyzer.toEquipmentStatusAlert(raw); assertThat(alert.alertType()).isEqualTo("EQUIPMENT_ABNORMAL"); assertThat(alert.riskLevel()).isEqualTo("CRITICAL"); + assertThat(alert.imageUrl()).isEqualTo( + "s3://event-image-858507113889-ap-northeast-2-an/press_1.png" + ); assertThat(analyzer.toEquipmentStatusEvent(raw).operationStatus()).isEqualTo("FAULT"); assertThat(analyzer.toEquipmentStatusEvent(raw).riskLevel()).isEqualTo("CRITICAL"); } + @Test + void equipmentWarningUsesWarningImageWithoutChangingAlertCondition() { + ManufacturingRawEvent raw = rawWithEquipmentStatus(ProcessCode.BODY, "WARNING"); + + assertThat(analyzer.isEquipmentAbnormal(raw)).isTrue(); + ManufacturingAlertEvent alert = analyzer.toEquipmentStatusAlert(raw); + + assertThat(alert.riskLevel()).isEqualTo("WARNING"); + assertThat(alert.riskScore()).isEqualTo(60.0); + assertThat(alert.imageUrl()).isEqualTo( + "s3://event-image-858507113889-ap-northeast-2-an/body_3.png" + ); + } + + @Test + void processCriticalUsesProcessCriticalImage() { + ManufacturingRawEvent raw = raw(ProcessCode.PRESS, Map.of( + "processMetrics", Map.of( + "cycleTimeSec", 75.0, + "stationDelaySec", 40.0 + ), + "processData", Map.of("press", Map.of( + "targetCycleTimeSec", 40.0, + "countIncreaseYn", false + )) + )); + + ManufacturingAnalysisEvent analysis = analyzer.analyze(raw); + ManufacturingAlertEvent alert = analyzer.toAlertEvent(analysis); + + assertThat(analysis.riskLevel()).isEqualTo("CRITICAL"); + assertThat(alert.imageUrl()).isEqualTo( + "s3://event-image-858507113889-ap-northeast-2-an/press_2.png" + ); + } + @Test @DisplayName("8. alert topic message key는 alertId (PROCESS_RISK 케이스)") void alertEventAlertIdIsNotBlank() { diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java index f2a45ef..8f3075c 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java @@ -197,7 +197,8 @@ void rawEquipmentAbnormalPublishesEquipmentStatusAndAlertTopics() { "ALT-1", raw.eventId(), null, raw.eventTime(), null, null, raw.processCode(), raw.equipmentCode(), null, raw.carMasterId(), raw.equipmentId(), "EQUIPMENT_ABNORMAL", "title", "message", "CRITICAL", 100.0, - "OPEN", true, List.of("error"), "check" + "OPEN", true, List.of("error"), "check", + "s3://event-image-858507113889-ap-northeast-2-an/press_1.png" ); when(parser.parse("raw-json")).thenReturn(raw); when(analyzer.isEquipmentAbnormal(raw)).thenReturn(true); diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java index 5f3006f..6d1240a 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java @@ -3,6 +3,7 @@ import com.aims.assembly.common.status.KafkaErrorStatus; import com.aims.assembly.exception.KafkaException; import com.aims.assembly.kafka.model.KafkaPublishResult; +import com.aims.assembly.kafka.model.ManufacturingAlertEvent; import com.aims.assembly.kafka.model.ManufacturingAnalysisEvent; import com.aims.assembly.kafka.model.ManufacturingRawEvent; import com.aims.assembly.properties.KafkaCustomProperties; @@ -22,6 +23,7 @@ import tools.jackson.databind.ObjectMapper; import java.time.LocalDateTime; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -29,6 +31,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.mock; @@ -56,6 +60,57 @@ class ManufacturingKafkaProducerTest { @Mock private KafkaMessageTraceStore traceStore; + @Test + void publishesAlertEvenWhenImageMappingIsMissing() { + ManufacturingKafkaProducer producer = new ManufacturingKafkaProducer( + kafkaTemplate, + new ObjectMapper(), + new KafkaCustomProperties(), + traceStore + ); + ManufacturingAlertEvent event = new ManufacturingAlertEvent( + "ALT-NO-IMAGE", + "EVT-NO-IMAGE", + "ANL-NO-IMAGE", + LocalDateTime.of(2026, 7, 13, 10, 0), + null, + null, + ProcessCode.PRESS, + "PRESS-1", + null, + 1L, + null, + ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL, + "title", + "message", + "LOW", + 20.0, + "OPEN", + true, + List.of("reason"), + "check", + null + ); + when(kafkaTemplate.send( + eq("factory.manufacturing.alert"), + eq("ALT-NO-IMAGE"), + anyString() + )).thenReturn(CompletableFuture.completedFuture(sendResult)); + when(sendResult.getRecordMetadata()).thenReturn(recordMetadata); + when(recordMetadata.topic()).thenReturn("factory.manufacturing.alert"); + + KafkaPublishResult result = producer.sendAlert(event).join(); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(String.class); + verify(kafkaTemplate).send( + eq("factory.manufacturing.alert"), + eq("ALT-NO-IMAGE"), + payloadCaptor.capture() + ); + assertThat(payloadCaptor.getValue()).contains("\"imageUrl\":null"); + assertThat(result.topic()).isEqualTo("factory.manufacturing.alert"); + } + @Test @DisplayName("불량 전이 분석은 carId를 message key로 사용") void usesCarIdForDefectTransferAnalysis() { diff --git a/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java b/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java new file mode 100644 index 0000000..eacfb50 --- /dev/null +++ b/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java @@ -0,0 +1,46 @@ +package com.aims.assembly.kafka.model; + +import com.aims.assembly.domain.enums.ProcessCode; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ManufacturingAlertEventJsonTest { + + @Test + void serializesImageUrlInFinalKafkaPayload() { + ManufacturingAlertEvent event = new ManufacturingAlertEvent( + "ALT-1", + "EVT-1", + "ANL-1", + LocalDateTime.of(2026, 7, 13, 10, 0), + "FACTORY-1", + "LINE-1", + ProcessCode.PRESS, + "PRESS-1", + "Press 1", + 1L, + 2L, + ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL, + "프레스 설비 이상 감지", + "프레스 설비에서 위험 상태가 감지되었습니다.", + "CRITICAL", + 100.0, + "OPEN", + true, + List.of("FAULT"), + "설비 확인", + "s3://event-image-858507113889-ap-northeast-2-an/press_1.png" + ); + + String json = new ObjectMapper().writeValueAsString(event); + + assertThat(json).contains( + "\"imageUrl\":\"s3://event-image-858507113889-ap-northeast-2-an/press_1.png\"" + ); + } +} From 4b2f511bccd01cbf30c3c4b6911a238dce2f2384 Mon Sep 17 00:00:00 2001 From: hyein0514 Date: Mon, 13 Jul 2026 15:44:58 +0900 Subject: [PATCH 23/35] =?UTF-8?q?fix:=ED=95=84=EC=9A=94=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jdhK~j[-XlS | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 jdhK~j[-XlS diff --git a/jdhK~j[-XlS b/jdhK~j[-XlS deleted file mode 100644 index e69de29..0000000 From cf989394a43849f743ea8bc4cd8698c4e8bd0b02 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Tue, 14 Jul 2026 09:45:27 +0900 Subject: [PATCH 24/35] =?UTF-8?q?fix:=20=ED=94=84=EB=A0=88=EC=8A=A4&?= =?UTF-8?q?=EC=B0=A8=EC=B2=B4=20=EC=9D=B4=EC=83=81=ED=83=90=EC=A7=80=20rea?= =?UTF-8?q?sons=20=EB=B0=98=ED=99=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../body/BodyAnomalyDetectionResponse.java | 9 +- .../body/BodyAnomalyDetectionService.java | 134 ++++++++++-------- .../press/PressAnomalyDetectionService.java | 19 +-- .../PressAnomalyDetectionServiceTest.java | 8 ++ 5 files changed, 102 insertions(+), 69 deletions(-) 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/dto/body/BodyAnomalyDetectionResponse.java b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java index 08bd425..63127b0 100644 --- a/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java @@ -131,10 +131,11 @@ public record FrequencyZonePoint( } public record FrequencyZoneAnalysis( - ZoneStats low, - ZoneStats main, - ZoneStats high, - ZoneStats ultra + ZoneStats zone1, + ZoneStats zone2, + ZoneStats zone3, + ZoneStats zone4, + ZoneStats zone5 ) { public record ZoneStats( Double avg, 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 531ccfc..4ce128e 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -35,7 +35,7 @@ @Transactional(readOnly = true) public class BodyAnomalyDetectionService { private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; - // ISO 13373/20816의 밴드별 진동 관리 원칙을 참고해 LOW/MID/HIGH 임계값을 분리한다. + // 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; @@ -340,7 +340,6 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( summarizeFrequencyBands(parseFrequencyBands(result.getFrequencyBandsJson())) ); } - private Map parseFrequencyBands(String json) { if (json == null || json.isBlank()) { return new HashMap<>(); @@ -400,60 +399,67 @@ private List buildFrequencyChar return List.of(); } - return bands.entrySet().stream() - .filter(entry -> entry.getValue() != null) + Map summaryBands = summarizeFrequencyBands(bands); + List points = new ArrayList<>(); + summaryBands.entrySet().stream() .sorted(Comparator.comparingInt(entry -> frequencyBandOrder(entry.getKey()))) - .map(entry -> BodyAnomalyDetectionResponseMapper.toFrequencyBandPoint( - chartTime, - localizeFrequencyBandLabel(entry.getKey()), - entry.getValue(), - thresholdForBand(entry.getKey()).target(), - thresholdForBand(entry.getKey()).warning(), - thresholdForBand(entry.getKey()).danger() - )) - .toList(); + .forEach(entry -> { + BandThreshold threshold = thresholdForBand(entry.getKey()); + points.add(BodyAnomalyDetectionResponseMapper.toFrequencyBandPoint( + chartTime, + localizeFrequencyBandKey(entry.getKey()), + entry.getValue(), + threshold.target(), + threshold.warning(), + threshold.danger() + )); + }); + return points; } - private List buildFrequencyZoneChart(Map rawBands) { - if (rawBands == null || rawBands.isEmpty()) { + private List buildFrequencyZoneChart(Map raw) { + if (raw == null || raw.isEmpty()) { return List.of(); } - List zone1 = new ArrayList<>(); - List zone2 = new ArrayList<>(); - List zone3 = new ArrayList<>(); - List zone4 = new ArrayList<>(); - List zone5 = new ArrayList<>(); + List lowVals = new ArrayList<>(); + List mainVals = new ArrayList<>(); + List highVals = new ArrayList<>(); + List zone4Vals = new ArrayList<>(); + List zone5Vals = new ArrayList<>(); - for (Map.Entry entry : rawBands.entrySet()) { - Double value = entry.getValue(); - if (value == null) { + for (Map.Entry entry : raw.entrySet()) { + Double val = entry.getValue(); + if (val == null) { continue; } + int upperHz = extractUpperHz(entry.getKey()); if (upperHz <= 0) { continue; } if (upperHz <= 300) { - zone1.add(value); + lowVals.add(val); } else if (upperHz <= 600) { - zone2.add(value); + mainVals.add(val); } else if (upperHz <= 900) { - zone3.add(value); + highVals.add(val); } else if (upperHz <= 1200) { - zone4.add(value); + zone4Vals.add(val); } else if (upperHz <= 1600) { - zone5.add(value); + zone5Vals.add(val); + } else { + zone5Vals.add(val); } } return List.of( - buildFrequencyZonePoint("Zone 1", "0~300Hz", "저주파 / 기본 구조 진동", zone1), - buildFrequencyZonePoint("Zone 2", "301~600Hz", "로봇 본체 진동", zone2), - buildFrequencyZonePoint("Zone 3", "601~900Hz", "관절·감속기 진동", zone3), - buildFrequencyZonePoint("Zone 4", "901~1200Hz", "베어링·기계 이상 진동", zone4), - buildFrequencyZonePoint("Zone 5", "1201~1600Hz", "고주파 충격·충돌 위험", zone5) + buildFrequencyZonePoint("Zone 1", "0~300Hz", "로봇 진동 / 보호 대역", lowVals, LOW_BAND_THRESHOLD), + buildFrequencyZonePoint("Zone 2", "301~600Hz", "정상 동작 / 중간 절차 대역", mainVals, MID_BAND_THRESHOLD), + buildFrequencyZonePoint("Zone 3", "601~900Hz", "로봇 진동 감시 대역", highVals, HIGH_BAND_THRESHOLD), + buildFrequencyZonePoint("Zone 4", "901~1200Hz", "고주파 진동 집중 감시 대역", zone4Vals, HIGH_BAND_THRESHOLD), + buildFrequencyZonePoint("Zone 5", "1201~1600Hz", "초고주파 충격 / 충돌 위험 대역", zone5Vals, HIGH_BAND_THRESHOLD) ); } @@ -465,7 +471,8 @@ private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis analyzeFrequencyZones List lowVals = new ArrayList<>(); List mainVals = new ArrayList<>(); List highVals = new ArrayList<>(); - List ultraVals = new ArrayList<>(); + List zone4Vals = new ArrayList<>(); + List zone5Vals = new ArrayList<>(); for (Map.Entry entry : raw.entrySet()) { Double val = entry.getValue(); @@ -480,8 +487,12 @@ private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis analyzeFrequencyZones mainVals.add(val); } else if (upperHz <= 900) { highVals.add(val); + } else if (upperHz <= 1200) { + zone4Vals.add(val); + } else if (upperHz <= 1600) { + zone5Vals.add(val); } else { - ultraVals.add(val); + zone5Vals.add(val); } } @@ -489,7 +500,8 @@ private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis analyzeFrequencyZones computeZoneStats(lowVals), computeZoneStats(mainVals), computeZoneStats(highVals), - computeZoneStats(ultraVals) + computeZoneStats(zone4Vals), + computeZoneStats(zone5Vals) ); } @@ -497,7 +509,8 @@ private BodyAnomalyDetectionResponse.FrequencyZonePoint buildFrequencyZonePoint( String zone, String range, String description, - List values + List values, + BandThreshold threshold ) { BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats stats = computeZoneStats(values); return BodyAnomalyDetectionResponseMapper.toFrequencyZonePoint( @@ -506,9 +519,9 @@ private BodyAnomalyDetectionResponse.FrequencyZonePoint buildFrequencyZonePoint( description, stats.avg(), stats.max(), - MID_BAND_THRESHOLD.target(), - MID_BAND_THRESHOLD.warning(), - MID_BAND_THRESHOLD.danger() + threshold.target(), + threshold.warning(), + threshold.danger() ); } @@ -632,8 +645,9 @@ private int[] extractBandRange(String rawKey) { }; } + // 주파수 대역 키를 LOW / MID / HIGH 임계값 세트로 변환한다. private BandThreshold thresholdForBand(String rawKey) { - // 상세 주파수 키가 아니라도, ISO 기준처럼 대역별 정상 범위를 먼저 적용한다. + // 해석된 대역명을 ISO 기준 임계값 세트에 매핑한다. String normalized = rawKey == null ? "" : rawKey.trim().toUpperCase(Locale.ROOT); if (normalized.contains("LOW")) { return LOW_BAND_THRESHOLD; @@ -694,6 +708,7 @@ private String resolveFrequencyPeakBand( return bestBand; } + // 개별 신호를 합쳐 차체 최종 심각도 문자열을 만든다. private String bodySeverity( String motionStatus, String operationMode, @@ -704,7 +719,7 @@ private String bodySeverity( Double riskScore, boolean analysisAbnormal ) { - // 로봇 상태 + 운전 모드 + 주파수 피크를 합산해 차체 이상 등급을 정한다. + // 동작 상태, 운전 모드, 주파수 대역, 분석 위험도를 합쳐 최종 심각도를 계산한다. SeverityLevel severity = SeverityLevel.NORMAL; severity = SeverityLevel.max(severity, motionSeverity(motionStatus)); severity = SeverityLevel.max(severity, operationSeverity(operationMode)); @@ -783,8 +798,12 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( double maxRobotVibrationScore = 0.0; for (BodyAnomalyDetectionResponse.ChartPoint point : points) { - if (!isBodyAnomaly(point)) continue; - if (!"NORMAL".equalsIgnoreCase(point.severity()) || Boolean.TRUE.equals(point.isAbnormal())) abnormalCount++; + if (!isBodyAnomaly(point)) { + continue; + } + if (!"NORMAL".equalsIgnoreCase(point.severity()) || Boolean.TRUE.equals(point.isAbnormal())) { + abnormalCount++; + } if (point.robotVibrationScore() != null) { maxRobotVibrationScore = Math.max(maxRobotVibrationScore, point.robotVibrationScore()); } @@ -792,32 +811,31 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( if (summaryResult != null) { if ("COLLISION_RISK".equals(summaryResult.getRobotMotionStatus())) { - reasons.add("로봇 진동 상태 = 충돌 위험"); - } else if (summaryResult.getRobotMotionStatus() != null - && !"NORMAL".equals(summaryResult.getRobotMotionStatus())) { - reasons.add("로봇 진동 상태 = " + localizeRobotMotionStatus(summaryResult.getRobotMotionStatus())); + reasons.add("로봇 진동 상태: 충돌 위험"); + } else if (summaryResult.getRobotMotionStatus() != null && !"NORMAL".equals(summaryResult.getRobotMotionStatus())) { + reasons.add("로봇 진동 상태: " + localizeRobotMotionStatus(summaryResult.getRobotMotionStatus())); } if ("AUTO_MANUAL_STOPPED".equals(summaryResult.getRobotOperationMode()) || "STOPPED".equals(summaryResult.getRobotOperationMode()) || "MANUAL".equals(summaryResult.getRobotOperationMode())) { - reasons.add("로봇 동작 모드 = " + localizeRobotOperationMode(summaryResult.getRobotOperationMode())); + reasons.add("로봇 동작 모드: " + localizeRobotOperationMode(summaryResult.getRobotOperationMode())); } if (summaryResult.getFrequencyPeakValue() != null && summaryResult.getFrequencyPeakValue() > 0) { - reasons.add(String.format("피크 진동 증가 (%.6f mm/s)", summaryResult.getFrequencyPeakValue())); + reasons.add(String.format("주파수 피크 증가: %.6f mm/s", summaryResult.getFrequencyPeakValue())); } if (summaryResult.getFrequencyPeakBand() != null) { - reasons.add("주파수 피크 대역 = " + localizeFrequencyPeakBand(summaryResult.getFrequencyPeakBand())); + reasons.add("주파수 피크 대역: " + localizeFrequencyPeakBand(summaryResult.getFrequencyPeakBand())); } } if (abnormalCount > 0) { - reasons.add("이상 탐지 건수 = " + abnormalCount); + reasons.add("이상 탐지 건수: " + abnormalCount); } if (maxRobotVibrationScore > 0) { - reasons.add(String.format("최대 로봇 진동 점수 = %.2f", maxRobotVibrationScore)); + reasons.add(String.format("최대 로봇 진동 가속도: %.4f", maxRobotVibrationScore)); } - return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상이 감지되었습니다", List.copyOf(reasons)); + return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상 탐지 경고", List.copyOf(reasons)); } private String formatFrequencyBand(String raw) { @@ -830,6 +848,7 @@ private String formatFrequencyBand(String raw) { }; } + // 로봇 이동 상태를 기준으로 경고/위험 심각도를 판정한다. private SeverityLevel motionSeverity(String motionStatus) { if (motionStatus == null || motionStatus.isBlank()) { return SeverityLevel.WARNING; @@ -842,6 +861,7 @@ private SeverityLevel motionSeverity(String motionStatus) { }; } + // 동작 모드를 기준으로 정상/경고 심각도를 판정한다. private SeverityLevel operationSeverity(String operationMode) { if (operationMode == null || operationMode.isBlank()) { return SeverityLevel.WARNING; @@ -853,12 +873,13 @@ private SeverityLevel operationSeverity(String operationMode) { }; } + // 주파수 피크 값과 대역 임계값을 비교해 이상 심각도를 계산한다. private SeverityLevel frequencySeverity( String frequencyPeakBand, Map frequencyBands, Double frequencyPeakValue ) { - // 주파수 피크는 밴드별 정상/경고/위험 임계값을 따로 둔다. + // 대역별 피크 임계값으로 경고 / 위험 심각도를 판정한다. if (frequencyPeakValue == null || frequencyPeakValue <= 0.0) { return SeverityLevel.NORMAL; } @@ -874,8 +895,9 @@ private SeverityLevel frequencySeverity( return SeverityLevel.NORMAL; } + // 차체 이상 판단에 쓰는 핵심 신호를 종합해 anomaly 여부를 결정한다. private boolean isBodyAnomaly(BodyAnomalyDetectionResponse.ChartPoint point) { - // 차체는 severity, analysis abnormal, risk score 중 하나만 이상이어도 anomaly로 본다. + // severity, abnormal 플래그, risk score 중 하나라도 기준을 넘으면 이상으로 본다. return point != null && (!"NORMAL".equalsIgnoreCase(point.severity()) || Boolean.TRUE.equals(point.isAbnormal()) diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index c30fcf9..1a8d0b1 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -26,7 +26,7 @@ @Transactional(readOnly = true) public class PressAnomalyDetectionService { private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; - // ISO 7870 관리도 해석을 참고해, 프레스 사이클 편차를 정상/경고/위험으로 나누는 기준값이다. + // 생산 수 증가 여부, 사이클 편차, 분석 결과 abnormal 플래그를 함께 반영한다. private static final double PRESS_NORMAL_CYCLE_DELTA_SEC = 2.0; private static final double PRESS_WARNING_CYCLE_DELTA_SEC = 3.0; @@ -191,9 +191,9 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( countIncreaseFail++; } double gap = cycleTimeGap(point); + maxCycleTimeGap = Math.max(maxCycleTimeGap, gap); if (gap > PRESS_NORMAL_CYCLE_DELTA_SEC) { cycleTimeExceeded++; - maxCycleTimeGap = Math.max(maxCycleTimeGap, gap); } if (!"NORMAL".equalsIgnoreCase(pressSeverity(point))) { abnormalCount++; @@ -201,15 +201,12 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( } List reasons = new ArrayList<>(); - if (countIncreaseFail > 0) { - reasons.add("생산 수 증가 미충족: " + countIncreaseFail + "건"); - } + reasons.add("생산량 미증가: " + countIncreaseFail + "건"); + reasons.add("최대 사이클 차이: " + formatSec(maxCycleTimeGap) + " sec"); if (cycleTimeExceeded > 0) { reasons.add("사이클 지연 초과: " + cycleTimeExceeded + "건, 최대 +" + formatSec(maxCycleTimeGap) + " sec"); } - if (abnormalCount > 0) { - reasons.add("이상 감지: " + abnormalCount + "건"); - } + reasons.add("이상 감지: " + abnormalCount + "건"); if (summaryPoint != null) { reasons.add("대표 이상 이벤트: " + summaryPoint.eventId()); } @@ -217,11 +214,13 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( return PressAnomalyDetectionResponseMapper.toAlert(true, "프레스 이상 탐지 경고", List.copyOf(reasons)); } + // 카운트 증가, 사이클 편차, abnormal 플래그를 종합해 프레스 이상 여부를 판단한다. private boolean isPressAnomaly(PressAnomalyDetectionResponse.ChartPoint point) { - // 카운트 증가 여부, 사이클 편차, 분석 결과 abnormal 플래그를 함께 반영한다. + // 카운트 증가, 사이클 편차, abnormal 플래그를 함께 이상 조건으로 본다. return point != null && !"NORMAL".equalsIgnoreCase(pressSeverity(point)); } + // 이상 후보들 중 대표 이벤트를 고르기 위한 가중치를 계산한다. private double anomalyWeight(PressAnomalyDetectionResponse.ChartPoint point) { if (point == null) { return -1.0; @@ -249,6 +248,7 @@ private double anomalyWeight(PressAnomalyDetectionResponse.ChartPoint point) { return weight; } + // 목표 사이클과 실제 사이클의 차이를 초 단위로 계산한다. private double cycleTimeGap(PressAnomalyDetectionResponse.ChartPoint point) { if (point == null || point.targetCycleTimeSec() == null || point.actualCycleTimeSec() == null) { return 0.0; @@ -256,6 +256,7 @@ private double cycleTimeGap(PressAnomalyDetectionResponse.ChartPoint point) { return Math.abs(point.actualCycleTimeSec() - point.targetCycleTimeSec()); } + // 사이클 편차와 생산 수 증가 상태를 반영해 최종 심각도를 정한다. private String pressSeverity(PressAnomalyDetectionResponse.ChartPoint point) { if (point == null) { return "NORMAL"; diff --git a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java index 6522dc0..6864ed5 100644 --- a/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java +++ b/src/test/java/com/aims/assembly/service/press/PressAnomalyDetectionServiceTest.java @@ -76,6 +76,14 @@ void dashboardUsesAnalysisResultEventTimeAsPrimarySource() { assertThat(response.metrics().riskScore()).isEqualTo(78.0); assertThat(response.alert().detected()).isTrue(); assertThat(response.alert().title()).isNotBlank(); + assertThat(response.alert().reasons()) + .anySatisfy(reason -> { + assertThat(reason).contains("EVT-20260601-000397"); + assertThat(reason).contains("targetCycleTimeSec"); + assertThat(reason).contains("actualCycleTimeSec"); + assertThat(reason).contains("cycleTimeGapSec"); + assertThat(reason).contains("severity="); + }); } private PressAnalysisResult pressResult(String eventId, LocalDateTime eventTime, double riskScore) { From 0ea90b3c9605ff5091fe74f2b6276ca571f61295 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Tue, 14 Jul 2026 10:40:10 +0900 Subject: [PATCH 25/35] =?UTF-8?q?fix:=20=ED=94=84=EB=A0=88=EC=8A=A4&?= =?UTF-8?q?=EC=B0=A8=EC=B2=B4=20=EC=9D=B4=EC=83=81=20=ED=83=90=EC=A7=80?= =?UTF-8?q?=EC=97=90=20logNO=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assembly/dashbord/enums/ProcessCode.java | 16 +++ .../assembly/domain/alert/ActionCategory.java | 10 ++ .../assembly/domain/alert/ActionTimeline.java | 50 ++++++++ .../domain/alert/AlertActionStatus.java | 8 ++ .../assembly/domain/alert/AlertEvent.java | 110 ++++++++++++++++++ .../assembly/domain/alert/AlertSeverity.java | 7 ++ .../aims/assembly/domain/alert/AlertType.java | 6 + .../aims/assembly/domain/alert/Severity.java | 13 +++ .../aims/assembly/domain/user/UserRole.java | 6 + .../body/BodyAnomalyDetectionResponse.java | 4 + .../press/PressAnomalyDetectionResponse.java | 13 ++- .../BodyAnomalyDetectionResponseMapper.java | 9 +- .../PressAnomalyDetectionResponseMapper.java | 14 ++- .../event/AlertEventRepository.java | 19 +++ .../body/BodyAnomalyDetectionService.java | 105 ++++++++++++++++- .../press/PressAnomalyDetectionService.java | 63 +++++++++- 16 files changed, 443 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/aims/assembly/dashbord/enums/ProcessCode.java create mode 100644 src/main/java/com/aims/assembly/domain/alert/ActionCategory.java create mode 100644 src/main/java/com/aims/assembly/domain/alert/ActionTimeline.java create mode 100644 src/main/java/com/aims/assembly/domain/alert/AlertActionStatus.java create mode 100644 src/main/java/com/aims/assembly/domain/alert/AlertEvent.java create mode 100644 src/main/java/com/aims/assembly/domain/alert/AlertSeverity.java create mode 100644 src/main/java/com/aims/assembly/domain/alert/AlertType.java create mode 100644 src/main/java/com/aims/assembly/domain/alert/Severity.java create mode 100644 src/main/java/com/aims/assembly/domain/user/UserRole.java create mode 100644 src/main/java/com/aims/assembly/repository/event/AlertEventRepository.java 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/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 63127b0..385cb89 100644 --- a/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/body/BodyAnomalyDetectionResponse.java @@ -68,6 +68,7 @@ public record PeakMetricChart( public record RobotMetricPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double value, Double warningLine, @@ -80,6 +81,7 @@ public record RobotMetricPoint( public record PeakMetricPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double value, Double secondaryValue, @@ -93,6 +95,7 @@ public record PeakMetricPoint( public record ChartPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double robotVibrationScore, Double frequencyPeakValue, @@ -147,6 +150,7 @@ public record ZoneStats( public record AlertPanel( Boolean detected, String title, + String logNo, List reasons ) { } 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 b7b9cb1..4c5164f 100644 --- a/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java +++ b/src/main/java/com/aims/assembly/dto/press/PressAnomalyDetectionResponse.java @@ -64,6 +64,7 @@ public record DelayChart( public record CycleTimePoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double targetCycleTimeSec, Double actualCycleTimeSec, @@ -78,6 +79,7 @@ public record CycleTimePoint( public record DelayPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double cycleTimeGapSec, Boolean countIncreaseYn, @@ -91,6 +93,7 @@ public record DelayPoint( public record ChartPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double targetCycleTimeSec, Double actualCycleTimeSec, @@ -103,10 +106,14 @@ public record ChartPoint( 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(); boolean isAbnormal = Boolean.TRUE.equals(analysis.getIsAbnormal()); @@ -114,6 +121,7 @@ public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJso return new ChartPoint( analysis.getEventId(), analysis.getAnalysisId(), + logNo, eventJsonEventTime, result.getTargetCycleTimeSec(), result.getActualCycleTimeSec(), @@ -131,6 +139,7 @@ public static ChartPoint from(PressAnalysisResult result, LocalDateTime eventJso public record AlertPanel( Boolean detected, String title, + String logNo, List reasons ) { } @@ -200,6 +209,7 @@ private static CycleTimePoint toCycleTimePoint(ChartPoint point) { return new CycleTimePoint( point.eventId(), point.analysisId(), + point.logNo(), point.timestamp(), point.targetCycleTimeSec(), point.actualCycleTimeSec(), @@ -215,6 +225,7 @@ private static DelayPoint toDelayPoint(ChartPoint point) { return new DelayPoint( point.eventId(), point.analysisId(), + point.logNo(), point.timestamp(), point.cycleTimeGapSec(), point.countIncreaseYn(), diff --git a/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java b/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java index 54306a3..bbbd5ab 100644 --- a/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java +++ b/src/main/java/com/aims/assembly/mapper/BodyAnomalyDetectionResponseMapper.java @@ -37,6 +37,7 @@ public static BodyAnomalyDetectionResponse.DateOption toDateOption( public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double robotVibrationScore, Double frequencyPeakValue, @@ -53,6 +54,7 @@ public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( return new BodyAnomalyDetectionResponse.ChartPoint( eventId, analysisId, + logNo, timestamp, robotVibrationScore, frequencyPeakValue, @@ -71,6 +73,7 @@ public static BodyAnomalyDetectionResponse.ChartPoint toChartPoint( public static BodyAnomalyDetectionResponse.RobotMetricPoint toRobotMetricPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double value, Double warningLine, @@ -81,6 +84,7 @@ public static BodyAnomalyDetectionResponse.RobotMetricPoint toRobotMetricPoint( return new BodyAnomalyDetectionResponse.RobotMetricPoint( eventId, analysisId, + logNo, timestamp, value, warningLine, @@ -93,6 +97,7 @@ public static BodyAnomalyDetectionResponse.RobotMetricPoint toRobotMetricPoint( public static BodyAnomalyDetectionResponse.PeakMetricPoint toPeakMetricPoint( String eventId, String analysisId, + String logNo, LocalDateTime timestamp, Double value, Double secondaryValue, @@ -104,6 +109,7 @@ public static BodyAnomalyDetectionResponse.PeakMetricPoint toPeakMetricPoint( return new BodyAnomalyDetectionResponse.PeakMetricPoint( eventId, analysisId, + logNo, timestamp, value, secondaryValue, @@ -218,8 +224,9 @@ public static BodyAnomalyDetectionResponse.FrequencyZonePoint toFrequencyZonePoi 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/PressAnomalyDetectionResponseMapper.java b/src/main/java/com/aims/assembly/mapper/PressAnomalyDetectionResponseMapper.java index 2193034..f2e46f9 100644 --- a/src/main/java/com/aims/assembly/mapper/PressAnomalyDetectionResponseMapper.java +++ b/src/main/java/com/aims/assembly/mapper/PressAnomalyDetectionResponseMapper.java @@ -37,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 ) { @@ -46,9 +57,10 @@ 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) { 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/service/body/BodyAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java index 4ce128e..10e74d5 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -5,6 +5,7 @@ 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.ObjectMapper; @@ -24,6 +25,7 @@ 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; @@ -47,6 +49,7 @@ public class BodyAnomalyDetectionService { private final BodyAnalysisResultRepository repository; private final ManufacturingEventJsonRepository eventJsonRepository; + private final AlertEventRepository alertEventRepository; private final ObjectMapper objectMapper; @Cacheable( @@ -86,6 +89,13 @@ public BodyAnomalyDetectionResponse findDashboard( .toList(); 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); @@ -126,6 +136,7 @@ public BodyAnomalyDetectionResponse findDashboard( .map(point -> BodyAnomalyDetectionResponseMapper.toRobotMetricPoint( point.eventId(), point.analysisId(), + point.logNo(), point.timestamp(), point.robotVibrationScore(), point.vibrationWarningLine(), @@ -138,6 +149,7 @@ public BodyAnomalyDetectionResponse findDashboard( .map(point -> BodyAnomalyDetectionResponseMapper.toPeakMetricPoint( point.eventId(), point.analysisId(), + point.logNo(), point.timestamp(), point.frequencyPeakValue(), point.vibrationRms(), @@ -242,6 +254,7 @@ private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult return BodyAnomalyDetectionResponseMapper.toChartPoint( analysis.getEventId(), analysis.getAnalysisId(), + null, analysis.getEventTime(), robotVibrationScore, result.getFrequencyPeakValue(), @@ -340,6 +353,87 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( summarizeFrequencyBands(parseFrequencyBands(result.getFrequencyBandsJson())) ); } + + private Map resolveAlertLogNos(List eventIds) { + 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<>(); @@ -787,10 +881,10 @@ private String localizeRobotOperationMode(String raw) { private BodyAnomalyDetectionResponse.AlertPanel toAlert( List points, boolean detected, - BodyAnalysisResult summaryResult + BodyAnalysisResult summaryResult ) { if (!detected || points == null || points.isEmpty()) { - return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상 탐지 미검출", List.of()); + return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상 탐지 미검출", null, List.of()); } List reasons = new ArrayList<>(); @@ -835,7 +929,12 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( reasons.add(String.format("최대 로봇 진동 가속도: %.4f", maxRobotVibrationScore)); } - return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상 탐지 경고", List.copyOf(reasons)); + String logNo = summaryResult == null ? null : resolveAlertLogNo(summaryResult.getAnalysisResult().getEventId()); + return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상 탐지 경고", logNo, List.copyOf(reasons)); + } + + private String resolveAlertLogNo(String eventId) { + return alertEventRepository.findLogNoByEventId(eventId).orElse(null); } private String formatFrequencyBand(String raw) { diff --git a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java index 1a8d0b1..18d8db5 100644 --- a/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/press/PressAnomalyDetectionService.java @@ -3,6 +3,7 @@ import com.aims.assembly.domain.press.PressAnalysisResult; import com.aims.assembly.dto.press.PressAnomalyDetectionResponse; import com.aims.assembly.mapper.PressAnomalyDetectionResponseMapper; +import com.aims.assembly.repository.event.AlertEventRepository; import com.aims.assembly.repository.analysis.PressAnalysisResultRepository; import com.aims.assembly.repository.event.ManufacturingEventJsonRepository; import lombok.RequiredArgsConstructor; @@ -17,7 +18,9 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Service @@ -32,6 +35,7 @@ public class PressAnomalyDetectionService { private final PressAnalysisResultRepository repository; private final ManufacturingEventJsonRepository eventRepository; + private final AlertEventRepository alertEventRepository; @Cacheable( cacheNames = "press-anomaly-dashboard", @@ -69,6 +73,13 @@ public PressAnomalyDetectionResponse findDashboard( .sorted(Comparator.comparing(PressAnomalyDetectionResponse.ChartPoint::timestamp) .thenComparing(PressAnomalyDetectionResponse.ChartPoint::eventId)) .toList(); + Map logNoByEventId = resolveAlertLogNos(points.stream() + .map(PressAnomalyDetectionResponse.ChartPoint::eventId) + .filter(eventId -> eventId != null && !eventId.isBlank()) + .toList()); + points = points.stream() + .map(point -> attachLogNo(point, logNoByEventId.get(point.eventId()))) + .toList(); PressAnomalyDetectionResponse.ChartPoint latest = points.isEmpty() ? null : points.get(points.size() - 1); PressAnomalyDetectionResponse.ChartPoint summaryPoint = points.stream() @@ -127,6 +138,44 @@ private PressAnomalyDetectionResponse.ChartPoint toChartPoint(PressAnalysisResul return PressAnomalyDetectionResponseMapper.toChartPoint(result); } + private Map resolveAlertLogNos(List eventIds) { + if (eventIds == null || eventIds.isEmpty()) { + return Map.of(); + } + + return alertEventRepository.findByEventIdIn(eventIds).stream() + .collect(Collectors.toMap( + event -> event.getEventId(), + event -> event.getLogNo(), + (left, right) -> left, + LinkedHashMap::new + )); + } + + private PressAnomalyDetectionResponse.ChartPoint attachLogNo( + PressAnomalyDetectionResponse.ChartPoint point, + String logNo + ) { + if (point == null) { + return null; + } + return new PressAnomalyDetectionResponse.ChartPoint( + point.eventId(), + point.analysisId(), + logNo, + point.timestamp(), + point.targetCycleTimeSec(), + point.actualCycleTimeSec(), + point.cycleTimeGapSec(), + point.riskScore(), + point.countIncreaseYn(), + point.isAbnormal(), + point.severity(), + point.warningCycleTimeGapSec(), + point.dangerCycleTimeGapSec() + ); + } + private PressAnomalyDetectionResponse.Metrics toMetrics( PressAnomalyDetectionResponse.ChartPoint point, boolean detected, @@ -134,9 +183,9 @@ private PressAnomalyDetectionResponse.Metrics toMetrics( ) { if (point == null) { String severity = detected ? "WARNING" : "NORMAL"; - return PressAnomalyDetectionResponseMapper.toMetrics( + return PressAnomalyDetectionResponseMapper.toMetrics( new PressAnomalyDetectionResponse.ChartPoint( - null, null, null, + null, null, null, null, 0.0, 0.0, 0.0, maxRiskScore, null, null, @@ -155,6 +204,7 @@ private PressAnomalyDetectionResponse.Metrics toMetrics( PressAnomalyDetectionResponse.ChartPoint updatedPoint = new PressAnomalyDetectionResponse.ChartPoint( point.eventId(), point.analysisId(), + point.logNo(), point.timestamp(), point.targetCycleTimeSec(), point.actualCycleTimeSec(), @@ -175,7 +225,7 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( PressAnomalyDetectionResponse.ChartPoint summaryPoint ) { if (!detected || points == null || points.isEmpty()) { - return PressAnomalyDetectionResponseMapper.toAlert(false, "프레스 이상 탐지 미검출", List.of()); + return PressAnomalyDetectionResponseMapper.toAlert(false, "프레스 이상 탐지 미검출", null, List.of()); } int countIncreaseFail = 0; @@ -211,7 +261,12 @@ private PressAnomalyDetectionResponse.AlertPanel toAlert( reasons.add("대표 이상 이벤트: " + summaryPoint.eventId()); } - return PressAnomalyDetectionResponseMapper.toAlert(true, "프레스 이상 탐지 경고", List.copyOf(reasons)); + String logNo = summaryPoint == null ? null : resolveAlertLogNo(summaryPoint.eventId()); + return PressAnomalyDetectionResponseMapper.toAlert(true, "프레스 이상 탐지 경고", logNo, List.copyOf(reasons)); + } + + private String resolveAlertLogNo(String eventId) { + return alertEventRepository.findLogNoByEventId(eventId).orElse(null); } // 카운트 증가, 사이클 편차, abnormal 플래그를 종합해 프레스 이상 여부를 판단한다. From 02bb6f4ac14267edd3418e45d8b7dbb396b41261 Mon Sep 17 00:00:00 2001 From: hyein0514 Date: Wed, 15 Jul 2026 09:36:30 +0900 Subject: [PATCH 26/35] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20url=20=EB=A7=A4=ED=95=91=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assembly/kafka/AlertImageUrlResolver.java | 65 ---------------- .../kafka/ManufacturingEventAnalyzer.java | 14 +--- .../kafka/model/ManufacturingAlertEvent.java | 3 +- .../kafka/AlertImageUrlResolverTest.java | 76 ------------------- .../kafka/ManufacturingEventAnalyzerTest.java | 30 +------- .../kafka/ManufacturingKafkaConsumerTest.java | 3 +- .../kafka/ManufacturingKafkaProducerTest.java | 7 +- .../ManufacturingAlertEventJsonTest.java | 9 +-- 8 files changed, 11 insertions(+), 196 deletions(-) delete mode 100644 src/main/java/com/aims/assembly/kafka/AlertImageUrlResolver.java delete mode 100644 src/test/java/com/aims/assembly/kafka/AlertImageUrlResolverTest.java diff --git a/src/main/java/com/aims/assembly/kafka/AlertImageUrlResolver.java b/src/main/java/com/aims/assembly/kafka/AlertImageUrlResolver.java deleted file mode 100644 index 0faf0b4..0000000 --- a/src/main/java/com/aims/assembly/kafka/AlertImageUrlResolver.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.aims.assembly.kafka; - -import com.aims.assembly.domain.enums.ProcessCode; -import com.aims.assembly.kafka.model.ManufacturingAlertEvent; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public final class AlertImageUrlResolver { - private static final String IMAGE_BASE_URI = - "s3://event-image-858507113889-ap-northeast-2-an/"; - - private AlertImageUrlResolver() { - } - - public static String resolve( - ProcessCode processCode, - String alertType, - String riskLevel - ) { - if (processCode == null || alertType == null || riskLevel == null) { - return mappingNotFound(processCode, alertType, riskLevel); - } - - String filePrefix = switch (processCode) { - case PRESS -> "press"; - case BODY -> "body"; - case PAINT -> "paint"; - case ASSEMBLY -> "assamble"; - }; - - String fileSuffix = switch (riskLevel) { - case "CRITICAL" -> switch (alertType) { - case ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL -> "1"; - case ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL -> "2"; - default -> null; - }; - case "WARNING" -> isSupportedAlertType(alertType) ? "3" : null; - default -> null; - }; - - if (fileSuffix == null) { - return mappingNotFound(processCode, alertType, riskLevel); - } - return IMAGE_BASE_URI + filePrefix + "_" + fileSuffix + ".png"; - } - - private static boolean isSupportedAlertType(String alertType) { - return ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL.equals(alertType) - || ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL.equals(alertType); - } - - private static String mappingNotFound( - ProcessCode processCode, - String alertType, - String riskLevel - ) { - log.warn( - "Alert image mapping not found. processCode={}, alertType={}, riskLevel={}", - processCode, - alertType, - riskLevel - ); - return null; - } -} diff --git a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java index db96f6b..2f3626e 100644 --- a/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java +++ b/src/main/java/com/aims/assembly/kafka/ManufacturingEventAnalyzer.java @@ -297,12 +297,7 @@ public ManufacturingAlertEvent toAlertEvent(ManufacturingAnalysisEvent analysis) "OPEN", true, analysis.reason().detailReasons(), - analysis.recommendation().message(), - AlertImageUrlResolver.resolve( - analysis.processCode(), - alertType, - analysis.riskLevel() - ) + analysis.recommendation().message() ); } @@ -335,12 +330,7 @@ public ManufacturingAlertEvent toEquipmentStatusAlert(ManufacturingRawEvent even "OPEN", true, List.of(statusReason), - "설비 상태를 즉시 확인하고 안전 절차를 따르세요.", - AlertImageUrlResolver.resolve( - event.processCode(), - alertType, - riskLevel - ) + "설비 상태를 즉시 확인하고 안전 절차를 따르세요." ); } 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 5739cfd..ff9b28d 100644 --- a/src/main/java/com/aims/assembly/kafka/model/ManufacturingAlertEvent.java +++ b/src/main/java/com/aims/assembly/kafka/model/ManufacturingAlertEvent.java @@ -35,8 +35,7 @@ public record ManufacturingAlertEvent( String alertStatus, boolean needAction, List reason, - String recommendedAction, - String imageUrl + String recommendedAction ) { public static final String TYPE_MANUFACTURING_ABNORMAL = "MANUFACTURING_ABNORMAL"; public static final String TYPE_EQUIPMENT_ABNORMAL = "EQUIPMENT_ABNORMAL"; diff --git a/src/test/java/com/aims/assembly/kafka/AlertImageUrlResolverTest.java b/src/test/java/com/aims/assembly/kafka/AlertImageUrlResolverTest.java deleted file mode 100644 index 0e69045..0000000 --- a/src/test/java/com/aims/assembly/kafka/AlertImageUrlResolverTest.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.aims.assembly.kafka; - -import com.aims.assembly.domain.enums.ProcessCode; -import com.aims.assembly.kafka.model.ManufacturingAlertEvent; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import java.util.stream.Stream; - -import static org.assertj.core.api.Assertions.assertThat; - -class AlertImageUrlResolverTest { - private static final String BASE_URI = - "s3://event-image-858507113889-ap-northeast-2-an/"; - - @ParameterizedTest - @MethodSource("mappedImages") - void resolvesImageUrl( - ProcessCode processCode, - String alertType, - String riskLevel, - String fileName - ) { - assertThat(AlertImageUrlResolver.resolve(processCode, alertType, riskLevel)) - .isEqualTo(BASE_URI + fileName); - } - - static Stream mappedImages() { - return Stream.of( - mapping(ProcessCode.PRESS, "press"), - mapping(ProcessCode.BODY, "body"), - mapping(ProcessCode.PAINT, "paint"), - mapping(ProcessCode.ASSEMBLY, "assamble") - ).flatMap(stream -> stream); - } - - private static Stream mapping(ProcessCode processCode, String filePrefix) { - return Stream.of( - Arguments.of( - processCode, - ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL, - "CRITICAL", - filePrefix + "_1.png" - ), - Arguments.of( - processCode, - ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL, - "CRITICAL", - filePrefix + "_2.png" - ), - Arguments.of( - processCode, - ManufacturingAlertEvent.TYPE_EQUIPMENT_ABNORMAL, - "WARNING", - filePrefix + "_3.png" - ), - Arguments.of( - processCode, - ManufacturingAlertEvent.TYPE_MANUFACTURING_ABNORMAL, - "WARNING", - filePrefix + "_3.png" - ) - ); - } - - @Test - void returnsNullForUnmappedValues() { - assertThat(AlertImageUrlResolver.resolve(null, "EQUIPMENT_ABNORMAL", "CRITICAL")).isNull(); - assertThat(AlertImageUrlResolver.resolve(ProcessCode.PRESS, null, "CRITICAL")).isNull(); - assertThat(AlertImageUrlResolver.resolve(ProcessCode.PRESS, "UNKNOWN", "CRITICAL")).isNull(); - assertThat(AlertImageUrlResolver.resolve(ProcessCode.PRESS, "EQUIPMENT_ABNORMAL", null)).isNull(); - assertThat(AlertImageUrlResolver.resolve(ProcessCode.PRESS, "EQUIPMENT_ABNORMAL", "LOW")).isNull(); - } -} diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java index b5a5e90..684f7a6 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingEventAnalyzerTest.java @@ -457,15 +457,12 @@ void equipmentAbnormalStatusTriggersEquipmentAlert() { ManufacturingAlertEvent alert = analyzer.toEquipmentStatusAlert(raw); assertThat(alert.alertType()).isEqualTo("EQUIPMENT_ABNORMAL"); assertThat(alert.riskLevel()).isEqualTo("CRITICAL"); - assertThat(alert.imageUrl()).isEqualTo( - "s3://event-image-858507113889-ap-northeast-2-an/press_1.png" - ); assertThat(analyzer.toEquipmentStatusEvent(raw).operationStatus()).isEqualTo("FAULT"); assertThat(analyzer.toEquipmentStatusEvent(raw).riskLevel()).isEqualTo("CRITICAL"); } @Test - void equipmentWarningUsesWarningImageWithoutChangingAlertCondition() { + void equipmentWarningKeepsWarningRiskValues() { ManufacturingRawEvent raw = rawWithEquipmentStatus(ProcessCode.BODY, "WARNING"); assertThat(analyzer.isEquipmentAbnormal(raw)).isTrue(); @@ -473,31 +470,6 @@ void equipmentWarningUsesWarningImageWithoutChangingAlertCondition() { assertThat(alert.riskLevel()).isEqualTo("WARNING"); assertThat(alert.riskScore()).isEqualTo(60.0); - assertThat(alert.imageUrl()).isEqualTo( - "s3://event-image-858507113889-ap-northeast-2-an/body_3.png" - ); - } - - @Test - void processCriticalUsesProcessCriticalImage() { - ManufacturingRawEvent raw = raw(ProcessCode.PRESS, Map.of( - "processMetrics", Map.of( - "cycleTimeSec", 75.0, - "stationDelaySec", 40.0 - ), - "processData", Map.of("press", Map.of( - "targetCycleTimeSec", 40.0, - "countIncreaseYn", false - )) - )); - - ManufacturingAnalysisEvent analysis = analyzer.analyze(raw); - ManufacturingAlertEvent alert = analyzer.toAlertEvent(analysis); - - assertThat(analysis.riskLevel()).isEqualTo("CRITICAL"); - assertThat(alert.imageUrl()).isEqualTo( - "s3://event-image-858507113889-ap-northeast-2-an/press_2.png" - ); } @Test diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java index 8f3075c..f2a45ef 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java @@ -197,8 +197,7 @@ void rawEquipmentAbnormalPublishesEquipmentStatusAndAlertTopics() { "ALT-1", raw.eventId(), null, raw.eventTime(), null, null, raw.processCode(), raw.equipmentCode(), null, raw.carMasterId(), raw.equipmentId(), "EQUIPMENT_ABNORMAL", "title", "message", "CRITICAL", 100.0, - "OPEN", true, List.of("error"), "check", - "s3://event-image-858507113889-ap-northeast-2-an/press_1.png" + "OPEN", true, List.of("error"), "check" ); when(parser.parse("raw-json")).thenReturn(raw); when(analyzer.isEquipmentAbnormal(raw)).thenReturn(true); diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java index 6d1240a..70b561d 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaProducerTest.java @@ -61,7 +61,7 @@ class ManufacturingKafkaProducerTest { private KafkaMessageTraceStore traceStore; @Test - void publishesAlertEvenWhenImageMappingIsMissing() { + void publishesAlertWithoutImageUrl() { ManufacturingKafkaProducer producer = new ManufacturingKafkaProducer( kafkaTemplate, new ObjectMapper(), @@ -88,8 +88,7 @@ void publishesAlertEvenWhenImageMappingIsMissing() { "OPEN", true, List.of("reason"), - "check", - null + "check" ); when(kafkaTemplate.send( eq("factory.manufacturing.alert"), @@ -107,7 +106,7 @@ void publishesAlertEvenWhenImageMappingIsMissing() { eq("ALT-NO-IMAGE"), payloadCaptor.capture() ); - assertThat(payloadCaptor.getValue()).contains("\"imageUrl\":null"); + assertThat(payloadCaptor.getValue()).doesNotContain("imageUrl"); assertThat(result.topic()).isEqualTo("factory.manufacturing.alert"); } diff --git a/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java b/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java index eacfb50..867c391 100644 --- a/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java +++ b/src/test/java/com/aims/assembly/kafka/model/ManufacturingAlertEventJsonTest.java @@ -12,7 +12,7 @@ class ManufacturingAlertEventJsonTest { @Test - void serializesImageUrlInFinalKafkaPayload() { + void excludesImageUrlFromFinalKafkaPayload() { ManufacturingAlertEvent event = new ManufacturingAlertEvent( "ALT-1", "EVT-1", @@ -33,14 +33,11 @@ void serializesImageUrlInFinalKafkaPayload() { "OPEN", true, List.of("FAULT"), - "설비 확인", - "s3://event-image-858507113889-ap-northeast-2-an/press_1.png" + "설비 확인" ); String json = new ObjectMapper().writeValueAsString(event); - assertThat(json).contains( - "\"imageUrl\":\"s3://event-image-858507113889-ap-northeast-2-an/press_1.png\"" - ); + assertThat(json).doesNotContain("imageUrl"); } } From fcb06b2ef24a143b11309713ce16690a35a967ed Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Wed, 15 Jul 2026 13:56:06 +0900 Subject: [PATCH 27/35] =?UTF-8?q?docs:=20README=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 680 +++++++++++++----------------------------------------- 1 file changed, 154 insertions(+), 526 deletions(-) diff --git a/README.md b/README.md index 266145c..05ccbfd 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,68 @@ -# Assembly Service +# AIMS - Assembly Service -`assembly-service`는 모빌리티 스마트팩토리 관제 시스템에서 제조 공정 이벤트를 수집하고, 공정/설비/품질 분석 결과를 대시보드에 제공하는 Spring Boot 기반 백엔드 서비스입니다. +`assembly-service`는 SK 쉴더스 루키즈 개발 5기 **AI 기반 자동차 스마트팩토리 관제 시스템 AIMS**에서 제조 공정 이벤트를 수집하고, 공정/설비/품질 분석 결과를 대시보드에 제공하는 Spring Boot 기반 백엔드 서비스입니다. -이 서비스는 프레스, 차체, 도장, 의장 조립 공정에서 발생하는 생산 이벤트와 센서 데이터를 기반으로 제조 병목 탐지, 공정별 이상 분석, 불량 전이 예측 결과를 생성하거나 조회하는 역할을 담당합니다. +제조 서비스는 프레스·차체·도장·의장 조립 공정에서 발생해 Kafka로 발행된 이벤트를 수집하고, 생산 이벤트와 센서 데이터를 기반으로 이상을 탐지·저장·조회하는 역할을 담당합니다. ## 주요 역할 - 제조 공정 이벤트 수집 및 조회 - 차량별 공정 이동 이력 관리 - 프레스, 차체, 도장, 의장 공정별 분석 결과 관리 - - 프레스 이상 정지 탐지 - - 차체 로봇 이상 동작 및 충돌 위험 탐지 - - 도장 품질 이상 탐지 - - 의장 조립 순서 오류 탐지 - 실시간 병목 분석 결과 제공 - 공정 간 불량 전이 예측 결과 제공 - Kafka 기반 제조 이벤트 스트리밍 연동 - Redis 기반 실시간 대시보드 캐시 연동 -- OpenSearch 기반 제조 이벤트/분석 로그 검색 연동 -## MVP 기능 +## ✨ 제조 주요 기능 + +### 공정별 이상 탐지 분석 (ISO 통계적 공정관리 적용) +스크린샷(22) + +**ISO 표준 문서 및 통계적 공정관리(SPC) 원칙**에 따라 설비별 정상 데이터의 평균(μ)과 표준편차(σ)를 기반으로 동적 임계값을 생성하여 이상을 탐지합니다. (2σ 이내 정상, 2~3σ 경고, 3σ 초과 위험) + +### 1. 프레스 공정 (Press) +스크린샷(23) + +- **적용 레퍼런스:** `ISO 22400`(제조 KPI), `ISO 7870`(관리도), `ISO 20958`(모터 전류 상태감시) +- **사이클 시간 (`cycleTimeSec`) 및 지연시간 (`timestampDelaySec`):** + - 차종, 금형, 작업별 평균 사이클 시간 및 지연시간에 대해 평균+2σ 이내면 `NORMAL`, 2~3σ는 `WARNING`, 3σ 초과는 `CRITICAL`로 판정합니다. +- **전류 RMS (`current.rmsAmpere`):** + - 모터 및 운전 단계(타격/복귀/대기)별 정상 전류의 평균과 표준편차를 기준으로 평가합니다. 제조사 과부하 한계를 초과하거나 3σ를 벗어나면 `CRITICAL`입니다. +- **생산 카운트 (`countIncreaseYn`):** `true` 시 정상, 데이터 누락(`null`) 시 경고, `false` 시 위험. +- **설비 상태 (`operationStatus`):** `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험. + +### 2. 차체 공정 (Body) +스크린샷(26) +image + +- **적용 레퍼런스:** `ISO 13373`(진동 센서 측정/분석), `ISO 20816`(기계 진동 상태평가), `ISO 7870` +- **로봇 진동 점수 (`vibrationScore`) 및 주파수 피크 (`frequencyBands`):** + - 로봇 번호, 작업 프로그램, 속도, 페이로드 조건에 따라 정상 평균과 표준편차를 도출합니다. 조건별 평균+2σ 이하 `NORMAL`, 2~3σ `WARNING`, 3σ 초과 `CRITICAL`. (각 주파수 대역별로 별도 임계치 적용) +- **로봇 상태 (`robotMotionStatus`):** `NORMAL` 정상, `WARNING` 경고, `ABNORMAL` 또는 `COLLISION_RISK` 위험. +- **운전 모드 (`robotOperationMode`):** 생산 중 `AUTO` 정상. 계획 없는 `MANUAL/STOPPED` 위험. + +### 3. 도장 공정 (Paint) +image +image + +- **적용 레퍼런스:** `ISO 4628-1`(도막 결함 평가), `ISO 2808`(도막 두께 측정) +- **표면 품질 점수 (`surfaceQualityScore`):** ISO 결함 등급을 점수로 환산. 80점 이상 정상, 60~80점 경고, 60점 미만 위험. +- **도막 두께 (`thicknessValue`):** 목표치 115μm (90~120μm 범위 내 정상). 80μm 미만 또는 130μm 초과 시 위험. +- **불량 점수 (`defectScore`):** 0.4 미만 정상, 0.4~0.6 경고, 0.6 이상 위험 (`visionLabel`이 정상이어도 점수에 따라 경고 발송). +- **온도 편차 (`thermalStdTemp`):** 오븐 온도 균일도 기준에 따라 2℃ 미만 정상, 2~5℃ 경고, 5℃ 이상 위험. + +### 4. 의장 공정 (Assembly) +image + +- **작업/조립 순서 오류 (`sequenceErrorCount`):** 실제 작업 순서(`actualSequence`)가 기준 순서(`expectedSequence`)와 불일치할 경우 위험(`CRITICAL`) 판정. +- **부품 누락 (`missingPartCount`) 및 체결 오류 (`fasteningErrorCount`):** 1건이라도 발생 시 위험 판정. ### 제조 병목 탐지 Bosch Production Line Performance Dataset의 Station 통과 시간, 공정 처리 시간, 대기 시간을 기반으로 병목 공정을 탐지합니다. 판단 예시: - - 평균 처리 시간 대비 30% 이상 증가 - 특정 Station 체류 시간 급증 - 생산 대기열 증가 @@ -37,567 +73,159 @@ Bosch Production Line Performance Dataset의 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 - -관제 시스템으로 유입되는 샘플 원천 데이터를 저장합니다. - -주요 테이블: +## 📊 활용 데이터셋 -- `car_master`: 차량 기준 정보 -- `equipment`: 샘플 설비 정보 -- `manufacturing_event_json`: 공정/센서/품질 공통 이벤트 +프로젝트의 AI 분석 신뢰도와 공정 모의(Simulation)를 위해 다음의 산업용 오픈 데이터셋을 활용합니다. -### maindb +### 1. [Ford Engine Dataset](https://www.kamp-ai.kr/aidataDetail?DATASET_SEQ=2) +엔진 진동 시계열 데이터를 바탕으로 정상(`1`)과 이상(`-1`)을 분류하는 KAMP 예지보전 데이터셋입니다. +- **활용 공정:** 프레스, 차체 공정 +- **주요 활용도:** 로봇 암 및 프레스 설비 진동 이상 탐지 패턴 적용 -대시보드 조회와 분석 결과 데이터를 저장합니다. +### 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`)이 라벨링된 품질 검사 데이터셋입니다. +- **활용 공정:** 도장 공정 +- **주요 활용도:** 도장 표면 온도 편차 분석 및 비전 기반 품질 불량 탐지 이벤트 생성 -- `press_analysis_result`: 프레스 공정 분석 결과 -- `body_analysis_result`: 차체 공정 분석 결과 -- `paint_analysis_result`: 도장 공정 분석 결과 -- `assembly_analysis_result`: 의장 공정 분석 결과 -- `bottleneck_analysis_result`: 병목 분석 결과 -- `defect_transfer_prediction_result`: 불량 전이 예측 결과 -- `notification`: 실시간 알림 +### 4. [Bosch Production Line Performance Dataset](https://www.kaggle.com/competitions/bosch-production-line-performance/overview) +Kaggle에서 제공하는 대규모 제조 라인 성능 데이터셋으로, 공정 센서값, 시간 정보, 상태 정보 등을 포함합니다. +- **활용 공정:** 통합 관제 (병목 및 품질 분석) +- **주요 활용도:** + - Station 체류 시간 등을 분석하여 **제조 병목 공정 탐지** + - 특정 공정의 센서 데이터가 후속 공정에 미치는 영향을 분석해 **공정 간 불량 전이 예측** -### DB 간 참조 방식 -`sampledb`와 `maindb` 사이에는 물리 FK를 사용하지 않고 논리 참조를 사용합니다. +## 🛠 전체 데이터 기능 흐름 +데이터 기능 흐름도 -예시: - -- `maindb.body_analysis_result.manufacturing_event_id` -- `sampledb.manufacturing_event.id` -- `sampledb.robot_arm_vibration.manufacturing_event_id` - -## 인프라 연동 - -### Kafka - -SampleDB의 제조 이벤트를 재생하고 공정 분석, AI 분석, 설비 상태, 위험 알림을 비동기로 전달하는 이벤트 백본으로 사용합니다. -현재 사용하는 Topic은 다음과 같으며 모두 Partition 2개로 구성합니다. +## 🚀 이벤트 JSON 및 Kafka 처리 (상세 설계) -| 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. 원천 데이터베이스 설계 -### Redis +DB는 `sampledb`(샘플 원천 데이터)와 `maindb`(분석 결과 데이터)로 분리됩니다. 원천 데이터 흐름 제어는 다음 3개의 주요 테이블을 통해 관리됩니다. -실시간 대시보드 조회 성능을 위해 캐시로 사용합니다. +- **`car_master`**: 차량 기준 정보 및 상태 관리 (`WAITING`, `RUNNING`, `HOLD`, `DEFECT`, `COMPLETED`) +- **`equipment`**: 공정별 설비 정보 및 상태 (`RUNNING`, `IDLE`, `STOPPED`, `FAULT`, `MAINTENANCE`) +- **`manufacturing_event_json`**: 10만 건 이상의 공정/센서/품질 원천 이벤트 -예시 캐시 데이터: +**제조 이벤트 흐름 제어 원칙:** +차량 1대당 4개 공정(PRESS, BODY, PAINT, ASSEMBLY)의 이벤트가 존재하지만, **최초에는 PRESS만 `READY` 상태**입니다. +정상적으로 공정이 완료된 경우에만 다음 공정 row의 상태를 `READY`로 변경하여 스케줄러가 가져갈 수 있게 합니다. -- 설비별 Safe Score -- 병목 위험도 -- RUL 예측치 -- 최근 알림 수 +*상태값 의미:* +- `dispatch_status`: `PENDING`(대기), `READY`(발행 가능), `SENT`(발행 완료), `BLOCKED`(설비 고장 대기), `SKIPPED`(진행 불가), `FAILED` +- `analysis_status`: `NOT_ANALYZED`, `NORMAL`, `ABNORMAL` -### OpenSearch +### 2. Kafka Topic 구성 -제조 이벤트 로그와 분석 결과 검색에 사용합니다. +제조 이벤트, 분석 결과, 설비 상태, 알림을 분리하기 위해 4개의 Topic을 운영합니다. 모든 Topic은 2개의 파티션으로 구성됩니다. -활용 예시: +| Topic | 역할 | Producer | Consumer Group | Message Key | +| --- | --- | --- | --- | --- | +| `factory.manufacturing.raw` | 원천 제조 이벤트 전달 | Scheduler | `manufacturing-consumer-group`, `ai-consumer-group`, `main-agv-group` | `carId` | +| `factory.manufacturing.analysis` | 공정/AI 분석 결과 전달 | Manufacturing / AI Service | `analysis-consumer-group` | `carId` | +| `factory.equipment.status` | 설비 상태 변경 이벤트 전달 | Equipment Service 등 | `equipment-consumer-group` | `equipmentCode` | +| `factory.manufacturing.alert` | 이상/위험 알림 이벤트 전달 | 각 도메인 Service | `alert-consumer-group` | `carId` | -- 병목 공정 검색 -- 불량 이력 검색 -- 차량별 제조 이력 검색 -- 센서 트렌드 집계 +* `carId`를 Key로 사용함으로써 동일 차량의 이벤트 순서를 파티션 레벨에서 보장합니다. -## Kafka 제조 이벤트 파이프라인 - -### 전체 처리 흐름 +### 3. 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: carId)"]] -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 + subgraph CONSUMERS [Event Consumers] + MANUFACTURING_CG["manufacturing-consumer-group
(Press/Body/Paint/Assembly 분석)"] + AI_CG["ai-consumer-group
(병목/불량 전이 AI 분석)"] + AGV_CG["main-agv-group
(물류/AGV 연동)"] + end -환경변수 기반으로 `maindb`, `sampledb`를 설정합니다. + ANALYSIS_TOPIC[["Topic: factory.manufacturing.analysis
(Key: carId)"]] + EQUIPMENT_TOPIC[["Topic: factory.equipment.status
(Key: equipmentCode)"]] + ALERT_TOPIC[["Topic: factory.manufacturing.alert
(Key: carId)"]] -- `MAIN_DB_JDBC_URL` -- `MAIN_DB_USERNAME` -- `MAIN_DB_PASSWORD` -- `SAMPLE_DB_JDBC_URL` -- `SAMPLE_DB_USERNAME` -- `SAMPLE_DB_PASSWORD` + ANALYSIS_CG["analysis-consumer-group
대시보드 표시"] + EQUIPMENT_CG["equipment-consumer-group
상태 이력 기록"] + ALERT_CG["alert-consumer-group
실시간 WebSocket 알림"] -### 환경변수 + DB --> REPLAY + REPLAY -->|조건 충족 시 발송| RAW_TOPIC -`.env.example`에 필요한 환경변수 예시가 정의되어 있습니다. + RAW_TOPIC --> MANUFACTURING_CG + RAW_TOPIC --> AI_CG + RAW_TOPIC --> AGV_CG -Spring Boot는 `.env` 파일을 자동으로 읽지 않으므로 IDE 실행 설정, Docker Compose, 쉘 환경변수 등을 통해 주입해야 합니다. + 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 ``` -## 로컬 실행 +### 4. Scheduler 및 Consumer 상세 로직 + +**1) Scheduler 로직** +- `manufacturing_event_json`에서 `dispatch_status = 'READY'`인 이벤트를 조회합니다. +- 해당 설비의 현재 `operation_status`가 `RUNNING`인지 확인합니다. +- 정상이면 `factory.manufacturing.raw`로 이벤트를 발행하고 `is_sent=1`, `dispatch_status='SENT'`로 업데이트합니다. +- 설비가 고장(`FAULT`, `STOPPED`)이면 `BLOCKED` 처리하고 발행하지 않습니다. + +**2) Consumer (제조 공정 로직)** +- `manufacturing-consumer-group`은 이벤트를 소비하고 `processCode`에 따라 분기합니다. (예: `PRESS` -> `PressAnalysisService`) +- 분석 결과가 **정상(NORMAL)**인 경우: 현재 이벤트를 `NORMAL`로 기록하고, **해당 차량의 다음 공정 이벤트를 `READY`로 업데이트**합니다. +- 분석 결과가 **이상(ABNORMAL)**인 경우: 다음 공정을 `READY`로 바꾸지 않고 해당 차량을 `HOLD` 또는 `DEFECT` 상태로 전환합니다. +- 설비 상태 변화나 이상이 발견되면 각각 설비 토픽 및 알림 토픽으로 후속 이벤트를 비동기 발행합니다. + +## 🔧 기술 스택 + +

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

+ +## 로컬 실행 및 테스트 ```bash +# 로컬 서버 실행 ./gradlew bootRun -``` - -Windows: - -```bash -./gradlew.bat bootRun -``` -Swagger UI: - -```text -http://localhost:8082/swagger-ui/index.html -``` - -## 테스트 - -```bash +# 단위 및 통합 테스트 실행 ./gradlew test ``` -Windows: - -```bash -./gradlew.bat test -``` +- Swagger UI: `http://localhost:8082/swagger-ui/index.html` From f2196cb8e540c02c807df01cccfdd67fc695f0de Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Wed, 15 Jul 2026 13:57:58 +0900 Subject: [PATCH 28/35] =?UTF-8?q?remove:=20=ED=95=84=EC=9A=94=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rewrite_service.py | 552 --------------------------------------------- 1 file changed, 552 deletions(-) delete mode 100644 rewrite_service.py diff --git a/rewrite_service.py b/rewrite_service.py deleted file mode 100644 index 0d95c02..0000000 --- a/rewrite_service.py +++ /dev/null @@ -1,552 +0,0 @@ -import re - -with open('src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java', 'r', encoding='utf-8') as f: - content = f.read() - -# I will just write a completely clean version based on the code I know -clean_code = """package com.aims.assembly.service.body; - -import com.aims.assembly.domain.body.BodyAnalysisResult; -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.ManufacturingEventJsonRepository; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class BodyAnomalyDetectionService { - private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; - private static final Double TARGET_VIBRATION_SCORE = 0.75; - private static final Double TARGET_FREQUENCY_PEAK = 3.0; - - private final BodyAnalysisResultRepository repository; - private final ManufacturingEventJsonRepository eventJsonRepository; - private final ObjectMapper 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)" - ) - public BodyAnomalyDetectionResponse findDashboard( - LocalDate date, - LocalDateTime from, - LocalDateTime to, - LocalDateTime endAt, - int limit - ) { - List dateOptions = dateOptions(); - LocalDate targetDate = resolveDate(date, from, to, endAt, dateOptions); - LocalDateTime rangeFrom = from != null ? from : targetDate.atStartOfDay(); - LocalDateTime rangeTo = to != null ? to : LocalDateTime.of(targetDate, LocalTime.MAX); - if (endAt != null && endAt.isBefore(rangeTo)) { - rangeTo = endAt; - } - - int size = Math.max(1, Math.min(limit, 200)); - List allPoints = repository.findDashboardByEventTimeBetween( - rangeFrom, - rangeTo, - PageRequest.of(0, MAX_EVENT_LOOKUP_SIZE) - ).stream() - .collect(Collectors.toMap( - result -> result.getAnalysisResult().getEventId(), - result -> result, - (left, right) -> isLater(left, right) ? left : right - )) - .values() - .stream() - .map(this::toChartPoint) - .filter(point -> point.timestamp() != null) - .sorted(Comparator.comparing(BodyAnomalyDetectionResponse.ChartPoint::timestamp)) - .toList(); - - List points = allPoints.size() <= size - ? allPoints - : allPoints.subList(allPoints.size() - size, allPoints.size()); - - 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) - .max() - .orElse(points.isEmpty() ? 0.0 : safeNumber(points.get(points.size() - 1).riskScore())); - - 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); - - BodyAnalysisResult summaryResult = summaryPoint == null ? null - : repository.findByAnalysisResult_AnalysisId(summaryPoint.analysisId()).orElse(null); - - BodyAnomalyDetectionResponse.Metrics metrics = toMetrics(summaryResult, summaryPoint, detected, maxRiskScore); - - BodyAnomalyDetectionResponse.FrequencyZoneAnalysis freqAnalysis = null; - if (metrics != null && metrics.frequencyBands() != null) { - freqAnalysis = analyzeFrequencyZones(metrics.frequencyBands()); - } - - BodyAnomalyDetectionResponse response = BodyAnomalyDetectionResponseMapper.toResponse( - targetDate, - rangeFrom, - rangeTo, - previousEndAt, - dateOptions, - metrics, - points, - freqAnalysis, - toAlert(points, detected, summaryResult) - ); - log.info( - "차체 이상 탐지 대시보드 조회 완료: date={}, from={}, to={}, 건수={}, 탐지여부={}", - targetDate, - rangeFrom, - rangeTo, - points.size(), - detected - ); - return response; - } - - private List dateOptions() { - return repository.findBodyAnalysisDateOptions().stream() - .map(option -> BodyAnomalyDetectionResponseMapper.toDateOption( - option.getDate(), - option.getSampleEventId() - )) - .toList(); - } - - private LocalDate resolveDate( - LocalDate date, - LocalDateTime from, - LocalDateTime to, - LocalDateTime endAt, - List dateOptions - ) { - if (date != null) return date; - if (from != null) return from.toLocalDate(); - if (endAt != null) return endAt.toLocalDate(); - if (to != null) return to.toLocalDate(); - return dateOptions.isEmpty() ? LocalDate.now() : dateOptions.get(0).date(); - } - - private BodyAnomalyDetectionResponse.ChartPoint toChartPoint(BodyAnalysisResult result) { - var analysis = result.getAnalysisResult(); - Double vibrationScore = result.getRobotVibrationScore(); - Double vibrationPeak = null; - Double vibrationRms = null; - - SensorData sensorData = extractSensorDataFromEventJson(analysis.getEventId()); - if (sensorData != null) { - if (vibrationScore == null) { - vibrationScore = 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"; - } - - return BodyAnomalyDetectionResponseMapper.toChartPoint( - analysis.getEventId(), - analysis.getAnalysisId(), - analysis.getEventTime(), - TARGET_VIBRATION_SCORE, - vibrationScore, - TARGET_FREQUENCY_PEAK, - toDisplayPeakValue(vibrationPeak), - vibrationRms, - analysis.getRiskScore(), - isAbnormal, - severity - ); - } - - private BodyAnomalyDetectionResponse.Metrics toMetrics( - BodyAnalysisResult result, - BodyAnomalyDetectionResponse.ChartPoint point, - boolean detected, - double maxRiskScore - ) { - String severity = detected ? "WARNING" : "NORMAL"; - if (point != null && point.severity() != null && !detected) { - severity = point.severity(); - } - - Double pointVibrationScore = point != null ? point.vibrationScore() : null; - Double pointVibrationPeak = point != null ? point.vibrationPeak() : null; - Double pointVibrationRms = point != null ? point.vibrationRms() : null; - - if (result == null) { - 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", - TARGET_VIBRATION_SCORE, - pointVibrationScore, - TARGET_FREQUENCY_PEAK, - pointVibrationPeak, - pointVibrationRms, - bodyData.frequencyPeakBand, - null, - maxRiskScore, - "0-100", - severity, - bodyData.frequencyBands != null ? bodyData.frequencyBands : new HashMap<>() - ); - } - return BodyAnomalyDetectionResponseMapper.toMetrics( - null, "NORMAL", TARGET_VIBRATION_SCORE, pointVibrationScore, TARGET_FREQUENCY_PEAK, pointVibrationPeak, pointVibrationRms, null, null, maxRiskScore, "0-100", severity, new HashMap<>() - ); - } - return BodyAnomalyDetectionResponseMapper.toMetrics( - result.getRobotMotionStatus(), - result.getRobotOperationMode() != null ? result.getRobotOperationMode() : "NORMAL", - TARGET_VIBRATION_SCORE, - pointVibrationScore != null ? pointVibrationScore : result.getRobotVibrationScore(), - TARGET_FREQUENCY_PEAK, - pointVibrationPeak, - pointVibrationRms, - result.getFrequencyPeakBand(), - toDisplayPeakValue(result.getFrequencyPeakValue()), - maxRiskScore, - "0-100", - severity, - parseFrequencyBands(result.getFrequencyBandsJson()) - ); - } - - private Map parseFrequencyBands(String json) { - if (json == null || json.isBlank()) { - return new HashMap<>(); - } - try { - return objectMapper.readValue(json, new TypeReference>() {}); - } catch (Exception e) { - log.warn("Failed to parse frequencyBands json", e); - return new HashMap<>(); - } - } - - private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis analyzeFrequencyZones(Map raw) { - if (raw == null || raw.isEmpty()) { - return null; - } - - List lowVals = new ArrayList<>(); - List mainVals = new ArrayList<>(); - List highVals = new ArrayList<>(); - List ultraVals = new ArrayList<>(); - - for (Map.Entry entry : raw.entrySet()) { - String key = entry.getKey().toUpperCase(); - Double val = entry.getValue(); - if (val == null) continue; - - int upperHz = -1; - String[] parts = key.replace("FREQ_", "").replace("_HZ", "").split("_"); - try { - if (parts.length >= 2) { - upperHz = Integer.parseInt(parts[1]); - } else if (parts.length == 1) { - upperHz = Integer.parseInt(parts[0]); - } - } catch (Exception e) { - // ignore - } - - if (upperHz <= 0) continue; - - if (upperHz <= 300) { - lowVals.add(val); - } else if (upperHz <= 600) { - mainVals.add(val); - } else if (upperHz <= 1000) { - highVals.add(val); - } else { - ultraVals.add(val); - } - } - - return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis( - computeZoneStats(lowVals), - computeZoneStats(mainVals), - computeZoneStats(highVals), - computeZoneStats(ultraVals) - ); - } - - private BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats computeZoneStats(List vals) { - if (vals.isEmpty()) return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(0.0, 0.0); - double max = vals.stream().mapToDouble(v -> v).max().orElse(0.0); - double avg = vals.stream().mapToDouble(v -> v).average().orElse(0.0); - return new BodyAnomalyDetectionResponse.FrequencyZoneAnalysis.ZoneStats(avg, max); - } - - private BodyAnomalyDetectionResponse.AlertPanel toAlert( - List points, - boolean detected, - BodyAnalysisResult summaryResult - ) { - if (!detected || points == null || points.isEmpty()) { - return BodyAnomalyDetectionResponseMapper.toAlert(false, "차체 이상 미탐지", List.of()); - } - - List reasons = new ArrayList<>(); - int abnormalCount = 0; - double maxVibrationScore = 0.0; - - for (BodyAnomalyDetectionResponse.ChartPoint point : points) { - if (!isBodyAnomaly(point)) continue; - if (Boolean.TRUE.equals(point.isAbnormal())) abnormalCount++; - if (point.robotVibrationScore() != null) { - maxVibrationScore = Math.max(maxVibrationScore, point.robotVibrationScore()); - } - } - - if (summaryResult != null) { - if ("COLLISION_RISK".equals(summaryResult.getRobotMotionStatus())) { - reasons.add("robot_motion_status = COLLISION_RISK"); - } else if (summaryResult.getRobotMotionStatus() != null - && !"NORMAL".equals(summaryResult.getRobotMotionStatus())) { - reasons.add("robot_motion_status = " + summaryResult.getRobotMotionStatus()); - } - if ("AUTO_MANUAL_STOPPED".equals(summaryResult.getRobotOperationMode()) - || "STOPPED".equals(summaryResult.getRobotOperationMode()) - || "MANUAL".equals(summaryResult.getRobotOperationMode())) { - reasons.add("robot_operation_mode = " + summaryResult.getRobotOperationMode()); - } - if (summaryResult.getFrequencyPeakValue() != null && summaryResult.getFrequencyPeakValue() > 0) { - reasons.add(String.format( - "피크 진동값 급증 (%.1f mm/s)", - toDisplayPeakValue(summaryResult.getFrequencyPeakValue()) - )); - } - if (summaryResult.getFrequencyPeakBand() != null) { - reasons.add("고주파 대역 이상 감지 (" + formatFrequencyBand(summaryResult.getFrequencyPeakBand()) + ")"); - } - } - - if (abnormalCount > 0) { - reasons.add("이상 징후 감지: " + abnormalCount + "건"); - } - if (maxVibrationScore > 0) { - reasons.add(String.format("최대 진동 점수: %.2f", maxVibrationScore)); - } - - return BodyAnomalyDetectionResponseMapper.toAlert(true, "차체 이상 탐지", List.copyOf(reasons)); - } - - private Double toDisplayPeakValue(Double rawValue) { - if (rawValue == null) { - return null; - } - return rawValue < 1.0 ? rawValue * 1000.0 : rawValue; - } - - private String formatFrequencyBand(String raw) { - if (raw == null) return ""; - return switch (raw.toUpperCase()) { - case "LOW" -> "LOW (0–100Hz)"; - case "MID", "MEDIUM" -> "MID (30–80Hz)"; - case "HIGH" -> "HIGH (80–300Hz)"; - default -> raw.replace("_HZ", "Hz").replace("_", "-"); - }; - } - - private boolean isBodyAnomaly(BodyAnomalyDetectionResponse.ChartPoint point) { - return point != null - && (Boolean.TRUE.equals(point.isAbnormal()) - || (point.riskScore() != null && point.riskScore() >= 30.0)); - } - - private double anomalyWeight(BodyAnomalyDetectionResponse.ChartPoint point) { - if (point == null) return -1.0; - double weight = 0.0; - if (Boolean.TRUE.equals(point.isAbnormal())) weight += 50.0; - if (point.riskScore() != null) weight += point.riskScore(); - if (point.robotVibrationScore() != null) weight += point.robotVibrationScore() * 10.0; - if (point.frequencyPeakValue() != null) weight += point.frequencyPeakValue() * 5.0; - return weight; - } - - private Double safeNumber(Double value) { - return value == null ? 0.0 : value; - } - - private boolean isLater(BodyAnalysisResult left, BodyAnalysisResult right) { - LocalDateTime leftAt = left.getAnalysisResult().getAnalyzedAt(); - LocalDateTime rightAt = right.getAnalysisResult().getAnalyzedAt(); - if (leftAt != null && rightAt != null) { - int compared = leftAt.compareTo(rightAt); - if (compared != 0) return compared > 0; - } else if (leftAt != null) return true; - else if (rightAt != null) return false; - Long leftId = left.getAnalysisResult().getId(); - Long rightId = right.getAnalysisResult().getId(); - if (leftId == null) return false; - if (rightId == null) return true; - return leftId >= rightId; - } - - private BodyProcessData extractBodyDataFromEventJson(String eventId) { - try { - var eventOpt = eventJsonRepository.findByEventId(eventId); - if (eventOpt.isEmpty()) { - return null; - } - - var storedEvent = eventOpt.get(); - Map eventMap = objectMapper.readValue( - storedEvent.rawJson(), - new TypeReference>() {} - ); - - if (eventMap == null) { - return null; - } - - @SuppressWarnings("unchecked") - Map processData = (Map) eventMap.get("processData"); - if (processData == null) { - return null; - } - - @SuppressWarnings("unchecked") - Map bodyData = (Map) processData.get("body"); - if (bodyData == null) { - return null; - } - - String robotMotionStatus = (String) bodyData.get("robotMotionStatus"); - String robotOperationMode = (String) bodyData.get("robotOperationMode"); - String frequencyPeakBand = (String) bodyData.get("frequencyPeakBand"); - - @SuppressWarnings("unchecked") - Map frequencyBands = (Map) bodyData.get("frequencyBands"); - - return new BodyProcessData(robotMotionStatus, robotOperationMode, frequencyPeakBand, frequencyBands); - } catch (Exception e) { - log.warn("Failed to extract body data from eventJson for eventId: {}", eventId, e); - return null; - } - } - - private static class BodyProcessData { - String robotMotionStatus; - String robotOperationMode; - String frequencyPeakBand; - Map frequencyBands; - - BodyProcessData(String robotMotionStatus, String robotOperationMode, String frequencyPeakBand, Map frequencyBands) { - this.robotMotionStatus = robotMotionStatus; - this.robotOperationMode = robotOperationMode; - this.frequencyPeakBand = frequencyPeakBand; - this.frequencyBands = frequencyBands; - } - } - - private SensorData extractSensorDataFromEventJson(String eventId) { - try { - var eventOpt = eventJsonRepository.findByEventId(eventId); - if (eventOpt.isEmpty()) { - return null; - } - - var storedEvent = eventOpt.get(); - Map eventMap = objectMapper.readValue( - storedEvent.rawJson(), - new TypeReference>() {} - ); - - if (eventMap == null) { - return null; - } - - @SuppressWarnings("unchecked") - Map sensor = (Map) eventMap.get("sensor"); - if (sensor == null) { - return null; - } - - @SuppressWarnings("unchecked") - Map robotArmVibration = (Map) sensor.get("robotArmVibration"); - Double vibrationScore = null; - Double peakValue = null; - Double vibrationRms = null; - - if (robotArmVibration != null) { - Object vibrationScoreObj = robotArmVibration.get("vibrationScore"); - if (vibrationScoreObj != null) { - vibrationScore = ((Number) vibrationScoreObj).doubleValue(); - } - Object vibrationPeakObj = robotArmVibration.get("vibrationPeak"); - if (vibrationPeakObj != null) { - peakValue = ((Number) vibrationPeakObj).doubleValue(); - } - Object vibrationRmsObj = robotArmVibration.get("vibrationRms"); - if (vibrationRmsObj != null) { - vibrationRms = ((Number) vibrationRmsObj).doubleValue(); - } - } - - return new SensorData(vibrationScore, peakValue, vibrationRms); - } catch (Exception e) { - log.warn("Failed to extract sensor data from eventJson for eventId: {}", eventId, e); - return null; - } - } - - private static class SensorData { - Double vibrationScore; - Double peakValue; - Double vibrationRms; - - SensorData(Double vibrationScore, Double peakValue, Double vibrationRms) { - this.vibrationScore = vibrationScore; - this.peakValue = peakValue; - this.vibrationRms = vibrationRms; - } - } -} -""" - -with open('src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java', 'w', encoding='utf-8') as f: - f.write(clean_code) - -print("Rewrite done") From 5353f779b328e68fb893e9c730eaa56df0081e93 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Wed, 15 Jul 2026 14:26:32 +0900 Subject: [PATCH 29/35] =?UTF-8?q?docs:=20README=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 05ccbfd..0e223e3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # AIMS - Assembly Service - +### AIMS (Auto Intelligence Manufacturing System) - AI 기반 자동차 스마트팩토리 관제 시스템 `assembly-service`는 SK 쉴더스 루키즈 개발 5기 **AI 기반 자동차 스마트팩토리 관제 시스템 AIMS**에서 제조 공정 이벤트를 수집하고, 공정/설비/품질 분석 결과를 대시보드에 제공하는 Spring Boot 기반 백엔드 서비스입니다. 제조 서비스는 프레스·차체·도장·의장 조립 공정에서 발생해 Kafka로 발행된 이벤트를 수집하고, 생산 이벤트와 센서 데이터를 기반으로 이상을 탐지·저장·조회하는 역할을 담당합니다. From fcb158eaf7cdbd1cbc4e5d156cd9fe53aa852964 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Wed, 15 Jul 2026 16:56:15 +0900 Subject: [PATCH 30/35] =?UTF-8?q?docs:=20README=20=EA=B8=B0=EC=88=A0=20?= =?UTF-8?q?=EC=8A=A4=ED=83=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0e223e3..bf65b8f 100644 --- a/README.md +++ b/README.md @@ -203,12 +203,12 @@ flowchart TD ## 🔧 기술 스택

- Java 17 - Spring Boot - Gradle - MySQL - Redis - Kafka + Java + Spring Boot + Gradle + MySQL + Redis + Kafka JPA Hibernate Spring Security JWT From 8da331c840bbbeccb3ddd32df6189e3af41d6906 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Wed, 15 Jul 2026 17:28:26 +0900 Subject: [PATCH 31/35] =?UTF-8?q?docs:=20README=20=EA=B8=B0=EC=88=A0=20?= =?UTF-8?q?=EC=8A=A4=ED=83=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 55 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index bf65b8f..9a67222 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,12 @@ DB는 `sampledb`(샘플 원천 데이터)와 `maindb`(분석 결과 데이터) | Topic | 역할 | Producer | Consumer Group | Message Key | | --- | --- | --- | --- | --- | -| `factory.manufacturing.raw` | 원천 제조 이벤트 전달 | Scheduler | `manufacturing-consumer-group`, `ai-consumer-group`, `main-agv-group` | `carId` | -| `factory.manufacturing.analysis` | 공정/AI 분석 결과 전달 | Manufacturing / AI Service | `analysis-consumer-group` | `carId` | -| `factory.equipment.status` | 설비 상태 변경 이벤트 전달 | Equipment Service 등 | `equipment-consumer-group` | `equipmentCode` | -| `factory.manufacturing.alert` | 이상/위험 알림 이벤트 전달 | 각 도메인 Service | `alert-consumer-group` | `carId` | +| `factory.manufacturing.raw` | 원천 제조 이벤트 전달 | `ManufacturingRawEventService`, `ManufacturingEventReplayScheduler`, `ManufacturingKafkaTestController` | `manufacturing-consumer-group`, `ai-consumer-group` | `carMasterId` | +| `factory.manufacturing.analysis` | 공정/AI 분석 결과 전달 | `ManufacturingKafkaConsumer` | `alert-analysis-consumer-group` | `carMasterId` | +| `factory.equipment.status` | 설비 상태 변경 이벤트 전달 | `ManufacturingKafkaConsumer`, `EquipmentStateKafkaListener` | `dashboard-consumer-group` | `equipmentId` 우선, 없으면 `equipmentCode` | +| `factory.manufacturing.alert` | 이상/위험 알림 이벤트 전달 | `ManufacturingKafkaConsumer` | `alert-notification-consumer-group` | `alertId` | -* `carId`를 Key로 사용함으로써 동일 차량의 이벤트 순서를 파티션 레벨에서 보장합니다. +* `carMasterId`를 Key로 사용함으로써 동일 차량의 이벤트 순서를 파티션 레벨에서 보장합니다. ### 3. Kafka 제조 이벤트 파이프라인 흐름도 @@ -150,29 +150,26 @@ flowchart TD REPLAY[ManufacturingEventReplayScheduler
READY 상태 조회 & 설비 RUNNING 확인] end - RAW_TOPIC[["Topic: factory.manufacturing.raw
(Partitions: 2, Key: carId)"]] + RAW_TOPIC[["Topic: factory.manufacturing.raw
(Partitions: 2, Key: carMasterId)"]] subgraph CONSUMERS [Event Consumers] MANUFACTURING_CG["manufacturing-consumer-group
(Press/Body/Paint/Assembly 분석)"] AI_CG["ai-consumer-group
(병목/불량 전이 AI 분석)"] - AGV_CG["main-agv-group
(물류/AGV 연동)"] end - ANALYSIS_TOPIC[["Topic: factory.manufacturing.analysis
(Key: carId)"]] - EQUIPMENT_TOPIC[["Topic: factory.equipment.status
(Key: equipmentCode)"]] - ALERT_TOPIC[["Topic: factory.manufacturing.alert
(Key: carId)"]] + 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["analysis-consumer-group
대시보드 표시"] - EQUIPMENT_CG["equipment-consumer-group
상태 이력 기록"] - ALERT_CG["alert-consumer-group
실시간 WebSocket 알림"] + ANALYSIS_CG["alert-analysis-consumer-group
분석 결과 알림"] + EQUIPMENT_CG["dashboard-consumer-group
상태 이력 기록"] + ALERT_CG["alert-notification-consumer-group
실시간 WebSocket 알림"] DB --> REPLAY REPLAY -->|조건 충족 시 발송| RAW_TOPIC RAW_TOPIC --> MANUFACTURING_CG RAW_TOPIC --> AI_CG - RAW_TOPIC --> AGV_CG - MANUFACTURING_CG -->|정상 시 다음 공정 DB 업데이트
분석 결과 발행| ANALYSIS_TOPIC AI_CG -->|분석 결과 발행| ANALYSIS_TOPIC @@ -189,16 +186,25 @@ flowchart TD ### 4. Scheduler 및 Consumer 상세 로직 **1) Scheduler 로직** -- `manufacturing_event_json`에서 `dispatch_status = 'READY'`인 이벤트를 조회합니다. -- 해당 설비의 현재 `operation_status`가 `RUNNING`인지 확인합니다. -- 정상이면 `factory.manufacturing.raw`로 이벤트를 발행하고 `is_sent=1`, `dispatch_status='SENT'`로 업데이트합니다. -- 설비가 고장(`FAULT`, `STOPPED`)이면 `BLOCKED` 처리하고 발행하지 않습니다. +- `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)**인 경우: 현재 이벤트를 `NORMAL`로 기록하고, **해당 차량의 다음 공정 이벤트를 `READY`로 업데이트**합니다. -- 분석 결과가 **이상(ABNORMAL)**인 경우: 다음 공정을 `READY`로 바꾸지 않고 해당 차량을 `HOLD` 또는 `DEFECT` 상태로 전환합니다. -- 설비 상태 변화나 이상이 발견되면 각각 설비 토픽 및 알림 토픽으로 후속 이벤트를 비동기 발행합니다. +- 분석 결과가 `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 기준으로 조회하는 진단용 엔드포인트입니다. ## 🔧 기술 스택 @@ -207,13 +213,14 @@ flowchart TD Spring Boot Gradle MySQL - Redis - Kafka JPA Hibernate Spring Security JWT Swagger OpenAPI QueryDSL + Kafka + Elasticsearch + Redis Docker Kubernetes

From 8d6543b16c59e3f56a45feaf89b9bcc47da62754 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Wed, 15 Jul 2026 17:31:13 +0900 Subject: [PATCH 32/35] =?UTF-8?q?docs:=20README=20=EC=A0=9C=EC=A1=B0=20kaf?= =?UTF-8?q?ka=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9a67222..4f26897 100644 --- a/README.md +++ b/README.md @@ -127,20 +127,30 @@ DB는 `sampledb`(샘플 원천 데이터)와 `maindb`(분석 결과 데이터) - `dispatch_status`: `PENDING`(대기), `READY`(발행 가능), `SENT`(발행 완료), `BLOCKED`(설비 고장 대기), `SKIPPED`(진행 불가), `FAILED` - `analysis_status`: `NOT_ANALYZED`, `NORMAL`, `ABNORMAL` -### 2. Kafka Topic 구성 +### 2. 제조 Kafka Topic 구성 제조 이벤트, 분석 결과, 설비 상태, 알림을 분리하기 위해 4개의 Topic을 운영합니다. 모든 Topic은 2개의 파티션으로 구성됩니다. -| Topic | 역할 | Producer | Consumer Group | Message Key | -| --- | --- | --- | --- | --- | -| `factory.manufacturing.raw` | 원천 제조 이벤트 전달 | `ManufacturingRawEventService`, `ManufacturingEventReplayScheduler`, `ManufacturingKafkaTestController` | `manufacturing-consumer-group`, `ai-consumer-group` | `carMasterId` | -| `factory.manufacturing.analysis` | 공정/AI 분석 결과 전달 | `ManufacturingKafkaConsumer` | `alert-analysis-consumer-group` | `carMasterId` | -| `factory.equipment.status` | 설비 상태 변경 이벤트 전달 | `ManufacturingKafkaConsumer`, `EquipmentStateKafkaListener` | `dashboard-consumer-group` | `equipmentId` 우선, 없으면 `equipmentCode` | -| `factory.manufacturing.alert` | 이상/위험 알림 이벤트 전달 | `ManufacturingKafkaConsumer` | `alert-notification-consumer-group` | `alertId` | +| Topic | 역할 | Message Key | +| -------------------------------- | --------------- | -------------------------------- | +| `factory.manufacturing.raw` | 원천 제조 이벤트 전달 | `carMasterId` | +| `factory.manufacturing.analysis` | 공정·AI 분석 결과 전달 | `carMasterId` | +| `factory.equipment.status` | 설비 상태 변경 이벤트 전달 | `equipmentId` 또는 `equipmentCode` | +| `factory.manufacturing.alert` | 이상·위험 알림 이벤트 전달 | `alertId` | + * `carMasterId`를 Key로 사용함으로써 동일 차량의 이벤트 순서를 파티션 레벨에서 보장합니다. -### 3. Kafka 제조 이벤트 파이프라인 흐름도 +### 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` | + + +### 4. Kafka 제조 이벤트 파이프라인 흐름도 ```mermaid flowchart TD @@ -183,7 +193,7 @@ flowchart TD ALERT_TOPIC --> ALERT_CG ``` -### 4. Scheduler 및 Consumer 상세 로직 +### 5. Scheduler 및 Consumer 상세 로직 **1) Scheduler 로직** - `app.kafka.scheduler.enabled=true`일 때만 자동 재생 스케줄러가 동작합니다. From 84fbfd5ca0adde83a3b1f2c21850bda9bdd3a8e0 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Wed, 15 Jul 2026 17:33:15 +0900 Subject: [PATCH 33/35] =?UTF-8?q?docs:=20README=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4f26897..7ae28f7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - Kafka 기반 제조 이벤트 스트리밍 연동 - Redis 기반 실시간 대시보드 캐시 연동 +  ## ✨ 제조 주요 기능 ### 공정별 이상 탐지 분석 (ISO 통계적 공정관리 적용) @@ -21,6 +22,7 @@ **ISO 표준 문서 및 통계적 공정관리(SPC) 원칙**에 따라 설비별 정상 데이터의 평균(μ)과 표준편차(σ)를 기반으로 동적 임계값을 생성하여 이상을 탐지합니다. (2σ 이내 정상, 2~3σ 경고, 3σ 초과 위험) +  ### 1. 프레스 공정 (Press) 스크린샷(23) @@ -32,6 +34,7 @@ - **생산 카운트 (`countIncreaseYn`):** `true` 시 정상, 데이터 누락(`null`) 시 경고, `false` 시 위험. - **설비 상태 (`operationStatus`):** `RUNNING` 정상, `WARNING` 경고, `STOPPED/FAULT` 위험. +  ### 2. 차체 공정 (Body) 스크린샷(26) image @@ -42,6 +45,7 @@ - **로봇 상태 (`robotMotionStatus`):** `NORMAL` 정상, `WARNING` 경고, `ABNORMAL` 또는 `COLLISION_RISK` 위험. - **운전 모드 (`robotOperationMode`):** 생산 중 `AUTO` 정상. 계획 없는 `MANUAL/STOPPED` 위험. +  ### 3. 도장 공정 (Paint) image image @@ -52,12 +56,14 @@ - **불량 점수 (`defectScore`):** 0.4 미만 정상, 0.4~0.6 경고, 0.6 이상 위험 (`visionLabel`이 정상이어도 점수에 따라 경고 발송). - **온도 편차 (`thermalStdTemp`):** 오븐 온도 균일도 기준에 따라 2℃ 미만 정상, 2~5℃ 경고, 5℃ 이상 위험. +  ### 4. 의장 공정 (Assembly) image - **작업/조립 순서 오류 (`sequenceErrorCount`):** 실제 작업 순서(`actualSequence`)가 기준 순서(`expectedSequence`)와 불일치할 경우 위험(`CRITICAL`) 판정. - **부품 누락 (`missingPartCount`) 및 체결 오류 (`fasteningErrorCount`):** 1건이라도 발생 시 위험 판정. +  ### 제조 병목 탐지 Bosch Production Line Performance Dataset의 Station 통과 시간, 공정 처리 시간, 대기 시간을 기반으로 병목 공정을 탐지합니다. @@ -68,6 +74,7 @@ Bosch Production Line Performance Dataset의 Station 통과 시간, 공정 처 - 생산 대기열 증가 - 공정별 지연 위험도 증가 +  ### 공정 간 불량 전이 예측 공정별 센서 데이터, 공정 이동 이력, 품질 검사 결과를 기반으로 특정 공정의 이상이 후속 공정의 불량으로 이어질 가능성을 예측합니다. @@ -78,6 +85,7 @@ Bosch Production Line Performance Dataset의 Station 통과 시간, 공정 처 - 주요 원인 Station 및 Sensor Feature - 위험도 등급: `LOW`, `MEDIUM`, `HIGH` +  ## 📊 활용 데이터셋 프로젝트의 AI 분석 신뢰도와 공정 모의(Simulation)를 위해 다음의 산업용 오픈 데이터셋을 활용합니다. @@ -104,11 +112,11 @@ Kaggle에서 제공하는 대규모 제조 라인 성능 데이터셋으로, 공 - Station 체류 시간 등을 분석하여 **제조 병목 공정 탐지** - 특정 공정의 센서 데이터가 후속 공정에 미치는 영향을 분석해 **공정 간 불량 전이 예측** - +  ## 🛠 전체 데이터 기능 흐름 데이터 기능 흐름도 - +  ## 🚀 이벤트 JSON 및 Kafka 처리 (상세 설계) ### 1. 원천 데이터베이스 설계 @@ -127,6 +135,7 @@ DB는 `sampledb`(샘플 원천 데이터)와 `maindb`(분석 결과 데이터) - `dispatch_status`: `PENDING`(대기), `READY`(발행 가능), `SENT`(발행 완료), `BLOCKED`(설비 고장 대기), `SKIPPED`(진행 불가), `FAILED` - `analysis_status`: `NOT_ANALYZED`, `NORMAL`, `ABNORMAL` +  ### 2. 제조 Kafka Topic 구성 제조 이벤트, 분석 결과, 설비 상태, 알림을 분리하기 위해 4개의 Topic을 운영합니다. 모든 Topic은 2개의 파티션으로 구성됩니다. @@ -141,6 +150,7 @@ DB는 `sampledb`(샘플 원천 데이터)와 `maindb`(분석 결과 데이터) * `carMasterId`를 Key로 사용함으로써 동일 차량의 이벤트 순서를 파티션 레벨에서 보장합니다. +  ### 3. 제조 Kafka Producer / Consumer 구성 | Topic | Producer | Consumer Group | | ------------------------ | ------------------------------------------- | ----------------------------------------------------- | @@ -150,6 +160,7 @@ DB는 `sampledb`(샘플 원천 데이터)와 `maindb`(분석 결과 데이터) | `manufacturing.alert` | 제조 이벤트 분석 Consumer | `alert-notification-consumer-group` | +  ### 4. Kafka 제조 이벤트 파이프라인 흐름도 ```mermaid @@ -193,6 +204,7 @@ flowchart TD ALERT_TOPIC --> ALERT_CG ``` +  ### 5. Scheduler 및 Consumer 상세 로직 **1) Scheduler 로직** @@ -216,6 +228,7 @@ flowchart TD - `ManufacturingKafkaTestController`의 `/broker` API는 `KafkaDiagnosticsService`를 통해 실제 clusterId, broker 수, 토픽별 partition 수를 확인합니다. - `/messages`와 `/alerts` API는 현재 인스턴스가 본 Kafka 메시지 흐름을 eventId 또는 alert 기준으로 조회하는 진단용 엔드포인트입니다. +  ## 🔧 기술 스택

@@ -235,6 +248,7 @@ flowchart TD Kubernetes

+  ## 로컬 실행 및 테스트 ```bash From e100e39fbdf9d4904efd9b0ae9fb637411d6f7b4 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Wed, 15 Jul 2026 17:41:38 +0900 Subject: [PATCH 34/35] =?UTF-8?q?test:=20null=20=EA=B0=92=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/aims/assembly/exception/ExceptionAdviceKafkaTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/aims/assembly/exception/ExceptionAdviceKafkaTest.java b/src/test/java/com/aims/assembly/exception/ExceptionAdviceKafkaTest.java index fd0bbef..c9f6965 100644 --- a/src/test/java/com/aims/assembly/exception/ExceptionAdviceKafkaTest.java +++ b/src/test/java/com/aims/assembly/exception/ExceptionAdviceKafkaTest.java @@ -37,6 +37,7 @@ void mapsAsyncKafkaExceptionToConfiguredHttpStatusAndMessage() { assertThat(response.getBody()).isInstanceOf(ApiResponse.class); ApiResponse body = (ApiResponse) response.getBody(); + assert body != null; assertThat(body.getSuccess()).isFalse(); assertThat(body.getMessage()) .isEqualTo(KafkaErrorStatus.MESSAGE_PUBLISH_FAILED.getMessage()); From e1271b44a40f73ff4ee84d56fbc6e161098fb831 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 16 Jul 2026 09:13:30 +0900 Subject: [PATCH 35/35] =?UTF-8?q?test:=20=EC=B0=A8=EC=B2=B4=20=EC=9D=B4?= =?UTF-8?q?=EC=83=81=20=ED=83=90=EC=A7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A7=A4=ED=95=91=20=EC=9D=B8=EC=9E=90=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../body/BodyAnomalyDetectionService.java | 19 +++++++++++++++++-- .../kafka/ManufacturingKafkaConsumerTest.java | 19 +++++++++---------- 2 files changed, 26 insertions(+), 12 deletions(-) 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 10e74d5..f125d7e 100644 --- a/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java +++ b/src/main/java/com/aims/assembly/service/body/BodyAnomalyDetectionService.java @@ -11,6 +11,7 @@ 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; @@ -33,7 +34,7 @@ @Slf4j @Service -@RequiredArgsConstructor +@RequiredArgsConstructor(onConstructor_ = @Autowired) @Transactional(readOnly = true) public class BodyAnomalyDetectionService { private static final int MAX_EVENT_LOOKUP_SIZE = 10_000; @@ -52,9 +53,17 @@ public class BodyAnomalyDetectionService { 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", - key = "T(java.time.LocalDate).parse(#date?.toString() ?: #from?.toLocalDate()?.toString() ?: #to?.toLocalDate()?.toString() ?: T(java.time.LocalDate).now().toString())" + 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, @@ -355,6 +364,9 @@ private BodyAnomalyDetectionResponse.Metrics toMetrics( } private Map resolveAlertLogNos(List eventIds) { + if (alertEventRepository == null) { + return Map.of(); + } if (eventIds == null || eventIds.isEmpty()) { return Map.of(); } @@ -934,6 +946,9 @@ private BodyAnomalyDetectionResponse.AlertPanel toAlert( } private String resolveAlertLogNo(String eventId) { + if (alertEventRepository == null || eventId == null || eventId.isBlank()) { + return null; + } return alertEventRepository.findLogNoByEventId(eventId).orElse(null); } diff --git a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java index f2a45ef..129fe28 100644 --- a/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java +++ b/src/test/java/com/aims/assembly/kafka/ManufacturingKafkaConsumerTest.java @@ -229,13 +229,13 @@ void pressNormalCompletionReleasesBodyInDatabaseUsingStoredCarMasterId() { createTables(jdbc); LocalDateTime now = LocalDateTime.of(2026, 6, 30, 9, 0); insert(jdbc, 37, "EVT-37", now.minusMinutes(3), 33L, "PRESS", - "SENT", "NOT_ANALYZED", true); + "SENT", true); insert(jdbc, 38, "EVT-38", now.minusMinutes(2), 33L, "BODY", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); insert(jdbc, 39, "EVT-39", now.minusMinutes(1), 33L, "PAINT", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); insert(jdbc, 40, "EVT-40", now, 33L, "ASSEMBLY", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); ObjectMapper objectMapper = mock(ObjectMapper.class); ManufacturingEventAnalyzer analyzer = mock(ManufacturingEventAnalyzer.class); @@ -290,13 +290,13 @@ void pressAbnormalCompletionUpdatesAnalysisStatusAndBlocksFollowingProcesses() { createTables(jdbc); LocalDateTime now = LocalDateTime.of(2026, 6, 30, 9, 30); insert(jdbc, 45, "EVT-20260601-000045", now.minusMinutes(3), 11L, "PRESS", - "SENT", "NOT_ANALYZED", true); + "SENT", true); insert(jdbc, 46, "EVT-20260601-000046", now.minusMinutes(2), 11L, "BODY", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); insert(jdbc, 47, "EVT-20260601-000047", now.minusMinutes(1), 11L, "PAINT", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); insert(jdbc, 48, "EVT-20260601-000048", now, 11L, "ASSEMBLY", - "PENDING", "NOT_ANALYZED", false); + "PENDING", false); ObjectMapper objectMapper = mock(ObjectMapper.class); ManufacturingEventAnalyzer analyzer = mock(ManufacturingEventAnalyzer.class); @@ -443,7 +443,6 @@ private void insert( long carMasterId, String processCode, String dispatchStatus, - String analysisStatus, boolean sent ) { jdbc.update(""" @@ -451,7 +450,7 @@ private void insert( (?, ?, ?, ?, 10, ?, '{"event":{"carId":"CAR-33"}}', ?, ?, ?, 0, NULL, CURRENT_TIMESTAMP) """, id, eventId, time, carMasterId, processCode, - dispatchStatus, analysisStatus, sent); + dispatchStatus, "NOT_ANALYZED", sent); } private String status(JdbcTemplate jdbc, long id) {