Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/wholesale/src/features/settlement/api/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import type { LedgerQuery } from "./queries";
* `all` ⊃ `retailers()` ← 소매처별 미수(아코디언 머리)
* ⊃ `orders(retailerId)` ← 확정 주문(정산 상태 표 + 배분 표가 **같은 키**)
* ⊃ `ledgers(retailerId)` ⊃ `ledger(q)` ← 원장(구분 필터별)
* ⊃ `prepaid(retailerId)` ← 선수금 3카드
* ⊃ `bankAccounts()`
*
* 입금(POST /payments)은 그 소매처의 미수·주문 미수·원장을 전부 바꾸므로 `retailers()`·`orders(id)`·`ledgers(id)`를
* 비운다. 판매 줄은 출고 탭이 만드는데 feature끼리 키를 못 비우니 탭 진입 때 `all`을 한 번 비운다(`useInvalidateOnMount`).
* 입금(POST /payments)은 그 소매처의 미수·주문 미수·원장·선수금을 전부 바꾸므로 `retailers()`·`orders(id)`·
* `ledgers(id)`·`prepaid(id)`를 비운다. 판매 줄은 출고 탭이 만드는데 feature끼리 키를 못 비우니 탭 진입 때 `all`을 한 번 비운다(`useInvalidateOnMount`).
*/
export const settlementKeys = {
all: ["settlement"] as const,
Expand All @@ -22,5 +23,7 @@ export const settlementKeys = {
[...settlementKeys.all, "ledger", retailerId] as const,
ledger: (query: LedgerQuery) =>
[...settlementKeys.ledgers(query.retailerId), query.entryType] as const,
prepaid: (retailerId: number) =>
[...settlementKeys.all, "prepaid", retailerId] as const,
bankAccounts: () => [...settlementKeys.all, "bank-accounts"] as const,
};
92 changes: 91 additions & 1 deletion apps/wholesale/src/features/settlement/api/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import { settlementKeys } from "./keys";
import { SETTLEMENT_PATH } from "./queries";
import { isStaleRejection } from "../derive";
import type {
AllocationCreated,
AllocationCreateRequest,
BankAccount,
BankAccountCreateRequest,
BankAccountUpdateRequest,
PaymentCreated,
PaymentCreateRequest,
PaymentVoided,
PaymentVoidRequest,
} from "../types";

/**
Expand Down Expand Up @@ -45,12 +49,18 @@ function refetch(
);
}

/** 입금 뒤 낡는 것 — 소매처 미수(행), 그 소매처의 주문 미수·정산 상태(표·배분 표), 원장 */
/**
* 입금 뒤 낡는 것 — 소매처 미수(행), 그 소매처의 주문 미수·정산 상태(표·배분 표), 원장, 선수금 3카드.
*
* 응답의 `ledgerBalance`·`prepaidRemaining`으로 캐시를 직접 고치지 않는다 — 3카드의 `totalPaid`·`totalAllocated`는
* 응답에 없어 화면이 더해야 하고, 그 순간 취소분 처리가 서버와 갈릴 수 있다. 원장 잔액과 같은 방식으로 전부 다시 받는다.
*/
function refetchAfterPayment(queryClient: QueryClient, retailerId: number) {
return refetch(queryClient, [
settlementKeys.retailers(),
settlementKeys.orders(retailerId),
settlementKeys.ledgers(retailerId),
settlementKeys.prepaid(retailerId),
]);
}

Expand Down Expand Up @@ -90,6 +100,86 @@ export function useCreatePaymentMutation({ onDone }: PaymentDone = {}) {
});
}

/**
* 선수금 정산 뒤 낡는 것 — 그 소매처의 주문 미수·정산 상태와 선수금 3카드. 원장은 안 바뀐다(스펙: 돈은 그대로
* 받은 상태) — 소매처 행의 미수 잔액도 원장 잔액이라 그대로다. 그래도 표와 카드는 같은 돈을 보므로 둘을 같이 받는다.
*/
function refetchAfterAllocation(queryClient: QueryClient, retailerId: number) {
return refetch(queryClient, [
settlementKeys.orders(retailerId),
settlementKeys.prepaid(retailerId),
]);
}

