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 { buildFileImageUrl } from '@/lib/data/repositories/file-repository';
import {
MAX_EXTRA_IMAGES,
type MerchantDetail,
type MerchantDetailSummary,
} from '@/lib/domain/merchant-detail';
import type { MerchantDetailValues } from '@/lib/domain/merchant-detail-form';
import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
/**
* 가맹몰 상세 Repository — edupay-backend origin/develop 15d409a·22f67cb 기준.
*
* ```
* GET /api/v1/mngr/voc/detail/pagination TB_COM_VOUCHER ⟕ TB_COM_VOUCHER_DTL — 가맹점 전체
* (화면은 상세 있는 건만 목록에, 없는 건은 등록 선택지에)
* GET /api/v1/mngr/voc/detail/{frcsNo}
* POST /api/v1/mngr/voc/detail @RequestBody · 가맹점이 있어야 붙는다
* PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody
* DELETE /api/v1/mngr/voc/{frcsNo} 논리 삭제(DEL_YN)
* ```
*
* ⚠️ 백엔드 결함(보고함, 미수정) — 화면은 시안대로 두고 값을 그대로 보낸다(사용자 확정):
* 1. 목록·단건 SELECT(`includeSql`)에 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서가
* 없어 수정 프리필이 빈다. 카테고리 이름(`CD_NM`)은 SELECT엔 있으나 VO에 담을 곳이 없다.
* 2. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다.
* 3. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다.
*
* 노출 상태(`useYn`)는 `MngrVocDetailVo`가 `MngrVocVo`에서 상속받아 등록·수정·조회 모두 실린다
* (처음엔 없다고 잘못 보고했다 — 2026-09-14 정정).
*/
const DETAIL_PATH = '/api/v1/mngr/voc/detail';
const VOC_PATH = '/api/v1/mngr/voc';
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key];
if (typeof value === 'string' && value !== '') return value;
if (typeof value === 'number') return String(value);
return null;
}
function readNumber(source: Record<string, unknown>, key: string): number | null {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function readYesNo(source: Record<string, unknown>, key: string): boolean | null {
const value = readString(source, key);
return value === null ? null : value.toUpperCase() === 'Y';
}
/**
* 등록일은 `d.FRST_REG_DT`가 별칭 없이 오므로 `frstRegDt`(Date → ISO 문자열 또는 epoch)로
* 받는다. 다른 화면의 `frstRegDtStr`가 있으면 그것을 우선한다.
*/
function readDate(source: Record<string, unknown>, key: string): string | null {
const formatted = readString(source, `${key}Str`);
if (formatted) return formatted.slice(0, 10);
const raw = source[key];
const date =
typeof raw === 'number'
? new Date(raw)
: typeof raw === 'string' && raw !== ''
? new Date(raw)
: null;
if (date === null || Number.isNaN(date.getTime())) return null;
return date.toISOString().slice(0, 10);
}
function toSummary(raw: unknown): MerchantDetailSummary[] {
if (!isRecord(raw)) return [];
const id = readString(raw, 'frcsNo');
const name = readString(raw, 'frcsNm');
if (!id || !name) return [];
const registeredAt = readDate(raw, 'frstRegDt');
return [
{
id,
name,
phoneNumber: readString(raw, 'rprsvTelno'),
address: readString(raw, 'frcsPostAddr'),
addressDetail: readString(raw, 'frcsDaddr'),
thumbnailFileId: readString(raw, 'thumbAtchFileId'),
thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')),
categoryCode: readString(raw, 'frcsTypeCd'),
isVisible: readYesNo(raw, 'useYn'),
registeredAt,
// 상세 행은 LEFT JOIN이라 없으면 상세 컬럼이 전부 null이다 — 등록일이 그 표지다.
hasDetail: registeredAt !== null || readString(raw, 'thumbAtchFileId') !== null,
},
];
}
/**
* 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로
* 없는 번호가 나올 때까지 HEAD로 더듬는다 — 없는 번호는 백엔드가 500으로 답한다.
*/
async function probeImageUrls(fileId: string | null): Promise<string[]> {
if (!fileId) return [];
const urls: string[] = [];
for (let fileSn = 1; fileSn <= MAX_EXTRA_IMAGES; fileSn += 1) {
const url = buildFileImageUrl(fileId, fileSn);
if (!url) break;
try {
const response = await fetch(url, { method: 'HEAD', cache: 'no-store' });
if (!response.ok) break;
} catch {
break;
}
urls.push(url);
}
return urls;
}
async function toDetail(raw: unknown): Promise<MerchantDetail | null> {
const [summary] = toSummary(raw);
if (!summary || !isRecord(raw)) return null;
const imageFileId = readString(raw, 'atchFileId');
return {
...summary,
imageUrls: await probeImageUrls(imageFileId),
postalCode: readString(raw, 'frcsPostZip'),
description: readString(raw, 'frcsCn'),
openTime: readString(raw, 'startHour'),
closeTime: readString(raw, 'endHour'),
closedDays: readString(raw, 'offDayCn'),
imageFileId,
sortOrder: readNumber(raw, 'sortOrdr'),
registeredBy: readString(raw, 'frstRgtrNm'),
modifiedAt: readDate(raw, 'lastMdfcnDt'),
modifiedBy: readString(raw, 'lastMdfrNm'),
};
}
type SummaryFilters = Pick<
MerchantDetailQuery,
'keyword' | 'category' | 'visibility' | 'startDate' | 'endDate'
>;
async function fetchSummaryPage(
filters: Partial<SummaryFilters>,
pageIndex: number,
pageSize: number
): Promise<{ items: MerchantDetailSummary[]; rowCount: number; totalCount: number }> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${DETAIL_PATH}/pagination`, {
method: 'GET',
query: {
...(filters.keyword ? { searchFrcsNm: filters.keyword } : {}),
...(filters.category ? { searchFrcsTypeCd: filters.category } : {}),
...(filters.visibility ? { searchUseYn: filters.visibility } : {}),
...(filters.startDate ? { searchStartDt: filters.startDate } : {}),
...(filters.endDate ? { searchEndDt: filters.endDate } : {}),
pageIndex,
recordCountPerPage: 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: [], rowCount: 0, totalCount: 0 };
}
if (!isRecord(data) || !Array.isArray(data.list)) {
throw new Error('가맹몰 상세 목록 응답의 형식이 올바르지 않습니다.');
}
return {
items: data.list.flatMap(toSummary),
// 매핑에서 걸러진 행이 있어도 페이지가 끝났는지는 원본 행 수로 판단한다.
rowCount: data.list.length,
totalCount:
typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
};
}
// 백엔드 목록에는 "상세가 있는 건만" 조건이 없다(보고함). 그래서 전 페이지를 받아 여기서 거른다 —
// 가맹점은 많아야 수백 건이라 한두 번의 요청이다. 상한을 넘으면 그 뒤는 잘린다.
// TODO(백엔드): 상세 유무 조건이 생기면 이 훑기를 없애고 페이지 하나만 받는다.
const SCAN_PAGE_SIZE = 200;
const MAX_SCAN_PAGES = 25;
async function fetchAllSummaries(
filters: Partial<SummaryFilters>
): Promise<MerchantDetailSummary[]> {
const items: MerchantDetailSummary[] = [];
let seen = 0;
for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) {
const page = await fetchSummaryPage(filters, pageIndex, SCAN_PAGE_SIZE);
items.push(...page.items);
seen += page.rowCount;
if (page.rowCount < SCAN_PAGE_SIZE || seen >= page.totalCount) {
break;
}
}
return items;
}
/** 목록 — 상세가 있는 가맹점만, 화면 쪽 페이지로 잘라 준다(사용자 확정). */
export async function fetchMerchantDetails(
query: MerchantDetailQuery
): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> {
const registered = (await fetchAllSummaries(query)).filter((row) => row.hasDetail);
const start = (query.page - 1) * query.pageSize;
return {
items: registered.slice(start, start + query.pageSize),
totalCount: registered.length,
};
}
/** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */
export async function fetchMerchantDetailCandidates(): Promise<MerchantDetailSummary[]> {
return (await fetchAllSummaries({})).filter((row) => !row.hasDetail);
}
export async function fetchMerchantDetail(
frcsNo: string
): Promise<MerchantDetail | null> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${DETAIL_PATH}/${encodeURIComponent(frcsNo)}`,
{
method: 'GET',
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
}
);
if (!result.ok) {
throw new BackendRequestError(result);
}
return toDetail(result.data);
}
function toWritePayload(values: MerchantDetailValues): Record<string, unknown> {
return {
frcsNo: values.frcsNo,
frcsTypeCd: values.categoryCode,
frcsCn: values.description,
startHour: values.openTime,
endHour: values.closeTime,
offDayCn: values.closedDays,
thumbAtchFileId: values.thumbnailFileId,
atchFileId: values.imageFileId || null,
sortOrdr: values.sortOrder,
useYn: values.isVisible ? 'Y' : 'N',
// 요청 VO에 없어 버려진다(⚠️ 5). 자리가 생기면 그대로 저장된다.
voucherUseYn: values.isVoucherUsable ? 'Y' : 'N',
};
}
async function sendWrite(
path: string,
method: 'POST' | 'PUT' | 'DELETE',
body?: unknown
): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(path, {
method,
...(body === undefined ? {} : { body }),
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}
export async function createMerchantDetail(
values: MerchantDetailValues
): Promise<void> {
await sendWrite(DETAIL_PATH, 'POST', toWritePayload(values));
}
export async function updateMerchantDetail(
values: MerchantDetailValues
): Promise<void> {
await sendWrite(
`${DETAIL_PATH}/${encodeURIComponent(values.frcsNo)}`,
'PUT',
toWritePayload(values)
);
}
export async function deleteMerchantDetail(frcsNo: string): Promise<void> {
await sendWrite(`${VOC_PATH}/${encodeURIComponent(frcsNo)}`, 'DELETE');
}