feat: P5-W3 본론 완료 — 회계결산/이상치예측/연말명세서 (V74~V78)

P5-W2 원천세·부가세 분기신고 미커밋분 + P5-W3 메뉴/권한 보강(V74) + P5-W3 본론(V75~V78)을 일괄 정리.

- V75 accounting_close / account_balance_snapshot (회계 결산)
- V76 retention_anomaly_alert / forecast_kpi_monthly (이상치·예측 KPI)
- V77 year_end_statement (연말 지급명세서)
- V78 P5-W3 본론 메뉴 4 + 권한 + 공통코드 7그룹 + 테스트데이터
- api: AccountingClose/YearEndStatement/ForecastKpi/RetentionAnomaly Service+Controller
- batch: BatchConfig Step 11/12 (원천세·부가세 분기) 연결
- frontend: 4화면(AccountingClose/YearEndStatement/RetentionAnomaly/ForecastKpi) + API 모듈 4 + 라우트
- frontend: usePermission/PermissionButton EXECUTE 권한 누락 보강
- fix: YearEndStatementMapper.xml 미존재 컬럼 a.agent_code 참조 제거

통합검증: Flyway V74~V78 운영DB 적용(v78), 4모듈 compileJava + 프론트 build PASS,
스모크 GET 8/8 + 쓰기 4/4 PASS, verify_v74.py 20/20 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-21 22:22:34 +09:00
parent d5ed642c80
commit 49f08f001d
80 changed files with 4506 additions and 62 deletions
@@ -0,0 +1,62 @@
package com.ga.core.mapper.accounting;
import com.ga.core.vo.accounting.AccountBalanceSnapshotVO;
import com.ga.core.vo.accounting.AccountingCloseResp;
import com.ga.core.vo.accounting.AccountingCloseSearchParam;
import com.ga.core.vo.accounting.AccountingCloseVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
@Mapper
public interface AccountingCloseMapper {
/** 결산 목록 */
List<AccountingCloseResp> selectList(AccountingCloseSearchParam param);
/** 결산 단건 상세 */
AccountingCloseResp selectDetailById(@Param("closeId") Long closeId);
/** 결산 헤더 INSERT (DRAFT 상태) */
int insert(AccountingCloseVO vo);
/**
* 마감 처리.
* - close_status = CLOSED, closed_at = now(), closed_by, journal_count/total_debit/total_credit UPDATE
*/
int updateClose(@Param("closeId") Long closeId,
@Param("journalCount") int journalCount,
@Param("totalDebit") BigDecimal totalDebit,
@Param("totalCredit") BigDecimal totalCredit,
@Param("closedBy") Long closedBy);
/**
* 재오픈 처리.
* - close_status = REOPENED, reopened_at = now(), reopened_by, reopen_reason UPDATE
*/
int updateReopen(@Param("closeId") Long closeId,
@Param("reopenedBy") Long reopenedBy,
@Param("reopenReason") String reopenReason);
/**
* 마감 대상 미전기 분개 엔트리 ID 목록 조회.
* - 월결산: entry_date가 YYYY-MM 범위 & journal_no IS NULL
* - 분기/연결산: 해당 분기/연도 범위
*/
List<Long> selectUnpostedEntryIds(@Param("closeType") String closeType,
@Param("closePeriod") String closePeriod);
/** 결산 시점 잔액 스냅샷 일괄 INSERT (debit/credit 합계 + balance) */
int insertBalanceSnapshot(@Param("list") List<AccountBalanceSnapshotVO> list);
/**
* 결산 시점 계정과목별 차변/대변 합계 집계.
* contract_accounting_entry 에서 journal_no가 채번된 분개를 account_code 별로 SUM.
*/
List<AccountBalanceSnapshotVO> selectBalanceAggregate(@Param("journalPrefix") String journalPrefix);
/** 단순 잔액 스냅샷 목록 (closeId 기준) */
List<AccountBalanceSnapshotVO> selectSnapshotByCloseId(@Param("closeId") Long closeId);
}
@@ -0,0 +1,39 @@
package com.ga.core.mapper.income;
import com.ga.core.vo.income.YearEndStatementExcelVO;
import com.ga.core.vo.income.YearEndStatementResp;
import com.ga.core.vo.income.YearEndStatementSearchParam;
import com.ga.core.vo.income.YearEndStatementVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.cursor.Cursor;
import java.util.List;
@Mapper
public interface YearEndStatementMapper {
List<YearEndStatementResp> selectList(YearEndStatementSearchParam param);
YearEndStatementResp selectDetailById(@Param("statementId") Long statementId);
/** 엑셀 다운로드 전용 (cursor 스트리밍) */
Cursor<YearEndStatementExcelVO> selectExcelCursor(YearEndStatementSearchParam param);
/**
* 연도 전체 UPSERT 생성.
* agent_annual_income 데이터를 가져와 statement 행 생성/갱신.
* agentIds 가 null 이면 전체 설계사 대상.
*/
int upsertByYear(@Param("year") int year,
@Param("agentIds") List<Long> agentIds);
/** 발급 처리 — file_path/file_format/file_status='GENERATED'/generated_at UPDATE */
int updateGenerated(@Param("statementId") Long statementId,
@Param("filePath") String filePath,
@Param("fileFormat") String fileFormat);
/** 발송 처리 — ids 일괄 UPDATE: status=SENT, sent_at=now() */
int updateSent(@Param("ids") List<Long> ids,
@Param("sentTo") String sentTo);
}
@@ -0,0 +1,30 @@
package com.ga.core.mapper.kpi;
import com.ga.core.vo.kpi.ForecastKpiMonthlyResp;
import com.ga.core.vo.kpi.ForecastKpiMonthlySearchParam;
import com.ga.core.vo.kpi.ForecastKpiMonthlyVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ForecastKpiMonthlyMapper {
/** 예측 목록 */
List<ForecastKpiMonthlyResp> selectList(ForecastKpiMonthlySearchParam param);
/**
* UPSERT — 동일 (forecast_month, target_org_id, metric_code) 조합은 갱신.
* target_org_id NULL 도 부분 UNIQUE 인덱스로 처리.
*/
int upsertBatch(@Param("list") List<ForecastKpiMonthlyVO> list);
/**
* 차월 예측 산출 — mv_monthly_summary / mv_retention_rate 기반.
* 입력 forecastMonth(YYYY-MM) 직전 sampleSize 개월 평균/표준편차.
*/
List<ForecastKpiMonthlyVO> computeForecast(@Param("forecastMonth") String forecastMonth,
@Param("method") String method,
@Param("sampleSize") int sampleSize);
}
@@ -0,0 +1,37 @@
package com.ga.core.mapper.kpi;
import com.ga.core.vo.kpi.RetentionAnomalyAlertResp;
import com.ga.core.vo.kpi.RetentionAnomalyAlertSearchParam;
import com.ga.core.vo.kpi.RetentionAnomalyAlertVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface RetentionAnomalyAlertMapper {
/** 이상치 목록 */
List<RetentionAnomalyAlertResp> selectList(RetentionAnomalyAlertSearchParam param);
/** 단건 상세 */
RetentionAnomalyAlertResp selectDetailById(@Param("alertId") Long alertId);
/** 일괄 INSERT — 탐지 배치 결과 저장 */
int insertBatch(@Param("list") List<RetentionAnomalyAlertVO> list);
/**
* 검토/완료 처리.
* status: REVIEWED 또는 RESOLVED.
*/
int updateStatus(@Param("ids") List<Long> ids,
@Param("status") String status,
@Param("note") String note,
@Param("reviewedBy") Long reviewedBy);
/**
* 이상치 탐지 — 입력 월 기준 mv_retention_rate 직전 3개월 평균 대비 1.5%P 이상 편차.
* 결과는 호출측에서 insertBatch로 저장 (INSERT 직접 안 함, UNIQUE 충돌 방지).
*/
List<RetentionAnomalyAlertVO> detectAnomalies(@Param("alertMonth") String alertMonth);
}
@@ -0,0 +1,31 @@
package com.ga.core.mapper.tax;
import com.ga.core.vo.tax.VatReportResp;
import com.ga.core.vo.tax.VatReportSearchParam;
import com.ga.core.vo.tax.VatReportVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface VatReportMapper {
/** 목록 조회 — settleQuarter / fileStatus 조건 */
List<VatReportResp> selectList(VatReportSearchParam param);
/** 단건 상세 */
VatReportResp selectDetailById(@Param("vatId") Long vatId);
/**
* 분기 전체 UPSERT 집계.
* tax_invoice 테이블 기준 해당 분기 SUPPLY/PURCHASE SUM 후 INSERT … ON CONFLICT DO UPDATE.
*
* @param quarter 집계할 분기 (예: 2025-Q1)
* @return 처리된 행 수
*/
int upsertAggregate(@Param("quarter") String quarter);
/** 신고 완료 처리 — file_status=FILED, filed_at=now(). 호출측에서 빈 리스트 방어. */
int updateFileStatusToFiled(@Param("ids") List<Long> ids);
}
@@ -25,4 +25,7 @@ public interface WithholdingTaxReportMapper {
* @return 처리된 행 수
*/
int upsertQuarterlyAggregate(@Param("quarter") String quarter);
/** 신고 완료 처리 — file_status=FILED, filed_at=now(). 호출측에서 빈 리스트 방어. */
int updateFileStatusToFiled(@Param("ids") List<Long> ids);
}
@@ -0,0 +1,24 @@
package com.ga.core.vo.accounting;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* account_balance_snapshot — 결산 시점 계정과목별 잔액 스냅샷 (V75)
*/
@Data
public class AccountBalanceSnapshotVO {
private Long snapshotId;
private Long closeId;
private String accountCode;
private String accountName;
/** DEBIT / CREDIT / BOTH */
private String accountType;
private BigDecimal debitTotal;
private BigDecimal creditTotal;
/** debit_total - credit_total */
private BigDecimal balance;
private LocalDateTime createdAt;
}
@@ -0,0 +1,24 @@
package com.ga.core.vo.accounting;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class AccountingCloseExcelVO {
@ExcelColumn(header = "결산기간", order = 1)
private String closePeriod;
@ExcelColumn(header = "구분", order = 2)
private String closeTypeName;
@ExcelColumn(header = "상태", order = 3)
private String closeStatusName;
@ExcelColumn(header = "분개수", order = 4)
private Integer journalCount;
@ExcelColumn(header = "차변합", order = 5)
private BigDecimal totalDebit;
@ExcelColumn(header = "대변합", order = 6)
private BigDecimal totalCredit;
@ExcelColumn(header = "마감자", order = 7)
private String closedByName;
}
@@ -0,0 +1,33 @@
package com.ga.core.vo.accounting;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 회계 결산 목록/상세 API 응답. 공통코드명, 처리자명 조인 포함.
*/
@Data
public class AccountingCloseResp {
private Long closeId;
private String closePeriod;
private String closeType;
private String closeTypeName;
private String closeStatus;
private String closeStatusName;
private String journalPrefix;
private Integer journalCount;
private BigDecimal totalDebit;
private BigDecimal totalCredit;
private BigDecimal balanceDiff;
private LocalDateTime closedAt;
private Long closedBy;
private String closedByName;
private LocalDateTime reopenedAt;
private Long reopenedBy;
private String reopenedByName;
private String reopenReason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,23 @@
package com.ga.core.vo.accounting;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 회계 결산 생성/실행 요청.
* - createDraft: closePeriod + closeType 지정해 DRAFT 행 생성
* - close : closeId 지정해 마감 실행 (분개 채번 + 잔액 스냅샷)
* - reopen : closeId + reopenReason
*/
@Data
public class AccountingCloseSaveReq {
private Long closeId;
/** YYYY-MM / YYYY-Q1 / YYYY */
@NotBlank
private String closePeriod;
/** MONTHLY / QUARTERLY / YEARLY */
@NotBlank
private String closeType;
/** reopen 사유 (REOPENED 처리 시 필수) */
private String reopenReason;
}
@@ -0,0 +1,15 @@
package com.ga.core.vo.accounting;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class AccountingCloseSearchParam extends SearchParam {
private String closePeriod;
/** MONTHLY / QUARTERLY / YEARLY */
private String closeType;
/** DRAFT / CLOSED / REOPENED */
private String closeStatus;
}
@@ -0,0 +1,35 @@
package com.ga.core.vo.accounting;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* accounting_close — 회계 결산 헤더 (V75)
* INSERT / UPDATE 전용. 조인 필드 없음.
*/
@Data
public class AccountingCloseVO {
private Long closeId;
/** 결산 기간 키 (월: YYYY-MM, 분기: YYYY-Q1, 연: YYYY) */
private String closePeriod;
/** MONTHLY / QUARTERLY / YEARLY */
private String closeType;
/** DRAFT / CLOSED / REOPENED */
private String closeStatus;
/** 분개 채번 prefix */
private String journalPrefix;
private Integer journalCount;
private BigDecimal totalDebit;
private BigDecimal totalCredit;
private LocalDateTime closedAt;
private Long closedBy;
private LocalDateTime reopenedAt;
private Long reopenedBy;
private String reopenReason;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.income;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class YearEndStatementExcelVO {
@ExcelColumn(header = "설계사코드", order = 1)
private String agentCode;
@ExcelColumn(header = "설계사명", order = 2)
private String agentName;
@ExcelColumn(header = "소속조직", order = 3)
private String orgName;
@ExcelColumn(header = "사업자번호", order = 4)
private String businessNo;
@ExcelColumn(header = "신고연도", order = 5)
private Integer statementYear;
@ExcelColumn(header = "총지급액", order = 6)
private BigDecimal totalIncome;
@ExcelColumn(header = "원천세", order = 7)
private BigDecimal totalWithheld;
@ExcelColumn(header = "지방세", order = 8)
private BigDecimal totalLocalTax;
@ExcelColumn(header = "상태", order = 9)
private String fileStatusName;
}
@@ -0,0 +1,32 @@
package com.ga.core.vo.income;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class YearEndStatementResp {
private Long statementId;
private Long agentId;
private String agentName;
private String agentCode;
private String businessNo;
private Long orgId;
private String orgName;
private Integer statementYear;
private String statementType;
private String statementTypeName;
private BigDecimal totalIncome;
private BigDecimal totalWithheld;
private BigDecimal totalLocalTax;
private String filePath;
private String fileFormat;
private String fileStatus;
private String fileStatusName;
private LocalDateTime generatedAt;
private LocalDateTime sentAt;
private String sentTo;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,21 @@
package com.ga.core.vo.income;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 연말 지급명세서 생성/발급/발송 요청.
* - generate: statementYear, agentIds(빈 리스트면 전체)
* - send : ids + sentTo
*/
@Data
public class YearEndStatementSaveReq {
private Integer statementYear;
private List<Long> agentIds;
private String statementType;
private String fileFormat;
private List<Long> ids;
private String sentTo;
}
@@ -0,0 +1,15 @@
package com.ga.core.vo.income;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class YearEndStatementSearchParam extends SearchParam {
private Integer statementYear;
private Long agentId;
private Long orgId;
private String fileStatus;
private String fileFormat;
}
@@ -0,0 +1,33 @@
package com.ga.core.vo.income;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* year_end_statement — 설계사 연말 지급명세서 (V77)
*/
@Data
public class YearEndStatementVO {
private Long statementId;
private Long agentId;
private Integer statementYear;
/** INCOME_DEDUCTION */
private String statementType;
private BigDecimal totalIncome;
private BigDecimal totalWithheld;
private BigDecimal totalLocalTax;
private String filePath;
/** XLSX / PDF */
private String fileFormat;
/** DRAFT / GENERATED / SENT */
private String fileStatus;
private LocalDateTime generatedAt;
private LocalDateTime sentAt;
private String sentTo;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,24 @@
package com.ga.core.vo.kpi;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class ForecastKpiMonthlyResp {
private Long forecastId;
private String forecastMonth;
private Long targetOrgId;
private String targetOrgName;
private String metricCode;
private String metricName;
private BigDecimal forecastValue;
private BigDecimal lowerBound;
private BigDecimal upperBound;
private String method;
private String methodName;
private String basePeriod;
private Integer sampleCount;
private LocalDateTime generatedAt;
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.kpi;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 차월 KPI 예측 재산출 요청.
* - forecastMonth: 산출 대상 월
* - method: 없으면 SMA3 기본
*/
@Data
public class ForecastKpiMonthlySaveReq {
@NotBlank
private String forecastMonth;
/** SMA3 / SMA6 / NAIVE — null 이면 SMA3 */
private String method;
}
@@ -0,0 +1,13 @@
package com.ga.core.vo.kpi;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ForecastKpiMonthlySearchParam extends SearchParam {
private String forecastMonth;
private Long targetOrgId;
private String metricCode;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.kpi;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* forecast_kpi_monthly — 차월 KPI 예측 (V76)
*/
@Data
public class ForecastKpiMonthlyVO {
private Long forecastId;
/** 예측 대상 월 YYYY-MM */
private String forecastMonth;
/** NULL = 전사 */
private Long targetOrgId;
/** MONTHLY_PREMIUM/MONTHLY_PAYOUT/RETENTION_13M/RETENTION_25M */
private String metricCode;
private BigDecimal forecastValue;
private BigDecimal lowerBound;
private BigDecimal upperBound;
/** SMA3 / SMA6 / NAIVE */
private String method;
private String basePeriod;
private Integer sampleCount;
private LocalDateTime generatedAt;
}
@@ -0,0 +1,30 @@
package com.ga.core.vo.kpi;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class RetentionAnomalyAlertResp {
private Long alertId;
private String targetType;
private String targetTypeName;
private Long targetId;
/** AGENT면 agent_name, ORG면 org_name, ALL이면 "전사" */
private String targetName;
private String alertMonth;
private String retentionBand;
private BigDecimal expectedRate;
private BigDecimal actualRate;
private BigDecimal deviation;
private String severity;
private String severityName;
private String status;
private String statusName;
private String note;
private LocalDateTime detectedAt;
private LocalDateTime reviewedAt;
private Long reviewedBy;
private String reviewedByName;
}
@@ -0,0 +1,23 @@
package com.ga.core.vo.kpi;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 이상치 검토/완료 처리 요청.
* - review : 다건 ID + status (REVIEWED/RESOLVED) + note
* - detect : alertMonth 단일 — 재탐지 트리거
*/
@Data
public class RetentionAnomalyAlertSaveReq {
/** detect 트리거에 사용 */
private String alertMonth;
/** review 처리에 사용 */
private List<Long> ids;
/** NEW / REVIEWED / RESOLVED */
private String status;
private String note;
}
@@ -0,0 +1,15 @@
package com.ga.core.vo.kpi;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class RetentionAnomalyAlertSearchParam extends SearchParam {
private String alertMonth;
private String targetType;
private String retentionBand;
private String severity;
private String status;
}
@@ -0,0 +1,33 @@
package com.ga.core.vo.kpi;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* retention_anomaly_alert — 13M/25M 유지율 이상치 (V76)
*/
@Data
public class RetentionAnomalyAlertVO {
private Long alertId;
/** AGENT / ORG / ALL */
private String targetType;
/** ALL 일 때 null */
private Long targetId;
/** YYYY-MM */
private String alertMonth;
/** 13M / 25M */
private String retentionBand;
private BigDecimal expectedRate;
private BigDecimal actualRate;
private BigDecimal deviation;
/** LOW / MED / HIGH */
private String severity;
/** NEW / REVIEWED / RESOLVED */
private String status;
private String note;
private LocalDateTime detectedAt;
private LocalDateTime reviewedAt;
private Long reviewedBy;
}
@@ -0,0 +1,21 @@
package com.ga.core.vo.tax;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 부가세 분기신고 엑셀 다운로드 전용 VO
*/
@Data
public class VatReportExcelVO {
private String settleQuarter;
private BigDecimal totalSales;
private BigDecimal totalPurchase;
private BigDecimal totalVatPayable;
private Integer taxInvoiceCount;
private String fileStatus;
private LocalDateTime filedAt;
private LocalDateTime generatedAt;
}
@@ -0,0 +1,25 @@
package com.ga.core.vo.tax;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 부가세 분기신고 목록/상세 API 응답
*/
@Data
public class VatReportResp {
private Long vatId;
private String settleQuarter;
private BigDecimal totalSales;
private BigDecimal totalPurchase;
private BigDecimal totalVatPayable;
private Integer taxInvoiceCount;
private LocalDateTime generatedAt;
private LocalDateTime filedAt;
private String fileStatus;
private String fileStatusName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,13 @@
package com.ga.core.vo.tax;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 부가세 분기신고 재집계 트리거 요청
*/
@Data
public class VatReportSaveReq {
@NotBlank
private String settleQuarter;
}
@@ -0,0 +1,13 @@
package com.ga.core.vo.tax;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class VatReportSearchParam extends SearchParam {
private String settleQuarter;
/** 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;
/**
* vat_report — 부가세 분기신고 (V68)
* INSERT/UPDATE 전용. 조인 필드 없음.
*/
@Data
public class VatReportVO {
private Long vatId;
/** 정산 분기 (예: 2025-Q1) */
private String settleQuarter;
private BigDecimal totalSales;
private BigDecimal totalPurchase;
private BigDecimal totalVatPayable;
private Integer taxInvoiceCount;
private LocalDateTime generatedAt;
private LocalDateTime filedAt;
/** DRAFT / FILED / AMENDED */
private String fileStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,167 @@
<?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.AccountingCloseMapper">
<resultMap id="RespMap" type="com.ga.core.vo.accounting.AccountingCloseResp"/>
<resultMap id="SnapMap" type="com.ga.core.vo.accounting.AccountBalanceSnapshotVO"/>
<sql id="joinCols">
ac.close_id,
ac.close_period,
ac.close_type,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'ACC_CLOSE_TYPE' AND cc.code = ac.close_type) AS close_type_name,
ac.close_status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'ACC_CLOSE_STATUS' AND cc.code = ac.close_status) AS close_status_name,
ac.journal_prefix,
ac.journal_count,
ac.total_debit,
ac.total_credit,
(ac.total_debit - ac.total_credit) AS balance_diff,
ac.closed_at,
ac.closed_by,
uc.user_name AS closed_by_name,
ac.reopened_at,
ac.reopened_by,
ur.user_name AS reopened_by_name,
ac.reopen_reason,
ac.created_at,
ac.updated_at
</sql>
<sql id="joinFrom">
FROM accounting_close ac
LEFT JOIN users uc ON uc.user_id = ac.closed_by
LEFT JOIN users ur ON ur.user_id = ac.reopened_by
</sql>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="joinCols"/>
<include refid="joinFrom"/>
<where>
<if test="closePeriod != null and closePeriod != ''">AND ac.close_period = #{closePeriod}</if>
<if test="closeType != null and closeType != ''">AND ac.close_type = #{closeType}</if>
<if test="closeStatus != null and closeStatus != ''">AND ac.close_status = #{closeStatus}</if>
</where>
ORDER BY ac.close_period DESC, ac.close_type
</select>
<select id="selectDetailById" resultMap="RespMap">
SELECT <include refid="joinCols"/>
<include refid="joinFrom"/>
WHERE ac.close_id = #{closeId}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="closeId">
INSERT INTO accounting_close
(close_period, close_type, close_status, journal_prefix,
journal_count, total_debit, total_credit,
created_at, created_by, updated_at)
VALUES
(#{closePeriod}, #{closeType}, COALESCE(#{closeStatus}, 'DRAFT'), #{journalPrefix},
0, 0, 0,
now(), #{createdBy}, now())
</insert>
<update id="updateClose">
UPDATE accounting_close
SET close_status = 'CLOSED',
closed_at = now(),
closed_by = #{closedBy},
journal_count = #{journalCount},
total_debit = #{totalDebit},
total_credit = #{totalCredit},
updated_at = now(),
updated_by = #{closedBy}
WHERE close_id = #{closeId}
AND close_status IN ('DRAFT','REOPENED')
</update>
<update id="updateReopen">
UPDATE accounting_close
SET close_status = 'REOPENED',
reopened_at = now(),
reopened_by = #{reopenedBy},
reopen_reason = #{reopenReason},
updated_at = now(),
updated_by = #{reopenedBy}
WHERE close_id = #{closeId}
AND close_status = 'CLOSED'
</update>
<!--
마감 대상 미전기 분개 ID 목록.
- MONTHLY: YYYY-MM → entry_date를 to_char(YYYY-MM)으로 비교
- QUARTERLY: YYYY-Qn → EXTRACT(QUARTER) 비교
- YEARLY: YYYY → EXTRACT(YEAR) 비교
-->
<select id="selectUnpostedEntryIds" resultType="java.lang.Long">
SELECT cae.entry_id
FROM contract_accounting_entry cae
WHERE cae.journal_no IS NULL
<choose>
<when test="closeType == 'MONTHLY'">
AND to_char(cae.entry_date, 'YYYY-MM') = #{closePeriod}
</when>
<when test="closeType == 'QUARTERLY'">
AND EXTRACT(YEAR FROM cae.entry_date) = CAST(SPLIT_PART(#{closePeriod}, '-Q', 1) AS INT)
AND EXTRACT(QUARTER FROM cae.entry_date) = CAST(SPLIT_PART(#{closePeriod}, '-Q', 2) AS INT)
</when>
<when test="closeType == 'YEARLY'">
AND EXTRACT(YEAR FROM cae.entry_date) = CAST(#{closePeriod} AS INT)
</when>
</choose>
ORDER BY cae.entry_date, cae.entry_id
</select>
<!--
결산 시점 계정과목별 차변/대변 합계 집계.
journal_prefix로 시작하는 journal_no 분개를 account_code 별로 SUM.
debit_account / credit_account를 별도 SELECT로 합산 후 FULL OUTER JOIN.
-->
<select id="selectBalanceAggregate" resultMap="SnapMap">
WITH d AS (
SELECT cae.debit_account AS account_code, SUM(cae.amount) AS debit_total
FROM contract_accounting_entry cae
WHERE cae.journal_no LIKE CONCAT(#{journalPrefix}, '-%')
GROUP BY cae.debit_account
), c AS (
SELECT cae.credit_account AS account_code, SUM(cae.amount) AS credit_total
FROM contract_accounting_entry cae
WHERE cae.journal_no LIKE CONCAT(#{journalPrefix}, '-%')
GROUP BY cae.credit_account
)
SELECT COALESCE(d.account_code, c.account_code) AS account_code,
ac.account_name,
ac.account_type,
COALESCE(d.debit_total, 0) AS debit_total,
COALESCE(c.credit_total, 0) AS credit_total,
COALESCE(d.debit_total, 0) - COALESCE(c.credit_total, 0) AS balance
FROM d
FULL OUTER JOIN c ON c.account_code = d.account_code
LEFT JOIN account_code ac ON ac.account_code = COALESCE(d.account_code, c.account_code)
ORDER BY 1
</select>
<insert id="insertBalanceSnapshot">
INSERT INTO account_balance_snapshot
(close_id, account_code, account_name, account_type,
debit_total, credit_total, balance)
VALUES
<foreach collection="list" item="s" separator=",">
(#{s.closeId}, #{s.accountCode}, #{s.accountName}, #{s.accountType},
#{s.debitTotal}, #{s.creditTotal}, #{s.balance})
</foreach>
</insert>
<select id="selectSnapshotByCloseId" resultMap="SnapMap">
SELECT snapshot_id, close_id, account_code, account_name, account_type,
debit_total, credit_total, balance, created_at
FROM account_balance_snapshot
WHERE close_id = #{closeId}
ORDER BY account_code
</select>
</mapper>
@@ -0,0 +1,134 @@
<?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.YearEndStatementMapper">
<resultMap id="RespMap" type="com.ga.core.vo.income.YearEndStatementResp"/>
<resultMap id="ExcelMap" type="com.ga.core.vo.income.YearEndStatementExcelVO"/>
<sql id="joinCols">
yes.statement_id,
yes.agent_id,
a.agent_name,
a.business_no,
a.org_id,
o.org_name,
yes.statement_year,
yes.statement_type,
CASE yes.statement_type WHEN 'INCOME_DEDUCTION' THEN '사업소득 지급명세서' ELSE yes.statement_type END
AS statement_type_name,
yes.total_income,
yes.total_withheld,
yes.total_local_tax,
yes.file_path,
yes.file_format,
yes.file_status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'YES_STATUS' AND cc.code = yes.file_status) AS file_status_name,
yes.generated_at,
yes.sent_at,
yes.sent_to,
yes.created_at,
yes.updated_at
</sql>
<sql id="joinFrom">
FROM year_end_statement yes
JOIN agent a ON a.agent_id = yes.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
</sql>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="joinCols"/>
<include refid="joinFrom"/>
<where>
<if test="statementYear != null">AND yes.statement_year = #{statementYear}</if>
<if test="agentId != null">AND yes.agent_id = #{agentId}</if>
<if test="orgId != null">AND a.org_id = #{orgId}</if>
<if test="fileStatus != null and fileStatus != ''">AND yes.file_status = #{fileStatus}</if>
<if test="fileFormat != null and fileFormat != ''">AND yes.file_format = #{fileFormat}</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%')
</if>
</where>
ORDER BY yes.statement_year DESC, a.agent_id
</select>
<select id="selectDetailById" resultMap="RespMap">
SELECT <include refid="joinCols"/>
<include refid="joinFrom"/>
WHERE yes.statement_id = #{statementId}
</select>
<select id="selectExcelCursor" resultMap="ExcelMap" fetchSize="500" resultSetType="FORWARD_ONLY">
SELECT a.agent_name,
o.org_name,
a.business_no,
yes.statement_year,
yes.total_income,
yes.total_withheld,
yes.total_local_tax,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'YES_STATUS' AND cc.code = yes.file_status) AS file_status_name
<include refid="joinFrom"/>
<where>
<if test="statementYear != null">AND yes.statement_year = #{statementYear}</if>
<if test="agentId != null">AND yes.agent_id = #{agentId}</if>
<if test="orgId != null">AND a.org_id = #{orgId}</if>
<if test="fileStatus != null and fileStatus != ''">AND yes.file_status = #{fileStatus}</if>
</where>
ORDER BY a.agent_id
</select>
<!--
agent_annual_income(V63)를 기반으로 UPSERT.
agent_annual_income 컬럼: agent_id / settle_year / total_income / total_withheld / generated_at
지방소득세 = total_withheld * 0.1 (한국 표준)
-->
<insert id="upsertByYear">
INSERT INTO year_end_statement
(agent_id, statement_year, statement_type,
total_income, total_withheld, total_local_tax, file_status, created_at)
SELECT ai.agent_id,
#{year},
'INCOME_DEDUCTION',
COALESCE(ai.total_commission, 0),
COALESCE(ai.total_tax_withheld, 0),
COALESCE(ai.total_local_tax, 0),
'DRAFT',
now()
FROM agent_annual_income ai
WHERE ai.settle_year = #{year}
<if test="agentIds != null and agentIds.size() > 0">
AND ai.agent_id IN
<foreach collection="agentIds" item="aid" open="(" separator="," close=")">#{aid}</foreach>
</if>
ON CONFLICT (agent_id, statement_year, statement_type) DO UPDATE SET
total_income = EXCLUDED.total_income,
total_withheld = EXCLUDED.total_withheld,
total_local_tax = EXCLUDED.total_local_tax,
updated_at = now()
</insert>
<update id="updateGenerated">
UPDATE year_end_statement
SET file_path = #{filePath},
file_format = #{fileFormat},
file_status = 'GENERATED',
generated_at = now(),
updated_at = now()
WHERE statement_id = #{statementId}
</update>
<update id="updateSent">
UPDATE year_end_statement
SET file_status = 'SENT',
sent_at = now(),
sent_to = #{sentTo},
updated_at = now()
WHERE statement_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
AND file_status = 'GENERATED'
</update>
</mapper>
@@ -0,0 +1,145 @@
<?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.kpi.ForecastKpiMonthlyMapper">
<resultMap id="RespMap" type="com.ga.core.vo.kpi.ForecastKpiMonthlyResp"/>
<resultMap id="VOMap" type="com.ga.core.vo.kpi.ForecastKpiMonthlyVO"/>
<sql id="joinCols">
f.forecast_id,
f.forecast_month,
f.target_org_id,
(SELECT o.org_name FROM organization o WHERE o.org_id = f.target_org_id) AS target_org_name,
f.metric_code,
CASE f.metric_code
WHEN 'MONTHLY_PREMIUM' THEN '월 수수료총액 (gross)'
WHEN 'MONTHLY_PAYOUT' THEN '월 순지급액 (net)'
WHEN 'RETENTION_13M' THEN '13개월 유지율'
WHEN 'RETENTION_25M' THEN '25개월 유지율'
ELSE f.metric_code
END AS metric_name,
f.forecast_value,
f.lower_bound,
f.upper_bound,
f.method,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'FORECAST_METHOD' AND cc.code = f.method) AS method_name,
f.base_period,
f.sample_count,
f.generated_at
</sql>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM forecast_kpi_monthly f
<where>
<if test="forecastMonth != null and forecastMonth != ''">AND f.forecast_month = #{forecastMonth}</if>
<if test="targetOrgId != null">AND f.target_org_id = #{targetOrgId}</if>
<if test="metricCode != null and metricCode != ''">AND f.metric_code = #{metricCode}</if>
</where>
ORDER BY f.forecast_month DESC, f.metric_code, f.target_org_id NULLS FIRST
</select>
<!--
UPSERT (forecast_month, COALESCE(target_org_id,-1), metric_code) 키.
target_org_id NULL은 부분 UNIQUE 인덱스(uq_fkm_all)로 처리.
ON CONFLICT 절은 두 가지 케이스 (전사/조직별)를 모두 다뤄야 하지만
MyBatis foreach에서는 키별로 INSERT 직후 갱신을 보장하기 어려우니
pre-DELETE 후 INSERT 패턴으로 단순화.
-->
<delete id="deleteByKey" parameterType="map">
DELETE FROM forecast_kpi_monthly
WHERE forecast_month = #{forecastMonth}
AND metric_code = #{metricCode}
AND ((#{targetOrgId} IS NULL AND target_org_id IS NULL)
OR target_org_id = #{targetOrgId})
</delete>
<insert id="upsertBatch">
INSERT INTO forecast_kpi_monthly
(forecast_month, target_org_id, metric_code,
forecast_value, lower_bound, upper_bound,
method, base_period, sample_count)
VALUES
<foreach collection="list" item="f" separator=",">
(#{f.forecastMonth}, #{f.targetOrgId}, #{f.metricCode},
#{f.forecastValue}, #{f.lowerBound}, #{f.upperBound},
COALESCE(#{f.method}, 'SMA3'), #{f.basePeriod}, #{f.sampleCount})
</foreach>
ON CONFLICT DO NOTHING
</insert>
<!--
예측 산출.
- sampleSize 개월 직전 평균(평균=forecast_value), 표준편차(±1σ=lower/upper)
- method = SMA3/SMA6/NAIVE
- metric:
MONTHLY_PREMIUM = mv_monthly_summary.total_gross
MONTHLY_PAYOUT = mv_monthly_summary.total_net
RETENTION_13M = mv_retention.retention_rate_13m (carrier 평균)
RETENTION_25M = mv_retention.retention_rate_25m (carrier 평균)
- target_org_id 는 모두 NULL (전사) — 조직별은 후속 보강
-->
<select id="computeForecast" resultMap="VOMap">
WITH params AS (
SELECT REPLACE(#{forecastMonth},'-','') AS fmonth_yyyymm,
TO_CHAR((TO_DATE(#{forecastMonth}||'-01','YYYY-MM-DD') - INTERVAL '1 months'), 'YYYYMM') AS prev_yyyymm,
TO_CHAR((TO_DATE(#{forecastMonth}||'-01','YYYY-MM-DD') - (#{sampleSize}||' months')::INTERVAL), 'YYYYMM') AS from_yyyymm
),
mp AS (
SELECT 'MONTHLY_PREMIUM' AS metric_code,
AVG(total_gross) AS fv,
AVG(total_gross) - COALESCE(STDDEV_POP(total_gross), 0) AS lb,
AVG(total_gross) + COALESCE(STDDEV_POP(total_gross), 0) AS ub,
COUNT(*) AS sc
FROM mv_monthly_summary, params
WHERE yyyymm BETWEEN from_yyyymm AND prev_yyyymm
),
mo AS (
SELECT 'MONTHLY_PAYOUT' AS metric_code,
AVG(total_net) AS fv,
AVG(total_net) - COALESCE(STDDEV_POP(total_net), 0) AS lb,
AVG(total_net) + COALESCE(STDDEV_POP(total_net), 0) AS ub,
COUNT(*) AS sc
FROM mv_monthly_summary, params
WHERE yyyymm BETWEEN from_yyyymm AND prev_yyyymm
),
r13 AS (
SELECT 'RETENTION_13M' AS metric_code,
AVG(retention_rate_13m) AS fv,
AVG(retention_rate_13m) - COALESCE(STDDEV_POP(retention_rate_13m), 0) AS lb,
AVG(retention_rate_13m) + COALESCE(STDDEV_POP(retention_rate_13m), 0) AS ub,
COUNT(*) AS sc
FROM mv_retention, params
WHERE contract_month BETWEEN from_yyyymm AND prev_yyyymm
),
r25 AS (
SELECT 'RETENTION_25M' AS metric_code,
AVG(retention_rate_25m) AS fv,
AVG(retention_rate_25m) - COALESCE(STDDEV_POP(retention_rate_25m), 0) AS lb,
AVG(retention_rate_25m) + COALESCE(STDDEV_POP(retention_rate_25m), 0) AS ub,
COUNT(*) AS sc
FROM mv_retention, params
WHERE contract_month BETWEEN from_yyyymm AND prev_yyyymm
),
merged AS (
SELECT * FROM mp WHERE mp.fv IS NOT NULL
UNION ALL SELECT * FROM mo WHERE mo.fv IS NOT NULL
UNION ALL SELECT * FROM r13 WHERE r13.fv IS NOT NULL
UNION ALL SELECT * FROM r25 WHERE r25.fv IS NOT NULL
)
SELECT
#{forecastMonth} AS forecast_month,
NULL::BIGINT AS target_org_id,
m.metric_code AS metric_code,
m.fv AS forecast_value,
m.lb AS lower_bound,
m.ub AS upper_bound,
#{method} AS method,
CONCAT((SELECT from_yyyymm FROM params), '~', (SELECT prev_yyyymm FROM params)) AS base_period,
m.sc::INT AS sample_count
FROM merged m
</select>
</mapper>
@@ -0,0 +1,155 @@
<?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.kpi.RetentionAnomalyAlertMapper">
<resultMap id="RespMap" type="com.ga.core.vo.kpi.RetentionAnomalyAlertResp"/>
<resultMap id="VOMap" type="com.ga.core.vo.kpi.RetentionAnomalyAlertVO"/>
<sql id="joinCols">
ra.alert_id,
ra.target_type,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'ANOMALY_TARGET_TYPE' AND cc.code = ra.target_type) AS target_type_name,
ra.target_id,
CASE ra.target_type
WHEN 'AGENT' THEN (SELECT a.agent_name FROM agent a WHERE a.agent_id = ra.target_id)
WHEN 'ORG' THEN (SELECT o.org_name FROM organization o WHERE o.org_id = ra.target_id)
ELSE '전사'
END AS target_name,
ra.alert_month,
ra.retention_band,
ra.expected_rate,
ra.actual_rate,
ra.deviation,
ra.severity,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'ANOMALY_SEVERITY' AND cc.code = ra.severity) AS severity_name,
ra.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'ANOMALY_STATUS' AND cc.code = ra.status) AS status_name,
ra.note,
ra.detected_at,
ra.reviewed_at,
ra.reviewed_by,
(SELECT u.user_name FROM users u WHERE u.user_id = ra.reviewed_by) AS reviewed_by_name
</sql>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM retention_anomaly_alert ra
<where>
<if test="alertMonth != null and alertMonth != ''">AND ra.alert_month = #{alertMonth}</if>
<if test="targetType != null and targetType != ''">AND ra.target_type = #{targetType}</if>
<if test="retentionBand != null and retentionBand != ''">AND ra.retention_band = #{retentionBand}</if>
<if test="severity != null and severity != ''">AND ra.severity = #{severity}</if>
<if test="status != null and status != ''">AND ra.status = #{status}</if>
</where>
ORDER BY ra.alert_month DESC, ra.severity DESC, ra.detected_at DESC
</select>
<select id="selectDetailById" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM retention_anomaly_alert ra
WHERE ra.alert_id = #{alertId}
</select>
<insert id="insertBatch">
INSERT INTO retention_anomaly_alert
(target_type, target_id, alert_month, retention_band,
expected_rate, actual_rate, deviation, severity, status, note)
VALUES
<foreach collection="list" item="a" separator=",">
(#{a.targetType}, #{a.targetId}, #{a.alertMonth}, #{a.retentionBand},
#{a.expectedRate}, #{a.actualRate}, #{a.deviation},
#{a.severity}, COALESCE(#{a.status}, 'NEW'), #{a.note})
</foreach>
ON CONFLICT DO NOTHING
</insert>
<update id="updateStatus">
UPDATE retention_anomaly_alert
SET status = #{status},
note = COALESCE(#{note}, note),
reviewed_at = now(),
reviewed_by = #{reviewedBy}
WHERE alert_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
<!--
탐지 SQL.
입력 alertMonth='YYYY-MM' → mv_retention.contract_month='YYYYMM'
직전 3개월 평균 rate 대비 deviation >= 1.5%P → 알림.
target_type='ALL' (carrier 통합) + carrier별 ('ORG'-like)는 carrier_id 를 target_id로.
retention_band 두 종류(13M, 25M) UNION ALL.
severity 룰:
|dev| < 3 → LOW
|dev| < 6 → MED
else → HIGH
-->
<select id="detectAnomalies" resultMap="VOMap">
WITH curr AS (
SELECT carrier_id, retention_rate_13m AS rate
FROM mv_retention
WHERE contract_month = REPLACE(#{alertMonth}, '-', '')
),
prev AS (
SELECT carrier_id, AVG(retention_rate_13m) AS exp_rate
FROM mv_retention
WHERE contract_month &lt; REPLACE(#{alertMonth}, '-', '')
AND contract_month >=
TO_CHAR((TO_DATE(#{alertMonth}||'-01','YYYY-MM-DD') - INTERVAL '3 months'), 'YYYYMM')
GROUP BY carrier_id
),
anom13 AS (
SELECT 'ALL'::VARCHAR(10) AS target_type,
c.carrier_id AS target_id,
#{alertMonth}::VARCHAR(7) AS alert_month,
'13M'::VARCHAR(5) AS retention_band,
p.exp_rate AS expected_rate,
c.rate AS actual_rate,
(c.rate - p.exp_rate) AS deviation,
CASE
WHEN ABS(c.rate - p.exp_rate) >= 6 THEN 'HIGH'
WHEN ABS(c.rate - p.exp_rate) >= 3 THEN 'MED'
ELSE 'LOW'
END AS severity,
'NEW'::VARCHAR(10) AS status,
NULL::VARCHAR(300) AS note
FROM curr c JOIN prev p ON p.carrier_id = c.carrier_id
WHERE ABS(c.rate - p.exp_rate) >= 1.5
),
curr25 AS (
SELECT carrier_id, retention_rate_25m AS rate
FROM mv_retention
WHERE contract_month = REPLACE(#{alertMonth}, '-', '')
),
prev25 AS (
SELECT carrier_id, AVG(retention_rate_25m) AS exp_rate
FROM mv_retention
WHERE contract_month &lt; REPLACE(#{alertMonth}, '-', '')
AND contract_month >=
TO_CHAR((TO_DATE(#{alertMonth}||'-01','YYYY-MM-DD') - INTERVAL '3 months'), 'YYYYMM')
GROUP BY carrier_id
),
anom25 AS (
SELECT 'ALL'::VARCHAR(10), c.carrier_id, #{alertMonth}::VARCHAR(7), '25M'::VARCHAR(5),
p.exp_rate, c.rate, (c.rate - p.exp_rate),
CASE
WHEN ABS(c.rate - p.exp_rate) >= 6 THEN 'HIGH'
WHEN ABS(c.rate - p.exp_rate) >= 3 THEN 'MED'
ELSE 'LOW'
END,
'NEW'::VARCHAR(10),
NULL::VARCHAR(300)
FROM curr25 c JOIN prev25 p ON p.carrier_id = c.carrier_id
WHERE ABS(c.rate - p.exp_rate) >= 1.5
)
SELECT * FROM anom13
UNION ALL
SELECT * FROM anom25
</select>
</mapper>
@@ -0,0 +1,95 @@
<?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.tax.VatReportMapper">
<!-- 부가세 분기신고 응답 매핑 (조인 없음, 분기 집계 단위) -->
<resultMap id="RespMap" type="com.ga.core.vo.tax.VatReportResp"/>
<!-- 목록/상세 공통 SELECT 컬럼 -->
<sql id="cols">
r.vat_id,
r.settle_quarter,
r.total_sales,
r.total_purchase,
r.total_vat_payable,
r.tax_invoice_count,
r.generated_at,
r.filed_at,
r.file_status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'TAX_FILE_STATUS' AND cc.code = r.file_status) AS file_status_name,
r.created_at,
r.updated_at
</sql>
<!-- ===== 목록 조회 ===== -->
<select id="selectList" resultMap="RespMap">
SELECT <include refid="cols"/>
FROM vat_report r
<where>
<if test="settleQuarter != null and settleQuarter != ''">AND r.settle_quarter = #{settleQuarter}</if>
<if test="fileStatus != null and fileStatus != ''">AND r.file_status = #{fileStatus}</if>
</where>
ORDER BY r.settle_quarter DESC
</select>
<!-- ===== 단건 상세 ===== -->
<select id="selectDetailById" resultMap="RespMap">
SELECT <include refid="cols"/>
FROM vat_report r
WHERE r.vat_id = #{vatId}
</select>
<!--
분기 부가세 UPSERT 집계.
tax_invoice 테이블에서 해당 분기 SUPPLY(매출)/PURCHASE(매입) SUM.
- total_sales = SUM(supply_amount) WHERE invoice_type='SUPPLY'
- total_purchase = SUM(supply_amount) WHERE invoice_type='PURCHASE'
- total_vat_payable = total_sales * 0.1 - (SUM(tax_amount) WHERE PURCHASE)
- tax_invoice_count = 건수 (취소 제외)
분기 판단: issue_date 로 EXTRACT(YEAR/QUARTER) 사용.
ON CONFLICT (settle_quarter) DO UPDATE.
-->
<insert id="upsertAggregate">
INSERT INTO vat_report
(settle_quarter,
total_sales, total_purchase, total_vat_payable,
tax_invoice_count,
generated_at)
SELECT
#{quarter} AS settle_quarter,
COALESCE(SUM(CASE WHEN ti.invoice_type = 'SUPPLY'
THEN ti.supply_amount ELSE 0 END), 0) AS total_sales,
COALESCE(SUM(CASE WHEN ti.invoice_type = 'PURCHASE'
THEN ti.supply_amount ELSE 0 END), 0) AS total_purchase,
COALESCE(SUM(CASE WHEN ti.invoice_type = 'SUPPLY'
THEN ti.supply_amount ELSE 0 END), 0) * 0.1
- COALESCE(SUM(CASE WHEN ti.invoice_type = 'PURCHASE'
THEN ti.tax_amount ELSE 0 END), 0) AS total_vat_payable,
COUNT(*) AS tax_invoice_count,
NOW() AS generated_at
FROM tax_invoice ti
WHERE EXTRACT(YEAR FROM ti.issue_date) = CAST(SPLIT_PART(#{quarter}, '-Q', 1) AS INT)
AND EXTRACT(QUARTER FROM ti.issue_date) = CAST(SPLIT_PART(#{quarter}, '-Q', 2) AS INT)
AND ti.cancel_date IS NULL
ON CONFLICT (settle_quarter) DO UPDATE SET
total_sales = EXCLUDED.total_sales,
total_purchase = EXCLUDED.total_purchase,
total_vat_payable = EXCLUDED.total_vat_payable,
tax_invoice_count = EXCLUDED.tax_invoice_count,
generated_at = NOW(),
updated_at = NOW()
</insert>
<!-- ===== 신고 완료 처리 ===== -->
<update id="updateFileStatusToFiled">
UPDATE vat_report
SET file_status = 'FILED',
filed_at = NOW(),
updated_at = NOW()
WHERE vat_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
</mapper>
@@ -0,0 +1,97 @@
<?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.tax.WithholdingTaxReportMapper">
<!-- agent, organization 조인 포함 응답 매핑 -->
<resultMap id="RespMap" type="com.ga.core.vo.tax.WithholdingTaxReportResp"/>
<!-- 목록/상세에서 공통으로 사용하는 SELECT 컬럼 -->
<sql id="joinCols">
r.report_id,
r.agent_id,
a.agent_name,
a.org_id,
o.org_name,
r.settle_quarter,
r.total_income,
r.total_withheld,
r.total_local_tax,
r.generated_at,
r.filed_at,
r.file_status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'TAX_FILE_STATUS' AND cc.code = r.file_status) AS file_status_name,
r.created_at,
r.updated_at
</sql>
<!-- ===== 목록 조회 ===== -->
<select id="selectList" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM withholding_tax_report r
JOIN agent a ON a.agent_id = r.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
<where>
<if test="settleQuarter != null and settleQuarter != ''">AND r.settle_quarter = #{settleQuarter}</if>
<if test="agentId != null">AND r.agent_id = #{agentId}</if>
<if test="orgId != null">AND a.org_id = #{orgId}</if>
<if test="fileStatus != null and fileStatus != ''">AND r.file_status = #{fileStatus}</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%')
</if>
</where>
ORDER BY r.settle_quarter DESC, r.agent_id ASC
</select>
<!-- ===== 단건 상세 ===== -->
<select id="selectDetailById" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM withholding_tax_report r
JOIN agent a ON a.agent_id = r.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
WHERE r.report_id = #{reportId}
</select>
<!--
분기 원천세 UPSERT 집계.
payment 테이블에서 해당 분기 설계사별 income_tax_amount / local_tax_amount / pay_amount SUM.
분기 판단: pay_date 로 EXTRACT(YEAR/QUARTER) 사용. 분기 포맷 YYYY-Qn 파싱.
예) '2025-Q1' → YEAR=2025, QUARTER=1
ON CONFLICT (agent_id, settle_quarter) DO UPDATE.
-->
<insert id="upsertQuarterlyAggregate">
INSERT INTO withholding_tax_report
(agent_id, settle_quarter,
total_income, total_withheld, total_local_tax,
generated_at)
SELECT
p.agent_id,
#{quarter} AS settle_quarter,
COALESCE(SUM(p.pay_amount), 0) AS total_income,
COALESCE(SUM(p.income_tax_amount), 0) AS total_withheld,
COALESCE(SUM(p.local_tax_amount), 0) AS total_local_tax,
NOW() AS generated_at
FROM payment p
WHERE EXTRACT(YEAR FROM p.pay_date) = CAST(SPLIT_PART(#{quarter}, '-Q', 1) AS INT)
AND EXTRACT(QUARTER FROM p.pay_date) = CAST(SPLIT_PART(#{quarter}, '-Q', 2) AS INT)
GROUP BY p.agent_id
ON CONFLICT (agent_id, settle_quarter) DO UPDATE SET
total_income = EXCLUDED.total_income,
total_withheld = EXCLUDED.total_withheld,
total_local_tax = EXCLUDED.total_local_tax,
generated_at = NOW(),
updated_at = NOW()
</insert>
<!-- ===== 신고 완료 처리 ===== -->
<update id="updateFileStatusToFiled">
UPDATE withholding_tax_report
SET file_status = 'FILED',
filed_at = NOW(),
updated_at = NOW()
WHERE report_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
</mapper>