feat: Risk&Compliance 값 변경 이력 확인 페이지 개발 (#111)

- 선박 위험지표/선박 제재/회사 제재 변경 이력 조회 API 및 UI
- tb_ship_risk_detail_hstry JOIN으로 Risk narrative(이전값/이후값) 표시
- indicator 테이블 column_name 매핑으로 다국어 필드명 지원
- Compliance overall 상태 토글 헤더에 배지 표시
- 다국어 캐시 (KO/EN 동시 조회, 언어 토글 즉시 전환)
- Screening Guide에서 분리된 독립 페이지 (/risk-compliance-history)
- indicator sort_order 기준 토글 내부 정렬

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-03-30 14:55:22 +09:00
부모 9ab7d44144
커밋 7eb2611c02
18개의 변경된 파일1146개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

@ -0,0 +1,193 @@
-- =============================================================================
-- Risk & Compliance Indicator 테이블에 column_name 매핑
-- 목적: flctn_col_nm (이력 테이블 변동컬럼명) → indicator 테이블 JOIN 가능하도록
-- 참고: INSERT SQL의 field_key 기준으로 매핑 (risk_indicator.sql, compliance_indicator.sql)
-- =============================================================================
-- ※ column_name 컬럼이 이미 INSERT SQL에 NULL로 포함되어 있으므로
-- ALTER TABLE은 column_name 컬럼이 없는 경우에만 실행
-- ALTER TABLE std_snp_data.risk_indicator ADD COLUMN column_name varchar(100);
-- ALTER TABLE std_snp_data.compliance_indicator ADD COLUMN column_name varchar(100);
-- 1. UPDATE: Risk Indicator
-- -----------------------------------------------------------------------------
-- AIS
UPDATE std_snp_data.risk_indicator SET column_name = 'ais_notrcv_elps_days' WHERE field_key = 'Time since last seen on AIS';
UPDATE std_snp_data.risk_indicator SET column_name = 'ais_lwrnk_days' WHERE field_key = 'Days under AIS coverage (last 12 months)';
UPDATE std_snp_data.risk_indicator SET column_name = 'mmsi_anom_message' WHERE field_key = 'Anomalous AIS Messages from MMSI (last 12 months)';
UPDATE std_snp_data.risk_indicator SET column_name = 'ais_up_imo_desc' WHERE field_key = 'IMO number transmitted correctly in AIS';
UPDATE std_snp_data.risk_indicator SET column_name = 'othr_ship_nm_voy_yn' WHERE field_key = 'Sailing under name transmitted on AIS';
-- PORT_CALLS
UPDATE std_snp_data.risk_indicator SET column_name = 'port_prtcll' WHERE field_key = 'Port calls (last 12 months)';
UPDATE std_snp_data.risk_indicator SET column_name = 'recent_sanction_prtcll' WHERE field_key = 'Most recent sanctioned port call';
UPDATE std_snp_data.risk_indicator SET column_name = 'port_risk' WHERE field_key = 'Highest ECR risk port call (last 12 months)';
-- ASSOCIATED_WITH_RUSSIA
UPDATE std_snp_data.risk_indicator SET column_name = 'rss_ownr_reg' WHERE field_key = 'Russian registration or ownership since February 2022';
UPDATE std_snp_data.risk_indicator SET column_name = 'rss_port_call' WHERE field_key = 'Russian port calls since February 2022';
UPDATE std_snp_data.risk_indicator SET column_name = 'rss_sts' WHERE field_key = 'Russian tanker STS since December 2022';
-- BEHAVIOURAL_RISK
UPDATE std_snp_data.risk_indicator SET column_name = 'recent_dark_actv' WHERE field_key = 'Most recent suspicious behavior detected';
UPDATE std_snp_data.risk_indicator SET column_name = 'sts_job' WHERE field_key = 'Ship-to-Ship operations (last 12 months)';
UPDATE std_snp_data.risk_indicator SET column_name = 'draft_chg' WHERE field_key = 'Draught changes (last 12 months)';
UPDATE std_snp_data.risk_indicator SET column_name = 'drift_chg' WHERE field_key = 'Drifting high seas (last 12 months)';
UPDATE std_snp_data.risk_indicator SET column_name = 'ilgl_fshr_viol' WHERE field_key = 'Illegal Unreported or Unregulated (IUU) Fishing Violation';
-- SAFETY_SECURITY_AND_INSPECTIONS
UPDATE std_snp_data.risk_indicator SET column_name = 'risk_event' WHERE field_key = 'Casualty & risk events (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'fltsfty' WHERE field_key = 'Fleet casualty & risk (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'vslage' WHERE field_key = 'Age of ship (compared to peer group average)';
UPDATE std_snp_data.risk_indicator SET column_name = 'psc_inspection' WHERE field_key = 'Inspection (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'psc_inspection_elps_hr' WHERE field_key = 'Time since last inspection';
UPDATE std_snp_data.risk_indicator SET column_name = 'psc_defect' WHERE field_key = 'PSC defects (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'psc_detained' WHERE field_key = 'PSC detentions (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'now_smgrc_evdc' WHERE field_key = 'Current Safety Management Certificate inspected';
UPDATE std_snp_data.risk_indicator SET column_name = 'flt_psc' WHERE field_key = 'Fleet PSC detentions (last 3 years)';
-- FLAG_RISK
UPDATE std_snp_data.risk_indicator SET column_name = 'ntnlty_chg' WHERE field_key = 'Flag changes (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'ntnlty_prs_mou_perf' WHERE field_key = 'Flag Paris MOU performance';
UPDATE std_snp_data.risk_indicator SET column_name = 'ntnlty_tky_mou_perf' WHERE field_key = 'Flag Tokyo MOU performance';
UPDATE std_snp_data.risk_indicator SET column_name = 'ntnlty_uscg_mou_perf' WHERE field_key = 'Flag US Coastguard MOU performance';
UPDATE std_snp_data.risk_indicator SET column_name = 'uscg_excl_ship_cert' WHERE field_key = 'Flag US Coastguard Qualship 21';
UPDATE std_snp_data.risk_indicator SET column_name = 'risk_data_maint' WHERE field_key = 'Risk Data Maintained For Vessel';
-- OWNER_AND_CLASSIFICATION
UPDATE std_snp_data.risk_indicator SET column_name = 'now_clfic' WHERE field_key = 'Classification Society';
UPDATE std_snp_data.risk_indicator SET column_name = 'clfic_status_chg' WHERE field_key = 'Class status changes (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'spc_inspection_ovdue' WHERE field_key = 'Special survey overdue';
UPDATE std_snp_data.risk_indicator SET column_name = 'pni_insrnc' WHERE field_key = 'P&I club check';
UPDATE std_snp_data.risk_indicator SET column_name = 'ship_nm_chg' WHERE field_key = 'Name changes (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'docc_chg' WHERE field_key = 'DOC company changes (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'gbo_chg' WHERE field_key = 'Group owner changes (last 3 years)';
UPDATE std_snp_data.risk_indicator SET column_name = 'ownr_unk' WHERE field_key = 'Ownership unknown';
UPDATE std_snp_data.risk_indicator SET column_name = 'sngl_ship_voy' WHERE field_key = 'Single-ship fleet (technical manager)';
-- 2. UPDATE: Compliance Indicator - SHIP
-- -----------------------------------------------------------------------------
-- ※ DB 컬럼이 없는 지표 (Suspicious Behavior, Ownership Screening 등)는 매핑하지 않음
-- Sanctions - Ship (US OFAC)
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ofac_sanction_list' WHERE field_key = 'Ship on OFAC Sanctions List (SDN)' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ofac_non_sdn_sanction_list' WHERE field_key = 'Ship on OFAC Consolidated (Non-SDN) List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ofac_cutn_list' WHERE field_key = 'Ship on OFAC Advisory List' AND indicator_type = 'SHIP';
-- Sanctions - Ownership (US OFAC)
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_ofac_sanction_list' WHERE field_key = 'Ownership on OFAC Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_ofcs_sanction_list' WHERE field_key = 'Ownership on OFAC SSI List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_ofac_sanction_country' WHERE field_key = 'Ownership in OFAC Sanctioned Country' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_ofac_sanction_hstry' WHERE field_key = 'Historical Ownership in OFAC Sanctioned Country' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_prnt_company_ofac_sanction_country' WHERE field_key = 'Parent Company in OFAC Sanctioned Country' AND indicator_type = 'SHIP';
-- Sanctions - Ship (Non-US)
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_un_sanction_list' WHERE field_key = 'Ship on UN Security Council Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_eu_sanction_list' WHERE field_key = 'Ship on EU Commission Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_swi_sanction_list' WHERE field_key = 'Ship on Swiss SECO Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_bes_sanction_list' WHERE field_key = 'Ship on HM Treasury (BES) Sanctions List' AND indicator_type = 'SHIP';
-- Sanctions - Ownership (Non-US)
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_un_sanction_list' WHERE field_key = 'Ownership on UN Security Council Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_eu_sanction_list' WHERE field_key = 'Ownership on EU Commission Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_swi_sanction_list' WHERE field_key = 'Ownership on Swiss SECO Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_bes_sanction_list' WHERE field_key = 'Ownership on HM Treasury (BES) Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_can_sanction_list' WHERE field_key = 'Ownership on Government of Canada Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_aus_sanction_list' WHERE field_key = 'Ownership on Australian DFAT Sanctions List' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_uae_sanction_list' WHERE field_key = 'Ownership on UAE Sanctions List' AND indicator_type = 'SHIP';
-- Sanctions - FATF
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_fatf_rgl_zone' WHERE field_key = 'Ownership in FATF High-risk or Non-cooperative Jurisdiction' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_prnt_company_fatf_rgl_zone' WHERE field_key = 'Parent Company in FATF High-risk or Non-cooperative Jurisdiction' AND indicator_type = 'SHIP';
-- Sanctions - Other
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_prnt_company_ncmplnc' WHERE field_key = 'Parent Company Noncompliant' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_flg_sanction_country' WHERE field_key = 'Flag Country Sanctioned' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_flg_sanction_country_hstry' WHERE field_key = 'Historical Flag Country Sanctioned' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_scrty_lgl_dspt_event' WHERE field_key = 'Security and Legal Dispute Event (Last 3 Years)' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_flg_dspt' WHERE field_key = 'Flag (MMSI, Call Sign) False or Flag Unknown' AND indicator_type = 'SHIP';
-- Port Calls
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_sanction_country_prtcll_last_thr_m' WHERE field_key = 'Port Call Last 3 Months to Sanctioned Country' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_sanction_country_prtcll_last_six_m' WHERE field_key = 'Port Call Last 180 Days to Sanctioned Country' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_sanction_country_prtcll_last_twelve_m' WHERE field_key = 'Port Call Last 12 Months to Sanctioned Country' AND indicator_type = 'SHIP';
-- STS Activity
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_sts_prtnr_non_compliance_twelve_m' WHERE field_key = 'STS Activity Partner Ship Compliance Status' AND indicator_type = 'SHIP';
-- Suspicious Behavior
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_dark_actv_ind' WHERE field_key = 'Dark for Extended Period in Watched Area (Severe)' AND indicator_type = 'SHIP';
UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_dtld_info_ntmntd' WHERE field_key = 'Not Seen 7 Days Near Sanctioned/Sensitive Country' AND indicator_type = 'SHIP';
-- Compliance Screening History
UPDATE std_snp_data.compliance_indicator SET column_name = 'lgl_snths_sanction' WHERE field_key = 'Overall Compliance Change History' AND indicator_type = 'SHIP';
-- 3. UPDATE: Compliance Indicator - COMPANY
-- -----------------------------------------------------------------------------
-- US Treasury Sanctions
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_ofac_sanction_list' WHERE field_key = 'Company on OFAC Entity List' AND indicator_type = 'COMPANY';
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_ofac_non_sdn_sanction_list' WHERE field_key = 'Company on OFAC Non-SDN Entity List' AND indicator_type = 'COMPANY';
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_ofacssi_sanction_list' WHERE field_key = 'Company on OFAC SSI Entity List' AND indicator_type = 'COMPANY';
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_ofac_sanction_country' WHERE field_key = 'Company in US Treasury OFAC Sanctioned Country' AND indicator_type = 'COMPANY';
-- Non-US Sanctions
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_un_sanction_list' WHERE field_key = 'Company on UN Security Council Entity List' AND indicator_type = 'COMPANY';
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_eu_sanction_list' WHERE field_key = 'Company on EU Entity List' AND indicator_type = 'COMPANY';
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_bes_sanction_list' WHERE field_key = 'Company on HM Treasury (BES) Entity List' AND indicator_type = 'COMPANY';
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_can_sanction_list' WHERE field_key = 'Company on Canadian Entity List' AND indicator_type = 'COMPANY';
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_aus_sanction_list' WHERE field_key = 'Company on Australian DFAT Entity List' AND indicator_type = 'COMPANY';
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_uae_sanction_list' WHERE field_key = 'Company on UAE Entity List' AND indicator_type = 'COMPANY';
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_swiss_sanction_list' WHERE field_key = 'Company on Swiss SECO Entity List' AND indicator_type = 'COMPANY';
-- FATF Jurisdiction
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_fatf_cmptnc_country' WHERE field_key = 'Company in FATF High-risk & Non-cooperative Jurisdiction' AND indicator_type = 'COMPANY';
-- Parent Company
UPDATE std_snp_data.compliance_indicator SET column_name = 'prnt_company_compliance_risk' WHERE field_key = 'Parent Company Compliance Risk' AND indicator_type = 'COMPANY';
-- Compliance Screening Change History
UPDATE std_snp_data.compliance_indicator SET column_name = 'company_snths_compliance_status' WHERE field_key = 'Historical Compliance Change Date (Company)' AND indicator_type = 'COMPANY';
-- 4. 검증 쿼리
-- -----------------------------------------------------------------------------
-- 매핑된 항목 수 확인
SELECT 'risk_indicator' as table_name,
COUNT(*) as total,
COUNT(column_name) as mapped,
COUNT(*) - COUNT(column_name) as unmapped
FROM std_snp_data.risk_indicator
UNION ALL
SELECT 'compliance_indicator (SHIP)',
COUNT(*), COUNT(column_name), COUNT(*) - COUNT(column_name)
FROM std_snp_data.compliance_indicator WHERE indicator_type = 'SHIP'
UNION ALL
SELECT 'compliance_indicator (COMPANY)',
COUNT(*), COUNT(column_name), COUNT(*) - COUNT(column_name)
FROM std_snp_data.compliance_indicator WHERE indicator_type = 'COMPANY';
-- 매핑 안 된 항목 확인 (DB 컬럼 없는 지표 = 정상적으로 NULL)
SELECT indicator_id, field_key, column_name
FROM std_snp_data.risk_indicator WHERE column_name IS NULL;
SELECT indicator_id, field_key, indicator_type, column_name
FROM std_snp_data.compliance_indicator WHERE column_name IS NULL;
-- 전체 매핑 확인 (다국어 포함)
SELECT ri.indicator_id, ri.field_key, ri.column_name, ril.field_name
FROM std_snp_data.risk_indicator ri
LEFT JOIN std_snp_data.risk_indicator_lang ril
ON ri.indicator_id = ril.indicator_id AND ril.lang_code = 'KO'
ORDER BY ri.indicator_id;
SELECT ci.indicator_id, ci.field_key, ci.indicator_type, ci.column_name, cil.field_name
FROM std_snp_data.compliance_indicator ci
LEFT JOIN std_snp_data.compliance_indicator_lang cil
ON ci.indicator_id = cil.indicator_id AND cil.lang_code = 'KO'
ORDER BY ci.indicator_type, ci.indicator_id;

파일 보기

@ -16,6 +16,7 @@ const Schedules = lazy(() => import('./pages/Schedules'));
const Timeline = lazy(() => import('./pages/Timeline'));
const BypassConfig = lazy(() => import('./pages/BypassConfig'));
const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide'));
const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory'));
function AppLayout() {
const { toasts, removeToast } = useToastContext();
@ -36,6 +37,7 @@ function AppLayout() {
<Route path="/schedule-timeline" element={<Timeline />} />
<Route path="/bypass-config" element={<BypassConfig />} />
<Route path="/screening-guide" element={<ScreeningGuide />} />
<Route path="/risk-compliance-history" element={<RiskComplianceHistory />} />
</Routes>
</Suspense>
</div>

파일 보기

@ -53,6 +53,20 @@ export interface MethodologyHistoryResponse {
collectionNote: string;
}
// 값 변경 이력 타입
export interface ChangeHistoryResponse {
rowIndex: number;
searchKey: string;
lastModifiedDate: string;
changedColumnName: string;
beforeValue: string;
afterValue: string;
fieldName: string;
narrative: string;
prevNarrative: string;
sortOrder: number;
}
const BASE = '/snp-api/api/screening-guide';
async function fetchJson<T>(url: string): Promise<T> {
@ -68,4 +82,10 @@ export const screeningGuideApi = {
fetchJson<ApiResponse<ComplianceCategoryResponse[]>>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`),
getMethodologyHistory: (lang = 'KO') =>
fetchJson<ApiResponse<MethodologyHistoryResponse[]>>(`${BASE}/methodology-history?lang=${lang}`),
getShipRiskHistory: (imoNo: string, lang = 'KO') =>
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`),
getShipComplianceHistory: (imoNo: string, lang = 'KO') =>
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-compliance?imoNo=${imoNo}&lang=${lang}`),
getCompanyComplianceHistory: (companyCode: string, lang = 'KO') =>
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/company-compliance?companyCode=${companyCode}&lang=${lang}`),
};

파일 보기

@ -10,6 +10,7 @@ const navItems = [
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
{ path: '/bypass-config', label: 'Bypass API', icon: '🔗' },
{ path: '/screening-guide', label: 'Screening Guide', icon: '⚖️' },
{ path: '/risk-compliance-history', label: 'Change History', icon: '📜' },
];
export default function Navbar() {

파일 보기

@ -0,0 +1,401 @@
import { useState, useMemo } from 'react';
import { screeningGuideApi, type ChangeHistoryResponse } from '../../api/screeningGuideApi';
type HistoryType = 'ship-risk' | 'ship-compliance' | 'company-compliance';
interface HistoryTabProps {
lang: string;
}
const HISTORY_TYPES: {
key: HistoryType;
label: string;
searchLabel: string;
searchPlaceholder: string;
overallColumn: string | null;
}[] = [
{
key: 'ship-risk',
label: '선박 위험지표',
searchLabel: 'IMO 번호',
searchPlaceholder: 'IMO 번호 : 9672533',
overallColumn: null,
},
{
key: 'ship-compliance',
label: '선박 제재',
searchLabel: 'IMO 번호',
searchPlaceholder: 'IMO 번호 : 9672533',
overallColumn: 'lgl_snths_sanction',
},
{
key: 'company-compliance',
label: '회사 제재',
searchLabel: '회사 코드',
searchPlaceholder: '회사 코드 : 1288896',
overallColumn: 'company_snths_compliance_status',
},
];
interface GroupedHistory {
lastModifiedDate: string;
items: ChangeHistoryResponse[];
overallBefore: string | null;
overallAfter: string | null;
}
const STATUS_MAP: Record<string, { label: string; className: string }> = {
'0': { label: 'All Clear', className: 'bg-green-100 text-green-800 border-green-300' },
'1': { label: 'Warning', className: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
'2': { label: 'Severe', className: 'bg-red-100 text-red-800 border-red-300' },
};
const STATUS_COLORS: Record<string, string> = {
'0': '#22c55e',
'1': '#eab308',
'2': '#ef4444',
};
const STATUS_LABELS: Record<string, string> = {
'0': 'All Clear',
'1': 'Warning',
'2': 'Severe',
};
function StatusBadge({ value }: { value: string | null }) {
if (value == null || value === '') return null;
const status = STATUS_MAP[value];
if (!status) return <span className="text-xs text-wing-muted">{value}</span>;
return (
<span className={`inline-block rounded-full px-3 py-0.5 text-xs font-bold border ${status.className}`}>
{status.label}
</span>
);
}
function RiskValueCell({ value, narrative }: { value: string | null; narrative?: string }) {
if (value == null || value === '') return null;
const color = STATUS_COLORS[value] ?? '#6b7280';
const label = narrative || STATUS_LABELS[value] || value;
return (
<div className="inline-flex items-start gap-1.5">
<span style={{ color }} className="text-sm leading-tight"></span>
<span className="text-xs text-wing-text leading-relaxed text-left">{label}</span>
</div>
);
}
export default function HistoryTab({ lang }: HistoryTabProps) {
const [historyType, setHistoryType] = useState<HistoryType>('ship-risk');
const [searchValue, setSearchValue] = useState('');
const [cache, setCache] = useState<Record<string, ChangeHistoryResponse[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searched, setSearched] = useState(false);
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
const currentType = HISTORY_TYPES.find((t) => t.key === historyType)!;
const isRisk = historyType === 'ship-risk';
const data = cache[lang] ?? [];
const grouped: GroupedHistory[] = useMemo(() => {
const map = new Map<string, ChangeHistoryResponse[]>();
for (const item of data) {
const key = item.lastModifiedDate;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return Array.from(map.entries()).map(([date, items]) => {
const overallColumn = currentType.overallColumn;
if (overallColumn) {
const overallItem = items.find((item) => item.changedColumnName === overallColumn);
return {
lastModifiedDate: date,
items,
overallBefore: overallItem?.beforeValue ?? null,
overallAfter: overallItem?.afterValue ?? null,
};
}
return { lastModifiedDate: date, items, overallBefore: null, overallAfter: null };
});
}, [data, currentType.overallColumn]);
function handleSearch() {
const trimmed = searchValue.trim();
if (!trimmed) return;
setLoading(true);
setError(null);
setSearched(true);
setExpandedDates(new Set());
const getApiCall = (l: string) =>
historyType === 'ship-risk'
? screeningGuideApi.getShipRiskHistory(trimmed, l)
: historyType === 'ship-compliance'
? screeningGuideApi.getShipComplianceHistory(trimmed, l)
: screeningGuideApi.getCompanyComplianceHistory(trimmed, l);
Promise.all([getApiCall('KO'), getApiCall('EN')])
.then(([ko, en]) => setCache({ KO: ko.data ?? [], EN: en.data ?? [] }))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') handleSearch();
}
function handleTypeChange(type: HistoryType) {
setHistoryType(type);
setSearchValue('');
setCache({});
setError(null);
setSearched(false);
setExpandedDates(new Set());
}
function toggleDate(date: string) {
setExpandedDates((prev) => {
const next = new Set(prev);
if (next.has(date)) next.delete(date);
else next.add(date);
return next;
});
}
return (
<div className="space-y-4">
{/* 이력 유형 선택 */}
<div className="flex gap-2 flex-wrap justify-center">
{HISTORY_TYPES.map((t) => (
<button
key={t.key}
onClick={() => handleTypeChange(t.key)}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all border ${
historyType === t.key
? 'bg-slate-900 text-white border-slate-900 shadow-md'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
}`}
>
{t.label}
</button>
))}
</div>
{/* 검색 */}
<div className="flex gap-3 items-center justify-center">
<div className="relative flex-1 min-w-[200px]">
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</span>
<input
type="text"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={currentType.searchPlaceholder}
className="w-full pl-10 pr-8 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
/>
{searchValue && (
<button
onClick={() => setSearchValue('')}
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<button
onClick={handleSearch}
disabled={!searchValue.trim() || loading}
className="px-5 py-2 rounded-lg bg-slate-900 text-white text-sm font-bold hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '조회 중...' : '조회'}
</button>
</div>
{/* 에러 */}
{error && (
<div className="bg-red-50 border border-red-300 rounded-xl p-4 text-red-800 text-sm">
<strong> :</strong> {error}
</div>
)}
{/* 결과: 날짜별 토글 */}
{searched && !loading && !error && (
<>
<div className="text-xs text-wing-muted">
: <strong className="text-wing-text">{grouped.length}</strong> ,{' '}
<strong className="text-wing-text">{data.length}</strong>
</div>
{grouped.length > 0 ? (
<div className="space-y-2">
{grouped.map((group) => {
const isExpanded = expandedDates.has(group.lastModifiedDate);
const hasOverall =
group.overallBefore != null || group.overallAfter != null;
const displayItems = (currentType.overallColumn
? group.items.filter(
(item) => item.changedColumnName !== currentType.overallColumn,
)
: [...group.items]
).sort((a, b) => (a.sortOrder ?? Infinity) - (b.sortOrder ?? Infinity));
return (
<div
key={group.lastModifiedDate}
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
>
<button
onClick={() => toggleDate(group.lastModifiedDate)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-wing-hover transition-colors"
>
<div className="flex items-center gap-3 flex-wrap">
<span
className={`text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
>
&#9654;
</span>
<span className="text-sm text-wing-muted">Change Date :</span>
<span className="text-sm font-semibold text-wing-text">
{group.lastModifiedDate}
</span>
{hasOverall ? (
<div className="flex items-center gap-1.5">
<StatusBadge value={group.overallBefore} />
<span className="text-xs text-wing-muted"></span>
<StatusBadge value={group.overallAfter} />
</div>
) : (
<span className="text-xs text-wing-muted">
{displayItems.length}
</span>
)}
</div>
{hasOverall && (
<span className="text-xs text-wing-muted">
{displayItems.length}
</span>
)}
</button>
{isExpanded && displayItems.length > 0 && (
<div className="border-t border-wing-border">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-slate-900 dark:bg-slate-700 text-white">
<th
style={{ width: '50%' }}
className="px-4 py-2 text-center font-semibold"
>
</th>
<th
style={{ width: '25%' }}
className="px-4 py-2 text-center font-semibold"
>
</th>
<th
style={{ width: '25%' }}
className="px-4 py-2 text-center font-semibold"
>
</th>
</tr>
</thead>
<tbody>
{displayItems.map((row) => (
<tr
key={row.rowIndex}
className="border-b border-wing-border even:bg-wing-card"
>
<td
style={{ width: '50%' }}
className="px-4 py-2.5 text-wing-text"
>
<div className="font-medium">
{row.fieldName || row.changedColumnName}
</div>
{row.fieldName && (
<div className="text-wing-muted mt-0.5">
{row.changedColumnName}
</div>
)}
</td>
{isRisk ? (
<>
<td
style={{ width: '25%' }}
className="px-4 py-2.5 text-center"
>
<RiskValueCell value={row.beforeValue} narrative={row.prevNarrative ?? undefined} />
</td>
<td
style={{ width: '25%' }}
className="px-4 py-2.5 text-center"
>
<RiskValueCell
value={row.afterValue}
narrative={row.narrative ?? undefined}
/>
</td>
</>
) : (
<>
<td
style={{ width: '25%' }}
className="px-4 py-2.5 text-center"
>
<StatusBadge value={row.beforeValue} />
</td>
<td
style={{ width: '25%' }}
className="px-4 py-2.5 text-center"
>
<StatusBadge value={row.afterValue} />
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="flex items-center justify-center py-12 text-wing-muted">
<div className="text-center">
<div className="text-2xl mb-2">📭</div>
<div className="text-sm"> .</div>
</div>
</div>
)}
</>
)}
{/* 초기 상태 */}
{!searched && (
<div className="flex items-center justify-center py-16 text-wing-muted">
<div className="text-center">
<div className="text-3xl mb-3">🔍</div>
<div className="text-sm">{currentType.searchLabel}() .</div>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,51 @@
import { useState } from 'react';
import HistoryTab from '../components/screening/HistoryTab';
export default function RiskComplianceHistory() {
const [lang, setLang] = useState('KO');
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="bg-gradient-to-r from-slate-900 via-blue-900 to-red-900 rounded-xl p-6 text-white">
<div className="text-xs opacity-75 mb-1">
S&P Global · Maritime Intelligence Risk Suite (MIRS)
</div>
<h1 className="text-xl font-bold mb-1">
Risk & Compliance Change History
</h1>
<p className="text-sm opacity-85">
</p>
</div>
{/* 언어 토글 */}
<div className="flex justify-end">
<div className="flex gap-1 border border-wing-border rounded-lg overflow-hidden">
<button
onClick={() => setLang('EN')}
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
lang === 'EN'
? 'bg-slate-900 text-white'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
}`}
>
EN
</button>
<button
onClick={() => setLang('KO')}
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
lang === 'KO'
? 'bg-slate-900 text-white'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
}`}
>
KO
</button>
</div>
</div>
<HistoryTab lang={lang} />
</div>
);
}

파일 보기

@ -1,6 +1,7 @@
package com.snp.batch.global.controller;
import com.snp.batch.common.web.ApiResponse;
import com.snp.batch.global.dto.screening.ChangeHistoryResponse;
import com.snp.batch.global.dto.screening.ComplianceCategoryResponse;
import com.snp.batch.global.dto.screening.MethodologyHistoryResponse;
import com.snp.batch.global.dto.screening.RiskCategoryResponse;
@ -55,4 +56,34 @@ public class ScreeningGuideController {
@RequestParam(defaultValue = "KO") String lang) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyHistory(lang)));
}
@Operation(summary = "선박 위험지표 값 변경 이력", description = "IMO 번호로 선박 위험지표 값 변경 이력을 조회합니다.")
@GetMapping("/history/ship-risk")
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipRiskDetailHistory(
@Parameter(description = "IMO 번호", example = "9330019", required = true)
@RequestParam String imoNo,
@Parameter(description = "언어 코드", example = "KO")
@RequestParam(defaultValue = "KO") String lang) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipRiskDetailHistory(imoNo, lang)));
}
@Operation(summary = "선박 제재 값 변경 이력", description = "IMO 번호로 선박 제재 값 변경 이력을 조회합니다.")
@GetMapping("/history/ship-compliance")
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipComplianceHistory(
@Parameter(description = "IMO 번호", example = "9330019", required = true)
@RequestParam String imoNo,
@Parameter(description = "언어 코드", example = "KO")
@RequestParam(defaultValue = "KO") String lang) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipComplianceHistory(imoNo, lang)));
}
@Operation(summary = "회사 제재 값 변경 이력", description = "회사 코드로 회사 제재 값 변경 이력을 조회합니다.")
@GetMapping("/history/company-compliance")
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getCompanyComplianceHistory(
@Parameter(description = "회사 코드", example = "5765290", required = true)
@RequestParam String companyCode,
@Parameter(description = "언어 코드", example = "KO")
@RequestParam(defaultValue = "KO") String lang) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceHistory(companyCode, lang)));
}
}

파일 보기

@ -15,10 +15,10 @@ public class WebViewController {
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
"/recollects", "/recollects/{id:\\d+}",
"/schedules", "/schedule-timeline", "/monitoring",
"/bypass-config", "/screening-guide",
"/bypass-config", "/screening-guide", "/risk-compliance-history",
"/jobs/**", "/executions/**", "/recollects/**",
"/schedules/**", "/schedule-timeline/**", "/monitoring/**",
"/bypass-config/**", "/screening-guide/**"})
"/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"})
public String forward() {
return "forward:/index.html";
}

