feat(negodata): 인터넷 최저가 출처·링크·가격 추이 UI — 검증 가능한 협상 근거

인터넷 최저가는 견적 목표가·협상 카드(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 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-10 17:10:00 +09:00
parent 1cce752b47
commit 4a20194dc8
13 changed files with 272 additions and 6 deletions

View File

@ -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, 워터마크 기준)

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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';

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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<number, string> = {
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 (
<Sheet open title="인터넷 최저가 상세" onClose={onClose}>
<div className="space-y-5 text-sm">
{/* 상품 + 대표값 */}
<div>
<Typography variant="muted" className="text-[11px]">{product.name}</Typography>
<div className="mt-1 flex items-baseline gap-3">
<span className="font-mono text-2xl font-bold text-rose-600 dark:text-rose-400">
{representative != null ? won(Number(representative)) : '수집 전'}
</span>
{product.price != null && representative != null && (
<Typography variant="muted" className="text-[11px]">
{won(product.price)} {' '}
<span className={Number(representative) <= product.price ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'}>
{`${Number(representative) - product.price > 0 ? '+' : Number(representative) - product.price < 0 ? '-' : ''}${won(Math.abs(Number(representative) - product.price))}`}
</span>
</Typography>
)}
</div>
</div>
{/* 출처 — 최신 성공 수집의 사이트/상품명/링크 */}
{latest && (
<div className="rounded-md border border-border bg-muted/20 p-3 space-y-1.5">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-[10px]">{websiteLabel(latest.website)}</Badge>
<Typography variant="muted" className="text-[10px]">
{latest.crawl_end_time ? timeLabel(latest.crawl_end_time) : ''}
</Typography>
</div>
{latest.lp_name ? (
<Typography as="p" variant="small" className="text-[12px] leading-snug text-foreground">{latest.lp_name}</Typography>
) : (
<Typography as="p" variant="muted" className="text-[11px]"> ( )</Typography>
)}
{latest.lp_url && (
<a
href={latest.lp_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-medium text-rose-600 dark:text-rose-400 hover:underline"
>
<ExternalLink size={11} />
</a>
)}
</div>
)}
{/* 가격 추이 — 성공 수집 2건부터 */}
{chartData.length >= 2 && (
<div>
<Typography variant="small" className="mb-2 block text-[11px] font-bold text-muted-foreground">
({chartData.length} )
</Typography>
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
<LineChart data={chartData} margin={{ top: 8, right: 12, left: 4, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="t" tickLine={false} axisLine={false} tickMargin={8} tickFormatter={timeLabel} fontSize={10} />
<YAxis
tickLine={false}
axisLine={false}
width={52}
domain={['dataMin', 'dataMax']}
tickFormatter={(v) => Number(v).toLocaleString()}
fontSize={10}
/>
<ChartTooltip cursor={{ strokeDasharray: '3 3' }} content={<PriceTooltip />} />
<Line
dataKey="price"
type="monotone"
stroke="var(--color-price)"
strokeWidth={2}
dot={{ r: 3, fill: 'var(--color-price)', strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ChartContainer>
</div>
)}
{/* 수집 이력 */}
<div>
<Typography variant="small" className="mb-2 block text-[11px] font-bold text-muted-foreground">
</Typography>
{isLoading ? (
<div className="flex items-center gap-2 py-6 justify-center text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
<span className="text-[11px]"> ...</span>
</div>
) : entries.length === 0 ? (
<div className="py-6 text-center border border-dashed border-border rounded bg-muted/10 space-y-1">
<TrendingDown size={18} className="mx-auto text-muted-foreground/60" />
<Typography as="p" variant="muted" className="text-[11px]">
"최저가 업데이트하기" .
</Typography>
</div>
) : (
<ul className="divide-y divide-border/60 rounded-md border border-border">
{entries.map((e, i) => (
<li key={i} className="flex items-center justify-between gap-2 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="secondary" className="shrink-0 text-[10px]">{websiteLabel(e.website)}</Badge>
<span className="truncate text-[11px] text-muted-foreground">
{e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'}
</span>
</div>
<div className="flex items-center gap-2 shrink-0 font-mono text-[12px]">
{e.success_yn && e.lp_price != null ? (
<span className="font-semibold text-rose-600 dark:text-rose-400">{won(e.lp_price)}</span>
) : (
<span className="text-amber-600 dark:text-amber-400"></span>
)}
{e.lp_url && (
<a href={e.lp_url} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground">
<ExternalLink size={12} />
</a>
)}
</div>
</li>
))}
</ul>
)}
</div>
</div>
</Sheet>
);
}
// 툴팁 — 시각 + 가격(텍스트 토큰, 시리즈색은 마크에만)
function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">{timeLabel(p.t)}</Typography>
<Typography as="p" variant="caption" className="font-mono text-foreground">{won(p.price)}</Typography>
</div>
);
}

View File

@ -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) => (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onLowestPriceClick(prod);
}}
className="cursor-pointer rounded px-1 -mx-1 hover:bg-rose-500/10 hover:underline decoration-rose-400/60 underline-offset-2"
title="출처·가격 추이 보기"
>
{prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}` : '-'}
</button>
),
},
{
header: '작성자',

View File

@ -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<string[]>([]);
// 딥링크·뒤로가기·새로고침 지원
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 && (
<LowestPriceHistorySheet product={lowestProduct} onClose={overlay.close} />
)}
{modal === 'excel' && (
<ExcelUploadModal
open

View File

@ -0,0 +1,6 @@
-- 2026-07-10 · 최저가 수집 이력에 출처(찾은 상품명·링크) 추가
-- 인터넷 최저가는 견적 목표가·협상 카드(시장가 근거)의 근거값이므로, MD 가 클릭 한 번으로
-- 검증할 수 있어야 한다. LPS(price_history)의 naver/coupang name·url 을 동기화 때 함께 실어온다.
-- 기존 행은 NULL(출처 미수집 — 이 컬럼 추가 이전 수집분)로 자연 공존.
ALTER TABLE partner.item_internet_lowest_prices ADD COLUMN IF NOT EXISTS lp_name VARCHAR(300) NULL; -- 찾은 상품명(판매 페이지 기준)
ALTER TABLE partner.item_internet_lowest_prices ADD COLUMN IF NOT EXISTS lp_url TEXT NULL; -- 찾은 판매 페이지 링크

View File

@ -180,7 +180,9 @@ CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices (
success_yn BOOLEAN NOT NULL, -- 크롤링 성공 여부
fail_reason VARCHAR(100) NULL, -- 실패 사유
ai_model SMALLINT NULL, -- 사용한 AI 모델 (코드, 앱 enum 매핑)
crawl_duration_ms INTEGER NULL, -- 크롤링 소요 시간(ms)
crawl_duration_ms INTEGER NULL,
lp_name VARCHAR(300) NULL, -- [2026-07-10] 찾은 상품명(판매 페이지 기준)
lp_url TEXT NULL, -- [2026-07-10] 찾은 판매 페이지 링크(근거 검증용) -- 크롤링 소요 시간(ms)
crawl_end_time TIMESTAMPTZ NOT NULL, -- 크롤링 종료 시각
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)