diff --git a/infra/images/scan-flow.svg b/infra/images/scan-flow.svg
new file mode 100644
index 0000000..2542491
--- /dev/null
+++ b/infra/images/scan-flow.svg
@@ -0,0 +1,125 @@
+
diff --git a/scripts/gen_store_erd.py b/scripts/gen_store_erd.py
new file mode 100644
index 0000000..10ef267
--- /dev/null
+++ b/scripts/gen_store_erd.py
@@ -0,0 +1,336 @@
+# -*- coding: utf-8 -*-
+"""가게 도메인 ERD SVG 생성기 — 속성 5열(Key/논리명/물리명/타입/Nullable)"""
+from xml.sax.saxutils import escape as esc
+
+HDR_H, CHDR_H, ROW_H, PAD_B = 34, 21, 19, 12
+TW = 450 # 테이블 폭
+CX = (13, 46, 168, 312, 436) # key, 논리, 물리, 타입, null(우측정렬)
+BADGE_C = {'PK':'#b25a12','FK':'#1e5aa8','UQ':'#2f7a5a','GEN':'#6b4ea8'}
+KIND = {'ref':('#1e5aa8','pnl-ref'), 'core':('#b25a12','pnl-core'), 'sat':('#2f7a5a','pnl-sat'),
+ 'exist':('#5a616b','pnl-exist'), 'ai':('#6b4ea8','pnl-ai')}
+out, R = [], {}
+
+def table(x, y, name, rows, kind, note=''):
+ hdr, _ = KIND[kind]
+ h = HDR_H + CHDR_H + ROW_H*len(rows) + PAD_B + (16 if note else 0)
+ out.append(f'')
+ out.append(f'')
+ out.append(f'{esc(name)}')
+ ch = y + HDR_H
+ out.append(f'')
+ for lbl, off, anc in (('KEY',CX[0],'start'),('논리명',CX[1],'start'),('물리명',CX[2],'start'),
+ ('데이터타입',CX[3],'start'),('NULL',CX[4],'end')):
+ out.append(f'{lbl}')
+ out.append(f'')
+ ty = y + HDR_H + CHDR_H + 14
+ for r in rows:
+ if r[0] == 'SEC':
+ out.append(f'')
+ out.append(f'{esc(r[1])}')
+ else:
+ k, lo, ph, dt, nu = r
+ if k:
+ out.append(f'{k}')
+ out.append(f'{esc(lo)}')
+ out.append(f'{esc(ph)}')
+ out.append(f'{esc(dt)}')
+ cls = 'nuY' if nu == 'Y' else 'nuN'
+ out.append(f'{nu}')
+ ty += ROW_H
+ if note:
+ out.append(f'{esc(note)}')
+ out.append('')
+ R[name] = (x, y, TW, h)
+ return h
+
+def panel(x, y, w, h, title, kind, dashed=False):
+ _, cls = KIND[kind]
+ d = ' stroke-dasharray="7 5"' if dashed else ''
+ out.append(f'')
+ out.append(f'{esc(title)}')
+
+# 앵커 헬퍼 — 커넥터 좌표를 rect에서 계산
+def ry(n, i): x,y,w,h = R[n]; return y + HDR_H + CHDR_H + 14 + ROW_H*i - 1
+def rt(n): x,y,w,h = R[n]; return y
+def rb(n): x,y,w,h = R[n]; return y + h
+def rl(n): return R[n][0]
+def rr(n): return R[n][0] + TW
+
+W, H = 2130, 1490
+TOP_Y, TOP_H, BOT_Y, BOT_H = 112, 680, 830, 546
+panel(40, TOP_Y, 490, TOP_H, '참조 마스터 · BIGINT PK', 'ref')
+panel(560, TOP_Y, 490, TOP_H, '가게 마스터 · V5 예정 (미적용)', 'core')
+panel(1080, TOP_Y, 490, TOP_H, '별칭 · 외부참조', 'sat')
+panel(40, BOT_Y, 1530, BOT_H, '기존 백엔드 스키마 · UUID PK (V1 배포됨)', 'exist')
+panel(1600, TOP_Y, 490, 1264, 'AI 연계 스키마 (catoin) · 백엔드 소유, 미구현', 'ai', dashed=True)
+
+y = TOP_Y + 50
+y += table(60, y, 'store_categories', [
+ ('PK','분류 식별자','id','BIGINT','N'),
+ ('UQ','업종분류 코드','code','VARCHAR(6)','N'),
+ ('','분류명','name','VARCHAR(60)','N'),
+ ('','분류 단계','level','SMALLINT','N'),
+ ('FK','상위 분류','parent_id','BIGINT','Y'),
+], 'ref', '247개 전체 적재 · store_cluster 축') + 22
+y += table(60, y, 'ksic_codes', [
+ ('PK','표준산업분류 코드','code','VARCHAR(6)','N'),
+ ('','분류명','name','VARCHAR(120)','N'),
+], 'ref') + 22
+table(60, y, 'store_import_batches', [
+ ('PK','적재 배치 식별자','id','BIGINT','N'),
+ ('UQ','원천 구분','source','VARCHAR(20)','N'),
+ ('UQ','원천 버전','source_version','VARCHAR(10)','N'),
+ ('','적재 건수','row_count','INTEGER','N'),
+ ('','처리 상태','status','VARCHAR(20)','N'),
+ ('','시작 시각','started_at','TIMESTAMPTZ','N'),
+ ('','종료 시각','finished_at','TIMESTAMPTZ','Y'),
+], 'ref', '분기 upsert 감사 · 롤백 기준점')
+
+table(580, TOP_Y+50, 'stores', [
+ ('PK','가게 식별자','id','BIGINT','N'),
+ ('UQ','상가업소번호','sbiz_store_no','VARCHAR(24)','Y'),
+ ('SEC','표시 · 매칭'),
+ ('','상호명','name','VARCHAR(200)','N'),
+ ('','지점명','branch_name','VARCHAR(100)','N'),
+ ('GEN','정규화 상호명','name_normalized','VARCHAR(200)','N'),
+ ('SEC','분류'),
+ ('FK','업종 소분류','category_id','BIGINT','Y'),
+ ('FK','표준산업분류','ksic_code','VARCHAR(6)','Y'),
+ ('SEC','위치'),
+ ('','행정동 코드','admin_dong_code','VARCHAR(8)','N'),
+ ('','도로명주소','road_address','VARCHAR(300)','N'),
+ ('','층 정보','floor_info','VARCHAR(20)','N'),
+ ('','위도','lat','DOUBLE PREC.','N'),
+ ('','경도','lng','DOUBLE PREC.','N'),
+ ('SEC','상태 · 출처'),
+ ('','영업 상태','status','VARCHAR(20)','N'),
+ ('','레코드 출처','origin','VARCHAR(20)','N'),
+ ('','비활성 시각','inactive_at','TIMESTAMPTZ','Y'),
+ ('','검증 완료 시각','verified_at','TIMESTAMPTZ','Y'),
+ ('FK','제출 사용자','submitted_by','UUID','Y'),
+ ('FK','최종 적재 배치','last_batch_id','BIGINT','Y'),
+ ('SEC','감사'),
+ ('','생성 시각','created_at','TIMESTAMPTZ','N'),
+ ('','수정 시각','updated_at','TIMESTAMPTZ','N'),
+], 'core', '359,832건 · 156MB · 반경검색 실측 1.87ms')
+
+y = TOP_Y + 50
+y += table(1100, y, 'store_aliases', [
+ ('PK','별칭 식별자','id','BIGINT','N'),
+ ('FK','가게','store_id','BIGINT','N'),
+ ('','별칭','alias','VARCHAR(200)','N'),
+ ('GEN','정규화 별칭','alias_normalized','VARCHAR(200)','N'),
+ ('','별칭 출처','source','VARCHAR(20)','N'),
+], 'sat', '가게별 별칭 (사용자 제보)') + 22
+y += table(1100, y, 'brand_aliases', [
+ ('PK','변형 식별자','id','BIGINT','N'),
+ ('UQ','정규화 변형표기','variant_normalized','VARCHAR(200)','N'),
+ ('','정규화 대표표기','canonical_normalized','VARCHAR(200)','N'),
+ ('','비고','note','VARCHAR(200)','N'),
+], 'sat', '전역 표기 변형 · store FK 없음 (앱 레벨 치환)') + 22
+table(1100, y, 'store_external_refs', [
+ ('PK','참조 식별자','id','BIGINT','N'),
+ ('FK','가게','store_id','BIGINT','N'),
+ ('UQ','제공자','provider','VARCHAR(20)','N'),
+ ('UQ','외부 장소 ID','external_id','VARCHAR(64)','N'),
+ ('','외부 장소 URL','external_url','VARCHAR(512)','N'),
+ ('','연결 시각','linked_at','TIMESTAMPTZ','N'),
+], 'sat', '⚠ 약관: ID/URL만 저장. 상호·주소·좌표 금지')
+
+table(60, BOT_Y+50, 'users', [
+ ('PK','사용자 식별자','id','UUID','N'),
+ ('UQ','이메일','email','VARCHAR(255)','N'),
+ ('','닉네임','nickname','VARCHAR(100)','N'),
+ ('','표시 언어','language_code','VARCHAR(2)','N'),
+], 'exist')
+
+table(580, BOT_Y+50, 'scan_sessions', [
+ ('PK','스캔 식별자','id','UUID','N'),
+ ('FK','사용자','user_id','UUID','N'),
+ ('SEC','V5 가게 연결 (미적용)'),
+ ('FK','가게','store_id','BIGINT','Y'),
+ ('','가게명 스냅샷','store_name_snapshot','VARCHAR(200)','Y'),
+ ('','가게 매칭 방식','store_match_method','VARCHAR(20)','Y'),
+ ('SEC','V2 스캔 멱등 · 실패코드'),
+ ('UQ','S3 객체 키','storage_key','VARCHAR(512)','Y'),
+ ('','실패 코드','failure_code','VARCHAR(64)','Y'),
+ ('','낙관적 락','lock_version','BIGINT','N'),
+ ('SEC','기존 · V3 재촬영 안내'),
+ ('','스캔 상태','scan_status','VARCHAR(20)','N'),
+ ('','메뉴 수','menu_count','INTEGER','Y'),
+ ('','스캔 시각','scanned_at','TIMESTAMPTZ','Y'),
+ ('','재촬영 사유','retake_reasons','JSONB','Y'),
+ ('','재촬영 제안','retake_suggestions','JSONB','Y'),
+], 'exist', 'store context 3개 all-or-none · 사용자 GPS 원본 없음')
+
+yy = BOT_Y + 50
+yy += table(1100, yy, 'menu_images', [
+ ('PK','이미지 식별자','id','UUID','N'),
+ ('FK','스캔 세션','scan_session_id','UUID','N'),
+ ('','이미지 소스','source','VARCHAR(20)','Y'),
+ ('','저장 키','storage_key','VARCHAR(512)','Y'),
+ ('','S3 객체 버전','object_version_id','VARCHAR(1024)','Y'),
+ ('','S3 ETag','etag','VARCHAR(255)','Y'),
+], 'exist', 'V4: OCR 분석 대상 객체 동일성 검증') + 22
+table(1100, yy, 'menu_analyses', [
+ ('PK','분석 식별자','id','UUID','N'),
+ ('FK','스캔 세션','scan_session_id','UUID','N'),
+ ('','표시 순서','display_order','INTEGER','Y'),
+ ('','메뉴명(한/영)','menu_name_ko/_en','VARCHAR(255)','Y'),
+ ('','설명(한/영)','description_ko/_en','TEXT','Y'),
+ ('','가격 텍스트','price_text','VARCHAR(100)','Y'),
+ ('','위험도','risk_level','VARCHAR(20)','Y'),
+ ('','히트 태그','hit_tags','JSONB','Y'),
+ ('','다국어 안내문','message','JSONB','Y'),
+ ('','사장님 카드','owner_card','JSONB','Y'),
+], 'exist')
+
+y = TOP_Y + 50
+y += table(1620, y, 'store_menus', [
+ ('FK','가게','store_id','BIGINT','N'),
+ ('FK','메뉴','menu_id','BIGINT','N'),
+ ('','최초 확인 시각','first_seen_at','TIMESTAMPTZ','N'),
+], 'ai', '가게 ↔ 메뉴 연결점') + 22
+y += table(1620, y, 'ingredient_risk_scores', [
+ ('FK','가게','store_id','BIGINT','N'),
+ ('FK','메뉴','menu_id','BIGINT','N'),
+ ('FK','재료','ingredient_id','BIGINT','N'),
+ ('','알파','alpha','DOUBLE PREC.','N'),
+ ('','베타','beta','DOUBLE PREC.','N'),
+], 'ai', 'store_cluster fallback 대상') + 22
+y += table(1620, y, 'ingredient_confirmations', [
+ ('FK','가게','store_id','BIGINT','N'),
+ ('FK','메뉴','menu_id','BIGINT','N'),
+ ('FK','재료','ingredient_id','BIGINT','N'),
+ ('','존재 여부','present','BOOLEAN','N'),
+ ('','이상 플래그','flagged_anomaly','BOOLEAN','N'),
+], 'ai', 'hard evidence override') + 22
+y += table(1620, y, 'ingredient_evidence_log', [
+ ('PK','증거 식별자','id','BIGINT','N'),
+ ('FK','가게','store_id','BIGINT','N'),
+ ('','알파 증분','delta_alpha','DOUBLE PREC.','N'),
+ ('','증거 출처','source_type','VARCHAR(20)','N'),
+ ('','생성 시각','created_at','TIMESTAMPTZ','N'),
+], 'ai', '시계열 · 파티셔닝 후보') + 22
+table(1620, y, 'owner_verification_requests', [
+ ('PK','질의 식별자','id','BIGINT','N'),
+ ('FK','가게','store_id','BIGINT','N'),
+ ('FK','스캔 세션','scan_session_id','UUID','N'),
+ ('','답변 내용','answer_text','TEXT','Y'),
+], 'ai')
+
+# ══ 커넥터 ══
+def link(d, dashed=False):
+ da = ' stroke-dasharray="6 4"' if dashed else ''
+ out.append(f'')
+def lab(t, x, y): out.append(f'{esc(t)}')
+
+SC, KS, IB, ST = 'store_categories','ksic_codes','store_import_batches','stores'
+AL, EX, US, SS = 'store_aliases','store_external_refs','users','scan_sessions'
+MI, MA, SM, IRS = 'menu_images','menu_analyses','store_menus','ingredient_risk_scores'
+
+link(f'M {rr(SC)} {ry(SC,0)} H 534 V {ry(ST,7)} H {rl(ST)}'); lab('1:N', 516, ry(SC,0)-6)
+link(f'M {rl(SC)} {ry(SC,4)} H 49 V {ry(SC,0)} H {rl(SC)}') # 자기참조 상위분류
+lab('self', 26, (ry(SC,0)+ry(SC,4))//2)
+link(f'M {rr(KS)} {ry(KS,0)} H 542 V {ry(ST,8)} H {rl(ST)}')
+link(f'M {rr(IB)} {ry(IB,0)} H 550 V {ry(ST,21)} H {rl(ST)}')
+link(f'M {rr(ST)} {ry(ST,0)} H 1065 V {ry(EX,1)} H {rl(EX)}'); lab('1:N', 1036, ry(ST,0)-6)
+link(f'M 1065 {ry(AL,1)} H {rl(AL)}')
+link(f'M 640 {rb(ST)} V 812 H 285 V {rt(US)}'); lab('submitted_by', 300, 806)
+link(f'M 805 {rb(ST)} V {rt(SS)}'); lab('1:N (NULL 허용)', 813, 790)
+link(f'M {rr(US)} {ry(US,0)} H 545 V {ry(SS,1)} H {rl(SS)}'); lab('1:N', 516, ry(US,0)-6)
+link(f'M {rr(SS)} {ry(SS,0)} H 1065 V {ry(MI,1)} H {rl(MI)}'); lab('1:1', 1036, ry(SS,0)-6)
+link(f'M 1065 {ry(MI,1)} V {ry(MA,1)} H {rl(MA)}'); lab('1:N', 1036, ry(MA,1)-6)
+link(f'M 805 {rt(ST)} V 96 H 1585 V {ry(IRS,0)} H {rl(IRS)}')
+link(f'M 1585 {ry(SM,0)} H {rl(SM)}')
+out.append('store_id — 모든 store-scoped 테이블에 NOT NULL 강제 (agent-1 §0-1)')
+
+for i, t in enumerate(['⚠ 이 패널 5개 테이블은 아직 구현되지 않았다.',
+ '물리 스키마와 마이그레이션은 백엔드가 단일 소유한다.',
+ 'AI는 구조화된 결과/저장 명령만 반환하고 백엔드가',
+ '권한·FK·멱등성을 검증한 뒤 트랜잭션으로 반영한다.',
+ '',
+ 'store_id 계약: PostgreSQL BIGINT · Java Long ·',
+ 'JSON 정수 · Python int. AI의 stores 직접 쓰기 금지.']):
+ out.append(f'{esc(t)}')
+
+# ══ 범례 ══
+LY = 1436
+out.append(f'')
+out.append(f'속성 구성: KEY · 논리명 · 물리명 · 데이터타입 · NULL')
+lx = 62
+for b, t in [('PK','기본키'),('FK','외래키'),('UQ','유니크'),('GEN','생성 컬럼')]:
+ out.append(f'{b}')
+ out.append(f'{esc(t)}')
+ lx += 122
+out.append(f'Y')
+out.append(f'NULL 허용')
+lx += 110
+out.append(f'N')
+out.append(f'NOT NULL')
+lx += 118
+out.append(f'')
+out.append(f'DB FK 제약')
+lx += 150
+for cx, cls, t in [(lx,'pnl-ref','참조 마스터'),(lx+140,'pnl-core','신규 핵심'),(lx+262,'pnl-sat','부속'),
+ (lx+352,'pnl-exist','기존 V1'),(lx+462,'pnl-ai','AI 연계')]:
+ out.append(f'')
+ out.append(f'{esc(t)}')
+out.append(f'PK 이원화: 공개 마스터=BIGINT IDENTITY(좁은 FK·순차 적재) / 사용자 귀속 리소스=UUID(URL 열거 방어)')
+
+SVG = f''''''
+
+import io, sys
+io.open(sys.argv[1],'w',encoding='utf-8').write(SVG)
+
+bad, items = [], list(R.items())
+for i in range(len(items)):
+ for j in range(i+1, len(items)):
+ (n1,(x1,y1,w1,h1)), (n2,(x2,y2,w2,h2)) = items[i], items[j]
+ if x1 < x2+w2 and x2 < x1+w1 and y1 < y2+h2 and y2 < y1+h1: bad.append(f'겹침 {n1}×{n2}')
+ n,(x,y,w,h) = items[i]
+ if x+w > W or y+h > H: bad.append(f'캔버스 초과 {n}')
+PANELS = [(40,TOP_Y,490,TOP_H),(560,TOP_Y,490,TOP_H),(1080,TOP_Y,490,TOP_H),(40,BOT_Y,1530,BOT_H),(1600,TOP_Y,490,1076)]
+for n,(x,y,w,h) in items:
+ if not any(px<=x and py<=y and x+w<=px+pw and y+h<=py+ph for px,py,pw,ph in PANELS):
+ bad.append(f'패널 이탈 {n}')
+print(f'테이블 {len(R)}개 · 검증 ' + ('실패: '+'; '.join(bad) if bad else '통과 ✓'), file=sys.stderr)
+print('written', sys.argv[1], len(SVG), 'bytes')
diff --git a/scripts/load_stores.py b/scripts/load_stores.py
new file mode 100755
index 0000000..e4edfdb
--- /dev/null
+++ b/scripts/load_stores.py
@@ -0,0 +1,334 @@
+#!/usr/bin/env python3
+"""
+소상공인시장진흥공단 상가(상권)정보 CSV → stores 적재
+
+설계 메모
+ · 표준 라이브러리만 사용.
+ · 전체가 단일 트랜잭션. 중간 실패 시 부분 적재가 남지 않음.
+ · 사용자 제출 가게(sbiz_store_no IS NULL)는 덮어쓰지 않는다.
+
+사용 예
+ # 전국 적재 (로컬 docker)
+ python3 scripts/load_stores.py --csv-dir ~/Downloads/소상공인..._20260630 --sweep-inactive
+
+ # 개발용 일부 지역만
+ python3 scripts/load_stores.py --csv-dir ... --regions 경북,서울
+
+ # 실행 없이 SQL 만 확인
+ python3 scripts/load_stores.py --csv-dir ... --out /tmp/load.sql
+"""
+from __future__ import annotations
+
+import argparse
+from collections import Counter
+import csv
+import io
+import shutil
+import subprocess
+import unicodedata
+import sys
+from pathlib import Path
+
+MIDDLE_CATEGORY = "I201" # 한식. MVP 범위
+SOURCE = "sbiz"
+FULL_DATASET_REGIONS = frozenset({
+ "강원", "경기", "경남", "경북", "대구", "대전", "부산", "서울",
+ "세종", "울산", "인천", "전남광주", "전북", "제주", "충남", "충북",
+})
+
+COL = { # CSV 헤더 → 내부 키
+ "no": "상가업소번호", "name": "상호명", "branch": "지점명",
+ "l1c": "상권업종대분류코드", "l1n": "상권업종대분류명",
+ "l2c": "상권업종중분류코드", "l2n": "상권업종중분류명",
+ "l3c": "상권업종소분류코드", "l3n": "상권업종소분류명",
+ "ksicc": "표준산업분류코드", "ksicn": "표준산업분류명",
+ "dong": "행정동코드", "addr": "도로명주소", "floor": "층정보",
+ "lng": "경도", "lat": "위도",
+}
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument("--csv-dir", required=True, type=Path, help="지역별 CSV 가 들어있는 디렉터리")
+ p.add_argument("--source-version", help="스냅샷 버전. 미지정 시 파일명에서 추출 (예: 202606)")
+ p.add_argument("--regions", help="쉼표 구분 지역 필터 (예: 경북,서울). 미지정 시 전체")
+ p.add_argument("--category", default=MIDDLE_CATEGORY, help=f"적재할 상권업종 중분류 코드 (기본 {MIDDLE_CATEGORY})")
+ p.add_argument("--sweep-inactive", "--sweep-closed", dest="sweep_inactive", action="store_true",
+ help="이번 배치에 없는 sbiz 가게를 비활성 처리. 전국 16개·기본 업종의 완전한 적재에서만 허용")
+ p.add_argument("--psql", help="psql 실행 명령. 미지정 시 자동 탐지")
+ p.add_argument("--dsn", default="postgresql://hanspoon:hanspoon@localhost:5432/hanspoon",
+ help="로컬 psql 사용 시 접속 문자열")
+ p.add_argument("--container", default="hanspoon-postgres", help="docker 폴백에 사용할 컨테이너 이름")
+ p.add_argument("--out", type=Path, help="실행하지 않고 SQL 을 이 파일에 기록")
+ return p.parse_args(argv)
+
+
+def resolve_psql(a: argparse.Namespace) -> list[str]:
+ if a.psql:
+ return a.psql.split()
+ if shutil.which("psql"):
+ return ["psql", a.dsn]
+ if shutil.which("docker"):
+ running = subprocess.run(["docker", "ps", "--format", "{{.Names}}"],
+ capture_output=True, text=True).stdout.split()
+ if a.container in running:
+ return ["docker", "exec", "-i", a.container, "psql", "-U", "hanspoon", "-d", "hanspoon"]
+ sys.exit("psql 을 찾지 못했습니다. --psql 로 실행 명령을 직접 지정하세요.")
+
+
+def file_metadata(f: Path) -> tuple[str, str]:
+ """파일명 끝의 지역·스냅샷 버전을 읽는다.
+
+ 접두부에 밑줄이 추가돼도 영향을 받지 않도록 오른쪽에서 두 토큰만 분리한다.
+ macOS가 한글 파일명을 NFD로 저장할 수 있어 비교 전 NFC로 맞춘다.
+ """
+ parts = unicodedata.normalize("NFC", f.stem).rsplit("_", 2)
+ if len(parts) != 3 or len(parts[2]) != 6 or not parts[2].isdigit():
+ sys.exit(f"예상한 CSV 파일명이 아닙니다: {f.name}")
+ return parts[1], parts[2]
+
+
+def discover(csv_dir: Path, regions: str | None) -> list[Path]:
+ files = sorted(f for f in csv_dir.glob("*.csv"))
+ if not files:
+ sys.exit(f"CSV 를 찾을 수 없습니다: {csv_dir}")
+ if regions:
+ want = {unicodedata.normalize("NFC", r.strip()) for r in regions.split(",")}
+ files = [f for f in files if file_metadata(f)[0] in want]
+ if not files:
+ sys.exit(f"지역 필터에 맞는 파일이 없습니다: {regions}")
+ return files
+
+
+def version_of(files: list[Path]) -> str:
+ vs = {file_metadata(f)[1] for f in files}
+ if len(vs) != 1:
+ sys.exit(f"파일들의 스냅샷 버전이 섞여 있습니다: {sorted(vs)}")
+ return vs.pop()
+
+
+def resolve_version(files: list[Path], override: str | None) -> str:
+ """파일명 버전을 기준으로 배치 버전을 확정한다.
+
+ 사용자가 잘못된 버전을 강제로 지정하면 동일 스냅샷의 감사 기록과 비활성 스윕 범위가
+ 어긋날 수 있으므로, override는 파일명에서 확인한 버전과 같을 때만 허용한다.
+ """
+ detected = version_of(files)
+ if override and override != detected:
+ sys.exit(f"--source-version({override})이 파일 버전({detected})과 다릅니다.")
+ return detected
+
+
+def validate_sweep_scope(a: argparse.Namespace, files: list[Path]) -> None:
+ """비활성 스윕이 전국·기본 업종의 완전한 스냅샷에서만 실행되도록 강제한다."""
+ if not a.sweep_inactive:
+ return
+ if a.regions:
+ sys.exit("--sweep-inactive는 --regions와 함께 사용할 수 없습니다.")
+ if a.category != MIDDLE_CATEGORY:
+ sys.exit(
+ f"--sweep-inactive는 기본 적재 범위({MIDDLE_CATEGORY})에서만 사용할 수 있습니다. "
+ f"현재 범위: {a.category}"
+ )
+
+ regions = [file_metadata(f)[0] for f in files]
+ counts = Counter(regions)
+ duplicates = sorted(region for region, count in counts.items() if count > 1)
+ missing = sorted(FULL_DATASET_REGIONS - counts.keys())
+ unexpected = sorted(counts.keys() - FULL_DATASET_REGIONS)
+ if missing or unexpected or duplicates:
+ sys.exit(
+ "--sweep-inactive에는 전국 전체 스냅샷이 필요합니다. "
+ f"누락={missing or '없음'}, 예상외={unexpected or '없음'}, 중복={duplicates or '없음'}"
+ )
+
+
+def emit(w: io.TextIOBase, files: list[Path], category: str, sweep: bool, version: str) -> dict:
+ """SQL 전문을 w 에 스트리밍하고 집계를 돌려준다."""
+ stat = {"rows": 0, "cats": 0, "ksic": 0, "skipped": 0}
+ out = csv.writer(w, lineterminator="\n")
+
+ w.write(f"""\
+\\set ON_ERROR_STOP on
+BEGIN;
+
+INSERT INTO store_import_batches (source, source_version, status, started_at)
+VALUES ('{SOURCE}', '{version}', 'running', now())
+ON CONFLICT (source, source_version)
+DO UPDATE SET status = 'running', started_at = now(), finished_at = NULL
+RETURNING id AS batch_id
+\\gset
+
+CREATE TEMP TABLE stg_category (code text, name text, level smallint, parent_code text) ON COMMIT DROP;
+CREATE TEMP TABLE stg_ksic (code text, name text) ON COMMIT DROP;
+CREATE TEMP TABLE stg_store (
+ sbiz_store_no text, name text, branch_name text, category_code text, ksic_code text,
+ admin_dong_code text, road_address text, floor_info text, lat float8, lng float8
+) ON COMMIT DROP;
+
+""")
+
+ def copy_block(table: str, rows) -> int:
+ w.write(f"\\copy {table} FROM STDIN WITH (FORMAT csv)\n")
+ n = 0
+ for row in rows:
+ out.writerow(row)
+ n += 1
+ w.write("\\.\n\n")
+ return n
+
+ # 업종/KSIC 는 음식 외 대분류까지 전부 모은다 — 참조 데이터는 비용이 없고,
+ # 일식·중식 확장 시 재적재 없이 stores 적재 범위만 넓히면 된다.
+ cats: dict[str, tuple[str, int, str]] = {}
+ ksic: dict[str, str] = {}
+
+ def store_rows():
+ """CSV 를 한 행씩 흘려보내며 분류/KSIC 를 부수적으로 수집한다.
+ 전량을 메모리에 쌓지 않으므로 적재 범위를 전체 업종으로 넓혀도 견딘다."""
+ for f in files:
+ print(f" 읽는 중 {f.name}", file=sys.stderr)
+ with f.open(encoding="utf-8", newline="") as fh:
+ for r in csv.DictReader(fh):
+ if r[COL["l1c"]]:
+ cats.setdefault(r[COL["l1c"]], (r[COL["l1n"]], 1, ""))
+ if r[COL["l2c"]]:
+ cats.setdefault(r[COL["l2c"]], (r[COL["l2n"]], 2, r[COL["l1c"]]))
+ if r[COL["l3c"]]:
+ cats.setdefault(r[COL["l3c"]], (r[COL["l3n"]], 3, r[COL["l2c"]]))
+ if r[COL["ksicc"]].strip():
+ ksic.setdefault(r[COL["ksicc"]].strip(), r[COL["ksicn"]].strip())
+
+ if r[COL["l2c"]] != category:
+ continue
+ # sbiz_store_no 는 멱등 upsert 의 충돌 키다. 비면 중복 행이 쌓이므로 버린다.
+ if not r[COL["no"]].strip() or not r[COL["name"]].strip():
+ stat["skipped"] += 1
+ continue
+ try:
+ lat, lng = float(r[COL["lat"]]), float(r[COL["lng"]])
+ except ValueError:
+ stat["skipped"] += 1
+ continue
+ # 스키마 CHECK 와 같은 범위. 여기서 거르면 트랜잭션 전체가 죽는 일이 없다.
+ if not (33 <= lat <= 39 and 124 <= lng <= 132):
+ stat["skipped"] += 1
+ continue
+ yield [
+ r[COL["no"]].strip(), r[COL["name"]].strip(), r[COL["branch"]].strip(),
+ r[COL["l3c"]].strip(), r[COL["ksicc"]].strip(),
+ r[COL["dong"]].strip(), r[COL["addr"]].strip(), r[COL["floor"]].strip(),
+ lat, lng,
+ ]
+
+ stat["rows"] = copy_block("stg_store", store_rows())
+ stat["cats"] = copy_block("stg_category", ([c, v[0], v[1], v[2]] for c, v in sorted(cats.items())))
+ stat["ksic"] = copy_block("stg_ksic", ([c, n] for c, n in sorted(ksic.items())))
+
+ w.write("""\
+INSERT INTO store_categories (code, name, level)
+SELECT DISTINCT ON (code) code, name, 1 FROM stg_category WHERE level = 1 ORDER BY code
+ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, updated_at = now();
+
+INSERT INTO store_categories (code, name, level, parent_id)
+SELECT DISTINCT ON (s.code) s.code, s.name, 2, p.id
+FROM stg_category s JOIN store_categories p ON p.code = s.parent_code
+WHERE s.level = 2 ORDER BY s.code
+ON CONFLICT (code) DO UPDATE
+ SET name = EXCLUDED.name, parent_id = EXCLUDED.parent_id, updated_at = now();
+
+INSERT INTO store_categories (code, name, level, parent_id)
+SELECT DISTINCT ON (s.code) s.code, s.name, 3, p.id
+FROM stg_category s JOIN store_categories p ON p.code = s.parent_code
+WHERE s.level = 3 ORDER BY s.code
+ON CONFLICT (code) DO UPDATE
+ SET name = EXCLUDED.name, parent_id = EXCLUDED.parent_id, updated_at = now();
+
+INSERT INTO ksic_codes (code, name)
+SELECT DISTINCT ON (code) code, name FROM stg_ksic ORDER BY code
+ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name;
+
+INSERT INTO stores (
+ sbiz_store_no, name, branch_name, category_id, ksic_code,
+ admin_dong_code, road_address, floor_info, lat, lng,
+ status, origin, last_batch_id, created_at, updated_at)
+SELECT s.sbiz_store_no, s.name, coalesce(s.branch_name, ''), c.id,
+ (SELECT k.code FROM ksic_codes k WHERE k.code = NULLIF(s.ksic_code, '')),
+ coalesce(s.admin_dong_code, ''), coalesce(s.road_address, ''), coalesce(s.floor_info, ''),
+ s.lat, s.lng,
+ 'active', 'sbiz', :batch_id, now(), now()
+FROM stg_store s
+JOIN store_categories c ON c.code = s.category_code
+ON CONFLICT (sbiz_store_no) DO UPDATE SET
+ name = EXCLUDED.name,
+ branch_name = EXCLUDED.branch_name, -- EXCLUDED 는 위 SELECT 의 coalesce 결과라 NULL 이 아니다
+ category_id = EXCLUDED.category_id,
+ ksic_code = EXCLUDED.ksic_code,
+ admin_dong_code = EXCLUDED.admin_dong_code,
+ road_address = EXCLUDED.road_address,
+ floor_info = EXCLUDED.floor_info,
+ lat = EXCLUDED.lat,
+ lng = EXCLUDED.lng,
+ last_batch_id = EXCLUDED.last_batch_id,
+ updated_at = now(),
+ -- 이전 스냅샷에서 빠졌던 가게가 다시 나타나면 활성 상태로 되돌린다.
+ status = CASE WHEN stores.status = 'inactive' THEN 'active' ELSE stores.status END,
+ inactive_at = CASE WHEN stores.status = 'inactive' THEN NULL ELSE stores.inactive_at END;
+
+""")
+
+ if sweep:
+ w.write("""\
+-- 이번 스냅샷에 없는 sbiz 가게를 비활성 처리. 실제 폐업 확정으로 해석하지 않는다.
+-- Python 사전 검증을 통과한 전국 16개·기본 업종의 완전한 스냅샷에서만 실행된다.
+UPDATE stores SET status = 'inactive', inactive_at = now(), updated_at = now()
+WHERE origin = 'sbiz' AND status = 'active' AND last_batch_id IS DISTINCT FROM :batch_id;
+
+""")
+
+ w.write("""\
+UPDATE store_import_batches
+ SET status = 'completed', finished_at = now(), row_count = (SELECT count(*) FROM stg_store)
+ WHERE id = :batch_id;
+
+COMMIT;
+
+\\echo '── 적재 결과 ──'
+SELECT (SELECT count(*) FROM store_categories) AS categories,
+ (SELECT count(*) FROM ksic_codes) AS ksic_codes,
+ (SELECT count(*) FROM stores WHERE status = 'active') AS active_stores,
+ (SELECT count(*) FROM stores WHERE status = 'inactive') AS inactive_stores;
+ANALYZE stores;
+""")
+ return stat
+
+
+def main() -> None:
+ a = parse_args()
+ files = discover(a.csv_dir, a.regions)
+ version = resolve_version(files, a.source_version)
+ validate_sweep_scope(a, files)
+ print(f"대상 파일 {len(files)}개 · 스냅샷 {version} · 중분류 {a.category}"
+ f"{' · 비활성 스윕 ON' if a.sweep_inactive else ''}", file=sys.stderr)
+
+ if a.out:
+ with a.out.open("w", encoding="utf-8") as fh:
+ stat = emit(fh, files, a.category, a.sweep_inactive, version)
+ print(f"SQL 기록: {a.out} ({a.out.stat().st_size / 1e6:.1f} MB)", file=sys.stderr)
+ else:
+ cmd = resolve_psql(a)
+ print(f"실행: {' '.join(cmd[:3])} …", file=sys.stderr)
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, text=True, encoding="utf-8")
+ assert proc.stdin is not None
+ try:
+ stat = emit(proc.stdin, files, a.category, a.sweep_inactive, version)
+ finally:
+ proc.stdin.close()
+ if proc.wait() != 0:
+ sys.exit(f"psql 실패 (exit {proc.returncode}) — 트랜잭션은 롤백되었습니다.")
+
+ print(f"분류 {stat['cats']:,} · KSIC {stat['ksic']:,} · 가게 {stat['rows']:,}"
+ f" · 좌표 이상으로 제외 {stat['skipped']:,}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/store_domain.sql b/scripts/store_domain.sql
new file mode 100644
index 0000000..50556a7
--- /dev/null
+++ b/scripts/store_domain.sql
@@ -0,0 +1,290 @@
+-- 가게 도메인
+-- 마스터: 소상공인시장진흥공단 상가(상권)정보 한식(I201) — 2026-06 기준 전국 359,832건
+-- 설계 문서: docs/12-store-domain-erd.svg
+--
+-- PK 이원화 원칙
+-- · 공개 참조 마스터(stores/categories/ksic/batches) = BIGINT IDENTITY
+-- → FK 폭을 좁혀 store-scoped 대용량 테이블의 인덱스 비용을 줄이고, 벌크 적재 시 순차 삽입 이점을 얻는다.
+-- 상가정보는 공개 데이터라 순차 ID 노출로 잃을 것이 없다.
+-- · 사용자 귀속 리소스(users/scan_sessions/…) = UUID (V1 그대로)
+-- → URL 노출 시 열거 공격 방어.
+--
+-- 데이터 타입 근거: 전국 CSV 실측 최대 길이
+-- 상가업소번호 20 · 상호명 32 · 지점명 9 · 도로명주소 34 · 층정보 4 · 행정동코드 8
+-- (사용자 제출 가게를 감안해 여유를 둔 값으로 지정)
+
+-- 반경 검색(GiST) / 상호명 유사도(GIN trigram)에 필요.
+-- AWS RDS PostgreSQL 16. 세 확장 모두 RDS 지원 목록에 있고, 접속 계정(hanspoon_app)이
+-- 마스터 사용자라 rds_superuser 권한으로 CREATE EXTENSION 이 가능하다. shared_preload_libraries 변경 불필요.
+CREATE EXTENSION IF NOT EXISTS cube;
+CREATE EXTENSION IF NOT EXISTS earthdistance;
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+-- 상호명 매칭용 정규화. DB 와 애플리케이션이 반드시 같은 규칙을 써야 하므로 함수로 고정한다.
+-- 검색어도 이 함수를 거쳐야 한다: WHERE s.name_normalized % normalize_store_name(:q)
+--
+-- · NFKC 정규화로 전각 문자를 반각으로 접는다. 실측에서 'CU 마트 B1' 이 '마트' 로,
+-- '369' 이 빈 문자열로 뭉개지는 사례가 나왔다(전국 3건). 간판·OCR 에 전각이 흔하다.
+-- · POSIX alnum 문자군으로 모든 유니코드 문자·숫자를 보존한다. 한글 호환 자모는 NFKC 후
+-- 현대 한글 자모로 바뀌므로 '가-힣ㄱ-ㅎ' 같은 고정 범위만 허용하면 일부 글자가 유실된다.
+-- 한자·일본어 가나·CJK 확장 문자도 음식점 상호 검색을 위해 보존한다.
+--
+-- ⚠ 이 함수를 CREATE OR REPLACE 로 바꾸면 기존 생성 컬럼 값과 새 값의 규칙이 어긋나고
+-- trigram 인덱스가 실제 데이터와 불일치한다. 변경 시 컬럼 재계산 + REINDEX 가 함께 필요하다.
+CREATE FUNCTION normalize_store_name(src text) RETURNS text
+ LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
+ RETURN regexp_replace(lower(normalize(src, NFKC)), '[^[:alnum:]]', '', 'g');
+
+-- 운영 DB의 인코딩·locale 차이로 정규화 결과가 달라지면 마이그레이션 단계에서 즉시 실패시킨다.
+DO $normalization_contract$
+BEGIN
+ IF normalize_store_name('CU 마트 B1') IS DISTINCT FROM 'cu마트b1' THEN
+ RAISE EXCEPTION 'normalize_store_name contract failed: full-width characters';
+ END IF;
+ IF normalize_store_name('369') IS DISTINCT FROM '369' THEN
+ RAISE EXCEPTION 'normalize_store_name contract failed: full-width digits';
+ END IF;
+ IF normalize_store_name('竹田家') IS DISTINCT FROM '竹田家' THEN
+ RAISE EXCEPTION 'normalize_store_name contract failed: CJK characters';
+ END IF;
+ IF normalize_store_name('스시 さくら') IS DISTINCT FROM '스시さくら' THEN
+ RAISE EXCEPTION 'normalize_store_name contract failed: Japanese characters';
+ END IF;
+ IF length(normalize_store_name('ㄱㅎ')) IS DISTINCT FROM 2 THEN
+ RAISE EXCEPTION 'normalize_store_name contract failed: Hangul Jamo';
+ END IF;
+ IF normalize_store_name('한 스푼! @강남점') IS DISTINCT FROM '한스푼강남점' THEN
+ RAISE EXCEPTION 'normalize_store_name contract failed: separators';
+ END IF;
+END
+$normalization_contract$;
+
+-- ─────────────────────────────────────────────────────────────
+-- 참조 마스터
+-- ─────────────────────────────────────────────────────────────
+
+-- 상권업종 분류 (대2 / 중4 / 소6자리) 자기참조 3계층.
+-- 음식 외 대분류까지 247개 전체를 적재한다 — 참조 데이터는 비용이 없고,
+-- 일식·중식 확장 시 스키마 변경 없이 stores 적재 범위만 넓히면 되기 때문.
+CREATE TABLE store_categories (
+ id BIGINT GENERATED ALWAYS AS IDENTITY,
+ code VARCHAR(6) NOT NULL, -- I2 / I201 / I20101
+ name VARCHAR(60) NOT NULL,
+ level SMALLINT NOT NULL, -- 1=대분류 2=중분류 3=소분류
+ parent_id BIGINT NULL, -- level 1 은 NULL
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT pk_store_categories PRIMARY KEY (id),
+ CONSTRAINT uq_store_categories_code UNIQUE (code),
+ CONSTRAINT fk_store_categories_parent FOREIGN KEY (parent_id)
+ REFERENCES store_categories (id),
+ CONSTRAINT ck_store_categories_level CHECK (level BETWEEN 1 AND 3),
+ CONSTRAINT ck_store_categories_root CHECK ((level = 1) = (parent_id IS NULL))
+);
+CREATE INDEX idx_store_categories_parent ON store_categories (parent_id);
+
+COMMENT ON TABLE store_categories IS '상권업종 분류 3계층. 소분류(level 3)가 Bayesian store_cluster prior 의 축이 된다.';
+COMMENT ON COLUMN store_categories.level IS '1=대분류(2자리) 2=중분류(4자리) 3=소분류(6자리)';
+
+-- 한국표준산업분류(KSIC 10차). 상권업종분류와 독립된 축이라 별도 테이블로 둔다.
+CREATE TABLE ksic_codes (
+ code VARCHAR(6) NOT NULL,
+ name VARCHAR(120) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT pk_ksic_codes PRIMARY KEY (code)
+);
+
+-- 분기 갱신 적재 이력. "이 행이 어느 스냅샷에서 왔는가"를 추적해 롤백 판단 근거로 쓴다.
+CREATE TABLE store_import_batches (
+ id BIGINT GENERATED ALWAYS AS IDENTITY,
+ source VARCHAR(20) NOT NULL, -- sbiz | localdata
+ source_version VARCHAR(10) NOT NULL, -- '202606'
+ row_count INTEGER NOT NULL DEFAULT 0,
+ status VARCHAR(20) NOT NULL DEFAULT 'running',
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ finished_at TIMESTAMPTZ NULL,
+ CONSTRAINT pk_store_import_batches PRIMARY KEY (id),
+ CONSTRAINT uq_store_import_batches UNIQUE (source, source_version),
+ CONSTRAINT ck_store_import_batches_source CHECK (source IN ('sbiz', 'localdata')),
+ CONSTRAINT ck_store_import_batches_status CHECK (status IN ('running', 'completed', 'failed'))
+);
+
+-- ─────────────────────────────────────────────────────────────
+-- 가게 마스터
+-- ─────────────────────────────────────────────────────────────
+CREATE TABLE stores (
+ id BIGINT GENERATED ALWAYS AS IDENTITY,
+
+ -- 원천 식별자. 멱등 upsert 키. localdata/user_submitted 출처면 NULL
+ -- (PostgreSQL UNIQUE 는 NULL 을 서로 다른 값으로 보므로 다중 NULL 허용).
+ sbiz_store_no VARCHAR(24) NULL,
+
+ -- 표시 · 매칭
+ name VARCHAR(200) NOT NULL,
+ branch_name VARCHAR(100) NOT NULL DEFAULT '',
+ -- 매칭용 정규형. 애플리케이션이 따로 채우지 않도록 생성 컬럼으로 둔다.
+ name_normalized VARCHAR(200) GENERATED ALWAYS AS (normalize_store_name(name)) STORED,
+
+ -- 분류
+ -- 공공데이터 행은 항상 분류가 있지만, 신규 사용자 제보는 검증 전까지 분류를 모를 수 있다.
+ category_id BIGINT NULL,
+ ksic_code VARCHAR(6) NULL, -- 원천 결측 존재(전국 한식 343건)
+
+ -- 위치. 행정동은 코드만 보존한다 — 행정동'명'을 함께 저장하지 않으므로 이행 종속이 없고,
+ -- 나중에 regions 테이블이 필요해지면 재적재 없이 조인만 붙이면 된다.
+ admin_dong_code VARCHAR(8) NOT NULL DEFAULT '',
+ road_address VARCHAR(300) NOT NULL DEFAULT '',
+ floor_info VARCHAR(20) NOT NULL DEFAULT '', -- 결측 48% 이나 동일좌표 다중매장 구분 단서
+ lat DOUBLE PRECISION NOT NULL,
+ lng DOUBLE PRECISION NOT NULL,
+
+ -- 상태 · 출처. 분기 스냅샷에서 사라졌다는 사실만으로 실제 폐업을 단정하지 않는다.
+ status VARCHAR(20) NOT NULL DEFAULT 'active', -- active | inactive
+ origin VARCHAR(20) NOT NULL,
+ inactive_at TIMESTAMPTZ NULL,
+ -- 공공데이터 수록 여부와 서비스의 검증 완료는 다른 개념이다. 실제 검증 전에는 NULL.
+ verified_at TIMESTAMPTZ NULL,
+ submitted_by UUID NULL, -- origin='user_submitted' 인 경우의 제보자
+ last_batch_id BIGINT NULL,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+
+ CONSTRAINT pk_stores PRIMARY KEY (id),
+ CONSTRAINT uq_stores_sbiz_no UNIQUE (sbiz_store_no),
+ CONSTRAINT fk_stores_category FOREIGN KEY (category_id)
+ REFERENCES store_categories (id),
+ CONSTRAINT fk_stores_ksic FOREIGN KEY (ksic_code)
+ REFERENCES ksic_codes (code),
+ CONSTRAINT fk_stores_batch FOREIGN KEY (last_batch_id)
+ REFERENCES store_import_batches (id),
+ CONSTRAINT fk_stores_submitter FOREIGN KEY (submitted_by)
+ REFERENCES users (id) ON DELETE SET NULL,
+ -- 원천 실측 이상치 0건. 사용자 제출 가게의 오입력을 막는 방어선.
+ CONSTRAINT ck_stores_lat CHECK (lat BETWEEN 33 AND 39),
+ CONSTRAINT ck_stores_lng CHECK (lng BETWEEN 124 AND 132),
+ CONSTRAINT ck_stores_name CHECK (btrim(name) <> ''),
+ CONSTRAINT ck_stores_status CHECK (status IN ('active', 'inactive')),
+ CONSTRAINT ck_stores_origin CHECK (origin IN ('sbiz', 'localdata', 'user_submitted')),
+ CONSTRAINT ck_stores_inactive_at CHECK ((status = 'inactive') = (inactive_at IS NOT NULL)),
+ -- 출처별 식별자·배치 관계를 DB에서도 강제해 잘못 조합된 가게 행을 막는다.
+ CONSTRAINT ck_stores_sbiz_identity CHECK ((origin = 'sbiz') = (sbiz_store_no IS NOT NULL)),
+ CONSTRAINT ck_stores_batch_origin CHECK (
+ (origin IN ('sbiz', 'localdata')) = (last_batch_id IS NOT NULL)
+ ),
+ CONSTRAINT ck_stores_submitter_origin CHECK (submitted_by IS NULL OR origin = 'user_submitted'),
+ CONSTRAINT ck_stores_category_origin CHECK (category_id IS NOT NULL OR origin = 'user_submitted')
+);
+
+-- 반경 후보 검색. status 동등조건을 부분 인덱스 조건으로 흡수해 스캔 대상을 영업중 행으로 한정한다.
+CREATE INDEX idx_stores_geo_active ON stores USING gist (ll_to_earth(lat, lng))
+ WHERE status = 'active';
+-- 상호명 유사도 매칭(실측: 상호명 단독으로는 고유율 83% 라 좌표와 병행 필수).
+CREATE INDEX idx_stores_name_trgm ON stores USING gin (name_normalized gin_trgm_ops);
+CREATE INDEX idx_stores_category ON stores (category_id) WHERE status = 'active';
+CREATE INDEX idx_stores_batch ON stores (last_batch_id);
+
+COMMENT ON TABLE stores IS '가게 마스터. 상가정보 한식(I201) 기반, 분기 스냅샷을 sbiz_store_no 기준으로 멱등 upsert.';
+COMMENT ON COLUMN stores.status IS '데이터 소스 기준 노출 상태. inactive는 실제 폐업 확정이 아니라 최신 스냅샷 미수록을 뜻한다.';
+COMMENT ON COLUMN stores.verified_at IS '서비스가 사업자·관리자 검증을 완료한 시각. 공공데이터 수록만으로 채우지 않는다.';
+COMMENT ON COLUMN stores.origin IS '레코드 출처. 이 스캔에서 어떻게 식별했는지(match_method)와는 다른 축이다.';
+COMMENT ON COLUMN stores.name_normalized IS '매칭 전용 정규형(생성 컬럼). 표시에는 name 을 쓸 것. 검색어도 normalize_store_name() 을 거쳐야 한다.';
+
+-- ─────────────────────────────────────────────────────────────
+-- 별칭 — 두 종류를 분리한다.
+-- 가게별 별칭은 store 에 종속되지만, 브랜드 표기 변형(서브웨이↔써브웨이)은 특정 가게와 무관하다.
+-- 후자를 store_aliases 에 넣으면 같은 브랜드 지점 수만큼 행이 복제되어 삽입·수정 이상이 생긴다.
+-- ─────────────────────────────────────────────────────────────
+CREATE TABLE store_aliases (
+ id BIGINT GENERATED ALWAYS AS IDENTITY,
+ store_id BIGINT NOT NULL,
+ alias VARCHAR(200) NOT NULL,
+ alias_normalized VARCHAR(200) GENERATED ALWAYS AS (normalize_store_name(alias)) STORED,
+ source VARCHAR(20) NOT NULL DEFAULT 'manual',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT pk_store_aliases PRIMARY KEY (id),
+ CONSTRAINT fk_store_aliases_store FOREIGN KEY (store_id)
+ REFERENCES stores (id) ON DELETE CASCADE,
+ CONSTRAINT ck_store_aliases_source CHECK (source IN ('manual', 'user_reported'))
+);
+CREATE UNIQUE INDEX uq_store_aliases ON store_aliases (store_id, alias_normalized);
+CREATE INDEX idx_store_aliases_trgm ON store_aliases USING gin (alias_normalized gin_trgm_ops);
+
+-- 전역 표기 변형 사전. 검색어를 대표표기로 치환한 뒤 stores 를 조회한다. store FK 없음(앱 레벨 조회).
+CREATE TABLE brand_aliases (
+ id BIGINT GENERATED ALWAYS AS IDENTITY,
+ variant_normalized VARCHAR(200) NOT NULL,
+ canonical_normalized VARCHAR(200) NOT NULL,
+ note VARCHAR(200) NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT pk_brand_aliases PRIMARY KEY (id),
+ CONSTRAINT uq_brand_aliases_variant UNIQUE (variant_normalized)
+);
+
+-- ─────────────────────────────────────────────────────────────
+-- 외부 지도 서비스 참조
+-- 컬럼이 아니라 테이블로 분리한 이유: stores 에 kakao_place_id 컬럼을 두면
+-- 그 옆에 kakao_name / kakao_address 를 추가하는 것이 한 줄 ALTER 로 가능해진다.
+-- 저장이 허용되는 필드만 담는 테이블로 격리해 약관 경계를 스키마에 남긴다.
+-- ─────────────────────────────────────────────────────────────
+CREATE TABLE store_external_refs (
+ id BIGINT GENERATED ALWAYS AS IDENTITY,
+ store_id BIGINT NOT NULL,
+ provider VARCHAR(20) NOT NULL,
+ external_id VARCHAR(64) NOT NULL, -- 카카오 place_id
+ external_url VARCHAR(512) NOT NULL DEFAULT '', -- 카카오 place_url
+ linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT pk_store_external_refs PRIMARY KEY (id),
+ -- 양방향 1:1 — 같은 외부 장소가 두 가게에 붙거나, 한 가게에 같은 제공자가 둘 붙는 것을 막는다.
+ CONSTRAINT uq_store_external_refs_ext UNIQUE (provider, external_id),
+ CONSTRAINT uq_store_external_refs_store UNIQUE (store_id, provider),
+ CONSTRAINT fk_store_external_refs_store FOREIGN KEY (store_id)
+ REFERENCES stores (id) ON DELETE CASCADE,
+ CONSTRAINT ck_store_external_refs_provider CHECK (provider IN ('kakao'))
+);
+
+COMMENT ON TABLE store_external_refs IS
+ '외부 지도 서비스 참조. 카카오 약관상 place_id/place_url 만 저장 허용 — 상호명·주소·좌표·전화번호 저장 금지.';
+
+-- ─────────────────────────────────────────────────────────────
+-- 스캔 세션 연결
+-- 기존 운영 스캔은 가게 정보 없이 생성됐으므로 세 컬럼을 NULL 허용한다.
+-- 신규 스캔은 애플리케이션이 가게 선택 후 시작하고, store-scoped AI(③ 이후)는 store_id가
+-- 확정된 세션만 호출한다. 현재 OCR·정규화 경로와 과거 이력 조회는 NULL이어도 유지한다.
+--
+-- 사용자 GPS 원본 컬럼은 의도적으로 두지 않는다. 개인위치정보(위치정보법)에 해당해
+-- 저장 시 동의·보관기간·파기 의무가 발생하고, 스캔 이력과 결합되면 동선이 된다.
+-- 후보 조회에만 쓰고 결과(store_id)만 남긴다.
+-- ─────────────────────────────────────────────────────────────
+ALTER TABLE scan_sessions
+ ADD COLUMN store_id BIGINT NULL,
+ ADD COLUMN store_name_snapshot VARCHAR(200) NULL,
+ ADD COLUMN store_match_method VARCHAR(20) NULL;
+
+ALTER TABLE scan_sessions
+ -- RESTRICT: 스캔 이력이 참조하는 가게는 삭제 불가. 검색 제외는 stores.status 비활성 전이로 표현한다.
+ ADD CONSTRAINT fk_scan_sessions_store FOREIGN KEY (store_id)
+ REFERENCES stores (id) ON DELETE RESTRICT,
+ ADD CONSTRAINT ck_scan_sessions_match_method CHECK (store_match_method IS NULL OR store_match_method IN
+ ('gps_candidate', 'name_search', 'kakao_fallback', 'user_created')),
+ ADD CONSTRAINT ck_scan_sessions_store_context CHECK (
+ (store_id IS NULL AND store_name_snapshot IS NULL AND store_match_method IS NULL)
+ OR
+ (store_id IS NOT NULL
+ AND store_name_snapshot IS NOT NULL
+ AND btrim(store_name_snapshot) <> ''
+ AND store_match_method IS NOT NULL)
+ );
+
+CREATE INDEX idx_scan_sessions_store ON scan_sessions (store_id) WHERE store_id IS NOT NULL;
+
+COMMENT ON COLUMN scan_sessions.store_name_snapshot IS
+ '스캔 시점 상호명 동결. 서버가 stores.name에서 복사하며 클라이언트 입력을 신뢰하지 않는다.';
+COMMENT ON COLUMN scan_sessions.store_id IS
+ '가게 도입 전 레거시 스캔만 NULL. 신규 store-scoped AI 호출은 값이 확정된 세션에만 허용한다.';
+COMMENT ON COLUMN scan_sessions.store_match_method IS
+ '가게 식별 경로. 의사결정 근거로 사용하지 않는 관측용 메타데이터이며 store context와 함께 저장한다.';
diff --git a/scripts/test_load_stores.py b/scripts/test_load_stores.py
new file mode 100644
index 0000000..b9e6130
--- /dev/null
+++ b/scripts/test_load_stores.py
@@ -0,0 +1,89 @@
+import argparse
+import io
+import unittest
+from pathlib import Path
+
+from scripts import load_stores
+
+
+class LoadStoresSafetyTest(unittest.TestCase):
+
+ def test_full_default_snapshot_allows_sweep(self):
+ args = self.args(sweep_inactive=True)
+
+ load_stores.validate_sweep_scope(args, self.files())
+
+ def test_region_filter_rejects_sweep(self):
+ args = self.args(sweep_inactive=True, regions="서울")
+
+ with self.assertRaisesRegex(SystemExit, "--regions"):
+ load_stores.validate_sweep_scope(args, self.files(["서울"]))
+
+ def test_non_default_category_rejects_sweep(self):
+ args = self.args(sweep_inactive=True, category="I202")
+
+ with self.assertRaisesRegex(SystemExit, "기본 적재 범위"):
+ load_stores.validate_sweep_scope(args, self.files())
+
+ def test_missing_region_rejects_sweep(self):
+ regions = load_stores.FULL_DATASET_REGIONS - {"서울"}
+
+ with self.assertRaisesRegex(SystemExit, "서울"):
+ load_stores.validate_sweep_scope(self.args(sweep_inactive=True), self.files(regions))
+
+ def test_duplicate_region_rejects_sweep(self):
+ files = self.files() + [Path("상가_정보_서울_202606.csv")]
+
+ with self.assertRaisesRegex(SystemExit, r"중복=\['서울'\]"):
+ load_stores.validate_sweep_scope(self.args(sweep_inactive=True), files)
+
+ def test_partial_load_without_sweep_is_allowed(self):
+ args = self.args(sweep_inactive=False, regions="서울", category="I202")
+
+ load_stores.validate_sweep_scope(args, self.files(["서울"]))
+
+ def test_source_version_must_match_file_version(self):
+ with self.assertRaisesRegex(SystemExit, "파일 버전"):
+ load_stores.resolve_version(self.files(), "202603")
+
+ def test_detected_source_version_is_used(self):
+ self.assertEqual("202606", load_stores.resolve_version(self.files(), None))
+
+ def test_unexpected_filename_is_rejected(self):
+ with self.assertRaisesRegex(SystemExit, "예상한 CSV 파일명"):
+ load_stores.resolve_version([Path("stores.csv")], None)
+
+ def test_inactive_sweep_sql_does_not_claim_store_is_closed(self):
+ output = io.StringIO()
+
+ load_stores.emit(output, [], load_stores.MIDDLE_CATEGORY, True, "202606")
+
+ sql = output.getvalue()
+ self.assertIn("status = 'inactive'", sql)
+ self.assertIn("inactive_at = now()", sql)
+ self.assertNotIn("is_verified", sql)
+ self.assertNotIn("status = 'closed'", sql)
+
+ def test_legacy_sweep_option_maps_to_inactive_sweep(self):
+ args = load_stores.parse_args(["--csv-dir", "/tmp", "--sweep-closed"])
+
+ self.assertTrue(args.sweep_inactive)
+
+ @staticmethod
+ def args(**overrides) -> argparse.Namespace:
+ values = {
+ "sweep_inactive": False,
+ "regions": None,
+ "category": load_stores.MIDDLE_CATEGORY,
+ }
+ values.update(overrides)
+ return argparse.Namespace(**values)
+
+ @staticmethod
+ def files(regions=None) -> list[Path]:
+ selected = regions or load_stores.FULL_DATASET_REGIONS
+ return [Path(f"상가_정보_{region}_202606.csv") for region in sorted(selected)]
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ImageQuality.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ImageQuality.java
index 92e1da1..c4a19e5 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ImageQuality.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ImageQuality.java
@@ -10,6 +10,7 @@
* 알려진 필드만 매핑하고 나머지는 무시한다.
*
* @param available 분석 가능 여부 (PIL/cv2 미존재 시 false)
+ * @param error 분석 불가 사유
* @param score 이미지 품질 점수
* @param blurScore 흐림/초점 상태
* @param brightness 밝기
@@ -23,6 +24,7 @@
@JsonIgnoreProperties(ignoreUnknown = true)
public record ImageQuality(
Boolean available,
+ String error,
Integer score,
Double blurScore,
Double brightness,
diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/MenuAnalysis.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/MenuAnalysis.java
index bef1a1b..046389a 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/MenuAnalysis.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/MenuAnalysis.java
@@ -15,7 +15,7 @@
* @param priceText 가격 문자열 (보정된 원 단위)
* @param riskLevel 위험도 (OCR 단계 null)
* @param isSpicy 매움 여부
- * @param imageUrl 개별 메뉴 이미지 URL
+ * @param imageUrl 개별 메뉴 이미지 URL. 현재 AI 크롭 미구현이며, 향후에도 백엔드 검증 전에는 저장·공개하지 않음.
* @param displayOrder 화면 표시 순서
*/
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java
index 016adce..5e7a03b 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java
@@ -13,6 +13,10 @@
* @param rawLineCount OCR 원본 라인 수
* @param priceMatchCount 가격 매칭된 메뉴 수
* @param priceMatchRatio 가격 매칭 비율
+ * @param priceAnchorCount OCR에서 감지된 가격 후보 수
+ * @param pairCoverage 감지된 가격 후보 중 메뉴와 매칭된 비율
+ * @param meanOcrConfidence OCR 라인 평균 신뢰도
+ * @param meanPairConfidence 메뉴명-가격 쌍 평균 신뢰도
* @param imageWidth 이미지 가로 px
* @param imageHeight 이미지 세로 px
* @param imageQuality 이미지 품질 세부 분석
@@ -36,6 +40,10 @@ public record ScanQuality(
Integer rawLineCount,
Integer priceMatchCount,
Double priceMatchRatio,
+ Integer priceAnchorCount,
+ Double pairCoverage,
+ Double meanOcrConfidence,
+ Double meanPairConfidence,
Integer imageWidth,
Integer imageHeight,
ImageQuality imageQuality,
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/MenuResult.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/MenuResult.java
index 0958e41..76cd86c 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/MenuResult.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/MenuResult.java
@@ -8,10 +8,13 @@
/**
* 스캔 결과의 메뉴 1건 (OCR + ai_result 최종). 사용자 표시용.
+ * AI가 반환한 메뉴별 이미지 URL은 신뢰하지 않으며, 백엔드 소유의 크롭·서명 체계가 생기기 전까지 응답하지 않는다.
*
* @param displayOrder 표시 순서
* @param menuNameKo 한국어 메뉴명
* @param menuNameEn 영어 메뉴명
+ * @param descriptionKo 한국어 메뉴 설명
+ * @param descriptionEn 영어 메뉴 설명
* @param priceText 가격 문자열
* @param isSpicy 매움 여부
* @param riskLevel 위험도 (danger | caution | safe)
@@ -24,6 +27,8 @@ public record MenuResult(
Integer displayOrder,
String menuNameKo,
String menuNameEn,
+ String descriptionKo,
+ String descriptionEn,
String priceText,
Boolean isSpicy,
RiskLevel riskLevel,
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java
index 851b4eb..15e6744 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java
@@ -17,6 +17,7 @@
* @param scannedAt 스캔 시각
* @param menus 메뉴별 분석 결과
* @param retakeReasons 재촬영 사유 (status 가 NEEDS_RETAKE 일 때만 채워짐, 그 외 null). OCR 이 제공한 문자열 그대로(언어 혼재 가능, i18n 키 아님)
+ * @param retakeSuggestions 재촬영 방법 안내 (status 가 NEEDS_RETAKE 일 때만 채워짐, 그 외 null)
* @param failureCode 실패 원인 코드 (status 가 FAILED 일 때만 채워짐)
*/
@Schema(description = "스캔 결과 조회 응답")
@@ -29,4 +30,5 @@ public record ScanResultResponse(
Instant scannedAt,
List menus,
List retakeReasons,
+ List retakeSuggestions,
String failureCode) {}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/MenuImage.java b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/MenuImage.java
index af3d562..17e2001 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/MenuImage.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/MenuImage.java
@@ -35,6 +35,14 @@ public class MenuImage extends BaseEntity {
@Column(name = "storage_key", length = 512)
private String storageKey;
+ /** 검증 시점의 S3 객체 버전. 같은 키의 객체가 변경돼도 실제 분석 대상을 식별한다. */
+ @Column(name = "object_version_id", length = 1024)
+ private String objectVersionId;
+
+ /** 검증 시점의 S3 ETag. AI가 읽은 객체와 업로드 검증 결과의 동일성 확인에 사용한다. */
+ @Column(name = "etag", length = 255)
+ private String eTag;
+
@Column(name = "image_url", length = 1024)
private String imageUrl;
@@ -45,18 +53,34 @@ public class MenuImage extends BaseEntity {
private Long fileSize;
private MenuImage(
- UUID scanSessionId, String source, String storageKey, String imageUrl, String mimeType, Long fileSize) {
+ UUID scanSessionId,
+ String source,
+ String storageKey,
+ String imageUrl,
+ String mimeType,
+ Long fileSize,
+ String objectVersionId,
+ String eTag) {
this.id = UUID.randomUUID();
this.scanSessionId = scanSessionId;
this.source = source;
this.storageKey = storageKey;
+ this.objectVersionId = objectVersionId;
+ this.eTag = eTag;
this.imageUrl = imageUrl;
this.mimeType = mimeType;
this.fileSize = fileSize;
}
public static MenuImage create(
- UUID scanSessionId, String source, String storageKey, String imageUrl, String mimeType, Long fileSize) {
- return new MenuImage(scanSessionId, source, storageKey, imageUrl, mimeType, fileSize);
+ UUID scanSessionId,
+ String source,
+ String storageKey,
+ String imageUrl,
+ String mimeType,
+ Long fileSize,
+ String objectVersionId,
+ String eTag) {
+ return new MenuImage(scanSessionId, source, storageKey, imageUrl, mimeType, fileSize, objectVersionId, eTag);
}
}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java
index a3f73b7..85301d4 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java
@@ -63,6 +63,10 @@ public class ScanSession extends BaseEntity {
@Column(name = "retake_reasons", columnDefinition = "jsonb")
private List retakeReasons;
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(name = "retake_suggestions", columnDefinition = "jsonb")
+ private List retakeSuggestions;
+
private ScanSession(
UUID userId,
String storageKey,
@@ -124,11 +128,12 @@ public void changeTitle(String title) {
this.title = title;
}
- /** 재촬영 필요 시 상태 + OCR 이 제공한 사유를 반영. */
- public void applyNeedsRetake(List retakeReasons) {
+ /** 재촬영 필요 시 상태와 OCR 이 제공한 사유·개선 안내를 반영. */
+ public void applyNeedsRetake(List retakeReasons, List retakeSuggestions) {
ensureProcessing();
this.scanStatus = ScanStatus.NEEDS_RETAKE;
this.retakeReasons = retakeReasons;
+ this.retakeSuggestions = retakeSuggestions;
this.failureCode = null;
}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java
index 91549f9..e1700b7 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java
@@ -94,13 +94,13 @@ public void process(UUID scanId, UUID userId, VerifiedUpload upload, String sour
scanStateWriter.applyOcrResult(
scanId,
- toMenuImage(scanId, source, storageKey, ocr),
+ toMenuImage(scanId, source, upload, ocr),
ocr.scanSession() != null ? ocr.scanSession().menuCount() : null,
parseScannedAt(ocr.scanSession() != null ? ocr.scanSession().scannedAt() : null));
if (isNeedsRetake(ocr)) {
scanStateWriter.applyNeedsRetake(
- scanId, ocr.scanQuality() != null ? ocr.scanQuality().reasons() : null);
+ scanId, ocr.scanQuality().reasons(), ocr.scanQuality().retakeSuggestions());
log.info("Scan needs retake: {}", scanId);
return;
}
@@ -149,19 +149,20 @@ private void markFailedQuietly(UUID scanId, Exception cause) {
}
}
- private MenuImage toMenuImage(UUID scanId, String source, String storageKey, OcrResponse ocr) {
+ private MenuImage toMenuImage(UUID scanId, String source, VerifiedUpload upload, OcrResponse ocr) {
String resolvedSource = source;
- String mimeType = null;
- Long fileSize = null;
- if (ocr.menuImage() != null) {
- if (resolvedSource == null) {
- resolvedSource = ocr.menuImage().source();
- }
- mimeType = ocr.menuImage().mimeType();
- fileSize = ocr.menuImage().fileSize();
+ if (resolvedSource == null && ocr.menuImage() != null) {
+ resolvedSource = ocr.menuImage().source();
}
return MenuImage.create(
- scanId, resolvedSource, storageKey, s3StorageService.objectUri(storageKey), mimeType, fileSize);
+ scanId,
+ resolvedSource,
+ upload.storageKey(),
+ s3StorageService.objectUri(upload.storageKey()),
+ upload.contentType(),
+ upload.contentLength(),
+ upload.versionId(),
+ upload.eTag());
}
private boolean isNeedsRetake(OcrResponse ocr) {
@@ -212,7 +213,8 @@ private List merge(UUID scanId, OcrResponse ocr, FinalResultRespon
o.descriptionEn(),
o.priceText(),
o.isSpicy(),
- o.imageUrl(),
+ // AI 제공 URL은 출처·만료를 보장할 수 없다. 백엔드 소유의 크롭·서명 체계 도입 전에는 저장하지 않는다.
+ null,
f.riskLevel(),
f.hits(),
f.message(),
@@ -240,12 +242,36 @@ private String failureCode(Exception exception) {
private void logOcrCompleted(UUID scanId, long backendDurationMs, OcrResponse ocr) {
var quality = ocr.scanQuality();
+ if (quality != null
+ && quality.imageQuality() != null
+ && Boolean.FALSE.equals(quality.imageQuality().available())
+ && quality.imageQuality().error() != null) {
+ log.warn(
+ "Image quality analysis unavailable: {} (reason={})",
+ scanId,
+ quality.imageQuality().error());
+ }
log.info(
- "OCR completed: {} (backendMs={}, aiMs={}, attempts={}, preprocessingApplied={}, selectedAttempt={}, "
- + "retrySkippedReason={}, fetchSource={}, aiQueueMs={})",
+ "OCR completed: {} (backendMs={}, aiMs={}, qualityStatus={}, score={}, rawLines={}, priceMatches={}, "
+ + "priceAnchors={}, pairCoverage={}, meanOcrConfidence={}, meanPairConfidence={}, "
+ + "imageWidth={}, imageHeight={}, imageQualityScore={}, attempts={}, preprocessingApplied={}, "
+ + "selectedAttempt={}, retrySkippedReason={}, fetchSource={}, aiQueueMs={})",
scanId,
backendDurationMs,
quality != null ? quality.ocrProcessingTimeMs() : null,
+ quality != null ? quality.status() : null,
+ quality != null ? quality.score() : null,
+ quality != null ? quality.rawLineCount() : null,
+ quality != null ? quality.priceMatchCount() : null,
+ quality != null ? quality.priceAnchorCount() : null,
+ quality != null ? quality.pairCoverage() : null,
+ quality != null ? quality.meanOcrConfidence() : null,
+ quality != null ? quality.meanPairConfidence() : null,
+ quality != null ? quality.imageWidth() : null,
+ quality != null ? quality.imageHeight() : null,
+ quality != null && quality.imageQuality() != null
+ ? quality.imageQuality().score()
+ : null,
quality != null ? quality.ocrAttemptCount() : null,
quality != null ? quality.preprocessingApplied() : null,
quality != null ? quality.selectedOcrAttempt() : null,
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java
index 8d7e9bf..b7f4c62 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java
@@ -105,6 +105,7 @@ public ScanResultResponse getScan(UUID userId, UUID scanId) {
session.getScannedAt(),
menus,
session.getRetakeReasons(),
+ session.getRetakeSuggestions(),
session.getFailureCode());
}
@@ -140,6 +141,8 @@ private static MenuResult toMenuResult(MenuAnalysis m) {
m.getDisplayOrder(),
m.getMenuNameKo(),
m.getMenuNameEn(),
+ m.getDescriptionKo(),
+ m.getDescriptionEn(),
m.getPriceText(),
m.getIsSpicy(),
m.getRiskLevel(),
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java
index 1811ca5..c209763 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java
@@ -47,8 +47,8 @@ public void applyOcrResult(UUID scanId, MenuImage menuImage, Integer menuCount,
}
@Transactional
- public void applyNeedsRetake(UUID scanId, List retakeReasons) {
- session(scanId).ifPresent(session -> session.applyNeedsRetake(retakeReasons));
+ public void applyNeedsRetake(UUID scanId, List retakeReasons, List retakeSuggestions) {
+ session(scanId).ifPresent(session -> session.applyNeedsRetake(retakeReasons, retakeSuggestions));
}
@Transactional
diff --git a/src/main/java/com/hanspoon/backend_api/global/config/DevDataSeeder.java b/src/main/java/com/hanspoon/backend_api/global/config/DevDataSeeder.java
index a82228b..2005070 100644
--- a/src/main/java/com/hanspoon/backend_api/global/config/DevDataSeeder.java
+++ b/src/main/java/com/hanspoon/backend_api/global/config/DevDataSeeder.java
@@ -174,7 +174,7 @@ private void seedScans(UUID userId) {
// 3) NEEDS_RETAKE · 재촬영 사유
ScanSession s3 =
ScanSession.create(userId, null, 0, null, ScanStatus.PROCESSING, now.minus(Duration.ofDays(2)));
- s3.applyNeedsRetake(List.of("too blurry", "low light"));
+ s3.applyNeedsRetake(List.of("too blurry", "low light"), List.of("카메라를 메뉴판과 평행하게 두고 다시 촬영해 주세요."));
scanSessionRepository.save(s3);
// 4) PROCESSING · 진행 중
diff --git a/src/main/resources/db/migration/V3__add_scan_retake_suggestions.sql b/src/main/resources/db/migration/V3__add_scan_retake_suggestions.sql
new file mode 100644
index 0000000..adceb1a
--- /dev/null
+++ b/src/main/resources/db/migration/V3__add_scan_retake_suggestions.sql
@@ -0,0 +1,2 @@
+ALTER TABLE scan_sessions
+ ADD COLUMN retake_suggestions JSONB NULL;
diff --git a/src/main/resources/db/migration/V4__add_menu_image_provenance.sql b/src/main/resources/db/migration/V4__add_menu_image_provenance.sql
new file mode 100644
index 0000000..988ce26
--- /dev/null
+++ b/src/main/resources/db/migration/V4__add_menu_image_provenance.sql
@@ -0,0 +1,8 @@
+ALTER TABLE menu_images
+ ADD COLUMN object_version_id VARCHAR(1024) NULL,
+ ADD COLUMN etag VARCHAR(255) NULL;
+
+COMMENT ON COLUMN menu_images.object_version_id IS
+ 'S3 HeadObject 검증 시점의 객체 버전 ID. 실제 OCR 분석 대상을 식별한다.';
+COMMENT ON COLUMN menu_images.etag IS
+ 'S3 HeadObject 검증 시점의 ETag. 업로드 검증 객체의 동일성 확인에 사용한다.';
diff --git a/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java b/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java
index 2ba8044..a5e7085 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java
@@ -4,6 +4,7 @@
import com.hanspoon.backend_api.domain.ai.dto.common.EscalationCase;
import com.hanspoon.backend_api.domain.ai.dto.common.RiskLevel;
+import com.hanspoon.backend_api.domain.ai.dto.ocr.ImageQuality;
import com.hanspoon.backend_api.domain.ai.dto.ocr.OcrRequest;
import com.hanspoon.backend_api.domain.ai.dto.ocr.OcrResponse;
import com.hanspoon.backend_api.domain.ai.dto.ruleengine.RuleMenuAnalysis;
@@ -17,6 +18,25 @@ class AiDtoSerializationTest {
private final JsonMapper objectMapper = JsonMapper.builder().build();
+ @Test
+ void deserializesImageQualityAnalysisError() throws Exception {
+ String json =
+ """
+ {
+ "available": false,
+ "error": "이미지 품질 분석 패키지를 불러오지 못했습니다.",
+ "score": null,
+ "reasons": [],
+ "suggestions": []
+ }
+ """;
+
+ ImageQuality result = objectMapper.readValue(json, ImageQuality.class);
+
+ assertThat(result.available()).isFalse();
+ assertThat(result.error()).isEqualTo("이미지 품질 분석 패키지를 불러오지 못했습니다.");
+ }
+
@Test
void deserializesDangerMenu() throws Exception {
String json =
@@ -142,6 +162,10 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception {
"raw_line_count": 20,
"price_match_count": 2,
"price_match_ratio": 1.0,
+ "price_anchor_count": 3,
+ "pair_coverage": 0.67,
+ "mean_ocr_confidence": 0.91,
+ "mean_pair_confidence": 0.88,
"image_width": 1280,
"image_height": 960,
"image_quality": {
@@ -156,7 +180,7 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception {
"suggestions": [],
"future_unknown_field": "ignored"
},
- "retake_suggestions": [],
+ "retake_suggestions": ["카메라의 초점을 맞춰 다시 촬영해 주세요."],
"reasons": [],
"preprocessing_attempted": true,
"preprocessing_applied": true,
@@ -191,7 +215,12 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception {
assertThat(result.scanSession().riskyMenuCount()).isNull();
assertThat(result.menuImage().storageKey()).isEqualTo("scans/menu_001.jpg");
assertThat(result.scanQuality().status()).isEqualTo("usable");
+ assertThat(result.scanQuality().priceAnchorCount()).isEqualTo(3);
+ assertThat(result.scanQuality().pairCoverage()).isEqualTo(0.67);
+ assertThat(result.scanQuality().meanOcrConfidence()).isEqualTo(0.91);
+ assertThat(result.scanQuality().meanPairConfidence()).isEqualTo(0.88);
assertThat(result.scanQuality().imageQuality().glareRatio()).isEqualTo(0.02);
+ assertThat(result.scanQuality().retakeSuggestions()).containsExactly("카메라의 초점을 맞춰 다시 촬영해 주세요.");
assertThat(result.scanQuality().preprocessingApplied()).isTrue();
assertThat(result.scanQuality().ocrAttemptCount()).isEqualTo(2);
assertThat(result.scanQuality().ocrProcessingTimeMs()).isEqualTo(842L);
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/ScanPersistenceIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/ScanPersistenceIntegrationTest.java
index 22e1b79..a38a399 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/ScanPersistenceIntegrationTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/ScanPersistenceIntegrationTest.java
@@ -66,7 +66,9 @@ void persistsAndReloadsScanGraphWithJsonbFields() {
"scans/menu_001.jpg",
"https://example.com/scans/menu_001.jpg",
"image/jpeg",
- 123456L));
+ 123456L,
+ "version-1",
+ "\"etag-1\""));
menuAnalysisRepository.save(MenuAnalysis.create(
sessionId,
@@ -96,6 +98,8 @@ void persistsAndReloadsScanGraphWithJsonbFields() {
menuImageRepository.findByScanSessionId(sessionId).orElseThrow();
assertThat(reloadedImage.getStorageKey()).isEqualTo("scans/menu_001.jpg");
assertThat(reloadedImage.getSource()).isEqualTo("upload");
+ assertThat(reloadedImage.getObjectVersionId()).isEqualTo("version-1");
+ assertThat(reloadedImage.getETag()).isEqualTo("\"etag-1\"");
List analyses = menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(sessionId);
assertThat(analyses).hasSize(1);
@@ -129,7 +133,7 @@ void appliesNeedsRetakePersistsReasonsAsJsonb() {
ScanSession session = scanSessionRepository.save(
ScanSession.create(user.getId(), "blur.jpg", null, null, ScanStatus.PROCESSING, Instant.now()));
- session.applyNeedsRetake(List.of("too blurry", "low light"));
+ session.applyNeedsRetake(List.of("too blurry", "low light"), List.of("카메라의 초점을 맞춰 다시 촬영해 주세요."));
scanSessionRepository.save(session);
entityManager.flush();
entityManager.clear();
@@ -137,6 +141,7 @@ void appliesNeedsRetakePersistsReasonsAsJsonb() {
ScanSession reloaded = scanSessionRepository.findById(session.getId()).orElseThrow();
assertThat(reloaded.getScanStatus()).isEqualTo(ScanStatus.NEEDS_RETAKE);
assertThat(reloaded.getRetakeReasons()).containsExactly("too blurry", "low light");
+ assertThat(reloaded.getRetakeSuggestions()).containsExactly("카메라의 초점을 맞춰 다시 촬영해 주세요.");
}
@Test
@@ -146,7 +151,8 @@ void deletingScanSessionCascadesToImageAndAnalyses() {
ScanSession session = scanSessionRepository.save(
ScanSession.create(userId, "del.jpg", 1, 0, ScanStatus.COMPLETED, Instant.now()));
UUID sessionId = session.getId();
- menuImageRepository.save(MenuImage.create(sessionId, "upload", "scans/del.jpg", "u", "image/jpeg", 1L));
+ menuImageRepository.save(
+ MenuImage.create(sessionId, "upload", "scans/del.jpg", "u", "image/jpeg", 1L, null, null));
menuAnalysisRepository.save(MenuAnalysis.create(
sessionId,
1,
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java
index df7048d..20d0360 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java
@@ -113,8 +113,8 @@ void startScanReturns503WhenScanCapacityIsExhausted() throws Exception {
void getScanReturnsResult() throws Exception {
UUID scanId = UUID.randomUUID();
when(scanService.getScan(eq(USER_ID), eq(scanId)))
- .thenReturn(
- new ScanResultResponse(scanId, ScanStatus.COMPLETED, null, 2, 1, null, List.of(), null, null));
+ .thenReturn(new ScanResultResponse(
+ scanId, ScanStatus.COMPLETED, null, 2, 1, null, List.of(), null, null, null));
mockMvc.perform(get("/api/v1/scans/{scanId}", scanId))
.andExpect(status().isOk())
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java
index 7328565..880a54c 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java
@@ -93,9 +93,9 @@ void setUp() {
}
private static com.hanspoon.backend_api.domain.ai.dto.ocr.MenuAnalysis ocrMenu(
- String name, String price, boolean spicy, int order) {
+ String name, String price, boolean spicy, int order, String imageUrl) {
return new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuAnalysis(
- name, null, "", null, price, null, spicy, null, order);
+ name, null, "", null, price, null, spicy, imageUrl, order);
}
private OcrResponse usableOcr() {
@@ -103,13 +103,17 @@ private OcrResponse usableOcr() {
new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession(
"menu.jpg", 2, null, "completed", "2026-06-05T00:00:00Z"),
new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage(
- "upload", STORAGE_KEY, "https://s3/presigned", "image/jpeg", 123L),
+ "upload", STORAGE_KEY, "https://s3/presigned", "image/png", 999L),
new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality(
"usable",
80,
20,
2,
1.0,
+ 2,
+ 1.0,
+ 0.91,
+ 0.88,
1280,
960,
null,
@@ -124,7 +128,9 @@ private OcrResponse usableOcr() {
16_000L,
"s3_iam",
0L),
- List.of(ocrMenu("samgyeopsal", "9000", false, 1), ocrMenu("doenjang", "8000", true, 2)),
+ List.of(
+ ocrMenu("samgyeopsal", "9000", false, 1, "https://ai.invalid/crop.jpg"),
+ ocrMenu("doenjang", "8000", true, 2, null)),
null);
}
@@ -198,7 +204,16 @@ void completesScanAndMergesOcrWithRuleEngine() {
assertThat(session.getScanStatus()).isEqualTo(ScanStatus.COMPLETED);
assertThat(session.getMenuCount()).isEqualTo(2);
assertThat(session.getRiskyMenuCount()).isEqualTo(1);
- verify(menuImageRepository).save(any());
+ ArgumentCaptor imageCaptor =
+ ArgumentCaptor.forClass(com.hanspoon.backend_api.domain.scan.entity.MenuImage.class);
+ verify(menuImageRepository).save(imageCaptor.capture());
+ var savedImage = imageCaptor.getValue();
+ assertThat(savedImage.getStorageKey()).isEqualTo(STORAGE_KEY);
+ assertThat(savedImage.getImageUrl()).isEqualTo("s3://test-bucket/" + STORAGE_KEY);
+ assertThat(savedImage.getMimeType()).isEqualTo(VERIFIED_UPLOAD.contentType());
+ assertThat(savedImage.getFileSize()).isEqualTo(VERIFIED_UPLOAD.contentLength());
+ assertThat(savedImage.getObjectVersionId()).isEqualTo(VERSION_ID);
+ assertThat(savedImage.getETag()).isEqualTo(ETAG);
@SuppressWarnings("unchecked")
ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
@@ -208,6 +223,7 @@ void completesScanAndMergesOcrWithRuleEngine() {
// OCR 가격 + FinalOutput 위험도·태그의 동일 행 머지 확인
assertThat(saved.get(0).getMenuNameKo()).isEqualTo("samgyeopsal");
assertThat(saved.get(0).getPriceText()).isEqualTo("9000");
+ assertThat(saved.get(0).getImageUrl()).isNull();
assertThat(saved.get(0).getRiskLevel()).isEqualTo(RiskLevel.DANGER);
assertThat(saved.get(0).getHitTags()).containsExactly("is_pork");
assertThat(saved.get(0).getDisplayOrder()).isEqualTo(1);
@@ -259,10 +275,14 @@ void marksNeedsRetakeAndSkipsRuleEngine() {
1,
0,
0.0,
+ 1,
+ 0.0,
+ 0.42,
+ null,
100,
100,
null,
- List.of(),
+ List.of("카메라의 초점을 맞춰 다시 촬영해 주세요."),
List.of("too blurry"),
true,
true,
@@ -285,6 +305,7 @@ void marksNeedsRetakeAndSkipsRuleEngine() {
assertThat(session.getScanStatus()).isEqualTo(ScanStatus.NEEDS_RETAKE);
assertThat(session.getRetakeReasons()).containsExactly("too blurry");
+ assertThat(session.getRetakeSuggestions()).containsExactly("카메라의 초점을 맞춰 다시 촬영해 주세요.");
verify(aiClient, never()).judge(any());
verify(menuAnalysisRepository, never()).saveAll(any());
}
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java
index 7cebe91..ef3176c 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java
@@ -13,6 +13,7 @@
import com.hanspoon.backend_api.domain.scan.dto.ScanHistoryItem;
import com.hanspoon.backend_api.domain.scan.dto.StartScanRequest;
import com.hanspoon.backend_api.domain.scan.dto.UpdateScanTitleRequest;
+import com.hanspoon.backend_api.domain.scan.entity.MenuAnalysis;
import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository;
@@ -159,6 +160,37 @@ void getScanReturnsResultForOwner() {
assertThat(response.menus()).isEmpty();
}
+ @Test
+ void getScanReturnsPersistedMenuDescriptions() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = ScanSession.create(userId, null, 1, 0, ScanStatus.COMPLETED, Instant.now());
+ UUID scanId = session.getId();
+ MenuAnalysis menu = MenuAnalysis.create(
+ scanId,
+ 1,
+ "파돈불고기",
+ "Green Onion Bulgogi",
+ "파돈불고기 200g과 된장찌개",
+ "200g green onion bulgogi with soybean paste stew",
+ "14,000",
+ false,
+ null,
+ null,
+ List.of(),
+ null,
+ null);
+ when(scanSessionRepository.findByIdAndUserId(scanId, userId)).thenReturn(Optional.of(session));
+ when(menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(scanId))
+ .thenReturn(List.of(menu));
+
+ var response = scanService.getScan(userId, scanId);
+
+ assertThat(response.menus()).singleElement().satisfies(result -> {
+ assertThat(result.descriptionKo()).isEqualTo("파돈불고기 200g과 된장찌개");
+ assertThat(result.descriptionEn()).isEqualTo("200g green onion bulgogi with soybean paste stew");
+ });
+ }
+
@Test
void getScanReturnsFailureCodeWithoutInternalExceptionDetails() {
UUID userId = UUID.randomUUID();
@@ -174,6 +206,22 @@ void getScanReturnsFailureCodeWithoutInternalExceptionDetails() {
assertThat(response.failureCode()).isEqualTo("AI_SERVICE_OVERLOADED");
}
+ @Test
+ void getScanReturnsRetakeReasonsAndSuggestions() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = ScanSession.start(userId, "scans/" + userId + "/blurred.jpg");
+ session.applyNeedsRetake(List.of("이미지가 흐려 메뉴판 판독이 어렵습니다."), List.of("카메라의 초점을 맞춰 다시 촬영해 주세요."));
+ when(scanSessionRepository.findByIdAndUserId(session.getId(), userId)).thenReturn(Optional.of(session));
+ when(menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(session.getId()))
+ .thenReturn(List.of());
+
+ var response = scanService.getScan(userId, session.getId());
+
+ assertThat(response.status()).isEqualTo(ScanStatus.NEEDS_RETAKE);
+ assertThat(response.retakeReasons()).containsExactly("이미지가 흐려 메뉴판 판독이 어렵습니다.");
+ assertThat(response.retakeSuggestions()).containsExactly("카메라의 초점을 맞춰 다시 촬영해 주세요.");
+ }
+
@Test
void getScanReturnsNullTitleWhenNotEditedAndRawScannedAt() {
UUID userId = UUID.randomUUID();
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java
index aeba410..2728b3e 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java
@@ -52,7 +52,15 @@ void commitsOcrResultAndFailureInIndependentTransactions() {
scanStateWriter.applyOcrResult(
session.getId(),
- MenuImage.create(session.getId(), "upload", storageKey, "s3://test/" + storageKey, "image/jpeg", 123L),
+ MenuImage.create(
+ session.getId(),
+ "upload",
+ storageKey,
+ "s3://test/" + storageKey,
+ "image/jpeg",
+ 123L,
+ "version-1",
+ "\"etag-1\""),
2,
Instant.parse("2026-09-11T00:00:00Z"));