feat(api): DeductionService + PaymentService 차감 통합 + DeductionController (도메인 P0-2-c)

P0-2 공제/차감 도메인 마무리. ga-batch 정산 step 통합은 도메인 P0 마무리에서.

DeductionService (신규):
- CRUD: list/detail/create/update/cancel
- create는 (agent_id, settle_month, deduction_type) 유니크 중복 검증
- cancel은 status를 CANCELLED로 전이
- 정산용: sumPendingByAgent, selectPendingByMonth, markAsApplied(batch)

PaymentService 확장:
- applyDeductions(paymentId, settleMonth) — 단건:
  1. PENDING 차감 조회
  2. payment_deduction_detail INSERT
  3. agent_deduction.status → APPLIED 일괄 전이
  4. payment.deduction_amount UPDATE
  5. net_amount 재계산 (newNet = currentNet - deductionTotal)
- applyDeductionsForMonth(settleMonth) — 월 일괄

net_amount 공식 갱신:
- 세금 적용(calculateTaxes): amount - incomeTax - localTax + vat
- 차감 적용(applyDeductions): currentNet - deductionTotal
- 최종: amount - incomeTax - localTax + vat - deduction

Mapper 추가 (ga-core):
- AgentDeductionMapper: selectPendingByAgent, updateStatusBatch, countByAgentMonthType
- PaymentMapper: updateDeduction

