From d8d64625a98808cad5a9db393aa30682c519243a Mon Sep 17 00:00:00 2001 From: GA Pro Date: Fri, 29 May 2026 20:51:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20P6=20=ED=9B=84=EC=86=8D=204=20=E2=80=94?= =?UTF-8?q?=20=EC=B6=9C=EA=B8=88=20=ED=8F=BC=20=EC=9E=94=EC=97=AC=20?= =?UTF-8?q?=ED=95=9C=EB=8F=84=20=EB=AF=B8=EB=A6=AC=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드 GET /api/mobile/me/settle-masters/{id}/withdrawable-balance · 응답 {settleId, netAmount, usedAmount, remainingAmount} · 본인 settle_master 검증 후 net - sumActiveAmountByMaster 반환 · createWithdrawRequest 가 동일 조회 로직 재사용 (중복 제거) - PWA WithdrawCreate: settleMaster 선택 시 잔액 카드 표시 + amount 클라이언트 사전 검증 검증: ga-api compileJava BUILD SUCCESSFUL / ga-mobile-pwa tsc --noEmit 0 오류 (라이브 e2e 는 DB/docker 미가동으로 차단 — 환경 복구 후 스모크 예정) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controller/mobile/MobileMeController.java | 6 +++ .../api/service/mobile/MobileMeService.java | 50 ++++++++++++++----- ga-mobile-pwa/src/api/withdraw.ts | 13 +++++ ga-mobile-pwa/src/pages/WithdrawCreate.tsx | 32 ++++++++++++ 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java index 2cacd19..bb0e774 100644 --- a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java @@ -3,6 +3,7 @@ package com.ga.api.controller.mobile; import com.ga.api.service.mobile.MobileMeService; import com.ga.api.service.mobile.MobileMeService.MeSummary; import com.ga.api.service.mobile.MobileMeService.MobileWithdrawSaveReq; +import com.ga.api.service.mobile.MobileMeService.WithdrawableBalance; import com.ga.common.model.ApiResponse; import com.ga.common.model.PageResponse; import com.ga.core.vo.notice.UserNotificationResp; @@ -69,6 +70,11 @@ public class MobileMeController { return ApiResponse.ok(service.withdrawableSettleMasters()); } + @GetMapping("/settle-masters/{settleMasterId}/withdrawable-balance") + public ApiResponse withdrawableBalance(@PathVariable Long settleMasterId) { + return ApiResponse.ok(service.withdrawableBalance(settleMasterId)); + } + @GetMapping("/withdraw-requests") public ApiResponse> withdrawRequests( @ModelAttribute WithdrawRequestSearchParam param) { diff --git a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java index 654090a..2812e1f 100644 --- a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java +++ b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java @@ -168,6 +168,28 @@ public class MobileMeService { withdrawRequestService.cancel(requestId); } + /** + * 본인 정산 마스터의 출금 가능 잔액 조회 — 출금 신청 폼 미리 표시용. + * 본인 소유가 아니면 FORBIDDEN, 없으면 NOT_FOUND. + */ + @Transactional(readOnly = true) + public WithdrawableBalance withdrawableBalance(Long settleMasterId) { + Long agentId = requireAgentId(); + var master = settleMasterMapper.selectById(settleMasterId); + if (master == null) throw new BizException(ErrorCode.NOT_FOUND, "정산 마스터를 찾을 수 없습니다"); + if (!agentId.equals(master.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN); + + BigDecimal netAmount = master.getNetAmount() != null ? master.getNetAmount() : BigDecimal.ZERO; + BigDecimal used = withdrawMapper.sumActiveAmountByMaster(settleMasterId); + if (used == null) used = BigDecimal.ZERO; + return WithdrawableBalance.builder() + .settleId(settleMasterId) + .netAmount(netAmount) + .usedAmount(used) + .remainingAmount(netAmount.subtract(used)) + .build(); + } + /** * 본인 출금 신청 등록. agentId / requestedBy 는 토큰에서 강제 결정. * 클라이언트가 보낸 값은 무시한다. @@ -175,22 +197,14 @@ public class MobileMeService { @Transactional public Long createWithdrawRequest(MobileWithdrawSaveReq req) { Long userId = currentUserId(); - Long agentId = requireAgentId(); - // 1) settle_master 본인 소유 + net_amount 확보 - var master = settleMasterMapper.selectById(req.getSettleMasterId()); - if (master == null) throw new BizException(ErrorCode.NOT_FOUND, "정산 마스터를 찾을 수 없습니다"); - if (!agentId.equals(master.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN); - - // 2) 잔여 한도 = net_amount - 활성 출금합계 - BigDecimal netAmount = master.getNetAmount() != null ? master.getNetAmount() : BigDecimal.ZERO; - BigDecimal used = withdrawMapper.sumActiveAmountByMaster(req.getSettleMasterId()); - if (used == null) used = BigDecimal.ZERO; - BigDecimal remaining = netAmount.subtract(used); - if (req.getAmount().compareTo(remaining) > 0) { + // 본인 소유 검증 + 잔여 한도 = net_amount - 활성 출금합계 (조회 로직 재사용) + WithdrawableBalance balance = withdrawableBalance(req.getSettleMasterId()); + if (req.getAmount().compareTo(balance.getRemainingAmount()) > 0) { throw new BizException(ErrorCode.INVALID_PARAMETER, - "출금 가능 잔액(" + remaining + ")을 초과합니다"); + "출금 가능 잔액(" + balance.getRemainingAmount() + ")을 초과합니다"); } + Long agentId = requireAgentId(); WithdrawRequestSaveReq inner = new WithdrawRequestSaveReq(); inner.setSettleMasterId(req.getSettleMasterId()); @@ -234,6 +248,16 @@ public class MobileMeService { private int unreadNotificationCount; } + /** 출금 가능 잔액 — 폼 미리 표시용. */ + @Data + @Builder + public static class WithdrawableBalance { + private Long settleId; + private BigDecimal netAmount; + private BigDecimal usedAmount; + private BigDecimal remainingAmount; + } + /** 모바일 출금 신청 — 토큰에서 agentId/requestedBy 강제. */ @Data public static class MobileWithdrawSaveReq { diff --git a/ga-mobile-pwa/src/api/withdraw.ts b/ga-mobile-pwa/src/api/withdraw.ts index e48b622..264e2f9 100644 --- a/ga-mobile-pwa/src/api/withdraw.ts +++ b/ga-mobile-pwa/src/api/withdraw.ts @@ -41,6 +41,13 @@ export interface WithdrawCreateReq { accountNo: string; } +export interface WithdrawableBalance { + settleId: number; + netAmount: number; + usedAmount: number; + remainingAmount: number; +} + export const withdrawApi = { /** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */ list: (params: WithdrawSearchParam = {}) => @@ -50,6 +57,12 @@ export const withdrawApi = { settleMasters: () => unwrap(api.get('/api/mobile/me/settle-masters')), + /** 선택한 정산 마스터의 출금 가능 잔액 (net - 활성 출금합계). */ + withdrawableBalance: (settleMasterId: number) => + unwrap( + api.get(`/api/mobile/me/settle-masters/${settleMasterId}/withdrawable-balance`), + ), + /** 본인 출금 신청 등록. */ create: (req: WithdrawCreateReq) => unwrap(api.post('/api/mobile/me/withdraw-requests', req)), diff --git a/ga-mobile-pwa/src/pages/WithdrawCreate.tsx b/ga-mobile-pwa/src/pages/WithdrawCreate.tsx index c5b1294..d034b2c 100644 --- a/ga-mobile-pwa/src/pages/WithdrawCreate.tsx +++ b/ga-mobile-pwa/src/pages/WithdrawCreate.tsx @@ -18,6 +18,7 @@ export default function WithdrawCreate() { const [form] = Form.useForm(); const [pickerVisible, setPickerVisible] = useState(false); const [pickedLabel, setPickedLabel] = useState(''); + const [selectedId, setSelectedId] = useState(undefined); const { data: settleMasters = [], isLoading } = useQuery({ queryKey: ['mobile.me.settle-masters'], @@ -25,6 +26,13 @@ export default function WithdrawCreate() { retry: false, }); + const { data: balance } = useQuery({ + queryKey: ['mobile.me.withdrawable-balance', selectedId], + queryFn: () => withdrawApi.withdrawableBalance(selectedId as number), + enabled: selectedId != null, + retry: false, + }); + const pickerColumns: PickerColumn[] = useMemo( () => [ settleMasters.map((s: SettleMasterResp) => ({ @@ -56,6 +64,10 @@ export default function WithdrawCreate() { Toast.show({ icon: 'fail', content: '금액을 입력하세요' }); return; } + if (balance && amount > balance.remainingAmount) { + Toast.show({ icon: 'fail', content: `출금 가능 잔액(${fmt(balance.remainingAmount)}원)을 초과합니다` }); + return; + } create.mutate({ settleMasterId: Number(values.settleMasterId), amount, @@ -67,6 +79,25 @@ export default function WithdrawCreate() { return (
navigate(-1)}>출금 신청 + {balance && ( +
+
출금 가능 잔액
+
+ {fmt(balance.remainingAmount)}원 +
+
+ 실수령 {fmt(balance.netAmount)}원 · 신청중 {fmt(balance.usedAmount)}원 +
+
+ )}
{ const id = val[0] != null ? Number(val[0]) : undefined; form.setFieldValue('settleMasterId', id); + setSelectedId(id); const picked = settleMasters.find((s) => String(s.settleId) === val[0]); if (picked) setPickedLabel(`${picked.settleMonth} · 실수령 ${fmt(picked.netAmount)}원`); }}