From 4a20194dc8d7d3b18493a91f8f3b497ef173cd40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Fri, 10 Jul 2026 17:10:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(negodata):=20=EC=9D=B8=ED=84=B0=EB=84=B7?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=80=EA=B0=80=20=EC=B6=9C=EC=B2=98=C2=B7?= =?UTF-8?q?=EB=A7=81=ED=81=AC=C2=B7=EA=B0=80=EA=B2=A9=20=EC=B6=94=EC=9D=B4?= =?UTF-8?q?=20UI=20=E2=80=94=20=EA=B2=80=EC=A6=9D=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=ED=98=91=EC=83=81=20=EA=B7=BC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인터넷 최저가는 견적 목표가·협상 카드(NGC-008 시장가 근거)의 근거값 — "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있어야 한다. - 스키마: iilp 에 lp_name(찾은 상품명)·lp_url(판매 페이지 링크) 추가 (alters/2026-07-10-iilp-source-link.sql, init.sql 멱등 반영·dev 적용) - 동기화: price_history 의 final_source 에 맞는 naver/coupang name·url 을 함께 반영(기존 행은 NULL 공존) - API: LowestPriceEntry 에 lp_name/lp_url + orval 재생성 - UI: 상품 테이블 인터넷 최저가 셀 클릭 → 상세 시트 · 대표가(rose) + 상품 단가 대비 차액(절감=emerald/역전=amber) · 출처 카드: 사이트 배지·찾은 상품명·판매 페이지 새탭 링크 · 가격 추이 라인 그래프(성공 수집 2건부터, recharts+ChartContainer 라이트/다크 쌍 — 통계 차트와 동일 문법) · 최근 수집 이력 리스트(미발견 amber, 행별 외부링크) 검증: 실데이터 e2e(A4 재수집 → lp_name/lp_url 반영 확인) + 헤드리스 브라우저 렌더 확인(시트·그래프·툴팁·링크), tsc·build 통과 Co-Authored-By: Claude Fable 5 --- .../backend/common/database/model/models.py | 2 + negodata/backend/crud/lps_sync_crud.py | 8 + negodata/backend/router/v1/item/protocol.py | 2 + negodata/backend/services/lps_sync_service.py | 15 +- .../front/src/api/generated/model/index.ts | 2 + .../api/generated/model/lowestPriceEntry.ts | 4 + .../generated/model/lowestPriceEntryLpName.ts | 8 + .../generated/model/lowestPriceEntryLpUrl.ts | 8 + .../components/LowestPriceHistorySheet.tsx | 191 ++++++++++++++++++ .../products/components/ProductTable.tsx | 17 +- negodata/front/src/pages/products.tsx | 11 +- .../alters/2026-07-10-iilp-source-link.sql | 6 + postgres-init/init-data/init.sql | 4 +- 13 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 negodata/front/src/api/generated/model/lowestPriceEntryLpName.ts create mode 100644 negodata/front/src/api/generated/model/lowestPriceEntryLpUrl.ts create mode 100644 negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx create mode 100644 postgres-init/alters/2026-07-10-iilp-source-link.sql diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 583b3d6..6383fe6 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -128,6 +128,8 @@ class item_internet_lowest_prices(MainTableMixin, MAIN_BASE): fail_reason = Column(String(100), nullable=True) # 실패 사유(예: not_found) ai_model = Column(SmallInteger, nullable=True) # (예약) AI 모델 코드 — LPS 계약엔 미포함 crawl_duration_ms = Column(Integer, nullable=True) # (예약) 수집 소요 — LPS 계약엔 미포함 + lp_name = Column(String(300), nullable=True) # 찾은 상품명(판매 페이지 기준) — 근거 검증용 + lp_url = Column(String, nullable=True) # 찾은 판매 페이지 링크(TEXT) — 근거 검증용 crawl_end_time = Column(DateTime(timezone=True), nullable=False) # 수집 완료 시각(=price_history.created_at, 워터마크 기준) diff --git a/negodata/backend/crud/lps_sync_crud.py b/negodata/backend/crud/lps_sync_crud.py index ae00eb5..4d0ffeb 100644 --- a/negodata/backend/crud/lps_sync_crud.py +++ b/negodata/backend/crud/lps_sync_crud.py @@ -25,6 +25,10 @@ _price_history = table( column("outcome"), # found | not_found column("final_lowest"), # 전체 최저가(원) column("final_source"), # naver | coupang | (폴백몰) + column("naver_name"), # 네이버 최저가 상품명/링크 — final_source 에 맞는 출처를 실어온다 + column("naver_url"), + column("coupang_name"), # 쿠팡 최저가 상품명/링크 + column("coupang_url"), column("created_at"), ) @@ -108,6 +112,10 @@ class LpsSyncCRUD(ILpsSyncCRUD): _price_history.c.outcome, _price_history.c.final_lowest, _price_history.c.final_source, + _price_history.c.naver_name, + _price_history.c.naver_url, + _price_history.c.coupang_name, + _price_history.c.coupang_url, _price_history.c.created_at, ).order_by(_price_history.c.created_at.asc()) if since is not None: diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index 8663a74..0d5e7f1 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -139,6 +139,8 @@ class LowestPriceEntry(WebPacketProtocol): website: int = 0 # LowestPriceWebsite 코드(1=naver 2=coupang …) success_yn: bool = False fail_reason: Optional[str] = None + lp_name: Optional[str] = None # 찾은 상품명(판매 페이지 기준) — 근거 검증용 + lp_url: Optional[str] = None # 찾은 판매 페이지 링크 crawl_end_time: Optional[datetime] = None diff --git a/negodata/backend/services/lps_sync_service.py b/negodata/backend/services/lps_sync_service.py index 9762067..ec9721f 100644 --- a/negodata/backend/services/lps_sync_service.py +++ b/negodata/backend/services/lps_sync_service.py @@ -129,11 +129,18 @@ class LpsSyncService: # product_code(uuid=item_id) 검증 — LPS 부하테스트 등 비상품 코드는 조용히 스킵 parsed = [] - for code, outcome, final_lowest, final_source, created_at in rows: + for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, created_at in rows: try: - parsed.append((uuid.UUID(code), outcome, final_lowest, final_source, created_at)) + iid = uuid.UUID(code) except (ValueError, AttributeError, TypeError): results["skipped_not_uuid"] += 1 + continue + # 출처(찾은 상품명·링크) — 최종 최저가를 낸 소스의 것을 싣는다(근거 검증용) + src_name, src_url = { + "naver": (nv_name, nv_url), + "coupang": (cp_name, cp_url), + }.get((final_source or "").lower(), (None, None)) + parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, created_at)) err, existing = await DB_SESSION_MNG.execute_lambda( DBType.MAIN.value, DBWRType.DB_READ.value, @@ -143,7 +150,7 @@ class LpsSyncService: return results history_rows, latest_found = [], {} # latest_found: item_id → (created_at, price) - for item_id, outcome, final_lowest, final_source, created_at in parsed: + for item_id, outcome, final_lowest, final_source, src_name, src_url, created_at in parsed: if item_id not in existing: results["skipped_unknown_item"] += 1 continue @@ -154,6 +161,8 @@ class LpsSyncService: website=LowestPriceWebsite.from_source(final_source).value, success_yn=found, fail_reason=None if found else (outcome or "unknown")[:100], + lp_name=(src_name or None) and src_name[:300], + lp_url=src_url or None, crawl_end_time=created_at, # 워터마크 기준값 — price_history.created_at 그대로 보존 )) results["found" if found else "not_found"] += 1 diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index b752a35..05fa5ff 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -81,7 +81,9 @@ export * from './listUsersParams'; export * from './lowestPriceEntry'; export * from './lowestPriceEntryCrawlEndTime'; export * from './lowestPriceEntryFailReason'; +export * from './lowestPriceEntryLpName'; export * from './lowestPriceEntryLpPrice'; +export * from './lowestPriceEntryLpUrl'; export * from './notificationData'; export * from './notificationDataCreatedAt'; export * from './notificationDataData'; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntry.ts b/negodata/front/src/api/generated/model/lowestPriceEntry.ts index 572a880..54d102b 100644 --- a/negodata/front/src/api/generated/model/lowestPriceEntry.ts +++ b/negodata/front/src/api/generated/model/lowestPriceEntry.ts @@ -6,6 +6,8 @@ */ import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice'; import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason'; +import type { LowestPriceEntryLpName } from './lowestPriceEntryLpName'; +import type { LowestPriceEntryLpUrl } from './lowestPriceEntryLpUrl'; import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime'; /** @@ -16,5 +18,7 @@ export interface LowestPriceEntry { website?: number; success_yn?: boolean; fail_reason?: LowestPriceEntryFailReason; + lp_name?: LowestPriceEntryLpName; + lp_url?: LowestPriceEntryLpUrl; crawl_end_time?: LowestPriceEntryCrawlEndTime; } diff --git a/negodata/front/src/api/generated/model/lowestPriceEntryLpName.ts b/negodata/front/src/api/generated/model/lowestPriceEntryLpName.ts new file mode 100644 index 0000000..7d7cb19 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntryLpName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type LowestPriceEntryLpName = string | null; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntryLpUrl.ts b/negodata/front/src/api/generated/model/lowestPriceEntryLpUrl.ts new file mode 100644 index 0000000..98402ed --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntryLpUrl.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type LowestPriceEntryLpUrl = string | null; diff --git a/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx new file mode 100644 index 0000000..465a4ae --- /dev/null +++ b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx @@ -0,0 +1,191 @@ +import { ExternalLink, Loader2, TrendingDown } from 'lucide-react'; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart'; +import { Badge } from '@/components/ui/badge'; +import { Sheet } from '@/components/ui/sheet'; +import { Typography } from '@/components/ui/typography'; +import { useGetLowestPrice } from '@/api/generated/item/item'; +import type { LowestPriceEntry } from '@/api/generated/model'; +import type { Product } from '../types'; + +type LowestPriceHistorySheetProps = { + product: Product; + onClose: () => void; +}; + +// LowestPriceWebsite 코드(백엔드 common/enums.py) → 표시 라벨 +const WEBSITE_LABEL: Record = { + 1: '네이버', + 2: '쿠팡', + 3: 'G마켓', + 4: '옥션', + 5: '11번가', + 99: '기타', +}; +const websiteLabel = (code?: number) => WEBSITE_LABEL[code ?? 99] ?? '기타'; + +// 단일 시리즈(인터넷 최저가) — 가격 UI 컨벤션인 rose 를 라이트/다크 쌍으로(통계 팔레트와 동일 문법). +const chartConfig = { + price: { label: '인터넷 최저가', theme: { light: '#f43f5e', dark: '#fb7185' } }, +} satisfies ChartConfig; + +const won = (n: number) => `₩${Math.round(n).toLocaleString()}`; +const timeLabel = (iso: string) => + new Date(iso).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + +// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력. +// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다. +export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistorySheetProps) { + // GET 이 서버에서 lps_db 증분 동기화를 겸하므로, 열 때마다 최신 상태가 온다. + const { data, isLoading } = useGetLowestPrice(product.item_id); + + const entries = data?.results ?? []; + const successes = entries.filter((e): e is LowestPriceEntry & { lp_price: number } => !!e.success_yn && e.lp_price != null); + const latest = successes[0]; // API 가 최신순으로 준다 + const representative = data?.lowest_price ?? product.internet_lowest_price ?? latest?.lp_price ?? null; + + // 그래프는 시간 오름차순(성공 수집만). 점 1개는 추이가 아니므로 2건부터 그린다. + const chartData = [...successes] + .reverse() + .map((e) => ({ t: e.crawl_end_time ?? '', price: e.lp_price })); + + return ( + +
+ {/* 상품 + 대표값 */} +
+ {product.name} +
+ + {representative != null ? won(Number(representative)) : '수집 전'} + + {product.price != null && representative != null && ( + + 상품 단가 {won(product.price)} 대비{' '} + + {`${Number(representative) - product.price > 0 ? '+' : Number(representative) - product.price < 0 ? '-' : ''}${won(Math.abs(Number(representative) - product.price))}`} + + + )} +
+
+ + {/* 출처 — 최신 성공 수집의 사이트/상품명/링크 */} + {latest && ( +
+
+ {websiteLabel(latest.website)} + + {latest.crawl_end_time ? timeLabel(latest.crawl_end_time) : ''} 수집 + +
+ {latest.lp_name ? ( + {latest.lp_name} + ) : ( + 출처 상세 미수집(이전 버전 수집분) + )} + {latest.lp_url && ( + + 판매 페이지에서 확인 + + )} +
+ )} + + {/* 가격 추이 — 성공 수집 2건부터 */} + {chartData.length >= 2 && ( +
+ + 가격 추이 ({chartData.length}회 수집) + + + + + + Number(v).toLocaleString()} + fontSize={10} + /> + } /> + + + +
+ )} + + {/* 수집 이력 */} +
+ + 최근 수집 이력 + + {isLoading ? ( +
+ + 이력을 불러오는 중... +
+ ) : entries.length === 0 ? ( +
+ + + 수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요. + +
+ ) : ( +
    + {entries.map((e, i) => ( +
  • +
    + {websiteLabel(e.website)} + + {e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'} + +
    +
    + {e.success_yn && e.lp_price != null ? ( + {won(e.lp_price)} + ) : ( + 미발견 + )} + {e.lp_url && ( + + + + )} +
    +
  • + ))} +
+ )} +
+
+
+ ); +} + +// 툴팁 — 시각 + 가격(텍스트 토큰, 시리즈색은 마크에만) +function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
+ {timeLabel(p.t)} + {won(p.price)} +
+ ); +} diff --git a/negodata/front/src/features/products/components/ProductTable.tsx b/negodata/front/src/features/products/components/ProductTable.tsx index b287add..12018bb 100644 --- a/negodata/front/src/features/products/components/ProductTable.tsx +++ b/negodata/front/src/features/products/components/ProductTable.tsx @@ -9,6 +9,7 @@ type ProductTableProps = { selectedIds: string[]; onSelectionChange: (ids: string[]) => void; onRowClick: (prod: Product) => void; + onLowestPriceClick: (prod: Product) => void; // 인터넷 최저가 셀 클릭 → 출처·이력 시트 page: number; totalPages: number; totalCount: number; @@ -21,6 +22,7 @@ export function ProductTable({ selectedIds, onSelectionChange, onRowClick, + onLowestPriceClick, page, totalPages, totalCount, @@ -91,7 +93,20 @@ export function ProductTable({ header: '인터넷 최저가', align: 'right', cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400', - cell: (prod) => (prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'), + // 클릭 → 출처(사이트·링크)·가격 추이 시트. 행 클릭(수정 시트)과 겹치지 않게 전파 차단. + cell: (prod) => ( + + ), }, { header: '작성자', diff --git a/negodata/front/src/pages/products.tsx b/negodata/front/src/pages/products.tsx index 705ef0f..bf85df2 100644 --- a/negodata/front/src/pages/products.tsx +++ b/negodata/front/src/pages/products.tsx @@ -15,6 +15,7 @@ import type { ListItemsParams } from '@/api/generated/model/listItemsParams'; import { ProductTable } from '@/features/products/components/ProductTable'; import { ProductFormSheet } from '@/features/products/components/ProductFormSheet'; import { PriceUpdateModal } from '@/features/products/components/PriceUpdateModal'; +import { LowestPriceHistorySheet } from '@/features/products/components/LowestPriceHistorySheet'; import { ExcelUploadModal, downloadProductTemplate } from '@/features/products/components/ExcelUploadModal'; import { type Product } from '@/features/products/types'; @@ -47,13 +48,16 @@ export default function ProductsPage() { const [selectedIds, setSelectedIds] = useState([]); // 딥링크·뒤로가기·새로고침 지원 - const overlay = useOverlayRouter(['new', 'detail', 'modal']); + const overlay = useOverlayRouter(['new', 'detail', 'modal', 'lowest']); const editId = overlay.get('detail'); const modal = overlay.get('modal'); // 'price' | 'excel' | null const editing = editId ? allProducts.find((p) => p.item_id === editId) ?? null : null; const formMode: 'create' | 'edit' = editId ? 'edit' : 'create'; const isFormOpen = overlay.has('new') || !!editing; + const lowestId = overlay.get('lowest'); + const lowestProduct = products.find((prod) => prod.item_id === lowestId) ?? null; + const openCreate = () => overlay.open('new'); const openEdit = (prod: Product) => overlay.open('detail', prod.item_id); @@ -138,6 +142,7 @@ export default function ProductsPage() { selectedIds={selectedIds} onSelectionChange={setSelectedIds} onRowClick={openEdit} + onLowestPriceClick={(prod) => overlay.open('lowest', prod.item_id)} page={list.page} totalPages={totalPages} totalCount={total} @@ -171,6 +176,10 @@ export default function ProductsPage() { /> )} + {lowestProduct && ( + + )} + {modal === 'excel' && (