Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 169e0ca2ef |
@@ -1,336 +0,0 @@
|
|||||||
# GA 수수료 정산 솔루션 — 신입 개발자 가이드
|
|
||||||
|
|
||||||
> 처음 합류한 개발자가 30분 안에 로컬 환경을 띄우고, 새 화면 1개를 추가할 수 있도록 안내합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 환경 설정
|
|
||||||
|
|
||||||
### 필수 설치
|
|
||||||
| 도구 | 버전 | 비고 |
|
|
||||||
|------|------|------|
|
|
||||||
| **JDK** | 17+ | `java -version` |
|
|
||||||
| **Node.js** | 18+ | `node -v` |
|
|
||||||
| **PostgreSQL** | 14+ | 로컬 또는 운영 DB 접속 |
|
|
||||||
| **Redis** | 7+ | 공통코드 캐시 (선택) |
|
|
||||||
| **IntelliJ IDEA** | Community 이상 | Lombok 플러그인 활성화 필수 |
|
|
||||||
|
|
||||||
### IDE 설정 (IntelliJ)
|
|
||||||
1. `File > Open` → `ga-commission-system` 선택 → Gradle 자동 임포트
|
|
||||||
2. `Settings > Build > Compiler > Annotation Processors` → **Enable**
|
|
||||||
3. `Settings > Plugins` → **Lombok** 설치/활성화
|
|
||||||
4. JDK 17 지정 (`Project Structure > SDK`)
|
|
||||||
|
|
||||||
### 빠른 기동
|
|
||||||
```bash
|
|
||||||
# 1) 인프라 (PostgreSQL + Redis)
|
|
||||||
docker compose up -d postgres redis
|
|
||||||
|
|
||||||
# 2) 백엔드 (ga-api 8080, ga-batch 8081 별도 콘솔)
|
|
||||||
./gradlew :ga-api:bootRun
|
|
||||||
./gradlew :ga-batch:bootRun
|
|
||||||
|
|
||||||
# 3) 프론트엔드 (Vite dev server, 3000)
|
|
||||||
cd ga-frontend && npm install && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
로그인: `admin / admin1234!`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 프로젝트 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
ga-commission-system/
|
|
||||||
├── ga-common/ 공통 프레임워크 — 인증/AOP/엑셀/공통코드/메뉴/유틸
|
|
||||||
├── ga-core/ 도메인 코어 — VO + Mapper Interface + Mapper XML + MapStruct
|
|
||||||
├── ga-api/ REST 컨트롤러 + 서비스 (포트 8080)
|
|
||||||
├── ga-batch/ Spring Batch 정산 잡 (포트 8081)
|
|
||||||
└── ga-frontend/ React + Vite + Ant Design + AG Grid (포트 3000)
|
|
||||||
```
|
|
||||||
|
|
||||||
### ga-common 핵심 클래스
|
|
||||||
- **`ApiResponse<T>`** — 모든 API 표준 응답
|
|
||||||
- **`PageResponse<T>`** — 페이징 응답 (PageHelper 자동 변환)
|
|
||||||
- **`SearchParam`** — `pageNum/pageSize/searchKeyword/startDate/endDate/sortField/sortOrder`
|
|
||||||
- **`@RequirePermission(menu, perm)`** — AOP 권한 체크
|
|
||||||
- **`@DataChangeLog(menu, table)`** — 변경 이력 자동 기록
|
|
||||||
- **`CommonCodeService`** — Redis 캐시 기반 코드 변환 (`getCodeName`, `validateCode`)
|
|
||||||
- **`ExcelService`** — `exportLargeExcel` (100만건), `importLargeExcel` (70만건)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 새 화면 만들기 — Step-by-Step (5분 컷)
|
|
||||||
|
|
||||||
예시: **상품 그룹 관리** 화면 추가.
|
|
||||||
|
|
||||||
### Step 1 — 메뉴 등록 (DB)
|
|
||||||
```sql
|
|
||||||
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path,
|
|
||||||
component_path, menu_level, sort_order, is_visible, is_active)
|
|
||||||
VALUES ((SELECT menu_id FROM menu WHERE menu_code='SYSTEM'),
|
|
||||||
'PRODUCT_GROUP', '상품그룹', 'PAGE', '/system/product-groups',
|
|
||||||
'system/ProductGroupList', 2, 99, 'Y', 'Y');
|
|
||||||
|
|
||||||
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
|
|
||||||
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'PRODUCT_GROUP'
|
|
||||||
UNION ALL SELECT menu_id, 'CREATE', '등록', 2 FROM menu WHERE menu_code = 'PRODUCT_GROUP'
|
|
||||||
UNION ALL SELECT menu_id, 'UPDATE', '수정', 3 FROM menu WHERE menu_code = 'PRODUCT_GROUP'
|
|
||||||
UNION ALL SELECT menu_id, 'DELETE', '삭제', 4 FROM menu WHERE menu_code = 'PRODUCT_GROUP';
|
|
||||||
|
|
||||||
-- 관리자 역할에 권한 부여
|
|
||||||
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
|
|
||||||
SELECT r.role_id, m.menu_id, p.perm_code, 'Y'
|
|
||||||
FROM role r CROSS JOIN menu m JOIN menu_permission p ON p.menu_id = m.menu_id
|
|
||||||
WHERE r.role_code = 'SUPER_ADMIN' AND m.menu_code = 'PRODUCT_GROUP';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2 — VO 작성 (`ga-core`)
|
|
||||||
- `ProductGroupVO.java` — 테이블 1:1
|
|
||||||
- `ProductGroupResp.java` — 화면 응답 (조인 필드 포함)
|
|
||||||
- `ProductGroupSaveReq.java` — 입력 (`@NotBlank`, `@Size`)
|
|
||||||
- `ProductGroupSearchParam.java` — `extends SearchParam`
|
|
||||||
|
|
||||||
### Step 3 — Mapper Interface + XML
|
|
||||||
```java
|
|
||||||
@Mapper
|
|
||||||
public interface ProductGroupMapper {
|
|
||||||
List<ProductGroupResp> selectList(ProductGroupSearchParam p);
|
|
||||||
ProductGroupVO selectById(@Param("id") Long id);
|
|
||||||
int insert(ProductGroupVO vo);
|
|
||||||
int update(ProductGroupVO vo);
|
|
||||||
int deleteById(@Param("id") Long id);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`ga-core/src/main/resources/mapper/product/ProductGroupMapper.xml`:
|
|
||||||
```xml
|
|
||||||
<select id="selectList" resultType="com.ga.core.vo.product.ProductGroupResp">
|
|
||||||
SELECT pg.* FROM product_group pg
|
|
||||||
<where>
|
|
||||||
<if test="searchKeyword != null and searchKeyword != ''">
|
|
||||||
AND pg.group_name LIKE CONCAT('%', #{searchKeyword}, '%')
|
|
||||||
</if>
|
|
||||||
</where>
|
|
||||||
ORDER BY pg.sort_order
|
|
||||||
</select>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4 — Service
|
|
||||||
```java
|
|
||||||
@Service @RequiredArgsConstructor
|
|
||||||
public class ProductGroupService {
|
|
||||||
private final ProductGroupMapper mapper;
|
|
||||||
public PageResponse<ProductGroupResp> list(ProductGroupSearchParam p) {
|
|
||||||
p.startPage();
|
|
||||||
return PageResponse.of(mapper.selectList(p));
|
|
||||||
}
|
|
||||||
@Transactional
|
|
||||||
public Long create(ProductGroupSaveReq req) { /* ... */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5 — Controller (`ga-api`)
|
|
||||||
```java
|
|
||||||
@RestController @RequestMapping("/api/product-groups")
|
|
||||||
@Tag(name = "상품그룹") @RequiredArgsConstructor
|
|
||||||
public class ProductGroupController {
|
|
||||||
private final ProductGroupService service;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@RequirePermission(menu = "PRODUCT_GROUP", perm = "READ")
|
|
||||||
public ApiResponse<PageResponse<ProductGroupResp>> list(ProductGroupSearchParam p) {
|
|
||||||
return ApiResponse.ok(service.list(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@RequirePermission(menu = "PRODUCT_GROUP", perm = "CREATE")
|
|
||||||
@DataChangeLog(menu = "PRODUCT_GROUP", table = "product_group")
|
|
||||||
public ApiResponse<Long> create(@Valid @RequestBody ProductGroupSaveReq req) {
|
|
||||||
return ApiResponse.ok(service.create(req));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6 — React 페이지 (`ga-frontend`)
|
|
||||||
`src/api/productGroup.ts`:
|
|
||||||
```ts
|
|
||||||
export const productGroupApi = {
|
|
||||||
list: (p: any) => request.get('/api/product-groups', { params: p }),
|
|
||||||
create: (data: any) => request.post('/api/product-groups', data),
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
`src/pages/system/ProductGroupList.tsx`:
|
|
||||||
```tsx
|
|
||||||
export default function ProductGroupList() {
|
|
||||||
const [param, setParam] = useState({ pageNum: 1, pageSize: 20 });
|
|
||||||
const { data } = useQuery({ queryKey: ['pg', param], queryFn: () => productGroupApi.list(param) });
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SearchForm conditions={[{ type: 'text', name: 'searchKeyword', label: '그룹명' }]}
|
|
||||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })} />
|
|
||||||
<PermissionButton menu="PRODUCT_GROUP" perm="CREATE" type="primary">신규</PermissionButton>
|
|
||||||
<DataTable data={data} page={param} onPageChange={(p) => setParam({ ...param, ...p })}
|
|
||||||
columns={[{ title: '그룹명', dataIndex: 'groupName' }]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
라우터 등록 — `App.tsx`:
|
|
||||||
```tsx
|
|
||||||
<Route path="system/product-groups" element={<ProductGroupList />} />
|
|
||||||
```
|
|
||||||
|
|
||||||
`router/dynamicRoutes.tsx` 의 `componentMap` 에 키도 추가:
|
|
||||||
```ts
|
|
||||||
'system/ProductGroupList': lazy(() => import('@/pages/system/ProductGroupList')),
|
|
||||||
```
|
|
||||||
|
|
||||||
끝! 메뉴 권한이 있는 사용자에게만 좌측 메뉴가 보이고, `READ/CREATE` 버튼이 권한별로 렌더됩니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 자주 쓰는 패턴
|
|
||||||
|
|
||||||
### 공통코드 드롭다운
|
|
||||||
```tsx
|
|
||||||
<CodeSelect groupCode="AGENT_STATUS" value={status} onChange={setStatus} includeAll />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 상태 뱃지
|
|
||||||
```tsx
|
|
||||||
<CodeBadge groupCode="CONTRACT_STATUS" code={row.status} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 권한 버튼
|
|
||||||
```tsx
|
|
||||||
<PermissionButton menu="ORG_AGENT" perm="UPDATE" onClick={save}>저장</PermissionButton>
|
|
||||||
```
|
|
||||||
권한 없으면 자동으로 disabled 처리. AOP `@RequirePermission` 와 동일한 메뉴×권한 매트릭스 사용.
|
|
||||||
|
|
||||||
### 엑셀 다운로드 버튼
|
|
||||||
```tsx
|
|
||||||
<ExcelExportButton url="/api/agents/excel" filename="설계사목록" params={searchParam} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 엑셀 업로드 (템플릿 매핑 기반)
|
|
||||||
```tsx
|
|
||||||
<ExcelImportButton templateCode="SAMSUNG_RECRUIT" onComplete={refetch} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### AG Grid 서버사이드 페이징
|
|
||||||
```tsx
|
|
||||||
<DataGrid
|
|
||||||
rowModelType="serverSide"
|
|
||||||
serverSideStoreType="partial"
|
|
||||||
cacheBlockSize={100}
|
|
||||||
fetchUrl="/api/ledger/recruit"
|
|
||||||
columnDefs={[/*...*/]}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 코드 컨벤션
|
|
||||||
|
|
||||||
### 네이밍
|
|
||||||
| 종류 | 패턴 | 예시 |
|
|
||||||
|------|------|------|
|
|
||||||
| 테이블 | snake_case | `agent_org_history` |
|
|
||||||
| Java 클래스 | PascalCase | `AgentOrgHistoryVO` |
|
|
||||||
| 필드 | camelCase | `agentName` |
|
|
||||||
| URL | kebab-case 복수형 | `/api/exception-codes` |
|
|
||||||
| 메뉴 코드 | UPPER_SNAKE | `RULE_COMMISSION` |
|
|
||||||
| 공통코드 그룹 | UPPER_SNAKE | `AGENT_STATUS` |
|
|
||||||
|
|
||||||
### VO 5종 패턴 (한 도메인 = 3~5 클래스)
|
|
||||||
- `XxxVO` — 테이블 1:1 (INSERT/UPDATE)
|
|
||||||
- `XxxResp` — 응답 (조인 결과 + `*Name` 필드)
|
|
||||||
- `XxxSaveReq` — 입력 (`@Valid`)
|
|
||||||
- `XxxSearchParam` — `extends SearchParam`
|
|
||||||
- `XxxExcelVO` — 엑셀 전용 (`@ExcelColumn`)
|
|
||||||
|
|
||||||
### Mapper XML
|
|
||||||
- `SELECT *` 금지 — 컬럼 명시
|
|
||||||
- 목록은 `XxxResp`, INSERT/UPDATE 는 `XxxVO` 매핑
|
|
||||||
- 동적 WHERE 는 `<where>` + `<if>`
|
|
||||||
- 1:N 은 `<resultMap><collection>`
|
|
||||||
- 자기참조 트리는 `WITH RECURSIVE`
|
|
||||||
|
|
||||||
### 트랜잭션
|
|
||||||
- 읽기 전용 메서드는 어노테이션 없음 (서비스 클래스 기본은 비트랜잭션)
|
|
||||||
- 쓰기 메서드는 `@Transactional`
|
|
||||||
- 외부 API 호출과 DB 쓰기를 같이 묶지 말 것 (외부 실패 시 DB 롤백 영향)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 트러블슈팅 FAQ
|
|
||||||
|
|
||||||
### Q. 권한 에러 "FORBIDDEN"
|
|
||||||
- `@RequirePermission(menu="X", perm="Y")` 의 `menu` 가 `menu` 테이블에 등록됐는지 확인
|
|
||||||
- 사용자 역할에 `role_menu_permission` 행이 있는지: `is_granted='Y'`
|
|
||||||
- 캐시 무효화: `POST /api/system/permission/refresh` 또는 재로그인
|
|
||||||
|
|
||||||
### Q. 공통코드 변경 후 화면에 반영 안 됨
|
|
||||||
- Redis 캐시 TTL — `CommonCodeService.refreshCache()` 호출
|
|
||||||
- 또는 관리자 화면 → "캐시 새로고침"
|
|
||||||
|
|
||||||
### Q. MyBatis 매핑 에러 `Could not find result map ...`
|
|
||||||
- `resultType` 클래스 풀패키지명 확인
|
|
||||||
- `mapUnderscoreToCamelCase=true` 가 `mybatis-config.xml` 에 적용됐는지 확인
|
|
||||||
- 컬럼명과 필드명 매핑이 안 맞으면 `<resultMap>` 명시
|
|
||||||
|
|
||||||
### Q. `EncryptTypeHandler` 가 적용 안 됨
|
|
||||||
- VO 매퍼에 `typeHandler="com.ga.common.mybatis.EncryptTypeHandler"` 명시
|
|
||||||
- 또는 컬럼명 단위로 `#{accountNo,typeHandler=...}` 명시
|
|
||||||
|
|
||||||
### Q. 엑셀 70만건 업로드 중 OOM
|
|
||||||
- `xlsx-streamer` (SAX) 가 사용됐는지 확인 (`StreamingReader.builder()`)
|
|
||||||
- `batchSize` 1000~5000 사이에서 조정
|
|
||||||
- LOOKUP 변환은 `LookupCache.preload()` 로 시작 전 일괄 적재
|
|
||||||
|
|
||||||
### Q. AG Grid 합계행이 안 보임
|
|
||||||
- `pinnedBottomRowData` 에 합계 행 객체 전달
|
|
||||||
- 또는 `enableRangeSelection` + `aggFunc="sum"` 컬럼 정의
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 자주 묻는 운영 작업
|
|
||||||
|
|
||||||
### 비밀번호 강제 초기화
|
|
||||||
```sql
|
|
||||||
UPDATE users SET password = '$2b$10$...', fail_count = 0 WHERE login_id = 'kyu';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 메뉴 권한 재계산 (역할별)
|
|
||||||
```sql
|
|
||||||
DELETE FROM role_menu_permission WHERE role_id = 2;
|
|
||||||
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
|
|
||||||
SELECT 2, m.menu_id, p.perm_code, 'Y'
|
|
||||||
FROM menu m JOIN menu_permission p ON p.menu_id = m.menu_id
|
|
||||||
WHERE m.menu_code IN ('ORG_AGENT', 'CONTRACT', 'LEDGER_RECRUIT');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 정산 배치 수동 트리거
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/batch/settlement/run \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"settleMonth":"202405"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 캐시 비우기
|
|
||||||
```bash
|
|
||||||
docker exec -it ga-redis redis-cli FLUSHALL
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 참고 링크
|
|
||||||
|
|
||||||
- 운영 DB — `192.168.0.60:55432/trading_ai/ga`
|
|
||||||
- API 문서 — `http://localhost:8080/swagger-ui.html`
|
|
||||||
- Git — `https://gl.kyleyang.co.kr/kyu/ga-commission-system`
|
|
||||||
- 기획 원본 — `GA_솔루션_Agent_Teams_최종통합본.md`
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.ga.batch.settlement.receive;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.ga.common.exception.BizException;
|
||||||
|
import com.ga.common.exception.ErrorCode;
|
||||||
|
import com.ga.core.mapper.receive.ReceiveMapper;
|
||||||
|
import com.ga.core.vo.receive.CompanyProfileVO;
|
||||||
|
import com.ga.core.vo.receive.RawCommissionDataVO;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보험사 OpenAPI 호출하여 데이터 수신.
|
||||||
|
* - api_url 은 company_profile 에서 가져옴. {settleMonth} placeholder 치환 지원.
|
||||||
|
* - 응답은 JSON array 가정. 각 element 를 raw_content 에 적재.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiReceiver implements DataReceiver {
|
||||||
|
|
||||||
|
private final ReceiveMapper mapper;
|
||||||
|
private final RestClient restClient = RestClient.builder().build();
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(String companyCode) {
|
||||||
|
CompanyProfileVO p = mapper.selectProfile(companyCode);
|
||||||
|
return p != null && "API".equalsIgnoreCase(p.getDataFormat());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int receive(String companyCode, String settleMonth) {
|
||||||
|
CompanyProfileVO profile = mapper.selectProfile(companyCode);
|
||||||
|
if (profile == null || profile.getApiUrl() == null) {
|
||||||
|
log.warn("API profile not configured: {}", companyCode);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
String url = profile.getApiUrl().replace("{settleMonth}", settleMonth);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String body = restClient.get().uri(url).retrieve().body(String.class);
|
||||||
|
if (body == null || body.isBlank()) return 0;
|
||||||
|
JsonNode arr = JSON.readTree(body);
|
||||||
|
if (!arr.isArray()) {
|
||||||
|
log.warn("API response is not array: {}", url);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String batchId = UUID.randomUUID().toString();
|
||||||
|
List<RawCommissionDataVO> buf = new ArrayList<>(arr.size());
|
||||||
|
int rowNum = 0;
|
||||||
|
for (JsonNode item : arr) {
|
||||||
|
rowNum++;
|
||||||
|
RawCommissionDataVO raw = new RawCommissionDataVO();
|
||||||
|
raw.setCompanyCode(companyCode);
|
||||||
|
raw.setBatchId(batchId);
|
||||||
|
raw.setSettleMonth(settleMonth);
|
||||||
|
raw.setDataType("API");
|
||||||
|
raw.setRawContent(item.toString());
|
||||||
|
raw.setParseStatus("PENDING");
|
||||||
|
raw.setFileName(url);
|
||||||
|
raw.setRowNumber(rowNum);
|
||||||
|
buf.add(raw);
|
||||||
|
}
|
||||||
|
if (!buf.isEmpty()) mapper.insertRawBatch(buf);
|
||||||
|
log.info("ApiReceiver: company={}, url={}, rows={}", companyCode, url, buf.size());
|
||||||
|
return buf.size();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("API receive error: {}", url, e);
|
||||||
|
throw new BizException(ErrorCode.EXTERNAL_API_FAIL, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.ga.batch.settlement.receive;
|
||||||
|
|
||||||
|
import com.ga.common.exception.BizException;
|
||||||
|
import com.ga.common.exception.ErrorCode;
|
||||||
|
import com.ga.core.mapper.receive.ReceiveMapper;
|
||||||
|
import com.ga.core.vo.receive.CompanyProfileVO;
|
||||||
|
import com.ga.core.vo.receive.RawCommissionDataVO;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV/TSV 파일 수신.
|
||||||
|
* file_encoding, delimiter 는 company_profile 에서 가져옴.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CsvReceiver implements DataReceiver {
|
||||||
|
|
||||||
|
private final ReceiveMapper mapper;
|
||||||
|
|
||||||
|
@Value("${ga.batch.receive.csv-dir:./data/receive/csv}")
|
||||||
|
private String csvDir;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(String companyCode) {
|
||||||
|
CompanyProfileVO p = mapper.selectProfile(companyCode);
|
||||||
|
return p != null && "CSV".equalsIgnoreCase(p.getDataFormat());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int receive(String companyCode, String settleMonth) {
|
||||||
|
CompanyProfileVO profile = mapper.selectProfile(companyCode);
|
||||||
|
Path file = Paths.get(csvDir, settleMonth, companyCode + ".csv");
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
log.warn("CSV file not found: {}", file);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Charset cs = profile.getFileEncoding() != null ? Charset.forName(profile.getFileEncoding()) : Charset.forName("UTF-8");
|
||||||
|
String delim = profile.getDelimiter() != null ? profile.getDelimiter() : ",";
|
||||||
|
int dataStart = profile.getDataStartRow() == null ? 2 : profile.getDataStartRow();
|
||||||
|
String batchId = UUID.randomUUID().toString();
|
||||||
|
int batchSize = 1000;
|
||||||
|
int total = 0;
|
||||||
|
List<RawCommissionDataVO> buf = new ArrayList<>(batchSize);
|
||||||
|
|
||||||
|
try (BufferedReader reader = Files.newBufferedReader(file, cs)) {
|
||||||
|
String line;
|
||||||
|
int rowNum = 0;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
rowNum++;
|
||||||
|
if (rowNum < dataStart) continue;
|
||||||
|
if (line.isBlank()) continue;
|
||||||
|
RawCommissionDataVO raw = new RawCommissionDataVO();
|
||||||
|
raw.setCompanyCode(companyCode);
|
||||||
|
raw.setBatchId(batchId);
|
||||||
|
raw.setSettleMonth(settleMonth);
|
||||||
|
raw.setDataType("CSV");
|
||||||
|
raw.setRawContent(toJsonArray(line.split(java.util.regex.Pattern.quote(delim), -1)));
|
||||||
|
raw.setParseStatus("PENDING");
|
||||||
|
raw.setFileName(file.getFileName().toString());
|
||||||
|
raw.setRowNumber(rowNum);
|
||||||
|
buf.add(raw);
|
||||||
|
if (buf.size() >= batchSize) {
|
||||||
|
mapper.insertRawBatch(buf);
|
||||||
|
total += buf.size();
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!buf.isEmpty()) {
|
||||||
|
mapper.insertRawBatch(buf);
|
||||||
|
total += buf.size();
|
||||||
|
}
|
||||||
|
log.info("CsvReceiver: company={}, file={}, rows={}", companyCode, file, total);
|
||||||
|
return total;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("CSV receive error", e);
|
||||||
|
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJsonArray(String[] cols) {
|
||||||
|
StringBuilder sb = new StringBuilder("[");
|
||||||
|
for (int i = 0; i < cols.length; i++) {
|
||||||
|
if (i > 0) sb.append(',');
|
||||||
|
sb.append('"').append(cols[i].replace("\"", "\\\"")).append('"');
|
||||||
|
}
|
||||||
|
sb.append(']');
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.ga.batch.settlement.receive;
|
||||||
|
|
||||||
|
import com.ga.common.exception.BizException;
|
||||||
|
import com.ga.common.exception.ErrorCode;
|
||||||
|
import com.ga.core.mapper.receive.ReceiveMapper;
|
||||||
|
import com.ga.core.vo.receive.CompanyProfileVO;
|
||||||
|
import com.ga.core.vo.receive.RawCommissionDataVO;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보험사별 EDI(고정 폭) 텍스트 파일 수신.
|
||||||
|
* 각 행을 그대로 raw_content 에 적재 — 매핑은 transform 단계에서 company_field_mapping 의 sourceColIndex(시작/길이)로 분해.
|
||||||
|
*
|
||||||
|
* 보험사별 EDI 양식이 매우 다양하므로 여기서는 line 자체를 raw로 보존하고
|
||||||
|
* MappingEngine 이 sourceColIndex 기준으로 substring 처리.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EdiReceiver implements DataReceiver {
|
||||||
|
|
||||||
|
private final ReceiveMapper mapper;
|
||||||
|
|
||||||
|
@Value("${ga.batch.receive.edi-dir:./data/receive/edi}")
|
||||||
|
private String ediDir;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(String companyCode) {
|
||||||
|
CompanyProfileVO p = mapper.selectProfile(companyCode);
|
||||||
|
return p != null && "EDI".equalsIgnoreCase(p.getDataFormat());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int receive(String companyCode, String settleMonth) {
|
||||||
|
CompanyProfileVO profile = mapper.selectProfile(companyCode);
|
||||||
|
Path file = Paths.get(ediDir, settleMonth, companyCode + ".dat");
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
log.warn("EDI file not found: {}", file);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Charset cs = profile.getFileEncoding() != null ? Charset.forName(profile.getFileEncoding()) : Charset.forName("EUC-KR");
|
||||||
|
int dataStart = profile.getDataStartRow() == null ? 1 : profile.getDataStartRow();
|
||||||
|
String batchId = UUID.randomUUID().toString();
|
||||||
|
int batchSize = 1000;
|
||||||
|
int total = 0;
|
||||||
|
List<RawCommissionDataVO> buf = new ArrayList<>(batchSize);
|
||||||
|
|
||||||
|
try (BufferedReader reader = Files.newBufferedReader(file, cs)) {
|
||||||
|
String line;
|
||||||
|
int rowNum = 0;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
rowNum++;
|
||||||
|
if (rowNum < dataStart) continue;
|
||||||
|
if (line.isBlank()) continue;
|
||||||
|
RawCommissionDataVO raw = new RawCommissionDataVO();
|
||||||
|
raw.setCompanyCode(companyCode);
|
||||||
|
raw.setBatchId(batchId);
|
||||||
|
raw.setSettleMonth(settleMonth);
|
||||||
|
raw.setDataType("EDI");
|
||||||
|
raw.setRawContent(line); // 고정 폭 라인 그대로
|
||||||
|
raw.setParseStatus("PENDING");
|
||||||
|
raw.setFileName(file.getFileName().toString());
|
||||||
|
raw.setRowNumber(rowNum);
|
||||||
|
buf.add(raw);
|
||||||
|
if (buf.size() >= batchSize) {
|
||||||
|
mapper.insertRawBatch(buf);
|
||||||
|
total += buf.size();
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!buf.isEmpty()) {
|
||||||
|
mapper.insertRawBatch(buf);
|
||||||
|
total += buf.size();
|
||||||
|
}
|
||||||
|
log.info("EdiReceiver: company={}, file={}, rows={}", companyCode, file, total);
|
||||||
|
return total;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("EDI receive error", e);
|
||||||
|
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.ga.batch.settlement.receive;
|
||||||
|
|
||||||
|
import com.ga.common.exception.BizException;
|
||||||
|
import com.ga.common.exception.ErrorCode;
|
||||||
|
import com.ga.core.mapper.receive.ReceiveMapper;
|
||||||
|
import com.ga.core.vo.receive.CompanyProfileVO;
|
||||||
|
import com.ga.core.vo.receive.RawCommissionDataVO;
|
||||||
|
import com.monitorjbl.xlsx.StreamingReader;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 파일을 읽어 raw_commission_data 에 적재.
|
||||||
|
* 보험사별 FTP 다운로드 폴더 또는 운영자가 미리 옮겨둔 폴더에서 읽음.
|
||||||
|
*
|
||||||
|
* - SAX 스트리밍으로 70만건 안전 처리
|
||||||
|
* - 컬럼 매핑은 ReceiveMapper.selectFieldMappings(companyCode, "raw") 의 정의를 따름
|
||||||
|
* - data_format = 'EXCEL' 인 보험사 지원
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExcelReceiver implements DataReceiver {
|
||||||
|
|
||||||
|
private final ReceiveMapper mapper;
|
||||||
|
|
||||||
|
@Value("${ga.batch.receive.excel-dir:./data/receive/excel}")
|
||||||
|
private String excelDir;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(String companyCode) {
|
||||||
|
CompanyProfileVO p = mapper.selectProfile(companyCode);
|
||||||
|
return p != null && "EXCEL".equalsIgnoreCase(p.getDataFormat());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int receive(String companyCode, String settleMonth) {
|
||||||
|
CompanyProfileVO profile = mapper.selectProfile(companyCode);
|
||||||
|
Path file = resolveFile(companyCode, settleMonth);
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
log.warn("Excel file not found: {}", file);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String batchId = UUID.randomUUID().toString();
|
||||||
|
int header = profile.getHeaderRow() == null ? 1 : profile.getHeaderRow();
|
||||||
|
int dataStart = profile.getDataStartRow() == null ? 2 : profile.getDataStartRow();
|
||||||
|
int batchSize = 1000;
|
||||||
|
int total = 0;
|
||||||
|
|
||||||
|
List<RawCommissionDataVO> buf = new ArrayList<>(batchSize);
|
||||||
|
try (var is = Files.newInputStream(file);
|
||||||
|
Workbook wb = StreamingReader.builder().rowCacheSize(100).bufferSize(4096).open(is)) {
|
||||||
|
|
||||||
|
Sheet sheet = profile.getSheetName() != null ? wb.getSheet(profile.getSheetName()) : wb.getSheetAt(0);
|
||||||
|
int rowNum = 0;
|
||||||
|
for (Row row : sheet) {
|
||||||
|
rowNum++;
|
||||||
|
if (rowNum < dataStart) continue;
|
||||||
|
RawCommissionDataVO raw = new RawCommissionDataVO();
|
||||||
|
raw.setCompanyCode(companyCode);
|
||||||
|
raw.setBatchId(batchId);
|
||||||
|
raw.setSettleMonth(settleMonth);
|
||||||
|
raw.setDataType("EXCEL");
|
||||||
|
raw.setRawContent(rowToJson(row));
|
||||||
|
raw.setParseStatus("PENDING");
|
||||||
|
raw.setFileName(file.getFileName().toString());
|
||||||
|
raw.setRowNumber(rowNum);
|
||||||
|
buf.add(raw);
|
||||||
|
if (buf.size() >= batchSize) {
|
||||||
|
mapper.insertRawBatch(buf);
|
||||||
|
total += buf.size();
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!buf.isEmpty()) {
|
||||||
|
mapper.insertRawBatch(buf);
|
||||||
|
total += buf.size();
|
||||||
|
}
|
||||||
|
log.info("ExcelReceiver: company={}, file={}, rows={}", companyCode, file, total);
|
||||||
|
return total;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Excel receive error", e);
|
||||||
|
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path resolveFile(String companyCode, String settleMonth) {
|
||||||
|
return Paths.get(excelDir, settleMonth, companyCode + ".xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row 의 셀 값을 단순 JSON 배열 문자열로 직렬화. */
|
||||||
|
private String rowToJson(Row row) {
|
||||||
|
StringBuilder sb = new StringBuilder("[");
|
||||||
|
boolean first = true;
|
||||||
|
for (var cell : row) {
|
||||||
|
if (!first) sb.append(',');
|
||||||
|
sb.append('"').append(cell == null ? "" : cell.toString().replace("\"", "\\\"")).append('"');
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
sb.append(']');
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.ga.batch.settlement.receive;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보험사별 적절한 Receiver 를 선택해 호출.
|
||||||
|
* 모든 DataReceiver 가 자동 주입되며, supports(companyCode) 가 true 인 첫 구현체를 사용.
|
||||||
|
*
|
||||||
|
* 우선순위는 List 주입 순서. ManualReceiver 는 fallback 역할이므로 마지막에 두는 것을 권장
|
||||||
|
* (현재는 supports 가 NULL 프로파일도 true로 하므로 우선순위 위에 있을 수 없음).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReceiverDispatcher {
|
||||||
|
|
||||||
|
private final List<DataReceiver> receivers;
|
||||||
|
|
||||||
|
public int dispatch(String companyCode, String settleMonth) {
|
||||||
|
for (DataReceiver r : receivers) {
|
||||||
|
if (r == null) continue;
|
||||||
|
try {
|
||||||
|
if (r.supports(companyCode)) {
|
||||||
|
log.info("Dispatching {} -> {}", companyCode, r.getClass().getSimpleName());
|
||||||
|
return r.receive(companyCode, settleMonth);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Receiver {} supports() error", r.getClass().getSimpleName(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.warn("No receiver supports company: {}", companyCode);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user