diff options
| -rw-r--r-- | lib/rfq-last/shared/rfq-items-dialog.tsx | 30 | ||||
| -rw-r--r-- | lib/rfq-last/table/create-general-rfq-dialog.tsx | 44 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-columns.tsx | 292 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/vendor-detail-dialog.tsx | 120 | ||||
| -rw-r--r-- | lib/shi-api/shi-api-utils.ts | 53 |
5 files changed, 224 insertions, 315 deletions
diff --git a/lib/rfq-last/shared/rfq-items-dialog.tsx b/lib/rfq-last/shared/rfq-items-dialog.tsx index afed9576..6a40dcfa 100644 --- a/lib/rfq-last/shared/rfq-items-dialog.tsx +++ b/lib/rfq-last/shared/rfq-items-dialog.tsx @@ -272,6 +272,8 @@ export function RfqItemsDialog({ <TableHead className="w-[60px]">아이템</TableHead> <TableHead className="w-[120px]">자재코드</TableHead> <TableHead>자재명</TableHead> + <TableHead className="w-[140px]">자재그룹</TableHead> + <TableHead className="w-[120px]">사이즈</TableHead> <TableHead className="w-[200px]">Specification</TableHead> <TableHead className="w-[80px]">수량</TableHead> <TableHead className="w-[60px]">수량단위</TableHead> @@ -280,7 +282,9 @@ export function RfqItemsDialog({ <TableHead className="w-[110px]">PR 발행일</TableHead> <TableHead className="w-[100px]">PR납기 요청일</TableHead> <TableHead className="w-[100px]">PR번호</TableHead> + <TableHead className="w-[100px]">PR 아이템 번호</TableHead> <TableHead className="w-[120px]">사양/설계문서</TableHead> + <TableHead className="w-[100px]">프로젝트</TableHead> </TableRow> </TableHeader> <TableBody> @@ -298,6 +302,10 @@ export function RfqItemsDialog({ <TableCell><Skeleton className="h-8 w-full" /></TableCell> <TableCell><Skeleton className="h-8 w-full" /></TableCell> <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> </TableRow> ))} </TableBody> @@ -314,6 +322,8 @@ export function RfqItemsDialog({ <TableHead className="w-[60px]">아이템</TableHead> <TableHead className="w-[120px]">자재코드</TableHead> <TableHead>자재명</TableHead> + <TableHead className="w-[140px]">자재그룹</TableHead> + <TableHead className="w-[120px]">사이즈</TableHead> <TableHead className="w-[200px]">Specification</TableHead> <TableHead className="w-[80px]">수량</TableHead> <TableHead className="w-[60px]">수량단위</TableHead> @@ -355,19 +365,19 @@ export function RfqItemsDialog({ <span className="text-sm font-medium" title={item.materialDescription || ""}> {item.materialDescription || "-"} </span> - {item.materialCategory && ( - <span className="text-xs text-muted-foreground"> - {item.materialCategory} - </span> - )} - {item.size && ( - <span className="text-xs text-muted-foreground"> - 크기: {item.size} - </span> - )} </div> </TableCell> <TableCell> + <span className="text-sm text-muted-foreground"> + {item.materialCategory || "-"} + </span> + </TableCell> + <TableCell> + <span className="text-sm text-muted-foreground"> + {item.size || "-"} + </span> + </TableCell> + <TableCell> {item.specification?.trim() ? ( <TooltipProvider> <Tooltip> diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index b46249f0..6f752bd0 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { useForm, useFieldArray } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" -import { format } from "date-fns" +import { format, addDays } from "date-fns" import { CalendarIcon, Plus, Loader2, Trash2, PlusCircle } from "lucide-react" import { useSession } from "next-auth/react" @@ -103,25 +103,25 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp const form = useForm<CreateGeneralRfqFormValues>({ resolver: zodResolver(createGeneralRfqSchema), - defaultValues: { - rfqType: "", - rfqTitle: "", - dueDate: undefined, - picUserId: userId || undefined, - projectId: undefined, - remark: "", - items: [ - { - itemCode: "", - itemName: "", - materialCode: "", - materialName: "", - quantity: 1, - uom: "", - remark: "", - }, - ], - }, + defaultValues: { + rfqType: "", + rfqTitle: "", + dueDate: addDays(new Date(), 7), + picUserId: userId || undefined, + projectId: undefined, + remark: "", + items: [ + { + itemCode: "", + itemName: "", + materialCode: "", + materialName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }, }) const { fields, append, remove } = useFieldArray({ @@ -193,7 +193,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp form.reset({ rfqType: "", rfqTitle: "", - dueDate: undefined, + dueDate: addDays(new Date(), 7), picUserId: userId || undefined, projectId: undefined, remark: "", @@ -219,7 +219,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp form.reset({ rfqType: "", rfqTitle: "", - dueDate: undefined, + dueDate: addDays(new Date(), 7), picUserId: userId || undefined, projectId: undefined, remark: "", diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx index 58c45aa0..0d39e0d0 100644 --- a/lib/rfq-last/table/rfq-table-columns.tsx +++ b/lib/rfq-last/table/rfq-table-columns.tsx @@ -15,7 +15,7 @@ import { import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; import { RfqsLastView } from "@/db/schema"; import { DataTableRowAction } from "@/types/table"; -import { format, differenceInDays } from "date-fns"; +import { format, differenceInCalendarDays } from "date-fns"; import { ko } from "date-fns/locale"; import { useRouter } from "next/navigation"; import { RfqSealToggleCell } from "./rfq-seal-toggle-cell"; @@ -44,6 +44,53 @@ const getStatusBadgeVariant = (status: string) => { } }; +const renderDueDateCell = (date?: string | Date | null) => { + if (!date) return "-"; + + const now = new Date(); + const dueDate = new Date(date); + const daysLeft = differenceInCalendarDays(dueDate, now); + + let statusIcon; + let statusText; + let statusClass; + + if (daysLeft < 0) { + const daysOverdue = Math.abs(daysLeft); + statusIcon = <XCircle className="h-4 w-4" />; + statusText = `${daysOverdue}일 지남`; + statusClass = "text-red-600"; + } else if (daysLeft === 0) { + statusIcon = <AlertTriangle className="h-4 w-4" />; + statusText = "오늘 마감"; + statusClass = "text-orange-600"; + } else if (daysLeft <= 3) { + statusIcon = <AlertCircle className="h-4 w-4" />; + statusText = `${daysLeft}일 남음`; + statusClass = "text-amber-600"; + } else if (daysLeft <= 7) { + statusIcon = <Clock className="h-4 w-4" />; + statusText = `${daysLeft}일 남음`; + statusClass = "text-blue-600"; + } else { + statusIcon = <CheckCircle className="h-4 w-4" />; + statusText = `${daysLeft}일 남음`; + statusClass = "text-green-600"; + } + + return ( + <div className="flex flex-col gap-1"> + <span className="text-sm text-muted-foreground"> + {format(dueDate, "yyyy-MM-dd")} + </span> + <div className={`flex items-center gap-1 ${statusClass}`}> + {statusIcon} + <span className="text-xs font-medium">{statusText}</span> + </div> + </div> + ); +}; + export function getRfqColumns({ setRowAction, rfqCategory = "itb", @@ -125,7 +172,7 @@ export function getRfqColumns({ cell: ({ row, table }) => ( <RfqSealToggleCell rfqId={row.original.id} - isSealed={row.original.rfqSealedYn} + isSealed={!!row.original.rfqSealedYn} onUpdate={() => { // 테이블 데이터를 새로고침하는 로직 // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용 @@ -134,7 +181,7 @@ export function getRfqColumns({ }} /> ), - size: 80, + size: 120, }, // 구매담당자 @@ -258,59 +305,7 @@ export function getRfqColumns({ { accessorKey: "dueDate", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, - cell: ({ row }) => { - const date = row.original.dueDate; - if (!date) return "-"; - - const now = new Date(); - const dueDate = new Date(date); - const daysLeft = differenceInDays(dueDate, now); - - // 상태별 스타일과 아이콘 설정 - let statusIcon; - let statusText; - let statusClass; - - if (daysLeft < 0) { - // 마감일 지남 - const daysOverdue = Math.abs(daysLeft); - statusIcon = <XCircle className="h-4 w-4" />; - statusText = `${daysOverdue}일 지남`; - statusClass = "text-red-600"; - } else if (daysLeft === 0) { - // 오늘 마감 - statusIcon = <AlertTriangle className="h-4 w-4" />; - statusText = "오늘 마감"; - statusClass = "text-orange-600"; - } else if (daysLeft <= 3) { - // 3일 이내 마감 임박 - statusIcon = <AlertCircle className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-amber-600"; - } else if (daysLeft <= 7) { - // 일주일 이내 - statusIcon = <Clock className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-blue-600"; - } else { - // 여유 있음 - statusIcon = <CheckCircle className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-green-600"; - } - - return ( - <div className="flex flex-col gap-1"> - <span className="text-sm text-muted-foreground"> - {format(dueDate, "yyyy-MM-dd")} - </span> - <div className={`flex items-center gap-1 ${statusClass}`}> - {statusIcon} - <span className="text-xs font-medium">{statusText}</span> - </div> - </div> - ); - }, + cell: ({ row }) => renderDueDateCell(row.original.dueDate), size: 120, // 크기를 약간 늘림 }, @@ -463,7 +458,7 @@ export function getRfqColumns({ cell: ({ row, table }) => ( <RfqSealToggleCell rfqId={row.original.id} - isSealed={row.original.rfqSealedYn} + isSealed={!!row.original.rfqSealedYn} onUpdate={() => { // 테이블 데이터를 새로고침하는 로직 // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용 @@ -472,7 +467,7 @@ export function getRfqColumns({ }} /> ), - size: 80, + size: 120, }, // 구매담당자 @@ -628,59 +623,7 @@ export function getRfqColumns({ { accessorKey: "dueDate", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, - cell: ({ row }) => { - const date = row.original.dueDate; - if (!date) return "-"; - - const now = new Date(); - const dueDate = new Date(date); - const daysLeft = differenceInDays(dueDate, now); - - // 상태별 스타일과 아이콘 설정 - let statusIcon; - let statusText; - let statusClass; - - if (daysLeft < 0) { - // 마감일 지남 - const daysOverdue = Math.abs(daysLeft); - statusIcon = <XCircle className="h-4 w-4" />; - statusText = `${daysOverdue}일 지남`; - statusClass = "text-red-600"; - } else if (daysLeft === 0) { - // 오늘 마감 - statusIcon = <AlertTriangle className="h-4 w-4" />; - statusText = "오늘 마감"; - statusClass = "text-orange-600"; - } else if (daysLeft <= 3) { - // 3일 이내 마감 임박 - statusIcon = <AlertCircle className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-amber-600"; - } else if (daysLeft <= 7) { - // 일주일 이내 - statusIcon = <Clock className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-blue-600"; - } else { - // 여유 있음 - statusIcon = <CheckCircle className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-green-600"; - } - - return ( - <div className="flex flex-col gap-1"> - <span className="text-sm text-muted-foreground"> - {format(dueDate, "yyyy-MM-dd")} - </span> - <div className={`flex items-center gap-1 ${statusClass}`}> - {statusIcon} - <span className="text-xs font-medium">{statusText}</span> - </div> - </div> - ); - }, + cell: ({ row }) => renderDueDateCell(row.original.dueDate), size: 120, // 크기를 약간 늘림 }, @@ -978,54 +921,7 @@ export function getRfqColumns({ { accessorKey: "dueDate", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, - cell: ({ row }) => { - const date = row.original.dueDate; - if (!date) return "-"; - - const now = new Date(); - const dueDate = new Date(date); - const daysLeft = differenceInDays(dueDate, now); - - // 상태별 스타일과 아이콘 설정 - let statusIcon; - let statusText; - let statusClass; - - if (daysLeft < 0) { - const daysOverdue = Math.abs(daysLeft); - statusIcon = <XCircle className="h-4 w-4" />; - statusText = `${daysOverdue}일 지남`; - statusClass = "text-red-600"; - } else if (daysLeft === 0) { - statusIcon = <AlertTriangle className="h-4 w-4" />; - statusText = "오늘 마감"; - statusClass = "text-orange-600"; - } else if (daysLeft <= 3) { - statusIcon = <AlertCircle className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-amber-600"; - } else if (daysLeft <= 7) { - statusIcon = <Clock className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-blue-600"; - } else { - statusIcon = <CheckCircle className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-green-600"; - } - - return ( - <div className="flex flex-col gap-1"> - <span className="text-sm text-muted-foreground"> - {format(dueDate, "yyyy-MM-dd")} - </span> - <div className={`flex items-center gap-1 ${statusClass}`}> - {statusIcon} - <span className="text-xs font-medium">{statusText}</span> - </div> - </div> - ); - }, + cell: ({ row }) => renderDueDateCell(row.original.dueDate), size: 120, }, @@ -1166,7 +1062,7 @@ export function getRfqColumns({ cell: ({ row, table }) => ( <RfqSealToggleCell rfqId={row.original.id} - isSealed={row.original.rfqSealedYn} + isSealed={!!row.original.rfqSealedYn} onUpdate={() => { // 테이블 데이터를 새로고침하는 로직 // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용 @@ -1204,17 +1100,17 @@ export function getRfqColumns({ }, // 시리즈 - { - accessorKey: "series", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />, - cell: ({ row }) => { - const series = row.original.series; - if (!series) return "-"; - const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series; - return <Badge variant="outline">{label}</Badge>; - }, - size: 100, - }, + // { + // accessorKey: "series", + // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />, + // cell: ({ row }) => { + // const series = row.original.series; + // if (!series) return "-"; + // const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series; + // return <Badge variant="outline">{label}</Badge>; + // }, + // size: 100, + // }, // 선급 { @@ -1322,59 +1218,7 @@ export function getRfqColumns({ { accessorKey: "dueDate", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, - cell: ({ row }) => { - const date = row.original.dueDate; - if (!date) return "-"; - - const now = new Date(); - const dueDate = new Date(date); - const daysLeft = differenceInDays(dueDate, now); - - // 상태별 스타일과 아이콘 설정 - let statusIcon; - let statusText; - let statusClass; - - if (daysLeft < 0) { - // 마감일 지남 - const daysOverdue = Math.abs(daysLeft); - statusIcon = <XCircle className="h-4 w-4" />; - statusText = `${daysOverdue}일 지남`; - statusClass = "text-red-600"; - } else if (daysLeft === 0) { - // 오늘 마감 - statusIcon = <AlertTriangle className="h-4 w-4" />; - statusText = "오늘 마감"; - statusClass = "text-orange-600"; - } else if (daysLeft <= 3) { - // 3일 이내 마감 임박 - statusIcon = <AlertCircle className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-amber-600"; - } else if (daysLeft <= 7) { - // 일주일 이내 - statusIcon = <Clock className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-blue-600"; - } else { - // 여유 있음 - statusIcon = <CheckCircle className="h-4 w-4" />; - statusText = `${daysLeft}일 남음`; - statusClass = "text-green-600"; - } - - return ( - <div className="flex flex-col gap-1"> - <span className="text-sm text-muted-foreground"> - {format(dueDate, "yyyy-MM-dd")} - </span> - <div className={`flex items-center gap-1 ${statusClass}`}> - {statusIcon} - <span className="text-xs font-medium">{statusText}</span> - </div> - </div> - ); - }, + cell: ({ row }) => renderDueDateCell(row.original.dueDate), size: 120, // 크기를 약간 늘림 }, diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx index 9c112efa..cbe4f919 100644 --- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx +++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx @@ -55,6 +55,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { formatFileSize, quickDownload, smartFileAction } from "@/lib/file-download"; // Props 타입 정의 interface VendorResponseDetailDialogProps { @@ -183,10 +184,13 @@ export function VendorResponseDetailDialog({ <div className="flex-1 overflow-y-auto px-6 "> <Tabs defaultValue="overview" className="mb-2"> - <TabsList className="grid w-full grid-cols-3"> + <TabsList className="grid w-full grid-cols-4"> <TabsTrigger value="overview">개요</TabsTrigger> <TabsTrigger value="quotation">견적정보</TabsTrigger> <TabsTrigger value="items">품목상세</TabsTrigger> + <TabsTrigger value="documents"> + 제출 문서 + </TabsTrigger> </TabsList> {/* 개요 탭 */} @@ -773,60 +777,78 @@ export function VendorResponseDetailDialog({ )} </TabsContent> - {/* 첨부파일 탭 */} - {/* <TabsContent value="attachments" className="space-y-4"> - {attachments.length > 0 ? ( - <Card> - <CardHeader> - <CardTitle className="text-base">첨부파일</CardTitle> - <CardDescription> - 총 {attachments.length}개 파일 - </CardDescription> - </CardHeader> - <CardContent> - <div className="space-y-2"> - {attachments.map((file: any) => ( - <div - key={file.id} - className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent" - > - <div className="flex items-center gap-3"> - <Paperclip className="h-4 w-4 text-muted-foreground" /> - <div> - <p className="text-sm font-medium">{file.originalFileName}</p> - <p className="text-xs text-muted-foreground"> - {file.attachmentType} • {file.fileSize ? `${(file.fileSize / 1024).toFixed(2)} KB` : "크기 미상"} - {file.description && ` • ${file.description}`} + {/* 제출 문서 탭 */} + <TabsContent value="documents" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="text-base">제출 문서</CardTitle> + <CardDescription> + 총 {attachments.length}개 파일 + </CardDescription> + </CardHeader> + <CardContent> + {attachments.length > 0 ? ( + <div className="space-y-2 max-h-[52vh] overflow-y-auto pr-1"> + {attachments.map((file: any) => { + const fileLabel = file.originalFileName || file.fileName || "파일명 없음"; + const canOpen = !!file.filePath; + return ( + <div + key={`${file.id}-${file.filePath}-${file.originalFileName}`} + className="flex items-start justify-between gap-4 rounded-lg border p-3 hover:bg-accent/50" + > + <div className="space-y-1"> + <div className="flex items-center gap-2 flex-wrap"> + <Paperclip className="h-4 w-4 text-muted-foreground" /> + <Badge variant="secondary">{file.attachmentType || "문서"}</Badge> + <span className="font-medium break-all">{fileLabel}</span> + </div> + <p className="text-xs text-muted-foreground space-x-2 flex flex-wrap"> + {file.documentNo && <span>문서번호: {file.documentNo}</span>} + {file.description && <span>{file.description}</span>} + <span>{file.fileSize ? formatFileSize(file.fileSize) : "크기 정보 없음"}</span> + {file.uploadedAt && ( + <span> + 업로드: {format(new Date(file.uploadedAt), "yyyy-MM-dd HH:mm", { locale: ko })} + </span> + )} + {file.uploadedByName && <span>작성: {file.uploadedByName}</span>} </p> </div> + <div className="flex items-center gap-2"> + <Button + variant="ghost" + size="sm" + disabled={!canOpen} + onClick={() => smartFileAction(file.filePath, fileLabel)} + className="h-8" + > + <Eye className="h-4 w-4 mr-1" /> + 열기 + </Button> + <Button + variant="outline" + size="sm" + disabled={!canOpen} + onClick={() => quickDownload(file.filePath, fileLabel)} + className="h-8" + > + <Download className="h-4 w-4 mr-1" /> + 다운로드 + </Button> + </div> </div> - <div className="flex items-center gap-2"> - <Button - variant="ghost" - size="sm" - onClick={() => { - // 파일 다운로드 로직 - window.open(file.filePath, "_blank"); - }} - > - <Download className="h-4 w-4" /> - </Button> - </div> - </div> - ))} + ); + })} </div> - </CardContent> - </Card> - ) : ( - <Card> - <CardContent className="pt-6"> - <div className="text-center text-muted-foreground"> + ) : ( + <div className="text-sm text-muted-foreground"> 아직 제출된 첨부파일이 없습니다. </div> - </CardContent> - </Card> - )} - </TabsContent> */} + )} + </CardContent> + </Card> + </TabsContent> </Tabs> </div> diff --git a/lib/shi-api/shi-api-utils.ts b/lib/shi-api/shi-api-utils.ts index 2203f6b2..1e420356 100644 --- a/lib/shi-api/shi-api-utils.ts +++ b/lib/shi-api/shi-api-utils.ts @@ -89,6 +89,11 @@ export const getAllNonsapUser = async () => { let totalSkipped = 0; // 청크 단위로 매핑과 DB 처리를 동시에 수행 + // 전체 데이터 기준으로 "첫 번째 이메일만 반영" 정책을 지키기 위해 + // 전역 Set 으로 이메일/USR_ID 중복을 관리한다. + const globalSeenEmails = new Set<string>(); + const globalSeenNonsapUserIds = new Set<string>(); + for (let i = 0; i < sourceData.length; i += CHUNK_SIZE) { const chunk = sourceData.slice(i, i + CHUNK_SIZE); debugLog(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] Processing ${chunk.length} users (${i + 1}-${Math.min(i + chunk.length, sourceData.length)}/${sourceData.length})`); @@ -162,6 +167,20 @@ export const getAllNonsapUser = async () => { continue; } + const emailKey = mappedUser.email.toLowerCase(); + + // 데이터셋 전체 기준 중복 이메일/USR_ID 체크 (첫 번째 건만 반영) + if (globalSeenEmails.has(emailKey)) { + totalSkipped++; + debugWarn(`[GLOBAL] 중복 email 스킵: ${mappedUser.email}, nonsapUserId: ${mappedUser.nonsapUserId}`); + continue; + } + if (mappedUser.nonsapUserId && globalSeenNonsapUserIds.has(mappedUser.nonsapUserId)) { + totalSkipped++; + debugWarn(`[GLOBAL] 중복 nonsapUserId 스킵: ${mappedUser.nonsapUserId}, email: ${mappedUser.email}`); + continue; + } + // 청크 내 중복 체크 if (seenNonsapUserIds.has(mappedUser.nonsapUserId)) { totalSkipped++; @@ -169,7 +188,7 @@ export const getAllNonsapUser = async () => { continue; } - if (seenEmails.has(mappedUser.email.toLowerCase())) { + if (seenEmails.has(emailKey)) { totalSkipped++; debugWarn(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] 중복 email 발견 (청크 내): ${mappedUser.email}, nonsapUserId: ${mappedUser.nonsapUserId}`); continue; @@ -177,20 +196,34 @@ export const getAllNonsapUser = async () => { // 중복 체크 통과 시 추가 seenNonsapUserIds.add(mappedUser.nonsapUserId); - seenEmails.add(mappedUser.email.toLowerCase()); + seenEmails.add(emailKey); + globalSeenEmails.add(emailKey); + if (mappedUser.nonsapUserId) { + globalSeenNonsapUserIds.add(mappedUser.nonsapUserId); + } mappedChunk.push(mappedUser as InsertUser); } // 매핑된 청크가 있을 경우에만 DB 처리 (트랜잭션으로 처리) if (mappedChunk.length > 0) { - await db.transaction(async (tx) => { - // nonsapUserId를 기준으로 UPSERT (진정한 사용자 고유 ID) - // email은 변경될 수 있지만 nonsapUserId는 변경되지 않음 - await bulkUpsert(tx, users, mappedChunk, 'nonsapUserId', 200); // 청크 내에서도 200개씩 세분화 - }); - - totalUpserted += mappedChunk.length; - debugLog(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] Successfully processed ${mappedChunk.length} users`); + try { + await db.transaction(async (tx) => { + // nonsapUserId를 기준으로 UPSERT (진정한 사용자 고유 ID) + // email은 변경될 수 있지만 nonsapUserId는 변경되지 않음 + await bulkUpsert(tx, users, mappedChunk, 'nonsapUserId', 200); // 청크 내에서도 200개씩 세분화 + }); + + totalUpserted += mappedChunk.length; + debugLog(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] Successfully processed ${mappedChunk.length} users`); + } catch (chunkTxError: unknown) { + // 이메일 UNIQUE 제약 위반 등은 건너뛰고 다음 청크 진행 (첫 건 반영 정책) + const pgCode = (chunkTxError as any)?.code; + if (pgCode === '23505') { + debugWarn(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] Unique constraint 충돌로 청크 스킵: ${(chunkTxError as Error).message}`); + continue; + } + throw chunkTxError; + } } // 청크 간 잠시 대기하여 시스템 부하 방지 |