export interface AllocationDone {
onDone?: (created: AllocationCreated, refreshed: boolean) => void;
}

export interface AllocationVariables {
body: AllocationCreateRequest;
/** 입금 등록과 같은 규칙 — 입력이 바뀌면 새 키, 같은 입력의 재전송은 같은 키(`AllocationDraft.idempotencyKey`) */
idempotencyKey: string;
}

/** 선수금으로 정산(`POST /allocations`, Idempotency-Key 필수). 새 입금 없이 남은 선수금을 출고된 주문에 붙인다 */
export function useCreateAllocationMutation({ onDone }: AllocationDone = {}) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ body, idempotencyKey }: AllocationVariables) =>
apiFetch<AllocationCreated>(SETTLEMENT_PATH.allocations, {
method: "POST",
body,
idempotencyKey,
}),
onSuccess: async (created, { body }) => {
const refreshed = await refetchAfterAllocation(
queryClient,
body.retailerId,
);
onDone?.(created, refreshed);
},
onError: (error, { body }) =>
isStaleRejection(error)
? refetchAfterAllocation(queryClient, body.retailerId)
: undefined,
});
}

export interface PaymentVoidDone {
onDone?: (voided: PaymentVoided, refreshed: boolean) => void;
}

export interface PaymentVoidVariables {
paymentId: number;
/** 무효화할 키를 고르려고 받는다 — 응답에 소매처가 없다 */
retailerId: number;
body: PaymentVoidRequest;
}

/**
* 입금 취소(`POST /payments/{id}/void`). 그 입금의 배분이 전부 풀리고 원장에 취소 줄이 쌓이므로
* 입금 등록과 같은 넷(행·주문·원장·선수금)을 다시 받는다. 되돌릴 수 없다(스펙) — 다이얼로그가 한 번 막는다.
*/
export function useVoidPaymentMutation({ onDone }: PaymentVoidDone = {}) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ paymentId, body }: PaymentVoidVariables) =>
apiFetch<PaymentVoided>(SETTLEMENT_PATH.paymentVoid(paymentId), {
method: "POST",
body,
}),
onSuccess: async (voided, { retailerId }) => {
const refreshed = await refetchAfterPayment(queryClient, retailerId);
onDone?.(voided, refreshed);
},
// 404(이미 없는 입금)·409(이미 취소된 입금)면 원장이 낡은 것 — 다시 받아 그 줄의 `취소`가 사라지게 한다
onError: (error, { retailerId }) =>
isStaleRejection(error)
? refetchAfterPayment(queryClient, retailerId)
: undefined,
});
}

