feat: 선박/회사 기본정보 및 현재 Risk&Compliance 상태 조회 (#111)

- 선박 기본정보 (tb_ship_info_mst) / 회사 기본정보 (tb_company_dtl_info) 조회 API
- 현재 Risk 지표 상태 조회 (JdbcTemplate unpivot, 카테고리별 그리드 + 색상배지)
- 현재 Compliance 상태 조회 (선박: Sanctions/Port Calls/STS/Suspicious 탭 분리)
- 회사 Compliance 헤더에 Overall 상태 배지 표시
- Risk/Compliance 지표 예외 처리 (IUU, Risk Data Maintained, Parent Company 등)
- Risk prevNarrative LATERAL JOIN으로 이전값 설명 표시
- 다국어 캐시 + category 기반 탭 매칭 (언어 전환 시 데이터 유지)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-03-30 17:26:16 +09:00
부모 7eb2611c02
커밋 ba19ac203d
11개의 변경된 파일992개의 추가작업 그리고 155개의 파일을 삭제

파일 보기

@ -67,6 +67,43 @@ export interface ChangeHistoryResponse {
sortOrder: number; sortOrder: number;
} }
// 선박 기본 정보
export interface ShipInfoResponse {
imoNo: string;
shipName: string;
shipStatus: string;
nationality: string;
shipType: string;
dwt: string;
gt: string;
buildYear: string;
mmsiNo: string;
callSign: string;
shipTypeGroup: string;
}
// 회사 기본 정보
export interface CompanyInfoResponse {
companyCode: string;
fullName: string;
abbreviation: string;
country: string;
city: string;
status: string;
registrationCountry: string;
address: string;
}
// 지표 현재 상태
export interface IndicatorStatusResponse {
columnName: string;
fieldName: string;
category: string;
value: string | null;
narrative: string | null;
sortOrder: number;
}
const BASE = '/snp-api/api/screening-guide'; const BASE = '/snp-api/api/screening-guide';
async function fetchJson<T>(url: string): Promise<T> { async function fetchJson<T>(url: string): Promise<T> {
@ -88,4 +125,14 @@ export const screeningGuideApi = {
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-compliance?imoNo=${imoNo}&lang=${lang}`), fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-compliance?imoNo=${imoNo}&lang=${lang}`),
getCompanyComplianceHistory: (companyCode: string, lang = 'KO') => getCompanyComplianceHistory: (companyCode: string, lang = 'KO') =>
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/company-compliance?companyCode=${companyCode}&lang=${lang}`), fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/company-compliance?companyCode=${companyCode}&lang=${lang}`),
getShipInfo: (imoNo: string) =>
fetchJson<ApiResponse<ShipInfoResponse>>(`${BASE}/ship-info?imoNo=${imoNo}`),
getShipRiskStatus: (imoNo: string, lang = 'KO') =>
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/ship-risk-status?imoNo=${imoNo}&lang=${lang}`),
getShipComplianceStatus: (imoNo: string, lang = 'KO') =>
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/ship-compliance-status?imoNo=${imoNo}&lang=${lang}`),
getCompanyInfo: (companyCode: string) =>
fetchJson<ApiResponse<CompanyInfoResponse>>(`${BASE}/company-info?companyCode=${companyCode}`),
getCompanyComplianceStatus: (companyCode: string, lang = 'KO') =>
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/company-compliance-status?companyCode=${companyCode}&lang=${lang}`),
}; };

파일 보기

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { screeningGuideApi, type ChangeHistoryResponse } from '../../api/screeningGuideApi'; import { screeningGuideApi, type ChangeHistoryResponse, type ShipInfoResponse, type CompanyInfoResponse, type IndicatorStatusResponse } from '../../api/screeningGuideApi';
type HistoryType = 'ship-risk' | 'ship-compliance' | 'company-compliance'; type HistoryType = 'ship-risk' | 'ship-compliance' | 'company-compliance';
@ -73,6 +73,15 @@ function StatusBadge({ value }: { value: string | null }) {
); );
} }
function InfoField({ label, value }: { label: string; value: string | null | undefined }) {
return (
<div>
<div className="text-wing-muted mb-0.5">{label}</div>
<div className="font-medium text-wing-text">{value || '-'}</div>
</div>
);
}
function RiskValueCell({ value, narrative }: { value: string | null; narrative?: string }) { function RiskValueCell({ value, narrative }: { value: string | null; narrative?: string }) {
if (value == null || value === '') return null; if (value == null || value === '') return null;
const color = STATUS_COLORS[value] ?? '#6b7280'; const color = STATUS_COLORS[value] ?? '#6b7280';
@ -85,6 +94,228 @@ function RiskValueCell({ value, narrative }: { value: string | null; narrative?:
); );
} }
const COMPLIANCE_STATUS: Record<string, { label: string; className: string }> = {
'0': { label: 'No', className: 'bg-green-100 text-green-800' },
'1': { label: 'Warning', className: 'bg-yellow-100 text-yellow-800' },
'2': { label: 'Yes', className: 'bg-red-100 text-red-800' },
};
function getRiskLabel(item: IndicatorStatusResponse): string {
// IUU Fishing: All Clear -> None recorded
if (item.columnName === 'ilgl_fshr_viol' && item.value === '0') return 'None recorded';
// Risk Data Maintained: 0 -> Yes, 1 -> Not Maintained
if (item.columnName === 'risk_data_maint') {
if (item.value === '0') return 'Yes';
if (item.value === '1') return 'Not Maintained';
}
return item.narrative || STATUS_LABELS[item.value ?? ''] || item.value || '-';
}
function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
// Risk Data Maintained 체크: Not Maintained(1)이면 해당 지표만 표시
const riskDataMaintained = items.find((i) => i.columnName === 'risk_data_maint');
const isNotMaintained = riskDataMaintained?.value === '1';
const displayItems = isNotMaintained
? items.filter((i) => i.columnName === 'risk_data_maint')
: items;
const categories = useMemo(() => {
const map = new Map<string, IndicatorStatusResponse[]>();
for (const item of displayItems) {
const cat = item.category || 'Other';
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(item);
}
return Array.from(map.entries());
}, [displayItems]);
return (
<div>
{isNotMaintained && (
<div className="mb-3 px-3 py-2 bg-yellow-50 border border-yellow-300 rounded-lg text-xs text-yellow-800 font-medium">
Risk Data is not maintained for this vessel. Only the maintenance status is shown.
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{categories.map(([category, catItems]) => (
<div key={category}>
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
{category}
</div>
<div className="space-y-1.5">
{catItems.map((item) => {
const color = STATUS_COLORS[item.value ?? ''] ?? '#6b7280';
const label = getRiskLabel(item);
return (
<div key={item.columnName} className="flex items-center gap-2 text-xs">
<span className="text-wing-text truncate flex-1">{item.fieldName}</span>
<span style={{ color }} className="shrink-0 text-sm"></span>
<span
className="shrink-0 rounded px-2 py-0.5 text-[10px] font-bold text-wing-text text-center bg-wing-surface border border-wing-border"
style={{ minWidth: '140px' }}
>
{label}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
// 선박 Compliance 탭 분류
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (category: string) => boolean }[] = [
{ key: 'sanctions', label: 'Sanctions', match: (cat) => cat.includes('Sanctions') || cat.includes('FATF') || cat.includes('Other Compliance') },
{ key: 'portcalls', label: 'Port Calls', match: (cat) => cat === 'Port Calls' },
{ key: 'sts', label: 'STS Activity', match: (cat) => cat === 'STS Activity' },
{ key: 'suspicious', label: 'Suspicious Behavior', match: (cat) => cat === 'Suspicious Behavior' },
];
// Compliance 예외 처리
function getComplianceLabel(item: IndicatorStatusResponse, isCompany: boolean): string | null {
// Parent Company 관련: null -> No Parent
if (item.value == null || item.value === '') {
if (item.fieldName.includes('Parent Company') || item.fieldName.includes('Parent company')) return 'No Parent';
if (isCompany && item.columnName === 'prnt_company_compliance_risk') return 'No Parent';
}
return null;
}
// 제외할 컬럼명
const SHIP_COMPLIANCE_EXCLUDE = ['lgl_snths_sanction']; // Overall은 토글 헤더에 표시
const COMPANY_COMPLIANCE_EXCLUDE = ['company_snths_compliance_status']; // Overall은 토글 헤더에 표시
function ComplianceStatusItem({ item, isCompany }: { item: IndicatorStatusResponse; isCompany: boolean }) {
const overrideLabel = getComplianceLabel(item, isCompany);
const status = overrideLabel ? null : COMPLIANCE_STATUS[item.value ?? ''];
const displayLabel = overrideLabel || (status ? status.label : (item.value ?? '-'));
const displayClass = overrideLabel
? 'bg-wing-surface text-wing-muted border border-wing-border'
: status ? status.className : 'bg-wing-surface text-wing-muted border border-wing-border';
return (
<div className="flex items-center gap-2 text-xs">
<span className="text-wing-text truncate flex-1">{item.fieldName}</span>
<span
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-bold text-center ${displayClass}`}
style={{ minWidth: '80px' }}
>
{displayLabel}
</span>
</div>
);
}
function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResponse[]; isCompany: boolean }) {
const [activeTab, setActiveTab] = useState('sanctions');
// 제외 항목 필터링
const excludeList = isCompany ? COMPANY_COMPLIANCE_EXCLUDE : SHIP_COMPLIANCE_EXCLUDE;
const filteredItems = items.filter((i) => !excludeList.includes(i.columnName));
// 회사: 탭 없이 2컬럼 그리드
if (isCompany) {
const categories = useMemo(() => {
const map = new Map<string, IndicatorStatusResponse[]>();
for (const item of filteredItems) {
const cat = item.category || 'Other';
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(item);
}
return Array.from(map.entries());
}, [filteredItems]);
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{categories.map(([category, catItems]) => (
<div key={category}>
{categories.length > 1 && (
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
{category}
</div>
)}
<div className="space-y-1.5">
{catItems.map((item) => (
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} />
))}
</div>
</div>
))}
</div>
);
}
// 선박: 탭 기반 분류
const tabData = useMemo(() => {
const result: Record<string, IndicatorStatusResponse[]> = {};
for (const tab of SHIP_COMPLIANCE_TABS) {
result[tab.key] = filteredItems.filter((i) => tab.match(i.category));
}
return result;
}, [filteredItems]);
const currentItems = tabData[activeTab] ?? [];
// 현재 탭 내 카테고리별 그룹핑
const categories = useMemo(() => {
const map = new Map<string, IndicatorStatusResponse[]>();
for (const item of currentItems) {
const cat = item.category || 'Other';
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(item);
}
return Array.from(map.entries());
}, [currentItems]);
return (
<div className="space-y-3">
{/* 탭 버튼 */}
<div className="flex gap-1.5 flex-wrap">
{SHIP_COMPLIANCE_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${
activeTab === tab.key
? 'bg-slate-900 text-white border-slate-900'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
}`}
>
{tab.label} ({tabData[tab.key]?.length ?? 0})
</button>
))}
</div>
{/* 현재 탭 내용 */}
{currentItems.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{categories.map(([category, catItems]) => (
<div key={category}>
{categories.length > 1 && (
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
{category}
</div>
)}
<div className="space-y-1.5">
{catItems.map((item) => (
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} />
))}
</div>
</div>
))}
</div>
) : (
<div className="text-center text-xs text-wing-muted py-4"> .</div>
)}
</div>
);
}
export default function HistoryTab({ lang }: HistoryTabProps) { export default function HistoryTab({ lang }: HistoryTabProps) {
const [historyType, setHistoryType] = useState<HistoryType>('ship-risk'); const [historyType, setHistoryType] = useState<HistoryType>('ship-risk');
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
@ -93,10 +324,17 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searched, setSearched] = useState(false); const [searched, setSearched] = useState(false);
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set()); const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
const [shipInfo, setShipInfo] = useState<ShipInfoResponse | null>(null);
const [companyInfo, setCompanyInfo] = useState<CompanyInfoResponse | null>(null);
const [riskStatusCache, setRiskStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({});
const [complianceStatusCache, setComplianceStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({});
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['info', 'risk', 'compliance', 'history']));
const currentType = HISTORY_TYPES.find((t) => t.key === historyType)!; const currentType = HISTORY_TYPES.find((t) => t.key === historyType)!;
const isRisk = historyType === 'ship-risk'; const isRisk = historyType === 'ship-risk';
const data = cache[lang] ?? []; const data = cache[lang] ?? [];
const riskStatus = riskStatusCache[lang] ?? [];
const complianceStatus = complianceStatusCache[lang] ?? [];
const grouped: GroupedHistory[] = useMemo(() => { const grouped: GroupedHistory[] = useMemo(() => {
const map = new Map<string, ChangeHistoryResponse[]>(); const map = new Map<string, ChangeHistoryResponse[]>();
@ -127,16 +365,62 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
setError(null); setError(null);
setSearched(true); setSearched(true);
setExpandedDates(new Set()); setExpandedDates(new Set());
setExpandedSections(new Set(['info', 'risk', 'compliance', 'history']));
const getApiCall = (l: string) => const isShip = historyType !== 'company-compliance';
const showRisk = historyType === 'ship-risk';
const getHistoryCall = (l: string) =>
historyType === 'ship-risk' historyType === 'ship-risk'
? screeningGuideApi.getShipRiskHistory(trimmed, l) ? screeningGuideApi.getShipRiskHistory(trimmed, l)
: historyType === 'ship-compliance' : historyType === 'ship-compliance'
? screeningGuideApi.getShipComplianceHistory(trimmed, l) ? screeningGuideApi.getShipComplianceHistory(trimmed, l)
: screeningGuideApi.getCompanyComplianceHistory(trimmed, l); : screeningGuideApi.getCompanyComplianceHistory(trimmed, l);
Promise.all([getApiCall('KO'), getApiCall('EN')]) const promises: Promise<any>[] = [
.then(([ko, en]) => setCache({ KO: ko.data ?? [], EN: en.data ?? [] })) getHistoryCall('KO'),
getHistoryCall('EN'),
];
if (isShip) {
promises.push(
screeningGuideApi.getShipInfo(trimmed),
screeningGuideApi.getShipComplianceStatus(trimmed, 'KO'),
screeningGuideApi.getShipComplianceStatus(trimmed, 'EN'),
);
if (showRisk) {
promises.push(
screeningGuideApi.getShipRiskStatus(trimmed, 'KO'),
screeningGuideApi.getShipRiskStatus(trimmed, 'EN'),
);
}
} else {
promises.push(
screeningGuideApi.getCompanyInfo(trimmed),
screeningGuideApi.getCompanyComplianceStatus(trimmed, 'KO'),
screeningGuideApi.getCompanyComplianceStatus(trimmed, 'EN'),
);
}
Promise.all(promises)
.then((results) => {
setCache({ KO: results[0].data ?? [], EN: results[1].data ?? [] });
if (isShip) {
setShipInfo(results[2].data);
setCompanyInfo(null);
setComplianceStatusCache({ KO: results[3].data ?? [], EN: results[4].data ?? [] });
if (showRisk) {
setRiskStatusCache({ KO: results[5].data ?? [], EN: results[6].data ?? [] });
} else {
setRiskStatusCache({});
}
} else {
setShipInfo(null);
setCompanyInfo(results[2].data);
setRiskStatusCache({});
setComplianceStatusCache({ KO: results[3].data ?? [], EN: results[4].data ?? [] });
}
})
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} }
@ -152,6 +436,20 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
setError(null); setError(null);
setSearched(false); setSearched(false);
setExpandedDates(new Set()); setExpandedDates(new Set());
setShipInfo(null);
setCompanyInfo(null);
setRiskStatusCache({});
setComplianceStatusCache({});
setExpandedSections(new Set(['info', 'risk', 'compliance', 'history']));
}
function toggleSection(section: string) {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) next.delete(section);
else next.add(section);
return next;
});
} }
function toggleDate(date: string) { function toggleDate(date: string) {
@ -231,15 +529,115 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
</div> </div>
)} )}
{/* 결과: 날짜별 토글 */} {/* 결과: 3개 섹션 */}
{searched && !loading && !error && ( {searched && !loading && !error && (
<> <div className="space-y-3">
<div className="text-xs text-wing-muted"> {/* Section 1: 기본 정보 */}
: <strong className="text-wing-text">{grouped.length}</strong> ,{' '} {(shipInfo || companyInfo) && (
<strong className="text-wing-text">{data.length}</strong> <div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<button
onClick={() => toggleSection('info')}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
>
<span className={`text-xs transition-transform ${expandedSections.has('info') ? 'rotate-90' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text">
{shipInfo ? '선박 기본 정보' : '회사 기본 정보'}
</span>
</button>
{expandedSections.has('info') && (
<div className="border-t border-wing-border px-4 py-4">
{shipInfo && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<InfoField label="선박명" value={shipInfo.shipName} />
<InfoField label="IMO" value={shipInfo.imoNo} />
<InfoField label="MMSI" value={shipInfo.mmsiNo} />
<InfoField label="호출부호" value={shipInfo.callSign} />
<InfoField label="국적" value={shipInfo.nationality} />
<InfoField label="선종" value={shipInfo.shipType} />
<InfoField label="DWT" value={shipInfo.dwt} />
<InfoField label="GT" value={shipInfo.gt} />
<InfoField label="건조연도" value={shipInfo.buildYear} />
<InfoField label="상태" value={shipInfo.shipStatus} />
</div> </div>
)}
{companyInfo && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<InfoField label="회사명" value={companyInfo.fullName} />
<InfoField label="회사코드" value={companyInfo.companyCode} />
<InfoField label="약칭" value={companyInfo.abbreviation} />
<InfoField label="국가" value={companyInfo.country} />
<InfoField label="도시" value={companyInfo.city} />
<InfoField label="등록국가" value={companyInfo.registrationCountry} />
<InfoField label="상태" value={companyInfo.status} />
<InfoField label="주소" value={companyInfo.address} />
</div>
)}
</div>
)}
</div>
)}
{/* Section 2: Current Risk Indicators (선박 탭만) */}
{riskStatus.length > 0 && (
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<button
onClick={() => toggleSection('risk')}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
>
<span className={`text-xs transition-transform ${expandedSections.has('risk') ? 'rotate-90' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text">Current Risk Indicators</span>
</button>
{expandedSections.has('risk') && (
<div className="border-t border-wing-border px-4 py-4">
<RiskStatusGrid items={riskStatus} />
</div>
)}
</div>
)}
{/* Section 3: Current Compliance (선박 제재/회사 제재 탭만) */}
{complianceStatus.length > 0 && !isRisk && (() => {
const isCompany = historyType === 'company-compliance';
const overallItem = isCompany
? complianceStatus.find((i) => i.columnName === 'company_snths_compliance_status')
: null;
return (
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<button
onClick={() => toggleSection('compliance')}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
>
<span className={`text-xs transition-transform ${expandedSections.has('compliance') ? 'rotate-90' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text">Current Compliance</span>
{overallItem && (
<StatusBadge value={overallItem.value} />
)}
</button>
{expandedSections.has('compliance') && (
<div className="border-t border-wing-border px-4 py-4">
<ComplianceStatusGrid items={complianceStatus} isCompany={isCompany} />
</div>
)}
</div>
);
})()}
{/* Section 4: 값 변경 이력 */}
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<button
onClick={() => toggleSection('history')}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
>
<span className={`text-xs transition-transform ${expandedSections.has('history') ? 'rotate-90' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text"> </span>
<span className="text-xs text-wing-muted">
{grouped.length} , {data.length}
</span>
</button>
{expandedSections.has('history') && (
<div className="border-t border-wing-border">
{grouped.length > 0 ? ( {grouped.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2 p-2">
{grouped.map((group) => { {grouped.map((group) => {
const isExpanded = expandedDates.has(group.lastModifiedDate); const isExpanded = expandedDates.has(group.lastModifiedDate);
const hasOverall = const hasOverall =
@ -377,14 +775,17 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
})} })}
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center py-12 text-wing-muted"> <div className="flex items-center justify-center py-8 text-wing-muted">
<div className="text-center"> <div className="text-center">
<div className="text-2xl mb-2">📭</div> <div className="text-xl mb-1">📭</div>
<div className="text-sm"> .</div> <div className="text-sm"> .</div>
</div> </div>
</div> </div>
)} )}
</> </div>
)}
</div>
</div>
)} )}
{/* 초기 상태 */} {/* 초기 상태 */}

파일 보기

@ -2,9 +2,12 @@ package com.snp.batch.global.controller;
import com.snp.batch.common.web.ApiResponse; import com.snp.batch.common.web.ApiResponse;
import com.snp.batch.global.dto.screening.ChangeHistoryResponse; import com.snp.batch.global.dto.screening.ChangeHistoryResponse;
import com.snp.batch.global.dto.screening.CompanyInfoResponse;
import com.snp.batch.global.dto.screening.ComplianceCategoryResponse; import com.snp.batch.global.dto.screening.ComplianceCategoryResponse;
import com.snp.batch.global.dto.screening.IndicatorStatusResponse;
import com.snp.batch.global.dto.screening.MethodologyHistoryResponse; import com.snp.batch.global.dto.screening.MethodologyHistoryResponse;
import com.snp.batch.global.dto.screening.RiskCategoryResponse; import com.snp.batch.global.dto.screening.RiskCategoryResponse;
import com.snp.batch.global.dto.screening.ShipInfoResponse;
import com.snp.batch.service.ScreeningGuideService; import com.snp.batch.service.ScreeningGuideService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@ -86,4 +89,50 @@ public class ScreeningGuideController {
@RequestParam(defaultValue = "KO") String lang) { @RequestParam(defaultValue = "KO") String lang) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceHistory(companyCode, lang))); return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceHistory(companyCode, lang)));
} }
@Operation(summary = "선박 기본 정보", description = "IMO 번호로 선박 기본 정보를 조회합니다.")
@GetMapping("/ship-info")
public ResponseEntity<ApiResponse<ShipInfoResponse>> getShipInfo(
@Parameter(description = "IMO 번호", example = "9672533", required = true)
@RequestParam String imoNo) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipInfo(imoNo)));
}
@Operation(summary = "선박 현재 Risk 지표 상태", description = "IMO 번호로 선박의 현재 Risk 지표 상태를 조회합니다.")
@GetMapping("/ship-risk-status")
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getShipRiskStatus(
@Parameter(description = "IMO 번호", example = "9672533", required = true)
@RequestParam String imoNo,
@Parameter(description = "언어 코드", example = "KO")
@RequestParam(defaultValue = "KO") String lang) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipRiskStatus(imoNo, lang)));
}
@Operation(summary = "선박 현재 Compliance 상태", description = "IMO 번호로 선박의 현재 Compliance 상태를 조회합니다.")
@GetMapping("/ship-compliance-status")
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getShipComplianceStatus(
@Parameter(description = "IMO 번호", example = "9672533", required = true)
@RequestParam String imoNo,
@Parameter(description = "언어 코드", example = "KO")
@RequestParam(defaultValue = "KO") String lang) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipComplianceStatus(imoNo, lang)));
}
@Operation(summary = "회사 기본 정보", description = "회사 코드로 회사 기본 정보를 조회합니다.")
@GetMapping("/company-info")
public ResponseEntity<ApiResponse<CompanyInfoResponse>> getCompanyInfo(
@Parameter(description = "회사 코드", example = "1288896", required = true)
@RequestParam String companyCode) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyInfo(companyCode)));
}
@Operation(summary = "회사 현재 Compliance 상태", description = "회사 코드로 회사의 현재 Compliance 상태를 조회합니다.")
@GetMapping("/company-compliance-status")
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getCompanyComplianceStatus(
@Parameter(description = "회사 코드", example = "1288896", required = true)
@RequestParam String companyCode,
@Parameter(description = "언어 코드", example = "KO")
@RequestParam(defaultValue = "KO") String lang) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceStatus(companyCode, lang)));
}
} }

파일 보기

@ -0,0 +1,18 @@
package com.snp.batch.global.dto.screening;
import lombok.*;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CompanyInfoResponse {
private String companyCode;
private String fullName;
private String abbreviation;
private String country;
private String city;
private String status;
private String registrationCountry;
private String address;
}

파일 보기

@ -0,0 +1,16 @@
package com.snp.batch.global.dto.screening;
import lombok.*;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IndicatorStatusResponse {
private String columnName;
private String fieldName;
private String category;
private String value;
private String narrative;
private Integer sortOrder;
}

파일 보기

@ -0,0 +1,21 @@
package com.snp.batch.global.dto.screening;
import lombok.*;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShipInfoResponse {
private String imoNo;
private String shipName;
private String shipStatus;
private String nationality;
private String shipType;
private String dwt;
private String gt;
private String buildYear;
private String mmsiNo;
private String callSign;
private String shipTypeGroup;
}

파일 보기

@ -0,0 +1,37 @@
package com.snp.batch.global.model.screening;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "tb_company_dtl_info", schema = "std_snp_svc")
@Getter
@NoArgsConstructor
public class CompanyDetailInfo {
@Id
@Column(name = "company_cd", length = 14)
private String companyCode;
@Column(name = "full_nm", length = 280)
private String fullName;
@Column(name = "company_name_abbr", length = 60)
private String abbreviation;
@Column(name = "country_nm", length = 40)
private String country;
@Column(name = "cty_nm", length = 200)
private String city;
@Column(name = "company_status", length = 8)
private String status;
@Column(name = "country_reg", length = 40)
private String registrationCountry;
@Column(name = "oa_addr", length = 512)
private String address;
}

파일 보기

@ -0,0 +1,46 @@
package com.snp.batch.global.model.screening;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "tb_ship_info_mst", schema = "std_snp_svc")
@Getter
@NoArgsConstructor
public class ShipInfoMaster {
@Id
@Column(name = "imo_no", length = 10)
private String imoNo;
@Column(name = "ship_nm", length = 75)
private String shipName;
@Column(name = "ship_status", length = 50)
private String shipStatus;
@Column(name = "ship_ntnlty", length = 57)
private String nationality;
@Column(name = "ship_type_lv_five", length = 73)
private String shipType;
@Column(name = "dwt", length = 50)
private String dwt;
@Column(name = "gt", length = 50)
private String gt;
@Column(name = "build_yy", length = 50)
private String buildYear;
@Column(name = "mmsi_no", length = 50)
private String mmsiNo;
@Column(name = "clsgn_no", length = 50)
private String callSign;
@Column(name = "ship_type_lv_two", length = 50)
private String shipTypeGroup;
}

파일 보기

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

파일 보기

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

파일 보기

@ -1,11 +1,14 @@
package com.snp.batch.service; package com.snp.batch.service;
import com.snp.batch.global.dto.screening.ChangeHistoryResponse; import com.snp.batch.global.dto.screening.ChangeHistoryResponse;
import com.snp.batch.global.dto.screening.CompanyInfoResponse;
import com.snp.batch.global.dto.screening.ComplianceCategoryResponse; import com.snp.batch.global.dto.screening.ComplianceCategoryResponse;
import com.snp.batch.global.dto.screening.ComplianceIndicatorResponse; import com.snp.batch.global.dto.screening.ComplianceIndicatorResponse;
import com.snp.batch.global.dto.screening.IndicatorStatusResponse;
import com.snp.batch.global.dto.screening.MethodologyHistoryResponse; import com.snp.batch.global.dto.screening.MethodologyHistoryResponse;
import com.snp.batch.global.dto.screening.RiskCategoryResponse; import com.snp.batch.global.dto.screening.RiskCategoryResponse;
import com.snp.batch.global.dto.screening.RiskIndicatorResponse; import com.snp.batch.global.dto.screening.RiskIndicatorResponse;
import com.snp.batch.global.dto.screening.ShipInfoResponse;
import com.snp.batch.global.model.screening.ChangeTypeLang; import com.snp.batch.global.model.screening.ChangeTypeLang;
import com.snp.batch.global.model.screening.CompanyComplianceHistory; import com.snp.batch.global.model.screening.CompanyComplianceHistory;
import com.snp.batch.global.model.screening.ComplianceIndicator; import com.snp.batch.global.model.screening.ComplianceIndicator;
@ -18,6 +21,7 @@ import com.snp.batch.global.model.screening.RiskIndicatorLang;
import com.snp.batch.global.model.screening.ShipComplianceHistory; import com.snp.batch.global.model.screening.ShipComplianceHistory;
import com.snp.batch.global.repository.screening.ChangeTypeLangRepository; import com.snp.batch.global.repository.screening.ChangeTypeLangRepository;
import com.snp.batch.global.repository.screening.CompanyComplianceHistoryRepository; import com.snp.batch.global.repository.screening.CompanyComplianceHistoryRepository;
import com.snp.batch.global.repository.screening.CompanyDetailInfoRepository;
import com.snp.batch.global.repository.screening.ComplianceIndicatorLangRepository; import com.snp.batch.global.repository.screening.ComplianceIndicatorLangRepository;
import com.snp.batch.global.repository.screening.ComplianceIndicatorRepository; import com.snp.batch.global.repository.screening.ComplianceIndicatorRepository;
import com.snp.batch.global.repository.screening.MethodologyHistoryLangRepository; import com.snp.batch.global.repository.screening.MethodologyHistoryLangRepository;
@ -26,13 +30,18 @@ import com.snp.batch.global.repository.screening.RiskIndicatorCategoryLangReposi
import com.snp.batch.global.repository.screening.RiskIndicatorLangRepository; import com.snp.batch.global.repository.screening.RiskIndicatorLangRepository;
import com.snp.batch.global.repository.screening.RiskIndicatorRepository; import com.snp.batch.global.repository.screening.RiskIndicatorRepository;
import com.snp.batch.global.repository.screening.ShipComplianceHistoryRepository; import com.snp.batch.global.repository.screening.ShipComplianceHistoryRepository;
import com.snp.batch.global.repository.screening.ShipInfoMasterRepository;
import com.snp.batch.global.repository.screening.ShipRiskDetailHistoryRepository; import com.snp.batch.global.repository.screening.ShipRiskDetailHistoryRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -57,6 +66,9 @@ public class ScreeningGuideService {
private final ShipRiskDetailHistoryRepository shipRiskDetailHistoryRepo; private final ShipRiskDetailHistoryRepository shipRiskDetailHistoryRepo;
private final ShipComplianceHistoryRepository shipComplianceHistoryRepo; private final ShipComplianceHistoryRepository shipComplianceHistoryRepo;
private final CompanyComplianceHistoryRepository companyComplianceHistoryRepo; private final CompanyComplianceHistoryRepository companyComplianceHistoryRepo;
private final ShipInfoMasterRepository shipInfoMasterRepo;
private final CompanyDetailInfoRepository companyDetailInfoRepo;
private final JdbcTemplate jdbcTemplate;
/** /**
* 카테고리별 Risk 지표 목록 조회 * 카테고리별 Risk 지표 목록 조회
@ -253,6 +265,149 @@ public class ScreeningGuideService {
.toList(); .toList();
} }
/**
* 선박 기본 정보 조회
*/
@Transactional(readOnly = true)
public ShipInfoResponse getShipInfo(String imoNo) {
return shipInfoMasterRepo.findByImoNo(imoNo)
.map(s -> ShipInfoResponse.builder()
.imoNo(s.getImoNo())
.shipName(s.getShipName())
.shipStatus(s.getShipStatus())
.nationality(s.getNationality())
.shipType(s.getShipType())
.dwt(s.getDwt())
.gt(s.getGt())
.buildYear(s.getBuildYear())
.mmsiNo(s.getMmsiNo())
.callSign(s.getCallSign())
.shipTypeGroup(s.getShipTypeGroup())
.build())
.orElse(null);
}
/**
* 회사 기본 정보 조회
*/
@Transactional(readOnly = true)
public CompanyInfoResponse getCompanyInfo(String companyCode) {
return companyDetailInfoRepo.findByCompanyCode(companyCode)
.map(c -> CompanyInfoResponse.builder()
.companyCode(c.getCompanyCode())
.fullName(c.getFullName())
.abbreviation(c.getAbbreviation())
.country(c.getCountry())
.city(c.getCity())
.status(c.getStatus())
.registrationCountry(c.getRegistrationCountry())
.address(c.getAddress())
.build())
.orElse(null);
}
/**
* 선박 현재 Risk 지표 상태 조회
*/
@Transactional(readOnly = true)
public List<IndicatorStatusResponse> getShipRiskStatus(String imoNo, String lang) {
Map<String, String> fieldNameMap = getRiskFieldNameMap(lang);
Map<String, Integer> sortOrderMap = getRiskSortOrderMap();
Map<String, String> categoryMap = getRiskCategoryMap(lang);
try {
Map<String, Object> row = jdbcTemplate.queryForMap(
"SELECT * FROM std_snp_svc.tb_ship_risk_detail_info WHERE imo_no = ?", imoNo);
List<IndicatorStatusResponse> result = new ArrayList<>();
for (Map.Entry<String, Integer> entry : sortOrderMap.entrySet()) {
String colName = entry.getKey();
Object codeVal = row.get(colName);
String descColName = "ais_up_imo_desc".equals(colName) ? "ais_up_imo_desc_val" : colName + "_desc";
Object descVal = row.get(descColName);
result.add(IndicatorStatusResponse.builder()
.columnName(colName)
.fieldName(fieldNameMap.getOrDefault(colName, colName))
.category(categoryMap.getOrDefault(colName, ""))
.value(codeVal != null ? codeVal.toString() : null)
.narrative(descVal != null ? descVal.toString() : null)
.sortOrder(entry.getValue())
.build());
}
result.sort(Comparator.comparingInt(IndicatorStatusResponse::getSortOrder));
return result;
} catch (EmptyResultDataAccessException e) {
return List.of();
}
}
/**
* 선박 현재 Compliance 상태 조회
*/
@Transactional(readOnly = true)
public List<IndicatorStatusResponse> getShipComplianceStatus(String imoNo, String lang) {
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "SHIP");
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("SHIP");
Map<String, String> categoryMap = getComplianceCategoryMap("SHIP");
try {
Map<String, Object> row = jdbcTemplate.queryForMap(
"SELECT * FROM std_snp_svc.tb_ship_compliance_info WHERE imo_no = ?", imoNo);
List<IndicatorStatusResponse> result = new ArrayList<>();
for (Map.Entry<String, Integer> entry : sortOrderMap.entrySet()) {
String colName = entry.getKey();
Object codeVal = row.get(colName);
result.add(IndicatorStatusResponse.builder()
.columnName(colName)
.fieldName(fieldNameMap.getOrDefault(colName, colName))
.category(categoryMap.getOrDefault(colName, ""))
.value(codeVal != null ? codeVal.toString() : null)
.sortOrder(entry.getValue())
.build());
}
result.sort(Comparator.comparingInt(IndicatorStatusResponse::getSortOrder));
return result;
} catch (EmptyResultDataAccessException e) {
return List.of();
}
}
/**
* 회사 현재 Compliance 상태 조회
*/
@Transactional(readOnly = true)
public List<IndicatorStatusResponse> getCompanyComplianceStatus(String companyCode, String lang) {
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY");
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("COMPANY");
Map<String, String> categoryMap = getComplianceCategoryMap("COMPANY");
try {
Map<String, Object> row = jdbcTemplate.queryForMap(
"SELECT * FROM std_snp_svc.tb_company_compliance_info WHERE company_cd = ?", companyCode);
List<IndicatorStatusResponse> result = new ArrayList<>();
for (Map.Entry<String, Integer> entry : sortOrderMap.entrySet()) {
String colName = entry.getKey();
Object codeVal = row.get(colName);
result.add(IndicatorStatusResponse.builder()
.columnName(colName)
.fieldName(fieldNameMap.getOrDefault(colName, colName))
.category(categoryMap.getOrDefault(colName, ""))
.value(codeVal != null ? codeVal.toString() : null)
.sortOrder(entry.getValue())
.build());
}
result.sort(Comparator.comparingInt(IndicatorStatusResponse::getSortOrder));
return result;
} catch (EmptyResultDataAccessException e) {
return List.of();
}
}
/** /**
* Risk 지표 컬럼명 필드명 매핑 조회 * Risk 지표 컬럼명 필드명 매핑 조회
*/ */
@ -267,6 +422,17 @@ public class ScreeningGuideService {
(a, b) -> a)); (a, b) -> a));
} }
private Map<String, String> getRiskCategoryMap(String lang) {
Map<String, String> catNameMap = riskCategoryLangRepo.findByLangCode(lang).stream()
.collect(Collectors.toMap(RiskIndicatorCategoryLang::getCategoryCode, RiskIndicatorCategoryLang::getCategoryName));
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
.filter(ri -> ri.getColumnName() != null)
.collect(Collectors.toMap(
RiskIndicator::getColumnName,
ri -> catNameMap.getOrDefault(ri.getCategoryCode(), ri.getCategoryCode()),
(a, b) -> a));
}
private Map<String, Integer> getRiskSortOrderMap() { private Map<String, Integer> getRiskSortOrderMap() {
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream() return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
.filter(ri -> ri.getColumnName() != null) .filter(ri -> ri.getColumnName() != null)
@ -293,6 +459,18 @@ public class ScreeningGuideService {
(a, b) -> a)); (a, b) -> a));
} }
private Map<String, String> getComplianceCategoryMap(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::getCategory,
(a, b) -> a));
}
private Map<String, Integer> getComplianceSortOrderMap(String type) { private Map<String, Integer> getComplianceSortOrderMap(String type) {
List<ComplianceIndicator> indicators = (type != null && !type.isBlank()) List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type) ? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)