diff --git a/frontend/src/api/screeningGuideApi.ts b/frontend/src/api/screeningGuideApi.ts index 3a2847a..0609fa0 100644 --- a/frontend/src/api/screeningGuideApi.ts +++ b/frontend/src/api/screeningGuideApi.ts @@ -67,6 +67,43 @@ export interface ChangeHistoryResponse { 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'; async function fetchJson(url: string): Promise { @@ -88,4 +125,14 @@ export const screeningGuideApi = { fetchJson>(`${BASE}/history/ship-compliance?imoNo=${imoNo}&lang=${lang}`), getCompanyComplianceHistory: (companyCode: string, lang = 'KO') => fetchJson>(`${BASE}/history/company-compliance?companyCode=${companyCode}&lang=${lang}`), + getShipInfo: (imoNo: string) => + fetchJson>(`${BASE}/ship-info?imoNo=${imoNo}`), + getShipRiskStatus: (imoNo: string, lang = 'KO') => + fetchJson>(`${BASE}/ship-risk-status?imoNo=${imoNo}&lang=${lang}`), + getShipComplianceStatus: (imoNo: string, lang = 'KO') => + fetchJson>(`${BASE}/ship-compliance-status?imoNo=${imoNo}&lang=${lang}`), + getCompanyInfo: (companyCode: string) => + fetchJson>(`${BASE}/company-info?companyCode=${companyCode}`), + getCompanyComplianceStatus: (companyCode: string, lang = 'KO') => + fetchJson>(`${BASE}/company-compliance-status?companyCode=${companyCode}&lang=${lang}`), }; diff --git a/frontend/src/components/screening/HistoryTab.tsx b/frontend/src/components/screening/HistoryTab.tsx index 254af17..68e2034 100644 --- a/frontend/src/components/screening/HistoryTab.tsx +++ b/frontend/src/components/screening/HistoryTab.tsx @@ -1,5 +1,5 @@ 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'; @@ -73,6 +73,15 @@ function StatusBadge({ value }: { value: string | null }) { ); } +function InfoField({ label, value }: { label: string; value: string | null | undefined }) { + return ( +
+
{label}
+
{value || '-'}
+
+ ); +} + function RiskValueCell({ value, narrative }: { value: string | null; narrative?: string }) { if (value == null || value === '') return null; const color = STATUS_COLORS[value] ?? '#6b7280'; @@ -85,6 +94,228 @@ function RiskValueCell({ value, narrative }: { value: string | null; narrative?: ); } +const COMPLIANCE_STATUS: Record = { + '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(); + 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 ( +
+ {isNotMaintained && ( +
+ Risk Data is not maintained for this vessel. Only the maintenance status is shown. +
+ )} +
+ {categories.map(([category, catItems]) => ( +
+
+ {category} +
+
+ {catItems.map((item) => { + const color = STATUS_COLORS[item.value ?? ''] ?? '#6b7280'; + const label = getRiskLabel(item); + return ( +
+ {item.fieldName} + + + {label} + +
+ ); + })} +
+
+ ))} +
+
+ ); +} + +// 선박 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 ( +
+ {item.fieldName} + + {displayLabel} + +
+ ); +} + +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(); + 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 ( +
+ {categories.map(([category, catItems]) => ( +
+ {categories.length > 1 && ( +
+ {category} +
+ )} +
+ {catItems.map((item) => ( + + ))} +
+
+ ))} +
+ ); + } + + // 선박: 탭 기반 분류 + const tabData = useMemo(() => { + const result: Record = {}; + 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(); + 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 ( +
+ {/* 탭 버튼 */} +
+ {SHIP_COMPLIANCE_TABS.map((tab) => ( + + ))} +
+ + {/* 현재 탭 내용 */} + {currentItems.length > 0 ? ( +
+ {categories.map(([category, catItems]) => ( +
+ {categories.length > 1 && ( +
+ {category} +
+ )} +
+ {catItems.map((item) => ( + + ))} +
+
+ ))} +
+ ) : ( +
해당 항목이 없습니다.
+ )} +
+ ); +} + export default function HistoryTab({ lang }: HistoryTabProps) { const [historyType, setHistoryType] = useState('ship-risk'); const [searchValue, setSearchValue] = useState(''); @@ -93,10 +324,17 @@ export default function HistoryTab({ lang }: HistoryTabProps) { const [error, setError] = useState(null); const [searched, setSearched] = useState(false); const [expandedDates, setExpandedDates] = useState>(new Set()); + const [shipInfo, setShipInfo] = useState(null); + const [companyInfo, setCompanyInfo] = useState(null); + const [riskStatusCache, setRiskStatusCache] = useState>({}); + const [complianceStatusCache, setComplianceStatusCache] = useState>({}); + const [expandedSections, setExpandedSections] = useState>(new Set(['info', 'risk', 'compliance', 'history'])); const currentType = HISTORY_TYPES.find((t) => t.key === historyType)!; const isRisk = historyType === 'ship-risk'; const data = cache[lang] ?? []; + const riskStatus = riskStatusCache[lang] ?? []; + const complianceStatus = complianceStatusCache[lang] ?? []; const grouped: GroupedHistory[] = useMemo(() => { const map = new Map(); @@ -127,16 +365,62 @@ export default function HistoryTab({ lang }: HistoryTabProps) { setError(null); setSearched(true); 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' ? 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 ?? [] })) + const promises: Promise[] = [ + 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)) .finally(() => setLoading(false)); } @@ -152,6 +436,20 @@ export default function HistoryTab({ lang }: HistoryTabProps) { setError(null); setSearched(false); 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) { @@ -231,160 +529,263 @@ export default function HistoryTab({ lang }: HistoryTabProps) { )} - {/* 결과: 날짜별 토글 */} + {/* 결과: 3개 섹션 */} {searched && !loading && !error && ( - <> -
- 조회 결과: {grouped.length}개 일시,{' '} - {data.length}건 변동 -
- {grouped.length > 0 ? ( -
- {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 ( -
- - {isExpanded && displayItems.length > 0 && ( -
- - - - - - - - - - {displayItems.map((row) => ( - - - {isRisk ? ( - <> - - - - ) : ( - <> - - - - )} - - ))} - -
- 필드명 - - 이전값 - - 이후값 -
-
- {row.fieldName || row.changedColumnName} -
- {row.fieldName && ( -
- {row.changedColumnName} -
- )} -
- - - - - - - -
-
- )} -
- ); - })} -
- ) : ( -
-
-
📭
-
조회 결과가 없습니다.
-
+
+ {/* Section 1: 기본 정보 */} + {(shipInfo || companyInfo) && ( +
+ + {expandedSections.has('info') && ( +
+ {shipInfo && ( +
+ + + + + + + + + + +
+ )} + {companyInfo && ( +
+ + + + + + + + +
+ )} +
+ )}
)} - + + {/* Section 2: Current Risk Indicators (선박 탭만) */} + {riskStatus.length > 0 && ( +
+ + {expandedSections.has('risk') && ( +
+ +
+ )} +
+ )} + + {/* 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 ( +
+ + {expandedSections.has('compliance') && ( +
+ +
+ )} +
+ ); + })()} + + {/* Section 4: 값 변경 이력 */} +
+ + {expandedSections.has('history') && ( +
+ {grouped.length > 0 ? ( +
+ {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 ( +
+ + {isExpanded && displayItems.length > 0 && ( +
+ + + + + + + + + + {displayItems.map((row) => ( + + + {isRisk ? ( + <> + + + + ) : ( + <> + + + + )} + + ))} + +
+ 필드명 + + 이전값 + + 이후값 +
+
+ {row.fieldName || row.changedColumnName} +
+ {row.fieldName && ( +
+ {row.changedColumnName} +
+ )} +
+ + + + + + + +
+
+ )} +
+ ); + })} +
+ ) : ( +
+
+
📭
+
변경 이력이 없습니다.
+
+
+ )} +
+ )} +
+
)} {/* 초기 상태 */} diff --git a/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java b/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java index 8b33824..df5d2b0 100644 --- a/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java +++ b/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java @@ -2,9 +2,12 @@ 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.CompanyInfoResponse; 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.RiskCategoryResponse; +import com.snp.batch.global.dto.screening.ShipInfoResponse; import com.snp.batch.service.ScreeningGuideService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -86,4 +89,50 @@ public class ScreeningGuideController { @RequestParam(defaultValue = "KO") String lang) { return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceHistory(companyCode, lang))); } + + @Operation(summary = "선박 기본 정보", description = "IMO 번호로 선박 기본 정보를 조회합니다.") + @GetMapping("/ship-info") + public ResponseEntity> 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>> 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>> 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> 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>> 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))); + } } diff --git a/src/main/java/com/snp/batch/global/dto/screening/CompanyInfoResponse.java b/src/main/java/com/snp/batch/global/dto/screening/CompanyInfoResponse.java new file mode 100644 index 0000000..cfe76fa --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/screening/CompanyInfoResponse.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/dto/screening/IndicatorStatusResponse.java b/src/main/java/com/snp/batch/global/dto/screening/IndicatorStatusResponse.java new file mode 100644 index 0000000..2fde664 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/screening/IndicatorStatusResponse.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/dto/screening/ShipInfoResponse.java b/src/main/java/com/snp/batch/global/dto/screening/ShipInfoResponse.java new file mode 100644 index 0000000..d122d2a --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/screening/ShipInfoResponse.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/model/screening/CompanyDetailInfo.java b/src/main/java/com/snp/batch/global/model/screening/CompanyDetailInfo.java new file mode 100644 index 0000000..c1d7923 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/screening/CompanyDetailInfo.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/model/screening/ShipInfoMaster.java b/src/main/java/com/snp/batch/global/model/screening/ShipInfoMaster.java new file mode 100644 index 0000000..942c4d0 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/screening/ShipInfoMaster.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/repository/screening/CompanyDetailInfoRepository.java b/src/main/java/com/snp/batch/global/repository/screening/CompanyDetailInfoRepository.java new file mode 100644 index 0000000..5d62004 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/screening/CompanyDetailInfoRepository.java @@ -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 { + Optional findByCompanyCode(String companyCode); +} diff --git a/src/main/java/com/snp/batch/global/repository/screening/ShipInfoMasterRepository.java b/src/main/java/com/snp/batch/global/repository/screening/ShipInfoMasterRepository.java new file mode 100644 index 0000000..12cb0f0 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/screening/ShipInfoMasterRepository.java @@ -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 { + Optional findByImoNo(String imoNo); +} diff --git a/src/main/java/com/snp/batch/service/ScreeningGuideService.java b/src/main/java/com/snp/batch/service/ScreeningGuideService.java index 118d796..6d2a378 100644 --- a/src/main/java/com/snp/batch/service/ScreeningGuideService.java +++ b/src/main/java/com/snp/batch/service/ScreeningGuideService.java @@ -1,11 +1,14 @@ package com.snp.batch.service; 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.ComplianceIndicatorResponse; +import com.snp.batch.global.dto.screening.IndicatorStatusResponse; 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.dto.screening.ShipInfoResponse; import com.snp.batch.global.model.screening.ChangeTypeLang; import com.snp.batch.global.model.screening.CompanyComplianceHistory; 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.repository.screening.ChangeTypeLangRepository; 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.ComplianceIndicatorRepository; 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.RiskIndicatorRepository; 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -57,6 +66,9 @@ public class ScreeningGuideService { private final ShipRiskDetailHistoryRepository shipRiskDetailHistoryRepo; private final ShipComplianceHistoryRepository shipComplianceHistoryRepo; private final CompanyComplianceHistoryRepository companyComplianceHistoryRepo; + private final ShipInfoMasterRepository shipInfoMasterRepo; + private final CompanyDetailInfoRepository companyDetailInfoRepo; + private final JdbcTemplate jdbcTemplate; /** * 카테고리별 Risk 지표 목록 조회 @@ -253,6 +265,149 @@ public class ScreeningGuideService { .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 getShipRiskStatus(String imoNo, String lang) { + Map fieldNameMap = getRiskFieldNameMap(lang); + Map sortOrderMap = getRiskSortOrderMap(); + Map categoryMap = getRiskCategoryMap(lang); + + try { + Map row = jdbcTemplate.queryForMap( + "SELECT * FROM std_snp_svc.tb_ship_risk_detail_info WHERE imo_no = ?", imoNo); + + List result = new ArrayList<>(); + for (Map.Entry 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 getShipComplianceStatus(String imoNo, String lang) { + Map fieldNameMap = getComplianceFieldNameMap(lang, "SHIP"); + Map sortOrderMap = getComplianceSortOrderMap("SHIP"); + Map categoryMap = getComplianceCategoryMap("SHIP"); + + try { + Map row = jdbcTemplate.queryForMap( + "SELECT * FROM std_snp_svc.tb_ship_compliance_info WHERE imo_no = ?", imoNo); + + List result = new ArrayList<>(); + for (Map.Entry 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 getCompanyComplianceStatus(String companyCode, String lang) { + Map fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY"); + Map sortOrderMap = getComplianceSortOrderMap("COMPANY"); + Map categoryMap = getComplianceCategoryMap("COMPANY"); + + try { + Map row = jdbcTemplate.queryForMap( + "SELECT * FROM std_snp_svc.tb_company_compliance_info WHERE company_cd = ?", companyCode); + + List result = new ArrayList<>(); + for (Map.Entry 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 지표 컬럼명 → 필드명 매핑 조회 */ @@ -267,6 +422,17 @@ public class ScreeningGuideService { (a, b) -> a)); } + private Map getRiskCategoryMap(String lang) { + Map 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 getRiskSortOrderMap() { return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream() .filter(ri -> ri.getColumnName() != null) @@ -293,6 +459,18 @@ public class ScreeningGuideService { (a, b) -> a)); } + private Map getComplianceCategoryMap(String type) { + List 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 getComplianceSortOrderMap(String type) { List indicators = (type != null && !type.isBlank()) ? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)