/**
* 재조회가 실패했을 때 `다시 불러오기`가 부른다 — 이 탭 키 전부. 활성 관찰자만 다시 부른다.
*/
Expand Down
21 changes: 21 additions & 0 deletions apps/wholesale/src/features/settlement/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
toBankAccountView,
toLedgerView,
toOrderView,
toPrepaidView,
toRetailerRow,
} from "../derive";
import type {
Expand All @@ -20,6 +21,8 @@ import type {
LedgerEntryType,
LedgerView,
OrderRowView,
PrepaidSummary,
PrepaidView,
ReceivableLedgerPage,
ReceivableRetailer,
RetailerRowView,
Expand All @@ -35,7 +38,12 @@ import type {
export const SETTLEMENT_PATH = {
receivableRetailers: "/api/wholesale/receivables/retailers",
receivables: "/api/wholesale/receivables",
prepaid: (retailerId: number) =>
`/api/wholesale/receivables/retailers/${retailerId}/prepaid`,
payments: "/api/wholesale/payments",
paymentVoid: (paymentId: number) =>
`/api/wholesale/payments/${paymentId}/void`,
allocations: "/api/wholesale/allocations",
orders: "/api/wholesale/orders",
bankAccounts: "/api/wholesale/bank-accounts",
bankAccount: (bankAccountId: number) =>
Expand Down Expand Up @@ -108,6 +116,19 @@ export function useLedgerQuery(query: LedgerQuery) {
});
}

/**
* 소매처 하나의 선수금 3카드(`GET /receivables/retailers/{id}/prepaid`). 거래 관계가 없는 소매처는 404지만
* 이 탭은 미수 목록에 있는 소매처만 펼치므로 오지 않는다 — 와도 경계의 기본 빈 상태로 그린다.
*/
export function usePrepaidQuery(retailerId: number) {
return useSuspenseQuery({
queryKey: settlementKeys.prepaid(retailerId),
queryFn: () =>
apiFetch<PrepaidSummary>(SETTLEMENT_PATH.prepaid(retailerId)),
select: (summary): PrepaidView => toPrepaidView(summary),
});
}

/** 정산 계좌 전부. 페이징 없음(스펙: 계좌는 소수). 순서는 서버(주계좌 먼저 → 등록순) */
export function useBankAccountsQuery() {
return useSuspenseQuery({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@

import { Table } from "@ondo/ui";
import { OrderStatusBadge, SettlementBadge } from "./StatusBadge";
import { allocationTotal, outstandingTotal } from "../derive";
import type { OrderRowView } from "../types";
import { NumericInput } from "@/shared/components/NumericInput";
import { formatNumber } from "@/shared/lib/format";

/**
* 입금 1건을 여러 주문에 나눠 붙이는 표(`allocations[]`, 입금 1 : 주문 N).
* 돈 한 뭉치를 여러 주문에 나눠 붙이는 표(`allocations[]`, 1 : N). 입금 등록(`POST /payments`)과
* 선수금 정산(`POST /allocations`)이 **같은 표**를 쓴다 — 상한이 무엇이든 행마다 붙이는 규칙은 같다.
*
* `배분` 열만 입력이고 나머지는 읽기 전용이다 — 나머지 4열은 서버값이라
* 여기서 고치면 화면끼리 숫자가 갈린다.
* `이번 배분` 열만 입력이고 나머지는 읽기 전용이다 — 나머지 4열은 서버값이라
* 여기서 고치면 화면끼리 숫자가 갈린다. `남은 미수`는 서버 정의(출고 미수 − 이미 붙은 배분) 그대로다.
*
* 값은 이 컴포넌트가 들고 있지 않는다. 입금액이 바뀌면 자동 배분이 다시 계산돼야 하고
* 그 계산은 폼 전체(입금액)를 아는 쪽에서만 할 수 있기 때문이다.
* 값은 이 컴포넌트가 들고 있지 않는다. 사용 가능액이 바뀌면 자동 배분이 다시 계산돼야 하고
* 그 계산은 폼 전체(입금액·선수금)를 아는 쪽에서만 할 수 있기 때문이다.
*
* 합계행은 Figma 개정(#138)에서 왔다 — `남은 미수` 합이 거래처 행의 미수 잔액과 같아야 하고, `이번 배분` 합이
* 요약 줄의 합계와 같아야 한다. 둘 다 derive의 같은 함수로 센다.
*/
export function AllocationTable({
targets,
Expand All @@ -27,7 +32,7 @@ export function AllocationTable({
values: Readonly<Record<number, number>>;
/** 상한을 넘긴 행의 이유 한 줄(`derive.allocationIssues`). 있는 행만 빨갛게 + 칸 아래 문구 */
issues: Readonly<Record<number, string>>;
/** 입금액을 아직 안 적었으면 배분할 돈이 없다 → 입력칸을 전부 잠근다 */
/** 배분할 돈이 아직 없으면(입금액 빈칸) 입력칸을 전부 잠근다 */
disabled: boolean;
onChange: (orderId: number, raw: string) => void;
}) {
Expand All @@ -46,8 +51,8 @@ export function AllocationTable({
<Table.Th align="left">주문번호</Table.Th>
<Table.Th align="center">주문 상태</Table.Th>
<Table.Th align="center">정산 상태</Table.Th>
<Table.Th>미수</Table.Th>
<Table.Th>배분</Table.Th>
<Table.Th>남은 미수</Table.Th>
<Table.Th>이번 배분</Table.Th>
</Table.Row>
</Table.Head>
<Table.Body>
Expand Down Expand Up @@ -92,6 +97,16 @@ export function AllocationTable({
);
})}
</Table.Body>
{/* 합계행. hover 면이 생기면 안 되는 줄이라 `Table.Row` 대신 생짜 tr — 데이터 행이 아니다 */}
<tfoot>
<tr className="font-medium">
<Table.Td align="left" colSpan={3}>
합계
</Table.Td>
<Table.Td>{formatNumber(outstandingTotal(targets))}</Table.Td>
<Table.Td>{formatNumber(allocationTotal(values))}</Table.Td>
</tr>
</tfoot>
</Table>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
allocationIssues,
allocationTargets,
allocationTotal,
availableTotal,
depositErrorText,
formatAmountInput,
formatInputDateTime,
Expand Down Expand Up @@ -53,10 +54,14 @@ type DepositField = (typeof DEPOSIT_FIELDS)[number];
*
* 배분 표의 주문은 **좌측 펼침 본문이 받은 것**을 그대로 쓴다(`orders`). 이 패널이 같은 쿼리를 따로 들면
* 경계가 둘이 된다(wire-order F6). 못 받았으면(`null`) 배분은 잠그고 `입금만 진행`만 열어 둔다.
*
* 배분 상한은 **총 사용 가능 = 이번 입금액 + 남은 선수금**이다(#138, 스펙: 이번 입금을 먼저 쓰고 모자라면 오래된
* 입금부터 끌어 쓴다). 남은 선수금은 위 3카드 패널이 받아 부모가 넘긴다 — 같은 쿼리를 여기서 또 들지 않는다.
*/
export function DepositFormPanel({
retailer,
orders,
prepaid,
draft,
onDraftChange,
inList,
Expand All @@ -68,6 +73,8 @@ export function DepositFormPanel({
retailer: RetailerView;
/** 이 거래처의 확정 주문. 좌측 펼침 본문이 넘긴다. 아직 못 받았으면 null */
orders: readonly OrderRowView[] | null;
/** 남은 선수금(3카드의 세 번째 값). 아직 못 받았으면 0 — 그러면 상한이 입금액뿐이라 더 보수적일 뿐 틀리진 않는다 */
prepaid: number;
draft: DepositDraft;
/** 부모가 병합하고 키를 새로 만든다. `keepKey`면 키를 유지한다(제출 시각 굳히기) */
onDraftChange: (
Expand All @@ -94,19 +101,21 @@ export function DepositFormPanel({
const amount = parseNumberInput(draft.amountRaw);
/* 상한을 넘긴 입금액. 칸은 빨갛게, 라벨 아래 한 줄, 두 버튼 다 잠근다(#199) */
const amountOverMax = exceedsNumericMax(draft.amountRaw);
/* 총 사용 가능 = 입금액 + 남은 선수금. 입금액이 빈칸이면 null — 표가 잠긴다 */
const available = availableTotal(amount, prepaid);
const targets = allocationTargets(orders ?? []);
const allocations = resolveAllocations(
targets,
draft.editedAllocations,
amount,
available,
);
const total = allocationTotal(allocations);
/* 상한(미수·남은 입금액)을 넘긴 행마다 이유 한 줄. 값을 자르지 않고 말한다(#207 F4) */
const issues = allocationIssues(targets, allocations, amount);
/* 상한(남은 미수·남은 사용 가능액)을 넘긴 행마다 이유 한 줄. 값을 자르지 않고 말한다(#207 F4) */
const issues = allocationIssues(targets, allocations, available);
const hasIssue = Object.keys(issues).length > 0;
const gapText = allocationGapText(amount, total);
/* 합계가 입금액을 넘긴 상태. 요약 숫자와 아래 한 줄을 빨갛게 — 미달은 허용이라 회색이다 */
const overAllocated = amount !== null && total > amount;
const gapText = allocationGapText(available, total);
/* 합계가 사용 가능액을 넘긴 상태. 요약 숫자와 아래 한 줄을 빨갛게 — 미달은 허용이라 회색이다 */
const overAllocated = available !== null && total > available;

/* 서버 오류: `VALIDATION_FAILED`는 칸으로, 정책·상태 오류(400 코드·409·404·5xx)는 버튼 위 한 줄 */
const serverErrors = create.error
Expand Down Expand Up @@ -149,13 +158,17 @@ export function DepositFormPanel({
/** 입금액을 안 적었거나 0이면 기록할 사실이 없다 — 두 버튼 모두 잠근다. 옛 숫자(`stale`)로도 안 보낸다 */
const canSubmit =
amount !== null && amount > 0 && !amountOverMax && !busy && !stale;
/** 배분이 상한 안이고 입금액과 딱 맞을 때만 정산까지 간다. 미달·초과는 `입금만 진행`으로 남긴다 */
/**
* 배분이 한 건이라도 있고 상한(남은 미수·사용 가능액) 안일 때 정산까지 간다. 예전엔 합계가 입금액과 **딱 맞아야**
* 했는데, 선수금 축이 생기며 그 규칙이 사라졌다(#138) — 덜 붙인 돈은 선수금으로 남아 3카드에 보이고, 옛 선수금을
* 끌어 쓰면 합계가 입금액을 넘는 게 정상이다. 합계 0은 `입금만 진행`과 같은 뜻이라 그쪽 버튼만 연다
*/
const canSettle =
canSubmit &&
orders !== null &&
targets.length > 0 &&
!hasIssue &&
total === amount;
total > 0;

const submit = (mode: DepositMode) => {
if (amount === null || !canSubmit) return;
Expand Down Expand Up @@ -297,7 +310,20 @@ export function DepositFormPanel({

<hr className="border-border mt-1 mb-6" />

<Panel.Section title="주문별 배분" className="mt-0">
<Panel.Section className="mt-0">
{/* 제목 오른쪽에 총 사용 가능(Figma 개정, #138). `Panel.Section`의 `title`은 문자열만 받아 제목 줄을
직접 그린다(같은 `mb-1 text-sm`). 입금액을 안 적었으면 아직 셀 수 없어 숫자를 안 보인다 */}
<div className="mb-1 flex items-baseline justify-between gap-3">
<h3 className="text-sm">주문별 배분</h3>
{available !== null ? (
<span className="text-muted-foreground text-xs">
총 사용 가능{" "}
<span className="text-primary text-sm font-medium tabular-nums">
{formatNumber(available)}
</span>
</span>
) : null}
</div>
{orders === null ? (
<p className="text-muted-foreground py-8 text-center text-sm">
좌측에서 거래처를 펼치면 배분할 주문이 보여요
Expand All @@ -312,17 +338,21 @@ export function DepositFormPanel({
/>
)}

{/* 요약 줄. 합계가 입금액과 어긋나면 그 아래 한 줄로 방향과 크기를 말한다 —
미달은 허용(`입금만 진행`), 초과·상한 위반은 `입금 및 정산`이 잠긴다 */}
{/* 요약 줄 — 사용 가능액이 어디서 왔는지(입금액 + 선수금)와 이번 배분 합계. 합계가 사용 가능액과 어긋나면
그 아래 한 줄로 방향과 크기를 말한다 — 미달은 허용(선수금으로 남는다), 초과·상한 위반은 `입금 및 정산`이 잠긴다 */}
{targets.length > 0 ? (
<>
<div className="mt-3 flex items-baseline justify-end gap-3 text-sm">
<span className="text-muted-foreground">입금액</span>
<span className="text-primary font-medium tabular-nums">
{formatNumber(amount ?? 0)}
</span>
<span className="text-muted-foreground">+ 선수금</span>
<span className="text-primary font-medium tabular-nums">
{formatNumber(prepaid)}
</span>
<span className="text-border-strong">|</span>
<span className="text-muted-foreground">배분 합계</span>
<span className="text-muted-foreground">이번 배분</span>
<span
className={`text-base font-medium tabular-nums ${
overAllocated ? "text-destructive-strong" : ""
Expand Down
Loading
Loading