임동욱 임동욱 09-17
docs: 가맹몰 상세 모듈을 develop(feature/voc 병합) 계약으로 재검토하고 409 안내를 더한다
단건 조회가 상세 없음을 NOT_FOUND 대신 성공 봉투의 null로 주는 것, 삭제된 상세를
selectOne이 그대로 돌려주는 결함(재등록 409·수정 화면에 삭제값 노출), 프론트가 쓰지 않게
된 건수 쿼리 문제를 문서에 맞춘다. 등록 409는 삭제 잔존 안내 문구로 바꾼다.

Co-Authored-By: Claude Fable 5.1 
@b1c4712395339108756fd62cc2240b981595fbe4
app/(protected)/(basic)/system/merchant-details/_actions.ts
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
@@ -51,10 +51,8 @@
 /**
  * 가맹몰 상세 Server Action — 상세(가맹점당 1건)와 메뉴(여러 건) 둘 다 여기서 다룬다.
  *
- * 이슈: 목록의 [비활성]/[노출]은 백엔드가 상세 전체를 돌려줘야 안전하다. `PUT`이 모든 컬럼을
- *   무조건 덮어써서, 단건 조회가 비어 있는 지금 값 없이 보내면 소개·영업시간·이미지가 지워진다.
- *   그래서 조회 결과에 대표이미지가 없으면 목록 토글을 거부하고 수정 화면으로 안내한다.
- *   단건 SELECT가 보강되면(보고함) 그대로 동작한다.
+ * 노출 토글은 단건을 읽어 전 컬럼을 그대로 다시 보낸다 — 백엔드 `PUT`이 모든 컬럼을 덮어쓰기 때문이다.
+ * 단건이 대표이미지·카테고리를 안 주는 응답이면(구 버전) 값이 지워지므로 토글을 거부한다.
  */
 
 const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
@@ -62,6 +60,9 @@
 const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
 const DETAIL_NOT_LOADED_MESSAGE =
   '백엔드가 상세 값을 돌려주지 않아 목록에서는 바꿀 수 없습니다. 수정 화면에서 바꿔 주세요.';
+// 백엔드가 삭제된 상세도 "있는 것"으로 보는 결함(보고함) — 409는 대개 이 경우다.
+const DETAIL_CONFLICT_MESSAGE =
+  '이 가맹점에는 이미 상세가 있습니다. 삭제했던 상세라면 백엔드에서 정리해야 다시 등록할 수 있습니다.';
 const IMAGE_OVERSIZE_MESSAGE = `이미지는 장당 ${Math.floor(MAX_IMAGE_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`;
 const IMAGES_TOO_MANY_MESSAGE = `추가 이미지는 최대 ${MAX_EXTRA_IMAGES}장까지입니다.`;
 const REQUEST_OVERSIZE_MESSAGE = `한 번에 올리는 이미지의 합이 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB를 넘습니다. 나눠서 저장해 주세요.`;
@@ -277,7 +278,11 @@
   } catch (error) {
     unstable_rethrow(error);
     console.error('[merchant-details] 저장 실패', error);
-    failed = { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
+    const isConflict = !isEdit && error instanceof BackendRequestError && error.code === 409;
+    failed = {
+      status: 'error',
+      message: isConflict ? DETAIL_CONFLICT_MESSAGE : failureMessage(error, SAVE_FAILED_MESSAGE),
+    };
   }
   if (failed) {
     return failed;
lib/data/repositories/merchant-detail-repository.ts
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
@@ -13,11 +13,11 @@
 import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
 
 /**
- * 가맹몰 상세 Repository — edupay-backend origin/feature/voc b009025(2026-09-17) 기준.
+ * 가맹몰 상세 Repository — edupay-backend origin/develop 8b1bf81(feature/voc 병합, 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 봉투
+ * GET    /api/v1/mngr/voc/detail/{frcsNo}     상세가 없으면 성공 봉투에 data: null (구 버전은 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)
@@ -29,11 +29,13 @@
  * (`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 화면(시안)에 자리가 없어 읽지 않는다.
  *
  * ⚠️ 백엔드 결함(보고함, 미수정):
- * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 그동안 업체명 자리에
- *    가맹점 코드를 보인다.
- * 2. 건수 쿼리(`selectPaginationCount`)만 LEFT JOIN이라 상세 없는 가맹점까지 세어 페이지 수가 부풀 수 있다.
- * 3. `selectOne`이 DEL_YN을 거르지 않아 삭제한 상세가 있는 가맹점은 다시 등록하면 409가 난다.
- * 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다.
+ * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 목록은 가맹점 목록의
+ *    이름으로 메우고, 단건은 코드만 오면 가맹점 목록을 한 번 더 읽는다.
+ * 2. `selectOne`이 DEL_YN을 거르지 않는다. 삭제한 상세가 남은 가맹점은 목록·앱에서는 빠지는데 단건은
+ *    삭제된 값을 돌려주고(수정 화면이 그 값을 보인다) 등록은 409로 막힌다 — 삭제 뒤 재등록 불가.
+ *    응답에 delYn이 없어(@JsonIgnore) 프론트가 가려낼 수도 없다.
+ * 3. 건수 쿼리(`selectPaginationCount`)만 LEFT JOIN이라 부풀어 온다 — 목록을 여기서 합쳐 세므로 쓰지 않는다.
+ * 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다 — 여기서 등록일로 거른다.
  * 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다.
  */
 
@@ -283,7 +285,7 @@
   );
 
   if (!result.ok) {
-    // 상세가 없는 가맹점은 NOT_FOUND 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다.
+    // 구 버전은 상세가 없으면 NOT_FOUND 봉투였다. 지금은 성공 봉투에 data: null이라 아래 toDetail이 null을 준다.
     if (result.code === 404) return null;
     throw new BackendRequestError(result);
   }
lib/data/repositories/merchant-product-repository.ts
--- lib/data/repositories/merchant-product-repository.ts
+++ lib/data/repositories/merchant-product-repository.ts
@@ -114,7 +114,7 @@
   );
 
   if (!result.ok) {
-    // 없는 메뉴는 404 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다.
+    // 구 버전은 없는 메뉴가 404 봉투였다. 지금은 성공 봉투에 data: null이라 아래에서 null이 된다.
     if (result.code === 404) return null;
     throw new BackendRequestError(result);
   }
Add a comment
List