feat: 엑셀 업로드 템플릿 시스템 (V19)

Agent Teams 3명 병렬 작업 (dba / api / frontend) 결과물.

DB (V19__업로드템플릿.sql):
- upload_template / upload_template_column / upload_history 3 테이블
- 인덱스 5종 (category / company_code / template_id / started_at)
- SYSTEM_UPLOAD_TEMPLATE 메뉴 + READ/CREATE/UPDATE/DELETE 권한
- 모든 역할에 권한 부여 (MANAGER 는 READ 만)

Backend:
- ga-core: VO 7개 (Template/Column/History의 VO/Resp/SaveReq/SearchParam) +
  Mapper 3개 + XML 3개 (UploadTemplate/Column/History)
- ga-api: TransformEngine (12 변환 규칙: TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/
  LOOKUP/CODE_MAP/FIXED/REPLACE/SUBSTRING/CONCAT/DEFAULT) +
  LookupCache (O(1) HashMap 캐시) + UploadService (processUpload/preview) +
  LookupMapper / DynamicInsertMapper (whitelist 기반 동적 SQL)
- ga-api Controller 2개:
  · UploadTemplateController: /api/upload-templates CRUD + columns + history
  · UploadController: POST /api/upload/{templateCode}, /preview

Frontend:
- api/uploadTemplate.ts (10 endpoints)
- pages/system/UploadTemplateManager.tsx — 좌(템플릿 목록) 우(컬럼 매핑 편집기)
  + ModalForm 등록/수정 + 미리보기 Modal + 이력 Drawer
- components/common/TemplateUpload.tsx — 범용 컴포넌트 (Select 템플릿 + Dragger
  + 진행률 + 결과 Modal + 에러 파일 다운로드)
- App.tsx 라우트 추가, MenuIcon.tsx 매핑 추가