파일 보기

@ -0,0 +1,24 @@
package com.snp.batch.global.dto.screening;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChangeHistoryResponse {
private Long rowIndex;
private String searchKey;
private String lastModifiedDate;
private String changedColumnName;
private String beforeValue;
private String afterValue;
private String fieldName;
private String narrative;
private String prevNarrative;
private Integer sortOrder;
}

파일 보기

@ -0,0 +1,45 @@
package com.snp.batch.global.model.screening;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 회사 제재 변경 이력 (읽기 전용)
*/
@Entity
@Table(name = "tb_company_compliance_info_hstry", schema = "std_snp_svc")
@Getter
@NoArgsConstructor
public class CompanyComplianceHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "row_index")
private Long rowIndex;
@Column(name = "company_cd", nullable = false, length = 14)
private String companyCode;
@Column(name = "last_mdfcn_dt", nullable = false)
private LocalDateTime lastModifiedDate;
@Column(name = "flctn_col_nm", nullable = false, length = 100)
private String changedColumnName;
@Column(name = "bfr_val", length = 4)
private String beforeValue;
@Column(name = "aftr_val", length = 4)
private String afterValue;
@Column(name = "crt_dt", nullable = false)
private LocalDateTime createdDate;
}

파일 보기

@ -41,4 +41,7 @@ public class ComplianceIndicator {
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@Column(name = "column_name", length = 100)
private String columnName;
}

