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:
parent
1cce752b47
commit
4a20194dc8
@ -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, 워터마크 기준)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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: '작성자',
|
||||
|
||||
@ -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
|
||||
|
||||
6
postgres-init/alters/2026-07-10-iilp-source-link.sql
Normal file
6
postgres-init/alters/2026-07-10-iilp-source-link.sql
Normal 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; -- 찾은 판매 페이지 링크
|
||||
@ -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, 앱에서 갱신)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user