검증:
- ./gradlew :ga-api:bootJar 통과
- Flyway V19 운영 DB 적용 성공 (200ms)
- ga-api 부팅 7.8s, /api/upload-templates 200 응답
- 메뉴 트리에 SYSTEM_UPLOAD_TEMPLATE 노출 확인
- 프론트 tsc -b / vite build 통과

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-10 23:10:32 +09:00
parent 4e008527b9
commit a314a95ecf
29 changed files with 2991 additions and 0 deletions
@@ -37,6 +37,7 @@ const ICON_MAP: Record<string, ReactNode> = {
SYSTEM_LOG: <IconClipboardList size={16} />,
SYSTEM_FILE: <IconFolder size={16} />,
SYSTEM_CONFIG: <IconSettings size={16} />,
SYSTEM_UPLOAD_TEMPLATE: <IconFileSpreadsheet size={16} />,
COMPANY: <IconBuildingBank size={16} />,
PRODUCT: <IconFileSpreadsheet size={16} />,
RECON: <IconReportMoney size={16} />,
@@ -0,0 +1,213 @@
import { useState } from 'react';
import { Button, message, Modal, Progress, Select, Space, Table, Typography, Upload } from 'antd';
import type { RcFile, UploadProps } from 'antd/es/upload';
import { IconDownload, IconUpload } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { uploadTemplateApi, type UploadResultResp } from '@/api/uploadTemplate';
const { Dragger } = Upload;
const { Text } = Typography;
interface Props {
/** 이 카테고리의 템플릿만 Select 에 표시 */
category?: string;
/** 업로드 완료 후 콜백 */
onComplete?: (result: UploadResultResp) => void;
}
export default function TemplateUpload({ category, onComplete }: Props) {
const [templateCode, setTemplateCode] = useState<string | undefined>();
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [result, setResult] = useState<UploadResultResp | null>(null);
const [resultVisible, setResultVisible] = useState(false);
// 카테고리에 맞는 템플릿 목록
const { data: templates = [], isLoading: templatesLoading } = useQuery({
queryKey: ['uploadTemplate', 'list', 'select', category],
queryFn: async () => {
const res = await uploadTemplateApi.list({
category,
isActive: 'Y',
pageSize: 200,
pageNum: 1,
});
return res.list;
},
});
const templateOptions = templates.map((t) => ({
value: t.templateCode,
label: `[${t.category}] ${t.templateName}`,
}));
const handleUpload = async (file: RcFile): Promise<false> => {
if (!templateCode) {
message.warning('템플릿을 먼저 선택하세요');
return false;
}
setUploading(true);
setProgress(10);
// 진행률 시뮬레이션 (실제 서버 스트림이 없으므로 tick 방식)
const ticker = setInterval(() => {
setProgress((p) => {
if (p >= 85) { clearInterval(ticker); return p; }
return p + 5;
});
}, 400);
try {
const res = await uploadTemplateApi.upload(templateCode, file);
clearInterval(ticker);
setProgress(100);
setResult(res);
setResultVisible(true);
onComplete?.(res);
} catch {
clearInterval(ticker);
setProgress(0);
} finally {
setUploading(false);
}
return false; // antd Upload 기본 동작 방지
};
const draggerProps: UploadProps = {
name: 'file',
accept: '.xlsx,.xls,.csv',
multiple: false,
showUploadList: false,
beforeUpload: handleUpload,
disabled: uploading || !templateCode,
};
const handleDownloadError = () => {
if (!result?.errorFileId) return;
// 오류 파일 다운로드 — 백엔드 파일 ID 기반 URL
window.open(`/api/files/${result.errorFileId}/download`, '_blank');
};
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{/* 템플릿 선택 */}
<div>
<Text style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>
릿
</Text>
<Select
placeholder="템플릿을 선택하세요"
loading={templatesLoading}
options={templateOptions}
value={templateCode}
onChange={setTemplateCode}
style={{ width: '100%' }}
showSearch
optionFilterProp="label"
/>
</div>
{/* 파일 드래그 업로드 */}
<Dragger {...draggerProps} style={{ padding: '8px 0' }}>
<p style={{ margin: 0 }}>
<IconUpload size={32} color={!templateCode ? '#bbb' : '#185FA5'} />
</p>
<p style={{ margin: '8px 0 4px', fontSize: 14, fontWeight: 500,
color: !templateCode ? '#bbb' : undefined }}>
</p>
<p style={{ margin: 0, fontSize: 12, color: '#999' }}>
Excel (.xlsx, .xls) / CSV
{!templateCode && ' · 먼저 템플릿을 선택하세요'}
</p>
</Dragger>
{/* 진행률 */}
{uploading && (
<Progress
percent={progress}
status={progress === 100 ? 'success' : 'active'}
strokeColor={{ '0%': '#185FA5', '100%': '#0F6E56' }}
/>
)}
{/* 결과 모달 */}
<Modal
open={resultVisible}
title="업로드 결과"
onCancel={() => { setResultVisible(false); setProgress(0); }}
footer={
<Space>
{result?.errorFileId && (
<Button
icon={<IconDownload size={14} />}
onClick={handleDownloadError}
>
</Button>
)}
<Button type="primary" onClick={() => { setResultVisible(false); setProgress(0); }}>
</Button>
</Space>
}
>
{result && (
<Space direction="vertical" style={{ width: '100%' }}>
<Table
size="small"
pagination={false}
dataSource={[
{ key: 'total', label: '전체', value: result.totalCount },
{ key: 'success', label: '성공', value: result.successCount },
{ key: 'error', label: '오류', value: result.errorCount },
{ key: 'skip', label: '건너뜀', value: result.skipCount },
]}
columns={[
{ title: '구분', dataIndex: 'label', width: 100 },
{
title: '건수', dataIndex: 'value', align: 'right',
render: (v, r) => {
if (r.key === 'success') return <Text style={{ color: '#0F6E56', fontWeight: 600 }}>{v}</Text>;
if (r.key === 'error' && v > 0) return <Text type="danger" style={{ fontWeight: 600 }}>{v}</Text>;
return <Text>{v}</Text>;
},
},
]}
/>
{result.durationSec !== undefined && (
<Text type="secondary" style={{ fontSize: 12 }}>
: {result.durationSec}
</Text>
)}
{/* 오류 상세 (최대 10건) */}
{result.errors && result.errors.length > 0 && (
<>
<Text strong style={{ color: '#E24B4A' }}>
( {Math.min(result.errors.length, 10)})
</Text>
<Table
size="small"
pagination={false}
dataSource={result.errors.slice(0, 10)}
rowKey="rowNumber"
scroll={{ y: 200 }}
columns={[
{ title: '행', dataIndex: 'rowNumber', width: 60 },
{ title: '필드', dataIndex: 'fieldName', width: 100 },
{ title: '값', dataIndex: 'value', width: 100, ellipsis: true },
{ title: '오류', dataIndex: 'errorMessage', ellipsis: true },
]}
/>
</>
)}
</Space>
)}
</Modal>
</Space>
);
}