summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx5
-rw-r--r--app/[lng]/evcp/(evcp)/edp-progress/page.tsx4
-rw-r--r--components/common/date-picker/date-picker-with-input.tsx322
-rw-r--r--components/common/date-picker/index.ts3
-rw-r--r--i18n/locales/en/menu.json5
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx87
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts158
7 files changed, 574 insertions, 10 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx
index e7109dcb..799c3b5a 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx
@@ -13,13 +13,14 @@ import { InformationButton } from "@/components/information/information-button"
import { useTranslation } from "@/i18n"
interface IndexPageProps {
searchParams: Promise<SearchParams>
+ params: Promise<{ lng: string }>
}
export default async function IndexPage(props: IndexPageProps) {
const searchParams = await props.searchParams
const search = searchParamsInvestigationCache.parse(searchParams)
- const {lng} = await props.params
- const {t} = await useTranslation(lng, 'menu')
+ const { lng } = await props.params
+ const { t } = await useTranslation(lng, 'menu')
const validFilters = getValidFilters(search.filters)
diff --git a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
index 9464c037..b205dd77 100644
--- a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
+++ b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
@@ -10,8 +10,8 @@ interface edpProgressPageProps {
params: Promise<{ lng: string }>
}
-export default async function IndexPage({ params: edpProgressPageProps }) {
- const { lng } = await params
+export default async function IndexPage(props: edpProgressPageProps) {
+ const { lng } = await props.params
const { t } = await useTranslation(lng, 'menu')
return (
diff --git a/components/common/date-picker/date-picker-with-input.tsx b/components/common/date-picker/date-picker-with-input.tsx
new file mode 100644
index 00000000..6e768601
--- /dev/null
+++ b/components/common/date-picker/date-picker-with-input.tsx
@@ -0,0 +1,322 @@
+"use client"
+
+import * as React from "react"
+import { format, parse, isValid } from "date-fns"
+import { ko } from "date-fns/locale"
+import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+export interface DatePickerWithInputProps {
+ value?: Date
+ onChange?: (date: Date | undefined) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+ minDate?: Date
+ maxDate?: Date
+ dateFormat?: string
+ inputClassName?: string
+ locale?: "ko" | "en"
+}
+
+/**
+ * DatePickerWithInput - 캘린더 선택 및 직접 입력이 가능한 날짜 선택기
+ *
+ * 사용법:
+ * ```tsx
+ * <DatePickerWithInput
+ * value={selectedDate}
+ * onChange={(date) => setSelectedDate(date)}
+ * placeholder="날짜를 선택하세요"
+ * minDate={new Date()}
+ * />
+ * ```
+ */
+export function DatePickerWithInput({
+ value,
+ onChange,
+ disabled = false,
+ placeholder = "YYYY-MM-DD",
+ className,
+ minDate,
+ maxDate,
+ dateFormat = "yyyy-MM-dd",
+ inputClassName,
+ locale: localeProp = "en",
+}: DatePickerWithInputProps) {
+ const [open, setOpen] = React.useState(false)
+ const [inputValue, setInputValue] = React.useState<string>("")
+ const [month, setMonth] = React.useState<Date>(value || new Date())
+ const [hasError, setHasError] = React.useState(false)
+ const [errorMessage, setErrorMessage] = React.useState<string>("")
+
+ // 외부 value가 변경되면 inputValue도 업데이트
+ React.useEffect(() => {
+ if (value && isValid(value)) {
+ setInputValue(format(value, dateFormat))
+ setMonth(value)
+ setHasError(false)
+ setErrorMessage("")
+ } else {
+ setInputValue("")
+ }
+ }, [value, dateFormat])
+
+ // 날짜 유효성 검사 및 에러 메시지 설정
+ const validateDate = (date: Date): { valid: boolean; message: string } => {
+ if (minDate) {
+ const minDateStart = new Date(minDate)
+ minDateStart.setHours(0, 0, 0, 0)
+ const dateToCheck = new Date(date)
+ dateToCheck.setHours(0, 0, 0, 0)
+ if (dateToCheck < minDateStart) {
+ return {
+ valid: false,
+ message: `${format(minDate, dateFormat)} 이후 날짜를 선택해주세요`
+ }
+ }
+ }
+ if (maxDate) {
+ const maxDateEnd = new Date(maxDate)
+ maxDateEnd.setHours(23, 59, 59, 999)
+ if (date > maxDateEnd) {
+ return {
+ valid: false,
+ message: `${format(maxDate, dateFormat)} 이전 날짜를 선택해주세요`
+ }
+ }
+ }
+ return { valid: true, message: "" }
+ }
+
+ // 캘린더에서 날짜 선택
+ const handleCalendarSelect = React.useCallback((date: Date | undefined, e?: React.MouseEvent) => {
+ // 이벤트 전파 중지
+ if (e) {
+ e.preventDefault()
+ e.stopPropagation()
+ }
+
+ if (date) {
+ const validation = validateDate(date)
+ if (validation.valid) {
+ setInputValue(format(date, dateFormat))
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(date)
+ setMonth(date)
+ } else {
+ setHasError(true)
+ setErrorMessage(validation.message)
+ }
+ }
+ setOpen(false)
+ }, [dateFormat, onChange, minDate, maxDate])
+
+ // 직접 입력값 변경
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const newValue = e.target.value
+ setInputValue(newValue)
+
+ // 입력 중에는 에러 상태 초기화
+ if (hasError) {
+ setHasError(false)
+ setErrorMessage("")
+ }
+
+ // YYYY-MM-DD 형식인 경우에만 파싱 시도
+ if (/^\d{4}-\d{2}-\d{2}$/.test(newValue)) {
+ const parsedDate = parse(newValue, dateFormat, new Date())
+
+ if (isValid(parsedDate)) {
+ const validation = validateDate(parsedDate)
+ if (validation.valid) {
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(parsedDate)
+ setMonth(parsedDate)
+ } else {
+ setHasError(true)
+ setErrorMessage(validation.message)
+ }
+ } else {
+ setHasError(true)
+ setErrorMessage("유효하지 않은 날짜 형식입니다")
+ }
+ }
+ }
+
+ // 입력 완료 시 (blur) 유효성 검사
+ const handleInputBlur = () => {
+ if (!inputValue) {
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(undefined)
+ return
+ }
+
+ const parsedDate = parse(inputValue, dateFormat, new Date())
+
+ if (isValid(parsedDate)) {
+ const validation = validateDate(parsedDate)
+ if (validation.valid) {
+ setHasError(false)
+ setErrorMessage("")
+ onChange?.(parsedDate)
+ } else {
+ setHasError(true)
+ setErrorMessage(validation.message)
+ // 유효 범위를 벗어난 경우 입력값은 유지하되 에러 표시
+ }
+ } else {
+ // 유효하지 않은 형식인 경우
+ setHasError(true)
+ setErrorMessage("YYYY-MM-DD 형식으로 입력해주세요")
+ }
+ }
+
+ // 키보드 이벤트 처리 (Enter 키로 완료)
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Enter") {
+ handleInputBlur()
+ }
+ }
+
+ // 날짜 비활성화 체크 (캘린더용)
+ const isDateDisabled = (date: Date) => {
+ if (disabled) return true
+ if (minDate) {
+ const minDateStart = new Date(minDate)
+ minDateStart.setHours(0, 0, 0, 0)
+ const dateToCheck = new Date(date)
+ dateToCheck.setHours(0, 0, 0, 0)
+ if (dateToCheck < minDateStart) return true
+ }
+ if (maxDate) {
+ const maxDateEnd = new Date(maxDate)
+ maxDateEnd.setHours(23, 59, 59, 999)
+ if (date > maxDateEnd) return true
+ }
+ return false
+ }
+
+ // 캘린더 버튼 클릭 핸들러
+ const handleCalendarButtonClick = (e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setOpen(!open)
+ }
+
+ // Popover 상태 변경 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen)
+ }
+
+ return (
+ <div className={cn("relative", className)}>
+ <div className="flex items-center gap-1">
+ <Input
+ type="text"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ disabled={disabled}
+ className={cn(
+ "pr-10",
+ hasError && "border-red-500 focus-visible:ring-red-500",
+ inputClassName
+ )}
+ />
+ <Popover open={open} onOpenChange={handleOpenChange} modal={true}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 h-full px-3 hover:bg-transparent"
+ disabled={disabled}
+ type="button"
+ onClick={handleCalendarButtonClick}
+ >
+ <CalendarIcon className={cn(
+ "h-4 w-4",
+ hasError ? "text-red-500" : "text-muted-foreground"
+ )} />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-auto p-0"
+ align="end"
+ onPointerDownOutside={(e) => e.preventDefault()}
+ onInteractOutside={(e) => e.preventDefault()}
+ >
+ <div
+ onClick={(e) => e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+ <DayPicker
+ mode="single"
+ selected={value}
+ onSelect={(date, selectedDay, activeModifiers, e) => {
+ handleCalendarSelect(date, e as unknown as React.MouseEvent)
+ }}
+ month={month}
+ onMonthChange={setMonth}
+ disabled={isDateDisabled}
+ locale={localeProp === "ko" ? ko : undefined}
+ showOutsideDays
+ className="p-3"
+ classNames={{
+ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
+ month: "space-y-4",
+ caption: "flex justify-center pt-1 relative items-center",
+ caption_label: "text-sm font-medium",
+ nav: "space-x-1 flex items-center",
+ nav_button: cn(
+ buttonVariants({ variant: "outline" }),
+ "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
+ ),
+ nav_button_previous: "absolute left-1",
+ nav_button_next: "absolute right-1",
+ table: "w-full border-collapse space-y-1",
+ head_row: "flex",
+ head_cell: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
+ row: "flex w-full mt-2",
+ cell: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md",
+ day: cn(
+ buttonVariants({ variant: "ghost" }),
+ "h-8 w-8 p-0 font-normal aria-selected:opacity-100"
+ ),
+ day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside: "text-muted-foreground opacity-50",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_hidden: "invisible",
+ }}
+ components={{
+ IconLeft: () => <ChevronLeft className="h-4 w-4" />,
+ IconRight: () => <ChevronRight className="h-4 w-4" />,
+ }}
+ />
+ </div>
+ </PopoverContent>
+ </Popover>
+ </div>
+ {/* 에러 메시지 표시 */}
+ {hasError && errorMessage && (
+ <p className="text-xs text-red-500 mt-1">{errorMessage}</p>
+ )}
+ </div>
+ )
+}
+
diff --git a/components/common/date-picker/index.ts b/components/common/date-picker/index.ts
new file mode 100644
index 00000000..85c0c259
--- /dev/null
+++ b/components/common/date-picker/index.ts
@@ -0,0 +1,3 @@
+// 공용 날짜 선택기 컴포넌트
+export { DatePickerWithInput, type DatePickerWithInputProps } from './date-picker-with-input'
+
diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json
index bee0a946..e4835c37 100644
--- a/i18n/locales/en/menu.json
+++ b/i18n/locales/en/menu.json
@@ -94,8 +94,9 @@
"tbe": "TBE",
"tbe_desc": "Technical Bid Evaluation",
"itb": "RFQ Creation",
- "itb_desc": "Create RFQ before PR Issue"
-
+ "itb_desc": "Create RFQ before PR Issue",
+ "vendor_progress": "Vendor Progress",
+ "vendor_progress_desc": "Vendor EDP input progress"
},
"vendor_management": {
"title": "Vendor",
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index d4e0ff33..b6cf6d7a 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -44,7 +44,8 @@ import {
updateDocument,
deleteDocuments,
updateStage,
- getDocumentClassOptionsByContract
+ getDocumentClassOptionsByContract,
+ checkDuplicateDocuments
} from "./document-stages-service"
import { type Row } from "@tanstack/react-table"
@@ -127,6 +128,14 @@ export function AddDocumentDialog({
const [cpyTypeConfigs, setCpyTypeConfigs] = React.useState<any[]>([])
const [cpyComboBoxOptions, setCpyComboBoxOptions] = React.useState<Record<number, any[]>>({})
+ // Duplicate check states
+ const [duplicateWarning, setDuplicateWarning] = React.useState<{
+ isDuplicate: boolean
+ type?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH'
+ message?: string
+ }>({ isDuplicate: false })
+ const [isCheckingDuplicate, setIsCheckingDuplicate] = React.useState(false)
+
// Initialize react-hook-form
const form = useForm<DocumentFormValues>({
resolver: zodResolver(documentFormSchema),
@@ -167,6 +176,7 @@ export function AddDocumentDialog({
setShiComboBoxOptions({})
setCpyComboBoxOptions({})
setDocumentClassOptions([])
+ setDuplicateWarning({ isDuplicate: false })
}
}, [open])
@@ -359,6 +369,59 @@ export function AddDocumentDialog({
return preview && preview !== '' && !preview.includes('[value]')
}
+ // Real-time duplicate check with debounce
+ const checkDuplicateDebounced = React.useMemo(() => {
+ let timeoutId: NodeJS.Timeout | null = null
+
+ return (shiDocNo: string, cpyDocNo: string) => {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ }
+
+ timeoutId = setTimeout(async () => {
+ // Skip if both are empty or incomplete
+ if ((!shiDocNo || shiDocNo.includes('[value]')) &&
+ (!cpyDocNo || cpyDocNo.includes('[value]'))) {
+ setDuplicateWarning({ isDuplicate: false })
+ return
+ }
+
+ setIsCheckingDuplicate(true)
+ try {
+ const result = await checkDuplicateDocuments(
+ contractId,
+ shiDocNo && !shiDocNo.includes('[value]') ? shiDocNo : undefined,
+ cpyDocNo && !cpyDocNo.includes('[value]') ? cpyDocNo : undefined
+ )
+
+ if (result.isDuplicate) {
+ setDuplicateWarning({
+ isDuplicate: true,
+ type: result.duplicateType,
+ message: result.message
+ })
+ } else {
+ setDuplicateWarning({ isDuplicate: false })
+ }
+ } catch (error) {
+ console.error('Duplicate check error:', error)
+ } finally {
+ setIsCheckingDuplicate(false)
+ }
+ }, 500) // 500ms debounce
+ }
+ }, [contractId])
+
+ // Trigger duplicate check when document numbers change
+ React.useEffect(() => {
+ const shiPreview = generateShiPreview()
+ const cpyPreview = generateCpyPreview()
+
+ if (shiPreview || cpyPreview) {
+ checkDuplicateDebounced(shiPreview, cpyPreview)
+ }
+ }, [shiFieldValues, cpyFieldValues])
+
const onSubmit = async (data: DocumentFormValues) => {
// Validate that at least one document number is configured and complete
if (shiType && !isShiComplete()) {
@@ -520,6 +583,24 @@ export function AddDocumentDialog({
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto pr-2 space-y-4">
+ {/* Duplicate Warning Alert */}
+ {duplicateWarning.isDuplicate && (
+ <Alert variant="destructive" className="border-red-300 bg-red-50 dark:bg-red-950/50">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ {duplicateWarning.message}
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* Checking Duplicate Indicator */}
+ {isCheckingDuplicate && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ Checking for duplicates...
+ </div>
+ )}
+
{/* SHI Document Number Card */}
{shiType && (
<Card className="border-blue-200 dark:border-blue-800">
@@ -719,7 +800,9 @@ export function AddDocumentDialog({
form.formState.isSubmitting ||
!hasAvailableTypes ||
(shiType && !isShiComplete()) ||
- (cpyType && !isCpyComplete())
+ (cpyType && !isCpyComplete()) ||
+ duplicateWarning.isDuplicate ||
+ isCheckingDuplicate
}
>
{form.formState.isSubmitting ? (
diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts
index ed4099b3..cf19eb41 100644
--- a/lib/vendor-document-list/plant/document-stages-service.ts
+++ b/lib/vendor-document-list/plant/document-stages-service.ts
@@ -878,6 +878,127 @@ interface CreateDocumentData {
vendorDocNumber?: string
}
+// ═══════════════════════════════════════════════════════════════════════════════
+// 문서번호 중복 체크 함수 (SHI_DOC_NO / OWN_DOC_NO 각각 중복 방지)
+// ═══════════════════════════════════════════════════════════════════════════════
+interface CheckDuplicateResult {
+ isDuplicate: boolean
+ duplicateType?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH'
+ existingDocNumbers?: {
+ shiDocNo?: string
+ ownDocNo?: string
+ }
+ message?: string
+}
+
+/**
+ * 프로젝트 내에서 SHI_DOC_NO (docNumber)와 OWN_DOC_NO (vendorDocNumber) 중복 체크
+ * @param contractId 계약 ID (프로젝트 ID를 가져오기 위함)
+ * @param shiDocNo SHI 문서번호 (docNumber)
+ * @param ownDocNo CPY 문서번호 (vendorDocNumber)
+ * @param excludeDocumentId 수정 시 제외할 문서 ID (선택)
+ */
+export async function checkDuplicateDocuments(
+ contractId: number,
+ shiDocNo?: string,
+ ownDocNo?: string,
+ excludeDocumentId?: number
+): Promise<CheckDuplicateResult> {
+ try {
+ // 1. 계약에서 프로젝트 ID 가져오기
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, contractId),
+ columns: { projectId: true },
+ })
+
+ if (!contract) {
+ return { isDuplicate: false, message: "유효하지 않은 계약입니다." }
+ }
+
+ const { projectId } = contract
+ let shiDuplicate = false
+ let ownDuplicate = false
+ const existingDocNumbers: { shiDocNo?: string; ownDocNo?: string } = {}
+
+ // 2. SHI_DOC_NO 중복 체크 (docNumber)
+ if (shiDocNo && shiDocNo.trim() !== '') {
+ const shiConditions = [
+ eq(stageDocuments.projectId, projectId),
+ eq(stageDocuments.docNumber, shiDocNo.trim()),
+ eq(stageDocuments.status, "ACTIVE"),
+ ]
+
+ if (excludeDocumentId) {
+ shiConditions.push(ne(stageDocuments.id, excludeDocumentId))
+ }
+
+ const existingShiDoc = await db
+ .select({ id: stageDocuments.id, docNumber: stageDocuments.docNumber })
+ .from(stageDocuments)
+ .where(and(...shiConditions))
+ .limit(1)
+
+ if (existingShiDoc.length > 0) {
+ shiDuplicate = true
+ existingDocNumbers.shiDocNo = existingShiDoc[0].docNumber
+ }
+ }
+
+ // 3. OWN_DOC_NO 중복 체크 (vendorDocNumber)
+ if (ownDocNo && ownDocNo.trim() !== '') {
+ const ownConditions = [
+ eq(stageDocuments.projectId, projectId),
+ eq(stageDocuments.vendorDocNumber, ownDocNo.trim()),
+ eq(stageDocuments.status, "ACTIVE"),
+ ]
+
+ if (excludeDocumentId) {
+ ownConditions.push(ne(stageDocuments.id, excludeDocumentId))
+ }
+
+ const existingOwnDoc = await db
+ .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber })
+ .from(stageDocuments)
+ .where(and(...ownConditions))
+ .limit(1)
+
+ if (existingOwnDoc.length > 0) {
+ ownDuplicate = true
+ existingDocNumbers.ownDocNo = existingOwnDoc[0].vendorDocNumber || undefined
+ }
+ }
+
+ // 4. 결과 반환
+ if (shiDuplicate && ownDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'BOTH',
+ existingDocNumbers,
+ message: `SHI Document Number '${shiDocNo}' and CPY Document Number '${ownDocNo}' already exist in this project.`
+ }
+ } else if (shiDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'SHI_DOC_NO',
+ existingDocNumbers,
+ message: `SHI Document Number '${shiDocNo}' already exists in this project.`
+ }
+ } else if (ownDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'OWN_DOC_NO',
+ existingDocNumbers,
+ message: `CPY Document Number '${ownDocNo}' already exists in this project.`
+ }
+ }
+
+ return { isDuplicate: false }
+ } catch (error) {
+ console.error("중복 체크 실패:", error)
+ return { isDuplicate: false, message: "중복 체크 중 오류가 발생했습니다." }
+ }
+}
+
// 문서 생성
export async function createDocument(data: CreateDocumentData) {
try {
@@ -907,6 +1028,20 @@ export async function createDocument(data: CreateDocumentData) {
return { success: false, error: configsResult.error }
}
+ /* ──────────────────────────────── 2. 중복 체크 (SHI_DOC_NO & OWN_DOC_NO) ─────────────────────────────── */
+ const duplicateCheck = await checkDuplicateDocuments(
+ data.contractId,
+ data.docNumber,
+ data.vendorDocNumber
+ )
+
+ if (duplicateCheck.isDuplicate) {
+ return {
+ success: false,
+ error: duplicateCheck.message || "Document number already exists in this project.",
+ duplicateType: duplicateCheck.duplicateType,
+ }
+ }
/* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */
const insertData = {
@@ -1403,7 +1538,7 @@ export async function uploadImportData(data: UploadData) {
try {
// 개별 트랜잭션으로 각 문서 처리
const result = await db.transaction(async (tx) => {
- // 먼저 문서가 이미 존재하는지 확인
+ // 먼저 SHI_DOC_NO (docNumber)가 이미 존재하는지 확인
const [existingDoc] = await tx
.select({ id: stageDocuments.id })
.from(stageDocuments)
@@ -1417,7 +1552,26 @@ export async function uploadImportData(data: UploadData) {
.limit(1)
if (existingDoc) {
- throw new Error(`문서번호 "${doc.docNumber}"가 이미 존재합니다`)
+ throw new Error(`SHI Document Number "${doc.docNumber}" already exists in this project`)
+ }
+
+ // OWN_DOC_NO (vendorDocNumber) 중복 체크
+ if (doc.vendorDocNumber && doc.vendorDocNumber.trim() !== '') {
+ const [existingVendorDoc] = await tx
+ .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber })
+ .from(stageDocuments)
+ .where(
+ and(
+ eq(stageDocuments.projectId, contract.projectId),
+ eq(stageDocuments.vendorDocNumber, doc.vendorDocNumber.trim()),
+ eq(stageDocuments.status, "ACTIVE")
+ )
+ )
+ .limit(1)
+
+ if (existingVendorDoc) {
+ throw new Error(`CPY Document Number "${doc.vendorDocNumber}" already exists in this project`)
+ }
}
// 3-1. 문서 생성