/** * 기술영업 조선 및 해양을 위한 스키마 관련 설명 * * 배경지식: * 기술영업 조선은 TBE, CBE 없이 RFQ로 업무가 종결됩니다. * 기술영업 해양은 RFQ,TBE, CBE 진행를 모두 진행해 업무를 종결합니다. * * 기술영업은 projects.ts 스키마의 biddingProjects 테이블을 참조하고, 기술영업의 아이템리스트를 참조하여 RFQ를 자동생성합니다. * 기술영업 RFQ 추가는 버튼 >> 모달로 진행합니다. * 모달에서 견적프로젝트를 선택하면, 해당하는 자재그룹들을 리스팅합니다. * 각 자재그룹별로 RFQ 여러 개를 한번에 생성할 수 있습니다. (전체 선택하고 만들면 자동 생성과 동일한 기능) * * 벤더가 토탈 가격을 산정하는데 필요한 프로젝트 관련 정보는 벤더에게 제공됩니다. (각 시리즈별 K/L 일정을 제공) * 구매와 달리 지불조건, 인코텀즈, 배송지 등의 세부적인 정보는 벤더에게 제공되지 않습니다. * * 즉, 벤더는 대략적인 일정과 척수, 선종, 자재 이름만 보고 가격을 어림잡아 산정하는 것입니다. * 이 가격은 내부적으로 사용되며, 구매 분과로는 '자재그룹코드'를 기준으로 이어질 수 있습니다. * * 기준정보인 견적프로젝트 정보가 변경되면 RFQ도 변경되어야 하는가? -> 아니다. 별도로 저장하게 해달라. (장민욱 프로) * */ // 기술영업 RFQ는 별도의 PR 아이템 테이블을 사용하지 않음. // 해당 자재그룹에 대해 프로젝트 스펙만 보고 토탈가를 리턴하는 방식임 (선종, 척수, 자재 이름 등 프로젝트 정보와 자재 정보만 보고 가격을 어림잡아 산정하는 것) import { foreignKey, pgTable, serial, varchar, text, timestamp, boolean, integer, numeric, date, json, index, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { biddingProjects } from "./projects"; import { users } from "./users"; import { itemOffshoreHull, itemOffshoreTop, itemShipbuilding } from "./items"; import { techVendors } from "./techVendors"; // ===== 기술영업 상태 관리 상수 및 타입 ===== // 기술영업 RFQ 상태 export const TECH_SALES_RFQ_STATUSES = { RFQ_CREATED: "RFQ Created", RFQ_VENDOR_ASSIGNED: "RFQ Vendor Assignned", RFQ_SENT: "RFQ Sent", QUOTATION_ANALYSIS: "Quotation Analysis", CLOSED: "Closed", } as const; export type TechSalesRfqStatus = typeof TECH_SALES_RFQ_STATUSES[keyof typeof TECH_SALES_RFQ_STATUSES]; // 기술영업 벤더 견적서 상태 export const TECH_SALES_QUOTATION_STATUSES = { DRAFT: "Draft", SUBMITTED: "Submitted", REVISED: "Revised", ACCEPTED: "Accepted", REJECTED: "Rejected", } as const; export type TechSalesQuotationStatus = typeof TECH_SALES_QUOTATION_STATUSES[keyof typeof TECH_SALES_QUOTATION_STATUSES]; // 상태 설정 객체 (UI에서 사용) export const TECH_SALES_QUOTATION_STATUS_CONFIG = { [TECH_SALES_QUOTATION_STATUSES.DRAFT]: { label: "초안", variant: "secondary" as const, description: "작성 중인 견적서", color: "text-yellow-600", }, [TECH_SALES_QUOTATION_STATUSES.SUBMITTED]: { label: "제출됨", variant: "default" as const, description: "제출된 견적서", color: "text-blue-600", }, [TECH_SALES_QUOTATION_STATUSES.REVISED]: { label: "수정됨", variant: "outline" as const, description: "수정된 견적서", color: "text-purple-600", }, [TECH_SALES_QUOTATION_STATUSES.ACCEPTED]: { label: "승인됨", variant: "success" as const, description: "승인된 견적서", color: "text-green-600", }, [TECH_SALES_QUOTATION_STATUSES.REJECTED]: { label: "거절됨", variant: "destructive" as const, description: "거절된 견적서", color: "text-red-600", }, } as const; // ===== 스키마 정의 ===== // 기술영업 RFQ 테이블 - 아이템 관계를 1:N으로 변경 export const techSalesRfqs = pgTable("tech_sales_rfqs", { id: serial("id").primaryKey(), rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001" // 아이템 직접 참조 제거 - tech_sales_rfq_items 테이블로 분리 // itemShipbuildingId: integer("item_shipbuilding_id") // .notNull() // .references(() => itemShipbuilding.id, { onDelete: "cascade" }), // 프로젝트 참조 ID biddingProjectId: integer("bidding_project_id").references(() => biddingProjects.id, { onDelete: "set null" }), // RFQ 설명 (새로 추가) description: text("description"), remark: text("remark"), // 기술영업에서 벤더에게 제공할 정보로, 모든 벤더에게 동일하게 제공함. materialCode: varchar("material_code", { length: 255 }), // 벤더별로 보내는 날짜는 다르지만, 이 업무를 언제까지 처리하겠다는 의미의 dueDate dueDate: date("due_date", { mode: "date" }).$type().notNull(), rfqSendDate: date("rfq_send_date", { mode: "date" }).$type(), status: varchar("status", { length: 30 }) .$type() .default(TECH_SALES_RFQ_STATUSES.RFQ_CREATED) .notNull(), //picCode: 발주자 코드 picCode: varchar("pic_code", { length: 50 }), // WHO sentBy: integer("sent_by").references(() => users.id, { onDelete: "set null", }), createdBy: integer("created_by") .notNull() .references(() => users.id, { onDelete: "set null" }), updatedBy: integer("updated_by") .notNull() .references(() => users.id, { onDelete: "set null" }), // WHEN createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), // 삼성중공업이 RFQ를 취소한 경우 cancelReason: text("cancel_reason"), // RFQ 타입 구분 (조선/해양top/해양hull) rfqType: varchar("rfq_type", { length: 20 }) .$type<"SHIP" | "TOP" | "HULL">() .default("SHIP") .notNull(), }); // 기술영업 RFQ 아이템 테이블 (1:N 관계) export const techSalesRfqItems = pgTable("tech_sales_rfq_items", { id: serial("id").primaryKey(), rfqId: integer("rfq_id") .notNull() .references(() => techSalesRfqs.id, { onDelete: "cascade" }), // 아이템 타입별 참조 itemShipbuildingId: integer("item_shipbuilding_id") .references(() => itemShipbuilding.id, { onDelete: "cascade" }), // 해양 관련 아이템들 itemOffshoreTopId: integer("item_offshore_top_id") .references(() => itemOffshoreTop.id, { onDelete: "cascade" }), itemOffshoreHullId: integer("item_offshore_hull_id") .references(() => itemOffshoreHull.id, { onDelete: "cascade" }), // 아이템 타입 itemType: varchar("item_type", { length: 20 }) .$type<"SHIP" | "TOP" | "HULL">() .notNull(), // 생성 정보 createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); // 기술영업 첨부파일 테이블 (RFQ 에 첨부되는 것임) export const techSalesAttachments = pgTable( "tech_sales_attachments", { id: serial("id").primaryKey(), attachmentType: varchar("attachment_type", { length: 50 }).notNull(), // 'RFQ_COMMON', 'VENDOR_SPECIFIC' techSalesRfqId: integer("tech_sales_rfq_id").references( () => techSalesRfqs.id, { onDelete: "cascade" } ), fileName: varchar("file_name", { length: 255 }).notNull(), originalFileName: varchar("original_file_name", { length: 255 }).notNull(), filePath: varchar("file_path", { length: 512 }).notNull(), fileSize: integer("file_size"), fileType: varchar("file_type", { length: 100 }), description: varchar("description", { length: 500 }), createdBy: integer("created_by") .references(() => users.id, { onDelete: "set null" }) .notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), }, ); // 기술영업 벤더 견적서(응답) 테이블 (핵심: Total Price, Currency, Validity) 토탈가격, 통화, 유효기간 export const techSalesVendorQuotations = pgTable( "tech_sales_vendor_quotations", { id: serial("id").primaryKey(), rfqId: integer("rfq_id") .notNull() .references(() => techSalesRfqs.id, { onDelete: "cascade" }), vendorId: integer("vendor_id") .notNull() .references(() => techVendors.id, { onDelete: "set null" }), // === [시작]견적 응답 정보 === quotationCode: varchar("quotation_code", { length: 50 }), quotationVersion: integer("quotation_version").default(1), totalPrice: numeric("total_price"), currency: varchar("currency", { length: 10 }), // 견적 유효 기간 validUntil: date("valid_until", { mode: "date" }).$type(), // === [끝] 견적 응답 정보 === // 상태 관리 status: varchar("status", { length: 30 }) .$type() .default(TECH_SALES_QUOTATION_STATUSES.DRAFT) .notNull(), // 기타 정보 remark: text("remark"), rejectionReason: text("rejection_reason"), submittedAt: timestamp("submitted_at"), acceptedAt: timestamp("accepted_at"), // 감사 필드 createdBy: integer("created_by"), updatedBy: integer("updated_by"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), } ); // 기술영업 벤더 견적서 revision 히스토리 테이블 (이전 버전 스냅샷 저장) export const techSalesVendorQuotationRevisions = pgTable( "tech_sales_vendor_quotation_revisions", { id: serial("id").primaryKey(), quotationId: integer("quotation_id") .notNull() .references(() => techSalesVendorQuotations.id, { onDelete: "cascade" }), // 버전 정보 version: integer("version").notNull(), // 이전 데이터 JSON 스냅샷 snapshot: json("snapshot").notNull(), // 변경 사유 changeReason: text("change_reason"), revisionNote: text("revision_note"), // 변경자 정보 revisedBy: integer("revised_by"), revisedAt: timestamp("revised_at").defaultNow().notNull(), }, (table) => ({ quotationVersionIdx: index("tech_sales_quotation_revisions_quotation_version_idx").on( table.quotationId, table.version ), }) ); export const techSalesRfqComments = pgTable( "tech_sales_rfq_comments", { id: serial("id").primaryKey(), rfqId: integer("rfq_id") .notNull() .references(() => techSalesRfqs.id, { onDelete: "cascade" }), vendorId: integer("vendor_id").references(() => techVendors.id, { onDelete: "set null", }), userId: integer("user_id").references(() => users.id, { onDelete: "set null", }), content: text("content").notNull(), isVendorComment: boolean("is_vendor_comment").default(false), isRead: boolean("is_read").default(false), // 읽음 상태 추가 parentCommentId: integer("parent_comment_id"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, // 자기참조 FK 정의 (table) => { return { parentFk: foreignKey({ columns: [table.parentCommentId], foreignColumns: [table.id], }).onDelete("set null"), }; } ); // 코멘트 파일 첨부 export const techSalesRfqCommentAttachments = pgTable("tech_sales_rfq_comment_attachments", { id: serial("id").primaryKey(), rfqId: integer("rfq_id") .notNull() .references(() => techSalesRfqs.id, { onDelete: "cascade" }), commentId: integer("comment_id").references(() => techSalesRfqComments.id, { onDelete: "cascade", }), quotationId: integer("quotation_id").references( () => techSalesVendorQuotations.id, { onDelete: "cascade" } ), fileName: varchar("file_name", { length: 255 }).notNull(), fileSize: integer("file_size").notNull(), fileType: varchar("file_type", { length: 100 }), filePath: varchar("file_path", { length: 500 }).notNull(), isVendorUpload: boolean("is_vendor_upload").default(false), uploadedBy: integer("uploaded_by").references(() => users.id, { onDelete: "set null", }), vendorId: integer("vendor_id").references(() => techVendors.id, { onDelete: "set null", }), uploadedAt: timestamp("uploaded_at").defaultNow().notNull(), }); // 기술영업 벤더 견적서 첨부파일 테이블 export const techSalesVendorQuotationAttachments = pgTable("tech_sales_vendor_quotation_attachments", { id: serial("id").primaryKey(), quotationId: integer("quotation_id") .notNull() .references(() => techSalesVendorQuotations.id, { onDelete: "cascade" }), fileName: varchar("file_name", { length: 255 }).notNull(), originalFileName: varchar("original_file_name", { length: 255 }).notNull(), fileSize: integer("file_size").notNull(), fileType: varchar("file_type", { length: 100 }), filePath: varchar("file_path", { length: 500 }).notNull(), description: text("description"), // 파일 설명 uploadedBy: integer("uploaded_by").references(() => users.id, { onDelete: "set null", }), vendorId: integer("vendor_id").references(() => techVendors.id, { onDelete: "set null", }), isVendorUpload: boolean("is_vendor_upload").default(true), // 벤더가 업로드한 파일인지 createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); // 타입 정의 export type TechSalesVendorQuotations = typeof techSalesVendorQuotations.$inferSelect; // Relations 정의 export const techSalesRfqsRelations = relations(techSalesRfqs, ({ one, many }) => ({ // 프로젝트 관계 biddingProject: one(biddingProjects, { fields: [techSalesRfqs.biddingProjectId], references: [biddingProjects.id], }), // 사용자 관계 createdByUser: one(users, { fields: [techSalesRfqs.createdBy], references: [users.id], relationName: "techSalesRfqCreatedBy", }), updatedByUser: one(users, { fields: [techSalesRfqs.updatedBy], references: [users.id], relationName: "techSalesRfqUpdatedBy", }), sentByUser: one(users, { fields: [techSalesRfqs.sentBy], references: [users.id], relationName: "techSalesRfqSentBy", }), // 하위 관계들 rfqItems: many(techSalesRfqItems), // 새로 추가된 1:N 관계 vendorQuotations: many(techSalesVendorQuotations), attachments: many(techSalesAttachments), comments: many(techSalesRfqComments), })); // 새로운 RFQ Items 관계 export const techSalesRfqItemsRelations = relations(techSalesRfqItems, ({ one }) => ({ // 상위 RFQ 관계 rfq: one(techSalesRfqs, { fields: [techSalesRfqItems.rfqId], references: [techSalesRfqs.id], }), // 조선 아이템 관계 itemShipbuilding: one(itemShipbuilding, { fields: [techSalesRfqItems.itemShipbuildingId], references: [itemShipbuilding.id], }), // 해양 Hull 아이템 관계 itemOffshoreHull: one(itemOffshoreHull, { fields: [techSalesRfqItems.itemOffshoreHullId], references: [itemOffshoreHull.id], }), // 해양 Top 아이템 관계 itemOffshoreTop: one(itemOffshoreTop, { fields: [techSalesRfqItems.itemOffshoreTopId], references: [itemOffshoreTop.id], }), })); export const techSalesVendorQuotationsRelations = relations(techSalesVendorQuotations, ({ one, many }) => ({ // 상위 RFQ 관계 rfq: one(techSalesRfqs, { fields: [techSalesVendorQuotations.rfqId], references: [techSalesRfqs.id], }), // 벤더 관계 vendor: one(techVendors, { fields: [techSalesVendorQuotations.vendorId], references: [techVendors.id], }), // 사용자 관계 createdByUser: one(users, { fields: [techSalesVendorQuotations.createdBy], references: [users.id], relationName: "techSalesQuotationCreatedBy", }), updatedByUser: one(users, { fields: [techSalesVendorQuotations.updatedBy], references: [users.id], relationName: "techSalesQuotationUpdatedBy", }), // 첨부파일 관계 attachments: many(techSalesRfqCommentAttachments), quotationAttachments: many(techSalesVendorQuotationAttachments), })); export const techSalesAttachmentsRelations = relations(techSalesAttachments, ({ one }) => ({ // 상위 RFQ 관계 rfq: one(techSalesRfqs, { fields: [techSalesAttachments.techSalesRfqId], references: [techSalesRfqs.id], }), // 생성자 관계 createdByUser: one(users, { fields: [techSalesAttachments.createdBy], references: [users.id], relationName: "techSalesAttachmentCreatedBy", }), })); export const techSalesRfqCommentsRelations = relations(techSalesRfqComments, ({ one, many }) => ({ // 상위 RFQ 관계 rfq: one(techSalesRfqs, { fields: [techSalesRfqComments.rfqId], references: [techSalesRfqs.id], }), // 벤더 관계 vendor: one(techVendors, { fields: [techSalesRfqComments.vendorId], references: [techVendors.id], }), // 사용자 관계 user: one(users, { fields: [techSalesRfqComments.userId], references: [users.id], relationName: "techSalesCommentUser", }), // 부모 댓글 관계 (자기참조) parentComment: one(techSalesRfqComments, { fields: [techSalesRfqComments.parentCommentId], references: [techSalesRfqComments.id], relationName: "techSalesCommentParent", }), // 자식 댓글들 childComments: many(techSalesRfqComments, { relationName: "techSalesCommentParent", }), // 첨부파일 관계 attachments: many(techSalesRfqCommentAttachments), })); export const techSalesRfqCommentAttachmentsRelations = relations(techSalesRfqCommentAttachments, ({ one }) => ({ // 상위 RFQ 관계 rfq: one(techSalesRfqs, { fields: [techSalesRfqCommentAttachments.rfqId], references: [techSalesRfqs.id], }), // 댓글 관계 comment: one(techSalesRfqComments, { fields: [techSalesRfqCommentAttachments.commentId], references: [techSalesRfqComments.id], }), // 견적서 관계 quotation: one(techSalesVendorQuotations, { fields: [techSalesRfqCommentAttachments.quotationId], references: [techSalesVendorQuotations.id], }), // 업로드한 사용자 관계 uploadedByUser: one(users, { fields: [techSalesRfqCommentAttachments.uploadedBy], references: [users.id], relationName: "techSalesCommentAttachmentUploadedBy", }), // 벤더 관계 vendor: one(techVendors, { fields: [techSalesRfqCommentAttachments.vendorId], references: [techVendors.id], }), })); // 기술영업 벤더 견적서 첨부파일 relations export const techSalesVendorQuotationAttachmentsRelations = relations(techSalesVendorQuotationAttachments, ({ one }) => ({ // 견적서 관계 quotation: one(techSalesVendorQuotations, { fields: [techSalesVendorQuotationAttachments.quotationId], references: [techSalesVendorQuotations.id], }), // 업로드한 사용자 관계 uploadedByUser: one(users, { fields: [techSalesVendorQuotationAttachments.uploadedBy], references: [users.id], relationName: "techSalesQuotationAttachmentUploadedBy", }), // 벤더 관계 vendor: one(techVendors, { fields: [techSalesVendorQuotationAttachments.vendorId], references: [techVendors.id], }), }));