DeductionController + PaymentController 엔드포인트 추가:
- /api/deductions CRUD (5종)
- /api/payments/{id}/apply-deductions
- /api/payments/apply-deductions?settleMonth=YYYYMM
This commit is contained in:
GA Pro
2026-05-13 00:18:53 +09:00
parent a3ba7fa361
commit b8f33f13d1
6 changed files with 247 additions and 0 deletions
@@ -0,0 +1,59 @@
package com.ga.api.controller.deduction;
import com.ga.api.service.deduction.DeductionService;
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.settle.AgentDeductionResp;
import com.ga.core.vo.settle.AgentDeductionSaveReq;
import com.ga.core.vo.settle.AgentDeductionSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "공제 관리")
@RestController
@RequestMapping("/api/deductions")
@RequiredArgsConstructor
public class DeductionController {
private final DeductionService deductionService;
@GetMapping
@RequirePermission(menu = "DEDUCTION", perm = "READ")
public ApiResponse<PageResponse<AgentDeductionResp>> list(@ModelAttribute AgentDeductionSearchParam param) {
return ApiResponse.ok(deductionService.list(param));
}
@GetMapping("/{deductionId}")
@RequirePermission(menu = "DEDUCTION", perm = "READ")
public ApiResponse<AgentDeductionResp> detail(@PathVariable Long deductionId) {
return ApiResponse.ok(deductionService.detail(deductionId));
}
@PostMapping
@RequirePermission(menu = "DEDUCTION", perm = "CREATE")
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
public ApiResponse<Long> create(@Valid @RequestBody AgentDeductionSaveReq req) {
return ApiResponse.ok(deductionService.create(req));
}
@PutMapping("/{deductionId}")
@RequirePermission(menu = "DEDUCTION", perm = "UPDATE")
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
public ApiResponse<Void> update(@PathVariable Long deductionId,
@Valid @RequestBody AgentDeductionSaveReq req) {
deductionService.update(deductionId, req);
return ApiResponse.ok();
}
@PutMapping("/{deductionId}/cancel")
@RequirePermission(menu = "DEDUCTION", perm = "UPDATE")
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
public ApiResponse<Void> cancel(@PathVariable Long deductionId) {
deductionService.cancel(deductionId);
return ApiResponse.ok();
}
}
@@ -67,4 +67,25 @@ public class PaymentController {
paymentService.calculateTaxesForMonth(settleMonth);
return ApiResponse.ok();
}
@Operation(summary = "차감 단건 적용")
@PostMapping("/{paymentId}/apply-deductions")
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<Void> applyDeductions(
@PathVariable Long paymentId,
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) {
paymentService.applyDeductions(paymentId, settleMonth);
return ApiResponse.ok();
}
@Operation(summary = "차감 월별 일괄 적용")
@PostMapping("/apply-deductions")
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<Void> applyDeductionsForMonth(
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) {
paymentService.applyDeductionsForMonth(settleMonth);
return ApiResponse.ok();
}
}
@@ -0,0 +1,99 @@
package com.ga.api.service.deduction;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.settle.AgentDeductionMapper;
import com.ga.core.vo.settle.AgentDeductionResp;
import com.ga.core.vo.settle.AgentDeductionSaveReq;
import com.ga.core.vo.settle.AgentDeductionSearchParam;
import com.ga.core.vo.settle.AgentDeductionVO;
import com.ga.core.vo.settle.DeductionStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class DeductionService {
private final AgentDeductionMapper mapper;
@Transactional(readOnly = true)
public PageResponse<AgentDeductionResp> list(AgentDeductionSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
@Transactional(readOnly = true)
public AgentDeductionResp detail(Long deductionId) {
AgentDeductionResp resp = mapper.selectDetailById(deductionId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(AgentDeductionSaveReq req) {
if (mapper.countByAgentMonthType(req.getAgentId(), req.getSettleMonth(), req.getDeductionType()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA,
"동일 설계사/정산월/차감유형 조합이 이미 존재합니다");
}
AgentDeductionVO vo = new AgentDeductionVO();
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setDeductionType(req.getDeductionType());
vo.setAmount(req.getAmount());
vo.setDescription(req.getDescription());
vo.setStatus(DeductionStatus.PENDING.name());
mapper.insert(vo);
return vo.getDeductionId();
}
@Transactional
public void update(Long deductionId, AgentDeductionSaveReq req) {
AgentDeductionVO vo = mapper.selectById(deductionId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!DeductionStatus.PENDING.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "PENDING 상태만 수정 가능합니다");
}
vo.setAmount(req.getAmount());
vo.setDescription(req.getDescription());
mapper.update(vo);
}
@Transactional
public void cancel(Long deductionId) {
AgentDeductionVO vo = mapper.selectById(deductionId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (DeductionStatus.APPLIED.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "이미 적용된 차감은 취소할 수 없습니다");
}
mapper.updateStatus(deductionId, DeductionStatus.CANCELLED.name());
}
@Transactional(readOnly = true)
public BigDecimal sumPendingByAgent(Long agentId, String settleMonth) {
BigDecimal sum = mapper.sumPendingByAgent(agentId, settleMonth);
return sum != null ? sum : BigDecimal.ZERO;
}
@Transactional(readOnly = true)
public List<AgentDeductionVO> selectPendingByMonth(String settleMonth) {
return mapper.selectPendingByMonth(settleMonth);
}
@Transactional(readOnly = true)
public List<AgentDeductionVO> selectPendingByAgent(Long agentId, String settleMonth) {
return mapper.selectPendingByAgent(agentId, settleMonth);
}
@Transactional
public void markAsApplied(List<Long> deductionIds) {
if (deductionIds.isEmpty()) return;
mapper.updateStatusBatch(deductionIds, DeductionStatus.APPLIED.name());
}
}
@@ -1,5 +1,6 @@
package com.ga.api.service.settle;
import com.ga.api.service.deduction.DeductionService;
import com.ga.api.service.tax.TaxBreakdown;
import com.ga.api.service.tax.TaxCalculator;
import com.ga.api.service.transfer.TransferFileService;
@@ -7,10 +8,13 @@ import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.settle.PaymentDeductionDetailMapper;
import com.ga.core.mapper.settle.PaymentMapper;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import com.ga.core.vo.settle.AgentDeductionVO;
import com.ga.core.vo.settle.PaymentDeductionDetailVO;
import com.ga.core.vo.settle.PaymentResp;
import com.ga.core.vo.settle.PaymentVO;
import com.ga.core.vo.settle.SettleSearchParam;
@@ -20,7 +24,10 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
@@ -29,8 +36,10 @@ public class PaymentService {
private final PaymentMapper mapper;
private final AgentMapper agentMapper;
private final PaymentDeductionDetailMapper deductionDetailMapper;
private final TransferFileService transferFileService;
private final TaxCalculator taxCalculator;
private final DeductionService deductionService;
@Transactional(readOnly = true)
public PageResponse<PaymentResp> list(SettleSearchParam param) {
@@ -68,6 +77,48 @@ public class PaymentService {
}
}
@Transactional
public void applyDeductions(Long paymentId, String settleMonth) {
PaymentVO payment = mapper.selectById(paymentId);
if (payment == null) throw new BizException(ErrorCode.NOT_FOUND);
List<AgentDeductionVO> pending = deductionService.selectPendingByAgent(payment.getAgentId(), settleMonth);
if (pending.isEmpty()) return;
// 명세 기록
List<PaymentDeductionDetailVO> details = pending.stream().map(d -> {
PaymentDeductionDetailVO detail = new PaymentDeductionDetailVO();
detail.setPaymentId(paymentId);
detail.setDeductionType(d.getDeductionType());
detail.setAmount(d.getAmount());
detail.setDescription(d.getDescription());
detail.setAppliedAt(LocalDateTime.now());
return detail;
}).collect(Collectors.toList());
deductionDetailMapper.insertBatch(details);
// 상태 전이
List<Long> ids = pending.stream().map(AgentDeductionVO::getDeductionId).collect(Collectors.toList());
deductionService.markAsApplied(ids);
// deduction_amount + net_amount UPDATE
// net_amount = amount incomeTax localTax + vat deductionAmount
BigDecimal deductionTotal = pending.stream()
.map(AgentDeductionVO::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal currentNet = payment.getNetAmount() != null ? payment.getNetAmount() : payment.getPayAmount();
BigDecimal newNet = currentNet.subtract(deductionTotal);
mapper.updateDeduction(paymentId, deductionTotal, newNet);
}
@Transactional
public void applyDeductionsForMonth(String settleMonth) {
List<PaymentVO> payments = mapper.selectBySettleMonth(settleMonth);
for (PaymentVO payment : payments) {
applyDeductions(payment.getPaymentId(), settleMonth);
}
}
private void applyTaxes(PaymentVO payment) {
AgentVO agent = agentMapper.selectById(payment.getAgentId());
TaxType taxType = parseTaxType(agent);
@@ -25,4 +25,17 @@ public interface AgentDeductionMapper {
/** 정산월 전체 PENDING 목록 (배치용) */
List<AgentDeductionVO> selectPendingByMonth(@Param("settleMonth") String settleMonth);
/** 설계사의 PENDING 목록 (단건 차감 적용용) */
List<AgentDeductionVO> selectPendingByAgent(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
/** 일괄 상태 전이 */
int updateStatusBatch(@Param("deductionIds") List<Long> deductionIds,
@Param("status") String status);
/** 중복 검증 */
int countByAgentMonthType(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth,
@Param("deductionType") String deductionType);
}
@@ -27,6 +27,10 @@ public interface PaymentMapper {
@Param("netAmount") BigDecimal netAmount);
int updateTaxesBatch(@Param("list") List<PaymentVO> list);
int updateDeduction(@Param("paymentId") Long paymentId,
@Param("deductionAmount") BigDecimal deductionAmount,
@Param("netAmount") BigDecimal netAmount);
List<PaymentVO> selectPendingByMonthAndBank(@Param("settleMonth") String settleMonth,
@Param("bankCode") String bankCode);
List<PaymentVO> selectBySettleMonth(@Param("settleMonth") String settleMonth);