From b8f33f13d1ef177c8af44a41cb9c812f8578e5d5 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Wed, 13 May 2026 00:18:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(api):=20DeductionService=20+=20PaymentServ?= =?UTF-8?q?ice=20=EC=B0=A8=EA=B0=90=20=ED=86=B5=ED=95=A9=20+=20DeductionCo?= =?UTF-8?q?ntroller=20(=EB=8F=84=EB=A9=94=EC=9D=B8=20P0-2-c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../deduction/DeductionController.java | 59 +++++++++++ .../controller/settle/PaymentController.java | 21 ++++ .../service/deduction/DeductionService.java | 99 +++++++++++++++++++ .../ga/api/service/settle/PaymentService.java | 51 ++++++++++ .../mapper/settle/AgentDeductionMapper.java | 13 +++ .../ga/core/mapper/settle/PaymentMapper.java | 4 + 6 files changed, 247 insertions(+) create mode 100644 ga-api/src/main/java/com/ga/api/controller/deduction/DeductionController.java create mode 100644 ga-api/src/main/java/com/ga/api/service/deduction/DeductionService.java diff --git a/ga-api/src/main/java/com/ga/api/controller/deduction/DeductionController.java b/ga-api/src/main/java/com/ga/api/controller/deduction/DeductionController.java new file mode 100644 index 0000000..5e22d37 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/deduction/DeductionController.java @@ -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> list(@ModelAttribute AgentDeductionSearchParam param) { + return ApiResponse.ok(deductionService.list(param)); + } + + @GetMapping("/{deductionId}") + @RequirePermission(menu = "DEDUCTION", perm = "READ") + public ApiResponse detail(@PathVariable Long deductionId) { + return ApiResponse.ok(deductionService.detail(deductionId)); + } + + @PostMapping + @RequirePermission(menu = "DEDUCTION", perm = "CREATE") + @DataChangeLog(menu = "DEDUCTION", table = "agent_deduction") + public ApiResponse 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 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 cancel(@PathVariable Long deductionId) { + deductionService.cancel(deductionId); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java index c2cf63d..d676c37 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java @@ -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 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 applyDeductionsForMonth( + @Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) { + paymentService.applyDeductionsForMonth(settleMonth); + return ApiResponse.ok(); + } } diff --git a/ga-api/src/main/java/com/ga/api/service/deduction/DeductionService.java b/ga-api/src/main/java/com/ga/api/service/deduction/DeductionService.java new file mode 100644 index 0000000..b4f60cb --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/deduction/DeductionService.java @@ -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 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 selectPendingByMonth(String settleMonth) { + return mapper.selectPendingByMonth(settleMonth); + } + + @Transactional(readOnly = true) + public List selectPendingByAgent(Long agentId, String settleMonth) { + return mapper.selectPendingByAgent(agentId, settleMonth); + } + + @Transactional + public void markAsApplied(List deductionIds) { + if (deductionIds.isEmpty()) return; + mapper.updateStatusBatch(deductionIds, DeductionStatus.APPLIED.name()); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/settle/PaymentService.java b/ga-api/src/main/java/com/ga/api/service/settle/PaymentService.java index be6f3e5..acb91d4 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/PaymentService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/PaymentService.java @@ -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 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 pending = deductionService.selectPendingByAgent(payment.getAgentId(), settleMonth); + if (pending.isEmpty()) return; + + // 명세 기록 + List 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 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 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); diff --git a/ga-core/src/main/java/com/ga/core/mapper/settle/AgentDeductionMapper.java b/ga-core/src/main/java/com/ga/core/mapper/settle/AgentDeductionMapper.java index 3891c5a..3c2a1bf 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/settle/AgentDeductionMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/settle/AgentDeductionMapper.java @@ -25,4 +25,17 @@ public interface AgentDeductionMapper { /** 정산월 전체 PENDING 목록 (배치용) */ List selectPendingByMonth(@Param("settleMonth") String settleMonth); + + /** 설계사의 PENDING 목록 (단건 차감 적용용) */ + List selectPendingByAgent(@Param("agentId") Long agentId, + @Param("settleMonth") String settleMonth); + + /** 일괄 상태 전이 */ + int updateStatusBatch(@Param("deductionIds") List deductionIds, + @Param("status") String status); + + /** 중복 검증 */ + int countByAgentMonthType(@Param("agentId") Long agentId, + @Param("settleMonth") String settleMonth, + @Param("deductionType") String deductionType); } diff --git a/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java index 18e8f8e..2dd8cd4 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java @@ -27,6 +27,10 @@ public interface PaymentMapper { @Param("netAmount") BigDecimal netAmount); int updateTaxesBatch(@Param("list") List list); + int updateDeduction(@Param("paymentId") Long paymentId, + @Param("deductionAmount") BigDecimal deductionAmount, + @Param("netAmount") BigDecimal netAmount); + List selectPendingByMonthAndBank(@Param("settleMonth") String settleMonth, @Param("bankCode") String bankCode); List selectBySettleMonth(@Param("settleMonth") String settleMonth);