From 7eb2611c02fe709e44d9fad30b4db37d79985e24 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Mon, 30 Mar 2026 14:55:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Risk&Compliance=20=EA=B0=92=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C=EB=B0=9C=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선박 위험지표/선박 제재/회사 제재 변경 이력 조회 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) --- docs/ddl/indicator_column_name_mapping.sql | 193 +++++++++ frontend/src/App.tsx | 2 + frontend/src/api/screeningGuideApi.ts | 20 + frontend/src/components/Navbar.tsx | 1 + .../src/components/screening/HistoryTab.tsx | 401 ++++++++++++++++++ frontend/src/pages/RiskComplianceHistory.tsx | 51 +++ .../controller/ScreeningGuideController.java | 31 ++ .../global/controller/WebViewController.java | 4 +- .../dto/screening/ChangeHistoryResponse.java | 24 ++ .../screening/CompanyComplianceHistory.java | 45 ++ .../model/screening/ComplianceIndicator.java | 3 + .../global/model/screening/RiskIndicator.java | 3 + .../screening/ShipComplianceHistory.java | 45 ++ .../screening/ShipRiskDetailHistory.java | 45 ++ .../CompanyComplianceHistoryRepository.java | 12 + .../ShipComplianceHistoryRepository.java | 12 + .../ShipRiskDetailHistoryRepository.java | 113 +++++ .../batch/service/ScreeningGuideService.java | 143 +++++++ 18 files changed, 1146 insertions(+), 2 deletions(-) create mode 100644 docs/ddl/indicator_column_name_mapping.sql create mode 100644 frontend/src/components/screening/HistoryTab.tsx create mode 100644 frontend/src/pages/RiskComplianceHistory.tsx create mode 100644 src/main/java/com/snp/batch/global/dto/screening/ChangeHistoryResponse.java create mode 100644 src/main/java/com/snp/batch/global/model/screening/CompanyComplianceHistory.java create mode 100644 src/main/java/com/snp/batch/global/model/screening/ShipComplianceHistory.java create mode 100644 src/main/java/com/snp/batch/global/model/screening/ShipRiskDetailHistory.java create mode 100644 src/main/java/com/snp/batch/global/repository/screening/CompanyComplianceHistoryRepository.java create mode 100644 src/main/java/com/snp/batch/global/repository/screening/ShipComplianceHistoryRepository.java create mode 100644 src/main/java/com/snp/batch/global/repository/screening/ShipRiskDetailHistoryRepository.java diff --git a/docs/ddl/indicator_column_name_mapping.sql b/docs/ddl/indicator_column_name_mapping.sql new file mode 100644 index 0000000..c6ebc4f --- /dev/null +++ b/docs/ddl/indicator_column_name_mapping.sql @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d98087e..7efdcfd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/api/screeningGuideApi.ts b/frontend/src/api/screeningGuideApi.ts index e104286..3a2847a 100644 --- a/frontend/src/api/screeningGuideApi.ts +++ b/frontend/src/api/screeningGuideApi.ts @@ -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(url: string): Promise { @@ -68,4 +82,10 @@ export const screeningGuideApi = { fetchJson>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`), getMethodologyHistory: (lang = 'KO') => fetchJson>(`${BASE}/methodology-history?lang=${lang}`), + getShipRiskHistory: (imoNo: string, lang = 'KO') => + fetchJson>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`), + getShipComplianceHistory: (imoNo: string, lang = 'KO') => + fetchJson>(`${BASE}/history/ship-compliance?imoNo=${imoNo}&lang=${lang}`), + getCompanyComplianceHistory: (companyCode: string, lang = 'KO') => + fetchJson>(`${BASE}/history/company-compliance?companyCode=${companyCode}&lang=${lang}`), }; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 9707045..142689f 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -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() { diff --git a/frontend/src/components/screening/HistoryTab.tsx b/frontend/src/components/screening/HistoryTab.tsx new file mode 100644 index 0000000..254af17 --- /dev/null +++ b/frontend/src/components/screening/HistoryTab.tsx @@ -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 = { + '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 = { + '0': '#22c55e', + '1': '#eab308', + '2': '#ef4444', +}; + +const STATUS_LABELS: Record = { + '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 {value}; + return ( + + {status.label} + + ); +} + +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 ( +
+ + {label} +
+ ); +} + +export default function HistoryTab({ lang }: HistoryTabProps) { + const [historyType, setHistoryType] = useState('ship-risk'); + const [searchValue, setSearchValue] = useState(''); + const [cache, setCache] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [searched, setSearched] = useState(false); + const [expandedDates, setExpandedDates] = useState>(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(); + 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 ( +
+ {/* 이력 유형 선택 */} +
+ {HISTORY_TYPES.map((t) => ( + + ))} +
+ + {/* 검색 */} +
+
+ + + + + + 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 && ( + + )} +
+ +
+ + {/* 에러 */} + {error && ( +
+ 조회 실패: {error} +
+ )} + + {/* 결과: 날짜별 토글 */} + {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} +
+ )} +
+ + + + + + + +
+
+ )} +
+ ); + })} +
+ ) : ( +
+
+
📭
+
조회 결과가 없습니다.
+
+
+ )} + + )} + + {/* 초기 상태 */} + {!searched && ( +
+
+
🔍
+
{currentType.searchLabel}을(를) 입력하고 조회하세요.
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/RiskComplianceHistory.tsx b/frontend/src/pages/RiskComplianceHistory.tsx new file mode 100644 index 0000000..04c900c --- /dev/null +++ b/frontend/src/pages/RiskComplianceHistory.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import HistoryTab from '../components/screening/HistoryTab'; + +export default function RiskComplianceHistory() { + const [lang, setLang] = useState('KO'); + + return ( +
+ {/* 헤더 */} +
+
+ S&P Global · Maritime Intelligence Risk Suite (MIRS) +
+

+ Risk & Compliance Change History +

+

+ 위험 지표 및 컴플라이언스 값 변경 이력 +

+
+ + {/* 언어 토글 */} +
+
+ + +
+
+ + +
+ ); +} 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 edc2784..8b33824 100644 --- a/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java +++ b/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java @@ -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>> 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>> 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>> 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))); + } } diff --git a/src/main/java/com/snp/batch/global/controller/WebViewController.java b/src/main/java/com/snp/batch/global/controller/WebViewController.java index 57a9068..cfb5928 100644 --- a/src/main/java/com/snp/batch/global/controller/WebViewController.java +++ b/src/main/java/com/snp/batch/global/controller/WebViewController.java @@ -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"; } diff --git a/src/main/java/com/snp/batch/global/dto/screening/ChangeHistoryResponse.java b/src/main/java/com/snp/batch/global/dto/screening/ChangeHistoryResponse.java new file mode 100644 index 0000000..eb3e3fd --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/screening/ChangeHistoryResponse.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/model/screening/CompanyComplianceHistory.java b/src/main/java/com/snp/batch/global/model/screening/CompanyComplianceHistory.java new file mode 100644 index 0000000..2d56843 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/screening/CompanyComplianceHistory.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/model/screening/ComplianceIndicator.java b/src/main/java/com/snp/batch/global/model/screening/ComplianceIndicator.java index a53fff5..6211b3b 100644 --- a/src/main/java/com/snp/batch/global/model/screening/ComplianceIndicator.java +++ b/src/main/java/com/snp/batch/global/model/screening/ComplianceIndicator.java @@ -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; } diff --git a/src/main/java/com/snp/batch/global/model/screening/RiskIndicator.java b/src/main/java/com/snp/batch/global/model/screening/RiskIndicator.java index 58fdb0e..374ab01 100644 --- a/src/main/java/com/snp/batch/global/model/screening/RiskIndicator.java +++ b/src/main/java/com/snp/batch/global/model/screening/RiskIndicator.java @@ -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; } diff --git a/src/main/java/com/snp/batch/global/model/screening/ShipComplianceHistory.java b/src/main/java/com/snp/batch/global/model/screening/ShipComplianceHistory.java new file mode 100644 index 0000000..0d0f13c --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/screening/ShipComplianceHistory.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/model/screening/ShipRiskDetailHistory.java b/src/main/java/com/snp/batch/global/model/screening/ShipRiskDetailHistory.java new file mode 100644 index 0000000..03f0fd0 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/screening/ShipRiskDetailHistory.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/repository/screening/CompanyComplianceHistoryRepository.java b/src/main/java/com/snp/batch/global/repository/screening/CompanyComplianceHistoryRepository.java new file mode 100644 index 0000000..522062f --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/screening/CompanyComplianceHistoryRepository.java @@ -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 { + List findByCompanyCodeOrderByLastModifiedDateDesc(String companyCode); +} diff --git a/src/main/java/com/snp/batch/global/repository/screening/ShipComplianceHistoryRepository.java b/src/main/java/com/snp/batch/global/repository/screening/ShipComplianceHistoryRepository.java new file mode 100644 index 0000000..d64adbf --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/screening/ShipComplianceHistoryRepository.java @@ -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 { + List findByImoNoOrderByLastModifiedDateDesc(String imoNo); +} diff --git a/src/main/java/com/snp/batch/global/repository/screening/ShipRiskDetailHistoryRepository.java b/src/main/java/com/snp/batch/global/repository/screening/ShipRiskDetailHistoryRepository.java new file mode 100644 index 0000000..40d855f --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/screening/ShipRiskDetailHistoryRepository.java @@ -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 { + + @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 findRiskHistoryWithNarrative(@Param("imoNo") 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 9984a1e..118d796 100644 --- a/src/main/java/com/snp/batch/service/ScreeningGuideService.java +++ b/src/main/java/com/snp/batch/service/ScreeningGuideService.java @@ -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 getShipRiskDetailHistory(String imoNo, String lang) { + Map fieldNameMap = getRiskFieldNameMap(lang); + Map sortOrderMap = getRiskSortOrderMap(); + List 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 getShipComplianceHistory(String imoNo, String lang) { + Map fieldNameMap = getComplianceFieldNameMap(lang, "SHIP"); + Map 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 getCompanyComplianceHistory(String companyCode, String lang) { + Map fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY"); + Map 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 getRiskFieldNameMap(String lang) { + Map 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 getRiskSortOrderMap() { + return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream() + .filter(ri -> ri.getColumnName() != null) + .collect(Collectors.toMap( + RiskIndicator::getColumnName, + RiskIndicator::getSortOrder, + (a, b) -> a)); + } + + /** + * Compliance 지표 컬럼명 → 필드명 매핑 조회 + */ + private Map getComplianceFieldNameMap(String lang, String type) { + Map langMap = complianceIndicatorLangRepo.findByLangCode(lang).stream() + .collect(Collectors.toMap(ComplianceIndicatorLang::getIndicatorId, ComplianceIndicatorLang::getFieldName)); + List 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 getComplianceSortOrderMap(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::getSortOrder, + (a, b) -> a)); + } } From ba19ac203d4b55d305238d455ef04cd854dca11f Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Mon, 30 Mar 2026 17:26:16 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=84=A0=EB=B0=95/=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EA=B8=B0=EB=B3=B8=EC=A0=95=EB=B3=B4=20=EB=B0=8F=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20Risk&Compliance=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선박 기본정보 (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) --- frontend/src/api/screeningGuideApi.ts | 47 ++ .../src/components/screening/HistoryTab.tsx | 711 ++++++++++++++---- .../controller/ScreeningGuideController.java | 49 ++ .../dto/screening/CompanyInfoResponse.java | 18 + .../screening/IndicatorStatusResponse.java | 16 + .../dto/screening/ShipInfoResponse.java | 21 + .../model/screening/CompanyDetailInfo.java | 37 + .../model/screening/ShipInfoMaster.java | 46 ++ .../CompanyDetailInfoRepository.java | 12 + .../screening/ShipInfoMasterRepository.java | 12 + .../batch/service/ScreeningGuideService.java | 178 +++++ 11 files changed, 992 insertions(+), 155 deletions(-) create mode 100644 src/main/java/com/snp/batch/global/dto/screening/CompanyInfoResponse.java create mode 100644 src/main/java/com/snp/batch/global/dto/screening/IndicatorStatusResponse.java create mode 100644 src/main/java/com/snp/batch/global/dto/screening/ShipInfoResponse.java create mode 100644 src/main/java/com/snp/batch/global/model/screening/CompanyDetailInfo.java create mode 100644 src/main/java/com/snp/batch/global/model/screening/ShipInfoMaster.java create mode 100644 src/main/java/com/snp/batch/global/repository/screening/CompanyDetailInfoRepository.java create mode 100644 src/main/java/com/snp/batch/global/repository/screening/ShipInfoMasterRepository.java 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) From b47b5050fdc2ccce67668b3f97801f391c1995e0 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Tue, 31 Mar 2026 09:23:21 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=84=A0=EB=B0=95/=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EA=B8=B0=EB=B3=B8=EC=A0=95=EB=B3=B4=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B5=AD=EA=B8=B0=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=ED=91=9C=EC=8B=9C=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B안 2분할 레이아웃 적용 (좌: 핵심 식별정보, 우: 스펙/상세) - 국가코드 → ISO2 변환 (tb_ship_country_cd JOIN) → 국기 이모지 표시 - 회사 모회사 셀프조인 (prnt_company_cd → 회사명, 없으면 UNKNOWN) - Current Compliance 탭 분리 (Sanctions/Port Calls/STS/Suspicious) - Compliance 예외 처리 (Parent Company null → No Parent, Overall 헤더 이동) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/screeningGuideApi.ts | 16 +- .../src/components/screening/HistoryTab.tsx | 160 +++++++++++++++--- .../dto/screening/CompanyInfoResponse.java | 14 +- .../dto/screening/ShipInfoResponse.java | 2 + .../model/screening/CompanyDetailInfo.java | 24 +++ .../model/screening/ShipCountryCode.java | 25 +++ .../model/screening/ShipInfoMaster.java | 3 + .../screening/ShipCountryCodeRepository.java | 12 ++ .../batch/service/ScreeningGuideService.java | 49 ++++-- 9 files changed, 262 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/snp/batch/global/model/screening/ShipCountryCode.java create mode 100644 src/main/java/com/snp/batch/global/repository/screening/ShipCountryCodeRepository.java diff --git a/frontend/src/api/screeningGuideApi.ts b/frontend/src/api/screeningGuideApi.ts index 0609fa0..4be75d1 100644 --- a/frontend/src/api/screeningGuideApi.ts +++ b/frontend/src/api/screeningGuideApi.ts @@ -72,6 +72,8 @@ export interface ShipInfoResponse { imoNo: string; shipName: string; shipStatus: string; + nationalityCode: string; + nationalityIsoCode: string | null; nationality: string; shipType: string; dwt: string; @@ -87,11 +89,19 @@ export interface CompanyInfoResponse { companyCode: string; fullName: string; abbreviation: string; - country: string; - city: string; status: string; + parentCompanyCode: string | null; + parentCompanyName: string | null; registrationCountry: string; - address: string; + registrationCountryCode: string; + registrationCountryIsoCode: string | null; + controlCountry: string | null; + controlCountryCode: string | null; + controlCountryIsoCode: string | null; + foundedDate: string | null; + email: string | null; + phone: string | null; + website: string | null; } // 지표 현재 상태 diff --git a/frontend/src/components/screening/HistoryTab.tsx b/frontend/src/components/screening/HistoryTab.tsx index 68e2034..0507406 100644 --- a/frontend/src/components/screening/HistoryTab.tsx +++ b/frontend/src/components/screening/HistoryTab.tsx @@ -73,13 +73,11 @@ function StatusBadge({ value }: { value: string | null }) { ); } -function InfoField({ label, value }: { label: string; value: string | null | undefined }) { - return ( -
-
{label}
-
{value || '-'}
-
- ); +function countryFlag(code: string | null | undefined): string { + if (!code || code.length < 2) return ''; + const cc = code.slice(0, 2).toUpperCase(); + const codePoints = [...cc].map((c) => 0x1F1E6 + c.charCodeAt(0) - 65); + return String.fromCodePoint(...codePoints); } function RiskValueCell({ value, narrative }: { value: string | null; narrative?: string }) { @@ -547,29 +545,137 @@ export default function HistoryTab({ lang }: HistoryTabProps) { {expandedSections.has('info') && (
{shipInfo && ( -
- - - - - - - - - - +
+ {/* 좌측: 핵심 식별 정보 */} +
+
+
{shipInfo.shipName || '-'}
+
+
+
+ IMO + {shipInfo.imoNo} +
+
+ MMSI + {shipInfo.mmsiNo || '-'} +
+
+ Status + {shipInfo.shipStatus || '-'} +
+
+
+ + {/* 구분선 */} +
+ + {/* 우측: 스펙 정보 */} +
+
+ 국적 + + {countryFlag(shipInfo.nationalityIsoCode)} {shipInfo.nationality || '-'} + +
+
+ 선종 + {shipInfo.shipType || '-'} +
+
+ DWT + {shipInfo.dwt || '-'} +
+
+ GT + {shipInfo.gt || '-'} +
+
+ 건조연도 + {shipInfo.buildYear || '-'} +
+
)} {companyInfo && ( -
- - - - - - - - +
+ {/* 좌측: 핵심 식별 정보 */} +
+
+
{companyInfo.fullName || '-'}
+ {companyInfo.abbreviation && ( +
{companyInfo.abbreviation}
+ )} +
+
+
+ Code + {companyInfo.companyCode} +
+
+ Status + {companyInfo.status || '-'} +
+ {companyInfo.parentCompanyName && ( +
+ 모회사 + {companyInfo.parentCompanyName} +
+ )} +
+
+ + {/* 구분선 */} +
+ + {/* 우측: 상세 정보 */} +
+
+ 등록국가 + + {countryFlag(companyInfo.registrationCountryIsoCode)} {companyInfo.registrationCountry || '-'} + +
+ {companyInfo.controlCountry && ( +
+ 관리국가 + + {countryFlag(companyInfo.controlCountryIsoCode)} {companyInfo.controlCountry} + +
+ )} + {companyInfo.foundedDate && ( +
+ 설립일 + {companyInfo.foundedDate} +
+ )} + {companyInfo.email && ( +
+ 이메일 + {companyInfo.email} +
+ )} + {companyInfo.phone && ( +
+ 전화 + {companyInfo.phone} +
+ )} + {companyInfo.website && ( +
+ 웹사이트 + + {companyInfo.website} + +
+ )} +
)}
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 index cfe76fa..1033d9a 100644 --- a/src/main/java/com/snp/batch/global/dto/screening/CompanyInfoResponse.java +++ b/src/main/java/com/snp/batch/global/dto/screening/CompanyInfoResponse.java @@ -10,9 +10,17 @@ public class CompanyInfoResponse { private String companyCode; private String fullName; private String abbreviation; - private String country; - private String city; private String status; + private String parentCompanyCode; + private String parentCompanyName; private String registrationCountry; - private String address; + private String registrationCountryCode; + private String registrationCountryIsoCode; + private String controlCountry; + private String controlCountryCode; + private String controlCountryIsoCode; + private String foundedDate; + private String email; + private String phone; + private String website; } 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 index d122d2a..b3c666d 100644 --- a/src/main/java/com/snp/batch/global/dto/screening/ShipInfoResponse.java +++ b/src/main/java/com/snp/batch/global/dto/screening/ShipInfoResponse.java @@ -10,6 +10,8 @@ public class ShipInfoResponse { private String imoNo; private String shipName; private String shipStatus; + private String nationalityCode; + private String nationalityIsoCode; private String nationality; private String shipType; private String dwt; 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 index c1d7923..2d52875 100644 --- a/src/main/java/com/snp/batch/global/model/screening/CompanyDetailInfo.java +++ b/src/main/java/com/snp/batch/global/model/screening/CompanyDetailInfo.java @@ -34,4 +34,28 @@ public class CompanyDetailInfo { @Column(name = "oa_addr", length = 512) private String address; + + @Column(name = "prnt_company_cd", length = 14) + private String parentCompanyCode; + + @Column(name = "country_reg_cd", length = 6) + private String registrationCountryCode; + + @Column(name = "country_ctrl", length = 40) + private String controlCountry; + + @Column(name = "country_ctrl_cd", length = 6) + private String controlCountryCode; + + @Column(name = "company_fndn_ymd", length = 8) + private String foundedDate; + + @Column(name = "eml_addr", length = 320) + private String email; + + @Column(name = "tel", length = 60) + private String phone; + + @Column(name = "wbst_url", length = 120) + private String website; } diff --git a/src/main/java/com/snp/batch/global/model/screening/ShipCountryCode.java b/src/main/java/com/snp/batch/global/model/screening/ShipCountryCode.java new file mode 100644 index 0000000..dcd7df9 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/screening/ShipCountryCode.java @@ -0,0 +1,25 @@ +package com.snp.batch.global.model.screening; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "tb_ship_country_cd", schema = "std_snp_svc") +@Getter +@NoArgsConstructor +public class ShipCountryCode { + + @Id + @Column(name = "ship_country_cd", length = 3) + private String shipCountryCode; + + @Column(name = "cd_nm", length = 100) + private String codeName; + + @Column(name = "iso_two_cd", length = 2) + private String isoTwoCode; + + @Column(name = "iso_thr_cd", length = 3) + private String isoThreeCode; +} 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 index 942c4d0..4fb9950 100644 --- a/src/main/java/com/snp/batch/global/model/screening/ShipInfoMaster.java +++ b/src/main/java/com/snp/batch/global/model/screening/ShipInfoMaster.java @@ -20,6 +20,9 @@ public class ShipInfoMaster { @Column(name = "ship_status", length = 50) private String shipStatus; + @Column(name = "ntnlty_cd", length = 50) + private String nationalityCode; + @Column(name = "ship_ntnlty", length = 57) private String nationality; diff --git a/src/main/java/com/snp/batch/global/repository/screening/ShipCountryCodeRepository.java b/src/main/java/com/snp/batch/global/repository/screening/ShipCountryCodeRepository.java new file mode 100644 index 0000000..7d48f7e --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/screening/ShipCountryCodeRepository.java @@ -0,0 +1,12 @@ +package com.snp.batch.global.repository.screening; + +import com.snp.batch.global.model.screening.ShipCountryCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ShipCountryCodeRepository extends JpaRepository { + Optional findByShipCountryCode(String shipCountryCode); +} diff --git a/src/main/java/com/snp/batch/service/ScreeningGuideService.java b/src/main/java/com/snp/batch/service/ScreeningGuideService.java index 6d2a378..1b8f296 100644 --- a/src/main/java/com/snp/batch/service/ScreeningGuideService.java +++ b/src/main/java/com/snp/batch/service/ScreeningGuideService.java @@ -10,6 +10,8 @@ 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.CompanyDetailInfo; +import com.snp.batch.global.model.screening.ShipCountryCode; import com.snp.batch.global.model.screening.CompanyComplianceHistory; import com.snp.batch.global.model.screening.ComplianceIndicator; import com.snp.batch.global.model.screening.ComplianceIndicatorLang; @@ -30,6 +32,7 @@ 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.ShipCountryCodeRepository; import com.snp.batch.global.repository.screening.ShipInfoMasterRepository; import com.snp.batch.global.repository.screening.ShipRiskDetailHistoryRepository; import lombok.RequiredArgsConstructor; @@ -68,6 +71,7 @@ public class ScreeningGuideService { private final CompanyComplianceHistoryRepository companyComplianceHistoryRepo; private final ShipInfoMasterRepository shipInfoMasterRepo; private final CompanyDetailInfoRepository companyDetailInfoRepo; + private final ShipCountryCodeRepository shipCountryCodeRepo; private final JdbcTemplate jdbcTemplate; /** @@ -275,6 +279,8 @@ public class ScreeningGuideService { .imoNo(s.getImoNo()) .shipName(s.getShipName()) .shipStatus(s.getShipStatus()) + .nationalityCode(s.getNationalityCode()) + .nationalityIsoCode(resolveIsoTwoCode(s.getNationalityCode())) .nationality(s.getNationality()) .shipType(s.getShipType()) .dwt(s.getDwt()) @@ -293,16 +299,32 @@ public class ScreeningGuideService { @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()) + .map(c -> { + String parentCompanyName = null; + if (c.getParentCompanyCode() != null && !c.getParentCompanyCode().isBlank()) { + parentCompanyName = companyDetailInfoRepo.findByCompanyCode(c.getParentCompanyCode()) + .map(CompanyDetailInfo::getFullName) + .orElse("UNKNOWN"); + } + return CompanyInfoResponse.builder() + .companyCode(c.getCompanyCode()) + .fullName(c.getFullName()) + .abbreviation(c.getAbbreviation()) + .status(c.getStatus()) + .parentCompanyCode(c.getParentCompanyCode()) + .parentCompanyName(parentCompanyName) + .registrationCountry(c.getRegistrationCountry()) + .registrationCountryCode(c.getRegistrationCountryCode()) + .registrationCountryIsoCode(resolveIsoTwoCode(c.getRegistrationCountryCode())) + .controlCountry(c.getControlCountry()) + .controlCountryCode(c.getControlCountryCode()) + .controlCountryIsoCode(resolveIsoTwoCode(c.getControlCountryCode())) + .foundedDate(c.getFoundedDate()) + .email(c.getEmail()) + .phone(c.getPhone()) + .website(c.getWebsite()) + .build(); + }) .orElse(null); } @@ -408,6 +430,13 @@ public class ScreeningGuideService { } } + private String resolveIsoTwoCode(String shipCountryCode) { + if (shipCountryCode == null || shipCountryCode.isBlank()) return null; + return shipCountryCodeRepo.findByShipCountryCode(shipCountryCode) + .map(ShipCountryCode::getIsoTwoCode) + .orElse(null); + } + /** * Risk 지표 컬럼명 → 필드명 매핑 조회 */ From 30453c1cf51b6d9fcbc3a4b8b7a504117a7c992f Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Tue, 31 Mar 2026 09:25:14 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 143ff32..9046f36 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,15 @@ ## [Unreleased] ### 추가 +- Risk&Compliance 값 변경 이력 확인 페이지 개발 (#111) + - 선박 위험지표/선박 제재/회사 제재 변경 이력 조회 + - 선박/회사 기본정보 및 현재 Risk&Compliance 상태 조회 + - Risk narrative(이전값/이후값) 표시 (LATERAL JOIN) + - indicator column_name 매핑으로 다국어 필드명 지원 + - 다국어 캐시 (KO/EN 동시 조회, 언어 토글 즉시 전환) + - 독립 페이지 분리 (/risk-compliance-history) + - 국가코드 ISO2 변환 → 국기 이모지 표시 + - Compliance 탭 분리 (Sanctions/Port Calls/STS/Suspicious) - favicon 변경 (#105) - BY PASS API 등록 프로세스 설계 및 개발 (#63) - 화면에서 API 정보 입력 → Java 코드 자동 생성 (Controller, Service)