feat(negodata/front): PriceUpdateModal 데모 → LPS 실연동

- 가짜 setTimeout 애니메이션 제거 — 상품별 POST lowest-price(큐 접수,
  duplicated 는 결과 대기 합류) 후 GET 5초 폴링(서버가 조회 시 lps_db
  증분 동기화를 겸하므로 폴링이 곧 수집)
- 요청 시각 이후 crawl_end_time 이력만 이번 결과로 인정, 완료/미발견
  로그 구분(emerald/amber), 5분 대기초과분은 주기 동기화 자동반영 안내
- 종료 시 /v1/item/list invalidate → 테이블 인터넷 최저가 컬럼 갱신
- 닫기 = 폴링만 중단(서버 검색은 계속됨을 UI 에 명시)
- orval 재생성: lowest-price 응답 타입화(LowestPriceEntry·lowest_price)

검증: tsc 통과, vite build 통과 (API 흐름 자체는 ③ e2e 로 검증됨)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-10 16:32:38 +09:00
parent 28b4a9e797
commit 3171311746
9 changed files with 175 additions and 49 deletions

View File

@ -644,7 +644,8 @@ export const useDeleteItem = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 최저가 수집 요청(스텁)
* 상품 1건을 LPS 에 즉시 검색 요청(수동 트리거, manual 우선순위). 결과는 GET lowest-price 폴링.
* @summary 최저가 수집 요청
*/
export const triggerLowestPrice = (
itemId: string,
@ -690,7 +691,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
export type TriggerLowestPriceMutationError = void | HTTPValidationError
/**
* @summary 최저가 수집 요청(스텁)
* @summary 최저가 수집 요청
*/
export const useTriggerLowestPrice = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
@ -706,7 +707,9 @@ export const useTriggerLowestPrice = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 최저가 수집 결과(스텁)
* 대표 최저가(items.internet_lowest_price) + 최근 수집 이력(최신순).
조회 전에 lps_db 증분 동기화를 한 번 수행해 5분 크론을 기다리지 않는다(멱등·저비용).
* @summary 최저가 수집 결과
*/
export const getLowestPrice = (
itemId: string,
@ -777,7 +780,7 @@ export function useGetLowestPrice<TData = Awaited<ReturnType<typeof getLowestPri
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 최저가 수집 결과(스텁)
* @summary 최저가 수집 결과
*/
export function useGetLowestPrice<TData = Awaited<ReturnType<typeof getLowestPrice>>, TError = void | HTTPValidationError>(

View File

@ -78,6 +78,10 @@ export * from './listNotificationsParams';
export * from './listQuotationsParams';
export * from './listSuppliersParams';
export * from './listUsersParams';
export * from './lowestPriceEntry';
export * from './lowestPriceEntryCrawlEndTime';
export * from './lowestPriceEntryFailReason';
export * from './lowestPriceEntryLpPrice';
export * from './notificationData';
export * from './notificationDataCreatedAt';
export * from './notificationDataData';
@ -278,6 +282,7 @@ export * from './resItemSupplyTypeListMsg';
export * from './resLogin';
export * from './resLoginMsg';
export * from './resLowestPriceResult';
export * from './resLowestPriceResultLowestPrice';
export * from './resLowestPriceResultMsg';
export * from './resLowestPriceTrigger';
export * from './resLowestPriceTriggerMsg';

View File

@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice';
import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason';
import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime';
/**
* 최저가 수집 이력 1건(partner.item_internet_lowest_prices).
*/
export interface LowestPriceEntry {
lp_price?: LowestPriceEntryLpPrice;
website?: number;
success_yn?: boolean;
fail_reason?: LowestPriceEntryFailReason;
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 LowestPriceEntryCrawlEndTime = 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 LowestPriceEntryFailReason = 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 LowestPriceEntryLpPrice = number | null;

View File

@ -6,11 +6,14 @@
*/
import type { ErrorInfo } from './errorInfo';
import type { ResLowestPriceResultMsg } from './resLowestPriceResultMsg';
import type { ResLowestPriceResultLowestPrice } from './resLowestPriceResultLowestPrice';
import type { LowestPriceEntry } from './lowestPriceEntry';
export interface ResLowestPriceResult {
result?: ErrorInfo;
msg?: ResLowestPriceResultMsg;
item_id?: string;
results?: unknown[];
lowest_price?: ResLowestPriceResultLowestPrice;
results?: LowestPriceEntry[];
message?: string;
}

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 ResLowestPriceResultLowestPrice = number | null;

View File

@ -1,10 +1,12 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { useScrollLock } from '@/lib/useScrollLock';
import { triggerLowestPrice, getLowestPrice } from '@/api/generated/item/item';
import type { Product } from '../types';
type PriceUpdateModalProps = {
@ -15,62 +17,113 @@ type PriceUpdateModalProps = {
onClose: () => void;
};
// 인터넷 최저가 실시간 수집 데모 모달. 크롤링 진행 state는 이 컴포넌트가 소유한다.
// NOTE: 서버 미연동 — 진행 애니메이션/로그만 데모.
const POLL_INTERVAL_MS = 5_000; // GET lowest-price 폴링 간격(서버가 조회 시 lps_db 증분 동기화를 겸함)
const POLL_TIMEOUT_MS = 300_000; // 상품당 수십 초 × 순차 처리 감안한 전체 상한(5분)
// 인터넷 최저가 실시간 수집 모달 — LPS 연동.
// 흐름: 선택 상품마다 POST(수집 요청, 큐 접수) → GET 폴링(요청 시각 이후의 수집 이력이 생기면 완료).
// 폴링이 시간을 초과해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다.
export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const queryClient = useQueryClient();
const [isCrawling, setIsCrawling] = useState(false);
const [crawlingProgress, setCrawlingProgress] = useState(0);
const [crawlerLogs, setCrawlerLogs] = useState<string[]>([]);
const cancelledRef = useRef(false); // 닫기 시 폴링 루프 중단(서버 검색은 계속)
if (!open) return null;
const handleStartCrawling = () => {
const pushLog = (line: string) => setCrawlerLogs((prev) => [...prev, line]);
const nameOf = (id: string) => products.find((p) => p.item_id === id)?.name || id;
const handleStartCrawling = async () => {
if (selectedIds.length === 0) {
toast.error('업데이트할 상품을 1개 이상 선택해 주십시오.');
return;
}
cancelledRef.current = false;
setIsCrawling(true);
setCrawlingProgress(10);
setCrawlingProgress(5);
setCrawlerLogs([
'[System] 실시간 최저가 수집용 웹 크롤러 엔진 가동...',
'[System] API Endpoint: https://api.commerce-crawler.co.kr/v2/itemsync',
`[Target] 선택된 ${selectedIds.length}개 상품의 고유 코드 및 품목 매핑 중...`,
'[System] 인터넷 최저가 검색(LPS) 요청 접수 중...',
`[Target] 선택된 ${selectedIds.length}개 상품`,
]);
const startedAt = new Date().toISOString(); // 이 시각 이후의 수집 이력만 "이번 요청 결과"로 인정
// Fast simulation timers
setTimeout(() => {
setCrawlingProgress(35);
setCrawlerLogs((prev) => [
...prev,
...selectedIds.map((id) => {
const p = products.find((prod) => prod.item_id === id);
return `[크롤링] '${p?.name || id}' 인터넷 최저가 비교 검색 수집 진행`;
}),
`[Search] 외부 커머스 유통 플랫폼(Coupang, Gmarket, Danawa) 지표 추출 시작...`,
]);
}, 500);
// 1) 상품별 수집 요청(POST) — 실패/중복은 로그로 구분하고 계속 진행
const pending = new Set<string>();
for (const id of selectedIds) {
try {
const r = await triggerLowestPrice(id);
if (r.status === 'queued') {
pending.add(id);
pushLog(`[접수] '${nameOf(id)}' 검색 큐 등록`);
} else if (r.status === 'duplicated') {
pending.add(id); // 이미 진행 중 → 결과는 폴링으로 같이 받는다
pushLog(`[진행중] '${nameOf(id)}' 이미 검색이 진행 중 — 결과 대기에 합류`);
} else {
pushLog(`[불가] '${nameOf(id)}' ${r.message || '검색 서비스 연결 불가'}`);
}
} catch {
pushLog(`[오류] '${nameOf(id)}' 요청 실패`);
}
}
setTimeout(() => {
setCrawlingProgress(72);
setCrawlerLogs((prev) => [
...prev,
`[OCR] 수집 완료된 실시간 HTML/DOM 가격 노드 데이터 분석 중...`,
`[Sync] 정제 단가 적용 (부가세 보정 및 할인 쿠폰 혜택가 산정)`,
]);
}, 1100);
if (pending.size === 0) {
showToast('접수된 상품이 없습니다. 검색 서비스 상태를 확인해 주세요.', 'error');
setIsCrawling(false);
setCrawlingProgress(0);
return;
}
setCrawlingProgress(15);
pushLog(`[Search] ${pending.size}개 상품 크롤링 진행 — 네이버·쿠팡 수집 및 AI 동일상품 판정...`);
setTimeout(() => {
// 2) 폴링 — GET 이 서버측 증분 동기화를 겸함. 요청 시각 이후 이력이 생긴 상품부터 완료 처리.
const total = pending.size;
let found = 0;
let notFound = 0;
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (pending.size > 0 && Date.now() < deadline && !cancelledRef.current) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
for (const id of [...pending]) {
try {
const r = await getLowestPrice(id);
const fresh = (r.results ?? []).find((e) => (e.crawl_end_time ?? '') >= startedAt);
if (!fresh) continue;
pending.delete(id);
if (fresh.success_yn && fresh.lp_price != null) {
found += 1;
pushLog(`[완료] '${nameOf(id)}' 최저가 ${fresh.lp_price.toLocaleString()}원 반영`);
} else {
notFound += 1;
pushLog(`[미발견] '${nameOf(id)}' 동일 상품을 찾지 못함(기존 값 유지)`);
}
} catch {
/* 일시 오류는 다음 tick 재시도 */
}
setCrawlingProgress(15 + Math.round(((total - pending.size) / total) * 85));
}
}
// 3) 마무리 — 목록 갱신(테이블 인터넷 최저가 컬럼 반영) 후 종료
const timedOut = pending.size > 0 && !cancelledRef.current;
if (timedOut) {
pushLog(`[대기초과] ${pending.size}개 상품은 아직 검색 중 — 완료되면 주기 동기화로 자동 반영됩니다.`);
}
await queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] });
setCrawlingProgress(100);
// NOTE: 인터넷 최저가 동기화는 전용 엔드포인트 연동 예정. 현재는 진행 애니메이션만 데모(서버 미반영).
showToast(`선택한 ${selectedIds.length}개 상품의 인터넷 최저가 동기화는 준비 중입니다(데모).`, 'info');
if (!cancelledRef.current) {
showToast(
`최저가 수집 완료: 반영 ${found} · 미발견 ${notFound}${timedOut ? ` · 검색중 ${pending.size}(자동 반영 예정)` : ''}`,
found > 0 ? 'success' : 'info',
);
}
onDone();
setIsCrawling(false);
onClose();
setCrawlingProgress(0);
setCrawlerLogs([]);
}, 2000);
};
return (
@ -81,7 +134,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2 text-foreground">
<Globe className="text-rose-500 animate-pulse" size={18} />
<Typography variant="h3">인터넷 최저가 API 실시간 수집 및 동기화</Typography>
<Typography variant="h3">인터넷 최저가 실시간 수집 및 동기화</Typography>
</div>
{!isCrawling && (
<button
@ -99,7 +152,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<Typography variant="small" className="text-[11.5px] leading-relaxed text-left flex items-start gap-2">
<AlertCircle size={15} className="text-rose-500 shrink-0 mt-0.5" />
<span>
총 <span className="font-bold text-rose-600 dark:text-rose-400 underline decoration-rose-500/50 decoration-2">{selectedIds.length}개</span> 품목에 대하여 네이버 오픈마켓, 다나와, 쿠팡 및 B2B 공공 유통망의 최저가 데이터를 수집 및 비교 분석하여 최신 최저가(minPrice) 필드로 다이렉트 동기화합니다.
총 <span className="font-bold text-rose-600 dark:text-rose-400 underline decoration-rose-500/50 decoration-2">{selectedIds.length}개</span> 품목에 대하여 네이버 쇼핑·쿠팡의 최저가를 수집하고 AI 가 동일 상품을 판정하여 인터넷 최저가 필드로 동기화합니다. 상품당 수십 초가 소요될 수 있습니다.
</span>
</Typography>
</div>
@ -116,7 +169,9 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<div className="flex items-center gap-1.5 font-mono">
<span className="text-muted-foreground">{(prod.price ?? 0).toLocaleString()}원</span>
<span className="text-muted-foreground">→</span>
<span className="text-rose-500 font-bold">자동 가공 수집</span>
<span className="text-rose-500 font-bold">
{prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}원 갱신` : '신규 수집'}
</span>
</div>
</div>
) : null;
@ -130,7 +185,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<div className="flex justify-between text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5 font-bold text-foreground">
<Loader2 size={13} className="animate-spin text-rose-500" />
실시간 크롤링 엔진 데이터 동기화 동작 중...
최저가 검색·수집 진행 중... (닫아도 검색은 계속됩니다)
</span>
<span className="font-bold text-rose-500">{crawlingProgress}%</span>
</div>
@ -148,7 +203,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
{crawlerLogs.map((log, idx) => (
<div key={idx} className="flex items-start gap-1">
<span className="text-zinc-600 select-none shrink-0">&gt;</span>
<span className={`text-left break-all ${log.includes('[System]') ? 'text-zinc-400 font-bold' : log.includes('[OCR]') ? 'text-blue-400' : 'text-emerald-400'}`}>
<span className={`text-left break-all ${log.includes('[System]') || log.includes('[Search]') ? 'text-zinc-400 font-bold' : log.includes('[완료]') ? 'text-emerald-400' : log.includes('[미발견]') || log.includes('[불가]') || log.includes('[오류]') || log.includes('[대기초과]') ? 'text-amber-400' : 'text-rose-300'}`}>
{log}
</span>
</div>
@ -159,7 +214,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<div className="text-center py-4 border border-dashed border-border rounded bg-muted/10 space-y-2">
<Cpu size={24} className="mx-auto text-muted-foreground/60" />
<Typography variant="muted" className="text-[11px]">
"최저가 업데이트 시작" 버튼을 누르시면 실시간 스크래핑 엔진이 시작됩니다.
"인터넷 최저가 가동" 버튼을 누르시면 실시간 수집이 시작됩니다.
</Typography>
</div>
)}
@ -167,7 +222,15 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
{/* Modal Footer */}
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<Button type="button" variant="outline" size="sm" disabled={isCrawling} onClick={onClose}>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
cancelledRef.current = true; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영)
onClose();
}}
>
닫기
</Button>
<Button type="button" variant="destructive" size="sm" disabled={isCrawling} onClick={handleStartCrawling}>