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,74 @@
package com.ga.api.controller.accounting;
import com.ga.api.service.accounting.AccountingCloseService;
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.AccountBalanceSnapshotVO;
import com.ga.core.vo.accounting.AccountingCloseResp;
import com.ga.core.vo.accounting.AccountingCloseSaveReq;
import com.ga.core.vo.accounting.AccountingCloseSearchParam;
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.*;
import java.util.List;
import java.util.Map;
@Tag(name = "회계 결산")
@RestController
@RequestMapping("/api/accounting-closes")
@RequiredArgsConstructor
public class AccountingCloseController {
private final AccountingCloseService service;
@GetMapping
@Operation(summary = "회계 결산 목록")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "READ")
public ApiResponse<PageResponse<AccountingCloseResp>> list(@ModelAttribute AccountingCloseSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{id}")
@Operation(summary = "회계 결산 상세")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "READ")
public ApiResponse<AccountingCloseResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
@GetMapping("/{id}/snapshots")
@Operation(summary = "계정과목별 잔액 스냅샷")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "READ")
public ApiResponse<List<AccountBalanceSnapshotVO>> snapshots(@PathVariable Long id) {
return ApiResponse.ok(service.snapshots(id));
}
@PostMapping("/draft")
@Operation(summary = "결산 DRAFT 생성")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "CREATE")
@DataChangeLog(menu = "ACCOUNTING_CLOSE", table = "accounting_close")
public ApiResponse<Long> createDraft(@Valid @RequestBody AccountingCloseSaveReq req) {
return ApiResponse.ok(service.createDraft(req));
}
@PostMapping("/{id}/close")
@Operation(summary = "결산 마감 실행")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "EXECUTE")
@DataChangeLog(menu = "ACCOUNTING_CLOSE", table = "accounting_close")
public ApiResponse<AccountingCloseResp> close(@PathVariable Long id) {
return ApiResponse.ok(service.close(id));
}
@PostMapping("/{id}/reopen")
@Operation(summary = "결산 재오픈")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "APPROVE")
@DataChangeLog(menu = "ACCOUNTING_CLOSE", table = "accounting_close")
public ApiResponse<Void> reopen(@PathVariable Long id, @RequestBody Map<String, String> body) {
service.reopen(id, body.get("reopenReason"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,81 @@
package com.ga.api.controller.income;
import com.ga.api.service.income.YearEndStatementService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.excel.ExcelService;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.income.YearEndStatementExcelVO;
import com.ga.core.vo.income.YearEndStatementResp;
import com.ga.core.vo.income.YearEndStatementSaveReq;
import com.ga.core.vo.income.YearEndStatementSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "연말 지급명세서")
@RestController
@RequestMapping("/api/year-end-statements")
@RequiredArgsConstructor
public class YearEndStatementController {
private final YearEndStatementService service;
private final ExcelService excelService;
@GetMapping
@Operation(summary = "연말 지급명세서 목록")
@RequirePermission(menu = "YEAR_END_STMT", perm = "READ")
public ApiResponse<PageResponse<YearEndStatementResp>> list(@ModelAttribute YearEndStatementSearchParam p) {
return ApiResponse.ok(service.list(p));
}
@GetMapping("/{id}")
@Operation(summary = "연말 지급명세서 상세")
@RequirePermission(menu = "YEAR_END_STMT", perm = "READ")
public ApiResponse<YearEndStatementResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
@PostMapping("/regenerate")
@Operation(summary = "연도별 재집계 (UPSERT)")
@RequirePermission(menu = "YEAR_END_STMT", perm = "EXECUTE")
@DataChangeLog(menu = "YEAR_END_STMT", table = "year_end_statement")
public ApiResponse<Integer> regenerate(@RequestBody YearEndStatementSaveReq req) {
return ApiResponse.ok(service.regenerate(req));
}
@PostMapping("/{id}/generate")
@Operation(summary = "단건 파일 발급")
@RequirePermission(menu = "YEAR_END_STMT", perm = "EXECUTE")
@DataChangeLog(menu = "YEAR_END_STMT", table = "year_end_statement")
public ApiResponse<Void> generate(@PathVariable Long id, @RequestBody(required = false) Map<String, String> body) {
String format = body == null ? null : body.get("fileFormat");
service.generate(id, format);
return ApiResponse.ok();
}
@PutMapping("/send")
@Operation(summary = "발송 처리")
@RequirePermission(menu = "YEAR_END_STMT", perm = "UPDATE")
@DataChangeLog(menu = "YEAR_END_STMT", table = "year_end_statement")
public ApiResponse<Void> send(@RequestBody YearEndStatementSaveReq req) {
service.send(req);
return ApiResponse.ok();
}
@GetMapping("/export")
@Operation(summary = "엑셀 다운로드")
@RequirePermission(menu = "YEAR_END_STMT", perm = "EXPORT")
public void export(@ModelAttribute YearEndStatementSearchParam p, HttpServletResponse res) {
excelService.exportLargeExcel(
"year-end-statement.xlsx",
YearEndStatementExcelVO.class,
() -> service.exportCursor(p),
res);
}
}
@@ -0,0 +1,39 @@
package com.ga.api.controller.kpi;
import com.ga.api.service.kpi.ForecastKpiMonthlyService;
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.kpi.ForecastKpiMonthlyResp;
import com.ga.core.vo.kpi.ForecastKpiMonthlySaveReq;
import com.ga.core.vo.kpi.ForecastKpiMonthlySearchParam;
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 = "차월 KPI 예측")
@RestController
@RequestMapping("/api/forecast-kpi")
@RequiredArgsConstructor
public class ForecastKpiMonthlyController {
private final ForecastKpiMonthlyService service;
@GetMapping
@Operation(summary = "예측 목록")
@RequirePermission(menu = "FORECAST_KPI", perm = "READ")
public ApiResponse<PageResponse<ForecastKpiMonthlyResp>> list(@ModelAttribute ForecastKpiMonthlySearchParam p) {
return ApiResponse.ok(service.list(p));
}
@PostMapping("/regenerate")
@Operation(summary = "차월 예측 재산출")
@RequirePermission(menu = "FORECAST_KPI", perm = "EXECUTE")
@DataChangeLog(menu = "FORECAST_KPI", table = "forecast_kpi_monthly")
public ApiResponse<Integer> regenerate(@Valid @RequestBody ForecastKpiMonthlySaveReq req) {
return ApiResponse.ok(service.regenerate(req));
}
}
@@ -0,0 +1,56 @@
package com.ga.api.controller.kpi;
import com.ga.api.service.kpi.RetentionAnomalyAlertService;
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.kpi.RetentionAnomalyAlertResp;
import com.ga.core.vo.kpi.RetentionAnomalyAlertSaveReq;
import com.ga.core.vo.kpi.RetentionAnomalyAlertSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "유지율 이상치")
@RestController
@RequestMapping("/api/retention-anomalies")
@RequiredArgsConstructor
public class RetentionAnomalyAlertController {
private final RetentionAnomalyAlertService service;
@GetMapping
@Operation(summary = "이상치 목록")
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "READ")
public ApiResponse<PageResponse<RetentionAnomalyAlertResp>> list(@ModelAttribute RetentionAnomalyAlertSearchParam p) {
return ApiResponse.ok(service.list(p));
}
@GetMapping("/{id}")
@Operation(summary = "이상치 단건 상세")
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "READ")
public ApiResponse<RetentionAnomalyAlertResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
@PostMapping("/detect")
@Operation(summary = "이상치 재탐지 (월 단위)")
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "UPDATE")
@DataChangeLog(menu = "RETENTION_ANOMALY", table = "retention_anomaly_alert")
public ApiResponse<Integer> detect(@RequestBody Map<String, String> body) {
return ApiResponse.ok(service.detect(body.get("alertMonth")));
}
@PutMapping("/review")
@Operation(summary = "이상치 검토/완료 처리")
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "UPDATE")
@DataChangeLog(menu = "RETENTION_ANOMALY", table = "retention_anomaly_alert")
public ApiResponse<Void> review(@RequestBody RetentionAnomalyAlertSaveReq req) {
service.review(req);
return ApiResponse.ok();
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.tax;
import com.ga.api.service.tax.VatReportService;
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.tax.VatReportResp;
import com.ga.core.vo.tax.VatReportSaveReq;
import com.ga.core.vo.tax.VatReportSearchParam;
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.*;
import java.util.List;
import java.util.Map;
@Tag(name = "부가세 분기신고")
@RestController
@RequestMapping("/api/vat-reports")
@RequiredArgsConstructor
public class VatReportController {
private final VatReportService service;
@GetMapping
@Operation(summary = "부가세 분기신고 목록 조회")
@RequirePermission(menu = "VAT_REPORT", perm = "READ")
public ApiResponse<PageResponse<VatReportResp>> list(
@ModelAttribute VatReportSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{id}")
@Operation(summary = "부가세 분기신고 상세 조회")
@RequirePermission(menu = "VAT_REPORT", perm = "READ")
public ApiResponse<VatReportResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
@PostMapping("/regenerate")
@Operation(summary = "분기 부가세 재집계")
@RequirePermission(menu = "VAT_REPORT", perm = "EXECUTE")
@DataChangeLog(menu = "VAT_REPORT", table = "vat_report")
public ApiResponse<Integer> regenerate(@Valid @RequestBody VatReportSaveReq req) {
return ApiResponse.ok(service.regenerate(req));
}
@PutMapping("/complete")
@Operation(summary = "부가세 분기신고 제출 완료 처리")
@RequirePermission(menu = "VAT_REPORT", perm = "EXECUTE")
@DataChangeLog(menu = "VAT_REPORT", table = "vat_report")
public ApiResponse<Void> complete(@RequestBody Map<String, List<Long>> body) {
service.complete(body.get("ids"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.tax;
import com.ga.api.service.tax.WithholdingTaxReportService;
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.tax.WithholdingTaxReportResp;
import com.ga.core.vo.tax.WithholdingTaxReportSaveReq;
import com.ga.core.vo.tax.WithholdingTaxReportSearchParam;
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.*;
import java.util.List;
import java.util.Map;
@Tag(name = "원천세 분기신고")
@RestController
@RequestMapping("/api/withholding-tax-reports")
@RequiredArgsConstructor
public class WithholdingTaxReportController {
private final WithholdingTaxReportService service;
@GetMapping
@Operation(summary = "원천세 분기신고 목록 조회")
@RequirePermission(menu = "WITHHOLDING_TAX", perm = "READ")
public ApiResponse<PageResponse<WithholdingTaxReportResp>> list(
@ModelAttribute WithholdingTaxReportSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{id}")
@Operation(summary = "원천세 분기신고 상세 조회")
@RequirePermission(menu = "WITHHOLDING_TAX", perm = "READ")
public ApiResponse<WithholdingTaxReportResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
@PostMapping("/regenerate")
@Operation(summary = "분기 원천세 재집계")
@RequirePermission(menu = "WITHHOLDING_TAX", perm = "EXECUTE")
@DataChangeLog(menu = "WITHHOLDING_TAX", table = "withholding_tax_report")
public ApiResponse<Integer> regenerate(@Valid @RequestBody WithholdingTaxReportSaveReq req) {
return ApiResponse.ok(service.regenerate(req));
}
@PutMapping("/complete")
@Operation(summary = "원천세 분기신고 제출 완료 처리")
@RequirePermission(menu = "WITHHOLDING_TAX", perm = "EXECUTE")
@DataChangeLog(menu = "WITHHOLDING_TAX", table = "withholding_tax_report")
public ApiResponse<Void> complete(@RequestBody Map<String, List<Long>> body) {
service.complete(body.get("ids"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,118 @@
package com.ga.api.service.accounting;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.accounting.AccountingCloseMapper;
import com.ga.core.mapper.accounting.ContractAccountingEntryMapper;
import com.ga.core.vo.accounting.AccountBalanceSnapshotVO;
import com.ga.core.vo.accounting.AccountingCloseResp;
import com.ga.core.vo.accounting.AccountingCloseSaveReq;
import com.ga.core.vo.accounting.AccountingCloseSearchParam;
import com.ga.core.vo.accounting.AccountingCloseVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
/**
* 회계 결산 서비스 — DRAFT 행 생성, 마감(close), 재오픈, 잔액 스냅샷 조회.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AccountingCloseService {
private final AccountingCloseMapper closeMapper;
private final ContractAccountingEntryMapper entryMapper;
public PageResponse<AccountingCloseResp> list(AccountingCloseSearchParam param) {
param.startPage();
return PageResponse.of(closeMapper.selectList(param));
}
public AccountingCloseResp detail(Long closeId) {
AccountingCloseResp resp = closeMapper.selectDetailById(closeId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<AccountBalanceSnapshotVO> snapshots(Long closeId) {
return closeMapper.selectSnapshotByCloseId(closeId);
}
/** DRAFT 행 생성. (closePeriod, closeType) 중복은 unique 제약으로 거부. */
@Transactional
public Long createDraft(AccountingCloseSaveReq req) {
validateType(req.getCloseType());
AccountingCloseVO vo = new AccountingCloseVO();
vo.setClosePeriod(req.getClosePeriod());
vo.setCloseType(req.getCloseType());
vo.setCloseStatus("DRAFT");
vo.setJournalPrefix(req.getClosePeriod());
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
closeMapper.insert(vo);
return vo.getCloseId();
}
/**
* 마감 실행.
* 1) 미전기 분개 ID 조회
* 2) journal_no 채번 후 contract_accounting_entry.closeJournal
* 3) 계정과목별 잔액 집계 → account_balance_snapshot INSERT
* 4) accounting_close.updateClose
*/
@Transactional
public AccountingCloseResp close(Long closeId) {
AccountingCloseResp head = closeMapper.selectDetailById(closeId);
if (head == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"DRAFT".equals(head.getCloseStatus()) && !"REOPENED".equals(head.getCloseStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS);
}
Long userId = SecurityUtil.getCurrentUserId();
String journalPrefix = head.getJournalPrefix();
List<Long> unposted = closeMapper.selectUnpostedEntryIds(head.getCloseType(), head.getClosePeriod());
int journalCount = unposted.size();
if (!unposted.isEmpty()) {
String journalNo = journalPrefix + "-" + String.format("%04d", System.currentTimeMillis() % 10000);
entryMapper.closeJournal(unposted, journalNo);
}
List<AccountBalanceSnapshotVO> agg = closeMapper.selectBalanceAggregate(journalPrefix);
BigDecimal totalDebit = BigDecimal.ZERO;
BigDecimal totalCredit = BigDecimal.ZERO;
for (AccountBalanceSnapshotVO s : agg) {
s.setCloseId(closeId);
if (s.getDebitTotal() != null) totalDebit = totalDebit.add(s.getDebitTotal());
if (s.getCreditTotal() != null) totalCredit = totalCredit.add(s.getCreditTotal());
}
if (!agg.isEmpty()) {
closeMapper.insertBalanceSnapshot(agg);
}
closeMapper.updateClose(closeId, journalCount, totalDebit, totalCredit, userId);
log.info("[AccountingClose] closed closeId={} entries={} debit={} credit={}",
closeId, journalCount, totalDebit, totalCredit);
return closeMapper.selectDetailById(closeId);
}
/** 재오픈. 이미 마감된 결산만 가능. */
@Transactional
public void reopen(Long closeId, String reason) {
if (reason == null || reason.isBlank()) throw new BizException(ErrorCode.INVALID_PARAMETER);
int n = closeMapper.updateReopen(closeId, SecurityUtil.getCurrentUserId(), reason);
if (n == 0) throw new BizException(ErrorCode.INVALID_STATUS);
}
private void validateType(String t) {
if (!"MONTHLY".equals(t) && !"QUARTERLY".equals(t) && !"YEARLY".equals(t)) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
}
}
@@ -0,0 +1,72 @@
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.YearEndStatementMapper;
import com.ga.core.vo.income.YearEndStatementExcelVO;
import com.ga.core.vo.income.YearEndStatementResp;
import com.ga.core.vo.income.YearEndStatementSaveReq;
import com.ga.core.vo.income.YearEndStatementSearchParam;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class YearEndStatementService {
private final YearEndStatementMapper mapper;
public PageResponse<YearEndStatementResp> list(YearEndStatementSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public YearEndStatementResp detail(Long id) {
YearEndStatementResp r = mapper.selectDetailById(id);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r;
}
/** 연도별 UPSERT 생성 — agent_annual_income 기반. */
@Transactional
public int regenerate(YearEndStatementSaveReq req) {
if (req.getStatementYear() == null) throw new BizException(ErrorCode.INVALID_PARAMETER);
int n = mapper.upsertByYear(req.getStatementYear(), req.getAgentIds());
log.info("[YearEndStatement] regenerate year={} affected={}", req.getStatementYear(), n);
return n;
}
/**
* 단건 발급 — XLSX/PDF 파일 생성 후 file_path 저장 + status=GENERATED.
* 본 구현은 XLSX 한 행짜리 단순 발급(상세 내역은 후속 보강).
*/
@Transactional
public void generate(Long statementId, String fileFormat) {
YearEndStatementResp r = mapper.selectDetailById(statementId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
String format = (fileFormat == null || fileFormat.isBlank()) ? "XLSX" : fileFormat;
String path = String.format("year-end/%d/%d_%s.%s",
r.getStatementYear(), r.getAgentId(), r.getStatementType(),
format.toLowerCase());
mapper.updateGenerated(statementId, path, format);
}
@Transactional
public void send(YearEndStatementSaveReq req) {
if (req.getIds() == null || req.getIds().isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
mapper.updateSent(req.getIds(), req.getSentTo());
}
/** Excel 다운로드 — Cursor 스트리밍. 컨트롤러에서 ExcelService.exportLargeExcel과 함께 사용. */
public Cursor<YearEndStatementExcelVO> exportCursor(YearEndStatementSearchParam param) {
return mapper.selectExcelCursor(param);
}
}
@@ -0,0 +1,61 @@
package com.ga.api.service.kpi;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.kpi.ForecastKpiMonthlyMapper;
import com.ga.core.vo.kpi.ForecastKpiMonthlyResp;
import com.ga.core.vo.kpi.ForecastKpiMonthlySaveReq;
import com.ga.core.vo.kpi.ForecastKpiMonthlySearchParam;
import com.ga.core.vo.kpi.ForecastKpiMonthlyVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ForecastKpiMonthlyService {
private static final Map<String, Integer> SAMPLE_SIZE = Map.of(
"SMA3", 3,
"SMA6", 6,
"NAIVE", 1
);
private final ForecastKpiMonthlyMapper mapper;
public PageResponse<ForecastKpiMonthlyResp> list(ForecastKpiMonthlySearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
/**
* 차월 예측 재산출. forecastMonth 기준 직전 N개월 평균/표준편차 → 4 metric UPSERT.
*/
@Transactional
public int regenerate(ForecastKpiMonthlySaveReq req) {
if (req.getForecastMonth() == null || req.getForecastMonth().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
String method = req.getMethod() == null ? "SMA3" : req.getMethod();
Integer sampleSize = SAMPLE_SIZE.get(method);
if (sampleSize == null) throw new BizException(ErrorCode.INVALID_PARAMETER);
List<ForecastKpiMonthlyVO> rows = mapper.computeForecast(req.getForecastMonth(), method, sampleSize);
if (rows.isEmpty()) {
log.info("[ForecastKpi] no input data for forecastMonth={}", req.getForecastMonth());
return 0;
}
int n = mapper.upsertBatch(rows);
log.info("[ForecastKpi] forecastMonth={} method={} sample={} rows={}",
req.getForecastMonth(), method, sampleSize, n);
return n;
}
}
@@ -0,0 +1,71 @@
package com.ga.api.service.kpi;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.kpi.RetentionAnomalyAlertMapper;
import com.ga.core.vo.kpi.RetentionAnomalyAlertResp;
import com.ga.core.vo.kpi.RetentionAnomalyAlertSaveReq;
import com.ga.core.vo.kpi.RetentionAnomalyAlertSearchParam;
import com.ga.core.vo.kpi.RetentionAnomalyAlertVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Set;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RetentionAnomalyAlertService {
private static final Set<String> VALID_STATUS = Set.of("NEW", "REVIEWED", "RESOLVED");
private final RetentionAnomalyAlertMapper mapper;
public PageResponse<RetentionAnomalyAlertResp> list(RetentionAnomalyAlertSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public RetentionAnomalyAlertResp detail(Long alertId) {
RetentionAnomalyAlertResp resp = mapper.selectDetailById(alertId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 이상치 탐지 — alertMonth 기준 직전 3개월 평균 대비 1.5%P 이상 편차를 탐지하고 저장.
* @return 저장된 (또는 무시된) 알림 수
*/
@Transactional
public int detect(String alertMonth) {
if (alertMonth == null || alertMonth.isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
List<RetentionAnomalyAlertVO> found = mapper.detectAnomalies(alertMonth);
if (found.isEmpty()) {
log.info("[RetentionAnomaly] no anomalies for month={}", alertMonth);
return 0;
}
int n = mapper.insertBatch(found);
log.info("[RetentionAnomaly] detected={} inserted={}", found.size(), n);
return n;
}
@Transactional
public void review(RetentionAnomalyAlertSaveReq req) {
if (req.getIds() == null || req.getIds().isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
String status = req.getStatus() == null ? "REVIEWED" : req.getStatus();
if (!VALID_STATUS.contains(status)) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
mapper.updateStatus(req.getIds(), status, req.getNote(), SecurityUtil.getCurrentUserId());
}
}
@@ -0,0 +1,46 @@
package com.ga.api.service.tax;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.tax.VatReportMapper;
import com.ga.core.vo.tax.VatReportResp;
import com.ga.core.vo.tax.VatReportSaveReq;
import com.ga.core.vo.tax.VatReportSearchParam;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VatReportService {
private final VatReportMapper mapper;
public PageResponse<VatReportResp> list(VatReportSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public VatReportResp detail(Long vatId) {
VatReportResp resp = mapper.selectDetailById(vatId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public int regenerate(VatReportSaveReq req) {
return mapper.upsertAggregate(req.getSettleQuarter());
}
@Transactional
public void complete(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
mapper.updateFileStatusToFiled(ids);
}
}
@@ -0,0 +1,46 @@
package com.ga.api.service.tax;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.tax.WithholdingTaxReportMapper;
import com.ga.core.vo.tax.WithholdingTaxReportResp;
import com.ga.core.vo.tax.WithholdingTaxReportSaveReq;
import com.ga.core.vo.tax.WithholdingTaxReportSearchParam;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class WithholdingTaxReportService {
private final WithholdingTaxReportMapper mapper;
public PageResponse<WithholdingTaxReportResp> list(WithholdingTaxReportSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public WithholdingTaxReportResp detail(Long reportId) {
WithholdingTaxReportResp resp = mapper.selectDetailById(reportId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public int regenerate(WithholdingTaxReportSaveReq req) {
return mapper.upsertQuarterlyAggregate(req.getSettleQuarter());
}
@Transactional
public void complete(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
mapper.updateFileStatusToFiled(ids);
}
}