File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
import {
MERCHANT_CATEGORY_READ_FIELD,
MERCHANT_CATEGORY_WRITE_FIELD,
type Merchant,
} from '@/lib/domain/merchant';
import type { MerchantQuery } from '@/lib/domain/merchant-query';
import type { MerchantValues } from '@/lib/domain/merchant-form';
/**
* 가맹점 Repository — `TB_COM_VOUCHER`.
*
* ```
* GET /api/v1/mngr/voc/pagination searchCondition 1=가맹점명 2=사업자번호 3=상세주소
* POST /api/v1/mngr/voc 어노테이션 없음 → form
* ```
*
* ⚠️ 백엔드 현황(보고함)
* 1. **등록이 아직 동작하지 않는다.** 컨트롤러 `insert`의 본문이 전부 주석 처리돼 있고
* `return null;`이다 — 봉투 없는 빈 200이 온다. 그래서 저장해도 아무 일도 일어나지 않는다.
* 2. **수정·삭제·단건 조회 API가 없다.** 목록과 등록 두 개뿐이다.
* 3. 검색 필터가 어긋난다 — 업종 검색 파라미터는 `searchFrcsTpbizCd`인데 SQL은 `FRCS_DV_CD`를
* 비교하고, 저장은 `frcsTpbizCd`로 받는다. 저장한 업종으로 검색이 걸리지 않는다.
* 4. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시
* 뒤집어 오래된 가맹점부터 나온다.
* 5. `USE_YN`을 select하지 않는다 — 검색 조건에는 쓰면서 응답에는 없어 사용여부를 알 수 없다.
* 화면은 노출로 보인다.
* 6. 브랜드명·메모·등록일·최종수정일 컬럼이 없다 — 기획에 있는 항목이라 화면은 그대로 두고
* 값만 비워 `-`로 보인다. 등록 시 보내는 브랜드명·메모도 요청 VO에 없어 버려진다.
*/
const VOC_PATH = '/api/v1/mngr/voc';
/** 엑셀은 전체를 담는다 — 이 상한을 넘으면 조용히 잘린다. */
const EXCEL_ROW_LIMIT = 10_000;
/** 화면 검색 대상 → 백엔드 `searchCondition`. */
const SEARCH_CONDITION: Record<string, string> = {
name: '1',
businessNumber: '2',
address: '3',
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === 'string' ? value : '';
}
function toMerchant(raw: unknown): Merchant[] {
if (!isRecord(raw)) {
return [];
}
const code = readString(raw, 'frcsNo');
if (!code) {
return [];
}
return [
{
id: code,
code,
// 백엔드에 컬럼이 없다(⚠️ 6) — 응답에 있으면 쓰고, 없으면 빈 값으로 두어 화면이 `-`를 낸다.
brandName: readString(raw, 'brandNm'),
memo: readString(raw, 'memo'),
createdAt: readString(raw, 'frstRegDtStr'),
updatedAt: readString(raw, 'lastMdfcnDtStr'),
name: readString(raw, 'frcsNm'),
categoryCode: readString(raw, MERCHANT_CATEGORY_READ_FIELD),
businessNumber: readString(raw, 'brno'),
ownerName: readString(raw, 'rprsvNm'),
phoneNumber: readString(raw, 'rprsvTelno'),
address: readString(raw, 'frcsPostAddr'),
addressDetail: readString(raw, 'frcsDaddr'),
postalCode: readString(raw, 'frcsPostZip'),
latitude: readString(raw, 'lat'),
longitude: readString(raw, 'lng'),
// 조회 SQL이 USE_YN을 select하지 않는다(⚠️ 5) — 값이 없으면 노출로 본다.
isVisible: readString(raw, 'useYn') !== 'N',
},
];
}
export async function fetchMerchants(
query: MerchantQuery
): Promise<{ items: Merchant[]; totalCount: number }> {
const accessToken = await getSessionAccessToken();
const keyword = query.keyword.trim();
const result = await backendFetch<unknown>(`${VOC_PATH}/pagination`, {
method: 'GET',
query: {
...(keyword
? {
searchCondition: SEARCH_CONDITION[query.searchField] ?? '1',
searchKeyword: keyword,
}
: {}),
...(query.visibility
? { searchUseYn: query.visibility === 'visible' ? 'Y' : 'N' }
: {}),
pageIndex: query.page,
recordCountPerPage: query.pageSize,
},
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
const data = result.data;
if (data === null || data === undefined) {
return { items: [], totalCount: 0 };
}
if (!isRecord(data) || !Array.isArray(data.list)) {
throw new Error('가맹점 목록 응답의 형식이 올바르지 않습니다.');
}
return {
items: data.list.flatMap(toMerchant),
totalCount:
typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
};
}
/** 엑셀 다운로드는 검색 조건과 무관하게 전체다(기획 MCH_001 ②). */
export async function fetchAllMerchants(): Promise<Merchant[]> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${VOC_PATH}/pagination`, {
method: 'GET',
query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT },
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
const data = result.data;
return isRecord(data) && Array.isArray(data.list)
? data.list.flatMap(toMerchant)
: [];
}
export async function createMerchant(values: MerchantValues): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(VOC_PATH, {
method: 'POST',
form: {
frcsNm: values.name,
[MERCHANT_CATEGORY_WRITE_FIELD]: values.categoryCode,
// 요청 VO의 필드 이름이 대문자다(`private String BRNO`) — Spring이 그 이름으로 바인딩한다.
BRNO: values.businessNumber,
rprsvNm: values.ownerName,
rprsvTelno: values.phoneNumber,
frcsPostAddr: values.address,
frcsDaddr: values.addressDetail,
frcsPostZip: values.postalCode,
lat: values.latitude,
lng: values.longitude,
useYn: values.isVisible ? 'Y' : 'N',
// 요청 VO에 없어 무시된다. 컬럼이 생기면 그대로 저장되도록 미리 싣는다.
brandNm: values.brandName,
memo: values.memo,
},
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}