feat(detection): GEAR_IDENTITY_COLLISION 탐지 패턴 추가
동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 공존 케이스를 신규 탐지 패턴으로 분리해 기록·분류한다. 부수 효과로 fleet_tracker.track_gear_identity 의 PK 충돌로 인한 사이클 실패도 해소. Prediction - algorithms/gear_identity.py: detect_gear_name_collisions + classify_severity - fleet_tracker.py: 공존/교체 분기 분리, UPSERT helper, savepoint 점수 이전 - output/event_generator.py: run_gear_identity_collision_events 추가 - scheduler.py: track_gear_identity 직후 이벤트 승격 호출 Backend (domain/analysis) - GearIdentityCollision 엔티티 + Repository(Specification+stats) - GearIdentityCollisionService (@Transactional readOnly / @Auditable resolve) - GearCollisionController /api/analysis/gear-collisions (list/stats/detail/resolve) - GearCollisionResponse / StatsResponse / ResolveRequest (record) DB - V030__gear_identity_collision.sql: gear_identity_collisions 테이블 + auth_perm_tree 엔트리(detection:gear-collision nav_sort=950) + 역할별 권한 Frontend - shared/constants/gearCollisionStatuses.ts + catalogRegistry 등록 - services/gearCollisionApi.ts (list/stats/get/resolve) - features/detection/GearCollisionDetection.tsx (PageContainer+Section+DataTable + 분류 액션 폼, design system SSOT 준수) - componentRegistry + feature index + i18n detection.json / common.json(ko/en)
This commit is contained in:
부모
831045ace9
커밋
a4e29629fc
@ -0,0 +1,64 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회 + 분류 API.
|
||||||
|
*
|
||||||
|
* 경로: /api/analysis/gear-collisions
|
||||||
|
* - GET / 목록 (status/severity/name 필터, hours 윈도우)
|
||||||
|
* - GET /stats status/severity 집계
|
||||||
|
* - GET /{id} 단건 상세
|
||||||
|
* - POST /{id}/resolve 분류 (REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/analysis/gear-collisions")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GearCollisionController {
|
||||||
|
|
||||||
|
private static final String RESOURCE = "detection:gear-collision";
|
||||||
|
|
||||||
|
private final GearIdentityCollisionService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "READ")
|
||||||
|
public Page<GearCollisionResponse> list(
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(required = false) String severity,
|
||||||
|
@RequestParam(required = false) String name,
|
||||||
|
@RequestParam(defaultValue = "48") int hours,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size
|
||||||
|
) {
|
||||||
|
return service.list(status, severity, name, hours,
|
||||||
|
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")))
|
||||||
|
.map(GearCollisionResponse::from);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats")
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "READ")
|
||||||
|
public GearCollisionStatsResponse stats(@RequestParam(defaultValue = "48") int hours) {
|
||||||
|
return service.stats(hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "READ")
|
||||||
|
public GearCollisionResponse get(@PathVariable Long id) {
|
||||||
|
return GearCollisionResponse.from(service.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/resolve")
|
||||||
|
@RequirePermission(resource = RESOURCE, operation = "UPDATE")
|
||||||
|
public GearCollisionResponse resolve(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody GearCollisionResolveRequest req
|
||||||
|
) {
|
||||||
|
return GearCollisionResponse.from(service.resolve(id, req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions 분류(해결) 액션 요청.
|
||||||
|
*
|
||||||
|
* action: REVIEWED | CONFIRMED_ILLEGAL | FALSE_POSITIVE | REOPEN
|
||||||
|
* note : 선택 (운영자 메모)
|
||||||
|
*/
|
||||||
|
public record GearCollisionResolveRequest(
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "REVIEWED|CONFIRMED_ILLEGAL|FALSE_POSITIVE|REOPEN")
|
||||||
|
String action,
|
||||||
|
String note
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions 조회 응답 DTO.
|
||||||
|
*/
|
||||||
|
public record GearCollisionResponse(
|
||||||
|
Long id,
|
||||||
|
String name,
|
||||||
|
String mmsiLo,
|
||||||
|
String mmsiHi,
|
||||||
|
String parentName,
|
||||||
|
Long parentVesselId,
|
||||||
|
OffsetDateTime firstSeenAt,
|
||||||
|
OffsetDateTime lastSeenAt,
|
||||||
|
Integer coexistenceCount,
|
||||||
|
Integer swapCount,
|
||||||
|
BigDecimal maxDistanceKm,
|
||||||
|
BigDecimal lastLatLo,
|
||||||
|
BigDecimal lastLonLo,
|
||||||
|
BigDecimal lastLatHi,
|
||||||
|
BigDecimal lastLonHi,
|
||||||
|
String severity,
|
||||||
|
String status,
|
||||||
|
String resolutionNote,
|
||||||
|
List<Map<String, Object>> evidence,
|
||||||
|
OffsetDateTime updatedAt
|
||||||
|
) {
|
||||||
|
public static GearCollisionResponse from(GearIdentityCollision e) {
|
||||||
|
return new GearCollisionResponse(
|
||||||
|
e.getId(),
|
||||||
|
e.getName(),
|
||||||
|
e.getMmsiLo(),
|
||||||
|
e.getMmsiHi(),
|
||||||
|
e.getParentName(),
|
||||||
|
e.getParentVesselId(),
|
||||||
|
e.getFirstSeenAt(),
|
||||||
|
e.getLastSeenAt(),
|
||||||
|
e.getCoexistenceCount(),
|
||||||
|
e.getSwapCount(),
|
||||||
|
e.getMaxDistanceKm(),
|
||||||
|
e.getLastLatLo(),
|
||||||
|
e.getLastLonLo(),
|
||||||
|
e.getLastLatHi(),
|
||||||
|
e.getLastLonHi(),
|
||||||
|
e.getSeverity(),
|
||||||
|
e.getStatus(),
|
||||||
|
e.getResolutionNote(),
|
||||||
|
e.getEvidence(),
|
||||||
|
e.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions status/severity 별 집계 응답.
|
||||||
|
*/
|
||||||
|
public record GearCollisionStatsResponse(
|
||||||
|
long total,
|
||||||
|
Map<String, Long> byStatus,
|
||||||
|
Map<String, Long> bySeverity,
|
||||||
|
int hours
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gear_identity_collisions 엔티티 (GEAR_IDENTITY_COLLISION 탐지 패턴).
|
||||||
|
*
|
||||||
|
* 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 내 동시 송출되는 공존 이력.
|
||||||
|
* prediction 엔진이 5분 주기로 UPSERT, 백엔드는 조회 및 운영자 분류(status) 만 갱신.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "gear_identity_collisions", schema = "kcg")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class GearIdentityCollision {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 200)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "mmsi_lo", nullable = false, length = 20)
|
||||||
|
private String mmsiLo;
|
||||||
|
|
||||||
|
@Column(name = "mmsi_hi", nullable = false, length = 20)
|
||||||
|
private String mmsiHi;
|
||||||
|
|
||||||
|
@Column(name = "parent_name", length = 100)
|
||||||
|
private String parentName;
|
||||||
|
|
||||||
|
@Column(name = "parent_vessel_id")
|
||||||
|
private Long parentVesselId;
|
||||||
|
|
||||||
|
@Column(name = "first_seen_at", nullable = false)
|
||||||
|
private OffsetDateTime firstSeenAt;
|
||||||
|
|
||||||
|
@Column(name = "last_seen_at", nullable = false)
|
||||||
|
private OffsetDateTime lastSeenAt;
|
||||||
|
|
||||||
|
@Column(name = "coexistence_count", nullable = false)
|
||||||
|
private Integer coexistenceCount;
|
||||||
|
|
||||||
|
@Column(name = "swap_count", nullable = false)
|
||||||
|
private Integer swapCount;
|
||||||
|
|
||||||
|
@Column(name = "max_distance_km", precision = 8, scale = 2)
|
||||||
|
private BigDecimal maxDistanceKm;
|
||||||
|
|
||||||
|
@Column(name = "last_lat_lo", precision = 9, scale = 6)
|
||||||
|
private BigDecimal lastLatLo;
|
||||||
|
|
||||||
|
@Column(name = "last_lon_lo", precision = 10, scale = 6)
|
||||||
|
private BigDecimal lastLonLo;
|
||||||
|
|
||||||
|
@Column(name = "last_lat_hi", precision = 9, scale = 6)
|
||||||
|
private BigDecimal lastLatHi;
|
||||||
|
|
||||||
|
@Column(name = "last_lon_hi", precision = 10, scale = 6)
|
||||||
|
private BigDecimal lastLonHi;
|
||||||
|
|
||||||
|
@Column(name = "severity", nullable = false, length = 20)
|
||||||
|
private String severity;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 30)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "resolved_by")
|
||||||
|
private UUID resolvedBy;
|
||||||
|
|
||||||
|
@Column(name = "resolved_at")
|
||||||
|
private OffsetDateTime resolvedAt;
|
||||||
|
|
||||||
|
@Column(name = "resolution_note", columnDefinition = "text")
|
||||||
|
private String resolutionNote;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "evidence", columnDefinition = "jsonb")
|
||||||
|
private List<Map<String, Object>> evidence;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface GearIdentityCollisionRepository
|
||||||
|
extends JpaRepository<GearIdentityCollision, Long>,
|
||||||
|
JpaSpecificationExecutor<GearIdentityCollision> {
|
||||||
|
|
||||||
|
Page<GearIdentityCollision> findAllByLastSeenAtAfterOrderByLastSeenAtDesc(
|
||||||
|
OffsetDateTime after, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* status 별 카운트 집계 (hours 윈도우).
|
||||||
|
* 반환: [{status, count}, ...] — Object[] {String status, Long count}
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT g.status AS status, COUNT(g) AS cnt
|
||||||
|
FROM GearIdentityCollision g
|
||||||
|
WHERE g.lastSeenAt > :after
|
||||||
|
GROUP BY g.status
|
||||||
|
""")
|
||||||
|
List<Object[]> countByStatus(OffsetDateTime after);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* severity 별 카운트 집계 (hours 윈도우).
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT g.severity AS severity, COUNT(g) AS cnt
|
||||||
|
FROM GearIdentityCollision g
|
||||||
|
WHERE g.lastSeenAt > :after
|
||||||
|
GROUP BY g.severity
|
||||||
|
""")
|
||||||
|
List<Object[]> countBySeverity(OffsetDateTime after);
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
|
import gc.mda.kcg.audit.annotation.Auditable;
|
||||||
|
import gc.mda.kcg.auth.AuthPrincipal;
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import jakarta.persistence.criteria.Predicate;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회/분류 서비스.
|
||||||
|
*
|
||||||
|
* 조회는 모두 {@link Transactional}(readOnly=true), 분류 액션은 {@link Auditable} 로
|
||||||
|
* 감사로그 기록. 상태 전이 화이트리스트는 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GearIdentityCollisionService {
|
||||||
|
|
||||||
|
private static final String RESOURCE_TYPE = "GEAR_COLLISION";
|
||||||
|
|
||||||
|
private final GearIdentityCollisionRepository repository;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<GearIdentityCollision> list(
|
||||||
|
String status,
|
||||||
|
String severity,
|
||||||
|
String name,
|
||||||
|
int hours,
|
||||||
|
Pageable pageable
|
||||||
|
) {
|
||||||
|
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
|
||||||
|
Specification<GearIdentityCollision> spec = (root, query, cb) -> {
|
||||||
|
List<Predicate> preds = new ArrayList<>();
|
||||||
|
preds.add(cb.greaterThan(root.get("lastSeenAt"), after));
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
preds.add(cb.equal(root.get("status"), status));
|
||||||
|
}
|
||||||
|
if (severity != null && !severity.isBlank()) {
|
||||||
|
preds.add(cb.equal(root.get("severity"), severity));
|
||||||
|
}
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
preds.add(cb.like(root.get("name"), "%" + name + "%"));
|
||||||
|
}
|
||||||
|
return cb.and(preds.toArray(new Predicate[0]));
|
||||||
|
};
|
||||||
|
return repository.findAll(spec, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public GearIdentityCollision get(Long id) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public GearCollisionStatsResponse stats(int hours) {
|
||||||
|
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
|
||||||
|
Map<String, Long> byStatus = new HashMap<>();
|
||||||
|
long total = 0;
|
||||||
|
for (Object[] row : repository.countByStatus(after)) {
|
||||||
|
String s = (String) row[0];
|
||||||
|
long c = ((Number) row[1]).longValue();
|
||||||
|
byStatus.put(s, c);
|
||||||
|
total += c;
|
||||||
|
}
|
||||||
|
Map<String, Long> bySeverity = new HashMap<>();
|
||||||
|
for (Object[] row : repository.countBySeverity(after)) {
|
||||||
|
bySeverity.put((String) row[0], ((Number) row[1]).longValue());
|
||||||
|
}
|
||||||
|
return new GearCollisionStatsResponse(total, byStatus, bySeverity, hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Auditable(action = "GEAR_COLLISION_RESOLVE", resourceType = RESOURCE_TYPE)
|
||||||
|
@Transactional
|
||||||
|
public GearIdentityCollision resolve(Long id, GearCollisionResolveRequest req) {
|
||||||
|
GearIdentityCollision row = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
|
||||||
|
AuthPrincipal principal = currentPrincipal();
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
|
switch (req.action().toUpperCase()) {
|
||||||
|
case "REVIEWED" -> {
|
||||||
|
row.setStatus("REVIEWED");
|
||||||
|
row.setResolvedBy(principal != null ? principal.getUserId() : null);
|
||||||
|
row.setResolvedAt(now);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
case "CONFIRMED_ILLEGAL" -> {
|
||||||
|
row.setStatus("CONFIRMED_ILLEGAL");
|
||||||
|
row.setResolvedBy(principal != null ? principal.getUserId() : null);
|
||||||
|
row.setResolvedAt(now);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
case "FALSE_POSITIVE" -> {
|
||||||
|
row.setStatus("FALSE_POSITIVE");
|
||||||
|
row.setResolvedBy(principal != null ? principal.getUserId() : null);
|
||||||
|
row.setResolvedAt(now);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
case "REOPEN" -> {
|
||||||
|
row.setStatus("OPEN");
|
||||||
|
row.setResolvedBy(null);
|
||||||
|
row.setResolvedAt(null);
|
||||||
|
row.setResolutionNote(req.note());
|
||||||
|
}
|
||||||
|
default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action());
|
||||||
|
}
|
||||||
|
row.setUpdatedAt(now);
|
||||||
|
return repository.save(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthPrincipal currentPrincipal() {
|
||||||
|
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- V030: 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴
|
||||||
|
-- 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는
|
||||||
|
-- 케이스를 독립 탐지 패턴으로 기록. 공존 이력·심각도·운영자 분류 상태를 보존한다.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. 충돌 이력 테이블
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS kcg.gear_identity_collisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL, -- 동일한 어구 이름
|
||||||
|
mmsi_lo VARCHAR(20) NOT NULL, -- 정렬된 쌍 (lo < hi)
|
||||||
|
mmsi_hi VARCHAR(20) NOT NULL,
|
||||||
|
parent_name VARCHAR(100),
|
||||||
|
parent_vessel_id BIGINT, -- fleet_vessels.id
|
||||||
|
first_seen_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||||
|
coexistence_count INT NOT NULL DEFAULT 1, -- 동일 cycle 공존 누적
|
||||||
|
swap_count INT NOT NULL DEFAULT 0, -- 시간차 스왑 누적(참고)
|
||||||
|
max_distance_km NUMERIC(8,2), -- 양 위치 최대 거리
|
||||||
|
last_lat_lo NUMERIC(9,6),
|
||||||
|
last_lon_lo NUMERIC(10,6),
|
||||||
|
last_lat_hi NUMERIC(9,6),
|
||||||
|
last_lon_hi NUMERIC(10,6),
|
||||||
|
severity VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- CRITICAL/HIGH/MEDIUM/LOW
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE
|
||||||
|
resolved_by UUID,
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
resolution_note TEXT,
|
||||||
|
evidence JSONB, -- 최근 관측 요약
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT gear_identity_collisions_pair_uk UNIQUE (name, mmsi_lo, mmsi_hi),
|
||||||
|
CONSTRAINT gear_identity_collisions_pair_ord CHECK (mmsi_lo < mmsi_hi)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_status
|
||||||
|
ON kcg.gear_identity_collisions(status, last_seen_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_severity
|
||||||
|
ON kcg.gear_identity_collisions(severity, last_seen_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_parent
|
||||||
|
ON kcg.gear_identity_collisions(parent_vessel_id)
|
||||||
|
WHERE parent_vessel_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_name
|
||||||
|
ON kcg.gear_identity_collisions(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gic_last_seen
|
||||||
|
ON kcg.gear_identity_collisions(last_seen_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE kcg.gear_identity_collisions IS
|
||||||
|
'동일 어구 이름이 서로 다른 MMSI 로 공존 송출되는 스푸핑 의심 패턴 (GEAR_IDENTITY_COLLISION).';
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm_tree
|
||||||
|
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
|
||||||
|
url_path, label_key, component_key, nav_sort, labels)
|
||||||
|
VALUES
|
||||||
|
('detection:gear-collision', NULL, '어구 정체성 충돌', 1, 40,
|
||||||
|
'/gear-collision', 'nav.gearCollision',
|
||||||
|
'features/detection/GearCollisionDetection', 950,
|
||||||
|
'{"ko":"어구 정체성 충돌","en":"Gear Identity Collision"}'::jsonb)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
-- 3. 권한 부여
|
||||||
|
-- ADMIN : 전체 op (READ/CREATE/UPDATE/DELETE/EXPORT)
|
||||||
|
-- OPERATOR: READ + UPDATE (분류 액션)
|
||||||
|
-- VIEWER/ANALYST/FIELD: READ
|
||||||
|
-- ──────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'OPERATOR'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'detection:gear-collision', 'READ', 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'FIELD')
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
@ -39,6 +39,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
|||||||
'features/detection/ChinaFishing': lazy(() =>
|
'features/detection/ChinaFishing': lazy(() =>
|
||||||
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
|
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
|
||||||
),
|
),
|
||||||
|
'features/detection/GearCollisionDetection': lazy(() =>
|
||||||
|
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
|
||||||
|
),
|
||||||
// ── 단속·이벤트 ──
|
// ── 단속·이벤트 ──
|
||||||
'features/enforcement/EnforcementHistory': lazy(() =>
|
'features/enforcement/EnforcementHistory': lazy(() =>
|
||||||
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
||||||
|
|||||||
427
frontend/src/features/detection/GearCollisionDetection.tsx
Normal file
427
frontend/src/features/detection/GearCollisionDetection.tsx
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AlertOctagon, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Button } from '@shared/components/ui/button';
|
||||||
|
import { Input } from '@shared/components/ui/input';
|
||||||
|
import { Select } from '@shared/components/ui/select';
|
||||||
|
import { Textarea } from '@shared/components/ui/textarea';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||||
|
import {
|
||||||
|
GEAR_COLLISION_STATUS_ORDER,
|
||||||
|
getGearCollisionStatusIntent,
|
||||||
|
getGearCollisionStatusLabel,
|
||||||
|
} from '@shared/constants/gearCollisionStatuses';
|
||||||
|
import {
|
||||||
|
getGearCollisionStats,
|
||||||
|
listGearCollisions,
|
||||||
|
resolveGearCollision,
|
||||||
|
type GearCollision,
|
||||||
|
type GearCollisionResolveAction,
|
||||||
|
type GearCollisionStats,
|
||||||
|
} from '@/services/gearCollisionApi';
|
||||||
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 페이지.
|
||||||
|
*
|
||||||
|
* 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 에 공존 송출되는 경우를 목록화하고
|
||||||
|
* 운영자가 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE 로 분류할 수 있게 한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SeverityCode = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||||
|
const SEVERITY_OPTIONS: SeverityCode[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
||||||
|
const DEFAULT_HOURS = 48;
|
||||||
|
|
||||||
|
export function GearCollisionDetection() {
|
||||||
|
const { t } = useTranslation('detection');
|
||||||
|
const { t: tc } = useTranslation('common');
|
||||||
|
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<GearCollision[]>([]);
|
||||||
|
const [stats, setStats] = useState<GearCollisionStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<string>('');
|
||||||
|
const [nameFilter, setNameFilter] = useState<string>('');
|
||||||
|
const [selected, setSelected] = useState<GearCollision | null>(null);
|
||||||
|
const [resolveAction, setResolveAction] = useState<GearCollisionResolveAction>('REVIEWED');
|
||||||
|
const [resolveNote, setResolveNote] = useState('');
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const [page, summary] = await Promise.all([
|
||||||
|
listGearCollisions({
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
severity: severityFilter || undefined,
|
||||||
|
name: nameFilter || undefined,
|
||||||
|
hours: DEFAULT_HOURS,
|
||||||
|
size: 200,
|
||||||
|
}),
|
||||||
|
getGearCollisionStats(DEFAULT_HOURS),
|
||||||
|
]);
|
||||||
|
setRows(page.content);
|
||||||
|
setStats(summary);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : t('gearCollision.error.loadFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [statusFilter, severityFilter, nameFilter, t]);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
// 선택된 row 와 현재 목록의 동기화
|
||||||
|
const syncedSelected = useMemo(
|
||||||
|
() => selected ? rows.find((r) => r.id === selected.id) ?? selected : null,
|
||||||
|
[rows, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cols: DataColumn<GearCollision & Record<string, unknown>>[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true,
|
||||||
|
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px',
|
||||||
|
render: (_, row) => (
|
||||||
|
<span className="font-mono text-[10px] text-label">
|
||||||
|
{row.mmsiLo} ↔ {row.mmsiHi}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px',
|
||||||
|
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'),
|
||||||
|
width: '90px', align: 'center', sortable: true,
|
||||||
|
render: (v) => <span className="font-mono text-label">{v as number}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxDistanceKm', label: t('gearCollision.columns.maxDistance'),
|
||||||
|
width: '110px', align: 'right', sortable: true,
|
||||||
|
render: (v) => {
|
||||||
|
const n = typeof v === 'number' ? v : Number(v ?? 0);
|
||||||
|
return <span className="font-mono text-[10px] text-label">{n.toFixed(2)}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'severity', label: t('gearCollision.columns.severity'),
|
||||||
|
width: '90px', align: 'center', sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||||
|
{getAlertLevelLabel(v as string, tc, lang)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status', label: t('gearCollision.columns.status'),
|
||||||
|
width: '110px', align: 'center', sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<Badge intent={getGearCollisionStatusIntent(v as string)} size="sm">
|
||||||
|
{getGearCollisionStatusLabel(v as string, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'),
|
||||||
|
width: '130px', sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [t, tc, lang]);
|
||||||
|
|
||||||
|
const handleResolve = useCallback(async () => {
|
||||||
|
if (!syncedSelected) return;
|
||||||
|
const ok = window.confirm(t('gearCollision.resolve.confirmPrompt'));
|
||||||
|
if (!ok) return;
|
||||||
|
setResolving(true);
|
||||||
|
try {
|
||||||
|
const updated = await resolveGearCollision(syncedSelected.id, {
|
||||||
|
action: resolveAction,
|
||||||
|
note: resolveNote || undefined,
|
||||||
|
});
|
||||||
|
setSelected(updated);
|
||||||
|
setResolveNote('');
|
||||||
|
await loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : t('gearCollision.error.resolveFailed'));
|
||||||
|
} finally {
|
||||||
|
setResolving(false);
|
||||||
|
}
|
||||||
|
}, [syncedSelected, resolveAction, resolveNote, loadData, t]);
|
||||||
|
|
||||||
|
const statusCount = (code: string) => stats?.byStatus?.[code] ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={AlertOctagon}
|
||||||
|
iconColor="text-orange-600 dark:text-orange-400"
|
||||||
|
title={t('gearCollision.title')}
|
||||||
|
description={t('gearCollision.desc')}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
>
|
||||||
|
{t('gearCollision.list.refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title={t('gearCollision.stats.title')}>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
<StatCard label={t('gearCollision.stats.total')} value={stats?.total ?? 0} />
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.open')}
|
||||||
|
value={statusCount('OPEN')}
|
||||||
|
intent="warning"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.reviewed')}
|
||||||
|
value={statusCount('REVIEWED')}
|
||||||
|
intent="info"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.confirmed')}
|
||||||
|
value={statusCount('CONFIRMED_ILLEGAL')}
|
||||||
|
intent="critical"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('gearCollision.stats.falsePositive')}
|
||||||
|
value={statusCount('FALSE_POSITIVE')}
|
||||||
|
intent="muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('gearCollision.list.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||||
|
<Select
|
||||||
|
aria-label={t('gearCollision.filters.status')}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('gearCollision.filters.allStatus')}</option>
|
||||||
|
{GEAR_COLLISION_STATUS_ORDER.map((s) => (
|
||||||
|
<option key={s} value={s}>{getGearCollisionStatusLabel(s, t, lang)}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
aria-label={t('gearCollision.filters.severity')}
|
||||||
|
value={severityFilter}
|
||||||
|
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('gearCollision.filters.allSeverity')}</option>
|
||||||
|
{SEVERITY_OPTIONS.map((sv) => (
|
||||||
|
<option key={sv} value={sv}>{getAlertLevelLabel(sv, tc, lang)}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
aria-label={t('gearCollision.filters.name')}
|
||||||
|
placeholder={t('gearCollision.filters.name')}
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Badge intent="info" size="sm">
|
||||||
|
{t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 && !loading ? (
|
||||||
|
<p className="text-hint text-xs py-4 text-center">
|
||||||
|
{t('gearCollision.list.empty', { hours: DEFAULT_HOURS })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={rows as (GearCollision & Record<string, unknown>)[]}
|
||||||
|
columns={cols}
|
||||||
|
pageSize={20}
|
||||||
|
showSearch={false}
|
||||||
|
showExport={false}
|
||||||
|
showPrint={false}
|
||||||
|
onRowClick={(row) => setSelected(row as GearCollision)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{syncedSelected && (
|
||||||
|
<Section title={t('gearCollision.detail.title')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<DetailRow label={t('gearCollision.columns.name')} value={syncedSelected.name} mono />
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.mmsiPair')}
|
||||||
|
value={`${syncedSelected.mmsiLo} ↔ ${syncedSelected.mmsiHi}`}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.parentName')}
|
||||||
|
value={syncedSelected.parentName ?? '-'}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.detail.firstSeenAt')}
|
||||||
|
value={formatDateTime(syncedSelected.firstSeenAt)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.detail.lastSeenAt')}
|
||||||
|
value={formatDateTime(syncedSelected.lastSeenAt)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.coexistenceCount')}
|
||||||
|
value={String(syncedSelected.coexistenceCount)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.detail.swapCount')}
|
||||||
|
value={String(syncedSelected.swapCount)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('gearCollision.columns.maxDistance')}
|
||||||
|
value={
|
||||||
|
syncedSelected.maxDistanceKm != null
|
||||||
|
? Number(syncedSelected.maxDistanceKm).toFixed(2)
|
||||||
|
: '-'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-label">
|
||||||
|
{t('gearCollision.columns.severity')}:
|
||||||
|
</span>
|
||||||
|
<Badge intent={getAlertLevelIntent(syncedSelected.severity)} size="sm">
|
||||||
|
{getAlertLevelLabel(syncedSelected.severity, tc, lang)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-label ml-3">
|
||||||
|
{t('gearCollision.columns.status')}:
|
||||||
|
</span>
|
||||||
|
<Badge intent={getGearCollisionStatusIntent(syncedSelected.status)} size="sm">
|
||||||
|
{getGearCollisionStatusLabel(syncedSelected.status, t, lang)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{syncedSelected.resolutionNote && (
|
||||||
|
<p className="text-xs text-hint border-l-2 border-border pl-2">
|
||||||
|
{syncedSelected.resolutionNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="gc-resolve-action"
|
||||||
|
className="block text-xs text-label"
|
||||||
|
>
|
||||||
|
{t('gearCollision.resolve.title')}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
id="gc-resolve-action"
|
||||||
|
aria-label={t('gearCollision.resolve.title')}
|
||||||
|
value={resolveAction}
|
||||||
|
onChange={(e) => setResolveAction(e.target.value as GearCollisionResolveAction)}
|
||||||
|
>
|
||||||
|
<option value="REVIEWED">{t('gearCollision.resolve.reviewed')}</option>
|
||||||
|
<option value="CONFIRMED_ILLEGAL">
|
||||||
|
{t('gearCollision.resolve.confirmedIllegal')}
|
||||||
|
</option>
|
||||||
|
<option value="FALSE_POSITIVE">
|
||||||
|
{t('gearCollision.resolve.falsePositive')}
|
||||||
|
</option>
|
||||||
|
<option value="REOPEN">{t('gearCollision.resolve.reopen')}</option>
|
||||||
|
</Select>
|
||||||
|
<Textarea
|
||||||
|
aria-label={t('gearCollision.resolve.note')}
|
||||||
|
placeholder={t('gearCollision.resolve.notePlaceholder')}
|
||||||
|
value={resolveNote}
|
||||||
|
onChange={(e) => setResolveNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setSelected(null); setResolveNote(''); }}
|
||||||
|
>
|
||||||
|
{t('gearCollision.resolve.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={resolving}
|
||||||
|
>
|
||||||
|
{t('gearCollision.resolve.submit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 컴포넌트 ─────────────
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, intent }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card variant="default">
|
||||||
|
<CardContent className="py-3 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] text-hint">{label}</span>
|
||||||
|
{intent ? (
|
||||||
|
<Badge intent={intent} size="md">
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-bold text-heading">{value}</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value, mono }: DetailRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-hint w-24 shrink-0">{label}</span>
|
||||||
|
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GearCollisionDetection;
|
||||||
@ -2,3 +2,4 @@ export { DarkVesselDetection } from './DarkVesselDetection';
|
|||||||
export { GearDetection } from './GearDetection';
|
export { GearDetection } from './GearDetection';
|
||||||
export { ChinaFishing } from './ChinaFishing';
|
export { ChinaFishing } from './ChinaFishing';
|
||||||
export { GearIdentification } from './GearIdentification';
|
export { GearIdentification } from './GearIdentification';
|
||||||
|
export { GearCollisionDetection } from './GearCollisionDetection';
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"darkVessel": "Dark Vessel",
|
"darkVessel": "Dark Vessel",
|
||||||
"gearDetection": "Gear Detection",
|
"gearDetection": "Gear Detection",
|
||||||
"chinaFishing": "Chinese Vessel",
|
"chinaFishing": "Chinese Vessel",
|
||||||
|
"gearCollision": "Gear Collision",
|
||||||
"patrolRoute": "Patrol Route",
|
"patrolRoute": "Patrol Route",
|
||||||
"fleetOptimization": "Fleet Optimize",
|
"fleetOptimization": "Fleet Optimize",
|
||||||
"enforcementHistory": "History",
|
"enforcementHistory": "History",
|
||||||
|
|||||||
@ -14,5 +14,77 @@
|
|||||||
"gearId": {
|
"gearId": {
|
||||||
"title": "Gear Identification",
|
"title": "Gear Identification",
|
||||||
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
||||||
|
},
|
||||||
|
"gearCollision": {
|
||||||
|
"title": "Gear Identity Collision",
|
||||||
|
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",
|
||||||
|
"stats": {
|
||||||
|
"title": "Overview",
|
||||||
|
"total": "Total",
|
||||||
|
"open": "Open",
|
||||||
|
"reviewed": "Reviewed",
|
||||||
|
"confirmed": "Confirmed Illegal",
|
||||||
|
"falsePositive": "False Positive"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Collision Log",
|
||||||
|
"empty": "No collisions detected in the last {{hours}} hours.",
|
||||||
|
"refresh": "Refresh"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"name": "Gear Name",
|
||||||
|
"mmsiPair": "MMSI Pair",
|
||||||
|
"parentName": "Parent Vessel (est.)",
|
||||||
|
"coexistenceCount": "Coexistence",
|
||||||
|
"maxDistance": "Max Distance (km)",
|
||||||
|
"severity": "Severity",
|
||||||
|
"status": "Status",
|
||||||
|
"lastSeen": "Last Seen",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"status": "Status",
|
||||||
|
"severity": "Severity",
|
||||||
|
"name": "Search by name",
|
||||||
|
"hours": "Window (hours)",
|
||||||
|
"allStatus": "All statuses",
|
||||||
|
"allSeverity": "All severities"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "Collision Detail",
|
||||||
|
"evidence": "Observations",
|
||||||
|
"trajectoryCompare": "Trajectory Compare",
|
||||||
|
"firstSeenAt": "First Seen",
|
||||||
|
"lastSeenAt": "Last Seen",
|
||||||
|
"swapCount": "Swap Count"
|
||||||
|
},
|
||||||
|
"resolve": {
|
||||||
|
"title": "Operator Review",
|
||||||
|
"reviewed": "Mark as Reviewed",
|
||||||
|
"confirmedIllegal": "Confirm Illegal",
|
||||||
|
"falsePositive": "Mark False Positive",
|
||||||
|
"reopen": "Reopen",
|
||||||
|
"note": "Note",
|
||||||
|
"notePlaceholder": "Record rationale or supporting evidence",
|
||||||
|
"submit": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirmPrompt": "Update status to the selected classification. Continue?"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"open": "Open",
|
||||||
|
"reviewed": "Reviewed",
|
||||||
|
"confirmedIllegal": "Confirmed Illegal",
|
||||||
|
"falsePositive": "False Positive"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"CRITICAL": "Critical",
|
||||||
|
"HIGH": "High",
|
||||||
|
"MEDIUM": "Medium",
|
||||||
|
"LOW": "Low"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "Failed to load collisions",
|
||||||
|
"resolveFailed": "Failed to save classification"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"darkVessel": "다크베셀 탐지",
|
"darkVessel": "다크베셀 탐지",
|
||||||
"gearDetection": "어구 탐지",
|
"gearDetection": "어구 탐지",
|
||||||
"chinaFishing": "중국어선 분석",
|
"chinaFishing": "중국어선 분석",
|
||||||
|
"gearCollision": "어구 정체성 충돌",
|
||||||
"patrolRoute": "순찰경로 추천",
|
"patrolRoute": "순찰경로 추천",
|
||||||
"fleetOptimization": "다함정 최적화",
|
"fleetOptimization": "다함정 최적화",
|
||||||
"enforcementHistory": "단속 이력",
|
"enforcementHistory": "단속 이력",
|
||||||
|
|||||||
@ -14,5 +14,77 @@
|
|||||||
"gearId": {
|
"gearId": {
|
||||||
"title": "어구 식별 분석",
|
"title": "어구 식별 분석",
|
||||||
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
|
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
|
||||||
|
},
|
||||||
|
"gearCollision": {
|
||||||
|
"title": "어구 정체성 충돌 탐지",
|
||||||
|
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",
|
||||||
|
"stats": {
|
||||||
|
"title": "현황 요약",
|
||||||
|
"total": "전체",
|
||||||
|
"open": "미검토",
|
||||||
|
"reviewed": "검토됨",
|
||||||
|
"confirmed": "불법 확정",
|
||||||
|
"falsePositive": "오탐"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "충돌 이력",
|
||||||
|
"empty": "최근 {{hours}}시간 내 감지된 충돌이 없습니다.",
|
||||||
|
"refresh": "새로고침"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"name": "어구명",
|
||||||
|
"mmsiPair": "MMSI 쌍",
|
||||||
|
"parentName": "추정 모선",
|
||||||
|
"coexistenceCount": "공존 횟수",
|
||||||
|
"maxDistance": "최대 거리(km)",
|
||||||
|
"severity": "심각도",
|
||||||
|
"status": "상태",
|
||||||
|
"lastSeen": "마지막 감지",
|
||||||
|
"actions": "액션"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"status": "상태",
|
||||||
|
"severity": "심각도",
|
||||||
|
"name": "어구명 검색",
|
||||||
|
"hours": "조회 기간(시간)",
|
||||||
|
"allStatus": "전체 상태",
|
||||||
|
"allSeverity": "전체 심각도"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "공존 상세",
|
||||||
|
"evidence": "관측 이력",
|
||||||
|
"trajectoryCompare": "궤적 비교",
|
||||||
|
"firstSeenAt": "최초 감지",
|
||||||
|
"lastSeenAt": "마지막 감지",
|
||||||
|
"swapCount": "교체 누적"
|
||||||
|
},
|
||||||
|
"resolve": {
|
||||||
|
"title": "운영자 분류",
|
||||||
|
"reviewed": "검토 완료",
|
||||||
|
"confirmedIllegal": "불법으로 확정",
|
||||||
|
"falsePositive": "오탐으로 분류",
|
||||||
|
"reopen": "재오픈",
|
||||||
|
"note": "판정 메모",
|
||||||
|
"notePlaceholder": "분류 사유·추가 증거 등을 기록하세요",
|
||||||
|
"submit": "저장",
|
||||||
|
"cancel": "취소",
|
||||||
|
"confirmPrompt": "선택한 분류로 상태를 갱신합니다. 계속할까요?"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"open": "미검토",
|
||||||
|
"reviewed": "검토됨",
|
||||||
|
"confirmedIllegal": "불법 확정",
|
||||||
|
"falsePositive": "오탐"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"CRITICAL": "매우 심각",
|
||||||
|
"HIGH": "심각",
|
||||||
|
"MEDIUM": "주의",
|
||||||
|
"LOW": "경미"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "충돌 목록을 불러오지 못했습니다",
|
||||||
|
"resolveFailed": "분류 저장에 실패했습니다"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
frontend/src/services/gearCollisionApi.ts
Normal file
114
frontend/src/services/gearCollisionApi.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* gear_identity_collisions 조회 + 분류 액션 API 서비스.
|
||||||
|
* 백엔드 /api/analysis/gear-collisions 연동.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AnalysisPageResponse } from './analysisApi';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||||
|
|
||||||
|
// ─── DTO (백엔드 GearCollisionResponse 1:1 매핑) ─────────────
|
||||||
|
|
||||||
|
export interface GearCollision {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
mmsiLo: string;
|
||||||
|
mmsiHi: string;
|
||||||
|
parentName: string | null;
|
||||||
|
parentVesselId: number | null;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
coexistenceCount: number;
|
||||||
|
swapCount: number;
|
||||||
|
maxDistanceKm: number | null;
|
||||||
|
lastLatLo: number | null;
|
||||||
|
lastLonLo: number | null;
|
||||||
|
lastLatHi: number | null;
|
||||||
|
lastLonHi: number | null;
|
||||||
|
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | string;
|
||||||
|
status: 'OPEN' | 'REVIEWED' | 'CONFIRMED_ILLEGAL' | 'FALSE_POSITIVE' | string;
|
||||||
|
resolutionNote: string | null;
|
||||||
|
evidence: Array<Record<string, unknown>> | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GearCollisionStats {
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
bySeverity: Record<string, number>;
|
||||||
|
hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GearCollisionResolveAction =
|
||||||
|
| 'REVIEWED'
|
||||||
|
| 'CONFIRMED_ILLEGAL'
|
||||||
|
| 'FALSE_POSITIVE'
|
||||||
|
| 'REOPEN';
|
||||||
|
|
||||||
|
export interface GearCollisionResolveRequest {
|
||||||
|
action: GearCollisionResolveAction;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 헬퍼 ─────────────
|
||||||
|
|
||||||
|
function buildQuery(params: Record<string, unknown>): string {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === undefined || v === null || v === '') continue;
|
||||||
|
qs.set(k, String(v));
|
||||||
|
}
|
||||||
|
const s = qs.toString();
|
||||||
|
return s ? `?${s}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGet<T>(path: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}${buildQuery(params)}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 공개 함수 ─────────────
|
||||||
|
|
||||||
|
/** 어구 정체성 충돌 목록 조회 (필터 + 페이징). */
|
||||||
|
export function listGearCollisions(params?: {
|
||||||
|
status?: string;
|
||||||
|
severity?: string;
|
||||||
|
name?: string;
|
||||||
|
hours?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}): Promise<AnalysisPageResponse<GearCollision>> {
|
||||||
|
return apiGet('/analysis/gear-collisions', {
|
||||||
|
hours: 48, page: 0, size: 50, ...params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** status/severity 집계 */
|
||||||
|
export function getGearCollisionStats(hours = 48): Promise<GearCollisionStats> {
|
||||||
|
return apiGet('/analysis/gear-collisions/stats', { hours });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단건 상세 조회 */
|
||||||
|
export function getGearCollision(id: number): Promise<GearCollision> {
|
||||||
|
return apiGet(`/analysis/gear-collisions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 운영자 분류 액션 */
|
||||||
|
export function resolveGearCollision(
|
||||||
|
id: number,
|
||||||
|
body: GearCollisionResolveRequest,
|
||||||
|
): Promise<GearCollision> {
|
||||||
|
return apiPost(`/analysis/gear-collisions/${id}/resolve`, body);
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ import { ZONE_CODES } from './zoneCodes';
|
|||||||
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
||||||
import { VESSEL_TYPES } from './vesselTypes';
|
import { VESSEL_TYPES } from './vesselTypes';
|
||||||
import { PERFORMANCE_STATUS_META } from './performanceStatus';
|
import { PERFORMANCE_STATUS_META } from './performanceStatus';
|
||||||
|
import { GEAR_COLLISION_STATUSES } from './gearCollisionStatuses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
||||||
@ -337,6 +338,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
|
|||||||
source: 'admin 성능·데이터 보관·모델 검증 공통',
|
source: 'admin 성능·데이터 보관·모델 검증 공통',
|
||||||
items: PERFORMANCE_STATUS_META,
|
items: PERFORMANCE_STATUS_META,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'gear-collision-status',
|
||||||
|
showcaseId: 'TRK-CAT-gear-collision-status',
|
||||||
|
titleKo: '어구 정체성 충돌 상태',
|
||||||
|
titleEn: 'Gear Identity Collision Status',
|
||||||
|
description: 'OPEN / REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE',
|
||||||
|
source: 'backend gear_identity_collisions.status (V030)',
|
||||||
|
items: GEAR_COLLISION_STATUSES,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** ID로 특정 카탈로그 조회 */
|
/** ID로 특정 카탈로그 조회 */
|
||||||
|
|||||||
74
frontend/src/shared/constants/gearCollisionStatuses.ts
Normal file
74
frontend/src/shared/constants/gearCollisionStatuses.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 운영자 분류 상태 카탈로그
|
||||||
|
*
|
||||||
|
* SSOT: backend GearIdentityCollision.status 컬럼 (V030 마이그레이션)
|
||||||
|
* 사용처: GearCollisionDetection 페이지 필터/테이블 Badge
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
|
|
||||||
|
export type GearCollisionStatus =
|
||||||
|
| 'OPEN' // 신규 탐지 (미검토)
|
||||||
|
| 'REVIEWED' // 검토됨 (확정 보류)
|
||||||
|
| 'CONFIRMED_ILLEGAL' // 불법 확정
|
||||||
|
| 'FALSE_POSITIVE'; // 오탐 처리
|
||||||
|
|
||||||
|
export interface GearCollisionStatusMeta {
|
||||||
|
code: GearCollisionStatus;
|
||||||
|
i18nKey: string;
|
||||||
|
fallback: { ko: string; en: string };
|
||||||
|
intent: BadgeIntent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GEAR_COLLISION_STATUSES: Record<GearCollisionStatus, GearCollisionStatusMeta> = {
|
||||||
|
OPEN: {
|
||||||
|
code: 'OPEN',
|
||||||
|
i18nKey: 'gearCollision.status.open',
|
||||||
|
fallback: { ko: '미검토', en: 'Open' },
|
||||||
|
intent: 'warning',
|
||||||
|
},
|
||||||
|
REVIEWED: {
|
||||||
|
code: 'REVIEWED',
|
||||||
|
i18nKey: 'gearCollision.status.reviewed',
|
||||||
|
fallback: { ko: '검토됨', en: 'Reviewed' },
|
||||||
|
intent: 'info',
|
||||||
|
},
|
||||||
|
CONFIRMED_ILLEGAL: {
|
||||||
|
code: 'CONFIRMED_ILLEGAL',
|
||||||
|
i18nKey: 'gearCollision.status.confirmedIllegal',
|
||||||
|
fallback: { ko: '불법 확정', en: 'Confirmed Illegal' },
|
||||||
|
intent: 'critical',
|
||||||
|
},
|
||||||
|
FALSE_POSITIVE: {
|
||||||
|
code: 'FALSE_POSITIVE',
|
||||||
|
i18nKey: 'gearCollision.status.falsePositive',
|
||||||
|
fallback: { ko: '오탐', en: 'False Positive' },
|
||||||
|
intent: 'muted',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getGearCollisionStatusMeta(s: string): GearCollisionStatusMeta | undefined {
|
||||||
|
return GEAR_COLLISION_STATUSES[s as GearCollisionStatus];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGearCollisionStatusIntent(s: string): BadgeIntent {
|
||||||
|
return getGearCollisionStatusMeta(s)?.intent ?? 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGearCollisionStatusLabel(
|
||||||
|
s: string,
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||||
|
lang: 'ko' | 'en' = 'ko',
|
||||||
|
): string {
|
||||||
|
const meta = getGearCollisionStatusMeta(s);
|
||||||
|
if (!meta) return s;
|
||||||
|
const translated = t(meta.i18nKey, { defaultValue: '' });
|
||||||
|
return translated || meta.fallback[lang];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GEAR_COLLISION_STATUS_ORDER: GearCollisionStatus[] = [
|
||||||
|
'OPEN',
|
||||||
|
'REVIEWED',
|
||||||
|
'CONFIRMED_ILLEGAL',
|
||||||
|
'FALSE_POSITIVE',
|
||||||
|
];
|
||||||
157
prediction/algorithms/gear_identity.py
Normal file
157
prediction/algorithms/gear_identity.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 알고리즘.
|
||||||
|
|
||||||
|
동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는 케이스를
|
||||||
|
스푸핑/복제 의심 패턴으로 탐지한다. fleet_tracker.track_gear_identity() 루프 진입
|
||||||
|
전에 사이클 단위로 사전 집계하는 데 사용된다.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from itertools import combinations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from algorithms.location import haversine_nm
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# 공존 판정 · 심각도 임계
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
MIN_COEXISTENCE_GROUP = 2 # 같은 이름에 MMSI 2개 이상
|
||||||
|
IMPOSSIBLE_SPEED_KTS = 60.0 # 두 위치 이동에 필요한 속도가 이보다 크면 물리 불가능
|
||||||
|
CRITICAL_DISTANCE_KM = 50.0 # 단발이라도 이 거리 이상이면 즉시 CRITICAL
|
||||||
|
HIGH_DISTANCE_KM = 10.0 # HIGH 기준 거리
|
||||||
|
CRITICAL_COEXISTENCE_COUNT = 3 # 누적 공존 N회 이상이면 CRITICAL 승격
|
||||||
|
HIGH_COEXISTENCE_COUNT = 2 # 누적 공존 N회 이상이면 HIGH
|
||||||
|
NM_TO_KM = 1.852 # 1 nautical mile = 1.852 km
|
||||||
|
|
||||||
|
|
||||||
|
def detect_gear_name_collisions(
|
||||||
|
gear_signals: list[dict],
|
||||||
|
now: datetime,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""동일 이름 · 다중 MMSI 공존 세트 추출.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gear_signals: [{mmsi, name, lat, lon}, ...] — track_gear_identity 와 동일 입력.
|
||||||
|
now: 사이클 기준 시각(UTC).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
공존 쌍 리스트. 세 개 이상 동시 송출 케이스는 모든 2-조합을 생성한다.
|
||||||
|
각 원소:
|
||||||
|
{
|
||||||
|
'name': str,
|
||||||
|
'mmsi_lo': str, # 사전순으로 작은 MMSI
|
||||||
|
'mmsi_hi': str,
|
||||||
|
'lat_lo', 'lon_lo': float,
|
||||||
|
'lat_hi', 'lon_hi': float,
|
||||||
|
'distance_km': float,
|
||||||
|
'parent_name': Optional[str], # 힌트 (GEAR_PATTERN parent 그룹, 있으면)
|
||||||
|
'observed_at': datetime,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not gear_signals:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 이름 기준 그룹핑
|
||||||
|
by_name: dict[str, list[dict]] = {}
|
||||||
|
for sig in gear_signals:
|
||||||
|
name = sig.get('name')
|
||||||
|
mmsi = sig.get('mmsi')
|
||||||
|
if not name or not mmsi:
|
||||||
|
continue
|
||||||
|
by_name.setdefault(name, []).append(sig)
|
||||||
|
|
||||||
|
collisions: list[dict] = []
|
||||||
|
for name, signals in by_name.items():
|
||||||
|
if len(signals) < MIN_COEXISTENCE_GROUP:
|
||||||
|
continue
|
||||||
|
# 같은 MMSI 중복은 제거 (한 cycle 에 동일 MMSI 가 다수 신호로 들어올 수 있음)
|
||||||
|
unique_by_mmsi: dict[str, dict] = {}
|
||||||
|
for sig in signals:
|
||||||
|
unique_by_mmsi.setdefault(sig['mmsi'], sig)
|
||||||
|
if len(unique_by_mmsi) < MIN_COEXISTENCE_GROUP:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parent_name = _infer_parent_name(name)
|
||||||
|
mmsis = sorted(unique_by_mmsi.keys())
|
||||||
|
for a, b in combinations(mmsis, 2):
|
||||||
|
sa, sb = unique_by_mmsi[a], unique_by_mmsi[b]
|
||||||
|
dist_km = _haversine_km(
|
||||||
|
sa.get('lat'), sa.get('lon'),
|
||||||
|
sb.get('lat'), sb.get('lon'),
|
||||||
|
)
|
||||||
|
collisions.append({
|
||||||
|
'name': name,
|
||||||
|
'mmsi_lo': a,
|
||||||
|
'mmsi_hi': b,
|
||||||
|
'lat_lo': _to_float(sa.get('lat')),
|
||||||
|
'lon_lo': _to_float(sa.get('lon')),
|
||||||
|
'lat_hi': _to_float(sb.get('lat')),
|
||||||
|
'lon_hi': _to_float(sb.get('lon')),
|
||||||
|
'distance_km': dist_km,
|
||||||
|
'parent_name': parent_name,
|
||||||
|
'observed_at': now,
|
||||||
|
})
|
||||||
|
return collisions
|
||||||
|
|
||||||
|
|
||||||
|
def classify_severity(
|
||||||
|
coexistence_count: int,
|
||||||
|
max_distance_km: Optional[float],
|
||||||
|
swap_count: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""충돌 심각도 산정.
|
||||||
|
|
||||||
|
- CRITICAL: 거리 불가능 / 누적 공존 N회 이상
|
||||||
|
- HIGH: 상당 거리 / 2회 이상
|
||||||
|
- MEDIUM: 단발 근거리
|
||||||
|
- LOW: 근거리 + 거리 정보 없음
|
||||||
|
"""
|
||||||
|
distance = max_distance_km or 0.0
|
||||||
|
if distance >= CRITICAL_DISTANCE_KM:
|
||||||
|
return 'CRITICAL'
|
||||||
|
if coexistence_count >= CRITICAL_COEXISTENCE_COUNT:
|
||||||
|
return 'CRITICAL'
|
||||||
|
if distance >= HIGH_DISTANCE_KM:
|
||||||
|
return 'HIGH'
|
||||||
|
if coexistence_count >= HIGH_COEXISTENCE_COUNT:
|
||||||
|
return 'HIGH'
|
||||||
|
if swap_count >= HIGH_COEXISTENCE_COUNT:
|
||||||
|
return 'HIGH'
|
||||||
|
if max_distance_km is None or max_distance_km < 0.1:
|
||||||
|
return 'LOW'
|
||||||
|
return 'MEDIUM'
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
||||||
|
"""두 좌표 사이 거리를 km 로 반환. 입력 누락 시 0.0."""
|
||||||
|
try:
|
||||||
|
if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
|
||||||
|
return 0.0
|
||||||
|
nm = haversine_nm(float(lat1), float(lon1), float(lat2), float(lon2))
|
||||||
|
return round(nm * NM_TO_KM, 2)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(val) -> Optional[float]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_parent_name(gear_name: str) -> Optional[str]:
|
||||||
|
"""어구 이름에서 모선명 부분 추출 (느슨).
|
||||||
|
|
||||||
|
fleet_tracker 가 이미 GEAR_PATTERN 으로 정교하게 파싱하지만, 알고리즘 모듈 독립성을
|
||||||
|
위해 단순 휴리스틱만 유지한다. 값이 필요한 경우 fleet_tracker 호출부에서 덮어쓴다.
|
||||||
|
"""
|
||||||
|
if not gear_name:
|
||||||
|
return None
|
||||||
|
# '_숫자' 로 끝나는 서픽스 제거
|
||||||
|
base = gear_name
|
||||||
|
parts = base.rsplit('_', 2)
|
||||||
|
if len(parts) >= 2 and any(ch.isdigit() for ch in parts[-1]):
|
||||||
|
return parts[0]
|
||||||
|
return None
|
||||||
@ -1,4 +1,5 @@
|
|||||||
"""등록 선단 기반 추적기."""
|
"""등록 선단 기반 추적기."""
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@ -6,7 +7,9 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
from algorithms.gear_identity import classify_severity, detect_gear_name_collisions
|
||||||
from algorithms.gear_name_rules import is_trackable_parent_name
|
from algorithms.gear_name_rules import is_trackable_parent_name
|
||||||
from config import qualified_table
|
from config import qualified_table
|
||||||
|
|
||||||
@ -21,6 +24,7 @@ FLEET_COMPANIES = qualified_table('fleet_companies')
|
|||||||
FLEET_VESSELS = qualified_table('fleet_vessels')
|
FLEET_VESSELS = qualified_table('fleet_vessels')
|
||||||
GEAR_IDENTITY_LOG = qualified_table('gear_identity_log')
|
GEAR_IDENTITY_LOG = qualified_table('gear_identity_log')
|
||||||
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
||||||
|
GEAR_IDENTITY_COLLISIONS = qualified_table('gear_identity_collisions')
|
||||||
FLEET_TRACKING_SNAPSHOT = qualified_table('fleet_tracking_snapshot')
|
FLEET_TRACKING_SNAPSHOT = qualified_table('fleet_tracking_snapshot')
|
||||||
|
|
||||||
# 선박명 정규화 (중국/한국 어선 식별자 보존):
|
# 선박명 정규화 (중국/한국 어선 식별자 보존):
|
||||||
@ -341,61 +345,40 @@ class FleetTracker:
|
|||||||
"""어구/어망 정체성 추적.
|
"""어구/어망 정체성 추적.
|
||||||
|
|
||||||
gear_signals: [{mmsi, name, lat, lon}, ...] — 이름이 XXX_숫자_숫자 패턴인 AIS 신호
|
gear_signals: [{mmsi, name, lat, lon}, ...] — 이름이 XXX_숫자_숫자 패턴인 AIS 신호
|
||||||
|
|
||||||
|
동일 이름이 서로 다른 MMSI 로 같은 cycle 에 동시에 수신된 경우는 "공존
|
||||||
|
(GEAR_IDENTITY_COLLISION)" 으로 간주해 gear_identity_collisions 에 누적하고,
|
||||||
|
gear_identity_log 는 각 MMSI 별로 독립 active 유지한다 (비활성화/점수 이전 X).
|
||||||
|
단일 MMSI 이름에 한해 기존 교체(sequential) 로직을 적용한다.
|
||||||
"""
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
self._recent_collision_ids: list[int] = []
|
||||||
|
|
||||||
|
# 1) 공존 감지 + UPSERT (이번 사이클에 동일 이름 다중 MMSI 수신 확인)
|
||||||
|
collisions = detect_gear_name_collisions(gear_signals, now)
|
||||||
|
colliding_names: set[str] = {c['name'] for c in collisions}
|
||||||
|
for c in collisions:
|
||||||
|
cid = self._upsert_gear_collision(cur, c, now)
|
||||||
|
if cid is not None:
|
||||||
|
self._recent_collision_ids.append(cid)
|
||||||
|
|
||||||
|
# 2) 개별 신호 처리
|
||||||
for g in gear_signals:
|
for g in gear_signals:
|
||||||
mmsi = g['mmsi']
|
mmsi = g['mmsi']
|
||||||
name = g['name']
|
name = g['name']
|
||||||
lat = g.get('lat', 0)
|
lat = g.get('lat', 0)
|
||||||
lon = g.get('lon', 0)
|
lon = g.get('lon', 0)
|
||||||
|
|
||||||
# 모선명 + 인덱스 추출
|
parent_name, idx1, idx2 = self._parse_gear_name(name)
|
||||||
parent_name: Optional[str] = None
|
|
||||||
idx1: Optional[int] = None
|
|
||||||
idx2: Optional[int] = None
|
|
||||||
|
|
||||||
m = GEAR_PATTERN.match(name)
|
|
||||||
if m:
|
|
||||||
# group(1): parent+index 패턴, group(2): 순수 숫자 패턴
|
|
||||||
if m.group(1):
|
|
||||||
parent_name = m.group(1).strip()
|
|
||||||
suffix = name[m.end(1):].strip(' _')
|
|
||||||
digits = re.findall(r'\d+', suffix)
|
|
||||||
idx1 = int(digits[0]) if len(digits) >= 1 else None
|
|
||||||
idx2 = int(digits[1]) if len(digits) >= 2 else None
|
|
||||||
else:
|
|
||||||
# 순수 숫자 이름 (예: 12345) — parent 없음, 인덱스만
|
|
||||||
idx1 = int(m.group(2))
|
|
||||||
else:
|
|
||||||
m2 = GEAR_PATTERN_PCT.match(name)
|
|
||||||
if m2:
|
|
||||||
parent_name = m2.group(1).strip()
|
|
||||||
|
|
||||||
effective_parent_name = parent_name or name
|
effective_parent_name = parent_name or name
|
||||||
if not is_trackable_parent_name(effective_parent_name):
|
if not is_trackable_parent_name(effective_parent_name):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 모선 매칭 (EXACT → FUZZY 순)
|
parent_mmsi, parent_vid = self._match_parent_vessel(parent_name)
|
||||||
parent_mmsi: Optional[str] = None
|
|
||||||
parent_vid: Optional[int] = None
|
|
||||||
if parent_name:
|
|
||||||
vid = self._name_cn_map.get(parent_name)
|
|
||||||
if not vid:
|
|
||||||
vid = self._name_en_map.get(parent_name.lower())
|
|
||||||
if not vid:
|
|
||||||
key = _normalize_vessel_name(parent_name)
|
|
||||||
if key:
|
|
||||||
candidates = self._name_fuzzy_map.get(key, [])
|
|
||||||
if len(candidates) == 1:
|
|
||||||
vid = candidates[0]
|
|
||||||
if vid:
|
|
||||||
parent_vid = vid
|
|
||||||
parent_mmsi = self._vessels[vid].get('mmsi')
|
|
||||||
|
|
||||||
match_method: Optional[str] = 'NAME_PARENT' if parent_vid else None
|
match_method: Optional[str] = 'NAME_PARENT' if parent_vid else None
|
||||||
confidence = 0.9 if parent_vid else 0.0
|
confidence = 0.9 if parent_vid else 0.0
|
||||||
|
is_colliding = name in colliding_names
|
||||||
|
|
||||||
# 기존 활성 행 조회
|
# 기존 활성 행 조회
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@ -431,7 +414,9 @@ class FleetTracker:
|
|||||||
match_method, confidence, now, now),
|
match_method, confidence, now, now),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인
|
# 새 MMSI — 이름이 공존 케이스면 다른 MMSI 활성행을 건드리지 않고 이번 것만 INSERT
|
||||||
|
if not is_colliding:
|
||||||
|
# 교체 경로 — 같은 이름이 다른 MMSI 로 active 면 MMSI 교체로 간주
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
|
f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
|
||||||
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
|
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
|
||||||
@ -439,28 +424,16 @@ class FleetTracker:
|
|||||||
)
|
)
|
||||||
old_mmsi_row = cur.fetchone()
|
old_mmsi_row = cur.fetchone()
|
||||||
if old_mmsi_row:
|
if old_mmsi_row:
|
||||||
# 같은 이름 + 다른 MMSI → MMSI 변경
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
|
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
|
||||||
(old_mmsi_row[0],),
|
(old_mmsi_row[0],),
|
||||||
)
|
)
|
||||||
logger.info('gear MMSI change: %s → %s (name=%s)', old_mmsi_row[1], mmsi, name)
|
|
||||||
|
|
||||||
# 어피니티 점수 이전 (이전 MMSI → 새 MMSI)
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE {GEAR_CORRELATION_SCORES} "
|
|
||||||
"SET target_mmsi = %s, updated_at = NOW() "
|
|
||||||
"WHERE target_mmsi = %s",
|
|
||||||
(mmsi, old_mmsi_row[1]),
|
|
||||||
)
|
|
||||||
if cur.rowcount > 0:
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'transferred %d affinity scores: %s → %s',
|
'gear MMSI change: %s → %s (name=%s)',
|
||||||
cur.rowcount, old_mmsi_row[1], mmsi,
|
old_mmsi_row[1], mmsi, name,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
# 어피니티 점수 이전 (savepoint 로 격리 — PK 충돌 시 트랜잭션 유지)
|
||||||
logger.warning('affinity score transfer failed: %s', e)
|
self._transfer_affinity_scores(cur, old_mmsi_row[1], mmsi)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""INSERT INTO {GEAR_IDENTITY_LOG}
|
f"""INSERT INTO {GEAR_IDENTITY_LOG}
|
||||||
@ -473,9 +446,190 @@ class FleetTracker:
|
|||||||
match_method, confidence, now, now),
|
match_method, confidence, now, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if collisions:
|
||||||
|
logger.info(
|
||||||
|
'gear identity collisions: %d pairs touched (%d distinct names)',
|
||||||
|
len(collisions), len(colliding_names),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
|
def _parse_gear_name(self, name: str) -> tuple[Optional[str], Optional[int], Optional[int]]:
|
||||||
|
"""어구 이름에서 parent_name, idx1, idx2 추출."""
|
||||||
|
parent_name: Optional[str] = None
|
||||||
|
idx1: Optional[int] = None
|
||||||
|
idx2: Optional[int] = None
|
||||||
|
m = GEAR_PATTERN.match(name)
|
||||||
|
if m:
|
||||||
|
if m.group(1):
|
||||||
|
parent_name = m.group(1).strip()
|
||||||
|
suffix = name[m.end(1):].strip(' _')
|
||||||
|
digits = re.findall(r'\d+', suffix)
|
||||||
|
idx1 = int(digits[0]) if len(digits) >= 1 else None
|
||||||
|
idx2 = int(digits[1]) if len(digits) >= 2 else None
|
||||||
|
else:
|
||||||
|
idx1 = int(m.group(2))
|
||||||
|
else:
|
||||||
|
m2 = GEAR_PATTERN_PCT.match(name)
|
||||||
|
if m2:
|
||||||
|
parent_name = m2.group(1).strip()
|
||||||
|
return parent_name, idx1, idx2
|
||||||
|
|
||||||
|
def _match_parent_vessel(self, parent_name: Optional[str]) -> tuple[Optional[str], Optional[int]]:
|
||||||
|
"""parent_name → (parent_mmsi, parent_vessel_id) 매칭. EXACT → FUZZY 순."""
|
||||||
|
if not parent_name:
|
||||||
|
return None, None
|
||||||
|
vid = self._name_cn_map.get(parent_name)
|
||||||
|
if not vid:
|
||||||
|
vid = self._name_en_map.get(parent_name.lower())
|
||||||
|
if not vid:
|
||||||
|
key = _normalize_vessel_name(parent_name)
|
||||||
|
if key:
|
||||||
|
candidates = self._name_fuzzy_map.get(key, [])
|
||||||
|
if len(candidates) == 1:
|
||||||
|
vid = candidates[0]
|
||||||
|
if vid:
|
||||||
|
return self._vessels[vid].get('mmsi'), vid
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _transfer_affinity_scores(self, cur, old_mmsi: str, new_mmsi: str) -> None:
|
||||||
|
"""gear_correlation_scores 의 target_mmsi 를 새 MMSI 로 이전.
|
||||||
|
|
||||||
|
PK = (model_id, group_key, sub_cluster_id, target_mmsi) 충돌 가능성이 있어
|
||||||
|
SAVEPOINT 로 격리. 충돌 시에는 이전을 포기하고 경고만 남긴다 — 상위 트랜잭션은
|
||||||
|
유지되어 뒤이은 INSERT 가 정상 동작한다.
|
||||||
|
"""
|
||||||
|
cur.execute('SAVEPOINT sp_score_xfer')
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE {GEAR_CORRELATION_SCORES} "
|
||||||
|
"SET target_mmsi = %s, updated_at = NOW() "
|
||||||
|
"WHERE target_mmsi = %s",
|
||||||
|
(new_mmsi, old_mmsi),
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
logger.info(
|
||||||
|
'transferred %d affinity scores: %s → %s',
|
||||||
|
cur.rowcount, old_mmsi, new_mmsi,
|
||||||
|
)
|
||||||
|
cur.execute('RELEASE SAVEPOINT sp_score_xfer')
|
||||||
|
except psycopg2.errors.UniqueViolation as e:
|
||||||
|
cur.execute('ROLLBACK TO SAVEPOINT sp_score_xfer')
|
||||||
|
cur.execute('RELEASE SAVEPOINT sp_score_xfer')
|
||||||
|
logger.warning(
|
||||||
|
'affinity score transfer skipped (pk conflict): %s → %s | %s',
|
||||||
|
old_mmsi, new_mmsi, e,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _upsert_gear_collision(
|
||||||
|
self,
|
||||||
|
cur,
|
||||||
|
collision: dict,
|
||||||
|
now: datetime,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""단일 공존 쌍 UPSERT → row id 반환.
|
||||||
|
|
||||||
|
- 기존 행: coexistence_count += 1, last_seen_at/좌표 갱신, max_distance_km =
|
||||||
|
GREATEST(기존, 신규). status 가 OPEN/REVIEWED 인 경우에 한해 severity 재계산.
|
||||||
|
- 신규 행: first_seen_at = last_seen_at = now, severity 계산 후 INSERT.
|
||||||
|
"""
|
||||||
|
name = collision['name']
|
||||||
|
mmsi_lo = collision['mmsi_lo']
|
||||||
|
mmsi_hi = collision['mmsi_hi']
|
||||||
|
distance_km = collision.get('distance_km') or 0.0
|
||||||
|
|
||||||
|
# 모선 힌트 — fleet_tracker 매칭 결과를 우선, 없으면 알고리즘이 추출한 값 사용
|
||||||
|
hint_parent = collision.get('parent_name')
|
||||||
|
parent_mmsi, parent_vid = self._match_parent_vessel(hint_parent)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""SELECT id, coexistence_count, max_distance_km, swap_count,
|
||||||
|
status, parent_name, parent_vessel_id
|
||||||
|
FROM {GEAR_IDENTITY_COLLISIONS}
|
||||||
|
WHERE name = %s AND mmsi_lo = %s AND mmsi_hi = %s""",
|
||||||
|
(name, mmsi_lo, mmsi_hi),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
evidence_fragment = {
|
||||||
|
'observed_at': now.isoformat(),
|
||||||
|
'distance_km': distance_km,
|
||||||
|
'positions': {
|
||||||
|
mmsi_lo: {
|
||||||
|
'lat': collision.get('lat_lo'),
|
||||||
|
'lon': collision.get('lon_lo'),
|
||||||
|
},
|
||||||
|
mmsi_hi: {
|
||||||
|
'lat': collision.get('lat_hi'),
|
||||||
|
'lon': collision.get('lon_hi'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if row:
|
||||||
|
(row_id, cur_count, cur_max_dist, swap_cnt,
|
||||||
|
cur_status, cur_parent_name, cur_parent_vid) = row
|
||||||
|
new_count = (cur_count or 0) + 1
|
||||||
|
merged_max = max(
|
||||||
|
float(cur_max_dist or 0),
|
||||||
|
float(distance_km or 0),
|
||||||
|
)
|
||||||
|
if cur_status in ('CONFIRMED_ILLEGAL', 'FALSE_POSITIVE'):
|
||||||
|
new_severity = None # 확정 상태는 유지
|
||||||
|
else:
|
||||||
|
new_severity = classify_severity(new_count, merged_max, swap_cnt or 0)
|
||||||
|
cur.execute(
|
||||||
|
f"""UPDATE {GEAR_IDENTITY_COLLISIONS}
|
||||||
|
SET last_seen_at = %s,
|
||||||
|
coexistence_count = %s,
|
||||||
|
max_distance_km = %s,
|
||||||
|
last_lat_lo = %s,
|
||||||
|
last_lon_lo = %s,
|
||||||
|
last_lat_hi = %s,
|
||||||
|
last_lon_hi = %s,
|
||||||
|
parent_name = COALESCE(%s, parent_name),
|
||||||
|
parent_vessel_id = COALESCE(%s, parent_vessel_id),
|
||||||
|
severity = COALESCE(%s, severity),
|
||||||
|
evidence = COALESCE(evidence, '[]'::jsonb)
|
||||||
|
|| to_jsonb(%s::jsonb),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s""",
|
||||||
|
(
|
||||||
|
now, new_count, merged_max,
|
||||||
|
collision.get('lat_lo'), collision.get('lon_lo'),
|
||||||
|
collision.get('lat_hi'), collision.get('lon_hi'),
|
||||||
|
hint_parent, parent_vid,
|
||||||
|
new_severity,
|
||||||
|
json.dumps([evidence_fragment]),
|
||||||
|
row_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return row_id
|
||||||
|
|
||||||
|
severity = classify_severity(1, distance_km, 0)
|
||||||
|
cur.execute(
|
||||||
|
f"""INSERT INTO {GEAR_IDENTITY_COLLISIONS}
|
||||||
|
(name, mmsi_lo, mmsi_hi, parent_name, parent_vessel_id,
|
||||||
|
first_seen_at, last_seen_at,
|
||||||
|
coexistence_count, max_distance_km,
|
||||||
|
last_lat_lo, last_lon_lo, last_lat_hi, last_lon_hi,
|
||||||
|
severity, status, evidence)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,1,%s,%s,%s,%s,%s,%s,'OPEN',%s)
|
||||||
|
RETURNING id""",
|
||||||
|
(
|
||||||
|
name, mmsi_lo, mmsi_hi, hint_parent, parent_vid,
|
||||||
|
now, now, distance_km,
|
||||||
|
collision.get('lat_lo'), collision.get('lon_lo'),
|
||||||
|
collision.get('lat_hi'), collision.get('lon_hi'),
|
||||||
|
severity,
|
||||||
|
json.dumps([evidence_fragment]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
def get_recent_collision_ids(self) -> list[int]:
|
||||||
|
"""이번 사이클에 갱신·추가된 gear_identity_collisions row id 목록."""
|
||||||
|
return list(getattr(self, '_recent_collision_ids', []))
|
||||||
|
|
||||||
def build_fleet_clusters(self, vessel_dfs: dict[str, pd.DataFrame]) -> dict[str, dict]:
|
def build_fleet_clusters(self, vessel_dfs: dict[str, pd.DataFrame]) -> dict[str, dict]:
|
||||||
"""등록 선단 기준으로 cluster 정보 구성.
|
"""등록 선단 기준으로 cluster 정보 구성.
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from db.kcgdb import get_conn
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
EVENTS_TABLE = qualified_table('prediction_events')
|
EVENTS_TABLE = qualified_table('prediction_events')
|
||||||
|
GEAR_IDENTITY_COLLISIONS_TABLE = qualified_table('gear_identity_collisions')
|
||||||
|
|
||||||
# 카테고리별 dedup 윈도우 (분).
|
# 카테고리별 dedup 윈도우 (분).
|
||||||
# 사이클이 5분 간격이므로 5의 배수를 피해서 boundary 일제 만료 패턴을 회피한다.
|
# 사이클이 5분 간격이므로 5의 배수를 피해서 boundary 일제 만료 패턴을 회피한다.
|
||||||
@ -34,6 +35,7 @@ DEDUP_WINDOWS = {
|
|||||||
'GEAR_ILLEGAL': 367,
|
'GEAR_ILLEGAL': 367,
|
||||||
'AIS_RESUME': 67,
|
'AIS_RESUME': 67,
|
||||||
'HIGH_RISK_VESSEL': 67,
|
'HIGH_RISK_VESSEL': 67,
|
||||||
|
'GEAR_IDENTITY_COLLISION': 367, # 같은 쌍의 반복 공존은 장기 이벤트로 집계
|
||||||
}
|
}
|
||||||
|
|
||||||
# 이벤트 생성 룰
|
# 이벤트 생성 룰
|
||||||
@ -294,3 +296,123 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
|
|||||||
|
|
||||||
logger.info(f'event_generator: generated={generated}, skipped_dedup={skipped_dedup}')
|
logger.info(f'event_generator: generated={generated}, skipped_dedup={skipped_dedup}')
|
||||||
return {'generated': generated, 'skipped_dedup': skipped_dedup}
|
return {'generated': generated, 'skipped_dedup': skipped_dedup}
|
||||||
|
|
||||||
|
|
||||||
|
_COLLISION_EVENT_LEVELS = {'CRITICAL', 'HIGH'}
|
||||||
|
|
||||||
|
|
||||||
|
def run_gear_identity_collision_events(collision_ids: list[int]) -> dict:
|
||||||
|
"""gear_identity_collisions 의 갱신 행을 prediction_events 로 승격.
|
||||||
|
|
||||||
|
이번 cycle 에 갱신·추가된 row 중 severity 가 CRITICAL/HIGH 이고 status=OPEN 인 것만
|
||||||
|
이벤트 허브에 등록한다. dedup 키는 `{name}:{mmsi_lo}:{mmsi_hi}` 기준으로 장기 윈도우.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collision_ids: fleet_tracker.get_recent_collision_ids() 가 돌려준 id 목록
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{ 'generated': int, 'skipped_dedup': int, 'skipped_low': int }
|
||||||
|
"""
|
||||||
|
if not collision_ids:
|
||||||
|
return {'generated': 0, 'skipped_dedup': 0, 'skipped_low': 0}
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
generated = 0
|
||||||
|
skipped_dedup = 0
|
||||||
|
skipped_low = 0
|
||||||
|
events_to_insert: list[tuple] = []
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
date_str = now.strftime('%Y%m%d')
|
||||||
|
seq = _get_next_seq(conn, date_str)
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
f"""SELECT id, name, mmsi_lo, mmsi_hi, parent_name,
|
||||||
|
coexistence_count, max_distance_km, severity, status,
|
||||||
|
last_lat_lo, last_lon_lo
|
||||||
|
FROM {GEAR_IDENTITY_COLLISIONS_TABLE}
|
||||||
|
WHERE id = ANY(%s)""",
|
||||||
|
(list(collision_ids),),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
for (cid, name, mmsi_lo, mmsi_hi, parent_name,
|
||||||
|
coexist_count, max_dist, severity, status,
|
||||||
|
lat_lo, lon_lo) in rows:
|
||||||
|
if status != 'OPEN':
|
||||||
|
skipped_low += 1
|
||||||
|
continue
|
||||||
|
if severity not in _COLLISION_EVENT_LEVELS:
|
||||||
|
skipped_low += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
category = 'GEAR_IDENTITY_COLLISION'
|
||||||
|
dedup_key = f"{name}:{mmsi_lo}:{mmsi_hi}:{category}"
|
||||||
|
|
||||||
|
if _check_dedup(conn, dedup_key, category, now):
|
||||||
|
skipped_dedup += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_uid = _make_event_uid(now, seq)
|
||||||
|
seq += 1
|
||||||
|
|
||||||
|
distance_txt = f"{float(max_dist or 0):.1f}km"
|
||||||
|
title = (
|
||||||
|
f"어구 정체성 충돌: {name} "
|
||||||
|
f"({mmsi_lo} ↔ {mmsi_hi}, 공존 {coexist_count}회, 거리 {distance_txt})"
|
||||||
|
)
|
||||||
|
features = {
|
||||||
|
'collision_id': cid,
|
||||||
|
'name': name,
|
||||||
|
'mmsi_lo': mmsi_lo,
|
||||||
|
'mmsi_hi': mmsi_hi,
|
||||||
|
'coexistence_count': coexist_count,
|
||||||
|
'max_distance_km': float(max_dist or 0),
|
||||||
|
'parent_name': parent_name,
|
||||||
|
}
|
||||||
|
events_to_insert.append((
|
||||||
|
event_uid,
|
||||||
|
now,
|
||||||
|
severity,
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
None, # detail
|
||||||
|
mmsi_lo, # 대표 MMSI (검색 편의)
|
||||||
|
parent_name, # vessel_name
|
||||||
|
None, # area_name
|
||||||
|
None, # zone_code
|
||||||
|
float(lat_lo) if lat_lo is not None else None,
|
||||||
|
float(lon_lo) if lon_lo is not None else None,
|
||||||
|
None, # speed_kn
|
||||||
|
'GEAR_COLLISION', # source_type
|
||||||
|
cid, # source_ref_id
|
||||||
|
None, # ai_confidence
|
||||||
|
'NEW',
|
||||||
|
dedup_key,
|
||||||
|
json.dumps(features, ensure_ascii=False, default=str),
|
||||||
|
))
|
||||||
|
generated += 1
|
||||||
|
|
||||||
|
if events_to_insert:
|
||||||
|
execute_values(
|
||||||
|
conn.cursor(),
|
||||||
|
f"""INSERT INTO {EVENTS_TABLE}
|
||||||
|
(event_uid, occurred_at, level, category, title, detail,
|
||||||
|
vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn,
|
||||||
|
source_type, source_ref_id, ai_confidence, status, dedup_key, features)
|
||||||
|
VALUES %s
|
||||||
|
ON CONFLICT (event_uid) DO NOTHING""",
|
||||||
|
events_to_insert,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'gear_collision_events: generated=%d, skipped_dedup=%d, skipped_low=%d',
|
||||||
|
generated, skipped_dedup, skipped_low,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'generated': generated,
|
||||||
|
'skipped_dedup': skipped_dedup,
|
||||||
|
'skipped_low': skipped_low,
|
||||||
|
}
|
||||||
|
|||||||
@ -157,6 +157,21 @@ def run_analysis_cycle():
|
|||||||
gear_signals = [v for v in all_ais if GEAR_PATTERN.match(v.get('name', '') or '')]
|
gear_signals = [v for v in all_ais if GEAR_PATTERN.match(v.get('name', '') or '')]
|
||||||
fleet_tracker.track_gear_identity(gear_signals, kcg_conn)
|
fleet_tracker.track_gear_identity(gear_signals, kcg_conn)
|
||||||
|
|
||||||
|
# 이번 사이클에 갱신된 어구 정체성 충돌을 이벤트 허브로 승격 (CRITICAL/HIGH 만)
|
||||||
|
collision_ids = fleet_tracker.get_recent_collision_ids()
|
||||||
|
if collision_ids:
|
||||||
|
try:
|
||||||
|
from output.event_generator import run_gear_identity_collision_events
|
||||||
|
collision_events = run_gear_identity_collision_events(collision_ids)
|
||||||
|
logger.info(
|
||||||
|
'gear collision events: generated=%d, skipped_dedup=%d, skipped_low=%d',
|
||||||
|
collision_events['generated'],
|
||||||
|
collision_events['skipped_dedup'],
|
||||||
|
collision_events['skipped_low'],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('gear collision event promotion failed: %s', e)
|
||||||
|
|
||||||
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
|
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
|
||||||
|
|
||||||
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)
|
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user