feat: 가맹몰 상세를 백엔드 feature/voc(d784bb6) 계약에 맞춘다
목록·단건이 소개·카테고리 코드·영업시간·휴무일·순서·추가이미지·우편번호·등록자·등록일·수정일을 돌려주고, 목록은 상세가 있는 가맹점만(INNER JOIN), 상세 없는 단건은 NOT_FOUND, 등록은 상세가 있으면 409다. 그래서 등록 진입을 다시 가맹점 선택 화면(/new)으로 두고 — 선택지는 가맹점 전체에서 상세 보유분을 뺀 차집합 — 행의 [수정]은 상세 있는 가맹점만 연다. 응답 VO에 frcsNm이 빠진 결함은 업체명 자리에 코드를 보이는 것으로 버틴다(보고함). Co-Authored-By: Claude Opus 5
@2acbb13fc5c058125889058bdbf1b68ad3af82f2
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
... | ... | @@ -43,25 +43,26 @@ |
| 43 | 43 |
import styles from './merchant-detail-form.module.scss'; |
| 44 | 44 |
|
| 45 | 45 |
interface MerchantDetailFormProps {
|
| 46 |
- /** 이 화면의 가맹점 — 업체명·연락처·주소의 출처. 상세 유무와 무관하게 늘 정해져 있다. */ |
|
| 47 |
- merchant: MerchantDetailSummary; |
|
| 48 |
- /** 상세가 이미 있으면 그 값. 없으면 이 화면에서 처음 만든다. */ |
|
| 46 |
+ /** 상세가 있는 가맹점 — 고친다. 없으면 등록이다. */ |
|
| 49 | 47 |
detail?: MerchantDetail; |
| 48 |
+ /** 등록 — 아직 상세가 없는 가맹점 선택지. 업체명 자리에서 하나를 고른다. */ |
|
| 49 |
+ candidates?: MerchantDetailSummary[]; |
|
| 50 | 50 |
products?: MerchantProduct[]; |
| 51 | 51 |
categories: CommonCode[]; |
| 52 | 52 |
} |
| 53 | 53 |
|
| 54 | 54 |
/** |
| 55 |
- * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). 목록의 행에서 |
|
| 56 |
- * 들어오며 `detail`이 있으면 고치고, 없으면 처음 만든다. 첫 저장 뒤에도 화면 구성은 그대로라 |
|
| 57 |
- * 바로 메뉴를 붙일 수 있고, 저장은 그 자리에 머문다. |
|
| 55 |
+ * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 있으면 |
|
| 56 |
+ * 고치고, 없으면 업체명 자리에서 상세가 없는 가맹점을 골라 처음 만든다 — 백엔드 목록·단건이 상세 있는 |
|
| 57 |
+ * 가맹점만 다루므로(feature/voc) 등록은 이 선택으로만 들어온다. 첫 저장 뒤에는 그 가맹점 주소로 옮겨 |
|
| 58 |
+ * 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후 저장은 그 자리에 머문다. |
|
| 58 | 59 |
* |
| 59 |
- * 업체명·연락처·우편번호·주소는 가맹점 원본이라 늘 읽기 전용이다(사용자 확정). 기획의 [주소 검색]은 |
|
| 60 |
- * 가맹점 관리 화면의 것이라 여기서는 값을 바꾸지 않는다. |
|
| 60 |
+ * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다. 기획의 [주소 검색]은 가맹점 관리 |
|
| 61 |
+ * 화면의 것이라 여기서는 값을 바꾸지 않는다. |
|
| 61 | 62 |
*/ |
| 62 | 63 |
export function MerchantDetailForm({
|
| 63 |
- merchant, |
|
| 64 | 64 |
detail, |
| 65 |
+ candidates = [], |
|
| 65 | 66 |
products = [], |
| 66 | 67 |
categories, |
| 67 | 68 |
}: MerchantDetailFormProps) {
|
... | ... | @@ -75,7 +76,10 @@ |
| 75 | 76 |
const isEdit = detail !== undefined; |
| 76 | 77 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 77 | 78 |
|
| 78 |
- const frcsNo = merchant.id; |
|
| 79 |
+ const [frcsNo, setFrcsNo] = useState(detail?.id ?? ''); |
|
| 80 |
+ // 읽기 전용 칸의 출처 — 수정은 상세, 등록은 고른 가맹점. |
|
| 81 |
+ const merchant: MerchantDetailSummary | null = |
|
| 82 |
+ detail ?? candidates.find((row) => row.id === frcsNo) ?? null; |
|
| 79 | 83 |
const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? ''); |
| 80 | 84 |
const [visibility, setVisibility] = useState<string[]>([ |
| 81 | 85 |
detail?.isVisible === false ? 'N' : 'Y', |
... | ... | @@ -159,7 +163,29 @@ |
| 159 | 163 |
<FoxCard size="lg" title="기본 정보" className={styles.section}>
|
| 160 | 164 |
<div className={styles.grid}>
|
| 161 | 165 |
<div className={styles.fieldWide}>
|
| 162 |
- <FoxInput size="md" label="업체명" requirement="required" value={merchant.name} readOnly state="view" />
|
|
| 166 |
+ {detail ? (
|
|
| 167 |
+ <FoxInput size="md" label="업체명" requirement="required" value={detail.name} readOnly state="view" />
|
|
| 168 |
+ ) : ( |
|
| 169 |
+ <> |
|
| 170 |
+ <FoxSelect |
|
| 171 |
+ size="md" |
|
| 172 |
+ label="업체명" |
|
| 173 |
+ requirement="required" |
|
| 174 |
+ placeholder="상세를 등록할 가맹점 선택" |
|
| 175 |
+ options={candidates.map((row) => ({ value: row.id, label: row.name }))}
|
|
| 176 |
+ value={frcsNo}
|
|
| 177 |
+ onValueChange={setFrcsNo}
|
|
| 178 |
+ error={Boolean(errors.frcsNo)}
|
|
| 179 |
+ /> |
|
| 180 |
+ {errors.frcsNo && <FoxHelperText type="danger" message={errors.frcsNo} />}
|
|
| 181 |
+ {candidates.length === 0 && (
|
|
| 182 |
+ <FoxHelperText |
|
| 183 |
+ type="information" |
|
| 184 |
+ message="상세를 붙일 가맹점이 없습니다. 모든 가맹점에 상세가 등록돼 있습니다." |
|
| 185 |
+ /> |
|
| 186 |
+ )} |
|
| 187 |
+ </> |
|
| 188 |
+ )} |
|
| 163 | 189 |
</div> |
| 164 | 190 |
<div className={styles.field}>
|
| 165 | 191 |
<FoxSelect |
... | ... | @@ -180,7 +206,7 @@ |
| 180 | 206 |
size="md" |
| 181 | 207 |
label="업체 연락처" |
| 182 | 208 |
requirement="required" |
| 183 |
- value={formatOptionalValue(formatPhoneNumber(merchant.phoneNumber))}
|
|
| 209 |
+ value={formatOptionalValue(formatPhoneNumber(merchant?.phoneNumber ?? null))}
|
|
| 184 | 210 |
readOnly |
| 185 | 211 |
state="view" |
| 186 | 212 |
/> |
... | ... | @@ -189,13 +215,13 @@ |
| 189 | 215 |
|
| 190 | 216 |
<div className={styles.grid}>
|
| 191 | 217 |
<div className={styles.fieldNarrow}>
|
| 192 |
- <FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(detail?.postalCode)} readOnly state="view" />
|
|
| 218 |
+ <FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(merchant?.postalCode)} readOnly state="view" />
|
|
| 193 | 219 |
</div> |
| 194 | 220 |
<div className={styles.fieldWide}>
|
| 195 |
- <FoxInput size="md" label="주소" value={formatOptionalValue(merchant.address)} readOnly state="view" />
|
|
| 221 |
+ <FoxInput size="md" label="주소" value={formatOptionalValue(merchant?.address)} readOnly state="view" />
|
|
| 196 | 222 |
</div> |
| 197 | 223 |
<div className={styles.field}>
|
| 198 |
- <FoxInput size="md" label="상세주소" value={formatOptionalValue(merchant.addressDetail)} readOnly state="view" />
|
|
| 224 |
+ <FoxInput size="md" label="상세주소" value={formatOptionalValue(merchant?.addressDetail)} readOnly state="view" />
|
|
| 199 | 225 |
</div> |
| 200 | 226 |
</div> |
| 201 | 227 |
|
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
... | ... | @@ -14,28 +14,23 @@ |
| 14 | 14 |
params: Promise<{ frcsNo: string }>;
|
| 15 | 15 |
} |
| 16 | 16 |
|
| 17 |
-/** 가맹점 하나의 상세 화면 — 상세가 아직 없으면 그 가맹점을 고정한 채 처음 만드는 화면이 된다. */ |
|
| 17 |
+/** |
|
| 18 |
+ * 상세가 있는 가맹점의 상세 화면. 상세가 없으면 백엔드(feature/voc)가 NOT_FOUND를 주므로 404다 — |
|
| 19 |
+ * develop 백엔드는 가맹점 행을 그대로 주므로 `hasDetail`로 한 번 더 거른다(수정이 빈 UPDATE가 되지 않게). |
|
| 20 |
+ */ |
|
| 18 | 21 |
export default async function Page({ params }: PageProps) {
|
| 19 | 22 |
await verifySession(); |
| 20 | 23 |
|
| 21 | 24 |
const { frcsNo } = await params;
|
| 22 | 25 |
const detail = await fetchMerchantDetail(frcsNo); |
| 23 |
- if (!detail) {
|
|
| 26 |
+ if (!detail || !detail.hasDetail) {
|
|
| 24 | 27 |
notFound(); |
| 25 | 28 |
} |
| 26 | 29 |
|
| 27 |
- // 상세가 없으면 백엔드 메뉴 목록도 비어 온다(상세와 INNER JOIN) — 부르지 않는다. |
|
| 28 | 30 |
const [products, categories] = await Promise.all([ |
| 29 |
- detail.hasDetail ? fetchMerchantProducts(frcsNo) : Promise.resolve([]), |
|
| 31 |
+ fetchMerchantProducts(frcsNo), |
|
| 30 | 32 |
fetchCommonCodes(CODE_GROUP.merchantType), |
| 31 | 33 |
]); |
| 32 | 34 |
|
| 33 |
- return ( |
|
| 34 |
- <MerchantDetailForm |
|
| 35 |
- merchant={detail}
|
|
| 36 |
- detail={detail.hasDetail ? detail : undefined}
|
|
| 37 |
- products={products}
|
|
| 38 |
- categories={categories}
|
|
| 39 |
- /> |
|
| 40 |
- ); |
|
| 35 |
+ return <MerchantDetailForm detail={detail} products={products} categories={categories} />;
|
|
| 41 | 36 |
} |
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
... | ... | @@ -222,7 +222,7 @@ |
| 222 | 222 |
const frcsNo = readString(formData, 'frcsNo'); |
| 223 | 223 |
const isEdit = readString(formData, 'mode') === 'edit'; |
| 224 | 224 |
if (!frcsNo) {
|
| 225 |
- return { status: 'error', message: INVALID_REQUEST_MESSAGE };
|
|
| 225 |
+ return { status: 'error', errors: { frcsNo: '업체를 선택해 주세요.' } };
|
|
| 226 | 226 |
} |
| 227 | 227 |
|
| 228 | 228 |
let failed: MerchantDetailFormState | null = null; |
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
... | ... | @@ -26,6 +26,7 @@ |
| 26 | 26 |
import {
|
| 27 | 27 |
FILTER_ALL, |
| 28 | 28 |
MERCHANT_DETAILS_PATH, |
| 29 |
+ MERCHANT_DETAIL_NEW_PATH, |
|
| 29 | 30 |
MERCHANT_DETAIL_PAGE_SIZE_OPTIONS, |
| 30 | 31 |
buildMerchantDetailHref, |
| 31 | 32 |
clampDateRange, |
... | ... | @@ -55,9 +56,8 @@ |
| 55 | 56 |
/** |
| 56 | 57 |
* 가맹몰 상세 목록 — 기획 BO-MAL-001을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것. |
| 57 | 58 |
* |
| 58 |
- * 가맹점 전체가 서고 상세가 없는 건은 상태·등록일이 `-`다(사용자 확정). 행의 [수정]은 그 가맹점의 |
|
| 59 |
- * 상세 화면으로 간다 — 상세가 없으면 거기서 처음 만든다. 업체(가맹점) 자체는 BizPlay 쪽에서 오므로 |
|
| 60 |
- * [+ 업체 등록]은 시안 자리만 지키고 비활성이다(사용자 확정). |
|
| 59 |
+ * 백엔드(feature/voc)가 상세가 있는 가맹점만 주므로 모든 행이 상세 행이다. [+ 업체 등록]은 상세 화면 |
|
| 60 |
+ * (등록)으로 가서 상세가 없는 가맹점을 고르고, 행의 [수정]은 그 가맹점의 상세 화면으로 간다. |
|
| 61 | 61 |
* |
| 62 | 62 |
* 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지 |
| 63 | 63 |
* 않아 대부분 `-`로 보인다. |
... | ... | @@ -371,7 +371,7 @@ |
| 371 | 371 |
size="md" |
| 372 | 372 |
leadingIcon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />}
|
| 373 | 373 |
label="업체 등록" |
| 374 |
- disabled |
|
| 374 |
+ onAction={() => router.push(MERCHANT_DETAIL_NEW_PATH)}
|
|
| 375 | 375 |
/> |
| 376 | 376 |
</> |
| 377 | 377 |
} |
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
... | ... | @@ -16,11 +16,7 @@ |
| 16 | 16 |
row: MerchantDetailSummary; |
| 17 | 17 |
} |
| 18 | 18 |
|
| 19 |
-/** |
|
| 20 |
- * 기획 BO-MAL-001 관리 열 — 모든 행에 [수정] [비활성|노출] [삭제](시안대로, 사용자 확정). |
|
| 21 |
- * [수정]은 그 가맹점의 상세 화면으로 가고(상세가 없으면 거기서 처음 만든다), 상세가 없는 행의 |
|
| 22 |
- * [비활성]·[삭제]는 백엔드가 NOT_FOUND로 거절해 사유가 토스트로 올라온다. |
|
| 23 |
- */ |
|
| 19 |
+/** 기획 BO-MAL-001 관리 열 — [수정] [비활성|노출] [삭제]. 목록 행은 모두 상세가 있는 가맹점이다. */ |
|
| 24 | 20 |
export function MerchantDetailRowActions({ row }: MerchantDetailRowActionsProps) {
|
| 25 | 21 |
const router = useRouter(); |
| 26 | 22 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
+++ app/(protected)/(basic)/system/merchant-details/new/page.tsx
... | ... | @@ -0,0 +1,21 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { verifySession } from '@/lib/auth/dal'; | |
| 3 | +import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository'; | |
| 4 | +import { fetchMerchantDetailCandidates } from '@/lib/data/repositories/merchant-detail-repository'; | |
| 5 | +import { CODE_GROUP } from '@/lib/domain/common-code'; | |
| 6 | +import { MerchantDetailForm } from '../[frcsNo]/_components/merchant-detail-form'; | |
| 7 | + | |
| 8 | +export const metadata: Metadata = { title: '가맹몰 상세 정보' }; | |
| 9 | +export const dynamic = 'force-dynamic'; | |
| 10 | + | |
| 11 | +/** 등록 진입 — 아직 상세가 없는 가맹점 중 하나를 골라 상세를 붙인다. */ | |
| 12 | +export default async function Page() { | |
| 13 | + await verifySession(); | |
| 14 | + | |
| 15 | + const [candidates, categories] = await Promise.all([ | |
| 16 | + fetchMerchantDetailCandidates(), | |
| 17 | + fetchCommonCodes(CODE_GROUP.merchantType), | |
| 18 | + ]); | |
| 19 | + | |
| 20 | + return <MerchantDetailForm candidates={candidates} categories={categories} />; | |
| 21 | +} |
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
... | ... | @@ -11,29 +11,34 @@ |
| 11 | 11 |
import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
|
| 12 | 12 |
|
| 13 | 13 |
/** |
| 14 |
- * 가맹몰 상세 Repository — edupay-backend origin/develop 15d409a·22f67cb 기준. |
|
| 14 |
+ * 가맹몰 상세 Repository — edupay-backend origin/feature/voc d784bb6(2026-09-16) 기준. |
|
| 15 | 15 |
* |
| 16 | 16 |
* ``` |
| 17 |
- * GET /api/v1/mngr/voc/detail/pagination TB_COM_VOUCHER ⟕ TB_COM_VOUCHER_DTL — 가맹점 전체 |
|
| 18 |
- * (상세 없는 건은 hasDetail=false로 목록에도 선다) |
|
| 19 |
- * GET /api/v1/mngr/voc/detail/{frcsNo}
|
|
| 20 |
- * POST /api/v1/mngr/voc/detail @RequestBody · 가맹점이 있어야 붙는다 |
|
| 21 |
- * PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody
|
|
| 17 |
+ * GET /api/v1/mngr/voc/detail/pagination 상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드) |
|
| 18 |
+ * GET /api/v1/mngr/voc/detail/{frcsNo} 상세가 없으면 NOT_FOUND 봉투
|
|
| 19 |
+ * POST /api/v1/mngr/voc/detail @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404 |
|
| 20 |
+ * PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody · 전 컬럼 덮어씀
|
|
| 22 | 21 |
* DELETE /api/v1/mngr/voc/{frcsNo} 논리 삭제(DEL_YN)
|
| 22 |
+ * GET /api/v1/mngr/voc/pagination 가맹점 전체 — 등록 화면의 선택지에만 쓴다 |
|
| 23 | 23 |
* ``` |
| 24 | 24 |
* |
| 25 |
- * ⚠️ 백엔드 결함(보고함, 미수정) — 화면은 시안대로 두고 값을 그대로 보낸다(사용자 확정): |
|
| 26 |
- * 1. 목록·단건 SELECT(`includeSql`)에 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서가 |
|
| 27 |
- * 없어 수정 프리필이 빈다. 카테고리 이름(`CD_NM`)은 SELECT엔 있으나 VO에 담을 곳이 없다. |
|
| 28 |
- * 2. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다. |
|
| 29 |
- * 3. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다. |
|
| 25 |
+ * 목록·단건이 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서·우편번호·등록자(`frstRgtrNmStr`)· |
|
| 26 |
+ * 등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다. |
|
| 30 | 27 |
* |
| 31 |
- * 노출 상태(`useYn`)는 `MngrVocDetailVo`가 `MngrVocVo`에서 상속받아 등록·수정·조회 모두 실린다 |
|
| 32 |
- * (처음엔 없다고 잘못 보고했다 — 2026-09-14 정정). |
|
| 28 |
+ * ⚠️ 백엔드 결함(보고함, 미수정): |
|
| 29 |
+ * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 그동안 업체명 자리에 |
|
| 30 |
+ * 가맹점 코드를 보인다. |
|
| 31 |
+ * 2. 건수 쿼리(`selectPaginationCount`)만 LEFT JOIN이라 상세 없는 가맹점까지 세어 페이지 수가 부풀 수 있다. |
|
| 32 |
+ * 3. `selectOne`이 DEL_YN을 거르지 않아 삭제한 상세가 있는 가맹점은 다시 등록하면 409가 난다. |
|
| 33 |
+ * 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다. |
|
| 34 |
+ * 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다. |
|
| 33 | 35 |
*/ |
| 34 | 36 |
|
| 35 | 37 |
const DETAIL_PATH = '/api/v1/mngr/voc/detail'; |
| 36 | 38 |
const VOC_PATH = '/api/v1/mngr/voc'; |
| 39 |
+// 등록 선택지·상세 보유 목록을 한 번에 훑는 크기. 가맹점은 많아야 수백 건이다. |
|
| 40 |
+const SCAN_PAGE_SIZE = 500; |
|
| 41 |
+const MAX_SCAN_PAGES = 20; |
|
| 37 | 42 |
|
| 38 | 43 |
function isRecord(value: unknown): value is Record<string, unknown> {
|
| 39 | 44 |
return value !== null && typeof value === 'object'; |
... | ... | @@ -80,13 +85,41 @@ |
| 80 | 85 |
return date.toISOString().slice(0, 10); |
| 81 | 86 |
} |
| 82 | 87 |
|
| 88 |
+/** 상세 목록·단건의 한 행. 가맹점 API의 행은 `toCandidate`가 따로 맡는다. */ |
|
| 83 | 89 |
function toSummary(raw: unknown): MerchantDetailSummary[] {
|
| 90 |
+ if (!isRecord(raw)) return []; |
|
| 91 |
+ const id = readString(raw, 'frcsNo'); |
|
| 92 |
+ if (!id) return []; |
|
| 93 |
+ |
|
| 94 |
+ const registeredAt = readDate(raw, 'frstRegDt'); |
|
| 95 |
+ return [ |
|
| 96 |
+ {
|
|
| 97 |
+ id, |
|
| 98 |
+ // 결함 1 — 업체명이 안 오면 코드라도 보인다. |
|
| 99 |
+ name: readString(raw, 'frcsNm') ?? id, |
|
| 100 |
+ phoneNumber: readString(raw, 'rprsvTelno'), |
|
| 101 |
+ address: readString(raw, 'frcsPostAddr'), |
|
| 102 |
+ addressDetail: readString(raw, 'frcsDaddr'), |
|
| 103 |
+ postalCode: readString(raw, 'frcsPostZip'), |
|
| 104 |
+ thumbnailFileId: readString(raw, 'thumbAtchFileId'), |
|
| 105 |
+ thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')), |
|
| 106 |
+ categoryCode: readString(raw, 'frcsTypeCd'), |
|
| 107 |
+ isVisible: readYesNo(raw, 'useYn'), |
|
| 108 |
+ registeredAt, |
|
| 109 |
+ hasDetail: |
|
| 110 |
+ registeredAt !== null || |
|
| 111 |
+ readString(raw, 'thumbAtchFileId') !== null || |
|
| 112 |
+ readString(raw, 'frcsTypeCd') !== null, |
|
| 113 |
+ }, |
|
| 114 |
+ ]; |
|
| 115 |
+} |
|
| 116 |
+ |
|
| 117 |
+/** 가맹점 API(`/mngr/voc/pagination`)의 한 행 — 아직 상세가 없는 가맹점의 선택지. */ |
|
| 118 |
+function toCandidate(raw: unknown): MerchantDetailSummary[] {
|
|
| 84 | 119 |
if (!isRecord(raw)) return []; |
| 85 | 120 |
const id = readString(raw, 'frcsNo'); |
| 86 | 121 |
const name = readString(raw, 'frcsNm'); |
| 87 | 122 |
if (!id || !name) return []; |
| 88 |
- |
|
| 89 |
- const registeredAt = readDate(raw, 'frstRegDt'); |
|
| 90 | 123 |
return [ |
| 91 | 124 |
{
|
| 92 | 125 |
id, |
... | ... | @@ -94,13 +127,13 @@ |
| 94 | 127 |
phoneNumber: readString(raw, 'rprsvTelno'), |
| 95 | 128 |
address: readString(raw, 'frcsPostAddr'), |
| 96 | 129 |
addressDetail: readString(raw, 'frcsDaddr'), |
| 97 |
- thumbnailFileId: readString(raw, 'thumbAtchFileId'), |
|
| 98 |
- thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')), |
|
| 99 |
- categoryCode: readString(raw, 'frcsTypeCd'), |
|
| 100 |
- isVisible: readYesNo(raw, 'useYn'), |
|
| 101 |
- registeredAt, |
|
| 102 |
- // 상세 행은 LEFT JOIN이라 없으면 상세 컬럼이 전부 null이다 — 등록일이 그 표지다. |
|
| 103 |
- hasDetail: registeredAt !== null || readString(raw, 'thumbAtchFileId') !== null, |
|
| 130 |
+ postalCode: readString(raw, 'frcsPostZip'), |
|
| 131 |
+ thumbnailFileId: null, |
|
| 132 |
+ thumbnailUrl: null, |
|
| 133 |
+ categoryCode: null, |
|
| 134 |
+ isVisible: null, |
|
| 135 |
+ registeredAt: null, |
|
| 136 |
+ hasDetail: false, |
|
| 104 | 137 |
}, |
| 105 | 138 |
]; |
| 106 | 139 |
} |
... | ... | @@ -134,15 +167,15 @@ |
| 134 | 167 |
return {
|
| 135 | 168 |
...summary, |
| 136 | 169 |
imageUrls: await probeImageUrls(imageFileId), |
| 137 |
- postalCode: readString(raw, 'frcsPostZip'), |
|
| 138 | 170 |
description: readString(raw, 'frcsCn'), |
| 139 | 171 |
openTime: readString(raw, 'startHour'), |
| 140 | 172 |
closeTime: readString(raw, 'endHour'), |
| 141 | 173 |
closedDays: readString(raw, 'offDayCn'), |
| 142 | 174 |
imageFileId, |
| 143 | 175 |
sortOrder: readNumber(raw, 'sortOrdr'), |
| 144 |
- registeredBy: readString(raw, 'frstRgtrNm'), |
|
| 176 |
+ registeredBy: readString(raw, 'frstRgtrNmStr') ?? readString(raw, 'frstRgtrNm'), |
|
| 145 | 177 |
modifiedAt: readDate(raw, 'lastMdfcnDt'), |
| 178 |
+ // 수정자는 `lastMdfrNm`이 JSON에서 빠지고 *Str 별칭도 없어 아직 안 온다. |
|
| 146 | 179 |
modifiedBy: readString(raw, 'lastMdfrNm'), |
| 147 | 180 |
}; |
| 148 | 181 |
} |
... | ... | @@ -203,11 +236,67 @@ |
| 203 | 236 |
); |
| 204 | 237 |
|
| 205 | 238 |
if (!result.ok) {
|
| 239 |
+ // 상세가 없는 가맹점은 NOT_FOUND 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다. |
|
| 240 |
+ if (result.code === 404) return null; |
|
| 206 | 241 |
throw new BackendRequestError(result); |
| 207 | 242 |
} |
| 208 | 243 |
return toDetail(result.data); |
| 209 | 244 |
} |
| 210 | 245 |
|
| 246 |
+async function fetchPage<T>( |
|
| 247 |
+ path: string, |
|
| 248 |
+ pageIndex: number, |
|
| 249 |
+ pageSize: number, |
|
| 250 |
+ toRow: (raw: unknown) => T[] |
|
| 251 |
+): Promise<{ rows: T[]; rowCount: number; totalCount: number }> {
|
|
| 252 |
+ const accessToken = await getSessionAccessToken(); |
|
| 253 |
+ const result = await backendFetch<unknown>(path, {
|
|
| 254 |
+ method: 'GET', |
|
| 255 |
+ query: { pageIndex, recordCountPerPage: pageSize },
|
|
| 256 |
+ accessToken: accessToken ?? undefined, |
|
| 257 |
+ cache: 'no-store', |
|
| 258 |
+ canHaveNullData: true, |
|
| 259 |
+ }); |
|
| 260 |
+ if (!result.ok) {
|
|
| 261 |
+ throw new BackendRequestError(result); |
|
| 262 |
+ } |
|
| 263 |
+ const data = result.data; |
|
| 264 |
+ if (!isRecord(data) || !Array.isArray(data.list)) {
|
|
| 265 |
+ return { rows: [], rowCount: 0, totalCount: 0 };
|
|
| 266 |
+ } |
|
| 267 |
+ return {
|
|
| 268 |
+ rows: data.list.flatMap(toRow), |
|
| 269 |
+ rowCount: data.list.length, |
|
| 270 |
+ totalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, |
|
| 271 |
+ }; |
|
| 272 |
+} |
|
| 273 |
+ |
|
| 274 |
+async function fetchAllPages<T>(path: string, toRow: (raw: unknown) => T[]): Promise<T[]> {
|
|
| 275 |
+ const rows: T[] = []; |
|
| 276 |
+ let seen = 0; |
|
| 277 |
+ for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) {
|
|
| 278 |
+ const page = await fetchPage(path, pageIndex, SCAN_PAGE_SIZE, toRow); |
|
| 279 |
+ rows.push(...page.rows); |
|
| 280 |
+ seen += page.rowCount; |
|
| 281 |
+ if (page.rowCount < SCAN_PAGE_SIZE || seen >= page.totalCount) break; |
|
| 282 |
+ } |
|
| 283 |
+ return rows; |
|
| 284 |
+} |
|
| 285 |
+ |
|
| 286 |
+/** |
|
| 287 |
+ * 등록 화면의 업체 선택지 — 가맹점 전체에서 이미 상세가 있는 것을 뺀다. 백엔드에 "상세 없는 가맹점" |
|
| 288 |
+ * 조회가 없어 두 목록을 훑어 차집합을 만든다. |
|
| 289 |
+ */ |
|
| 290 |
+export async function fetchMerchantDetailCandidates(): Promise<MerchantDetailSummary[]> {
|
|
| 291 |
+ const [merchants, registered] = await Promise.all([ |
|
| 292 |
+ fetchAllPages(`${VOC_PATH}/pagination`, toCandidate),
|
|
| 293 |
+ fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary),
|
|
| 294 |
+ ]); |
|
| 295 |
+ // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 목록에 섞어 주므로 상세가 있는 것만 뺀다. |
|
| 296 |
+ const registeredIds = new Set(registered.filter((row) => row.hasDetail).map((row) => row.id)); |
|
| 297 |
+ return merchants.filter((row) => !registeredIds.has(row.id)); |
|
| 298 |
+} |
|
| 299 |
+ |
|
| 211 | 300 |
function toWritePayload(values: MerchantDetailValues): Record<string, unknown> {
|
| 212 | 301 |
return {
|
| 213 | 302 |
frcsNo: values.frcsNo, |
--- lib/data/repositories/merchant-product-repository.ts
+++ lib/data/repositories/merchant-product-repository.ts
... | ... | @@ -10,11 +10,11 @@ |
| 10 | 10 |
import type { MerchantProductValues } from '@/lib/domain/merchant-product-form';
|
| 11 | 11 |
|
| 12 | 12 |
/** |
| 13 |
- * 가맹몰 메뉴 Repository — edupay-backend origin/develop 22f67cb 기준(2026-09-14 재확인). |
|
| 13 |
+ * 가맹몰 메뉴 Repository — edupay-backend origin/feature/voc d784bb6 기준(2026-09-16). |
|
| 14 | 14 |
* |
| 15 | 15 |
* ``` |
| 16 | 16 |
* GET /api/v1/mngr/voc/product/list/{frcsNo} 메뉴 전체(페이징 없음) — 상세와 INNER JOIN
|
| 17 |
- * GET /api/v1/mngr/voc/product/{frcsProductSn} 없으면 NOT_FOUND 봉투
|
|
| 17 |
+ * GET /api/v1/mngr/voc/product/{frcsProductSn} 없으면 성공 봉투에 data: null (develop은 NOT_FOUND)
|
|
| 18 | 18 |
* POST /api/v1/mngr/voc/product @RequestBody · 가맹점만 있으면 받는다 |
| 19 | 19 |
* PUT /api/v1/mngr/voc/product/{frcsProductSn} @RequestBody · 전 컬럼 덮어씀
|
| 20 | 20 |
* DELETE /api/v1/mngr/voc/product/{frcsProductSn} 논리 삭제(DEL_YN)
|
--- lib/domain/merchant-detail-form.ts
+++ lib/domain/merchant-detail-form.ts
... | ... | @@ -29,6 +29,7 @@ |
| 29 | 29 |
|
| 30 | 30 |
export type MerchantDetailFormErrors = Partial< |
| 31 | 31 |
Record< |
| 32 |
+ | 'frcsNo' |
|
| 32 | 33 |
| 'categoryCode' |
| 33 | 34 |
| 'description' |
| 34 | 35 |
| 'openTime' |
--- lib/domain/merchant-detail-query.ts
+++ lib/domain/merchant-detail-query.ts
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { MERCHANT_DETAIL_VISIBILITY_OPTIONS } from '@/lib/domain/merchant-detail';
|
| 2 | 2 |
|
| 3 | 3 |
export const MERCHANT_DETAILS_PATH = '/system/merchant-details'; |
| 4 |
+export const MERCHANT_DETAIL_NEW_PATH = `${MERCHANT_DETAILS_PATH}/new`;
|
|
| 4 | 5 |
|
| 5 | 6 |
export function merchantDetailPath(frcsNo: string): string {
|
| 6 | 7 |
return `${MERCHANT_DETAILS_PATH}/${encodeURIComponent(frcsNo)}`;
|
--- lib/domain/merchant-detail.ts
+++ lib/domain/merchant-detail.ts
... | ... | @@ -7,7 +7,10 @@ |
| 7 | 7 |
* 가져오고, 상세 항목만 등록·수정한다(사용자 확정). |
| 8 | 8 |
*/ |
| 9 | 9 |
|
| 10 |
-/** 목록 한 행 — 가맹점 전체가 오며 상세가 없는 건은 `hasDetail`이 false다(그대로 목록에 선다). */ |
|
| 10 |
+/** |
|
| 11 |
+ * 가맹점 한 건 + 상세 요약. 백엔드 목록(feature/voc)은 상세가 있는 가맹점만 주므로 거기서 온 행은 |
|
| 12 |
+ * `hasDetail`이 참이고, 등록 화면의 선택지(가맹점 API에서 온 것)만 거짓이다. |
|
| 13 |
+ */ |
|
| 11 | 14 |
export type MerchantDetailSummary = {
|
| 12 | 15 |
/** 백엔드 `frcsNo` — 가맹점 코드이자 상세의 키. */ |
| 13 | 16 |
id: string; |
... | ... | @@ -15,23 +18,23 @@ |
| 15 | 18 |
phoneNumber: string | null; |
| 16 | 19 |
address: string | null; |
| 17 | 20 |
addressDetail: string | null; |
| 21 |
+ postalCode: string | null; |
|
| 18 | 22 |
/** 백엔드 `thumbAtchFileId` — 대표 이미지. */ |
| 19 | 23 |
thumbnailFileId: string | null; |
| 20 | 24 |
/** Repository가 만든 이미지 URL. 화면은 파일 id를 URL로 바꾸는 법을 모른다. */ |
| 21 | 25 |
thumbnailUrl: string | null; |
| 22 |
- /** 백엔드 `frcsTypeCd` — 카테고리 코드. 목록 SQL에 아직 없어 늘 null이다(보고함). */ |
|
| 26 |
+ /** 백엔드 `frcsTypeCd` — 카테고리 코드. */ |
|
| 23 | 27 |
categoryCode: string | null; |
| 24 | 28 |
/** 백엔드 상세 `useYn`. 상세가 없는 가맹점은 null이다. */ |
| 25 | 29 |
isVisible: boolean | null; |
| 26 |
- /** 상세 등록일(`d.FRST_REG_DT`). 없으면 상세가 아직 없는 가맹점이다. */ |
|
| 30 |
+ /** 상세 등록일(`frstRegDtStr`, YYYY-MM-DD). */ |
|
| 27 | 31 |
registeredAt: string | null; |
| 28 |
- /** 상세 행이 있는지 — 일괄 비활성·삭제 대상인지, 메뉴를 붙일 수 있는지를 가른다. */ |
|
| 32 |
+ /** 상세 행이 있는지 — 목록 행은 참, 등록 선택지는 거짓. */ |
|
| 29 | 33 |
hasDetail: boolean; |
| 30 | 34 |
}; |
| 31 | 35 |
|
| 32 |
-/** 등록·수정 화면이 받는 한 건. 상세가 없으면 상세 항목이 전부 비어 있다. */ |
|
| 36 |
+/** 상세 화면이 받는 한 건. */ |
|
| 33 | 37 |
export type MerchantDetail = MerchantDetailSummary & {
|
| 34 |
- postalCode: string | null; |
|
| 35 | 38 |
/** 백엔드 `frcsCn` — 한줄 소개. */ |
| 36 | 39 |
description: string | null; |
| 37 | 40 |
/** 백엔드 `startHour` / `endHour` — "HH:mm". */ |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?