파일 보기

@ -37,4 +37,7 @@ public class RiskIndicator {
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@Column(name = "column_name", length = 100)
private String columnName;
}

파일 보기

@ -0,0 +1,45 @@
package com.snp.batch.global.model.screening;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 선박 제재 변경 이력 (읽기 전용)
*/
@Entity
@Table(name = "tb_ship_compliance_info_hstry", schema = "std_snp_svc")
@Getter
@NoArgsConstructor
public class ShipComplianceHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "row_index")
private Long rowIndex;
@Column(name = "imo_no", nullable = false, length = 10)
private String imoNo;
@Column(name = "last_mdfcn_dt", nullable = false)
private LocalDateTime lastModifiedDate;
@Column(name = "flctn_col_nm", nullable = false, length = 100)
private String changedColumnName;
@Column(name = "bfr_val", length = 4)
private String beforeValue;
@Column(name = "aftr_val", length = 4)
private String afterValue;
@Column(name = "crt_dt", nullable = false)
private LocalDateTime createdDate;
}

파일 보기

@ -0,0 +1,45 @@
package com.snp.batch.global.model.screening;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 선박 위험지표 변경 이력 (읽기 전용)
*/
@Entity
@Table(name = "tb_ship_risk_detail_info_hstry", schema = "std_snp_svc")
@Getter
@NoArgsConstructor
public class ShipRiskDetailHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "row_index")
private Long rowIndex;
@Column(name = "imo_no", nullable = false, length = 10)
private String imoNo;
@Column(name = "last_mdfcn_dt", nullable = false)
private LocalDateTime lastModifiedDate;
@Column(name = "flctn_col_nm", nullable = false, length = 100)
private String changedColumnName;
@Column(name = "bfr_val", length = 4)
private String beforeValue;
@Column(name = "aftr_val", length = 4)
private String afterValue;
@Column(name = "crt_dt", nullable = false)
private LocalDateTime createdDate;
}

