feat: P6 후속 4 — 출금 폼 잔여 한도 미리 표시
- 백엔드 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) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package com.ga.api.controller.mobile;
|
|||||||
import com.ga.api.service.mobile.MobileMeService;
|
import com.ga.api.service.mobile.MobileMeService;
|
||||||
import com.ga.api.service.mobile.MobileMeService.MeSummary;
|
import com.ga.api.service.mobile.MobileMeService.MeSummary;
|
||||||
import com.ga.api.service.mobile.MobileMeService.MobileWithdrawSaveReq;
|
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.ApiResponse;
|
||||||
import com.ga.common.model.PageResponse;
|
import com.ga.common.model.PageResponse;
|
||||||
import com.ga.core.vo.notice.UserNotificationResp;
|
import com.ga.core.vo.notice.UserNotificationResp;
|
||||||
@@ -69,6 +70,11 @@ public class MobileMeController {
|
|||||||
return ApiResponse.ok(service.withdrawableSettleMasters());
|
return ApiResponse.ok(service.withdrawableSettleMasters());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/settle-masters/{settleMasterId}/withdrawable-balance")
|
||||||
|
public ApiResponse<WithdrawableBalance> withdrawableBalance(@PathVariable Long settleMasterId) {
|
||||||
|
return ApiResponse.ok(service.withdrawableBalance(settleMasterId));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/withdraw-requests")
|
@GetMapping("/withdraw-requests")
|
||||||
public ApiResponse<PageResponse<WithdrawRequestResp>> withdrawRequests(
|
public ApiResponse<PageResponse<WithdrawRequestResp>> withdrawRequests(
|
||||||
@ModelAttribute WithdrawRequestSearchParam param) {
|
@ModelAttribute WithdrawRequestSearchParam param) {
|
||||||
|
|||||||
@@ -168,6 +168,28 @@ public class MobileMeService {
|
|||||||
withdrawRequestService.cancel(requestId);
|
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 는 토큰에서 강제 결정.
|
* 본인 출금 신청 등록. agentId / requestedBy 는 토큰에서 강제 결정.
|
||||||
* 클라이언트가 보낸 값은 무시한다.
|
* 클라이언트가 보낸 값은 무시한다.
|
||||||
@@ -175,22 +197,14 @@ public class MobileMeService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public Long createWithdrawRequest(MobileWithdrawSaveReq req) {
|
public Long createWithdrawRequest(MobileWithdrawSaveReq req) {
|
||||||
Long userId = currentUserId();
|
Long userId = currentUserId();
|
||||||
Long agentId = requireAgentId();
|
|
||||||
|
|
||||||
// 1) settle_master 본인 소유 + net_amount 확보
|
// 본인 소유 검증 + 잔여 한도 = net_amount - 활성 출금합계 (조회 로직 재사용)
|
||||||
var master = settleMasterMapper.selectById(req.getSettleMasterId());
|
WithdrawableBalance balance = withdrawableBalance(req.getSettleMasterId());
|
||||||
if (master == null) throw new BizException(ErrorCode.NOT_FOUND, "정산 마스터를 찾을 수 없습니다");
|
if (req.getAmount().compareTo(balance.getRemainingAmount()) > 0) {
|
||||||
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) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
||||||
"출금 가능 잔액(" + remaining + ")을 초과합니다");
|
"출금 가능 잔액(" + balance.getRemainingAmount() + ")을 초과합니다");
|
||||||
}
|
}
|
||||||
|
Long agentId = requireAgentId();
|
||||||
|
|
||||||
WithdrawRequestSaveReq inner = new WithdrawRequestSaveReq();
|
WithdrawRequestSaveReq inner = new WithdrawRequestSaveReq();
|
||||||
inner.setSettleMasterId(req.getSettleMasterId());
|
inner.setSettleMasterId(req.getSettleMasterId());
|
||||||
@@ -234,6 +248,16 @@ public class MobileMeService {
|
|||||||
private int unreadNotificationCount;
|
private int unreadNotificationCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 출금 가능 잔액 — 폼 미리 표시용. */
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class WithdrawableBalance {
|
||||||
|
private Long settleId;
|
||||||
|
private BigDecimal netAmount;
|
||||||
|
private BigDecimal usedAmount;
|
||||||
|
private BigDecimal remainingAmount;
|
||||||
|
}
|
||||||
|
|
||||||
/** 모바일 출금 신청 — 토큰에서 agentId/requestedBy 강제. */
|
/** 모바일 출금 신청 — 토큰에서 agentId/requestedBy 강제. */
|
||||||
@Data
|
@Data
|
||||||
public static class MobileWithdrawSaveReq {
|
public static class MobileWithdrawSaveReq {
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export interface WithdrawCreateReq {
|
|||||||
accountNo: string;
|
accountNo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WithdrawableBalance {
|
||||||
|
settleId: number;
|
||||||
|
netAmount: number;
|
||||||
|
usedAmount: number;
|
||||||
|
remainingAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const withdrawApi = {
|
export const withdrawApi = {
|
||||||
/** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */
|
/** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */
|
||||||
list: (params: WithdrawSearchParam = {}) =>
|
list: (params: WithdrawSearchParam = {}) =>
|
||||||
@@ -50,6 +57,12 @@ export const withdrawApi = {
|
|||||||
settleMasters: () =>
|
settleMasters: () =>
|
||||||
unwrap<SettleMasterResp[]>(api.get('/api/mobile/me/settle-masters')),
|
unwrap<SettleMasterResp[]>(api.get('/api/mobile/me/settle-masters')),
|
||||||
|
|
||||||
|
/** 선택한 정산 마스터의 출금 가능 잔액 (net - 활성 출금합계). */
|
||||||
|
withdrawableBalance: (settleMasterId: number) =>
|
||||||
|
unwrap<WithdrawableBalance>(
|
||||||
|
api.get(`/api/mobile/me/settle-masters/${settleMasterId}/withdrawable-balance`),
|
||||||
|
),
|
||||||
|
|
||||||
/** 본인 출금 신청 등록. */
|
/** 본인 출금 신청 등록. */
|
||||||
create: (req: WithdrawCreateReq) =>
|
create: (req: WithdrawCreateReq) =>
|
||||||
unwrap<number>(api.post('/api/mobile/me/withdraw-requests', req)),
|
unwrap<number>(api.post('/api/mobile/me/withdraw-requests', req)),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function WithdrawCreate() {
|
|||||||
const [form] = Form.useForm<FormValues>();
|
const [form] = Form.useForm<FormValues>();
|
||||||
const [pickerVisible, setPickerVisible] = useState(false);
|
const [pickerVisible, setPickerVisible] = useState(false);
|
||||||
const [pickedLabel, setPickedLabel] = useState<string>('');
|
const [pickedLabel, setPickedLabel] = useState<string>('');
|
||||||
|
const [selectedId, setSelectedId] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const { data: settleMasters = [], isLoading } = useQuery({
|
const { data: settleMasters = [], isLoading } = useQuery({
|
||||||
queryKey: ['mobile.me.settle-masters'],
|
queryKey: ['mobile.me.settle-masters'],
|
||||||
@@ -25,6 +26,13 @@ export default function WithdrawCreate() {
|
|||||||
retry: false,
|
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(
|
const pickerColumns: PickerColumn[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
settleMasters.map((s: SettleMasterResp) => ({
|
settleMasters.map((s: SettleMasterResp) => ({
|
||||||
@@ -56,6 +64,10 @@ export default function WithdrawCreate() {
|
|||||||
Toast.show({ icon: 'fail', content: '금액을 입력하세요' });
|
Toast.show({ icon: 'fail', content: '금액을 입력하세요' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (balance && amount > balance.remainingAmount) {
|
||||||
|
Toast.show({ icon: 'fail', content: `출금 가능 잔액(${fmt(balance.remainingAmount)}원)을 초과합니다` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
create.mutate({
|
create.mutate({
|
||||||
settleMasterId: Number(values.settleMasterId),
|
settleMasterId: Number(values.settleMasterId),
|
||||||
amount,
|
amount,
|
||||||
@@ -67,6 +79,25 @@ export default function WithdrawCreate() {
|
|||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<NavBar onBack={() => navigate(-1)}>출금 신청</NavBar>
|
<NavBar onBack={() => navigate(-1)}>출금 신청</NavBar>
|
||||||
|
{balance && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '12px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#e6f4ff',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: '#666' }}>출금 가능 잔액</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, color: '#1677ff' }}>
|
||||||
|
{fmt(balance.remainingAmount)}원
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#999', fontSize: 12, marginTop: 4 }}>
|
||||||
|
실수령 {fmt(balance.netAmount)}원 · 신청중 {fmt(balance.usedAmount)}원
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
@@ -107,6 +138,7 @@ export default function WithdrawCreate() {
|
|||||||
onConfirm={(val) => {
|
onConfirm={(val) => {
|
||||||
const id = val[0] != null ? Number(val[0]) : undefined;
|
const id = val[0] != null ? Number(val[0]) : undefined;
|
||||||
form.setFieldValue('settleMasterId', id);
|
form.setFieldValue('settleMasterId', id);
|
||||||
|
setSelectedId(id);
|
||||||
const picked = settleMasters.find((s) => String(s.settleId) === val[0]);
|
const picked = settleMasters.find((s) => String(s.settleId) === val[0]);
|
||||||
if (picked) setPickedLabel(`${picked.settleMonth} · 실수령 ${fmt(picked.netAmount)}원`);
|
if (picked) setPickedLabel(`${picked.settleMonth} · 실수령 ${fmt(picked.netAmount)}원`);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user