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:
htlee 2026-04-07 12:02:26 +09:00
부모 883b347359
커밋 91deb3ae55
33개의 변경된 파일1770개의 추가작업 그리고 1개의 파일을 삭제

파일 보기

@ -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;
}
}

파일 보기

@ -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);
}
}

파일 보기

@ -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);
}

파일 보기

@ -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
) {}
}

파일 보기

@ -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);
}

파일 보기

@ -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 ( 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_detections INT DEFAULT 0,
total_enforcements INT DEFAULT 0, total_enforcements INT DEFAULT 0,
by_category JSONB, by_category JSONB,