파일 보기

@ -0,0 +1,12 @@
package com.snp.batch.global.repository.screening;
import com.snp.batch.global.model.screening.CompanyComplianceHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CompanyComplianceHistoryRepository extends JpaRepository<CompanyComplianceHistory, Long> {
List<CompanyComplianceHistory> findByCompanyCodeOrderByLastModifiedDateDesc(String companyCode);
}

파일 보기

@ -0,0 +1,12 @@
package com.snp.batch.global.repository.screening;
import com.snp.batch.global.model.screening.ShipComplianceHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ShipComplianceHistoryRepository extends JpaRepository<ShipComplianceHistory, Long> {
List<ShipComplianceHistory> findByImoNoOrderByLastModifiedDateDesc(String imoNo);
}

파일 보기

@ -0,0 +1,113 @@
package com.snp.batch.global.repository.screening;
import com.snp.batch.global.model.screening.ShipRiskDetailHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ShipRiskDetailHistoryRepository extends JpaRepository<ShipRiskDetailHistory, Long> {
@Query(nativeQuery = true, value = """
SELECT h.row_index, h.imo_no, h.last_mdfcn_dt, h.flctn_col_nm, h.bfr_val, h.aftr_val,
CASE h.flctn_col_nm
WHEN 'ais_notrcv_elps_days' THEN d.ais_notrcv_elps_days_desc
WHEN 'ais_lwrnk_days' THEN d.ais_lwrnk_days_desc
WHEN 'ais_up_imo_desc' THEN d.ais_up_imo_desc_val
WHEN 'othr_ship_nm_voy_yn' THEN d.othr_ship_nm_voy_yn_desc
WHEN 'mmsi_anom_message' THEN d.mmsi_anom_message_desc
WHEN 'recent_dark_actv' THEN d.recent_dark_actv_desc
WHEN 'port_prtcll' THEN d.port_prtcll_desc
WHEN 'port_risk' THEN d.port_risk_desc
WHEN 'sts_job' THEN d.sts_job_desc
WHEN 'drift_chg' THEN d.drift_chg_desc
WHEN 'risk_event' THEN d.risk_event_desc
WHEN 'ntnlty_chg' THEN d.ntnlty_chg_desc
WHEN 'ntnlty_prs_mou_perf' THEN d.ntnlty_prs_mou_perf_desc
WHEN 'ntnlty_tky_mou_perf' THEN d.ntnlty_tky_mou_perf_desc
WHEN 'ntnlty_uscg_mou_perf' THEN d.ntnlty_uscg_mou_perf_desc
WHEN 'uscg_excl_ship_cert' THEN d.uscg_excl_ship_cert_desc
WHEN 'psc_inspection_elps_hr' THEN d.psc_inspection_elps_hr_desc
WHEN 'psc_inspection' THEN d.psc_inspection_desc
WHEN 'psc_defect' THEN d.psc_defect_desc
WHEN 'psc_detained' THEN d.psc_detained_desc
WHEN 'now_smgrc_evdc' THEN d.now_smgrc_evdc_desc
WHEN 'docc_chg' THEN d.docc_chg_desc
WHEN 'now_clfic' THEN d.now_clfic_desc
WHEN 'clfic_status_chg' THEN d.clfic_status_chg_desc
WHEN 'pni_insrnc' THEN d.pni_insrnc_desc
WHEN 'ship_nm_chg' THEN d.ship_nm_chg_desc
WHEN 'gbo_chg' THEN d.gbo_chg_desc
WHEN 'vslage' THEN d.vslage_desc
WHEN 'ilgl_fshr_viol' THEN d.ilgl_fshr_viol_desc
WHEN 'draft_chg' THEN d.draft_chg_desc
WHEN 'recent_sanction_prtcll' THEN d.recent_sanction_prtcll_desc
WHEN 'sngl_ship_voy' THEN d.sngl_ship_voy_desc
WHEN 'fltsfty' THEN d.fltsfty_desc
WHEN 'flt_psc' THEN d.flt_psc_desc
WHEN 'spc_inspection_ovdue' THEN d.spc_inspection_ovdue_desc
WHEN 'ownr_unk' THEN d.ownr_unk_desc
WHEN 'rss_port_call' THEN d.rss_port_call_desc
WHEN 'rss_ownr_reg' THEN d.rss_ownr_reg_desc
WHEN 'rss_sts' THEN d.rss_sts_desc
END as narrative,
CASE h.flctn_col_nm
WHEN 'ais_notrcv_elps_days' THEN p.ais_notrcv_elps_days_desc
WHEN 'ais_lwrnk_days' THEN p.ais_lwrnk_days_desc
WHEN 'ais_up_imo_desc' THEN p.ais_up_imo_desc_val
WHEN 'othr_ship_nm_voy_yn' THEN p.othr_ship_nm_voy_yn_desc
WHEN 'mmsi_anom_message' THEN p.mmsi_anom_message_desc
WHEN 'recent_dark_actv' THEN p.recent_dark_actv_desc
WHEN 'port_prtcll' THEN p.port_prtcll_desc
WHEN 'port_risk' THEN p.port_risk_desc
WHEN 'sts_job' THEN p.sts_job_desc
WHEN 'drift_chg' THEN p.drift_chg_desc
WHEN 'risk_event' THEN p.risk_event_desc
WHEN 'ntnlty_chg' THEN p.ntnlty_chg_desc
WHEN 'ntnlty_prs_mou_perf' THEN p.ntnlty_prs_mou_perf_desc
WHEN 'ntnlty_tky_mou_perf' THEN p.ntnlty_tky_mou_perf_desc
WHEN 'ntnlty_uscg_mou_perf' THEN p.ntnlty_uscg_mou_perf_desc
WHEN 'uscg_excl_ship_cert' THEN p.uscg_excl_ship_cert_desc
WHEN 'psc_inspection_elps_hr' THEN p.psc_inspection_elps_hr_desc
WHEN 'psc_inspection' THEN p.psc_inspection_desc
WHEN 'psc_defect' THEN p.psc_defect_desc
WHEN 'psc_detained' THEN p.psc_detained_desc
WHEN 'now_smgrc_evdc' THEN p.now_smgrc_evdc_desc
WHEN 'docc_chg' THEN p.docc_chg_desc
WHEN 'now_clfic' THEN p.now_clfic_desc
WHEN 'clfic_status_chg' THEN p.clfic_status_chg_desc
WHEN 'pni_insrnc' THEN p.pni_insrnc_desc
WHEN 'ship_nm_chg' THEN p.ship_nm_chg_desc
WHEN 'gbo_chg' THEN p.gbo_chg_desc
WHEN 'vslage' THEN p.vslage_desc
WHEN 'ilgl_fshr_viol' THEN p.ilgl_fshr_viol_desc
WHEN 'draft_chg' THEN p.draft_chg_desc
WHEN 'recent_sanction_prtcll' THEN p.recent_sanction_prtcll_desc
WHEN 'sngl_ship_voy' THEN p.sngl_ship_voy_desc
WHEN 'fltsfty' THEN p.fltsfty_desc
WHEN 'flt_psc' THEN p.flt_psc_desc
WHEN 'spc_inspection_ovdue' THEN p.spc_inspection_ovdue_desc
WHEN 'ownr_unk' THEN p.ownr_unk_desc
WHEN 'rss_port_call' THEN p.rss_port_call_desc
WHEN 'rss_ownr_reg' THEN p.rss_ownr_reg_desc
WHEN 'rss_sts' THEN p.rss_sts_desc
END as prev_narrative
FROM std_snp_svc.tb_ship_risk_detail_info_hstry h
LEFT JOIN std_snp_svc.tb_ship_risk_detail_hstry d
ON h.imo_no = d.imo_no AND h.last_mdfcn_dt = d.last_mdfcn_dt
LEFT JOIN LATERAL (
SELECT *
FROM std_snp_svc.tb_ship_risk_detail_hstry prev
WHERE prev.imo_no = h.imo_no
AND prev.last_mdfcn_dt < h.last_mdfcn_dt
ORDER BY prev.last_mdfcn_dt DESC
LIMIT 1
) p ON true
WHERE h.imo_no = :imoNo
ORDER BY h.last_mdfcn_dt DESC
""")
List<Object[]> findRiskHistoryWithNarrative(@Param("imoNo") String imoNo);
}

