feat: S1 백엔드 API — 이벤트/통계/단속/마스터 데이터 CRUD
이벤트 허브 (domain/event/): - PredictionEvent/EventWorkflow 엔티티 + JPA Specification 필터 - EventController: 목록/상세/이력/상태변경/통계 6개 엔드포인트 - 상태 변경 시 EventWorkflow 자동 기록 (감사 추적) 통계/KPI (domain/stats/): - PredictionKpi/StatsMonthly/StatsDaily 엔티티 - StatsController: KPI/월별/일별 통계 3개 엔드포인트 단속 이력/계획 (domain/enforcement/): - EnforcementRecord/Plan 엔티티 + UID 자동생성 - EnforcementController: 단속이력/계획 CRUD 6개 엔드포인트 - 단속 등록 시 이벤트 상태 자동 RESOLVED 연동 마스터 데이터 (master/): - CodeMaster/GearType/PatrolShip/VesselPermit 엔티티 + Repository - MasterDataController: 코드/어구유형/함정/선박허가 10개 엔드포인트 총 25개 신규 엔드포인트, 98개 Java 소스 파일 컴파일 성공. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
883b347359
커밋
91deb3ae55
@ -0,0 +1,97 @@
|
||||
package gc.mda.kcg.domain.enforcement;
|
||||
|
||||
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
|
||||
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
|
||||
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 단속 이력/계획 CRUD API.
|
||||
* enforcement_records, enforcement_plans 테이블 기반.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/enforcement")
|
||||
@RequiredArgsConstructor
|
||||
public class EnforcementController {
|
||||
|
||||
private final EnforcementService service;
|
||||
|
||||
// ========================================================================
|
||||
// 단속 이력 (Records)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 단속 이력 목록 조회 (violationType 필터, 페이징)
|
||||
*/
|
||||
@GetMapping("/records")
|
||||
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
|
||||
public Page<EnforcementRecord> listRecords(
|
||||
@RequestParam(required = false) String violationType,
|
||||
Pageable pageable
|
||||
) {
|
||||
return service.listRecords(violationType, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단속 이력 상세 조회
|
||||
*/
|
||||
@GetMapping("/records/{id}")
|
||||
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
|
||||
public EnforcementRecord getRecord(@PathVariable Long id) {
|
||||
return service.getRecord(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단속 이력 신규 등록. UID 자동 생성 (ENF-yyyyMMdd-NNNN).
|
||||
* event_id가 있으면 해당 prediction_events.status를 RESOLVED로 갱신.
|
||||
*/
|
||||
@PostMapping("/records")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(resource = "enforcement:enforcement-history", operation = "CREATE")
|
||||
public EnforcementRecord createRecord(@RequestBody CreateRecordRequest req) {
|
||||
return service.createRecord(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단속 이력 결과 수정 (result, ai_match_status, remarks)
|
||||
*/
|
||||
@PatchMapping("/records/{id}")
|
||||
@RequirePermission(resource = "enforcement:enforcement-history", operation = "UPDATE")
|
||||
public EnforcementRecord updateRecord(
|
||||
@PathVariable Long id,
|
||||
@RequestBody UpdateRecordRequest req
|
||||
) {
|
||||
return service.updateRecord(id, req);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 단속 계획 (Plans)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 단속 계획 목록 조회 (status 필터, 페이징)
|
||||
*/
|
||||
@GetMapping("/plans")
|
||||
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
|
||||
public Page<EnforcementPlan> listPlans(
|
||||
@RequestParam(required = false) String status,
|
||||
Pageable pageable
|
||||
) {
|
||||
return service.listPlans(status, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단속 계획 생성
|
||||
*/
|
||||
@PostMapping("/plans")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(resource = "enforcement:enforcement-history", operation = "CREATE")
|
||||
public EnforcementPlan createPlan(@RequestBody CreatePlanRequest req) {
|
||||
return service.createPlan(req);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package gc.mda.kcg.domain.enforcement;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 단속 계획.
|
||||
* 향후 단속 예정 계획을 관리.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "enforcement_plans", schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = "plan_uid"))
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class EnforcementPlan {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "plan_uid", nullable = false, length = 50, unique = true)
|
||||
private String planUid;
|
||||
|
||||
@Column(name = "title", length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(name = "zone_code", length = 30)
|
||||
private String zoneCode;
|
||||
|
||||
@Column(name = "area_name", length = 100)
|
||||
private String areaName;
|
||||
|
||||
@Column(name = "lat")
|
||||
private Double lat;
|
||||
|
||||
@Column(name = "lon")
|
||||
private Double lon;
|
||||
|
||||
@Column(name = "planned_date")
|
||||
private LocalDate plannedDate;
|
||||
|
||||
@Column(name = "planned_from")
|
||||
private OffsetDateTime plannedFrom;
|
||||
|
||||
@Column(name = "planned_to")
|
||||
private OffsetDateTime plannedTo;
|
||||
|
||||
@Column(name = "risk_level", length = 20)
|
||||
private String riskLevel;
|
||||
|
||||
@Column(name = "risk_score")
|
||||
private Integer riskScore;
|
||||
|
||||
@Column(name = "assigned_ship_count")
|
||||
private Integer assignedShipCount;
|
||||
|
||||
@Column(name = "assigned_crew")
|
||||
private Integer assignedCrew;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "alert_status", length = 20)
|
||||
private String alertStatus;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
@Column(name = "created_by")
|
||||
private UUID createdBy;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
@Column(name = "approved_by")
|
||||
private UUID approvedBy;
|
||||
|
||||
@Column(name = "remarks", columnDefinition = "text")
|
||||
private String remarks;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (createdAt == null) createdAt = now;
|
||||
if (updatedAt == null) updatedAt = now;
|
||||
if (status == null) status = "DRAFT";
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
package gc.mda.kcg.domain.enforcement;
|
||||
|
||||
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.UUID;
|
||||
|
||||
/**
|
||||
* 단속 이력.
|
||||
* 실제 단속 수행 기록을 저장.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "enforcement_records", schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = "enf_uid"))
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class EnforcementRecord {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "enf_uid", nullable = false, length = 50, unique = true)
|
||||
private String enfUid;
|
||||
|
||||
@Column(name = "event_id")
|
||||
private Long eventId;
|
||||
|
||||
@Column(name = "enforced_at")
|
||||
private OffsetDateTime enforcedAt;
|
||||
|
||||
@Column(name = "zone_code", length = 30)
|
||||
private String zoneCode;
|
||||
|
||||
@Column(name = "area_name", length = 100)
|
||||
private String areaName;
|
||||
|
||||
@Column(name = "lat")
|
||||
private Double lat;
|
||||
|
||||
@Column(name = "lon")
|
||||
private Double lon;
|
||||
|
||||
@Column(name = "vessel_mmsi", length = 20)
|
||||
private String vesselMmsi;
|
||||
|
||||
@Column(name = "vessel_name", length = 100)
|
||||
private String vesselName;
|
||||
|
||||
@Column(name = "flag_country", length = 10)
|
||||
private String flagCountry;
|
||||
|
||||
@Column(name = "violation_type", length = 50)
|
||||
private String violationType;
|
||||
|
||||
@Column(name = "action", length = 50)
|
||||
private String action;
|
||||
|
||||
@Column(name = "result", length = 50)
|
||||
private String result;
|
||||
|
||||
@Column(name = "ai_match_status", length = 20)
|
||||
private String aiMatchStatus;
|
||||
|
||||
@Column(name = "ai_confidence", precision = 5, scale = 4)
|
||||
private BigDecimal aiConfidence;
|
||||
|
||||
@Column(name = "patrol_ship_id")
|
||||
private Long patrolShipId;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
@Column(name = "enforced_by")
|
||||
private UUID enforcedBy;
|
||||
|
||||
@Column(name = "enforced_by_name", length = 100)
|
||||
private String enforcedByName;
|
||||
|
||||
@Column(name = "remarks", columnDefinition = "text")
|
||||
private String remarks;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (createdAt == null) createdAt = now;
|
||||
if (updatedAt == null) updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,146 @@
|
||||
package gc.mda.kcg.domain.enforcement;
|
||||
|
||||
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
|
||||
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
|
||||
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
|
||||
import gc.mda.kcg.domain.enforcement.repository.EnforcementPlanRepository;
|
||||
import gc.mda.kcg.domain.enforcement.repository.EnforcementRecordRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EnforcementService {
|
||||
|
||||
private final EnforcementRecordRepository recordRepository;
|
||||
private final EnforcementPlanRepository planRepository;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
private static final DateTimeFormatter UID_DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
|
||||
// ========================================================================
|
||||
// 단속 이력
|
||||
// ========================================================================
|
||||
|
||||
public Page<EnforcementRecord> listRecords(String violationType, Pageable pageable) {
|
||||
if (violationType != null && !violationType.isBlank()) {
|
||||
return recordRepository.findByViolationType(violationType, pageable);
|
||||
}
|
||||
return recordRepository.findAllByOrderByEnforcedAtDesc(pageable);
|
||||
}
|
||||
|
||||
public EnforcementRecord getRecord(Long id) {
|
||||
return recordRepository.findById(id)
|
||||
.orElseThrow(() -> new EntityNotFoundException("EnforcementRecord not found: " + id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public EnforcementRecord createRecord(CreateRecordRequest req) {
|
||||
EnforcementRecord record = EnforcementRecord.builder()
|
||||
.enfUid(generateEnfUid())
|
||||
.eventId(req.eventId())
|
||||
.enforcedAt(req.enforcedAt())
|
||||
.zoneCode(req.zoneCode())
|
||||
.areaName(req.areaName())
|
||||
.lat(req.lat())
|
||||
.lon(req.lon())
|
||||
.vesselMmsi(req.vesselMmsi())
|
||||
.vesselName(req.vesselName())
|
||||
.flagCountry(req.flagCountry())
|
||||
.violationType(req.violationType())
|
||||
.action(req.action())
|
||||
.result(req.result())
|
||||
.aiMatchStatus(req.aiMatchStatus())
|
||||
.aiConfidence(req.aiConfidence())
|
||||
.patrolShipId(req.patrolShipId())
|
||||
.enforcedBy(req.enforcedBy())
|
||||
.enforcedByName(req.enforcedByName())
|
||||
.remarks(req.remarks())
|
||||
.build();
|
||||
|
||||
EnforcementRecord saved = recordRepository.save(record);
|
||||
|
||||
// event_id가 있으면 prediction_events.status를 RESOLVED로 갱신
|
||||
if (req.eventId() != null) {
|
||||
entityManager.createQuery(
|
||||
"UPDATE PredictionEvent e SET e.status = 'RESOLVED', e.resolvedAt = :now, e.updatedAt = :now WHERE e.id = :eventId"
|
||||
)
|
||||
.setParameter("now", OffsetDateTime.now())
|
||||
.setParameter("eventId", req.eventId())
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
|
||||
EnforcementRecord record = getRecord(id);
|
||||
if (req.result() != null) record.setResult(req.result());
|
||||
if (req.aiMatchStatus() != null) record.setAiMatchStatus(req.aiMatchStatus());
|
||||
if (req.remarks() != null) record.setRemarks(req.remarks());
|
||||
return recordRepository.save(record);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 단속 계획
|
||||
// ========================================================================
|
||||
|
||||
public Page<EnforcementPlan> listPlans(String status, Pageable pageable) {
|
||||
if (status != null && !status.isBlank()) {
|
||||
return planRepository.findByStatusOrderByPlannedDateAsc(status, pageable);
|
||||
}
|
||||
return planRepository.findAllByOrderByPlannedDateDesc(pageable);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public EnforcementPlan createPlan(CreatePlanRequest req) {
|
||||
EnforcementPlan plan = EnforcementPlan.builder()
|
||||
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())
|
||||
.title(req.title())
|
||||
.zoneCode(req.zoneCode())
|
||||
.areaName(req.areaName())
|
||||
.lat(req.lat())
|
||||
.lon(req.lon())
|
||||
.plannedDate(req.plannedDate())
|
||||
.plannedFrom(req.plannedFrom())
|
||||
.plannedTo(req.plannedTo())
|
||||
.riskLevel(req.riskLevel())
|
||||
.riskScore(req.riskScore())
|
||||
.assignedShipCount(req.assignedShipCount())
|
||||
.assignedCrew(req.assignedCrew())
|
||||
.alertStatus(req.alertStatus())
|
||||
.createdBy(req.createdBy())
|
||||
.remarks(req.remarks())
|
||||
.build();
|
||||
|
||||
return planRepository.save(plan);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// UID 생성: ENF-yyyyMMdd-NNNN (일 단위 시퀀스)
|
||||
// ========================================================================
|
||||
|
||||
private String generateEnfUid() {
|
||||
String dateStr = LocalDate.now().format(UID_DATE_FMT);
|
||||
String prefix = "ENF-" + dateStr + "-";
|
||||
|
||||
Long count = (Long) entityManager.createQuery(
|
||||
"SELECT COUNT(r) FROM EnforcementRecord r WHERE r.enfUid LIKE :prefix"
|
||||
)
|
||||
.setParameter("prefix", prefix + "%")
|
||||
.getSingleResult();
|
||||
|
||||
return prefix + String.format("%04d", count + 1);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package gc.mda.kcg.domain.enforcement.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CreatePlanRequest(
|
||||
String title,
|
||||
String zoneCode,
|
||||
String areaName,
|
||||
Double lat,
|
||||
Double lon,
|
||||
LocalDate plannedDate,
|
||||
OffsetDateTime plannedFrom,
|
||||
OffsetDateTime plannedTo,
|
||||
String riskLevel,
|
||||
Integer riskScore,
|
||||
Integer assignedShipCount,
|
||||
Integer assignedCrew,
|
||||
String alertStatus,
|
||||
UUID createdBy,
|
||||
String remarks
|
||||
) {}
|
||||
@ -0,0 +1,26 @@
|
||||
package gc.mda.kcg.domain.enforcement.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CreateRecordRequest(
|
||||
Long eventId,
|
||||
OffsetDateTime enforcedAt,
|
||||
String zoneCode,
|
||||
String areaName,
|
||||
Double lat,
|
||||
Double lon,
|
||||
String vesselMmsi,
|
||||
String vesselName,
|
||||
String flagCountry,
|
||||
String violationType,
|
||||
String action,
|
||||
String result,
|
||||
String aiMatchStatus,
|
||||
BigDecimal aiConfidence,
|
||||
Long patrolShipId,
|
||||
UUID enforcedBy,
|
||||
String enforcedByName,
|
||||
String remarks
|
||||
) {}
|
||||
@ -0,0 +1,7 @@
|
||||
package gc.mda.kcg.domain.enforcement.dto;
|
||||
|
||||
public record UpdateRecordRequest(
|
||||
String result,
|
||||
String aiMatchStatus,
|
||||
String remarks
|
||||
) {}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.enforcement.repository;
|
||||
|
||||
import gc.mda.kcg.domain.enforcement.EnforcementPlan;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface EnforcementPlanRepository extends JpaRepository<EnforcementPlan, Long> {
|
||||
Page<EnforcementPlan> findByStatusOrderByPlannedDateAsc(String status, Pageable pageable);
|
||||
Page<EnforcementPlan> findAllByOrderByPlannedDateDesc(Pageable pageable);
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.enforcement.repository;
|
||||
|
||||
import gc.mda.kcg.domain.enforcement.EnforcementRecord;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface EnforcementRecordRepository extends JpaRepository<EnforcementRecord, Long> {
|
||||
Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable);
|
||||
Page<EnforcementRecord> findByViolationType(String violationType, Pageable pageable);
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import gc.mda.kcg.auth.AuthPrincipal;
|
||||
import gc.mda.kcg.domain.event.dto.EventStatusUpdateRequest;
|
||||
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.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 이벤트 관리 API.
|
||||
* 예측 이벤트의 조회, 확인, 상태 변경, 처리 이력을 제공.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/events")
|
||||
@RequiredArgsConstructor
|
||||
public class EventController {
|
||||
|
||||
private final EventService eventService;
|
||||
|
||||
/**
|
||||
* 이벤트 목록 조회 (필터 + 페이징).
|
||||
*/
|
||||
@GetMapping
|
||||
@RequirePermission(resource = "monitoring", operation = "READ")
|
||||
public Page<PredictionEvent> getEvents(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String level,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
return eventService.getEvents(
|
||||
status, level, category,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "occurredAt"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 상세 조회.
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@RequirePermission(resource = "monitoring", operation = "READ")
|
||||
public PredictionEvent getEvent(@PathVariable Long id) {
|
||||
return eventService.getEventById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 처리 이력 조회.
|
||||
*/
|
||||
@GetMapping("/{id}/workflow")
|
||||
@RequirePermission(resource = "monitoring", operation = "READ")
|
||||
public List<EventWorkflow> getWorkflowHistory(@PathVariable Long id) {
|
||||
return eventService.getEventWorkflowHistory(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 확인 처리 (NEW → ACK).
|
||||
*/
|
||||
@PatchMapping("/{id}/ack")
|
||||
@RequirePermission(resource = "monitoring", operation = "UPDATE")
|
||||
public PredictionEvent acknowledgeEvent(@PathVariable Long id) {
|
||||
AuthPrincipal principal = currentPrincipal();
|
||||
return eventService.acknowledgeEvent(
|
||||
id,
|
||||
principal != null ? principal.getUserId() : null,
|
||||
principal != null ? principal.getUserNm() : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 상태 변경 (범용).
|
||||
*/
|
||||
@PatchMapping("/{id}/status")
|
||||
@RequirePermission(resource = "monitoring", operation = "UPDATE")
|
||||
public PredictionEvent updateStatus(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody EventStatusUpdateRequest req
|
||||
) {
|
||||
AuthPrincipal principal = currentPrincipal();
|
||||
return eventService.updateEventStatus(
|
||||
id,
|
||||
req.status(),
|
||||
principal != null ? principal.getUserId() : null,
|
||||
principal != null ? principal.getUserNm() : null,
|
||||
req.comment()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태별 이벤트 카운트 통계.
|
||||
*/
|
||||
@GetMapping("/stats")
|
||||
@RequirePermission(resource = "monitoring", operation = "READ")
|
||||
public Map<String, Long> getEventStats() {
|
||||
return eventService.getEventStats();
|
||||
}
|
||||
|
||||
private AuthPrincipal currentPrincipal() {
|
||||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
162
backend/src/main/java/gc/mda/kcg/domain/event/EventService.java
Normal file
162
backend/src/main/java/gc/mda/kcg/domain/event/EventService.java
Normal file
@ -0,0 +1,162 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import gc.mda.kcg.audit.annotation.Auditable;
|
||||
import gc.mda.kcg.auth.AuthPrincipal;
|
||||
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.*;
|
||||
|
||||
/**
|
||||
* 이벤트 조회/상태 관리 서비스.
|
||||
* 모든 상태 변경은 EventWorkflow에 이력 기록.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EventService {
|
||||
|
||||
private static final Set<String> RESOLVED_STATUSES = Set.of("RESOLVED", "FALSE_POSITIVE");
|
||||
|
||||
private final PredictionEventRepository eventRepository;
|
||||
private final EventWorkflowRepository workflowRepository;
|
||||
|
||||
/**
|
||||
* 이벤트 목록 조회 (필터 조합).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<PredictionEvent> getEvents(String status, String level, String category, Pageable pageable) {
|
||||
Specification<PredictionEvent> spec = Specification.where(null);
|
||||
|
||||
if (status != null && !status.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("status"), status));
|
||||
}
|
||||
if (level != null && !level.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("level"), level));
|
||||
}
|
||||
if (category != null && !category.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("category"), category));
|
||||
}
|
||||
|
||||
// 기본 정렬: occurredAt DESC
|
||||
return eventRepository.findAll(spec, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 상세 조회.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PredictionEvent getEventById(Long id) {
|
||||
return eventRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("EVENT_NOT_FOUND: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 확인 처리 (NEW → ACK).
|
||||
*/
|
||||
@Auditable(action = "ACK_EVENT", resourceType = "PREDICTION_EVENT")
|
||||
@Transactional
|
||||
public PredictionEvent acknowledgeEvent(Long id, UUID actorId, String actorName) {
|
||||
PredictionEvent event = getEventById(id);
|
||||
String prevStatus = event.getStatus();
|
||||
|
||||
if (!"NEW".equals(prevStatus)) {
|
||||
throw new IllegalStateException("ACK_ONLY_FROM_NEW: current=" + prevStatus);
|
||||
}
|
||||
|
||||
event.setStatus("ACK");
|
||||
event.setAssigneeId(actorId);
|
||||
event.setAssigneeName(actorName);
|
||||
event.setAckedAt(OffsetDateTime.now());
|
||||
|
||||
PredictionEvent saved = eventRepository.save(event);
|
||||
|
||||
workflowRepository.save(EventWorkflow.builder()
|
||||
.eventId(id)
|
||||
.prevStatus(prevStatus)
|
||||
.newStatus("ACK")
|
||||
.actorId(actorId)
|
||||
.actorName(actorName)
|
||||
.build());
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 상태 변경 (범용) + EventWorkflow INSERT.
|
||||
*/
|
||||
@Auditable(action = "UPDATE_EVENT_STATUS", resourceType = "PREDICTION_EVENT")
|
||||
@Transactional
|
||||
public PredictionEvent updateEventStatus(Long id, String newStatus, UUID actorId, String actorName, String comment) {
|
||||
PredictionEvent event = getEventById(id);
|
||||
String prevStatus = event.getStatus();
|
||||
|
||||
event.setStatus(newStatus);
|
||||
|
||||
// ACK 전환 시 acked_at 자동 설정
|
||||
if ("ACK".equals(newStatus) && event.getAckedAt() == null) {
|
||||
event.setAckedAt(OffsetDateTime.now());
|
||||
event.setAssigneeId(actorId);
|
||||
event.setAssigneeName(actorName);
|
||||
}
|
||||
|
||||
// RESOLVED/FALSE_POSITIVE 전환 시 resolved_at 자동 설정
|
||||
if (RESOLVED_STATUSES.contains(newStatus) && event.getResolvedAt() == null) {
|
||||
event.setResolvedAt(OffsetDateTime.now());
|
||||
}
|
||||
|
||||
if (comment != null && !comment.isBlank()) {
|
||||
event.setResolutionNote(comment);
|
||||
}
|
||||
|
||||
PredictionEvent saved = eventRepository.save(event);
|
||||
|
||||
workflowRepository.save(EventWorkflow.builder()
|
||||
.eventId(id)
|
||||
.prevStatus(prevStatus)
|
||||
.newStatus(newStatus)
|
||||
.actorId(actorId)
|
||||
.actorName(actorName)
|
||||
.comment(comment)
|
||||
.build());
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 처리 이력 조회.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<EventWorkflow> getEventWorkflowHistory(Long eventId) {
|
||||
return workflowRepository.findByEventIdOrderByCreatedAtDesc(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태별 이벤트 카운트.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, Long> getEventStats() {
|
||||
Map<String, Long> stats = new LinkedHashMap<>();
|
||||
for (String s : List.of("NEW", "ACK", "IN_PROGRESS", "RESOLVED", "FALSE_POSITIVE", "DISMISSED")) {
|
||||
stats.put(s, eventRepository.countByStatus(s));
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 헬퍼
|
||||
// ========================================================================
|
||||
|
||||
AuthPrincipal currentPrincipal() {
|
||||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 상태 변경 이력 (감사 추적).
|
||||
* 이벤트의 상태가 변경될 때마다 기록.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "event_workflow", schema = "kcg")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class EventWorkflow {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "event_id", nullable = false)
|
||||
private Long eventId;
|
||||
|
||||
@Column(name = "prev_status", length = 20)
|
||||
private String prevStatus;
|
||||
|
||||
@Column(name = "new_status", length = 20)
|
||||
private String newStatus;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
@Column(name = "actor_id")
|
||||
private UUID actorId;
|
||||
|
||||
@Column(name = "actor_name", length = 100)
|
||||
private String actorName;
|
||||
|
||||
@Column(name = "comment", columnDefinition = "text")
|
||||
private String comment;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
if (createdAt == null) createdAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface EventWorkflowRepository extends JpaRepository<EventWorkflow, Long> {
|
||||
|
||||
List<EventWorkflow> findByEventIdOrderByCreatedAtDesc(Long eventId);
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
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.UUID;
|
||||
|
||||
/**
|
||||
* AI 예측 이벤트.
|
||||
* 불법어선 탐지, 이상행위 감지 등 시스템이 생성한 이벤트를 저장.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "prediction_events", schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = "event_uid"))
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class PredictionEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "event_uid", nullable = false, length = 50, unique = true)
|
||||
private String eventUid;
|
||||
|
||||
@Column(name = "occurred_at")
|
||||
private OffsetDateTime occurredAt;
|
||||
|
||||
@Column(name = "level", length = 20)
|
||||
private String level;
|
||||
|
||||
@Column(name = "category", length = 50)
|
||||
private String category;
|
||||
|
||||
@Column(name = "title", length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(name = "detail", columnDefinition = "text")
|
||||
private String detail;
|
||||
|
||||
@Column(name = "vessel_mmsi", length = 20)
|
||||
private String vesselMmsi;
|
||||
|
||||
@Column(name = "vessel_name", length = 100)
|
||||
private String vesselName;
|
||||
|
||||
@Column(name = "area_name", length = 100)
|
||||
private String areaName;
|
||||
|
||||
@Column(name = "zone_code", length = 30)
|
||||
private String zoneCode;
|
||||
|
||||
@Column(name = "lat")
|
||||
private Double lat;
|
||||
|
||||
@Column(name = "lon")
|
||||
private Double lon;
|
||||
|
||||
@Column(name = "speed_kn", precision = 5, scale = 2)
|
||||
private BigDecimal speedKn;
|
||||
|
||||
@Column(name = "source_type", length = 50)
|
||||
private String sourceType;
|
||||
|
||||
@Column(name = "source_ref_id")
|
||||
private Long sourceRefId;
|
||||
|
||||
@Column(name = "ai_confidence", precision = 5, scale = 4)
|
||||
private BigDecimal aiConfidence;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private String status;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
@Column(name = "assignee_id")
|
||||
private UUID assigneeId;
|
||||
|
||||
@Column(name = "assignee_name", length = 100)
|
||||
private String assigneeName;
|
||||
|
||||
@Column(name = "acked_at")
|
||||
private OffsetDateTime ackedAt;
|
||||
|
||||
@Column(name = "resolved_at")
|
||||
private OffsetDateTime resolvedAt;
|
||||
|
||||
@Column(name = "resolution_note", columnDefinition = "text")
|
||||
private String resolutionNote;
|
||||
|
||||
@Column(name = "dedup_key", length = 200)
|
||||
private String dedupKey;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (createdAt == null) createdAt = now;
|
||||
if (updatedAt == null) updatedAt = now;
|
||||
if (status == null) status = "NEW";
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
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 java.util.List;
|
||||
|
||||
public interface PredictionEventRepository
|
||||
extends JpaRepository<PredictionEvent, Long>, JpaSpecificationExecutor<PredictionEvent> {
|
||||
|
||||
Page<PredictionEvent> findByStatusInOrderByOccurredAtDesc(List<String> statuses, Pageable pageable);
|
||||
|
||||
Page<PredictionEvent> findByLevelOrderByOccurredAtDesc(String level, Pageable pageable);
|
||||
|
||||
Page<PredictionEvent> findByCategoryOrderByOccurredAtDesc(String category, Pageable pageable);
|
||||
|
||||
Page<PredictionEvent> findByVesselMmsiOrderByOccurredAtDesc(String mmsi, Pageable pageable);
|
||||
|
||||
long countByStatus(String status);
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.event.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 이벤트 상태 변경 요청 DTO.
|
||||
*/
|
||||
public record EventStatusUpdateRequest(
|
||||
@NotBlank String status,
|
||||
String comment
|
||||
) {}
|
||||
@ -0,0 +1,32 @@
|
||||
package gc.mda.kcg.domain.stats;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "prediction_kpi_realtime", schema = "kcg")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class PredictionKpi {
|
||||
|
||||
@Id
|
||||
@Column(name = "kpi_key", length = 50)
|
||||
private String kpiKey;
|
||||
|
||||
@Column(name = "kpi_label", length = 100)
|
||||
private String kpiLabel;
|
||||
|
||||
@Column(name = "value")
|
||||
private Integer value;
|
||||
|
||||
@Column(name = "trend", length = 10)
|
||||
private String trend;
|
||||
|
||||
@Column(name = "delta_pct", precision = 5, scale = 2)
|
||||
private BigDecimal deltaPct;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package gc.mda.kcg.domain.stats;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PredictionKpiRepository extends JpaRepository<PredictionKpi, String> {
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package gc.mda.kcg.domain.stats;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "prediction_stats_daily", schema = "kcg")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class PredictionStatsDaily {
|
||||
|
||||
@Id
|
||||
@Column(name = "stat_date")
|
||||
private LocalDate statDate;
|
||||
|
||||
@Column(name = "total_detections")
|
||||
private Integer totalDetections;
|
||||
|
||||
@Column(name = "enforcement_count")
|
||||
private Integer enforcementCount;
|
||||
|
||||
@Column(name = "manual_confirmed_parents")
|
||||
private Integer manualConfirmedParents;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_category", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byCategory;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_zone", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byZone;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_risk_level", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byRiskLevel;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_gear_type", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byGearType;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_violation_type", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byViolationType;
|
||||
|
||||
@Column(name = "event_count")
|
||||
private Integer eventCount;
|
||||
|
||||
@Column(name = "critical_event_count")
|
||||
private Integer criticalEventCount;
|
||||
|
||||
@Column(name = "false_positive_count")
|
||||
private Integer falsePositiveCount;
|
||||
|
||||
@Column(name = "ai_accuracy_pct", precision = 5, scale = 2)
|
||||
private BigDecimal aiAccuracyPct;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package gc.mda.kcg.domain.stats;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public interface PredictionStatsDailyRepository extends JpaRepository<PredictionStatsDaily, LocalDate> {
|
||||
List<PredictionStatsDaily> findByStatDateBetweenOrderByStatDateAsc(LocalDate from, LocalDate to);
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package gc.mda.kcg.domain.stats;
|
||||
|
||||
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.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "prediction_stats_monthly", schema = "kcg")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class PredictionStatsMonthly {
|
||||
|
||||
@Id
|
||||
@Column(name = "stat_month", length = 7, columnDefinition = "char(7)")
|
||||
private String statMonth;
|
||||
|
||||
@Column(name = "total_detections")
|
||||
private Integer totalDetections;
|
||||
|
||||
@Column(name = "total_enforcements")
|
||||
private Integer totalEnforcements;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_category", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byCategory;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_zone", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byZone;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_risk_level", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byRiskLevel;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_gear_type", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byGearType;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_violation_type", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byViolationType;
|
||||
|
||||
@Column(name = "event_count")
|
||||
private Integer eventCount;
|
||||
|
||||
@Column(name = "critical_event_count")
|
||||
private Integer criticalEventCount;
|
||||
|
||||
@Column(name = "false_positive_count")
|
||||
private Integer falsePositiveCount;
|
||||
|
||||
@Column(name = "ai_accuracy_pct", precision = 5, scale = 2)
|
||||
private BigDecimal aiAccuracyPct;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package gc.mda.kcg.domain.stats;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PredictionStatsMonthlyRepository extends JpaRepository<PredictionStatsMonthly, String> {
|
||||
List<PredictionStatsMonthly> findByStatMonthBetweenOrderByStatMonthAsc(String from, String to);
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package gc.mda.kcg.domain.stats;
|
||||
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 통계/KPI 조회 API.
|
||||
* prediction_kpi_realtime, prediction_stats_monthly, prediction_stats_daily 테이블 기반.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/stats")
|
||||
@RequiredArgsConstructor
|
||||
public class StatsController {
|
||||
|
||||
private final PredictionKpiRepository kpiRepository;
|
||||
private final PredictionStatsMonthlyRepository monthlyRepository;
|
||||
private final PredictionStatsDailyRepository dailyRepository;
|
||||
|
||||
/**
|
||||
* 실시간 KPI 전체 목록 조회
|
||||
*/
|
||||
@GetMapping("/kpi")
|
||||
@RequirePermission(resource = "statistics", operation = "READ")
|
||||
public List<PredictionKpi> getKpi() {
|
||||
return kpiRepository.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
* @param from 시작 월 (예: 2025-10)
|
||||
* @param to 종료 월 (예: 2026-04)
|
||||
*/
|
||||
@GetMapping("/monthly")
|
||||
@RequirePermission(resource = "statistics", operation = "READ")
|
||||
public List<PredictionStatsMonthly> getMonthly(
|
||||
@RequestParam String from,
|
||||
@RequestParam String to
|
||||
) {
|
||||
return monthlyRepository.findByStatMonthBetweenOrderByStatMonthAsc(from, to);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 통계 조회
|
||||
* @param from 시작 날짜 (예: 2026-04-01)
|
||||
* @param to 종료 날짜 (예: 2026-04-07)
|
||||
*/
|
||||
@GetMapping("/daily")
|
||||
@RequirePermission(resource = "statistics", operation = "READ")
|
||||
public List<PredictionStatsDaily> getDaily(
|
||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
|
||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
|
||||
) {
|
||||
return dailyRepository.findByStatDateBetweenOrderByStatDateAsc(from, to);
|
||||
}
|
||||
}
|
||||
66
backend/src/main/java/gc/mda/kcg/master/CodeMaster.java
Normal file
66
backend/src/main/java/gc/mda/kcg/master/CodeMaster.java
Normal file
@ -0,0 +1,66 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 계층형 코드 마스터.
|
||||
* 시스템 전반에서 사용하는 분류 코드를 트리 구조로 관리.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "code_master", schema = "kcg")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class CodeMaster {
|
||||
|
||||
@Id
|
||||
@Column(name = "code_id", length = 100)
|
||||
private String codeId;
|
||||
|
||||
@Column(name = "parent_id", length = 100)
|
||||
private String parentId;
|
||||
|
||||
@Column(name = "group_code", length = 50)
|
||||
private String groupCode;
|
||||
|
||||
@Column(name = "code", length = 50)
|
||||
private String code;
|
||||
|
||||
@Column(name = "depth")
|
||||
private Integer depth;
|
||||
|
||||
@Column(name = "name_ko", length = 100)
|
||||
private String nameKo;
|
||||
|
||||
@Column(name = "name_en", length = 100)
|
||||
private String nameEn;
|
||||
|
||||
@Column(name = "sort_order")
|
||||
private Integer sortOrder;
|
||||
|
||||
@Column(name = "color_hex", length = 10)
|
||||
private String colorHex;
|
||||
|
||||
@Column(name = "icon", length = 30)
|
||||
private String icon;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "metadata", columnDefinition = "jsonb")
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
@Column(name = "is_active")
|
||||
private Boolean isActive;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
if (createdAt == null) createdAt = OffsetDateTime.now();
|
||||
if (isActive == null) isActive = true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CodeMasterRepository extends JpaRepository<CodeMaster, String> {
|
||||
|
||||
List<CodeMaster> findByGroupCodeAndIsActiveTrueOrderBySortOrder(String groupCode);
|
||||
|
||||
List<CodeMaster> findByGroupCodeAndDepthOrderBySortOrder(String groupCode, int depth);
|
||||
|
||||
List<CodeMaster> findByParentIdOrderBySortOrder(String parentId);
|
||||
}
|
||||
94
backend/src/main/java/gc/mda/kcg/master/GearType.java
Normal file
94
backend/src/main/java/gc/mda/kcg/master/GearType.java
Normal file
@ -0,0 +1,94 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
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.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 어구 유형 마스터.
|
||||
* 어구별 속도/패턴/법적 허용 구역 등 분석에 필요한 메타데이터 관리.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "gear_type_master", schema = "kcg")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class GearType {
|
||||
|
||||
@Id
|
||||
@Column(name = "gear_code", length = 20)
|
||||
private String gearCode;
|
||||
|
||||
@Column(name = "gear_name_ko", length = 50)
|
||||
private String gearNameKo;
|
||||
|
||||
@Column(name = "gear_name_en", length = 50)
|
||||
private String gearNameEn;
|
||||
|
||||
@Column(name = "category", length = 20)
|
||||
private String category;
|
||||
|
||||
@Column(name = "speed_min_kn")
|
||||
private BigDecimal speedMinKn;
|
||||
|
||||
@Column(name = "speed_max_kn")
|
||||
private BigDecimal speedMaxKn;
|
||||
|
||||
@Column(name = "duration_min_minutes")
|
||||
private Integer durationMinMinutes;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "pattern_signature", columnDefinition = "jsonb")
|
||||
private Map<String, Object> patternSignature;
|
||||
|
||||
@Column(name = "polygon_shape_hint", length = 20)
|
||||
private String polygonShapeHint;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||
@Column(name = "legal_zones", columnDefinition = "text[]")
|
||||
private String[] legalZones;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "legal_seasons", columnDefinition = "jsonb")
|
||||
private Map<String, Object> legalSeasons;
|
||||
|
||||
@Column(name = "permit_required")
|
||||
private Boolean permitRequired;
|
||||
|
||||
@Column(name = "display_color", length = 7)
|
||||
private String displayColor;
|
||||
|
||||
@Column(name = "display_icon", length = 30)
|
||||
private String displayIcon;
|
||||
|
||||
@Column(name = "display_order")
|
||||
private Integer displayOrder;
|
||||
|
||||
@Column(name = "description", columnDefinition = "text")
|
||||
private String description;
|
||||
|
||||
@Column(name = "is_active")
|
||||
private Boolean isActive;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
@Column(name = "created_by")
|
||||
private UUID createdBy;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
if (isActive == null) isActive = true;
|
||||
if (updatedAt == null) updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface GearTypeRepository extends JpaRepository<GearType, String> {
|
||||
|
||||
List<GearType> findByIsActiveTrueOrderByDisplayOrder();
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 마스터 데이터 통합 컨트롤러.
|
||||
* 코드 마스터, 어구 유형, 함정, 선박 허가 조회/관리 API 제공.
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class MasterDataController {
|
||||
|
||||
private final CodeMasterRepository codeMasterRepository;
|
||||
private final GearTypeRepository gearTypeRepository;
|
||||
private final PatrolShipRepository patrolShipRepository;
|
||||
private final VesselPermitRepository vesselPermitRepository;
|
||||
|
||||
// ========================================================================
|
||||
// 코드 마스터 (인증만, 권한 불필요)
|
||||
// ========================================================================
|
||||
|
||||
@GetMapping("/api/codes")
|
||||
public List<CodeMaster> listCodes(@RequestParam String group) {
|
||||
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group);
|
||||
}
|
||||
|
||||
@GetMapping("/api/codes/{codeId}/children")
|
||||
public List<CodeMaster> listChildren(@PathVariable String codeId) {
|
||||
return codeMasterRepository.findByParentIdOrderBySortOrder(codeId);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 어구 유형 (조회: 인증만 / 생성·수정: admin:system-config)
|
||||
// ========================================================================
|
||||
|
||||
@GetMapping("/api/gear-types")
|
||||
public List<GearType> listGearTypes() {
|
||||
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
|
||||
}
|
||||
|
||||
@GetMapping("/api/gear-types/{gearCode}")
|
||||
public GearType getGearType(@PathVariable String gearCode) {
|
||||
return gearTypeRepository.findById(gearCode)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"어구 유형을 찾을 수 없습니다: " + gearCode));
|
||||
}
|
||||
|
||||
@PostMapping("/api/gear-types")
|
||||
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
|
||||
public GearType createGearType(@RequestBody GearType gearType) {
|
||||
if (gearTypeRepository.existsById(gearType.getGearCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
|
||||
}
|
||||
return gearTypeRepository.save(gearType);
|
||||
}
|
||||
|
||||
@PutMapping("/api/gear-types/{gearCode}")
|
||||
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
|
||||
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
|
||||
if (!gearTypeRepository.existsById(gearCode)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"어구 유형을 찾을 수 없습니다: " + gearCode);
|
||||
}
|
||||
gearType.setGearCode(gearCode);
|
||||
return gearTypeRepository.save(gearType);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 함정 (patrol 권한)
|
||||
// ========================================================================
|
||||
|
||||
@GetMapping("/api/patrol-ships")
|
||||
@RequirePermission(resource = "patrol", operation = "READ")
|
||||
public List<PatrolShip> listPatrolShips() {
|
||||
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
|
||||
}
|
||||
|
||||
@PatchMapping("/api/patrol-ships/{id}/status")
|
||||
@RequirePermission(resource = "patrol", operation = "UPDATE")
|
||||
public PatrolShip updatePatrolShipStatus(
|
||||
@PathVariable Long id,
|
||||
@RequestBody PatrolShipStatusRequest request
|
||||
) {
|
||||
PatrolShip ship = patrolShipRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"함정을 찾을 수 없습니다: " + id));
|
||||
|
||||
if (request.status() != null) ship.setCurrentStatus(request.status());
|
||||
if (request.lat() != null) ship.setCurrentLat(request.lat());
|
||||
if (request.lon() != null) ship.setCurrentLon(request.lon());
|
||||
if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode());
|
||||
if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct());
|
||||
|
||||
return patrolShipRepository.save(ship);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 선박 허가 (vessel 권한)
|
||||
// ========================================================================
|
||||
|
||||
@GetMapping("/api/vessel-permits")
|
||||
@RequirePermission(resource = "vessel", operation = "READ")
|
||||
public Page<VesselPermit> listVesselPermits(
|
||||
@RequestParam(required = false) String flag,
|
||||
@RequestParam(required = false) String permitStatus,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
PageRequest pageable = PageRequest.of(page, size);
|
||||
if (flag != null) {
|
||||
return vesselPermitRepository.findByFlagCountry(flag, pageable);
|
||||
}
|
||||
if (permitStatus != null) {
|
||||
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
|
||||
}
|
||||
return vesselPermitRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/api/vessel-permits/{mmsi}")
|
||||
@RequirePermission(resource = "vessel", operation = "READ")
|
||||
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
|
||||
return vesselPermitRepository.findByMmsi(mmsi)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 내부 DTO
|
||||
// ========================================================================
|
||||
|
||||
record PatrolShipStatusRequest(
|
||||
String status,
|
||||
Double lat,
|
||||
Double lon,
|
||||
String zoneCode,
|
||||
Integer fuelPct
|
||||
) {}
|
||||
}
|
||||
78
backend/src/main/java/gc/mda/kcg/master/PatrolShip.java
Normal file
78
backend/src/main/java/gc/mda/kcg/master/PatrolShip.java
Normal file
@ -0,0 +1,78 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 함정(경비함) 마스터.
|
||||
* 해양경찰 소속 함정의 제원 및 현재 상태 관리.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "patrol_ship_master", schema = "kcg")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class PatrolShip {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "ship_id")
|
||||
private Long shipId;
|
||||
|
||||
@Column(name = "ship_code", length = 20, unique = true)
|
||||
private String shipCode;
|
||||
|
||||
@Column(name = "ship_name", length = 100)
|
||||
private String shipName;
|
||||
|
||||
@Column(name = "ship_class", length = 50)
|
||||
private String shipClass;
|
||||
|
||||
@Column(name = "tonnage")
|
||||
private BigDecimal tonnage;
|
||||
|
||||
@Column(name = "max_speed_kn")
|
||||
private BigDecimal maxSpeedKn;
|
||||
|
||||
@Column(name = "fuel_capacity_l")
|
||||
private BigDecimal fuelCapacityL;
|
||||
|
||||
@Column(name = "base_port", length = 50)
|
||||
private String basePort;
|
||||
|
||||
@Column(name = "current_status", length = 20)
|
||||
private String currentStatus;
|
||||
|
||||
@Column(name = "current_lat")
|
||||
private Double currentLat;
|
||||
|
||||
@Column(name = "current_lon")
|
||||
private Double currentLon;
|
||||
|
||||
@Column(name = "current_zone_code", length = 30)
|
||||
private String currentZoneCode;
|
||||
|
||||
@Column(name = "fuel_pct")
|
||||
private Integer fuelPct;
|
||||
|
||||
@Column(name = "crew_count")
|
||||
private Integer crewCount;
|
||||
|
||||
@Column(name = "is_active")
|
||||
private Boolean isActive;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
if (isActive == null) isActive = true;
|
||||
if (updatedAt == null) updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PatrolShipRepository extends JpaRepository<PatrolShip, Long> {
|
||||
|
||||
List<PatrolShip> findByIsActiveTrueOrderByShipCode();
|
||||
|
||||
List<PatrolShip> findByCurrentStatus(String status);
|
||||
}
|
||||
87
backend/src/main/java/gc/mda/kcg/master/VesselPermit.java
Normal file
87
backend/src/main/java/gc/mda/kcg/master/VesselPermit.java
Normal file
@ -0,0 +1,87 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 선박 허가 마스터.
|
||||
* 어선 허가 정보, 허용 어구/구역, 유효기간 등 관리.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "vessel_permit_master", schema = "kcg")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class VesselPermit {
|
||||
|
||||
@Id
|
||||
@Column(name = "mmsi", length = 20)
|
||||
private String mmsi;
|
||||
|
||||
@Column(name = "vessel_name", length = 100)
|
||||
private String vesselName;
|
||||
|
||||
@Column(name = "vessel_name_cn", length = 100)
|
||||
private String vesselNameCn;
|
||||
|
||||
@Column(name = "flag_country", length = 10)
|
||||
private String flagCountry;
|
||||
|
||||
@Column(name = "vessel_type", length = 30)
|
||||
private String vesselType;
|
||||
|
||||
@Column(name = "tonnage")
|
||||
private BigDecimal tonnage;
|
||||
|
||||
@Column(name = "length_m")
|
||||
private BigDecimal lengthM;
|
||||
|
||||
@Column(name = "build_year")
|
||||
private Integer buildYear;
|
||||
|
||||
@Column(name = "permit_status", length = 20)
|
||||
private String permitStatus;
|
||||
|
||||
@Column(name = "permit_no", length = 50)
|
||||
private String permitNo;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||
@Column(name = "permitted_gear_codes", columnDefinition = "text[]")
|
||||
private String[] permittedGearCodes;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||
@Column(name = "permitted_zones", columnDefinition = "text[]")
|
||||
private String[] permittedZones;
|
||||
|
||||
@Column(name = "permit_valid_from")
|
||||
private LocalDate permitValidFrom;
|
||||
|
||||
@Column(name = "permit_valid_to")
|
||||
private LocalDate permitValidTo;
|
||||
|
||||
@Column(name = "company_id")
|
||||
private Long companyId;
|
||||
|
||||
@Column(name = "data_source", length = 50)
|
||||
private String dataSource;
|
||||
|
||||
@Column(name = "last_synced_at")
|
||||
private OffsetDateTime lastSyncedAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void prePersist() {
|
||||
if (updatedAt == null) updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface VesselPermitRepository extends JpaRepository<VesselPermit, String> {
|
||||
|
||||
Page<VesselPermit> findByFlagCountry(String flagCountry, Pageable pageable);
|
||||
|
||||
Page<VesselPermit> findByPermitStatus(String permitStatus, Pageable pageable);
|
||||
|
||||
Optional<VesselPermit> findByMmsi(String mmsi);
|
||||
}
|
||||
@ -181,7 +181,7 @@ CREATE TABLE kcg.prediction_stats_daily (
|
||||
-- 사전 집계 통계 — 월별
|
||||
-- ============================================================
|
||||
CREATE TABLE kcg.prediction_stats_monthly (
|
||||
stat_month CHAR(7) PRIMARY KEY, -- 'YYYY-MM'
|
||||
stat_month VARCHAR(7) PRIMARY KEY, -- 'YYYY-MM'
|
||||
total_detections INT DEFAULT 0,
|
||||
total_enforcements INT DEFAULT 0,
|
||||
by_category JSONB,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user