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/feature/voc b009025(2026-09-17) 기준.
*
* ```
* GET /api/v1/mngr/voc/detail/pagination 상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드)
* GET /api/v1/mngr/voc/detail/{frcsNo} 상세가 없으면 NOT_FOUND 봉투
* POST /api/v1/mngr/voc/detail @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404
* PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody · 전 컬럼 덮어씀
* DELETE /api/v1/mngr/voc/{frcsNo} 논리 삭제(DEL_YN)
* GET /api/v1/mngr/voc/pagination 가맹점 전체 — 목록 합치기·등록 선택지·상세 없는 단건에 쓴다
* ```
*
* 목록·단건이 소개·카테고리 코드·이름(`frcsTypeCdNm`)·영업시간·휴무일·추가이미지·순서·우편번호·
* 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다. 업종·업태 이름
* (`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 화면(시안)에 자리가 없어 읽지 않는다.
*
* ⚠️ 백엔드 결함(보고함, 미수정):
* 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 그동안 업체명 자리에
* 가맹점 코드를 보인다.
* 2. 건수 쿼리(`selectPaginationCount`)만 LEFT JOIN이라 상세 없는 가맹점까지 세어 페이지 수가 부풀 수 있다.
* 3. `selectOne`이 DEL_YN을 거르지 않아 삭제한 상세가 있는 가맹점은 다시 등록하면 409가 난다.
* 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다.
* 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다.
*/
const DETAIL_PATH = '/api/v1/mngr/voc/detail';
const VOC_PATH = '/api/v1/mngr/voc';
// 등록 선택지·상세 보유 목록을 한 번에 훑는 크기. 가맹점은 많아야 수백 건이다.
const SCAN_PAGE_SIZE = 500;
const MAX_SCAN_PAGES = 20;
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);
}
/** 상세 목록·단건의 한 행. 가맹점 API의 행은 `toCandidate`가 따로 맡는다. */
function toSummary(raw: unknown): MerchantDetailSummary[] {
if (!isRecord(raw)) return [];
const id = readString(raw, 'frcsNo');
if (!id) return [];
const registeredAt = readDate(raw, 'frstRegDt');
return [
{
id,
// 결함 1 — 업체명이 안 오면 코드라도 보인다.
name: readString(raw, 'frcsNm') ?? id,
phoneNumber: readString(raw, 'rprsvTelno'),
address: readString(raw, 'frcsPostAddr'),
addressDetail: readString(raw, 'frcsDaddr'),
postalCode: readString(raw, 'frcsPostZip'),
thumbnailFileId: readString(raw, 'thumbAtchFileId'),
thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')),
categoryCode: readString(raw, 'frcsTypeCd'),
categoryLabel: readString(raw, 'frcsTypeCdNm') ?? readString(raw, 'cdNm'),
isVisible: readYesNo(raw, 'useYn'),
registeredAt,
hasDetail:
registeredAt !== null ||
readString(raw, 'thumbAtchFileId') !== null ||
readString(raw, 'frcsTypeCd') !== null,
},
];
}
/** 가맹점 API(`/mngr/voc/pagination`)의 한 행 — 아직 상세가 없는 가맹점의 선택지. */
function toCandidate(raw: unknown): MerchantDetailSummary[] {
if (!isRecord(raw)) return [];
const id = readString(raw, 'frcsNo');
const name = readString(raw, 'frcsNm');
if (!id || !name) return [];
return [
{
id,
name,
phoneNumber: readString(raw, 'rprsvTelno'),
address: readString(raw, 'frcsPostAddr'),
addressDetail: readString(raw, 'frcsDaddr'),
postalCode: readString(raw, 'frcsPostZip'),
thumbnailFileId: null,
thumbnailUrl: null,
categoryCode: null,
categoryLabel: null,
isVisible: null,
registeredAt: null,
hasDetail: false,
},
];
}
/**
* 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로 최대 장수를
* 한꺼번에 HEAD로 두드려 이어지는 앞부분만 취한다 — 차례로 두드리면 장수만큼 왕복이 늘어난다.
* 없는 번호는 백엔드가 500으로 답한다.
*/
async function probeImageUrls(fileId: string | null): Promise<string[]> {
if (!fileId) return [];
const candidates = Array.from({ length: MAX_EXTRA_IMAGES }, (_, index) =>
buildFileImageUrl(fileId, index + 1)
).filter((url): url is string => url !== null);
const exists = await Promise.all(
candidates.map(async (url) => {
try {
return (await fetch(url, { method: 'HEAD', cache: 'no-store' })).ok;
} catch {
return false;
}
})
);
const missingAt = exists.indexOf(false);
return missingAt === -1 ? candidates : candidates.slice(0, missingAt);
}
async function toDetail(raw: unknown, withImages: boolean): Promise<MerchantDetail | null> {
const [summary] = toSummary(raw);
if (!summary || !isRecord(raw)) return null;
const imageFileId = readString(raw, 'atchFileId');
return {
...summary,
imageUrls: withImages ? await probeImageUrls(imageFileId) : [],
description: readString(raw, 'frcsCn'),
openTime: readString(raw, 'startHour'),
closeTime: readString(raw, 'endHour'),
closedDays: readString(raw, 'offDayCn'),
imageFileId,
sortOrder: readNumber(raw, 'sortOrdr'),
registeredBy: readString(raw, 'frstRgtrNmStr') ?? readString(raw, 'frstRgtrNm'),
modifiedAt: readDate(raw, 'lastMdfcnDt'),
// 수정자는 `lastMdfrNm`이 JSON에서 빠지고 *Str 별칭도 없어 아직 안 온다.
modifiedBy: readString(raw, 'lastMdfrNm'),
};
}
/**
* 목록 — 가맹점 전체에 상세를 덧입혀 상세가 없는 가맹점도 세운다(사용자 확정). 백엔드 목록은 상세가
* 있는 것만 주므로 가맹점 목록과 상세 목록을 모두 받아 합치고, 검색·기간·페이지는 여기서 처리한다.
* 부수 효과로 백엔드가 쓰지 않던 등록일 기간 조건과 응답에 빠진 업체명(결함 1)이 여기서 채워진다.
*/
export async function fetchMerchantDetails(
query: MerchantDetailQuery
): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> {
const merged = await fetchMergedSummaries();
const keyword = query.keyword.trim().toLowerCase();
const filtered = merged.filter((row) => {
if (keyword && !row.name.toLowerCase().includes(keyword)) return false;
if (query.category && row.categoryCode !== query.category) return false;
if (query.visibility && row.isVisible !== (query.visibility === 'Y')) return false;
if (query.startDate && (row.registeredAt === null || row.registeredAt < query.startDate)) return false;
if (query.endDate && (row.registeredAt === null || row.registeredAt > query.endDate)) return false;
return true;
});
const start = (query.page - 1) * query.pageSize;
return {
items: filtered.slice(start, start + query.pageSize),
totalCount: filtered.length,
};
}
/** 상세 있는 가맹점을 등록일 내림차순으로 먼저, 나머지 가맹점을 코드 내림차순으로 뒤에 둔다. */
function compareSummaryOrder(a: MerchantDetailSummary, b: MerchantDetailSummary): number {
if (a.hasDetail !== b.hasDetail) return a.hasDetail ? -1 : 1;
const dateA = a.registeredAt ?? '';
const dateB = b.registeredAt ?? '';
if (dateA !== dateB) return dateA < dateB ? 1 : -1;
return b.id.localeCompare(a.id, undefined, { numeric: true });
}
async function fetchMergedSummaries(): Promise<MerchantDetailSummary[]> {
const [merchants, details] = await Promise.all([
fetchAllPages(`${VOC_PATH}/pagination`, toCandidate),
fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary),
]);
const byId = new Map(merchants.map((row) => [row.id, row]));
for (const detail of details) {
// develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 섞어 준다 — 상세 있는 행만 덧입힌다.
if (!detail.hasDetail) continue;
const merchant = byId.get(detail.id);
byId.set(detail.id, {
...detail,
// 결함 1 — 상세 응답에 업체명이 없으면 가맹점 목록의 이름을 쓴다.
name: detail.name === detail.id && merchant ? merchant.name : detail.name,
});
}
return [...byId.values()].sort(compareSummaryOrder);
}
/**
* 단건. `withImages`를 끄면 추가 이미지 URL 더듬기(HEAD 여러 번)를 건너뛴다 — 노출 토글처럼
* 값만 다시 보내는 곳은 URL이 필요 없다.
*/
export async function fetchMerchantDetail(
frcsNo: string,
{ withImages = true }: { withImages?: boolean } = {}
): 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) {
// 상세가 없는 가맹점은 NOT_FOUND 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다.
if (result.code === 404) return null;
throw new BackendRequestError(result);
}
return toDetail(result.data, withImages);
}
async function fetchPage<T>(
path: string,
pageIndex: number,
pageSize: number,
toRow: (raw: unknown) => T[]
): Promise<{ rows: T[]; rowCount: number; totalCount: number }> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(path, {
method: 'GET',
query: { pageIndex, recordCountPerPage: pageSize },
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
const data = result.data;
if (!isRecord(data) || !Array.isArray(data.list)) {
return { rows: [], rowCount: 0, totalCount: 0 };
}
return {
rows: data.list.flatMap(toRow),
rowCount: data.list.length,
totalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
};
}
async function fetchAllPages<T>(path: string, toRow: (raw: unknown) => T[]): Promise<T[]> {
const rows: T[] = [];
let seen = 0;
for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) {
const page = await fetchPage(path, pageIndex, SCAN_PAGE_SIZE, toRow);
rows.push(...page.rows);
seen += page.rowCount;
if (page.rowCount < SCAN_PAGE_SIZE || seen >= page.totalCount) break;
}
return rows;
}
/** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */
export async function fetchMerchantDetailCandidates(): Promise<MerchantDetailSummary[]> {
return (await fetchMergedSummaries()).filter((row) => !row.hasDetail);
}
/** 가맹점 한 건(상세 없이) — 가맹점 단건 API가 없어 목록에서 찾는다. */
export async function fetchMerchantSummary(
frcsNo: string
): Promise<MerchantDetailSummary | null> {
const merchants = await fetchAllPages(`${VOC_PATH}/pagination`, toCandidate);
return merchants.find((row) => row.id === frcsNo) ?? null;
}
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에 없어 버려진다(⚠️ 3). 자리가 생기면 그대로 저장된다.
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');
}