feat: 가맹점 관리를 /api/v1/mngr/voc API로 이식
mock을 걷어내고 TB_COM_VOUCHER를 보는 실제 API에 붙였다. 필드 이름이 화면 용어와 달라 Repository가 옮긴다 — frcsNo=코드, frcsNm=가맹점명, brno=사업자번호, rprsvNm/rprsvTelno=대표자/전화, frcsPostAddr/frcsDaddr/frcsPostZip=주소 3종. 백엔드에 자리가 없는 것은 화면에서 뺐다 — 브랜드명·메모·등록일 컬럼이 없고, 수정·삭제·단건조회 API가 없다(목록과 등록 둘뿐). 행의 수정·삭제 버튼은 시안 자리를 두되 누르면 준비 중임을 알린다. 우편번호(frcsPostZip)를 새로 싣는다 — 주소 검색이 함께 채우는 값이다. Co-Authored-By: Claude Opus 5
@48276ee4d9a407350f1b93550415b40b6bb920ed
--- app/(protected)/(basic)/system/merchants/_actions.ts
+++ app/(protected)/(basic)/system/merchants/_actions.ts
... | ... | @@ -2,11 +2,7 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 | 4 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 |
-import {
|
|
| 6 |
- createMerchant, |
|
| 7 |
- deleteMerchant, |
|
| 8 |
- updateMerchant, |
|
| 9 |
-} from '@/lib/data/repositories/merchant-repository'; |
|
| 5 |
+import { createMerchant } from '@/lib/data/repositories/merchant-repository';
|
|
| 10 | 6 |
import {
|
| 11 | 7 |
validateMerchant, |
| 12 | 8 |
type MerchantFormState, |
... | ... | @@ -14,7 +10,6 @@ |
| 14 | 10 |
import { MERCHANTS_PATH } from '@/lib/domain/merchant-query';
|
| 15 | 11 |
|
| 16 | 12 |
const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
| 17 |
-const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
|
| 18 | 13 |
|
| 19 | 14 |
function readString(formData: FormData, key: string): string {
|
| 20 | 15 |
const value = formData.get(key); |
... | ... | @@ -29,7 +24,6 @@ |
| 29 | 24 |
|
| 30 | 25 |
const validation = validateMerchant({
|
| 31 | 26 |
id: readString(formData, 'id'), |
| 32 |
- brandName: readString(formData, 'brandName'), |
|
| 33 | 27 |
name: readString(formData, 'name'), |
| 34 | 28 |
categoryCode: readString(formData, 'categoryCode'), |
| 35 | 29 |
businessNumber: readString(formData, 'businessNumber'), |
... | ... | @@ -37,38 +31,20 @@ |
| 37 | 31 |
phoneNumber: readString(formData, 'phoneNumber'), |
| 38 | 32 |
address: readString(formData, 'address'), |
| 39 | 33 |
addressDetail: readString(formData, 'addressDetail'), |
| 34 |
+ postalCode: readString(formData, 'postalCode'), |
|
| 40 | 35 |
latitude: readString(formData, 'latitude'), |
| 41 | 36 |
longitude: readString(formData, 'longitude'), |
| 42 | 37 |
isVisible: readString(formData, 'isVisible') === 'Y', |
| 43 |
- memo: readString(formData, 'memo'), |
|
| 44 | 38 |
}); |
| 45 | 39 |
if (!validation.ok) {
|
| 46 | 40 |
return { status: 'error', errors: validation.errors };
|
| 47 | 41 |
} |
| 48 | 42 |
|
| 43 |
+ // TODO(백엔드): 수정 API가 없다(`/api/v1/mngr/voc`는 목록·등록 둘뿐). 생기면 여기서 가른다. |
|
| 49 | 44 |
try {
|
| 50 |
- if (validation.values.id) {
|
|
| 51 |
- await updateMerchant(validation.values); |
|
| 52 |
- } else {
|
|
| 53 |
- await createMerchant(validation.values); |
|
| 54 |
- } |
|
| 45 |
+ await createMerchant(validation.values); |
|
| 55 | 46 |
} catch {
|
| 56 | 47 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 57 |
- } |
|
| 58 |
- |
|
| 59 |
- revalidatePath(MERCHANTS_PATH); |
|
| 60 |
- return { status: 'success' };
|
|
| 61 |
-} |
|
| 62 |
- |
|
| 63 |
-export async function deleteMerchantAction( |
|
| 64 |
- id: string |
|
| 65 |
-): Promise<{ status: 'success' } | { status: 'error'; message: string }> {
|
|
| 66 |
- await verifySession(); |
|
| 67 |
- |
|
| 68 |
- try {
|
|
| 69 |
- await deleteMerchant(id); |
|
| 70 |
- } catch {
|
|
| 71 |
- return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
|
| 72 | 48 |
} |
| 73 | 49 |
|
| 74 | 50 |
revalidatePath(MERCHANTS_PATH); |
--- app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
... | ... | @@ -17,9 +17,7 @@ |
| 17 | 17 |
} from '@/lib/domain/merchant'; |
| 18 | 18 |
import {
|
| 19 | 19 |
ADDRESS_DETAIL_MAX_LENGTH, |
| 20 |
- BRAND_MAX_LENGTH, |
|
| 21 | 20 |
INITIAL_MERCHANT_FORM_STATE, |
| 22 |
- MEMO_MAX_LENGTH, |
|
| 23 | 21 |
NAME_MAX_LENGTH, |
| 24 | 22 |
OWNER_MAX_LENGTH, |
| 25 | 23 |
} from '@/lib/domain/merchant-form'; |
... | ... | @@ -44,6 +42,7 @@ |
| 44 | 42 |
const [isVisible, setIsVisible] = useState(merchant?.isVisible ?? true); |
| 45 | 43 |
// 주소·좌표는 검색 결과가 채운다(기획 ③ Read Only). |
| 46 | 44 |
const [address, setAddress] = useState(merchant?.address ?? ''); |
| 45 |
+ const [postalCode, setPostalCode] = useState(merchant?.postalCode ?? ''); |
|
| 47 | 46 |
const [latitude, setLatitude] = useState(merchant?.latitude ?? ''); |
| 48 | 47 |
const [longitude, setLongitude] = useState(merchant?.longitude ?? ''); |
| 49 | 48 |
|
... | ... | @@ -66,6 +65,7 @@ |
| 66 | 65 |
return; |
| 67 | 66 |
} |
| 68 | 67 |
setAddress(picked.trim()); |
| 68 |
+ setPostalCode('');
|
|
| 69 | 69 |
setLatitude('');
|
| 70 | 70 |
setLongitude('');
|
| 71 | 71 |
} |
... | ... | @@ -98,6 +98,7 @@ |
| 98 | 98 |
<input type="hidden" name="categoryCode" value={categoryCode} />
|
| 99 | 99 |
<input type="hidden" name="isVisible" value={isVisible ? 'Y' : 'N'} />
|
| 100 | 100 |
<input type="hidden" name="address" value={address} />
|
| 101 |
+ <input type="hidden" name="postalCode" value={postalCode} />
|
|
| 101 | 102 |
<input type="hidden" name="latitude" value={latitude} />
|
| 102 | 103 |
<input type="hidden" name="longitude" value={longitude} />
|
| 103 | 104 |
|
... | ... | @@ -113,17 +114,6 @@ |
| 113 | 114 |
/> |
| 114 | 115 |
</div> |
| 115 | 116 |
)} |
| 116 |
- |
|
| 117 |
- <div className={styles.field}>
|
|
| 118 |
- <FoxInput |
|
| 119 |
- size="md" |
|
| 120 |
- name="brandName" |
|
| 121 |
- label="브랜드명" |
|
| 122 |
- defaultValue={merchant?.brandName ?? ''}
|
|
| 123 |
- placeholder="브랜드명을 입력하세요." |
|
| 124 |
- maxLength={BRAND_MAX_LENGTH}
|
|
| 125 |
- /> |
|
| 126 |
- </div> |
|
| 127 | 117 |
|
| 128 | 118 |
<div className={styles.field}>
|
| 129 | 119 |
<FoxInput |
... | ... | @@ -265,39 +255,6 @@ |
| 265 | 255 |
/> |
| 266 | 256 |
</FoxRadioGroup> |
| 267 | 257 |
</div> |
| 268 |
- |
|
| 269 |
- <div className={styles.field}>
|
|
| 270 |
- <FoxInput |
|
| 271 |
- size="md" |
|
| 272 |
- name="memo" |
|
| 273 |
- label="메모" |
|
| 274 |
- defaultValue={merchant?.memo ?? ''}
|
|
| 275 |
- placeholder="관리자 메모 (사용자에게 노출되지 않음)" |
|
| 276 |
- maxLength={MEMO_MAX_LENGTH}
|
|
| 277 |
- /> |
|
| 278 |
- </div> |
|
| 279 |
- |
|
| 280 |
- {isEdit && (
|
|
| 281 |
- <div className={styles.field}>
|
|
| 282 |
- <FoxFormLabel as="span">등록일 / 최종수정일</FoxFormLabel> |
|
| 283 |
- <div className={styles.pair}>
|
|
| 284 |
- <FoxInput |
|
| 285 |
- size="md" |
|
| 286 |
- aria-label="등록일" |
|
| 287 |
- value={merchant?.createdAt ?? ''}
|
|
| 288 |
- readOnly |
|
| 289 |
- state="view" |
|
| 290 |
- /> |
|
| 291 |
- <FoxInput |
|
| 292 |
- size="md" |
|
| 293 |
- aria-label="최종수정일" |
|
| 294 |
- value={merchant?.updatedAt ?? ''}
|
|
| 295 |
- readOnly |
|
| 296 |
- state="view" |
|
| 297 |
- /> |
|
| 298 |
- </div> |
|
| 299 |
- </div> |
|
| 300 |
- )} |
|
| 301 | 258 |
|
| 302 | 259 |
{state.status === 'error' && state.message && (
|
| 303 | 260 |
<FoxHelperText type="danger" message={state.message} />
|
--- app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
... | ... | @@ -66,12 +66,6 @@ |
| 66 | 66 |
render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, |
| 67 | 67 |
}, |
| 68 | 68 |
{ key: 'code', header: '가맹점코드', width: 140 },
|
| 69 |
- {
|
|
| 70 |
- key: 'brandName', |
|
| 71 |
- header: '브랜드명', |
|
| 72 |
- width: 120, |
|
| 73 |
- render: (row) => formatOptionalValue(row.brandName), |
|
| 74 |
- }, |
|
| 75 | 69 |
{ key: 'name', header: '가맹점명', width: 240, emphasis: true },
|
| 76 | 70 |
{
|
| 77 | 71 |
key: 'categoryCode', |
... | ... | @@ -101,12 +95,6 @@ |
| 101 | 95 |
label={formatVisibilityLabel(row.isVisible)}
|
| 102 | 96 |
/> |
| 103 | 97 |
), |
| 104 |
- }, |
|
| 105 |
- {
|
|
| 106 |
- key: 'createdAt', |
|
| 107 |
- header: '등록일', |
|
| 108 |
- width: 140, |
|
| 109 |
- render: (row) => formatOptionalValue(row.createdAt), |
|
| 110 | 98 |
}, |
| 111 | 99 |
{
|
| 112 | 100 |
key: 'actions', |
--- app/(protected)/(basic)/system/merchants/_components/merchant-row-actions.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-row-actions.tsx
... | ... | @@ -1,73 +1,43 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useState, useTransition } from 'react';
|
|
| 4 |
-import { FoxButton } from '@fox/core/components/fox-button';
|
|
| 5 |
-import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
|
| 6 | 3 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 4 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 | 5 |
import type { Merchant } from '@/lib/domain/merchant';
|
| 9 |
-import { deleteMerchantAction } from '../_actions';
|
|
| 10 |
-import { MerchantFormModal } from './merchant-form-modal';
|
|
| 11 | 6 |
|
| 12 | 7 |
interface MerchantRowActionsProps {
|
| 13 | 8 |
merchant: Merchant; |
| 14 | 9 |
} |
| 15 | 10 |
|
| 11 |
+/** |
|
| 12 |
+ * TODO(백엔드): 수정·삭제 API가 없다 — `/api/v1/mngr/voc`는 목록(`/pagination`)과 등록뿐이다. |
|
| 13 |
+ * 시안의 자리는 두되 눌러도 할 수 있는 일이 없어, 비활성 대신 사유를 알리도록 했다. |
|
| 14 |
+ */ |
|
| 16 | 15 |
export function MerchantRowActions({ merchant }: MerchantRowActionsProps) {
|
| 17 |
- const { showAlert, hideAlert, showToast } = useFeedback();
|
|
| 18 |
- const [isEditOpen, setIsEditOpen] = useState(false); |
|
| 19 |
- const [isDeleting, startDeleting] = useTransition(); |
|
| 16 |
+ const { showToast } = useFeedback();
|
|
| 20 | 17 |
|
| 21 |
- function runDelete() {
|
|
| 22 |
- hideAlert(); |
|
| 23 |
- startDeleting(async () => {
|
|
| 24 |
- const result = await deleteMerchantAction(merchant.id); |
|
| 25 |
- if (result.status === 'error') {
|
|
| 26 |
- showToast({ variant: 'danger', message: result.message });
|
|
| 27 |
- return; |
|
| 28 |
- } |
|
| 29 |
- showToast({ variant: 'success', message: '가맹점을 삭제했습니다.' });
|
|
| 30 |
- }); |
|
| 31 |
- } |
|
| 32 |
- |
|
| 33 |
- function confirmDelete() {
|
|
| 34 |
- showAlert({
|
|
| 35 |
- variant: 'danger', |
|
| 36 |
- title: '가맹점을 삭제하시겠습니까?', |
|
| 37 |
- message: `"${merchant.name}"을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
|
|
| 38 |
- actions: ( |
|
| 39 |
- <FoxButtonGroup size="md"> |
|
| 40 |
- <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
|
|
| 41 |
- <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
|
|
| 42 |
- </FoxButtonGroup> |
|
| 43 |
- ), |
|
| 18 |
+ function notReady() {
|
|
| 19 |
+ showToast({
|
|
| 20 |
+ variant: 'warning', |
|
| 21 |
+ message: '가맹점 수정·삭제 기능은 백엔드 준비 중입니다.', |
|
| 44 | 22 |
}); |
| 45 | 23 |
} |
| 46 | 24 |
|
| 47 | 25 |
return ( |
| 48 |
- <FoxButtonGroup size="sm" align="center"> |
|
| 26 |
+ <span aria-label={`${merchant.name} 관리`}>
|
|
| 49 | 27 |
<FoxIconButton |
| 50 | 28 |
type="default" |
| 51 | 29 |
size="sm" |
| 52 | 30 |
icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
|
| 53 | 31 |
label="수정" |
| 54 |
- onAction={() => setIsEditOpen(true)}
|
|
| 32 |
+ onAction={notReady}
|
|
| 55 | 33 |
/> |
| 56 | 34 |
<FoxIconButton |
| 57 | 35 |
type="default" |
| 58 | 36 |
size="sm" |
| 59 | 37 |
icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
|
| 60 |
- label={isDeleting ? '삭제 중' : '삭제'}
|
|
| 61 |
- disabled={isDeleting}
|
|
| 62 |
- onAction={confirmDelete}
|
|
| 38 |
+ label="삭제" |
|
| 39 |
+ onAction={notReady}
|
|
| 63 | 40 |
/> |
| 64 |
- |
|
| 65 |
- {isEditOpen && (
|
|
| 66 |
- <MerchantFormModal |
|
| 67 |
- merchant={merchant}
|
|
| 68 |
- onClose={() => setIsEditOpen(false)}
|
|
| 69 |
- /> |
|
| 70 |
- )} |
|
| 71 |
- </FoxButtonGroup> |
|
| 41 |
+ </span> |
|
| 72 | 42 |
); |
| 73 | 43 |
} |
--- app/(protected)/(basic)/system/merchants/excel/route.ts
+++ app/(protected)/(basic)/system/merchants/excel/route.ts
... | ... | @@ -12,7 +12,6 @@ |
| 12 | 12 |
const HEADERS = [ |
| 13 | 13 |
'번호', |
| 14 | 14 |
'가맹점코드', |
| 15 |
- '브랜드명', |
|
| 16 | 15 |
'가맹점명', |
| 17 | 16 |
'업종', |
| 18 | 17 |
'사업자등록번호', |
... | ... | @@ -20,8 +19,8 @@ |
| 20 | 19 |
'전화번호', |
| 21 | 20 |
'주소', |
| 22 | 21 |
'상세주소', |
| 22 |
+ '우편번호', |
|
| 23 | 23 |
'사용여부', |
| 24 |
- '등록일', |
|
| 25 | 24 |
]; |
| 26 | 25 |
|
| 27 | 26 |
/** 쉼표·따옴표·줄바꿈이 든 값은 따옴표로 감싸고 내부 따옴표는 두 번 쓴다(RFC 4180). */ |
... | ... | @@ -38,7 +37,6 @@ |
| 38 | 37 |
[ |
| 39 | 38 |
String(index + 1), |
| 40 | 39 |
merchant.code, |
| 41 |
- merchant.brandName, |
|
| 42 | 40 |
merchant.name, |
| 43 | 41 |
formatCategoryLabel(merchant.categoryCode), |
| 44 | 42 |
merchant.businessNumber, |
... | ... | @@ -46,8 +44,8 @@ |
| 46 | 44 |
merchant.phoneNumber, |
| 47 | 45 |
merchant.address, |
| 48 | 46 |
merchant.addressDetail, |
| 47 |
+ merchant.postalCode, |
|
| 49 | 48 |
formatVisibilityLabel(merchant.isVisible), |
| 50 |
- merchant.createdAt, |
|
| 51 | 49 |
] |
| 52 | 50 |
.map(toCsvCell) |
| 53 | 51 |
.join(',')
|
--- lib/data/repositories/merchant-repository.ts
+++ lib/data/repositories/merchant-repository.ts
... | ... | @@ -1,162 +1,180 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 |
-import type { Merchant } from '@/lib/domain/merchant';
|
|
| 3 |
-import { MERCHANT_CATEGORY_OPTIONS } from '@/lib/domain/merchant';
|
|
| 2 |
+import { getSessionAccessToken } from '@/lib/auth/dal';
|
|
| 3 |
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
|
| 4 |
+import {
|
|
| 5 |
+ MERCHANT_CATEGORY_READ_FIELD, |
|
| 6 |
+ MERCHANT_CATEGORY_WRITE_FIELD, |
|
| 7 |
+ type Merchant, |
|
| 8 |
+} from '@/lib/domain/merchant'; |
|
| 4 | 9 |
import type { MerchantQuery } from '@/lib/domain/merchant-query';
|
| 5 | 10 |
import type { MerchantValues } from '@/lib/domain/merchant-form';
|
| 6 | 11 |
|
| 7 | 12 |
/** |
| 8 |
- * 가맹점 Repository — 기획 MCH_001/002_p/003_p. |
|
| 13 |
+ * 가맹점 Repository — `TB_COM_VOUCHER`. |
|
| 9 | 14 |
* |
| 10 |
- * TODO(백엔드): 관리자용 가맹점 API가 아직 없다(`mngr` 도메인에 해당 컨트롤러가 없고, |
|
| 11 |
- * `common/qrauth`의 `CmmPaymentMerchantVo`는 결제 조회용이라 id·name·type·addr·telNo뿐이다). |
|
| 12 |
- * 아래 mock 저장소를 지우고 다른 Repository처럼 backendFetch + getSessionAccessToken으로 |
|
| 13 |
- * 교체하면 호출부는 그대로 둘 수 있다. |
|
| 15 |
+ * ``` |
|
| 16 |
+ * GET /api/v1/mngr/voc/pagination searchCondition 1=가맹점명 2=사업자번호 3=상세주소 |
|
| 17 |
+ * POST /api/v1/mngr/voc 어노테이션 없음 → form |
|
| 18 |
+ * ``` |
|
| 14 | 19 |
* |
| 15 |
- * 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 등록·수정·삭제가 초기값으로 되돌아간다. |
|
| 20 |
+ * ⚠️ 백엔드 현황(보고함) |
|
| 21 |
+ * 1. **등록이 아직 동작하지 않는다.** 컨트롤러 `insert`의 본문이 전부 주석 처리돼 있고 |
|
| 22 |
+ * `return null;`이다 — 봉투 없는 빈 200이 온다. 그래서 저장해도 아무 일도 일어나지 않는다. |
|
| 23 |
+ * 2. **수정·삭제·단건 조회 API가 없다.** 목록과 등록 두 개뿐이다. |
|
| 24 |
+ * 3. 검색 필터가 어긋난다 — 업종 검색 파라미터는 `searchFrcsTpbizCd`인데 SQL은 `FRCS_DV_CD`를 |
|
| 25 |
+ * 비교하고, 저장은 `frcsTpbizCd`로 받는다. 저장한 업종으로 검색이 걸리지 않는다. |
|
| 26 |
+ * 4. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시 |
|
| 27 |
+ * 뒤집어 오래된 가맹점부터 나온다. |
|
| 28 |
+ * 5. `USE_YN`을 select하지 않는다 — 검색 조건에는 쓰면서 응답에는 없어 사용여부를 알 수 없다. |
|
| 29 |
+ * 화면은 노출로 보인다. |
|
| 30 |
+ * 6. 브랜드명·메모·등록일 컬럼이 없다 — 기획에 있으나 담을 자리가 없어 화면에서 뺐다. |
|
| 16 | 31 |
*/ |
| 17 | 32 |
|
| 18 |
-const TOTAL = 128; |
|
| 19 |
-const BRANDS = ['GS25', 'CU', '', '스타벅스', '', '교보문고']; |
|
| 20 |
-const CATEGORY_CODES = MERCHANT_CATEGORY_OPTIONS.map((option) => option.value); |
|
| 21 |
-const CITIES = [ |
|
| 22 |
- '경기도 평택시 송탄1로', |
|
| 23 |
- '경상남도 양산시 배움터길', |
|
| 24 |
- '경기도 용인시 기흥구 죽전로', |
|
| 25 |
- '서울특별시 마포구 양화로', |
|
| 26 |
- '부산광역시 해운대구 센텀중앙로', |
|
| 27 |
-]; |
|
| 28 |
-const BASE_DATE = Date.UTC(2026, 0, 5); |
|
| 29 |
-const DAY_MS = 24 * 60 * 60 * 1000; |
|
| 33 |
+const VOC_PATH = '/api/v1/mngr/voc'; |
|
| 30 | 34 |
|
| 31 |
-function dateAt(offsetDays: number): string {
|
|
| 32 |
- return new Date(BASE_DATE + offsetDays * DAY_MS).toISOString().slice(0, 10); |
|
| 33 |
-} |
|
| 35 |
+/** 엑셀은 전체를 담는다 — 이 상한을 넘으면 조용히 잘린다. */ |
|
| 36 |
+const EXCEL_ROW_LIMIT = 10_000; |
|
| 34 | 37 |
|
| 35 |
-/** 인덱스로만 만든다 — 난수를 쓰면 새로고침마다 목록이 흔들린다. */ |
|
| 36 |
-function buildMerchant(index: number): Merchant {
|
|
| 37 |
- const serial = index + 1; |
|
| 38 |
- const brand = BRANDS[index % BRANDS.length]; |
|
| 39 |
- const city = CITIES[index % CITIES.length]; |
|
| 40 |
- |
|
| 41 |
- return {
|
|
| 42 |
- id: `MCH-${String(serial).padStart(6, '0')}`,
|
|
| 43 |
- code: `MCH-${String(serial).padStart(6, '0')}`,
|
|
| 44 |
- brandName: brand, |
|
| 45 |
- name: brand ? `${brand} ${city.split(' ').at(-1)}점` : `${city.split(' ').at(-1)}점`,
|
|
| 46 |
- categoryCode: CATEGORY_CODES[index % CATEGORY_CODES.length], |
|
| 47 |
- businessNumber: `${100 + (index % 800)}-${10 + (index % 80)}-${String(10000 + index).slice(0, 5)}`,
|
|
| 48 |
- ownerName: `대표${serial}`,
|
|
| 49 |
- phoneNumber: `0${2 + (index % 6)}${String(10000000 + index * 7).slice(0, 8)}`,
|
|
| 50 |
- address: `${city} ${(index % 90) + 1}`,
|
|
| 51 |
- addressDetail: brand ? `1층 ${brand}` : '',
|
|
| 52 |
- latitude: (37.0987 + (index % 50) * 0.01).toFixed(4), |
|
| 53 |
- longitude: (127.0736 + (index % 50) * 0.01).toFixed(4), |
|
| 54 |
- // 몇 곳은 미노출로 둬 필터가 실제로 걸리는지 보이게 한다. |
|
| 55 |
- isVisible: index % 7 !== 0, |
|
| 56 |
- memo: '', |
|
| 57 |
- createdAt: dateAt(index % 60), |
|
| 58 |
- updatedAt: dateAt((index % 60) + 15), |
|
| 59 |
- }; |
|
| 60 |
-} |
|
| 61 |
- |
|
| 62 |
-let MERCHANTS: Merchant[] = Array.from({ length: TOTAL }, (_, index) =>
|
|
| 63 |
- buildMerchant(index) |
|
| 64 |
-); |
|
| 65 |
- |
|
| 66 |
-const SEARCH_VALUE: Record<string, (merchant: Merchant) => string> = {
|
|
| 67 |
- name: (merchant) => merchant.name, |
|
| 68 |
- businessNumber: (merchant) => merchant.businessNumber, |
|
| 69 |
- address: (merchant) => `${merchant.address} ${merchant.addressDetail}`,
|
|
| 38 |
+/** 화면 검색 대상 → 백엔드 `searchCondition`. */ |
|
| 39 |
+const SEARCH_CONDITION: Record<string, string> = {
|
|
| 40 |
+ name: '1', |
|
| 41 |
+ businessNumber: '2', |
|
| 42 |
+ address: '3', |
|
| 70 | 43 |
}; |
| 71 | 44 |
|
| 72 |
-function matchesQuery(merchant: Merchant, query: MerchantQuery): boolean {
|
|
| 73 |
- if (query.category && merchant.categoryCode !== query.category) {
|
|
| 74 |
- return false; |
|
| 75 |
- } |
|
| 76 |
- if (query.visibility === 'visible' && !merchant.isVisible) {
|
|
| 77 |
- return false; |
|
| 78 |
- } |
|
| 79 |
- if (query.visibility === 'hidden' && merchant.isVisible) {
|
|
| 80 |
- return false; |
|
| 81 |
- } |
|
| 82 |
- |
|
| 83 |
- const keyword = query.keyword.trim().toLowerCase(); |
|
| 84 |
- if (!keyword) {
|
|
| 85 |
- return true; |
|
| 86 |
- } |
|
| 87 |
- const read = SEARCH_VALUE[query.searchField]; |
|
| 88 |
- return read ? read(merchant).toLowerCase().includes(keyword) : true; |
|
| 45 |
+function isRecord(value: unknown): value is Record<string, unknown> {
|
|
| 46 |
+ return value !== null && typeof value === 'object'; |
|
| 89 | 47 |
} |
| 90 | 48 |
|
| 91 |
-/** 기획 MCH_001 ④ — 최근등록순 정렬. */ |
|
| 92 |
-function byNewestFirst(a: Merchant, b: Merchant): number {
|
|
| 93 |
- return Number(b.code.slice(4)) - Number(a.code.slice(4)); |
|
| 49 |
+function readString(source: Record<string, unknown>, key: string): string {
|
|
| 50 |
+ const value = source[key]; |
|
| 51 |
+ return typeof value === 'string' ? value : ''; |
|
| 52 |
+} |
|
| 53 |
+ |
|
| 54 |
+function toMerchant(raw: unknown): Merchant[] {
|
|
| 55 |
+ if (!isRecord(raw)) {
|
|
| 56 |
+ return []; |
|
| 57 |
+ } |
|
| 58 |
+ |
|
| 59 |
+ const code = readString(raw, 'frcsNo'); |
|
| 60 |
+ if (!code) {
|
|
| 61 |
+ return []; |
|
| 62 |
+ } |
|
| 63 |
+ |
|
| 64 |
+ return [ |
|
| 65 |
+ {
|
|
| 66 |
+ id: code, |
|
| 67 |
+ code, |
|
| 68 |
+ name: readString(raw, 'frcsNm'), |
|
| 69 |
+ categoryCode: readString(raw, MERCHANT_CATEGORY_READ_FIELD), |
|
| 70 |
+ businessNumber: readString(raw, 'brno'), |
|
| 71 |
+ ownerName: readString(raw, 'rprsvNm'), |
|
| 72 |
+ phoneNumber: readString(raw, 'rprsvTelno'), |
|
| 73 |
+ address: readString(raw, 'frcsPostAddr'), |
|
| 74 |
+ addressDetail: readString(raw, 'frcsDaddr'), |
|
| 75 |
+ postalCode: readString(raw, 'frcsPostZip'), |
|
| 76 |
+ latitude: readString(raw, 'lat'), |
|
| 77 |
+ longitude: readString(raw, 'lng'), |
|
| 78 |
+ // 조회 SQL이 USE_YN을 select하지 않는다(⚠️ 5) — 값이 없으면 노출로 본다. |
|
| 79 |
+ isVisible: readString(raw, 'useYn') !== 'N', |
|
| 80 |
+ }, |
|
| 81 |
+ ]; |
|
| 94 | 82 |
} |
| 95 | 83 |
|
| 96 | 84 |
export async function fetchMerchants( |
| 97 | 85 |
query: MerchantQuery |
| 98 | 86 |
): Promise<{ items: Merchant[]; totalCount: number }> {
|
| 99 |
- const matched = MERCHANTS.filter((merchant) => matchesQuery(merchant, query)); |
|
| 100 |
- matched.sort(byNewestFirst); |
|
| 87 |
+ const accessToken = await getSessionAccessToken(); |
|
| 88 |
+ const keyword = query.keyword.trim(); |
|
| 101 | 89 |
|
| 102 |
- const start = (query.page - 1) * query.pageSize; |
|
| 90 |
+ const result = await backendFetch<unknown>(`${VOC_PATH}/pagination`, {
|
|
| 91 |
+ method: 'GET', |
|
| 92 |
+ query: {
|
|
| 93 |
+ ...(keyword |
|
| 94 |
+ ? {
|
|
| 95 |
+ searchCondition: SEARCH_CONDITION[query.searchField] ?? '1', |
|
| 96 |
+ searchKeyword: keyword, |
|
| 97 |
+ } |
|
| 98 |
+ : {}),
|
|
| 99 |
+ ...(query.visibility |
|
| 100 |
+ ? { searchUseYn: query.visibility === 'visible' ? 'Y' : 'N' }
|
|
| 101 |
+ : {}),
|
|
| 102 |
+ ...(query.category ? { searchFrcsTpbizCd: query.category } : {}),
|
|
| 103 |
+ pageIndex: query.page, |
|
| 104 |
+ recordCountPerPage: query.pageSize, |
|
| 105 |
+ }, |
|
| 106 |
+ accessToken: accessToken ?? undefined, |
|
| 107 |
+ cache: 'no-store', |
|
| 108 |
+ canHaveNullData: true, |
|
| 109 |
+ }); |
|
| 110 |
+ |
|
| 111 |
+ if (!result.ok) {
|
|
| 112 |
+ throw new BackendRequestError(result); |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ const data = result.data; |
|
| 116 |
+ if (data === null || data === undefined) {
|
|
| 117 |
+ return { items: [], totalCount: 0 };
|
|
| 118 |
+ } |
|
| 119 |
+ if (!isRecord(data) || !Array.isArray(data.list)) {
|
|
| 120 |
+ throw new Error('가맹점 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 121 |
+ } |
|
| 122 |
+ |
|
| 103 | 123 |
return {
|
| 104 |
- items: matched.slice(start, start + query.pageSize), |
|
| 105 |
- totalCount: matched.length, |
|
| 124 |
+ items: data.list.flatMap(toMerchant), |
|
| 125 |
+ totalCount: |
|
| 126 |
+ typeof data.totalCount === 'number' ? data.totalCount : data.list.length, |
|
| 106 | 127 |
}; |
| 107 | 128 |
} |
| 108 | 129 |
|
| 109 | 130 |
/** 엑셀 다운로드는 검색 조건과 무관하게 전체다(기획 MCH_001 ②). */ |
| 110 | 131 |
export async function fetchAllMerchants(): Promise<Merchant[]> {
|
| 111 |
- return [...MERCHANTS].sort(byNewestFirst); |
|
| 132 |
+ const accessToken = await getSessionAccessToken(); |
|
| 133 |
+ |
|
| 134 |
+ const result = await backendFetch<unknown>(`${VOC_PATH}/pagination`, {
|
|
| 135 |
+ method: 'GET', |
|
| 136 |
+ query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT },
|
|
| 137 |
+ accessToken: accessToken ?? undefined, |
|
| 138 |
+ cache: 'no-store', |
|
| 139 |
+ canHaveNullData: true, |
|
| 140 |
+ }); |
|
| 141 |
+ |
|
| 142 |
+ if (!result.ok) {
|
|
| 143 |
+ throw new BackendRequestError(result); |
|
| 144 |
+ } |
|
| 145 |
+ |
|
| 146 |
+ const data = result.data; |
|
| 147 |
+ return isRecord(data) && Array.isArray(data.list) |
|
| 148 |
+ ? data.list.flatMap(toMerchant) |
|
| 149 |
+ : []; |
|
| 112 | 150 |
} |
| 113 | 151 |
|
| 114 |
-function nowDate(): string {
|
|
| 115 |
- return new Date().toISOString().slice(0, 10); |
|
| 116 |
-} |
|
| 117 |
- |
|
| 118 |
-/** 가맹점코드는 저장 시 자동 생성한다(기획 MCH_002_p ⑤). */ |
|
| 119 | 152 |
export async function createMerchant(values: MerchantValues): Promise<void> {
|
| 120 |
- const nextSerial = |
|
| 121 |
- MERCHANTS.reduce( |
|
| 122 |
- (max, merchant) => Math.max(max, Number(merchant.code.slice(4)) || 0), |
|
| 123 |
- 0 |
|
| 124 |
- ) + 1; |
|
| 125 |
- const code = `MCH-${String(nextSerial).padStart(6, '0')}`;
|
|
| 153 |
+ const accessToken = await getSessionAccessToken(); |
|
| 126 | 154 |
|
| 127 |
- MERCHANTS = [ |
|
| 128 |
- ...MERCHANTS, |
|
| 129 |
- {
|
|
| 130 |
- ...values, |
|
| 131 |
- id: code, |
|
| 132 |
- code, |
|
| 133 |
- createdAt: nowDate(), |
|
| 134 |
- updatedAt: nowDate(), |
|
| 155 |
+ const result = await backendFetch<unknown>(VOC_PATH, {
|
|
| 156 |
+ method: 'POST', |
|
| 157 |
+ form: {
|
|
| 158 |
+ frcsNm: values.name, |
|
| 159 |
+ [MERCHANT_CATEGORY_WRITE_FIELD]: values.categoryCode, |
|
| 160 |
+ // 요청 VO의 필드 이름이 대문자다(`private String BRNO`) — Spring이 그 이름으로 바인딩한다. |
|
| 161 |
+ BRNO: values.businessNumber, |
|
| 162 |
+ rprsvNm: values.ownerName, |
|
| 163 |
+ rprsvTelno: values.phoneNumber, |
|
| 164 |
+ frcsPostAddr: values.address, |
|
| 165 |
+ frcsDaddr: values.addressDetail, |
|
| 166 |
+ frcsPostZip: values.postalCode, |
|
| 167 |
+ lat: values.latitude, |
|
| 168 |
+ lng: values.longitude, |
|
| 169 |
+ useYn: values.isVisible ? 'Y' : 'N', |
|
| 135 | 170 |
}, |
| 136 |
- ]; |
|
| 137 |
-} |
|
| 171 |
+ accessToken: accessToken ?? undefined, |
|
| 172 |
+ cache: 'no-store', |
|
| 173 |
+ canHaveNullData: true, |
|
| 174 |
+ canHaveEmptyBody: true, |
|
| 175 |
+ }); |
|
| 138 | 176 |
|
| 139 |
-export async function updateMerchant(values: MerchantValues): Promise<void> {
|
|
| 140 |
- const index = MERCHANTS.findIndex((merchant) => merchant.id === values.id); |
|
| 141 |
- if (index < 0) {
|
|
| 142 |
- throw new Error('가맹점을 찾을 수 없습니다.');
|
|
| 177 |
+ if (!result.ok) {
|
|
| 178 |
+ throw new BackendRequestError(result); |
|
| 143 | 179 |
} |
| 144 |
- |
|
| 145 |
- MERCHANTS[index] = {
|
|
| 146 |
- ...MERCHANTS[index], |
|
| 147 |
- ...values, |
|
| 148 |
- // 코드·등록일은 바뀌지 않는다. |
|
| 149 |
- id: MERCHANTS[index].id, |
|
| 150 |
- code: MERCHANTS[index].code, |
|
| 151 |
- createdAt: MERCHANTS[index].createdAt, |
|
| 152 |
- updatedAt: nowDate(), |
|
| 153 |
- }; |
|
| 154 |
-} |
|
| 155 |
- |
|
| 156 |
-export async function deleteMerchant(id: string): Promise<void> {
|
|
| 157 |
- const next = MERCHANTS.filter((merchant) => merchant.id !== id); |
|
| 158 |
- if (next.length === MERCHANTS.length) {
|
|
| 159 |
- throw new Error('가맹점을 찾을 수 없습니다.');
|
|
| 160 |
- } |
|
| 161 |
- MERCHANTS = next; |
|
| 162 | 180 |
} |
--- lib/domain/merchant-form.ts
+++ lib/domain/merchant-form.ts
... | ... | @@ -1,12 +1,10 @@ |
| 1 |
+// 이슈: 브랜드명·메모는 백엔드 TB_COM_VOUCHER에 컬럼이 없어 화면에서 뺐다(기획에는 있다). |
|
| 1 | 2 |
export const NAME_MAX_LENGTH = 100; |
| 2 |
-export const BRAND_MAX_LENGTH = 100; |
|
| 3 | 3 |
export const OWNER_MAX_LENGTH = 50; |
| 4 | 4 |
export const ADDRESS_DETAIL_MAX_LENGTH = 200; |
| 5 |
-export const MEMO_MAX_LENGTH = 500; |
|
| 6 | 5 |
|
| 7 | 6 |
export interface MerchantValues {
|
| 8 | 7 |
id: string; |
| 9 |
- brandName: string; |
|
| 10 | 8 |
name: string; |
| 11 | 9 |
categoryCode: string; |
| 12 | 10 |
businessNumber: string; |
... | ... | @@ -14,10 +12,10 @@ |
| 14 | 12 |
phoneNumber: string; |
| 15 | 13 |
address: string; |
| 16 | 14 |
addressDetail: string; |
| 15 |
+ postalCode: string; |
|
| 17 | 16 |
latitude: string; |
| 18 | 17 |
longitude: string; |
| 19 | 18 |
isVisible: boolean; |
| 20 |
- memo: string; |
|
| 21 | 19 |
} |
| 22 | 20 |
|
| 23 | 21 |
export type MerchantFormErrors = Partial< |
... | ... | @@ -79,13 +77,11 @@ |
| 79 | 77 |
values: {
|
| 80 | 78 |
...values, |
| 81 | 79 |
name, |
| 82 |
- brandName: values.brandName.trim().slice(0, BRAND_MAX_LENGTH), |
|
| 83 | 80 |
ownerName: values.ownerName.trim().slice(0, OWNER_MAX_LENGTH), |
| 84 | 81 |
phoneNumber, |
| 85 | 82 |
businessNumber, |
| 86 | 83 |
address: values.address.trim(), |
| 87 | 84 |
addressDetail: values.addressDetail.trim().slice(0, ADDRESS_DETAIL_MAX_LENGTH), |
| 88 |
- memo: values.memo.trim().slice(0, MEMO_MAX_LENGTH), |
|
| 89 | 85 |
}, |
| 90 | 86 |
}; |
| 91 | 87 |
} |
--- lib/domain/merchant.ts
+++ lib/domain/merchant.ts
... | ... | @@ -1,8 +1,12 @@ |
| 1 |
+/** |
|
| 2 |
+ * 백엔드 `TB_COM_VOUCHER`(`/api/v1/mngr/voc/**`) 한 행. 필드 이름이 화면 용어와 달라 |
|
| 3 |
+ * Repository가 옮긴다 — `frcsNo`=코드, `frcsNm`=가맹점명, `brno`=사업자번호, |
|
| 4 |
+ * `rprsvNm`=대표자명, `rprsvTelno`=전화번호, `frcsPostAddr`/`frcsDaddr`=주소/상세주소. |
|
| 5 |
+ */ |
|
| 1 | 6 |
export interface Merchant {
|
| 2 | 7 |
id: string; |
| 3 |
- /** 등록 시 자동 생성 — 수정 불가(기획 MCH_003_p ①). */ |
|
| 8 |
+ /** `frcsNo` — 서버가 만든다. 수정 불가(기획 MCH_003_p ①). */ |
|
| 4 | 9 |
code: string; |
| 5 |
- brandName: string; |
|
| 6 | 10 |
name: string; |
| 7 | 11 |
categoryCode: string; |
| 8 | 12 |
businessNumber: string; |
... | ... | @@ -10,12 +14,10 @@ |
| 10 | 14 |
phoneNumber: string; |
| 11 | 15 |
address: string; |
| 12 | 16 |
addressDetail: string; |
| 17 |
+ postalCode: string; |
|
| 13 | 18 |
latitude: string; |
| 14 | 19 |
longitude: string; |
| 15 | 20 |
isVisible: boolean; |
| 16 |
- memo: string; |
|
| 17 |
- createdAt: string; |
|
| 18 |
- updatedAt: string; |
|
| 19 | 21 |
} |
| 20 | 22 |
|
| 21 | 23 |
export const EMPTY_FIELD_PLACEHOLDER = '-'; |
... | ... | @@ -64,3 +66,12 @@ |
| 64 | 66 |
export function formatAddressSummary(merchant: Merchant): string {
|
| 65 | 67 |
return formatOptionalValue(merchant.address); |
| 66 | 68 |
} |
| 69 |
+ |
|
| 70 |
+/** |
|
| 71 |
+ * 업종 코드 ↔ 백엔드 필드. |
|
| 72 |
+ * |
|
| 73 |
+ * 이슈: 백엔드가 저장은 `FRCS_TPBIZ_CD`로 받고 조회·검색은 `FRCS_DV_CD`로 한다. 두 컬럼을 |
|
| 74 |
+ * 모두 select하므로 화면은 조회 쪽(`frcsDvCd`)을 읽고, 저장할 때는 요청 VO에 있는 이름을 쓴다. |
|
| 75 |
+ */ |
|
| 76 |
+export const MERCHANT_CATEGORY_READ_FIELD = 'frcsDvCd'; |
|
| 77 |
+export const MERCHANT_CATEGORY_WRITE_FIELD = 'frcsTpbizCd'; |
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?