파일 보기

@ -1,11 +1,13 @@
package com.snp.batch.service;
import com.snp.batch.global.dto.screening.ChangeHistoryResponse;
import com.snp.batch.global.dto.screening.ComplianceCategoryResponse;
import com.snp.batch.global.dto.screening.ComplianceIndicatorResponse;
import com.snp.batch.global.dto.screening.MethodologyHistoryResponse;
import com.snp.batch.global.dto.screening.RiskCategoryResponse;
import com.snp.batch.global.dto.screening.RiskIndicatorResponse;
import com.snp.batch.global.model.screening.ChangeTypeLang;
import com.snp.batch.global.model.screening.CompanyComplianceHistory;
import com.snp.batch.global.model.screening.ComplianceIndicator;
import com.snp.batch.global.model.screening.ComplianceIndicatorLang;
import com.snp.batch.global.model.screening.MethodologyHistory;
@ -13,7 +15,9 @@ import com.snp.batch.global.model.screening.MethodologyHistoryLang;
import com.snp.batch.global.model.screening.RiskIndicator;
import com.snp.batch.global.model.screening.RiskIndicatorCategoryLang;
import com.snp.batch.global.model.screening.RiskIndicatorLang;
import com.snp.batch.global.model.screening.ShipComplianceHistory;
import com.snp.batch.global.repository.screening.ChangeTypeLangRepository;
import com.snp.batch.global.repository.screening.CompanyComplianceHistoryRepository;
import com.snp.batch.global.repository.screening.ComplianceIndicatorLangRepository;
import com.snp.batch.global.repository.screening.ComplianceIndicatorRepository;
import com.snp.batch.global.repository.screening.MethodologyHistoryLangRepository;
@ -21,11 +25,14 @@ import com.snp.batch.global.repository.screening.MethodologyHistoryRepository;
import com.snp.batch.global.repository.screening.RiskIndicatorCategoryLangRepository;
import com.snp.batch.global.repository.screening.RiskIndicatorLangRepository;
import com.snp.batch.global.repository.screening.RiskIndicatorRepository;
import com.snp.batch.global.repository.screening.ShipComplianceHistoryRepository;
import com.snp.batch.global.repository.screening.ShipRiskDetailHistoryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -37,6 +44,8 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class ScreeningGuideService {
private static final DateTimeFormatter DT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final RiskIndicatorRepository riskIndicatorRepo;
private final RiskIndicatorLangRepository riskIndicatorLangRepo;
private final RiskIndicatorCategoryLangRepository riskCategoryLangRepo;
@ -45,6 +54,9 @@ public class ScreeningGuideService {
private final MethodologyHistoryRepository methodologyHistoryRepo;
private final MethodologyHistoryLangRepository methodologyHistoryLangRepo;
private final ChangeTypeLangRepository changeTypeLangRepo;
private final ShipRiskDetailHistoryRepository shipRiskDetailHistoryRepo;
private final ShipComplianceHistoryRepository shipComplianceHistoryRepo;
private final CompanyComplianceHistoryRepository companyComplianceHistoryRepo;
/**
* 카테고리별 Risk 지표 목록 조회
@ -161,4 +173,135 @@ public class ScreeningGuideService {
.build();
}).toList();
}
/**
* 선박 위험지표 변경 이력 조회
*
* @param imoNo IMO 번호
* @param lang 언어 코드 (: KO, EN)
* @return 변경 이력 응답 목록
*/
@Transactional(readOnly = true)
public List<ChangeHistoryResponse> getShipRiskDetailHistory(String imoNo, String lang) {
Map<String, String> fieldNameMap = getRiskFieldNameMap(lang);
Map<String, Integer> sortOrderMap = getRiskSortOrderMap();
List<Object[]> rows = shipRiskDetailHistoryRepo.findRiskHistoryWithNarrative(imoNo);
return rows.stream().map(row -> {
String colName = (String) row[3];
return ChangeHistoryResponse.builder()
.rowIndex(((Number) row[0]).longValue())
.searchKey((String) row[1])
.lastModifiedDate(row[2] != null ? row[2].toString().substring(0, Math.min(row[2].toString().length(), 19)) : "")
.changedColumnName(colName)
.beforeValue((String) row[4])
.afterValue((String) row[5])
.narrative((String) row[6])
.prevNarrative((String) row[7])
.fieldName(fieldNameMap.getOrDefault(colName, colName))
.sortOrder(sortOrderMap.getOrDefault(colName, Integer.MAX_VALUE))
.build();
}).toList();
}
/**
* 선박 제재 변경 이력 조회
*
* @param imoNo IMO 번호
* @param lang 언어 코드 (: KO, EN)
* @return 변경 이력 응답 목록
*/
@Transactional(readOnly = true)
public List<ChangeHistoryResponse> getShipComplianceHistory(String imoNo, String lang) {
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "SHIP");
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("SHIP");
return shipComplianceHistoryRepo.findByImoNoOrderByLastModifiedDateDesc(imoNo).stream()
.map(h -> ChangeHistoryResponse.builder()
.rowIndex(h.getRowIndex())
.searchKey(h.getImoNo())
.lastModifiedDate(h.getLastModifiedDate() != null ? h.getLastModifiedDate().format(DT_FORMAT) : "")
.changedColumnName(h.getChangedColumnName())
.beforeValue(h.getBeforeValue())
.afterValue(h.getAfterValue())
.fieldName(fieldNameMap.getOrDefault(h.getChangedColumnName(), h.getChangedColumnName()))
.sortOrder(sortOrderMap.getOrDefault(h.getChangedColumnName(), Integer.MAX_VALUE))
.build())
.toList();
}
/**
* 회사 제재 변경 이력 조회
*
* @param companyCode 회사 코드
* @param lang 언어 코드 (: KO, EN)
* @return 변경 이력 응답 목록
*/
@Transactional(readOnly = true)
public List<ChangeHistoryResponse> getCompanyComplianceHistory(String companyCode, String lang) {
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY");
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("COMPANY");
return companyComplianceHistoryRepo.findByCompanyCodeOrderByLastModifiedDateDesc(companyCode).stream()
.map(h -> ChangeHistoryResponse.builder()
.rowIndex(h.getRowIndex())
.searchKey(h.getCompanyCode())
.lastModifiedDate(h.getLastModifiedDate() != null ? h.getLastModifiedDate().format(DT_FORMAT) : "")
.changedColumnName(h.getChangedColumnName())
.beforeValue(h.getBeforeValue())
.afterValue(h.getAfterValue())
.fieldName(fieldNameMap.getOrDefault(h.getChangedColumnName(), h.getChangedColumnName()))
.sortOrder(sortOrderMap.getOrDefault(h.getChangedColumnName(), Integer.MAX_VALUE))
.build())
.toList();
}
/**
* Risk 지표 컬럼명 필드명 매핑 조회
*/
private Map<String, String> getRiskFieldNameMap(String lang) {
Map<Integer, String> langMap = riskIndicatorLangRepo.findByLangCodeOrderByIndicatorIdAsc(lang).stream()
.collect(Collectors.toMap(RiskIndicatorLang::getIndicatorId, RiskIndicatorLang::getFieldName));
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
.filter(ri -> ri.getColumnName() != null)
.collect(Collectors.toMap(
RiskIndicator::getColumnName,
ri -> langMap.getOrDefault(ri.getIndicatorId(), ri.getFieldKey()),
(a, b) -> a));
}
private Map<String, Integer> getRiskSortOrderMap() {
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
.filter(ri -> ri.getColumnName() != null)
.collect(Collectors.toMap(
RiskIndicator::getColumnName,
RiskIndicator::getSortOrder,
(a, b) -> a));
}
/**
* Compliance 지표 컬럼명 필드명 매핑 조회
*/
private Map<String, String> getComplianceFieldNameMap(String lang, String type) {
Map<Integer, String> langMap = complianceIndicatorLangRepo.findByLangCode(lang).stream()
.collect(Collectors.toMap(ComplianceIndicatorLang::getIndicatorId, ComplianceIndicatorLang::getFieldName));
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
return indicators.stream()
.filter(ci -> ci.getColumnName() != null)
.collect(Collectors.toMap(
ComplianceIndicator::getColumnName,
ci -> langMap.getOrDefault(ci.getIndicatorId(), ci.getFieldKey()),
(a, b) -> a));
}
private Map<String, Integer> getComplianceSortOrderMap(String type) {
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
return indicators.stream()
.filter(ci -> ci.getColumnName() != null)
.collect(Collectors.toMap(
ComplianceIndicator::getColumnName,
ComplianceIndicator::getSortOrder,
(a, b) -> a));
}
}