summaryrefslogtreecommitdiff
path: root/db/schema
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:33:50 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:33:50 +0000
commitb7f5af1f4b6691052bb3e77968a478ff27696095 (patch)
tree21209c94779f2339a2a9351b52972ea5ea1d67a3 /db/schema
parent20800b214145ee6056f94ca18fa1054f145eb977 (diff)
(대표님 / 최겸) 스키마 변경사항 및 마이그레이션 커밋
Diffstat (limited to 'db/schema')
-rw-r--r--db/schema/pq.ts409
-rw-r--r--db/schema/techVendors.ts30
-rw-r--r--db/schema/vendorData.ts38
-rw-r--r--db/schema/vendorDocu.ts609
4 files changed, 885 insertions, 201 deletions
diff --git a/db/schema/pq.ts b/db/schema/pq.ts
index 9aac7b89..c5f8c732 100644
--- a/db/schema/pq.ts
+++ b/db/schema/pq.ts
@@ -1,91 +1,90 @@
import {
- pgTable, serial, integer, varchar, text,
- timestamp, uniqueIndex, pgView
+ pgTable, serial, integer, varchar, text,
+ timestamp, uniqueIndex, pgView
} from "drizzle-orm/pg-core";
import { vendorTypes, vendors } from "./vendors";
import { projects } from "./projects";
-import { sql } from "drizzle-orm";
+import { sql, eq } from "drizzle-orm";
+import { users } from "./users";
export const pqCriterias = pgTable("pq_criterias", {
- id: serial("id").primaryKey(),
- code: varchar("code", { length: 50 }).notNull(), // 예: "1-1"
- checkPoint: varchar("check_point", { length: 255 }).notNull(),
- description: text("description"),
- remarks: text("remarks"),
+ id: serial("id").primaryKey(),
+ code: varchar("code", { length: 50 }).notNull(), // 예: "1-1"
+ checkPoint: varchar("check_point", { length: 255 }).notNull(),
+ description: text("description"),
+ remarks: text("remarks"),
- // (선택) "GENERAL", "Quality Management System" 등 큰 분류
- groupName: varchar("group_name", { length: 255 }),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ // (선택) "GENERAL", "Quality Management System" 등 큰 분류
+ groupName: varchar("group_name", { length: 255 }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const pqCriteriasExtension = pgTable("pq_criterias_extension", {
- id: serial("id").primaryKey(),
-
- // pq_criterias와 연결 (1:1 or 1:N 중 필요에 맞춰 사용)
- pqCriteriaId: integer("pq_criteria_id")
- .notNull()
- .references(() => pqCriterias.id, {
- onDelete: "cascade",
- onUpdate: "cascade",
- }),
-
- // projects 테이블에 대한 FK
- projectId: integer("project_id")
- .notNull()
- .references(() => projects.id, {
- onDelete: "cascade",
- onUpdate: "cascade",
- }),
-
- // 프로젝트별 PQ 시 필요한 추가 정보
- contractInfo: text("contract_info"),
- additionalRequirement: text("additional_requirement"),
-
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
- });
+ id: serial("id").primaryKey(),
+
+ // pq_criterias와 연결 (1:1 or 1:N 중 필요에 맞춰 사용)
+ pqCriteriaId: integer("pq_criteria_id")
+ .notNull()
+ .references(() => pqCriterias.id, {
+ onDelete: "cascade",
+ onUpdate: "cascade",
+ }),
+ // projects 테이블에 대한 FK
+ projectId: integer("project_id")
+ .notNull()
+ .references(() => projects.id, {
+ onDelete: "cascade",
+ onUpdate: "cascade",
+ }),
+ // 프로젝트별 PQ 시 필요한 추가 정보
+ contractInfo: text("contract_info"),
+ additionalRequirement: text("additional_requirement"),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+});
export const vendorCriteriaAttachments = pgTable("vendor_criteria_attachments", {
- id: serial("id").primaryKey(),
- vendorCriteriaAnswerId: integer("vendor_criteria_answer_id")
- .references(() => vendorPqCriteriaAnswers.id, { onDelete: "cascade" })
- .notNull(),
+ id: serial("id").primaryKey(),
+ vendorCriteriaAnswerId: integer("vendor_criteria_answer_id")
+ .references(() => vendorPqCriteriaAnswers.id, { onDelete: "cascade" })
+ .notNull(),
- fileName: varchar("file_name", { length: 255 }).notNull(),
- filePath: varchar("file_path", { length: 1024 }).notNull(),
- fileType: varchar("file_type", { length: 50 }),
- fileSize: integer("file_size"),
+ fileName: varchar("file_name", { length: 255 }).notNull(),
+ filePath: varchar("file_path", { length: 1024 }).notNull(),
+ fileType: varchar("file_type", { length: 50 }),
+ fileSize: integer("file_size"),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const vendorPqReviewLogs = pgTable("vendor_pq_review_logs", {
- id: serial("id").primaryKey(),
-
- // Each log references a single vendorPqCriteriaAnswers row
- vendorPqCriteriaAnswerId: integer("vendor_pq_criteria_answer_id")
- .references(() => vendorPqCriteriaAnswers.id, { onDelete: "cascade" })
- .notNull(),
-
- // The reviewer's comment text
- reviewerComment: text("reviewer_comment").notNull(),
-
- // Optionally store the reviewer name or user ID, if you have it
- reviewerName: text("reviewer_name"),
-
- createdAt: timestamp("created_at").defaultNow().notNull(),
- })
+ id: serial("id").primaryKey(),
+
+ // Each log references a single vendorPqCriteriaAnswers row
+ vendorPqCriteriaAnswerId: integer("vendor_pq_criteria_answer_id")
+ .references(() => vendorPqCriteriaAnswers.id, { onDelete: "cascade" })
+ .notNull(),
+
+ // The reviewer's comment text
+ reviewerComment: text("reviewer_comment").notNull(),
+
+ // Optionally store the reviewer name or user ID, if you have it
+ reviewerName: text("reviewer_name"),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+})
export type PqCriterias = typeof pqCriterias.$inferSelect
// 협력업체와 프로젝트 PQ 요청 연결 테이블
export const vendorProjectPQs = pgTable("vendor_project_pqs", {
id: serial("id").primaryKey(),
-
+
// vendors 테이블 FK
vendorId: integer("vendor_id")
.notNull()
@@ -93,7 +92,7 @@ export const vendorProjectPQs = pgTable("vendor_project_pqs", {
onDelete: "cascade",
onUpdate: "cascade",
}),
-
+
// projects 테이블 FK
projectId: integer("project_id")
.notNull()
@@ -101,47 +100,92 @@ export const vendorProjectPQs = pgTable("vendor_project_pqs", {
onDelete: "cascade",
onUpdate: "cascade",
}),
-
+
// 상태: REQUESTED(요청됨), IN_PROGRESS(진행중), SUBMITTED(제출됨), APPROVED(승인됨), REJECTED(거부됨)
status: varchar("status", { length: 20 }).notNull().default("REQUESTED"),
-
+
// 메타데이터
submittedAt: timestamp("submitted_at"),
approvedAt: timestamp("approved_at"),
rejectedAt: timestamp("rejected_at"),
rejectReason: text("reject_reason"),
-
+
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
+export const vendorPQSubmissions = pgTable("vendor_pq_submissions", {
+ id: serial("id").primaryKey(),
+
+ pqNumber: varchar("pq_number", { length: 50 }).notNull().unique(),
+
+ requesterId: integer("requester_id")
+ .references(() => users.id),
+
+ vendorId: integer("vendor_id")
+ .notNull()
+ .references(() => vendors.id, {
+ onDelete: "cascade",
+ onUpdate: "cascade",
+ }),
+
+ // null이면 일반 PQ, 값이 있으면 프로젝트 PQ
+ projectId: integer("project_id")
+ .references(() => projects.id, {
+ onDelete: "cascade",
+ onUpdate: "cascade",
+ }),
+
+ // PQ 유형 구분을 명시적으로
+ type: varchar("type", { length: 20 }).notNull(), // "GENERAL" or "PROJECT"
+
+ status: varchar("status", { length: 20 }).notNull().default("REQUESTED"),
+
+ submittedAt: timestamp("submitted_at"),
+ approvedAt: timestamp("approved_at"),
+ rejectedAt: timestamp("rejected_at"),
+ rejectReason: text("reject_reason"),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+}, (table) => {
+ return {
+ // 협력업체별로 일반 PQ는 하나만, 프로젝트 PQ는 프로젝트당 하나만
+ uniqueConstraint: uniqueIndex("unique_pq_submission").on(
+ table.vendorId,
+ table.projectId,
+ table.type
+ ),
+ };
+});
+
// 기존 vendorPqCriteriaAnswers 테이블에 projectId 필드 추가
export const vendorPqCriteriaAnswers = pgTable("vendor_pq_criteria_answers", {
id: serial("id").primaryKey(),
-
+
vendorId: integer("vendor_id")
.notNull()
.references(() => vendors.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
-
+
criteriaId: integer("criteria_id")
.notNull()
.references(() => pqCriterias.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
-
+
// 추가: 프로젝트 ID (null은 일반 PQ를 의미)
projectId: integer("project_id")
.references(() => projects.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
-
+
answer: text("answer"),
-
+
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
@@ -164,23 +208,232 @@ export const projectApprovedVendors = pgView("project_approved_vendors").as((qb)
project_name: projects.name,
project_type: projects.type,
// pq_status: vendorProjectPQs.status,
- submitted_at: vendorProjectPQs.submittedAt,
- approved_at: vendorProjectPQs.approvedAt
+ submitted_at: vendorPQSubmissions.submittedAt,
+ approved_at: vendorPQSubmissions.approvedAt
})
.from(vendors)
.innerJoin(
- vendorProjectPQs,
- sql`${vendorProjectPQs.vendorId} = ${vendors.id}`
+ vendorPQSubmissions,
+ sql`${vendorPQSubmissions.vendorId} = ${vendors.id}`
)
.innerJoin(
projects,
- sql`${vendorProjectPQs.projectId} = ${projects.id}`
+ sql`${vendorPQSubmissions.projectId} = ${projects.id}`
)
.leftJoin(
vendorTypes,
sql`${vendors.vendorTypeId} = ${vendorTypes.id}`
)
- .where(sql`${vendorProjectPQs.status} = 'APPROVED'`);
+ .where(sql`${vendorPQSubmissions.status} = 'APPROVED'`);
});
export type ProjectApprovedVendors = typeof projectApprovedVendors.$inferSelect
+
+
+
+
+export const vendorInvestigations = pgTable("vendor_investigations", {
+ id: serial("id").primaryKey(),
+
+ // 어떤 벤더에 대한 실사인지 참조
+ vendorId: integer("vendor_id").notNull().references(() => vendors.id),
+
+ // PQ 제출에 대한 참조
+ pqSubmissionId: integer("pq_submission_id")
+ .references(() => vendorPQSubmissions.id, {
+ onDelete: "set null",
+ onUpdate: "cascade",
+ }),
+
+ requesterId: integer("requester_id")
+ .references(() => users.id),
+
+ qmManagerId: integer("qm_manager_id")
+ .references(() => users.id),
+
+ // 실사 상태
+ investigationStatus: varchar("investigation_status", {
+ length: 50,
+ enum: [
+ "PLANNED", // 계획됨
+ "IN_PROGRESS", // 진행 중
+ "COMPLETED", // 완료됨
+ "CANCELED", // 취소됨
+ ],
+ })
+ .notNull()
+ .default("PLANNED"),
+
+ // 평가 유형
+ evaluationType: varchar("evaluation_type", {
+ length: 50,
+ enum: [
+ "SITE_AUDIT", // 실사의뢰평가
+ "QM_SELF_AUDIT", // QM자체평가
+ ],
+ }),
+
+ // 실사 주소
+ investigationAddress: text("investigation_address"),
+
+ // 실사 방법
+ investigationMethod: varchar("investigation_method", { length: 100 }),
+
+ // 실사 일정 시작일 / 종료일
+ scheduledStartAt: timestamp("scheduled_start_at"),
+ scheduledEndAt: timestamp("scheduled_end_at"),
+
+
+ // 실사 예정일
+ forecastedAt: timestamp("forecasted_at"),
+
+ // 실사 의뢰일
+ requestedAt: timestamp("requested_at"),
+
+ // 실사 확정일
+ confirmedAt: timestamp("confirmed_at"),
+
+ // 실제 실사 완료일
+ completedAt: timestamp("completed_at"),
+
+ // 실사 평가점수
+ evaluationScore: integer("evaluation_score"),
+
+ // 실사 평가 결과
+ evaluationResult: varchar("evaluation_result", {
+ length: 50,
+ enum: [
+ "APPROVED", // 승인
+ "SUPPLEMENT", // 보완
+ "REJECTED", // 불가
+ ],
+ }),
+
+ // 실사 내용이나 특이사항
+ investigationNotes: text("investigation_notes"),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+});
+
+// 타입 정의
+export type VendorInvestigation = typeof vendorInvestigations.$inferSelect;
+export const vendorInvestigationAttachments = pgTable(
+ "vendor_investigation_attachments",
+ {
+ id: serial("id").primaryKey(),
+
+ // 어떤 실사 (investigation)에 대한 첨부파일인지
+ investigationId: integer("investigation_id")
+ .notNull()
+ .references(() => vendorInvestigations.id, { onDelete: "cascade" }),
+
+ fileName: varchar("file_name", { length: 255 }).notNull(),
+ filePath: varchar("file_path", { length: 1024 }).notNull(),
+
+ // 권장: 사용자 경험과 기능성을 위해 추가
+ fileSize: integer("file_size"), // bytes (nullable로 기존 데이터 호환)
+ mimeType: varchar("mime_type", { length: 100 }), // nullable로 기존 데이터 호환
+
+ // 첨부파일 종류 (예: 보고서, 사진, 기타 등 구분)
+ attachmentType: varchar("attachment_type", { length: 50 }).default("REPORT"),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ }
+);
+
+// 타입 정의
+export type VendorInvestigationAttachment = typeof vendorInvestigationAttachments.$inferSelect;
+export type NewVendorInvestigationAttachment = typeof vendorInvestigationAttachments.$inferInsert;
+
+// 파일 업로드 관련 유틸리티 타입
+export interface FileUploadResult {
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ filePath: string;
+ fileSize: number;
+ mimeType: string;
+ fileType: string;
+ uploadedBy: number;
+ description?: string;
+}
+
+// 파일 다운로드용 타입
+export interface FileDownloadInfo {
+ fileName: string;
+ originalFileName: string;
+ filePath: string;
+ mimeType: string;
+ fileSize: number;
+}
+
+/**
+ * A view that joins vendor_investigations + vendors,
+ * and also embeds contacts & possibleItems as JSON arrays.
+ */
+
+export const vendorInvestigationsView = pgView(
+ "vendor_investigations_view"
+).as((qb) => {
+ return qb
+ .select({
+ // Investigation fields - use investigationId as the primary identifier
+ investigationId: vendorInvestigations.id,
+ vendorId: vendorInvestigations.vendorId,
+ pqSubmissionId: vendorInvestigations.pqSubmissionId,
+ requesterId: vendorInvestigations.requesterId,
+ qmManagerId: vendorInvestigations.qmManagerId,
+ investigationStatus: vendorInvestigations.investigationStatus,
+ evaluationType: vendorInvestigations.evaluationType,
+ investigationAddress: vendorInvestigations.investigationAddress,
+ investigationMethod: vendorInvestigations.investigationMethod,
+ scheduledStartAt: vendorInvestigations.scheduledStartAt,
+ scheduledEndAt: vendorInvestigations.scheduledEndAt,
+ forecastedAt: vendorInvestigations.forecastedAt,
+ requestedAt: vendorInvestigations.requestedAt,
+ confirmedAt: vendorInvestigations.confirmedAt,
+ completedAt: vendorInvestigations.completedAt,
+ evaluationScore: vendorInvestigations.evaluationScore,
+ evaluationResult: vendorInvestigations.evaluationResult,
+ investigationNotes: vendorInvestigations.investigationNotes,
+ createdAt: vendorInvestigations.createdAt,
+ updatedAt: vendorInvestigations.updatedAt,
+
+ // Essential vendor fields
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+
+ // User names and emails instead of just IDs
+ requesterName: sql<string>`requester.name`.as("requesterName"),
+ requesterEmail: sql<string>`requester.email`.as("requesterEmail"),
+ qmManagerName: sql<string>`qm_manager.name`.as("qmManagerName"),
+ qmManagerEmail: sql<string>`qm_manager.email`.as("qmManagerEmail"),
+
+ // File attachment status
+ hasAttachments: sql<boolean>`(
+ CASE
+ WHEN EXISTS (
+ SELECT 1 FROM vendor_investigation_attachments via
+ WHERE via.investigation_id = ${vendorInvestigations.id}
+ )
+ THEN true
+ ELSE false
+ END
+ )`.as("hasAttachments"),
+ })
+ .from(vendorInvestigations)
+ .leftJoin(
+ vendors,
+ eq(vendorInvestigations.vendorId, vendors.id)
+ )
+ .leftJoin(
+ sql`users AS requester`,
+ eq(vendorInvestigations.requesterId, sql`requester.id`)
+ )
+ .leftJoin(
+ sql`users AS qm_manager`,
+ eq(vendorInvestigations.qmManagerId, sql`qm_manager.id`)
+ )
+}) \ No newline at end of file
diff --git a/db/schema/techVendors.ts b/db/schema/techVendors.ts
index 55060adf..dcf73611 100644
--- a/db/schema/techVendors.ts
+++ b/db/schema/techVendors.ts
@@ -11,29 +11,28 @@ export const techVendors = pgTable("tech_vendors", {
id: serial("id").primaryKey(),
vendorName: varchar("vendor_name", { length: 255 }).notNull(),
vendorCode: varchar("vendor_code", { length: 100 }),
- taxId: varchar("tax_id", { length: 100 }).notNull(),
- address: text("address"),
+ taxId: varchar("tax_id", { length: 100 }),
country: varchar("country", { length: 100 }),
+ countryEng: varchar("country_eng", { length: 100 }),
+ countryFab: varchar("country_fab", { length: 100 }),
+ agentName: varchar("agent_name", { length: 255 }),
+ agentPhone: varchar("agent_phone", { length: 50 }),
+ agentEmail: varchar("agent_email", { length: 255 }),
+ address: varchar("address", { length: 255 }),
phone: varchar("phone", { length: 50 }),
email: varchar("email", { length: 255 }),
website: varchar("website", { length: 255 }),
- // 벤더 타입 추가
- techVendorType: varchar("tech_vendor_type", {
- length: 20,
- enum: VENDOR_TYPES
- }).notNull(),
- // 상태 필드 추가
+ // 벤더 타입을 다중 선택 가능하도록 text 타입으로 변경 (JSON 배열 저장)
+ techVendorType: text("tech_vendor_type").notNull(),
+ // 상태 필드 - 더 많은 상태 추가
status: varchar("status", {
length: 30,
enum: [
- "PENDING_REVIEW",
- "IN_REVIEW",
- "REJECTED",
"ACTIVE",
"INACTIVE",
"BLACKLISTED"
]
- }).default("PENDING_REVIEW").notNull(),
+ }).default("ACTIVE").notNull(),
// 대표자 정보
representativeName: varchar("representative_name", { length: 255 }),
representativeEmail: varchar("representative_email", { length: 255 }),
@@ -108,6 +107,11 @@ export const techVendorDetailView = pgView("tech_vendor_detail_view").as((qb) =>
taxId: techVendors.taxId,
address: techVendors.address,
country: techVendors.country,
+ countryEng: techVendors.countryEng,
+ countryFab: techVendors.countryFab,
+ agentName: techVendors.agentName,
+ agentPhone: techVendors.agentPhone,
+ agentEmail: techVendors.agentEmail,
phone: techVendors.phone,
email: techVendors.email,
website: techVendors.website,
@@ -203,7 +207,7 @@ export const techVendorCandidates = pgTable("tech_vendor_candidates", {
contactEmail: varchar("contact_email", { length: 255 }),
contactPhone: varchar("contact_phone", { length: 50 }),
- taxId: varchar("tax_id", { length: 100 }).notNull(),
+ taxId: varchar("tax_id", { length: 100 }),
address: text("address"),
country: varchar("country", { length: 100 }),
diff --git a/db/schema/vendorData.ts b/db/schema/vendorData.ts
index 01608df4..20025dc0 100644
--- a/db/schema/vendorData.ts
+++ b/db/schema/vendorData.ts
@@ -292,4 +292,40 @@ export const vendorDataReportTemps = pgTable("vendor_data_report_temps", {
.notNull(),
});
-export type VendorDataReportTemps = typeof vendorDataReportTemps.$inferSelect; \ No newline at end of file
+export type VendorDataReportTemps = typeof vendorDataReportTemps.$inferSelect;
+
+
+export const formListsView = pgView("form_lists_view").as((qb) => {
+ return qb
+ .select({
+ // Primary identifiers
+ id: sql<number>`${tagTypeClassFormMappings.id}`.as("id"),
+ projectId: sql<number>`${tagTypeClassFormMappings.projectId}`.as("project_id"),
+
+ // Project information
+ projectCode: sql<string>`${projects.code}`.as("project_code"),
+ projectName: sql<string>`${projects.name}`.as("project_name"),
+
+ // Form information
+ tagTypeLabel: sql<string>`${tagTypeClassFormMappings.tagTypeLabel}`.as("tag_type_label"),
+ classLabel: sql<string>`${tagTypeClassFormMappings.classLabel}`.as("class_label"),
+ formCode: sql<string>`${tagTypeClassFormMappings.formCode}`.as("form_code"),
+ formName: sql<string>`${tagTypeClassFormMappings.formName}`.as("form_name"),
+
+ // Additional fields
+ ep: sql<string | null>`${tagTypeClassFormMappings.ep}`.as("ep"),
+ remark: sql<string | null>`${tagTypeClassFormMappings.remark}`.as("remark"),
+
+ // Timestamps
+ createdAt: sql<Date>`${tagTypeClassFormMappings.createdAt}`.as("created_at"),
+ updatedAt: sql<Date>`${tagTypeClassFormMappings.updatedAt}`.as("updated_at"),
+ })
+ .from(tagTypeClassFormMappings)
+ .innerJoin(
+ projects,
+ eq(tagTypeClassFormMappings.projectId, projects.id)
+ );
+ });
+
+
+ export type FormListsView = typeof formListsView.$inferSelect;
diff --git a/db/schema/vendorDocu.ts b/db/schema/vendorDocu.ts
index 8eb9cbc6..ad49f031 100644
--- a/db/schema/vendorDocu.ts
+++ b/db/schema/vendorDocu.ts
@@ -1,43 +1,31 @@
-import { pgTable, integer, varchar, timestamp, date, pgView, uniqueIndex, jsonb } from "drizzle-orm/pg-core"
+// enhanced-schema.ts
+import { pgTable, integer, varchar, timestamp, date, pgView, uniqueIndex, jsonb, boolean, serial,
+ index, text } from "drizzle-orm/pg-core"
import { eq, sql } from "drizzle-orm";
import { projects } from "./projects";
import { vendors } from "./vendors";
import { contracts } from "./contract";
-
+// 기존 documents 테이블 (변경 없음)
export const documents = pgTable(
"documents",
{
- // 주 키
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
-
- // documentType: varchar("document_type", { length: 50 }).notNull(),
-
pic: varchar("pic", { length: 50 }),
-
- // 어느 계약(Contract) 소속인지
contractId: integer("contract_id")
.notNull()
.references(() => contracts.id, { onDelete: "cascade" }),
-
- // 예: 문서 번호(유니크 설정 가능)
docNumber: varchar("doc_number", { length: 100 }).notNull(),
- // 예: 문서(도서) 제목
title: varchar("title", { length: 255 }).notNull(),
-
- // 추가 예시: 발행일, 상태 등
status: varchar("status", { length: 50 })
.notNull()
.default("ACTIVE"),
issuedDate: date("issued_date"),
-
- // 생성/수정 시각
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => {
return {
- // contractId + docNumber + status 복합 유니크
uniqueContractDocStatus: uniqueIndex("unique_contract_doc_status").on(
table.contractId,
table.docNumber,
@@ -47,60 +35,86 @@ export const documents = pgTable(
}
)
+// 확장된 issueStages 테이블
export const issueStages = pgTable(
"issue_stages",
{
- // 주 키
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
-
- // 어느 문서 소속인지
documentId: integer("document_id")
.notNull()
.references(() => documents.id, { onDelete: "cascade" }),
-
- // 스테이지명 (예: "Issued for Review", "AFC" 등)
stageName: varchar("stage_name", { length: 100 }).notNull(),
-
- // 계획일, 실제일
+
+ // 기존 필드
planDate: date("plan_date"),
actualDate: date("actual_date"),
-
- // 생성/수정 시각
+
+ // 새로운 스케줄 관리 필드들
+ stageStatus: varchar("stage_status", { length: 50 })
+ .notNull()
+ .default("PLANNED"), // PLANNED, IN_PROGRESS, SUBMITTED, APPROVED, REJECTED, COMPLETED
+ stageOrder: integer("stage_order").default(0),
+ priority: varchar("priority", { length: 20 }).default("MEDIUM"), // HIGH, MEDIUM, LOW
+
+ // 담당자 정보
+ assigneeId: integer("assignee_id"),
+ assigneeName: varchar("assignee_name", { length: 100 }),
+
+ // 알림 및 추가 정보
+ reminderDays: integer("reminder_days").default(3),
+ description: varchar("description", { length: 500 }),
+ notes: varchar("notes", { length: 1000 }),
+
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => {
return {
- // document_id + stage_name 조합을 유니크 인덱스로 지정
uniqueDocumentStage: uniqueIndex("unique_document_stage").on(
table.documentId,
table.stageName
),
+ documentStageOrder: uniqueIndex("document_stage_order").on(
+ table.documentId,
+ table.stageOrder
+ ),
}
}
);
+// 확장된 revisions 테이블
export const revisions = pgTable(
"revisions",
{
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
issueStageId: integer("issue_stage_id").notNull(),
revision: varchar("revision", { length: 50 }).notNull(),
- // 새로운 필드: 업로더 타입 (업체 또는 고객사)
uploaderType: varchar("uploader_type", { length: 20 }).notNull().default("vendor"),
- // 선택적: 업로더 ID 또는 이름
uploaderId: integer("uploader_id"),
uploaderName: varchar("uploader_name", { length: 100 }),
- // 선택적: 추가 메타데이터
- comment: varchar("comment", { length: 500 }),
- status: varchar("status", { length: 50 }),
+
+ // 확장된 상태 관리
+ revisionStatus: varchar("revision_status", { length: 50 })
+ .notNull()
+ .default("SUBMITTED"), // SUBMITTED, UNDER_REVIEW, APPROVED, REJECTED, SUPERSEDED
+
+ // 상세 날짜 추적
+ submittedDate: date("submitted_date"),
+ reviewStartDate: date("review_start_date"),
approvedDate: date("approved_date"),
+ rejectedDate: date("rejected_date"),
+
+ // 검토자 정보
+ reviewerId: integer("reviewer_id"),
+ reviewerName: varchar("reviewer_name", { length: 100 }),
+ reviewComments: varchar("review_comments", { length: 1000 }),
+
+ comment: varchar("comment", { length: 500 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => {
return {
- // "issue_stage_id + revision" 조합을 유니크로 묶음 (유지)
uniqueStageRev: uniqueIndex("unique_stage_rev").on(
table.issueStageId,
table.revision
@@ -108,6 +122,8 @@ export const revisions = pgTable(
}
}
)
+
+// 기존 documentAttachments (변경 없음)
export const documentAttachments = pgTable(
"document_attachments",
{
@@ -124,29 +140,308 @@ export const documentAttachments = pgTable(
}
)
+
+export const enhancedDocumentsView = pgView("enhanced_documents_view", {
+ // 기본 문서 정보
+ documentId: integer("document_id").notNull(),
+ docNumber: varchar("doc_number", { length: 100 }).notNull(),
+ title: varchar("title", { length: 255 }).notNull(),
+ pic: varchar("pic", { length: 50 }),
+ status: varchar("status", { length: 50 }).notNull(),
+ issuedDate: date("issued_date"),
+ contractId: integer("contract_id").notNull(),
+
+ // 현재 스테이지 정보 (가장 우선순위가 높은 미완료 스테이지)
+ currentStageId: integer("current_stage_id"),
+ currentStageName: varchar("current_stage_name", { length: 100 }),
+ currentStageStatus: varchar("current_stage_status", { length: 50 }),
+ currentStageOrder: integer("current_stage_order"),
+ currentStagePlanDate: date("current_stage_plan_date"),
+ currentStageActualDate: date("current_stage_actual_date"),
+ currentStageAssigneeName: varchar("current_stage_assignee_name", { length: 100 }),
+ currentStagePriority: varchar("current_stage_priority", { length: 20 }),
+
+ // 계산 필드
+ daysUntilDue: integer("days_until_due"),
+ isOverdue: boolean("is_overdue"),
+ daysDifference: integer("days_difference"),
+
+ // 전체 진행률
+ totalStages: integer("total_stages"),
+ completedStages: integer("completed_stages"),
+ progressPercentage: integer("progress_percentage"),
+
+ // 최신 리비전 정보
+ latestRevisionId: integer("latest_revision_id"),
+ latestRevision: varchar("latest_revision", { length: 50 }),
+ latestRevisionStatus: varchar("latest_revision_status", { length: 50 }),
+ latestRevisionUploaderName: varchar("latest_revision_uploader_name", { length: 100 }),
+ latestSubmittedDate: date("latest_submitted_date"),
+
+ // 전체 스테이지 목록 (리비전 및 첨부파일 포함)
+ allStages: jsonb("all_stages").$type<Array<{
+ id: number;
+ stageName: string;
+ stageStatus: string;
+ stageOrder: number;
+ planDate: string | null;
+ actualDate: string | null;
+ assigneeName: string | null;
+ priority: string;
+ revisions: Array<{
+ id: number;
+ issueStageId: number;
+ revision: string;
+ uploaderType: string;
+ uploaderId: number | null;
+ uploaderName: string | null;
+ comment: string | null;
+ revisionStatus: string;
+ submittedDate: string | null;
+ approvedDate: string | null;
+ reviewStartDate: string | null;
+ rejectedDate: string | null;
+ reviewerId: number | null;
+ reviewerName: string | null;
+ reviewComments: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ attachments: Array<{
+ id: number;
+ revisionId: number;
+ fileName: string;
+ filePath: string;
+ fileSize: number | null;
+ fileType: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ }>;
+ }>;
+ }>>(),
+
+ // 메타 정보
+ attachmentCount: integer("attachment_count"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+}).as(sql`
+ WITH document_stats AS (
+ SELECT
+ d.id as document_id,
+ COUNT(ist.id) as total_stages,
+ COUNT(CASE WHEN ist.stage_status IN ('COMPLETED', 'APPROVED') THEN 1 END) as completed_stages,
+ CASE
+ WHEN COUNT(ist.id) > 0
+ THEN ROUND((COUNT(CASE WHEN ist.stage_status IN ('COMPLETED', 'APPROVED') THEN 1 END) * 100.0) / COUNT(ist.id))
+ ELSE 0
+ END as progress_percentage
+ FROM documents d
+ LEFT JOIN issue_stages ist ON d.id = ist.document_id
+ GROUP BY d.id
+ ),
+ current_stage_info AS (
+ SELECT DISTINCT ON (document_id)
+ document_id,
+ id as current_stage_id,
+ stage_name as current_stage_name,
+ stage_status as current_stage_status,
+ stage_order as current_stage_order,
+ plan_date as current_stage_plan_date,
+ actual_date as current_stage_actual_date,
+ assignee_name as current_stage_assignee_name,
+ priority as current_stage_priority,
+ CASE
+ WHEN actual_date IS NULL AND plan_date IS NOT NULL
+ THEN plan_date - CURRENT_DATE
+ ELSE NULL
+ END as days_until_due,
+ CASE
+ WHEN actual_date IS NULL AND plan_date < CURRENT_DATE
+ THEN true
+ WHEN actual_date IS NOT NULL AND actual_date > plan_date
+ THEN true
+ ELSE false
+ END as is_overdue,
+ CASE
+ WHEN actual_date IS NOT NULL AND plan_date IS NOT NULL
+ THEN actual_date - plan_date
+ ELSE NULL
+ END as days_difference
+ FROM issue_stages
+ WHERE stage_status NOT IN ('COMPLETED', 'APPROVED')
+ ORDER BY document_id, stage_order ASC, priority DESC
+ ),
+ latest_revision_info AS (
+ SELECT DISTINCT ON (ist.document_id)
+ ist.document_id,
+ r.id as latest_revision_id,
+ r.revision as latest_revision,
+ r.revision_status as latest_revision_status,
+ r.uploader_name as latest_revision_uploader_name,
+ r.submitted_date as latest_submitted_date
+ FROM revisions r
+ JOIN issue_stages ist ON r.issue_stage_id = ist.id
+ ORDER BY ist.document_id, r.created_at DESC
+ ),
+ -- 리비전별 첨부파일 집계
+ revision_attachments AS (
+ SELECT
+ r.id as revision_id,
+ COALESCE(
+ json_agg(
+ json_build_object(
+ 'id', da.id,
+ 'revisionId', da.revision_id,
+ 'fileName', da.file_name,
+ 'filePath', da.file_path,
+ 'fileSize', da.file_size,
+ 'fileType', da.file_type,
+ 'createdAt', da.created_at,
+ 'updatedAt', da.updated_at
+ ) ORDER BY da.created_at
+ ) FILTER (WHERE da.id IS NOT NULL),
+ '[]'::json
+ ) as attachments
+ FROM revisions r
+ LEFT JOIN document_attachments da ON r.id = da.revision_id
+ GROUP BY r.id
+ ),
+ -- 스테이지별 리비전 집계 (첨부파일 포함)
+ stage_revisions AS (
+ SELECT
+ ist.id as stage_id,
+ COALESCE(
+ json_agg(
+ json_build_object(
+ 'id', r.id,
+ 'issueStageId', r.issue_stage_id,
+ 'revision', r.revision,
+ 'uploaderType', r.uploader_type,
+ 'uploaderId', r.uploader_id,
+ 'uploaderName', r.uploader_name,
+ 'comment', r.comment,
+ 'revisionStatus', r.revision_status,
+ 'submittedDate', r.submitted_date,
+ 'approvedDate', r.approved_date,
+ 'reviewStartDate', r.review_start_date,
+ 'rejectedDate', r.rejected_date,
+ 'reviewerId', r.reviewer_id,
+ 'reviewerName', r.reviewer_name,
+ 'reviewComments', r.review_comments,
+ 'createdAt', r.created_at,
+ 'updatedAt', r.updated_at,
+ 'attachments', ra.attachments
+ ) ORDER BY r.created_at
+ ) FILTER (WHERE r.id IS NOT NULL),
+ '[]'::json
+ ) as revisions
+ FROM issue_stages ist
+ LEFT JOIN revisions r ON ist.id = r.issue_stage_id
+ LEFT JOIN revision_attachments ra ON r.id = ra.revision_id
+ GROUP BY ist.id
+ ),
+ -- 문서별 스테이지 집계 (리비전 포함)
+ stage_aggregation AS (
+ SELECT
+ ist.document_id,
+ json_agg(
+ json_build_object(
+ 'id', ist.id,
+ 'stageName', ist.stage_name,
+ 'stageStatus', ist.stage_status,
+ 'stageOrder', ist.stage_order,
+ 'planDate', ist.plan_date,
+ 'actualDate', ist.actual_date,
+ 'assigneeName', ist.assignee_name,
+ 'priority', ist.priority,
+ 'revisions', sr.revisions
+ ) ORDER BY ist.stage_order
+ ) as all_stages
+ FROM issue_stages ist
+ LEFT JOIN stage_revisions sr ON ist.id = sr.stage_id
+ GROUP BY ist.document_id
+ ),
+ attachment_counts AS (
+ SELECT
+ ist.document_id,
+ COUNT(da.id) as attachment_count
+ FROM issue_stages ist
+ LEFT JOIN revisions r ON ist.id = r.issue_stage_id
+ LEFT JOIN document_attachments da ON r.id = da.revision_id
+ GROUP BY ist.document_id
+ )
+
+ SELECT
+ d.id as document_id,
+ d.doc_number,
+ d.title,
+ d.pic,
+ d.status,
+ d.issued_date,
+ d.contract_id,
+
+ -- 현재 스테이지 정보
+ csi.current_stage_id,
+ csi.current_stage_name,
+ csi.current_stage_status,
+ csi.current_stage_order,
+ csi.current_stage_plan_date,
+ csi.current_stage_actual_date,
+ csi.current_stage_assignee_name,
+ csi.current_stage_priority,
+
+ -- 계산 필드
+ csi.days_until_due,
+ csi.is_overdue,
+ csi.days_difference,
+
+ -- 진행률 정보
+ ds.total_stages,
+ ds.completed_stages,
+ ds.progress_percentage,
+
+ -- 최신 리비전 정보
+ lri.latest_revision_id,
+ lri.latest_revision,
+ lri.latest_revision_status,
+ lri.latest_revision_uploader_name,
+ lri.latest_submitted_date,
+
+ -- 전체 스테이지 (리비전 및 첨부파일 포함)
+ COALESCE(sa.all_stages, '[]'::json) as all_stages,
+
+ -- 기타
+ COALESCE(ac.attachment_count, 0) as attachment_count,
+ d.created_at,
+ d.updated_at
+
+ FROM documents d
+ LEFT JOIN document_stats ds ON d.id = ds.document_id
+ LEFT JOIN current_stage_info csi ON d.id = csi.document_id
+ LEFT JOIN latest_revision_info lri ON d.id = lri.document_id
+ LEFT JOIN stage_aggregation sa ON d.id = sa.document_id
+ LEFT JOIN attachment_counts ac ON d.id = ac.document_id
+
+ ORDER BY d.created_at DESC
+`);
+
+// 기존 뷰들도 유지 (호환성을 위해)
export const vendorDocumentsView = pgView("vendor_documents_view", {
- // Match the columns in your SELECT statement
id: integer("id").notNull(),
docNumber: varchar("doc_number", { length: 100 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
pic: varchar("pic", { length: 255 }).notNull(),
status: varchar("status", { length: 50 }).notNull(),
issuedDate: date("issued_date"),
-
contractId: integer("contract_id").notNull(),
-
- latestStageId: integer("latest_stage_id"), // possibly can be null
+ latestStageId: integer("latest_stage_id"),
latestStageName: varchar("latest_stage_name", { length: 100 }),
latestStagePlanDate: date("latest_stage_plan_date"),
latestStageActualDate: date("latest_stage_actual_date"),
-
latestRevisionId: integer("latest_revision_id"),
latestRevision: varchar("latest_revision", { length: 50 }),
latestRevisionUploaderType: varchar("latest_revision_uploader_type", { length: 20 }),
latestRevisionUploaderName: varchar("latest_revision_uploader_name", { length: 100 }),
-
attachmentCount: integer("attachment_count"),
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
}).as(sql`
@@ -157,61 +452,19 @@ export const vendorDocumentsView = pgView("vendor_documents_view", {
d.pic,
d.status,
d.issued_date,
-
d.contract_id,
+
+ (SELECT id FROM issue_stages WHERE document_id = d.id ORDER BY created_at DESC LIMIT 1) AS latest_stage_id,
+ (SELECT stage_name FROM issue_stages WHERE document_id = d.id ORDER BY created_at DESC LIMIT 1) AS latest_stage_name,
+ (SELECT plan_date FROM issue_stages WHERE document_id = d.id ORDER BY created_at DESC LIMIT 1) AS latest_stage_plan_date,
+ (SELECT actual_date FROM issue_stages WHERE document_id = d.id ORDER BY created_at DESC LIMIT 1) AS latest_stage_actual_date,
- (
- SELECT id FROM issue_stages
- WHERE document_id = d.id
- ORDER BY created_at DESC LIMIT 1
- ) AS latest_stage_id,
- (
- SELECT stage_name FROM issue_stages
- WHERE document_id = d.id
- ORDER BY created_at DESC LIMIT 1
- ) AS latest_stage_name,
- (
- SELECT plan_date FROM issue_stages
- WHERE document_id = d.id
- ORDER BY created_at DESC LIMIT 1
- ) AS latest_stage_plan_date,
- (
- SELECT actual_date FROM issue_stages
- WHERE document_id = d.id
- ORDER BY created_at DESC LIMIT 1
- ) AS latest_stage_actual_date,
-
- (
- SELECT r.id FROM revisions r
- JOIN issue_stages i ON r.issue_stage_id = i.id
- WHERE i.document_id = d.id
- ORDER BY r.created_at DESC LIMIT 1
- ) AS latest_revision_id,
- (
- SELECT r.revision FROM revisions r
- JOIN issue_stages i ON r.issue_stage_id = i.id
- WHERE i.document_id = d.id
- ORDER BY r.created_at DESC LIMIT 1
- ) AS latest_revision,
- (
- SELECT r.uploader_type FROM revisions r
- JOIN issue_stages i ON r.issue_stage_id = i.id
- WHERE i.document_id = d.id
- ORDER BY r.created_at DESC LIMIT 1
- ) AS latest_revision_uploader_type,
- (
- SELECT r.uploader_name FROM revisions r
- JOIN issue_stages i ON r.issue_stage_id = i.id
- WHERE i.document_id = d.id
- ORDER BY r.created_at DESC LIMIT 1
- ) AS latest_revision_uploader_name,
+ (SELECT r.id FROM revisions r JOIN issue_stages i ON r.issue_stage_id = i.id WHERE i.document_id = d.id ORDER BY r.created_at DESC LIMIT 1) AS latest_revision_id,
+ (SELECT r.revision FROM revisions r JOIN issue_stages i ON r.issue_stage_id = i.id WHERE i.document_id = d.id ORDER BY r.created_at DESC LIMIT 1) AS latest_revision,
+ (SELECT r.uploader_type FROM revisions r JOIN issue_stages i ON r.issue_stage_id = i.id WHERE i.document_id = d.id ORDER BY r.created_at DESC LIMIT 1) AS latest_revision_uploader_type,
+ (SELECT r.uploader_name FROM revisions r JOIN issue_stages i ON r.issue_stage_id = i.id WHERE i.document_id = d.id ORDER BY r.created_at DESC LIMIT 1) AS latest_revision_uploader_name,
- (
- SELECT COUNT(*) FROM document_attachments a
- JOIN revisions r ON a.revision_id = r.id
- JOIN issue_stages i ON r.issue_stage_id = i.id
- WHERE i.document_id = d.id
- ) AS attachment_count,
+ (SELECT COUNT(*) FROM document_attachments a JOIN revisions r ON a.revision_id = r.id JOIN issue_stages i ON r.issue_stage_id = i.id WHERE i.document_id = d.id) AS attachment_count,
d.created_at,
d.updated_at
@@ -219,7 +472,6 @@ export const vendorDocumentsView = pgView("vendor_documents_view", {
JOIN contracts c ON d.contract_id = c.id
`);
-// 문서 + 스테이지 리스트 뷰
export const documentStagesView = pgView("document_stages_view", {
documentId: integer("document_id").notNull(),
docNumber: varchar("doc_number", { length: 100 }).notNull(),
@@ -228,11 +480,7 @@ export const documentStagesView = pgView("document_stages_view", {
issuedDate: date("issued_date"),
contractId: integer("contract_id").notNull(),
stageCount: integer("stage_count").notNull(),
-
- // 문자열 배열을 받을 것이므로 jsonb + $type<string[]>()
- // 스테이지가 없으면 null이 올 수도 있다면 string[] | null
stageList: jsonb("stage_list").$type<string[] | null>(),
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
}).as(sql`
@@ -243,26 +491,169 @@ export const documentStagesView = pgView("document_stages_view", {
d.status,
d.issued_date,
d.contract_id,
-
- (
- SELECT COUNT(*)
- FROM issue_stages
- WHERE document_id = d.id
- ) AS stage_count,
-
+ (SELECT COUNT(*) FROM issue_stages WHERE document_id = d.id) AS stage_count,
COALESCE(
- (
- SELECT json_agg(i.stage_name)
- FROM issue_stages i
- WHERE i.document_id = d.id
- ),
+ (SELECT json_agg(i.stage_name) FROM issue_stages i WHERE i.document_id = d.id),
'[]'
) AS stage_list,
-
d.created_at,
d.updated_at
FROM documents d
`);
+// 타입 추출
export type VendorDocumentsView = typeof vendorDocumentsView.$inferSelect
export type DocumentStagesView = typeof documentStagesView.$inferSelect
+export type EnhancedDocumentsView = typeof enhancedDocumentsView.$inferSelect
+
+
+// 동기화 설정 테이블
+export const syncConfigs = pgTable(
+ "sync_configs",
+ {
+ id: serial("id").primaryKey(),
+ contractId: integer("contract_id")
+ .notNull()
+ .references(() => contracts.id, { onDelete: "cascade" }),
+ targetSystem: varchar("target_system", { length: 50 }).notNull(), // 'SHI', 'SAP' 등
+ syncEnabled: boolean("sync_enabled").default(true),
+ syncIntervalMinutes: integer("sync_interval_minutes").default(30), // 30분마다
+ lastSuccessfulSync: timestamp("last_successful_sync"),
+ lastSyncAttempt: timestamp("last_sync_attempt"),
+ endpointUrl: text("endpoint_url").notNull(),
+ authToken: text("auth_token"),
+ apiVersion: varchar("api_version", { length: 20 }).default("v1"),
+ maxBatchSize: integer("max_batch_size").default(100),
+ retryMaxAttempts: integer("retry_max_attempts").default(3),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => {
+ return {
+ contractSystemIdx: index("idx_sync_configs_contract_system").on(
+ table.contractId,
+ table.targetSystem
+ ),
+ }
+ }
+)
+
+// 변경 로그 테이블 (모든 변경사항 추적)
+export const changeLogs = pgTable(
+ "change_logs",
+ {
+ id: serial("id").primaryKey(),
+ contractId: integer("contract_id").notNull(),
+ entityType: varchar("entity_type", { length: 50 }).notNull(), // 'document', 'revision', 'attachment'
+ entityId: integer("entity_id").notNull(),
+ action: varchar("action", { length: 20 }).notNull(), // 'CREATE', 'UPDATE', 'DELETE'
+ changedFields: jsonb("changed_fields").$type<Record<string, any>>(),
+ oldValues: jsonb("old_values").$type<Record<string, any>>(),
+ newValues: jsonb("new_values").$type<Record<string, any>>(),
+ userId: integer("user_id"),
+ userName: varchar("user_name", { length: 255 }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ isSynced: boolean("is_synced").default(false),
+ syncAttempts: integer("sync_attempts").default(0),
+ lastSyncError: text("last_sync_error"),
+ syncedAt: timestamp("synced_at"),
+ targetSystems: jsonb("target_systems").$type<string[]>().default(sql`'[]'::jsonb`), // 동기화할 시스템 목록
+ },
+ (table) => {
+ return {
+ contractSyncedIdx: index("idx_change_logs_contract_synced").on(
+ table.contractId,
+ table.isSynced
+ ),
+ createdAtIdx: index("idx_change_logs_created_at").on(table.createdAt),
+ entityIdx: index("idx_change_logs_entity").on(table.entityType, table.entityId),
+ syncAttemptsIdx: index("idx_change_logs_sync_attempts").on(table.syncAttempts),
+ }
+ }
+)
+
+// 동기화 배치 테이블 (배치 단위로 동기화 관리)
+export const syncBatches = pgTable(
+ "sync_batches",
+ {
+ id: serial("id").primaryKey(),
+ contractId: integer("contract_id").notNull(),
+ targetSystem: varchar("target_system", { length: 50 }).notNull(),
+ batchSize: integer("batch_size").notNull(),
+ status: varchar("status", { length: 20 }).notNull().default("PENDING"), // 'PENDING', 'PROCESSING', 'SUCCESS', 'FAILED', 'PARTIAL'
+ startedAt: timestamp("started_at"),
+ completedAt: timestamp("completed_at"),
+ errorMessage: text("error_message"),
+ retryCount: integer("retry_count").default(0),
+ changeLogIds: jsonb("change_log_ids").$type<number[]>().notNull(), // 포함된 change_log ID들
+ successCount: integer("success_count").default(0),
+ failureCount: integer("failure_count").default(0),
+ syncMetadata: jsonb("sync_metadata").$type<Record<string, any>>(), // 추가 메타데이터
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => {
+ return {
+ contractSystemIdx: index("idx_sync_batches_contract_system").on(
+ table.contractId,
+ table.targetSystem
+ ),
+ statusIdx: index("idx_sync_batches_status").on(table.status),
+ createdAtIdx: index("idx_sync_batches_created_at").on(table.createdAt),
+ }
+ }
+)
+
+// 동기화 상태 추적을 위한 뷰
+export const syncStatusView = pgView("sync_status_view", {
+ contractId: integer("contract_id").notNull(),
+ targetSystem: varchar("target_system", { length: 50 }).notNull(),
+ totalChanges: integer("total_changes").notNull(),
+ pendingChanges: integer("pending_changes").notNull(),
+ syncedChanges: integer("synced_changes").notNull(),
+ failedChanges: integer("failed_changes").notNull(),
+ lastSyncAt: timestamp("last_sync_at"),
+ nextSyncAt: timestamp("next_sync_at"),
+ syncEnabled: boolean("sync_enabled"),
+}).as(sql`
+ WITH change_stats AS (
+ SELECT
+ cl.contract_id,
+ sc.target_system,
+ COUNT(*) as total_changes,
+ COUNT(CASE WHEN cl.is_synced = false AND cl.sync_attempts < sc.retry_max_attempts THEN 1 END) as pending_changes,
+ COUNT(CASE WHEN cl.is_synced = true THEN 1 END) as synced_changes,
+ COUNT(CASE WHEN cl.sync_attempts >= sc.retry_max_attempts AND cl.is_synced = false THEN 1 END) as failed_changes,
+ MAX(cl.synced_at) as last_sync_at
+ FROM change_logs cl
+ CROSS JOIN sync_configs sc
+ WHERE cl.contract_id = sc.contract_id
+ AND (cl.target_systems IS NULL OR cl.target_systems @> to_jsonb(sc.target_system))
+ GROUP BY cl.contract_id, sc.target_system
+ )
+ SELECT
+ cs.contract_id,
+ cs.target_system,
+ COALESCE(cs.total_changes, 0) as total_changes,
+ COALESCE(cs.pending_changes, 0) as pending_changes,
+ COALESCE(cs.synced_changes, 0) as synced_changes,
+ COALESCE(cs.failed_changes, 0) as failed_changes,
+ cs.last_sync_at,
+ CASE
+ WHEN sc.sync_enabled = true AND sc.last_successful_sync IS NOT NULL
+ THEN sc.last_successful_sync + (sc.sync_interval_minutes || ' minutes')::interval
+ ELSE NULL
+ END as next_sync_at,
+ sc.sync_enabled
+ FROM sync_configs sc
+ LEFT JOIN change_stats cs ON sc.contract_id = cs.contract_id AND sc.target_system = cs.target_system
+`)
+
+// 타입 추출
+export type SyncConfig = typeof syncConfigs.$inferSelect
+export type SyncConfigInsert = typeof syncConfigs.$inferInsert
+export type ChangeLog = typeof changeLogs.$inferSelect
+export type ChangeLogInsert = typeof changeLogs.$inferInsert
+export type SyncBatch = typeof syncBatches.$inferSelect
+export type SyncBatchInsert = typeof syncBatches.$inferInsert
+export type SyncStatusView = typeof syncStatusView.$inferSelect