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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user