동기화

This commit is contained in:
GA Pro
2026-05-15 00:25:18 +09:00
parent 88e14edffd
commit d5ed642c80
69 changed files with 2634 additions and 26 deletions
+42 -3
View File
@@ -118,14 +118,53 @@
### 3-6. P1 정산 통합 (batch-engineer 완료)
- ga-batch 8 Step에 TaxCalculator / DeductionService / RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator 통합 완료
## 4. 잔여 작업 (P5/P6)
## 3-7. P5 Wave 1 — agent + contract 회계 진입점 (V63~V66)
### P5 — 회계/분석 보강 (다음 라운드)
| 항목 | 마이그레이션 | 상태 |
|---|---|---|
| agent_annual_income 테이블 | V63 | 완료 |
| contract_accounting_entry 테이블 | V64 | 완료 |
| P5-W1 테스트 데이터 + 메뉴 INSERT (ANNUAL_INCOME / ACCOUNTING_JOURNAL) | V65 | 완료 |
| account_code 마스터 + 4건 초기 데이터 | V66 | 완료 |
| rule 감사 컬럼 보강 (commission_rate/payout_rule/override_rule 등) | V70 | 완료 |
| payment 세무 컬럼 보강 (income_tax_amount/local_tax_amount/vat_amount/net_amount) | V71 | 완료 |
핵심 산출물:
- `agent_annual_income` (agentId+settleYear 복합PK, 연도별 사업소득 집계)
- `contract_accounting_entry` (분개 엔트리, debit/credit 계정코드 + journalNo 마감)
- `account_code` 마스터 (account_type DEBIT/CREDIT/BOTH 구분, 초기 4건: 8350/2530/1130/4110)
- `AgentAnnualIncomeService` + `AgentAnnualIncomeController` (`/api/agent-annual-incomes`)
- `ContractAccountingService` + `ContractAccountingController` (`/api/accounting-entries`)
- `AccountCodeService` + `AccountCodeController` (`/api/common/account-codes`)
- `YearEndIncomeAggregateStep` (Step 10, 12월 마감 시만 실행, settleMonth.endsWith("12") 조건)
- `AccountingEntryGenerateStep` (Step 9, 매월 정산 후 분개 생성, ledger UNION 기반 contract_id 확보, journal_no=NULL 미전기)
- 프론트 화면: `AgentAnnualIncomeList` (`/commission/annual-income`) + `AccountingJournalList` (`/commission/accounting-journal`)
통합 검증 결과 (2026-05-15):
- Flyway: V63/V64/V65/V66/V70/V71 적용 확인 (현재 ga schema V71)
- GET /api/agent-annual-incomes → 200 OK
- POST /api/agent-annual-incomes/regenerate?year=2025 → 200 OK, affected=0 (payment 데이터 없음, 정상)
- GET /api/accounting-entries → 200 OK
- GET /api/common/account-codes?accountType=DEBIT → 200 OK, 2 items
보정 사항 (통합 검증 중 발견):
- App.tsx 라우트와 V65 menu_path 모두 `/commission/annual-income`, `/commission/accounting-journal`로 일치
- ANNUAL_INCOME EXECUTE 권한 누락 → DB 직접 INSERT로 보정
- payment 세무 컬럼 미적용 (V23 체크섬 충돌 이력) → V71로 idempotent 보강
운영 DB 적용: ga-api 재시작 → Flyway V18~V71 일괄 자동 적용.
## 4. 잔여 작업 (P5-W2/W3 + P6)
### P5-W2 — rule + payout_rule + payment (V67~V69 예정)
| 항목 | 설명 |
|---|---|
| 회계 전표 자동 분개 | 수수료 지급액 → 분개 엔트리 자동 생성 |
| 원천세 신고 자료 | 분기별 사업소득세 집계 + 국세청 포맷 |
| 부가세 신고 자료 | tax_invoice 기반 매출/매입 집계 |
### P5-W3 — 결산 / 이상치 / 연말 명세서
| 항목 | 설명 |
|---|---|
| 결산 | period_close 와 별개의 월/분기/연 회계 마감 |
| 이상치·예측 KPI | 13M/25M 유지율 이상 탐지 + 차월 예측 |
| 연말 지급명세서 | 설계사 연 사업소득 세무 자료 PDF |
+46 -8
View File
@@ -44,8 +44,8 @@ V1~V17 마이그레이션 + 테스트 데이터(보험사 5 / 상품 20 / 설계
`192.168.0.60:55432/trading_ai/ga` 스키마에 적용되어 있습니다.
> **주의**: 운영 DB 에는 현재 V1~V17(또는 V22 까지)만 적용되어 있습니다.
> V18~V55 는 ga-api 를 재시작하면 Flyway 가 자동으로 순서대로 적용합니다.
> 재시작 전까지 P2~P4 신규 도메인 API 는 테이블이 없어 동작하지 않습니다.
> V18~V61 은 ga-api 를 재시작하면 Flyway 가 자동으로 순서대로 적용합니다.
> 재시작 전까지 P2~P5-W1 신규 도메인 API 는 테이블이 없어 동작하지 않습니다.
#### 1) Backend (ga-api) 실행
@@ -111,6 +111,8 @@ PW : admin1234!
| 정산 결과 | `/settle` | 월 요약 + 확정/보류 |
| 정산 배치 | `/settle/batch` | 배치 실행 + 진행률 폴링 |
| 지급 관리 | `/payments` | 지급 상태 추적 |
| 연말 사업소득 명세서 | `/settle/annual-income` | 설계사별 연간 사업소득 집계 + 재집계 (P5-W1) |
| 분개장 | `/settle/accounting-journal` | 회계 자동분개 엔트리 + 마감 처리 (P5-W1) |
| 사용자 관리 | `/system/users` | 비번 초기화/잠금 해제 |
| 역할/권한 | `/system/roles` | 권한 매트릭스 (메뉴 × 6권한) |
| 공통코드 | `/system/codes` | 그룹별 코드 조회 |
@@ -274,7 +276,23 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin';
- **첨부파일 (P4-B-8)**: attachment — 도메인 무관 공통 첨부 (소속/경로/타입)
- **출금신청 (P4-B-9)**: withdraw_request — 결재 + 펌뱅킹 송신 + 결과 반영
### 정산 배치 8 Step
### P5 Wave 1 — 회계 진입점 (V63~V66)
- **연말 사업소득 명세서** (`/settle/annual-income`): 설계사별 귀속연도 사업소득 집계, 재집계 버튼
- `GET /api/agent-annual-incomes` — 목록 조회 (settleYear/agentId/orgId 필터)
- `GET /api/agent-annual-incomes/{agentId}/{settleYear}` — 단건 상세
- `POST /api/agent-annual-incomes/regenerate?year=YYYY` — 연도 전체 재집계 (EXECUTE 권한)
- **분개장** (`/settle/accounting-journal`): 수수료 지급 자동분개 엔트리, 마감(journalNo 채번) 기능
- `GET /api/accounting-entries` — 분개 목록 (settleMonth/journalNo/status 필터)
- `POST /api/accounting-entries/generate` — 정산월 기준 분개 자동생성 (settleMonth or paymentIds)
- `PUT /api/accounting-entries/close` — 분개 마감 처리 (entryIds + journalNo)
- **계정코드 마스터** (공통 드롭다운용)
- `GET /api/common/account-codes?accountType=DEBIT|CREDIT|BOTH` — 활성 계정코드 목록
- `GET /api/common/account-codes/all` — 전체 (시스템관리용)
- `POST /api/common/account-codes` — 등록
- `PUT /api/common/account-codes/{accountCode}` — 수정
- `DELETE /api/common/account-codes/{accountCode}` — 삭제
### 정산 배치 10 Step
1. **receiveData** — 보험사별 DataReceiver → raw_commission_data 적재
2. **transformData** — MappingEngine 으로 raw → 표준 원장 (TRIM/UPPER/DATE/DIVIDE/CODE_MAP)
3. **reconcile** — 보험사 통보액 vs 시스템 계산액 대사
@@ -283,6 +301,8 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin';
6. **chargebackException** — 실효 계약 환수 자동생성
7. **calcOverride** — 조직 트리 상향 탐색 → 상위 직급에 분배
8. **aggregate** — 설계사별 통합 집계 → settle_master UPSERT (3.3% 세금)
9. **accountingEntryGenerate** — recruit_ledger+maintain_ledger → contract_accounting_entry INSERT
10. **yearEndIncomeAggregate** — 12월 마감 시만 실행: payment 합산 → agent_annual_income UPSERT
---
@@ -382,9 +402,25 @@ public class FooController {
| V53 | 출금신청 — withdraw_request |
| V54 | P4 메뉴 INSERT (P4-A + P4-B 14개 메뉴) |
| V55 | P4 공통코드 추가 (approval_type, complaint_type, license_type 등) |
| V56 | agent 세무 컬럼 보강 (tax_type/business_no/vat_type — V23 체크섬 충돌 보정) |
| V57 | admin 계정 초기화 |
| V58 | 1200%룰 메뉴 권한 |
| V59 | 신규 메뉴 권한 |
| V60 | statement 메뉴 권한 |
| V61 | installment_plan 메뉴 권한 |
| V62 | 추가 메뉴 권한 |
| V63 | P5-W1: agent_annual_income 테이블 (설계사 연말 사업소득 집계) |
| V64 | P5-W1: contract_accounting_entry 테이블 (회계 분개 엔트리) |
| V65 | P5-W1: 테스트 데이터 + 메뉴 INSERT (ANNUAL_INCOME / ACCOUNTING_JOURNAL) |
| V66 | P5-W1: account_code 마스터 + 초기 4건 (8350/2530/1130/4110) |
| V67 | P5-W2: withholding_tax_report 테이블 (원천세 분기 신고) |
| V68 | P5-W2: vat_report 테이블 (부가세 분기 신고) |
| V69 | P5-W2: 테스트 데이터 + 메뉴 INSERT (원천세/부가세 신고) |
| V70 | rule 도메인 감사 컬럼 보강 (commission_rate/payout_rule/override_rule 등) |
| V71 | payment 세무 컬럼 보강 (income_tax_amount/local_tax_amount/vat_amount/net_amount) |
> 운영 DB (192.168.0.60) 에는 현재 V17(또는 V22) 까지 적용되어 있습니다.
> **ga-api 재시작 시** Flyway 가 V18~V55 를 자동으로 순차 적용합니다.
> 운영 DB (192.168.0.60) 현재 V71 까지 적용 완료.
> **ga-api 재시작 시** Flyway 가 신규 마이그레이션을 자동으로 순차 적용합니다.
---
@@ -397,7 +433,7 @@ public class FooController {
- [x] **ga-batch** — MonthlySettlementJob 8 Step (실제 동작 로직 포함)
- [x] **Docker** — docker-compose + multi-stage Dockerfile + nginx
- [x] **ga-frontend** — 50+ 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/KPI/수수료/시스템관리)
- [x] **DB 마이그레이션** — V1~V55 (55개 파일, ga-common/src/main/resources/db/migration)
- [x] **DB 마이그레이션** — V1~V71 (71개 파일, ga-common/src/main/resources/db/migration)
- [x] **코드 품질** — Enum/DTO/MapStruct/readOnly 트랜잭션 일괄 정비
- [x] **P0 세무·공제·명세서** — TaxCalculator / DeductionService / ExcelStatementGenerator
- [x] **P1 1200%룰·분급·차등환수** — RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator
@@ -405,10 +441,12 @@ public class FooController {
- [x] **P3 KPI 대시보드** — Materialized View 4종 + Service + 화면
- [x] **P4-A 수수료 종류 보강** — commission_master 통합 / 수금 / 갱신 / 부활 / 유지보수수당 / 오버라이드원장
- [x] **P4-B 운영 도메인** — 위촉계약 / 자격관리 / 보수교육 / 계약변경 / 민원 / 결재워크플로우 / 공지·알림 / 첨부파일 / 출금신청
- [x] **운영 DB** — 192.168.0.60:55432/trading_ai/ga (V17까지 적용, V18~V55는 재시작 시 자동 적용)
- [x] **P5-W1 회계 진입점** — agent_annual_income / contract_accounting_entry / account_code (V63~V66, 통합검증 완료)
- [x] **운영 DB** — 192.168.0.60:55432/trading_ai/ga (V71 까지 적용 완료)
- [ ] **단위/통합 테스트** — 미작성 (SqlSessionFactory 환경 문제로 보류)
- [ ] **MappingEngine 정밀화** — 일부 변환 규칙은 단순화된 형태
- [ ] **P5 회계/분석** — 전표 분개 / 원천세·부가세 신고 / 결산 / 이상치·예측 / 연말 지급명세서
- [ ] **P5-W2 원천세·부가세 신고** — withholding_tax_report / vat_report (V67~V69 스키마 완료, Service+Controller 미완)
- [ ] **P5-W3 결산 / 이상치 / 연말 지급명세서** — 미착수
- [ ] **P6 외부 연동** — 펌뱅킹 실연동 / SMS·카카오 / 모바일 셀프 조회
---
@@ -0,0 +1,15 @@
package com.ga.api.controller.accounting;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
@Data
public class CloseJournalReq {
@NotEmpty
private List<Long> entryIds;
@NotBlank
private String journalNo;
}
@@ -0,0 +1,47 @@
package com.ga.api.controller.accounting;
import com.ga.api.service.accounting.ContractAccountingService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.accounting.ContractAccountingEntryResp;
import com.ga.core.vo.accounting.ContractAccountingEntrySearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "계약 회계 분개")
@RestController
@RequestMapping("/api/accounting-entries")
@RequiredArgsConstructor
public class ContractAccountingController {
private final ContractAccountingService service;
@GetMapping
@Operation(summary = "분개 엔트리 목록 조회")
@RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "READ")
public ApiResponse<PageResponse<ContractAccountingEntryResp>> list(
@ModelAttribute ContractAccountingEntrySearchParam param) {
return ApiResponse.ok(service.list(param));
}
@PostMapping("/generate")
@Operation(summary = "자동 분개 생성 (paymentIds 또는 settleMonth)")
@RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "CREATE")
@DataChangeLog(menu = "ACCOUNTING_JOURNAL", table = "contract_accounting_entry")
public ApiResponse<Integer> generate(@RequestBody GenerateEntriesReq req) {
return ApiResponse.ok(service.generateFromPayments(req));
}
@PutMapping("/close")
@Operation(summary = "분개 마감 처리")
@RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "UPDATE")
@DataChangeLog(menu = "ACCOUNTING_JOURNAL", table = "contract_accounting_entry")
public ApiResponse<Integer> close(@Valid @RequestBody CloseJournalReq req) {
return ApiResponse.ok(service.closeJournal(req));
}
}
@@ -0,0 +1,11 @@
package com.ga.api.controller.accounting;
import lombok.Data;
import java.util.List;
@Data
public class GenerateEntriesReq {
private List<Long> paymentIds;
private String settleMonth;
}
@@ -0,0 +1,47 @@
package com.ga.api.controller.income;
import com.ga.api.service.income.AgentAnnualIncomeService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.income.AgentAnnualIncomeResp;
import com.ga.core.vo.income.AgentAnnualIncomeSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "연간 사업소득")
@RestController
@RequestMapping("/api/agent-annual-incomes")
@RequiredArgsConstructor
public class AgentAnnualIncomeController {
private final AgentAnnualIncomeService service;
@GetMapping
@Operation(summary = "연간소득 목록 조회")
@RequirePermission(menu = "ANNUAL_INCOME", perm = "READ")
public ApiResponse<PageResponse<AgentAnnualIncomeResp>> list(
@ModelAttribute AgentAnnualIncomeSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{agentId}/{settleYear}")
@Operation(summary = "연간소득 상세 조회")
@RequirePermission(menu = "ANNUAL_INCOME", perm = "READ")
public ApiResponse<AgentAnnualIncomeResp> detail(
@PathVariable Long agentId,
@PathVariable int settleYear) {
return ApiResponse.ok(service.detail(agentId, settleYear));
}
@PostMapping("/regenerate")
@Operation(summary = "연도 전체 재집계")
@RequirePermission(menu = "ANNUAL_INCOME", perm = "EXECUTE")
@DataChangeLog(menu = "ANNUAL_INCOME", table = "agent_annual_income")
public ApiResponse<Integer> regenerate(@RequestParam int year) {
return ApiResponse.ok(service.regenerate(year));
}
}
@@ -63,6 +63,14 @@ public class RegulatoryLimitController {
return ApiResponse.ok();
}
@PutMapping("/{limitId}/activate")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
public ApiResponse<Void> activate(@PathVariable Long limitId) {
limitService.activate(limitId);
return ApiResponse.ok();
}
@PostMapping("/check-contract")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<ViolationResult> checkContract(@Valid @RequestBody ContractLimitCheckReq req) {
@@ -0,0 +1,83 @@
package com.ga.api.service.accounting;
import com.ga.api.controller.accounting.CloseJournalReq;
import com.ga.api.controller.accounting.GenerateEntriesReq;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.accounting.ContractAccountingEntryMapper;
import com.ga.core.mapper.settle.PaymentMapper;
import com.ga.core.vo.accounting.ContractAccountingEntryResp;
import com.ga.core.vo.accounting.ContractAccountingEntrySearchParam;
import com.ga.core.vo.accounting.ContractAccountingEntryVO;
import com.ga.core.vo.settle.PaymentVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ContractAccountingService {
private static final String DEBIT_ACCOUNT = "8350"; // 지급수수료
private static final String CREDIT_ACCOUNT = "2530"; // 미지급금
private final ContractAccountingEntryMapper entryMapper;
private final PaymentMapper paymentMapper;
public PageResponse<ContractAccountingEntryResp> list(ContractAccountingEntrySearchParam param) {
param.startPage();
return PageResponse.of(entryMapper.selectList(param));
}
@Transactional
public int generateFromPayments(GenerateEntriesReq req) {
List<PaymentVO> payments = resolvePayments(req);
if (payments.isEmpty()) throw new BizException(ErrorCode.NOT_FOUND);
List<ContractAccountingEntryVO> entries = new ArrayList<>(payments.size());
for (PaymentVO p : payments) {
entries.add(buildEntry(p));
}
return entryMapper.insertBatch(entries);
}
@Transactional
public int closeJournal(CloseJournalReq req) {
if (req.getEntryIds() == null || req.getEntryIds().isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
return entryMapper.closeJournal(req.getEntryIds(), req.getJournalNo());
}
private List<PaymentVO> resolvePayments(GenerateEntriesReq req) {
if (req.getPaymentIds() != null && !req.getPaymentIds().isEmpty()) {
List<PaymentVO> result = new ArrayList<>();
for (Long id : req.getPaymentIds()) {
PaymentVO p = paymentMapper.selectById(id);
if (p != null) result.add(p);
}
return result;
}
if (req.getSettleMonth() != null && !req.getSettleMonth().isBlank()) {
return paymentMapper.selectBySettleMonth(req.getSettleMonth());
}
return List.of();
}
private ContractAccountingEntryVO buildEntry(PaymentVO p) {
ContractAccountingEntryVO vo = new ContractAccountingEntryVO();
vo.setPaymentId(p.getPaymentId());
vo.setEntryDate(LocalDate.now());
vo.setDebitAccount(DEBIT_ACCOUNT);
vo.setCreditAccount(CREDIT_ACCOUNT);
vo.setAmount(p.getNetAmount() != null ? p.getNetAmount() : p.getPayAmount());
vo.setDescription("commission payment " + p.getPaymentId());
return vo;
}
}
@@ -0,0 +1,35 @@
package com.ga.api.service.income;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.income.AgentAnnualIncomeMapper;
import com.ga.core.vo.income.AgentAnnualIncomeResp;
import com.ga.core.vo.income.AgentAnnualIncomeSearchParam;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AgentAnnualIncomeService {
private final AgentAnnualIncomeMapper mapper;
public PageResponse<AgentAnnualIncomeResp> list(AgentAnnualIncomeSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public AgentAnnualIncomeResp detail(Long agentId, int settleYear) {
AgentAnnualIncomeResp resp = mapper.selectDetailById(agentId, settleYear);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public int regenerate(int year) {
return mapper.upsertAggregate(year);
}
}
@@ -79,6 +79,24 @@ public class RegulatoryLimitService {
limitMapper.updateStatus(limitId, false);
}
@Transactional
public void activate(Long limitId) {
RegulatoryLimitVO existing = limitMapper.selectById(limitId);
if (existing == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
if (Boolean.TRUE.equals(existing.getIsActive())) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
String today = LocalDate.now().format(DATE_FMT);
RegulatoryLimitVO duplicate = limitMapper.selectActiveByType(
existing.getLimitType(), existing.getProductCategory(), today);
if (duplicate != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
limitMapper.updateStatus(limitId, true);
}
@Transactional(readOnly = true)
public RegulatoryLimitVO selectActiveByType(String limitType, String productCategory, String baseDate) {
return limitMapper.selectActiveByType(limitType, productCategory, baseDate);
@@ -4,7 +4,7 @@ ga:
spring:
datasource:
url: jdbc:postgresql://125.241.236.203:55432/ga
url: jdbc:postgresql://192.168.0.60:55432/ga
username: ga
password: ga
cache:
@@ -3,7 +3,7 @@
spring:
datasource:
url: jdbc:postgresql://125.241.236.203:55432/trading_ai?currentSchema=ga
url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga
username: ${DB_USER:kyu}
password: ${DB_PASSWORD:7895123}
driver-class-name: org.postgresql.Driver
@@ -38,16 +38,20 @@ public class BatchConfig {
CalcMaintainStep step5,
ChargebackExceptionStep step6,
CalcOverrideStep step7,
AggregateStep step8) {
AggregateStep step8,
AccountingEntryGenerateStep step9,
YearEndIncomeAggregateStep step10) {
return new JobBuilder("MONTHLY_SETTLEMENT", jobRepository)
.start(taskletStep("receiveData", step1, jobRepository, tx))
.next(taskletStep("transformData", step2, jobRepository, tx))
.next(taskletStep("reconcile", step3, jobRepository, tx))
.next(taskletStep("calcRecruit", step4, jobRepository, tx))
.next(taskletStep("calcMaintain", step5, jobRepository, tx))
.next(taskletStep("chargebackException", step6, jobRepository, tx))
.next(taskletStep("calcOverride", step7, jobRepository, tx))
.next(taskletStep("aggregate", step8, jobRepository, tx))
.start(taskletStep("receiveData", step1, jobRepository, tx))
.next(taskletStep("transformData", step2, jobRepository, tx))
.next(taskletStep("reconcile", step3, jobRepository, tx))
.next(taskletStep("calcRecruit", step4, jobRepository, tx))
.next(taskletStep("calcMaintain", step5, jobRepository, tx))
.next(taskletStep("chargebackException", step6, jobRepository, tx))
.next(taskletStep("calcOverride", step7, jobRepository, tx))
.next(taskletStep("aggregate", step8, jobRepository, tx))
.next(taskletStep("accountingEntryGenerate", step9, jobRepository, tx))
.next(taskletStep("yearEndIncomeAggregate", step10, jobRepository, tx))
.build();
}
@@ -0,0 +1,73 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.core.mapper.accounting.ContractAccountingEntryMapper;
import com.ga.core.vo.accounting.ContractAccountingEntryVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.configuration.annotation.JobScope;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Step 8b — accountingEntryGenerate: 회계 자동분개
*
* 정산월 기준 recruit_ledger + maintain_ledger → contract_accounting_entry INSERT.
* - 차변(8350 지급수수료) / 대변(2530 미지급금) 한 쌍을 단일 행으로 저장.
* - journal_no는 NULL(미전기). 마감 UI에서 채번.
* - 이미 분개된 payment는 existsByPaymentId로 중복 방어.
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
public class AccountingEntryGenerateStep implements Tasklet {
private final SettlementContext ctx;
private final ContractAccountingEntryMapper accountingMapper;
private static final int BATCH_SIZE = 500;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
String settleMonth = ctx.getSettleMonth();
log.info("[AccountingEntry] settleMonth={}", settleMonth);
List<ContractAccountingEntryVO> source = accountingMapper.selectBySettleMonth(settleMonth);
if (source.isEmpty()) {
log.info("[AccountingEntry] no ledger records for month={}", settleMonth);
return RepeatStatus.FINISHED;
}
LocalDate today = LocalDate.now();
List<ContractAccountingEntryVO> batch = new ArrayList<>(BATCH_SIZE);
int total = 0;
for (ContractAccountingEntryVO entry : source) {
if (entry.getAmount() == null || entry.getAmount().signum() == 0) continue;
entry.setEntryDate(today);
entry.setJournalNo(null);
batch.add(entry);
if (batch.size() >= BATCH_SIZE) {
accountingMapper.insertBatch(batch);
total += batch.size();
batch.clear();
}
}
if (!batch.isEmpty()) {
accountingMapper.insertBatch(batch);
total += batch.size();
}
log.info("[AccountingEntry] inserted {} entries for month={}", total, settleMonth);
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,58 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.core.mapper.income.AgentAnnualIncomeMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.configuration.annotation.JobScope;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
/**
* Step 9 (conditional) — yearEndIncomeAggregate: 연말 사업소득 집계
*
* 12월 마감 시에만 실행 (settleMonth ends with "-12" 또는 "12").
* payment 테이블 기준으로 해당 연도 설계사별 SUM → agent_annual_income UPSERT.
* SQL은 AgentAnnualIncomeMapper.upsertAggregate에 위임.
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
public class YearEndIncomeAggregateStep implements Tasklet {
private final SettlementContext ctx;
private final AgentAnnualIncomeMapper incomeMapper;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
String settleMonth = ctx.getSettleMonth();
if (!isDecemberClose(settleMonth)) {
log.info("[YearEndIncome] skipped — not a December close: {}", settleMonth);
return RepeatStatus.FINISHED;
}
int year = extractYear(settleMonth);
log.info("[YearEndIncome] aggregating income for year={}", year);
int rows = incomeMapper.upsertAggregate(year);
log.info("[YearEndIncome] agent_annual_income upserted: {} rows", rows);
return RepeatStatus.FINISHED;
}
private boolean isDecemberClose(String settleMonth) {
if (settleMonth == null) return false;
// 형식: yyyyMM (예: 202512) 또는 yyyy-MM
return settleMonth.endsWith("12");
}
private int extractYear(String settleMonth) {
// yyyyMM → 앞 4자리
String digits = settleMonth.replaceAll("[^0-9]", "");
return Integer.parseInt(digits.substring(0, 4));
}
}
@@ -1,7 +1,7 @@
# 실제 운영 PostgreSQL 연결용 프로파일 (ga-batch)
spring:
datasource:
url: jdbc:postgresql://125.241.236.203:55432/trading_ai?currentSchema=ga
url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga
username: ${DB_USER:kyu}
password: ${DB_PASSWORD:7895123}
driver-class-name: org.postgresql.Driver
@@ -0,0 +1,56 @@
package com.ga.common.code;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "계정코드")
@RestController
@RequestMapping("/api/common/account-codes")
@RequiredArgsConstructor
public class AccountCodeController {
private final AccountCodeService service;
/** 활성 계정코드 목록 — 드롭다운/배치 참조용. accountType 파라미터로 DEBIT/CREDIT/BOTH 필터 가능 */
@GetMapping
public ApiResponse<List<AccountCodeVO>> getActiveCodes(
@RequestParam(required = false) String accountType) {
return ApiResponse.ok(service.getActiveCodes(accountType));
}
// ----- 시스템관리 -----
@GetMapping("/all")
@RequirePermission(menu = "SYSTEM_CODE", perm = "READ")
public ApiResponse<List<AccountCodeVO>> listAll(
@RequestParam(required = false) String accountType) {
return ApiResponse.ok(service.listAll(accountType));
}
@PostMapping
@RequirePermission(menu = "SYSTEM_CODE", perm = "CREATE")
public ApiResponse<Void> create(@RequestBody AccountCodeVO vo) {
service.create(vo);
return ApiResponse.ok();
}
@PutMapping("/{accountCode}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE")
public ApiResponse<Void> update(@PathVariable String accountCode, @RequestBody AccountCodeVO vo) {
vo.setAccountCode(accountCode);
service.update(vo);
return ApiResponse.ok();
}
@DeleteMapping("/{accountCode}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "DELETE")
public ApiResponse<Void> delete(@PathVariable String accountCode) {
service.delete(accountCode);
return ApiResponse.ok();
}
}
@@ -0,0 +1,18 @@
package com.ga.common.code;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AccountCodeMapper {
List<AccountCodeVO> selectAll(@Param("accountType") String accountType);
List<AccountCodeVO> selectActive(@Param("accountType") String accountType);
AccountCodeVO selectOne(@Param("accountCode") String accountCode);
int insert(AccountCodeVO vo);
int update(AccountCodeVO vo);
int delete(@Param("accountCode") String accountCode);
int existsByCode(@Param("accountCode") String accountCode);
}
@@ -0,0 +1,50 @@
package com.ga.common.code;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AccountCodeService {
private final AccountCodeMapper mapper;
public List<AccountCodeVO> getActiveCodes(String accountType) {
return mapper.selectActive(accountType);
}
public AccountCodeVO getCode(String accountCode) {
AccountCodeVO vo = mapper.selectOne(accountCode);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
return vo;
}
public List<AccountCodeVO> listAll(String accountType) {
return mapper.selectAll(accountType);
}
@Transactional
public void create(AccountCodeVO vo) {
if (mapper.existsByCode(vo.getAccountCode()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insert(vo);
}
@Transactional
public void update(AccountCodeVO vo) {
getCode(vo.getAccountCode());
mapper.update(vo);
}
@Transactional
public void delete(String accountCode) {
getCode(accountCode);
mapper.delete(accountCode);
}
}
@@ -0,0 +1,19 @@
package com.ga.common.code;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class AccountCodeVO {
private String accountCode;
private String accountName;
private String accountType; // DEBIT / CREDIT / BOTH
private String isActive;
private Integer sortOrder;
private String description;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,25 @@
-- V63: 설계사 연말 사업소득 집계 테이블
CREATE TABLE agent_annual_income (
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_year INT NOT NULL,
total_commission NUMERIC(18,2) NOT NULL DEFAULT 0,
total_tax_withheld NUMERIC(18,2) NOT NULL DEFAULT 0,
total_local_tax NUMERIC(18,2) NOT NULL DEFAULT 0,
business_income NUMERIC(18,2) NOT NULL DEFAULT 0,
generated_at TIMESTAMP NOT NULL DEFAULT now(),
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP,
PRIMARY KEY (agent_id, settle_year)
);
COMMENT ON TABLE agent_annual_income IS '설계사 연말 사업소득 집계';
COMMENT ON COLUMN agent_annual_income.business_income IS 'total_commission - total_tax_withheld - total_local_tax';
CREATE INDEX idx_aai_year ON agent_annual_income(settle_year);
-- EXPLAIN ANALYZE: settle_year 단독 집계 — idx_aai_year Index Scan 확인
-- EXPLAIN ANALYZE
-- SELECT settle_year, SUM(business_income)
-- FROM agent_annual_income
-- GROUP BY settle_year;
@@ -0,0 +1,24 @@
-- V64: 계약 회계분개 엔트리 테이블
CREATE TABLE contract_accounting_entry (
entry_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
payment_id BIGINT REFERENCES payment(payment_id),
entry_date DATE NOT NULL,
debit_account VARCHAR(20) NOT NULL,
credit_account VARCHAR(20) NOT NULL,
amount NUMERIC(18,2) NOT NULL,
description VARCHAR(200),
journal_no VARCHAR(30),
created_at TIMESTAMP NOT NULL DEFAULT now()
);
COMMENT ON TABLE contract_accounting_entry IS '계약 회계 분개 엔트리';
COMMENT ON COLUMN contract_accounting_entry.debit_account IS '차변 계정코드 (예: 8350 지급수수료)';
COMMENT ON COLUMN contract_accounting_entry.credit_account IS '대변 계정코드 (예: 2530 미지급금)';
COMMENT ON COLUMN contract_accounting_entry.journal_no IS '마감 시 채번. NULL=미전기';
CREATE INDEX idx_cae_journal ON contract_accounting_entry(journal_no);
CREATE INDEX idx_cae_date ON contract_accounting_entry(entry_date);
CREATE INDEX idx_cae_contract ON contract_accounting_entry(contract_id, entry_date);
CREATE INDEX idx_cae_unposted ON contract_accounting_entry(entry_date) WHERE journal_no IS NULL;
@@ -0,0 +1,122 @@
-- V65: P5-W1 테스트 데이터 (agent_annual_income, contract_accounting_entry, 메뉴)
-- ============================================================
-- 1. agent_annual_income: 설계사 10명 / 2025년
-- ============================================================
INSERT INTO agent_annual_income (agent_id, settle_year, total_commission, total_tax_withheld, total_local_tax, business_income)
SELECT
a.agent_id,
2025,
v.total_commission,
v.total_commission * 0.033,
v.total_commission * 0.033 * 0.1,
v.total_commission - (v.total_commission * 0.033) - (v.total_commission * 0.033 * 0.1)
FROM (
SELECT agent_id, ROW_NUMBER() OVER (ORDER BY agent_id) AS rn
FROM agent
LIMIT 10
) a
JOIN (VALUES
(1, 42000000.00),
(2, 38500000.00),
(3, 55000000.00),
(4, 29800000.00),
(5, 61200000.00),
(6, 47300000.00),
(7, 33600000.00),
(8, 52100000.00),
(9, 28400000.00),
(10, 44700000.00)
) AS v(rn, total_commission) ON a.rn = v.rn
ON CONFLICT (agent_id, settle_year) DO NOTHING;
-- ============================================================
-- 2. contract_accounting_entry: payment 10건 기반
-- journal_no NULL 5건 (미전기) + 채번 5건 (전기완료)
-- ============================================================
INSERT INTO contract_accounting_entry (contract_id, payment_id, entry_date, debit_account, credit_account, amount, description, journal_no)
SELECT
c.contract_id,
p.payment_id,
COALESCE(p.pay_date, CURRENT_DATE),
'8350',
'2530',
p.pay_amount,
'지급수수료 자동분개 #' || p.payment_id,
CASE WHEN p.payment_id % 2 = 0 THEN 'JNL-2025-' || LPAD(p.payment_id::text, 5, '0') ELSE NULL END
FROM (
SELECT py.payment_id, py.pay_amount, py.pay_date, sm.agent_id
FROM payment py
JOIN settle_master sm ON sm.settle_id = py.settle_id
ORDER BY py.payment_id
LIMIT 10
) p
JOIN LATERAL (
SELECT c.contract_id
FROM contract c
WHERE c.agent_id = p.agent_id
ORDER BY c.contract_id
LIMIT 1
) c ON true;
-- ============================================================
-- 3. 메뉴: ANNUAL_INCOME + ACCOUNTING_JOURNAL (GRP_COMMISSION 하위)
-- ============================================================
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active)
SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y'
FROM menu p
JOIN (VALUES
('ANNUAL_INCOME', '연말 사업소득 명세서', '/commission/annual-income', 'commission/AnnualIncome', 80),
('ACCOUNTING_JOURNAL', '회계 분개장', '/commission/accounting-journal', 'commission/AccountingJournal', 90)
) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_COMMISSION'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 4. 권한 항목 등록
-- ============================================================
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT m.menu_id, p.code, p.name, p.sort
FROM menu m
CROSS JOIN (VALUES
('READ', '조회', 10),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code IN ('ANNUAL_INCOME', 'ACCOUNTING_JOURNAL')
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 5. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN: 전체
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code IN ('ANNUAL_INCOME', 'ACCOUNTING_JOURNAL')
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER: READ + EXPORT
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code = 'MANAGER'
AND mp.perm_code IN ('READ', 'EXPORT')
AND m.menu_code IN ('ANNUAL_INCOME', 'ACCOUNTING_JOURNAL')
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- AGENT: READ (본인 연말명세서)
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code = 'AGENT'
AND mp.perm_code = 'READ'
AND m.menu_code = 'ANNUAL_INCOME'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,26 @@
-- V66: 회계 계정코드 마스터 신설
-- contract_accounting_entry.debit_account / credit_account 참조용
-- FK 추가는 PL 별도 논의 후 진행
CREATE TABLE account_code (
account_code VARCHAR(20) PRIMARY KEY,
account_name VARCHAR(100) NOT NULL,
account_type VARCHAR(10) NOT NULL CHECK (account_type IN ('DEBIT','CREDIT','BOTH')),
is_active CHAR(1) NOT NULL DEFAULT 'Y',
sort_order INTEGER NOT NULL DEFAULT 0,
description VARCHAR(200),
created_at TIMESTAMP NOT NULL DEFAULT now(),
created_by BIGINT,
updated_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by BIGINT
);
COMMENT ON TABLE account_code IS '회계 계정코드 마스터';
COMMENT ON COLUMN account_code.account_type IS 'DEBIT=차변전용, CREDIT=대변전용, BOTH=겸용';
INSERT INTO account_code (account_code, account_name, account_type, sort_order) VALUES
('8350', '지급수수료', 'DEBIT', 10),
('2530', '미지급금', 'CREDIT', 20),
('1130', '미수수수료', 'DEBIT', 30),
('4110', '수수료수익', 'CREDIT', 40)
ON CONFLICT (account_code) DO NOTHING;
@@ -0,0 +1,23 @@
-- V67: 원천세 분기신고 테이블
CREATE TABLE withholding_tax_report (
report_id BIGSERIAL PRIMARY KEY,
settle_quarter VARCHAR(7) NOT NULL,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
total_income NUMERIC(18,2),
total_withheld NUMERIC(18,2),
total_local_tax NUMERIC(18,2),
generated_at TIMESTAMP,
filed_at TIMESTAMP,
file_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP,
UNIQUE (agent_id, settle_quarter),
CONSTRAINT chk_wtr_status CHECK (file_status IN ('DRAFT','FILED','AMENDED'))
);
COMMENT ON TABLE withholding_tax_report IS '원천세 분기신고';
COMMENT ON COLUMN withholding_tax_report.settle_quarter IS '정산 분기 (예: 2025-Q1)';
COMMENT ON COLUMN withholding_tax_report.file_status IS 'DRAFT=작성중, FILED=신고완료, AMENDED=수정신고';
CREATE INDEX idx_wtr_quarter ON withholding_tax_report(settle_quarter);
@@ -0,0 +1,22 @@
-- V68: 부가세 분기신고 테이블
CREATE TABLE vat_report (
vat_id BIGSERIAL PRIMARY KEY,
settle_quarter VARCHAR(7) NOT NULL,
total_sales NUMERIC(18,2),
total_purchase NUMERIC(18,2),
total_vat_payable NUMERIC(18,2),
tax_invoice_count INT,
generated_at TIMESTAMP,
filed_at TIMESTAMP,
file_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP,
UNIQUE (settle_quarter),
CONSTRAINT chk_vat_status CHECK (file_status IN ('DRAFT','FILED','AMENDED'))
);
COMMENT ON TABLE vat_report IS '부가세 분기신고';
COMMENT ON COLUMN vat_report.settle_quarter IS '정산 분기 (예: 2025-Q1)';
COMMENT ON COLUMN vat_report.total_vat_payable IS '납부 부가세 = total_sales * 0.1 - 매입세액';
COMMENT ON COLUMN vat_report.file_status IS 'DRAFT=작성중, FILED=신고완료, AMENDED=수정신고';
@@ -0,0 +1,87 @@
-- V69: P5-W2 테스트 데이터 (withholding_tax_report, vat_report, 메뉴)
-- ============================================================
-- 1. withholding_tax_report: agent 5명 × 2025 Q1~Q4 = 20건
-- ============================================================
INSERT INTO withholding_tax_report (settle_quarter, agent_id, total_income, total_withheld, total_local_tax, generated_at, file_status)
SELECT
q.quarter,
a.agent_id,
q.base_income,
q.base_income * 0.033,
q.base_income * 0.033 * 0.1,
now(),
CASE WHEN q.quarter IN ('2025-Q1','2025-Q2') THEN 'FILED' ELSE 'DRAFT' END
FROM (
SELECT agent_id, ROW_NUMBER() OVER (ORDER BY agent_id) AS rn
FROM agent LIMIT 5
) a
CROSS JOIN (VALUES
('2025-Q1', 10500000.00),
('2025-Q2', 11200000.00),
('2025-Q3', 10800000.00),
('2025-Q4', 12300000.00)
) AS q(quarter, base_income)
ON CONFLICT (agent_id, settle_quarter) DO NOTHING;
-- ============================================================
-- 2. vat_report: 2025 Q1~Q4 = 4건
-- ============================================================
INSERT INTO vat_report (settle_quarter, total_sales, total_purchase, total_vat_payable, tax_invoice_count, generated_at, file_status)
VALUES
('2025-Q1', 850000000.00, 120000000.00, 73000000.00, 142, now(), 'FILED'),
('2025-Q2', 920000000.00, 135000000.00, 78500000.00, 158, now(), 'FILED'),
('2025-Q3', 880000000.00, 128000000.00, 75200000.00, 151, now(), 'DRAFT'),
('2025-Q4', 960000000.00, 140000000.00, 82000000.00, 167, now(), 'DRAFT')
ON CONFLICT (settle_quarter) DO NOTHING;
-- ============================================================
-- 3. 메뉴: WITHHOLDING_TAX + VAT_REPORT (GRP_COMMISSION 하위)
-- ============================================================
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active)
SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y'
FROM menu p
JOIN (VALUES
('WITHHOLDING_TAX', '원천세 신고', '/commission/withholding-tax', 'commission/WithholdingTax', 100),
('VAT_REPORT', '부가세 신고', '/commission/vat-report', 'commission/VatReport', 110)
) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_COMMISSION'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 4. 권한 항목 등록
-- ============================================================
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT m.menu_id, p.code, p.name, p.sort
FROM menu m
CROSS JOIN (VALUES
('READ', '조회', 10),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code IN ('WITHHOLDING_TAX', 'VAT_REPORT')
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 5. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN: 전체
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code IN ('WITHHOLDING_TAX', 'VAT_REPORT')
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER: READ + EXPORT
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code = 'MANAGER'
AND mp.perm_code IN ('READ', 'EXPORT')
AND m.menu_code IN ('WITHHOLDING_TAX', 'VAT_REPORT')
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,29 @@
-- V70: rule 도메인 5개 테이블 감사 컬럼 보강
-- commission_rate / payout_rule: created_at/created_by 존재, updated_at/updated_by 누락
-- override_rule / chargeback_rule / exception_type_code: 감사 컬럼 전체 누락
ALTER TABLE commission_rate
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_by BIGINT;
ALTER TABLE payout_rule
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_by BIGINT;
ALTER TABLE override_rule
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS created_by BIGINT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_by BIGINT;
ALTER TABLE chargeback_rule
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS created_by BIGINT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_by BIGINT;
ALTER TABLE exception_type_code
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS created_by BIGINT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_by BIGINT;
@@ -0,0 +1,13 @@
-- V71: payment 테이블 세무 컬럼 누락 보강
-- V23 세무처리.sql 체크섬 미스매치로 income_tax_amount 등 미적용 → idempotent 추가
ALTER TABLE payment
ADD COLUMN IF NOT EXISTS income_tax_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS local_tax_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS net_amount NUMERIC(15,2);
COMMENT ON COLUMN payment.income_tax_amount IS '원천세 (소득세 3%)';
COMMENT ON COLUMN payment.local_tax_amount IS '지방소득세 (소득세의 10%)';
COMMENT ON COLUMN payment.vat_amount IS '부가세';
COMMENT ON COLUMN payment.net_amount IS '실수령액 (pay_amount - income_tax - local_tax)';
@@ -0,0 +1,29 @@
-- V72: P5-W1 ANNUAL_INCOME 메뉴 EXECUTE 권한 누락 보강
-- V65에서 READ/CREATE/UPDATE/EXPORT만 등록되어 regenerate 엔드포인트 403 발생
-- SUPER_ADMIN / ADMIN 역할에 EXECUTE 권한 부여
-- 1. menu_permission에 EXECUTE 항목 추가
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT m.menu_id, 'EXECUTE', '재집계', 70
FROM menu m
WHERE m.menu_code = 'ANNUAL_INCOME'
AND NOT EXISTS (
SELECT 1 FROM menu_permission mp2
WHERE mp2.menu_id = m.menu_id AND mp2.perm_code = 'EXECUTE'
);
-- 2. SUPER_ADMIN / ADMIN에 EXECUTE 권한 부여
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND mp.perm_code = 'EXECUTE'
AND m.menu_code = 'ANNUAL_INCOME'
AND NOT EXISTS (
SELECT 1 FROM role_menu_permission rmp2
WHERE rmp2.role_id = r.role_id
AND rmp2.menu_id = mp.menu_id
AND rmp2.perm_code = 'EXECUTE'
);
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.common.code.AccountCodeMapper">
<resultMap id="AccountCodeMap" type="com.ga.common.code.AccountCodeVO"/>
<sql id="Columns">
account_code, account_name, account_type, is_active, sort_order, description,
created_at, created_by, updated_at, updated_by
</sql>
<select id="selectAll" resultMap="AccountCodeMap">
SELECT <include refid="Columns"/>
FROM account_code
<where>
<if test="accountType != null and accountType != ''">
AND account_type = #{accountType}
</if>
</where>
ORDER BY sort_order, account_code
</select>
<select id="selectActive" resultMap="AccountCodeMap">
SELECT <include refid="Columns"/>
FROM account_code
WHERE is_active = 'Y'
<if test="accountType != null and accountType != ''">
AND account_type = #{accountType}
</if>
ORDER BY sort_order, account_code
</select>
<select id="selectOne" resultMap="AccountCodeMap">
SELECT <include refid="Columns"/>
FROM account_code WHERE account_code = #{accountCode}
</select>
<insert id="insert" parameterType="com.ga.common.code.AccountCodeVO">
INSERT INTO account_code (
account_code, account_name, account_type, is_active, sort_order, description,
created_at, created_by
) VALUES (
#{accountCode}, #{accountName}, #{accountType},
COALESCE(#{isActive},'Y'), COALESCE(#{sortOrder},0), #{description},
now(), #{createdBy}
)
</insert>
<update id="update" parameterType="com.ga.common.code.AccountCodeVO">
UPDATE account_code SET
account_name = #{accountName},
account_type = #{accountType},
is_active = #{isActive},
sort_order = #{sortOrder},
description = #{description},
updated_at = now(),
updated_by = #{updatedBy}
WHERE account_code = #{accountCode}
</update>
<delete id="delete">
DELETE FROM account_code WHERE account_code = #{accountCode}
</delete>
<select id="existsByCode" resultType="int">
SELECT COUNT(*) FROM account_code WHERE account_code = #{accountCode}
</select>
</mapper>
@@ -0,0 +1,36 @@
package com.ga.core.mapper.accounting;
import com.ga.core.vo.accounting.ContractAccountingEntryResp;
import com.ga.core.vo.accounting.ContractAccountingEntrySearchParam;
import com.ga.core.vo.accounting.ContractAccountingEntryVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ContractAccountingEntryMapper {
/** 목록 조회 — contractId / paymentId / entryDate 범위 / unpostedOnly 조건 */
List<ContractAccountingEntryResp> selectList(ContractAccountingEntrySearchParam param);
/** 단건 상세 */
ContractAccountingEntryResp selectDetailById(@Param("entryId") Long entryId);
/** 배치 step용: 정산월 기준 분개 대상 원장 조회 (recruit+maintain ledger UNION) */
List<ContractAccountingEntryVO> selectBySettleMonth(@Param("settleMonth") String settleMonth);
/**
* 분개 엔트리 일괄 INSERT.
* 회계 자동분개 배치 step에서 호출.
*/
int insertBatch(@Param("list") List<ContractAccountingEntryVO> list);
/**
* 미전기 엔트리 마감 처리 — journalNo 채번 후 UPDATE.
* @param ids 마감할 entry_id 목록
* @param journalNo 채번된 분개장 번호
*/
int closeJournal(@Param("ids") List<Long> ids,
@Param("journalNo") String journalNo);
}
@@ -0,0 +1,25 @@
package com.ga.core.mapper.income;
import com.ga.core.vo.income.AgentAnnualIncomeResp;
import com.ga.core.vo.income.AgentAnnualIncomeSearchParam;
import com.ga.core.vo.income.AgentAnnualIncomeVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AgentAnnualIncomeMapper {
/** 목록 조회 — settleYear / agentId / orgId 조건 */
List<AgentAnnualIncomeResp> selectList(AgentAnnualIncomeSearchParam param);
/** 단건 상세 (복합키: agentId + settleYear) */
AgentAnnualIncomeResp selectDetailById(@Param("agentId") Long agentId,
@Param("settleYear") int settleYear);
/**
* 연도 전체 UPSERT 집계 (전체 설계사).
*/
int upsertAggregate(@Param("year") int year);
}
@@ -0,0 +1,28 @@
package com.ga.core.mapper.tax;
import com.ga.core.vo.tax.WithholdingTaxReportResp;
import com.ga.core.vo.tax.WithholdingTaxReportSearchParam;
import com.ga.core.vo.tax.WithholdingTaxReportVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface WithholdingTaxReportMapper {
/** 목록 조회 — settleQuarter / agentId / orgId / fileStatus 조건 */
List<WithholdingTaxReportResp> selectList(WithholdingTaxReportSearchParam param);
/** 단건 상세 */
WithholdingTaxReportResp selectDetailById(@Param("reportId") Long reportId);
/**
* 분기 전체 UPSERT 집계.
* payment 테이블 기준 해당 분기 설계사별 SUM 후 INSERT … ON CONFLICT DO UPDATE.
*
* @param quarter 집계할 분기 (예: 2025-Q1)
* @return 처리된 행 수
*/
int upsertQuarterlyAggregate(@Param("quarter") String quarter);
}
@@ -0,0 +1,23 @@
package com.ga.core.vo.accounting;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 분개장 엑셀 다운로드 전용 VO
*/
@Data
public class ContractAccountingEntryExcelVO {
private String journalNo;
private LocalDate entryDate;
private String contractNo;
private String agentName;
private String debitAccount;
private String creditAccount;
private BigDecimal amount;
private String description;
private LocalDateTime createdAt;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.accounting;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 분개 엔트리 목록/상세 API 응답 — contract, agent 조인 포함
*/
@Data
public class ContractAccountingEntryResp {
private Long entryId;
private Long contractId;
private String contractNo;
private Long paymentId;
private Long agentId;
private String agentName;
private LocalDate entryDate;
private String debitAccount;
private String creditAccount;
private BigDecimal amount;
private String description;
/** NULL이면 미전기 상태 */
private String journalNo;
private LocalDateTime createdAt;
}
@@ -0,0 +1,29 @@
package com.ga.core.vo.accounting;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 분개 엔트리 수동 등록 요청 — 자동 분개 외 수기 입력용
*/
@Data
public class ContractAccountingEntrySaveReq {
@NotNull
private Long contractId;
private Long paymentId;
@NotNull
private LocalDate entryDate;
@NotBlank
private String debitAccount;
@NotBlank
private String creditAccount;
@NotNull
@DecimalMin("0.01")
private BigDecimal amount;
private String description;
}
@@ -0,0 +1,20 @@
package com.ga.core.vo.accounting;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
@Data
@EqualsAndHashCode(callSuper = true)
public class ContractAccountingEntrySearchParam extends SearchParam {
private Long contractId;
private Long paymentId;
private LocalDate entryDateFrom;
private LocalDate entryDateTo;
/** null이면 전체, ""이면 미전기(journalNo IS NULL) */
private String journalNo;
/** 미전기만 조회 플래그 */
private Boolean unpostedOnly;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.accounting;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* contract_accounting_entry — 계약 회계 분개 엔트리 (V64)
* INSERT/insertBatch/closeJournal 전용. 조인 필드 없음.
*/
@Data
public class ContractAccountingEntryVO {
private Long entryId;
private Long contractId;
private Long paymentId;
private LocalDate entryDate;
/** 차변 계정코드 (예: 8350 지급수수료) */
private String debitAccount;
/** 대변 계정코드 (예: 2530 미지급금) */
private String creditAccount;
private BigDecimal amount;
private String description;
/** 마감 시 채번. NULL=미전기 */
private String journalNo;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.ga.core.vo.income;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 연말 사업소득 집계 엑셀 다운로드 전용 VO
*/
@Data
public class AgentAnnualIncomeExcelVO {
private String agentName;
private String orgName;
private Integer settleYear;
private BigDecimal totalCommission;
private BigDecimal totalTaxWithheld;
private BigDecimal totalLocalTax;
private BigDecimal businessIncome;
private LocalDateTime generatedAt;
}
@@ -0,0 +1,25 @@
package com.ga.core.vo.income;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 연간소득 목록/상세 API 응답 — agent, organization 조인 포함
*/
@Data
public class AgentAnnualIncomeResp {
private Long agentId;
private String agentName;
private Long orgId;
private String orgName;
private Integer settleYear;
private BigDecimal totalCommission;
private BigDecimal totalTaxWithheld;
private BigDecimal totalLocalTax;
private BigDecimal businessIncome;
private LocalDateTime generatedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.income;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
/**
* 연간소득 재집계 트리거 요청 — 연도와 대상 설계사(선택)만 받음
*/
@Data
public class AgentAnnualIncomeSaveReq {
/** null이면 전체 설계사 집계 */
private Long agentId;
@NotNull
@Positive
private Integer settleYear;
}
@@ -0,0 +1,13 @@
package com.ga.core.vo.income;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class AgentAnnualIncomeSearchParam extends SearchParam {
private Long agentId;
private Integer settleYear;
private Long orgId;
}
@@ -0,0 +1,29 @@
package com.ga.core.vo.income;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* agent_annual_income — 설계사 연말 사업소득 집계 (V63)
* PK: (agent_id, settle_year) 복합키. INSERT/UPDATE 전용.
*/
@Data
public class AgentAnnualIncomeVO {
private Long agentId;
/** 정산 연도 (YYYY) */
private Integer settleYear;
/** 연간 총 수수료 합계 */
private BigDecimal totalCommission;
/** 원천징수 소득세 합계 */
private BigDecimal totalTaxWithheld;
/** 지방소득세 합계 */
private BigDecimal totalLocalTax;
/** 사업소득 = totalCommission - totalTaxWithheld - totalLocalTax */
private BigDecimal businessIncome;
/** 마지막 집계 실행 일시 */
private LocalDateTime generatedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -4,6 +4,7 @@ import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class ChargebackRuleVO {
@@ -18,4 +19,8 @@ public class ChargebackRuleVO {
private Integer maxInstallments;
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -19,4 +19,6 @@ public class CommissionRateVO {
private Integer version;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -2,6 +2,8 @@ package com.ga.core.vo.rule;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ExceptionTypeCodeVO {
private String exceptionCode;
@@ -13,4 +15,8 @@ public class ExceptionTypeCodeVO {
private String taxIncluded;
private String description;
private String isActive;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -4,6 +4,7 @@ import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class OverrideRuleVO {
@@ -16,4 +17,8 @@ public class OverrideRuleVO {
private BigDecimal capAmount;
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -20,4 +20,6 @@ public class PayoutRuleVO {
private Integer version;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,22 @@
package com.ga.core.vo.tax;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 원천세 분기신고 엑셀 다운로드 전용 VO
*/
@Data
public class WithholdingTaxReportExcelVO {
private String settleQuarter;
private String agentName;
private String orgName;
private BigDecimal totalIncome;
private BigDecimal totalWithheld;
private BigDecimal totalLocalTax;
private String fileStatus;
private LocalDateTime filedAt;
private LocalDateTime generatedAt;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.tax;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 원천세 분기신고 목록/상세 API 응답 — agent, organization 조인 포함
*/
@Data
public class WithholdingTaxReportResp {
private Long reportId;
private Long agentId;
private String agentName;
private Long orgId;
private String orgName;
private String settleQuarter;
private BigDecimal totalIncome;
private BigDecimal totalWithheld;
private BigDecimal totalLocalTax;
private LocalDateTime generatedAt;
private LocalDateTime filedAt;
private String fileStatus;
private String fileStatusName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,16 @@
package com.ga.core.vo.tax;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 원천세 분기신고 재집계 트리거 요청
*/
@Data
public class WithholdingTaxReportSaveReq {
/** null이면 전체 설계사 집계 */
private Long agentId;
@NotBlank
private String settleQuarter;
}
@@ -0,0 +1,15 @@
package com.ga.core.vo.tax;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class WithholdingTaxReportSearchParam extends SearchParam {
private String settleQuarter;
private Long agentId;
private Long orgId;
/** DRAFT / FILED / AMENDED */
private String fileStatus;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.tax;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* withholding_tax_report — 원천세 분기신고 (V67)
* INSERT/UPDATE 전용. 조인 필드 없음.
*/
@Data
public class WithholdingTaxReportVO {
private Long reportId;
private Long agentId;
/** 정산 분기 (예: 2025-Q1) */
private String settleQuarter;
private BigDecimal totalIncome;
private BigDecimal totalWithheld;
private BigDecimal totalLocalTax;
private LocalDateTime generatedAt;
private LocalDateTime filedAt;
/** DRAFT / FILED / AMENDED */
private String fileStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.accounting.ContractAccountingEntryMapper">
<!-- contract, agent 조인 포함 응답 매핑 -->
<resultMap id="RespMap" type="com.ga.core.vo.accounting.ContractAccountingEntryResp"/>
<!-- 목록/상세 공통 SELECT 컬럼 -->
<sql id="joinCols">
cae.entry_id,
cae.contract_id,
ac.contract_no,
cae.payment_id,
a.agent_id,
a.agent_name,
cae.entry_date,
cae.debit_account,
cae.credit_account,
cae.amount,
cae.description,
cae.journal_no,
cae.created_at
</sql>
<!-- 공통 FROM + JOIN -->
<sql id="joinFrom">
FROM contract_accounting_entry cae
JOIN contract ac ON ac.contract_id = cae.contract_id
JOIN agent a ON a.agent_id = ac.agent_id
</sql>
<!-- ===== 목록 조회 ===== -->
<select id="selectList" resultMap="RespMap">
SELECT <include refid="joinCols"/>
<include refid="joinFrom"/>
<where>
<if test="contractId != null">AND cae.contract_id = #{contractId}</if>
<if test="paymentId != null">AND cae.payment_id = #{paymentId}</if>
<if test="entryDateFrom != null">AND cae.entry_date &gt;= #{entryDateFrom}</if>
<if test="entryDateTo != null">AND cae.entry_date &lt;= #{entryDateTo}</if>
<if test="journalNo != null and journalNo != ''">AND cae.journal_no = #{journalNo}</if>
<!-- unpostedOnly=true이면 미전기(journal_no IS NULL)만 조회 -->
<if test="unpostedOnly != null and unpostedOnly == true">
AND cae.journal_no IS NULL
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (ac.contract_no LIKE CONCAT('%', #{searchKeyword}, '%')
OR a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
</where>
ORDER BY cae.entry_date DESC, cae.entry_id DESC
</select>
<!-- ===== 단건 상세 ===== -->
<select id="selectDetailById" resultMap="RespMap">
SELECT <include refid="joinCols"/>
<include refid="joinFrom"/>
WHERE cae.entry_id = #{entryId}
</select>
<!-- ===== 일괄 INSERT =====
회계 자동분개 배치 step에서 한 번에 다수 엔트리 저장.
foreach로 멀티 VALUES 구문 생성.
-->
<insert id="insertBatch" useGeneratedKeys="true" keyProperty="entryId">
INSERT INTO contract_accounting_entry
(contract_id, payment_id, entry_date,
debit_account, credit_account, amount, description)
VALUES
<foreach collection="list" item="e" separator=",">
(#{e.contractId}, #{e.paymentId}, #{e.entryDate},
#{e.debitAccount}, #{e.creditAccount}, #{e.amount}, #{e.description})
</foreach>
</insert>
<!-- ===== 마감 처리 (journalNo 채번) ===== -->
<update id="closeJournal">
UPDATE contract_accounting_entry
SET journal_no = #{journalNo}
WHERE entry_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
AND journal_no IS NULL
</update>
<!--
배치 step용: 정산월 기준 분개 대상 원장 조회.
recruit_ledger + maintain_ledger UNION → contract_id 포함.
AccountingEntryGenerateStep에서 호출.
-->
<select id="selectBySettleMonth" resultType="com.ga.core.vo.accounting.ContractAccountingEntryVO">
SELECT l.contract_id,
NULL::BIGINT AS payment_id,
CURRENT_DATE AS entry_date,
'8350' AS debit_account,
'2530' AS credit_account,
l.agent_amount AS amount,
CONCAT('모집수수료 ', l.settle_month) AS description,
NULL AS journal_no
FROM recruit_ledger l
WHERE l.settle_month = #{settleMonth}
AND l.agent_amount > 0
UNION ALL
SELECT l.contract_id,
NULL::BIGINT AS payment_id,
CURRENT_DATE AS entry_date,
'8350' AS debit_account,
'2530' AS credit_account,
l.agent_amount AS amount,
CONCAT('유지수수료 ', l.settle_month) AS description,
NULL AS journal_no
FROM maintain_ledger l
WHERE l.settle_month = #{settleMonth}
AND l.agent_amount > 0
</select>
</mapper>
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.income.AgentAnnualIncomeMapper">
<!-- agent 조인 포함 응답 매핑 -->
<resultMap id="RespMap" type="com.ga.core.vo.income.AgentAnnualIncomeResp"/>
<!-- 목록/상세에서 공통으로 사용하는 SELECT 컬럼 -->
<sql id="joinCols">
i.agent_id,
a.agent_name,
a.org_id,
o.org_name,
i.settle_year,
i.total_commission,
i.total_tax_withheld,
i.total_local_tax,
i.business_income,
i.generated_at,
i.created_at,
i.updated_at
</sql>
<!-- ===== 목록 조회 ===== -->
<select id="selectList" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM agent_annual_income i
JOIN agent a ON a.agent_id = i.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
<where>
<if test="settleYear != null">AND i.settle_year = #{settleYear}</if>
<if test="agentId != null">AND i.agent_id = #{agentId}</if>
<if test="orgId != null">AND a.org_id = #{orgId}</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%')
</if>
</where>
ORDER BY i.settle_year DESC, i.agent_id ASC
</select>
<select id="selectDetailById" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM agent_annual_income i
JOIN agent a ON a.agent_id = i.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
WHERE i.agent_id = #{agentId}
AND i.settle_year = #{settleYear}
</select>
<!-- 연도 전체 UPSERT 집계: payment 테이블 기준 설계사별 SUM 후 UPSERT -->
<insert id="upsertAggregate">
INSERT INTO agent_annual_income
(agent_id, settle_year,
total_commission, total_tax_withheld, total_local_tax, business_income,
generated_at)
SELECT
p.agent_id,
#{year} AS settle_year,
COALESCE(SUM(p.pay_amount), 0) AS total_commission,
COALESCE(SUM(p.income_tax_amount), 0) AS total_tax_withheld,
COALESCE(SUM(p.local_tax_amount), 0) AS total_local_tax,
COALESCE(SUM(p.pay_amount)
- SUM(p.income_tax_amount)
- SUM(p.local_tax_amount), 0) AS business_income,
NOW() AS generated_at
FROM payment p
WHERE EXTRACT(YEAR FROM p.pay_date) = #{year}
GROUP BY p.agent_id
ON CONFLICT (agent_id, settle_year) DO UPDATE SET
total_commission = EXCLUDED.total_commission,
total_tax_withheld = EXCLUDED.total_tax_withheld,
total_local_tax = EXCLUDED.total_local_tax,
business_income = EXCLUDED.business_income,
generated_at = NOW(),
updated_at = NOW()
</insert>
</mapper>
@@ -92,4 +92,51 @@
<foreach collection="paymentIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
<select id="selectBySettleMonth" resultMap="VOMap">
SELECT p.*
FROM payment p
JOIN settle_master s ON s.settle_id = p.settle_id
WHERE s.settle_month = #{settleMonth}
ORDER BY p.payment_id
</select>
<select id="selectByAgentAndMonth" resultMap="VOMap">
SELECT p.*
FROM payment p
JOIN settle_master s ON s.settle_id = p.settle_id
WHERE p.agent_id = #{agentId}
AND s.settle_month = #{settleMonth}
ORDER BY p.payment_id
</select>
<update id="updateTaxes">
UPDATE payment
SET income_tax_amount = #{incomeTaxAmount},
local_tax_amount = #{localTaxAmount},
vat_amount = #{vatAmount},
net_amount = #{netAmount},
updated_at = NOW()
WHERE payment_id = #{paymentId}
</update>
<update id="updateTaxesBatch">
<foreach collection="list" item="p" separator=";">
UPDATE payment
SET income_tax_amount = #{p.incomeTaxAmount},
local_tax_amount = #{p.localTaxAmount},
vat_amount = #{p.vatAmount},
net_amount = #{p.netAmount},
updated_at = NOW()
WHERE payment_id = #{p.paymentId}
</foreach>
</update>
<update id="updateDeduction">
UPDATE payment
SET deduction_amount = #{deductionAmount},
net_amount = #{netAmount},
updated_at = NOW()
WHERE payment_id = #{paymentId}
</update>
</mapper>
+16
View File
@@ -3,6 +3,14 @@ import LoginPage from '@/pages/LoginPage';
import MainLayout from '@/layouts/MainLayout';
import Dashboard from '@/pages/Dashboard';
// ── P5: 연말명세서 / 분개장 ──────────────────────────
import AgentAnnualIncomeList from '@/pages/income/AgentAnnualIncomeList';
import AccountingJournalList from '@/pages/accounting/AccountingJournalList';
// ── P5-W2: 원천세 / 부가세 신고 ──────────────────────
import WithholdingTaxReportList from '@/pages/tax/WithholdingTaxReportList';
import VatReportList from '@/pages/tax/VatReportList';
// ── P4: 수수료 종류 ──────────────────────────────────
import CommissionMaster from '@/pages/commission/CommissionMaster';
import CollectionCommission from '@/pages/commission/CollectionCommission';
@@ -146,6 +154,14 @@ export default function App() {
<Route path="kpi/retention" element={<KpiRetention />} />
<Route path="kpi/cumulative" element={<KpiCumulative />} />
{/* P5: 연말명세서 / 분개장 */}
<Route path="commission/annual-income" element={<AgentAnnualIncomeList />} />
<Route path="commission/accounting-journal" element={<AccountingJournalList />} />
{/* P5-W2: 원천세 / 부가세 신고 */}
<Route path="commission/withholding-tax" element={<WithholdingTaxReportList />} />
<Route path="commission/vat-report" element={<VatReportList />} />
{/* P4: 수수료 종류 */}
<Route path="commission/type" element={<CommissionMaster />} />
<Route path="commission/master" element={<CommissionMaster />} />
+48
View File
@@ -0,0 +1,48 @@
import api, { unwrap } from './request';
export interface AccountingJournalResp extends Record<string, unknown> {
entryId: number;
journalNo?: string;
agentId?: number;
agentName?: string;
contractNo?: string;
settleMonth?: string;
accountCode: string;
accountName?: string;
debitAmount: number;
creditAmount: number;
description?: string;
status?: string;
statusName?: string;
closedAt?: string;
createdAt?: string;
}
export interface AccountingGenerateReq {
settleMonth: string;
}
export interface AccountingCloseReq {
journalNo: string;
entryIds: number[];
}
export interface AccountingSearchParam {
settleMonth?: string;
journalNo?: string;
agentName?: string;
status?: string;
pageNum?: number;
pageSize?: number;
}
export const accountingApi = {
list: (params?: AccountingSearchParam) =>
unwrap<{ list: AccountingJournalResp[]; total: number }>(
api.get('/api/accounting-entries', { params })
),
generate: (req: AccountingGenerateReq) =>
unwrap<void>(api.post('/api/accounting-entries/generate', req)),
close: (req: AccountingCloseReq) =>
unwrap<void>(api.put('/api/accounting-entries/close', req)),
};
+44
View File
@@ -0,0 +1,44 @@
import api, { unwrap } from './request';
export interface AgentAnnualIncomeResp extends Record<string, unknown> {
incomeId: number;
agentId: number;
agentName?: string;
agentCode?: string;
orgName?: string;
settleYear: number;
totalCommission: number;
totalTaxWithheld: number;
businessIncome: number;
status?: string;
statusName?: string;
createdAt?: string;
updatedAt?: string;
}
export interface AgentAnnualIncomeSaveReq {
agentId: number;
settleYear: number;
totalCommission: number;
totalTaxWithheld: number;
businessIncome: number;
}
export interface AgentAnnualIncomeSearchParam {
settleYear?: number;
agentId?: number;
agentName?: string;
pageNum?: number;
pageSize?: number;
}
export const incomeApi = {
list: (params?: AgentAnnualIncomeSearchParam) =>
unwrap<{ list: AgentAnnualIncomeResp[]; total: number }>(
api.get('/api/agent-annual-incomes', { params })
),
detail: (id: number) =>
unwrap<AgentAnnualIncomeResp>(api.get(`/api/agent-annual-incomes/${id}`)),
regenerate: (year: number) =>
unwrap<void>(api.post(`/api/agent-annual-incomes/regenerate`, null, { params: { year } })),
};
+2
View File
@@ -40,4 +40,6 @@ export const regulatoryApi = {
unwrap<void>(api.put(`/api/regulatory-limits/${id}`, req)),
deactivate: (id: number) =>
unwrap<void>(api.put(`/api/regulatory-limits/${id}/deactivate`)),
activate: (id: number) =>
unwrap<void>(api.put(`/api/regulatory-limits/${id}/activate`)),
};
+47
View File
@@ -0,0 +1,47 @@
import api, { unwrap } from './request';
export interface VatReportResp extends Record<string, unknown> {
reportId: number;
reportYear: number;
reportQuarter: number;
salesAmount: number;
salesVat: number;
purchaseAmount: number;
purchaseVat: number;
payableVat: number;
status?: string;
statusName?: string;
reportedAt?: string;
createdAt?: string;
updatedAt?: string;
}
export interface VatSearchParam {
reportYear?: number;
reportQuarter?: number;
status?: string;
pageNum?: number;
pageSize?: number;
}
export interface VatRegenerateReq {
reportYear: number;
reportQuarter: number;
}
export interface VatReportCompleteReq {
ids: number[];
}
export const vatApi = {
list: (params?: VatSearchParam) =>
unwrap<{ list: VatReportResp[]; total: number }>(
api.get('/api/vat-reports', { params })
),
detail: (id: number) =>
unwrap<VatReportResp>(api.get(`/api/vat-reports/${id}`)),
regenerate: (req: VatRegenerateReq) =>
unwrap<void>(api.post('/api/vat-reports/regenerate', req)),
complete: (req: VatReportCompleteReq) =>
unwrap<void>(api.put('/api/vat-reports/complete', req)),
};
+50
View File
@@ -0,0 +1,50 @@
import api, { unwrap } from './request';
export interface WithholdingTaxReportResp extends Record<string, unknown> {
reportId: number;
agentId: number;
agentName?: string;
agentCode?: string;
orgName?: string;
reportYear: number;
reportQuarter: number;
totalIncome: number;
withholdingTax: number;
status?: string;
statusName?: string;
reportedAt?: string;
createdAt?: string;
updatedAt?: string;
}
export interface WithholdingTaxSearchParam {
reportYear?: number;
reportQuarter?: number;
agentId?: number;
agentName?: string;
status?: string;
pageNum?: number;
pageSize?: number;
}
export interface WithholdingTaxRegenerateReq {
reportYear: number;
reportQuarter: number;
}
export interface WithholdingTaxReportCompleteReq {
ids: number[];
}
export const withholdingTaxApi = {
list: (params?: WithholdingTaxSearchParam) =>
unwrap<{ list: WithholdingTaxReportResp[]; total: number }>(
api.get('/api/withholding-tax-reports', { params })
),
detail: (id: number) =>
unwrap<WithholdingTaxReportResp>(api.get(`/api/withholding-tax-reports/${id}`)),
regenerate: (req: WithholdingTaxRegenerateReq) =>
unwrap<void>(api.post('/api/withholding-tax-reports/regenerate', req)),
complete: (req: WithholdingTaxReportCompleteReq) =>
unwrap<void>(api.put('/api/withholding-tax-reports/complete', req)),
};
@@ -3,7 +3,7 @@ import { Skeleton } from 'antd';
import { AgGridReact } from '@ag-grid-community/react';
import { ModuleRegistry } from '@ag-grid-community/core';
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
import type { ColDef, GridReadyEvent, GridApi, RowClassParams, RowStyle } from '@ag-grid-community/core';
import type { ColDef, GridReadyEvent, GridApi, RowClassParams, RowStyle, SelectionChangedEvent } from '@ag-grid-community/core';
import '@ag-grid-community/styles/ag-grid.css';
import '@ag-grid-community/styles/ag-theme-quartz.css';
import { GRAY } from '@/theme/tokens';
@@ -33,6 +33,8 @@ interface Props<T> {
rowKey?: keyof T;
/** 행별 스타일 콜백 (만료 임박 강조 등) */
getRowStyle?: (params: RowClassParams<T>) => RowStyle | undefined;
rowSelection?: 'single' | 'multiple';
onSelectionChanged?: (e: SelectionChangedEvent<T>) => void;
}
/**
@@ -50,6 +52,8 @@ export default function DataGrid<T extends Record<string, unknown>>({
onCellChanged,
rowKey,
getRowStyle: getRowStyleProp,
rowSelection: rowSelectionProp = 'single',
onSelectionChanged,
}: Props<T>) {
const apiRef = useRef<GridApi<T> | null>(null);
@@ -93,7 +97,8 @@ export default function DataGrid<T extends Record<string, unknown>>({
rowHeight={56}
headerHeight={44}
localeText={AG_GRID_LOCALE_KO}
rowSelection="single"
rowSelection={rowSelectionProp}
onSelectionChanged={onSelectionChanged}
getRowId={rowKey ? (p) => String(p.data[rowKey]) : undefined}
pinnedBottomRowData={pinnedBottomRow ? [pinnedBottomRow as T] : undefined}
getRowStyle={(params) => {
@@ -0,0 +1,169 @@
import { useState, useCallback } from 'react';
import { Alert, Form, Input, Modal, Space, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef, SelectionChangedEvent } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import { accountingApi, AccountingJournalResp, AccountingSearchParam } from '@/api/accounting';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<AccountingJournalResp>[] = [
{ field: 'journalNo', headerName: '분개번호', width: 140, pinned: 'left', rowGroup: false },
{ field: 'settleMonth', headerName: '정산월', width: 100 },
{ field: 'agentName', headerName: '설계사', width: 120 },
{ field: 'contractNo', headerName: '계약번호', width: 140 },
{ field: 'accountCode', headerName: '계정코드', width: 110 },
{ field: 'accountName', headerName: '계정명', width: 150 },
{ field: 'debitAmount', headerName: '차변', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'creditAmount', headerName: '대변', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'description', headerName: '적요', flex: 1 },
{ field: 'statusName', headerName: '상태', width: 90 },
{ field: 'closedAt', headerName: '마감일시', width: 140 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function AccountingJournalList() {
const qc = useQueryClient();
const currentMonth = dayjs().format('YYYYMM');
const [params, setParams] = useState<AccountingSearchParam>({
settleMonth: currentMonth,
pageSize: 500,
});
const [selectedRows, setSelectedRows] = useState<AccountingJournalResp[]>([]);
const [closeModalOpen, setCloseModalOpen] = useState(false);
const [generateModalOpen, setGenerateModalOpen] = useState(false);
const [closeForm] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['accounting-journals', params],
queryFn: () => accountingApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const handleSelectionChanged = useCallback((e: SelectionChangedEvent<AccountingJournalResp>) => {
setSelectedRows(e.api.getSelectedRows());
}, []);
const handleGenerate = async () => {
try {
await accountingApi.generate({ settleMonth: params.settleMonth ?? currentMonth });
message.success('분개 생성 완료');
qc.invalidateQueries({ queryKey: ['accounting-journals'] });
setGenerateModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleClose = async () => {
const values = await closeForm.validateFields();
const ids = selectedRows.map((r) => r.entryId);
try {
await accountingApi.close({ journalNo: values.journalNo, entryIds: ids });
message.success('마감 완료');
qc.invalidateQueries({ queryKey: ['accounting-journals'] });
setCloseModalOpen(false);
closeForm.resetFields();
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
};
return (
<PageContainer
title="회계 분개장"
description="정산 회계 분개 엔트리 관리"
extra={
<Space>
<PermissionButton
menuCode="ACCOUNTING_JOURNAL"
permCode="CREATE"
onClick={() => setGenerateModalOpen(true)}
>
</PermissionButton>
<PermissionButton
menuCode="ACCOUNTING_JOURNAL"
permCode="APPROVE"
type="primary"
disabled={selectedRows.length === 0}
onClick={() => setCloseModalOpen(true)}
>
({selectedRows.length})
</PermissionButton>
</Space>
}
>
{isError && (
<Alert
type="warning"
showIcon
message="API 미응답"
description="/api/accounting-journals 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
{ type: 'text', name: 'journalNo', label: '분개번호', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
]}
onSearch={(v) => setParams({ ...v as AccountingSearchParam, pageSize: 500 })}
onReset={() => setParams({ settleMonth: currentMonth, pageSize: 500 })}
/>
<ProCard style={cardStyle}>
<DataGrid<AccountingJournalResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={620}
rowKey="entryId"
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
/>
</ProCard>
{/* 분개 생성 확인 모달 */}
<Modal
title="분개 생성"
open={generateModalOpen}
onOk={handleGenerate}
onCancel={() => setGenerateModalOpen(false)}
okText="생성"
cancelText="취소"
>
<p>{params.settleMonth} ?</p>
</Modal>
{/* 마감 처리 모달 */}
<Modal
title="분개 마감"
open={closeModalOpen}
onOk={handleClose}
onCancel={() => { setCloseModalOpen(false); closeForm.resetFields(); }}
okText="마감"
cancelText="취소"
>
<p> {selectedRows.length} .</p>
<Form form={closeForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="journalNo"
label="분개번호 (journal_no)"
rules={[{ required: true, message: '분개번호를 입력하세요' }]}
>
<Input placeholder="예: JNL-2024-001" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,115 @@
import { useState } from 'react';
import { Alert, Modal, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import { incomeApi, AgentAnnualIncomeResp, AgentAnnualIncomeSearchParam } from '@/api/income';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<AgentAnnualIncomeResp>[] = [
{ field: 'settleYear', headerName: '귀속연도', width: 100, pinned: 'left' },
{ field: 'agentCode', headerName: '설계사코드', width: 120 },
{ field: 'agentName', headerName: '설계사명', width: 120 },
{ field: 'orgName', headerName: '소속조직', width: 160 },
{ field: 'totalCommission', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'totalTaxWithheld', headerName: '원천징수세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'businessIncome', headerName: '사업소득', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'statusName', headerName: '상태', width: 90 },
{ field: 'updatedAt', headerName: '최종수정', width: 140 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function AgentAnnualIncomeList() {
const qc = useQueryClient();
const currentYear = dayjs().year();
const [params, setParams] = useState<AgentAnnualIncomeSearchParam>({
settleYear: currentYear,
pageSize: 500,
});
const { data, isLoading, isError } = useQuery({
queryKey: ['annual-income', params],
queryFn: () => incomeApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
agentName: '합계',
totalCommission: rows.reduce((s, r) => s + (r.totalCommission ?? 0), 0),
totalTaxWithheld: rows.reduce((s, r) => s + (r.totalTaxWithheld ?? 0), 0),
businessIncome: rows.reduce((s, r) => s + (r.businessIncome ?? 0), 0),
} as Partial<AgentAnnualIncomeResp> : undefined;
const handleRegenerate = () => {
Modal.confirm({
title: '연말 사업소득 재집계',
content: `${params.settleYear ?? currentYear}년 데이터를 재집계하시겠습니까? 기존 데이터가 갱신됩니다.`,
okText: '재집계',
cancelText: '취소',
onOk: async () => {
try {
await incomeApi.regenerate(params.settleYear ?? currentYear);
message.success('재집계 완료');
qc.invalidateQueries({ queryKey: ['annual-income'] });
} catch { /* request.ts 처리 */ }
},
});
};
return (
<PageContainer
title="연말 사업소득 명세서"
description="설계사별 귀속연도 사업소득 집계 현황"
extra={
<PermissionButton
menuCode="ANNUAL_INCOME"
permCode="APPROVE"
type="primary"
onClick={handleRegenerate}
>
</PermissionButton>
}
>
{isError && (
<Alert
type="warning"
showIcon
message="API 미응답"
description="/api/agent-annual-incomes 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<SearchForm
conditions={[
{ type: 'text', name: 'settleYear', label: '귀속연도', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
]}
onSearch={(v) => setParams({ ...v as AgentAnnualIncomeSearchParam, pageSize: 500 })}
onReset={() => setParams({ settleYear: currentYear, pageSize: 500 })}
/>
<ProCard style={cardStyle}>
<DataGrid<AgentAnnualIncomeResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={620}
rowKey="incomeId"
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}
@@ -59,6 +59,20 @@ export default function RegulatoryLimitList() {
});
};
const onActivate = (record: RegulatoryLimitResp) => {
Modal.confirm({
title: '규제 한도 활성화',
content: `[${record.limitTypeName ?? record.limitType}] 한도를 활성화하시겠습니까?`,
okText: '활성화',
cancelText: '취소',
onOk: async () => {
await regulatoryApi.activate(record.limitId);
message.success('활성화 완료');
actionRef.current?.reload();
},
});
};
const onSubmit = async (v: any) => {
const req: RegulatoryLimitSaveReq = {
...v,
@@ -108,9 +122,12 @@ export default function RegulatoryLimitList() {
render: (_, r) => [
<PermissionButton key="edit" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
r.isActive && (
r.isActive ? (
<PermissionButton key="deact" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link" danger
onClick={() => onDeactivate(r)}></PermissionButton>
) : (
<PermissionButton key="act" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link"
onClick={() => onActivate(r)}></PermissionButton>
),
],
},
+154
View File
@@ -0,0 +1,154 @@
import { useState, useCallback } from 'react';
import { Alert, Modal, Space, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef, SelectionChangedEvent } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import { vatApi, VatReportResp, VatSearchParam } from '@/api/vat';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<VatReportResp>[] = [
{ field: 'reportYear', headerName: '신고연도', width: 100, pinned: 'left' },
{ field: 'reportQuarter', headerName: '분기', width: 80, pinned: 'left', valueFormatter: (p) => `${p.value}Q` },
{ field: 'salesAmount', headerName: '매출액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'salesVat', headerName: '매출세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'purchaseAmount', headerName: '매입액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'purchaseVat', headerName: '매입세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'payableVat', headerName: '납부세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'statusName', headerName: '상태', width: 90 },
{ field: 'reportedAt', headerName: '신고일시', width: 140 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function VatReportList() {
const qc = useQueryClient();
const currentYear = dayjs().year();
const currentQuarter = Math.ceil((dayjs().month() + 1) / 3);
const [params, setParams] = useState<VatSearchParam>({
reportYear: currentYear,
reportQuarter: currentQuarter,
pageSize: 200,
});
const [selectedRows, setSelectedRows] = useState<VatReportResp[]>([]);
const { data, isLoading, isError } = useQuery({
queryKey: ['vat-reports', params],
queryFn: () => vatApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
reportYear: '합계' as unknown as number,
salesAmount: rows.reduce((s, r) => s + (r.salesAmount ?? 0), 0),
salesVat: rows.reduce((s, r) => s + (r.salesVat ?? 0), 0),
purchaseAmount: rows.reduce((s, r) => s + (r.purchaseAmount ?? 0), 0),
purchaseVat: rows.reduce((s, r) => s + (r.purchaseVat ?? 0), 0),
payableVat: rows.reduce((s, r) => s + (r.payableVat ?? 0), 0),
} as Partial<VatReportResp> : undefined;
const handleSelectionChanged = useCallback((e: SelectionChangedEvent<VatReportResp>) => {
setSelectedRows(e.api.getSelectedRows());
}, []);
const handleRegenerate = () => {
Modal.confirm({
title: '부가세 재집계',
content: `${params.reportYear}${params.reportQuarter}Q 데이터를 재집계하시겠습니까?`,
okText: '재집계',
cancelText: '취소',
onOk: async () => {
try {
await vatApi.regenerate({
reportYear: params.reportYear ?? currentYear,
reportQuarter: params.reportQuarter ?? currentQuarter,
});
message.success('재집계 완료');
qc.invalidateQueries({ queryKey: ['vat-reports'] });
} catch { /* request.ts 처리 */ }
},
});
};
const handleComplete = () => {
Modal.confirm({
title: '신고완료 처리',
content: `선택한 ${selectedRows.length}건을 신고완료 처리하시겠습니까?`,
okText: '신고완료',
cancelText: '취소',
onOk: async () => {
try {
// NOTE: ids 필드명은 T19 컨트롤러 확인 후 수정 필요
await vatApi.complete({ ids: selectedRows.map((r) => r.reportId) });
message.success('신고완료 처리됨');
qc.invalidateQueries({ queryKey: ['vat-reports'] });
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
},
});
};
return (
<PageContainer
title="부가세 신고 현황"
description="분기별 매출/매입/납부 부가세 집계 및 관리"
extra={
<Space>
<PermissionButton menuCode="VAT_REPORT" permCode="APPROVE" onClick={handleRegenerate}>
</PermissionButton>
<PermissionButton
menuCode="VAT_REPORT"
permCode="APPROVE"
type="primary"
disabled={selectedRows.length === 0}
onClick={handleComplete}
>
({selectedRows.length})
</PermissionButton>
</Space>
}
>
{isError && (
<Alert
type="warning"
showIcon
message="API 미응답"
description="/api/vat-reports 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<SearchForm
conditions={[
{ type: 'text', name: 'reportYear', label: '신고연도', span: 6 },
{ type: 'text', name: 'reportQuarter', label: '분기(1~4)', span: 6 },
]}
onSearch={(v) => setParams({ ...v as VatSearchParam, pageSize: 200 })}
onReset={() => setParams({ reportYear: currentYear, reportQuarter: currentQuarter, pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<VatReportResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={520}
rowKey="reportId"
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}
@@ -0,0 +1,156 @@
import { useState, useCallback } from 'react';
import { Alert, Modal, Space, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef, SelectionChangedEvent } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import {
withholdingTaxApi,
WithholdingTaxReportResp,
WithholdingTaxSearchParam,
} from '@/api/withholdingTax';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<WithholdingTaxReportResp>[] = [
{ field: 'reportYear', headerName: '신고연도', width: 100, pinned: 'left' },
{ field: 'reportQuarter', headerName: '분기', width: 80, pinned: 'left', valueFormatter: (p) => `${p.value}Q` },
{ field: 'agentCode', headerName: '설계사코드', width: 120 },
{ field: 'agentName', headerName: '설계사명', width: 120 },
{ field: 'orgName', headerName: '소속조직', width: 160 },
{ field: 'totalIncome', headerName: '지급총액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'withholdingTax', headerName: '원천징수세', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'statusName', headerName: '상태', width: 90 },
{ field: 'reportedAt', headerName: '신고일시', width: 140 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function WithholdingTaxReportList() {
const qc = useQueryClient();
const currentYear = dayjs().year();
const currentQuarter = Math.ceil((dayjs().month() + 1) / 3);
const [params, setParams] = useState<WithholdingTaxSearchParam>({
reportYear: currentYear,
reportQuarter: currentQuarter,
pageSize: 500,
});
const [selectedRows, setSelectedRows] = useState<WithholdingTaxReportResp[]>([]);
const { data, isLoading, isError } = useQuery({
queryKey: ['withholding-tax', params],
queryFn: () => withholdingTaxApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
agentName: '합계',
totalIncome: rows.reduce((s, r) => s + (r.totalIncome ?? 0), 0),
withholdingTax: rows.reduce((s, r) => s + (r.withholdingTax ?? 0), 0),
} as Partial<WithholdingTaxReportResp> : undefined;
const handleSelectionChanged = useCallback((e: SelectionChangedEvent<WithholdingTaxReportResp>) => {
setSelectedRows(e.api.getSelectedRows());
}, []);
const handleRegenerate = () => {
Modal.confirm({
title: '원천세 재집계',
content: `${params.reportYear}${params.reportQuarter}Q 데이터를 재집계하시겠습니까?`,
okText: '재집계',
cancelText: '취소',
onOk: async () => {
try {
await withholdingTaxApi.regenerate({
reportYear: params.reportYear ?? currentYear,
reportQuarter: params.reportQuarter ?? currentQuarter,
});
message.success('재집계 완료');
qc.invalidateQueries({ queryKey: ['withholding-tax'] });
} catch { /* request.ts 처리 */ }
},
});
};
const handleComplete = () => {
Modal.confirm({
title: '신고완료 처리',
content: `선택한 ${selectedRows.length}건을 신고완료 처리하시겠습니까?`,
okText: '신고완료',
cancelText: '취소',
onOk: async () => {
try {
// NOTE: ids 필드명은 T18 컨트롤러 확인 후 수정 필요
await withholdingTaxApi.complete({ ids: selectedRows.map((r) => r.reportId) });
message.success('신고완료 처리됨');
qc.invalidateQueries({ queryKey: ['withholding-tax'] });
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
},
});
};
return (
<PageContainer
title="원천세 신고 현황"
description="설계사별 분기 원천세 신고 집계 및 관리"
extra={
<Space>
<PermissionButton menuCode="WITHHOLDING_TAX" permCode="APPROVE" onClick={handleRegenerate}>
</PermissionButton>
<PermissionButton
menuCode="WITHHOLDING_TAX"
permCode="APPROVE"
type="primary"
disabled={selectedRows.length === 0}
onClick={handleComplete}
>
({selectedRows.length})
</PermissionButton>
</Space>
}
>
{isError && (
<Alert
type="warning"
showIcon
message="API 미응답"
description="/api/withholding-tax-reports 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<SearchForm
conditions={[
{ type: 'text', name: 'reportYear', label: '신고연도', span: 6 },
{ type: 'text', name: 'reportQuarter', label: '분기(1~4)', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
]}
onSearch={(v) => setParams({ ...v as WithholdingTaxSearchParam, pageSize: 500 })}
onReset={() => setParams({ reportYear: currentYear, reportQuarter: currentQuarter, pageSize: 500 })}
/>
<ProCard style={cardStyle}>
<DataGrid<WithholdingTaxReportResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={620}
rowKey="reportId"
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}