diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 19:03:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 19:03:21 +0000 |
| commit | 5036cf2908792cef45f06256e71f10920f647f49 (patch) | |
| tree | 3116e7419e872d45025d1d48e6ddaffe2ba2dd38 /db | |
| parent | 7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff) | |
(김준회) 기술영업 조선 RFQ (SHI/벤더)
Diffstat (limited to 'db')
| -rw-r--r-- | db/schema/index.ts | 1 | ||||
| -rw-r--r-- | db/schema/techSales.ts | 473 |
2 files changed, 474 insertions, 0 deletions
diff --git a/db/schema/index.ts b/db/schema/index.ts index fdd73344..309af050 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -14,3 +14,4 @@ export * from './logs'; export * from './basicContractDocumnet'; export * from './procurementRFQ'; export * from './setting'; +export * from './techSales';
\ No newline at end of file diff --git a/db/schema/techSales.ts b/db/schema/techSales.ts new file mode 100644 index 00000000..590ddc76 --- /dev/null +++ b/db/schema/techSales.ts @@ -0,0 +1,473 @@ +/** + * 기술영업 조선 및 해양을 위한 스키마 관련 설명 + * + * 배경지식: + * 기술영업 조선은 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, + jsonb, +} from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { biddingProjects } from "./projects"; +import { users } from "./users"; +import { items } from "./items"; +import { vendors } from "./vendors"; + +// ===== 기술영업 상태 관리 상수 및 타입 ===== + +// 기술영업 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", + REJECTED: "Rejected", + ACCEPTED: "Accepted", +} 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.REJECTED]: { + label: "반려됨", + variant: "destructive" as const, + description: "반려된 견적서", + color: "text-red-600", + }, + [TECH_SALES_QUOTATION_STATUSES.ACCEPTED]: { + label: "승인됨", + variant: "success" as const, + description: "승인된 견적서", + color: "text-green-600", + }, +} as const; + +// ===== 스키마 정의 ===== + +// 기술영업 RFQ 테이블 +export const techSalesRfqs = pgTable("tech_sales_rfqs", { + id: serial("id").primaryKey(), + rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001" + + // item에서 기술영업에서 사용하는 추가 정보는 itemShipbuilding 테이블에 저장되어 있다. + itemId: integer("item_id") + .notNull() + .references(() => items.id, { onDelete: "cascade" }), + + // 프로젝트 참조 ID + biddingProjectId: integer("bidding_project_id").references(() => biddingProjects.id, { onDelete: "set null" }), + + // 기술영업에서 벤더에게 제공할 정보로, 모든 벤더에게 동일하게 제공함. + materialCode: varchar("material_code", { length: 255 }), + + // 벤더별로 보내는 날짜는 다르지만, 이 업무를 언제까지 처리하겠다는 의미의 dueDate + dueDate: date("due_date", { mode: "date" }).$type<Date>().notNull(), + + rfqSendDate: date("rfq_send_date", { mode: "date" }).$type<Date | null>(), + status: varchar("status", { length: 30 }) + .$type<TechSalesRfqStatus>() + .default(TECH_SALES_RFQ_STATUSES.RFQ_CREATED) + .notNull(), + + // rfq 밀봉 기능은, 기술영업에서 사용하지 않겠다고 함. + + //picCode: 발주자 코드 + picCode: varchar("pic_code", { length: 50 }), + remark: text("remark"), + + // 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"), + + // 프로젝트 정보 스냅샷 (프로젝트 관련 모든 정보) + // 기존 개별 컬럼 방식에서 jsonb로 마이그레이션 시: + // 1. 기존 RFQ 데이터는 pspid 등의 개별 컬럼 값을 기반으로 jsonb 형태로 변환하여 마이그레이션 + // 2. 새로운 RFQ 생성 시에는 biddingProjects와 projectSeries 테이블에서 정보를 조회하여 스냅샷으로 저장 + projectSnapshot: jsonb("project_snapshot").$type<{ + pspid: string; // 견적프로젝트번호 + projNm?: string; // 견적프로젝트명 + sector?: string; // 부문(S / M) + projMsrm?: number; // 척수 + kunnr?: string; // 선주코드 + kunnrNm?: string; // 선주명 + cls1?: string; // 선급코드 + cls1Nm?: string; // 선급명 + ptype?: string; // 선종코드 + ptypeNm?: string; // 선종명 + pmodelCd?: string; // 선형코드 + pmodelNm?: string; // 선형명 + pmodelSz?: string; // 선형크기 + pmodelUom?: string; // 선형단위 + txt04?: string; // 견적상태코드 + txt30?: string; // 견적상태명 + estmPm?: string; // 견적대표PM 성명 + pspCreatedAt?: Date | string; // 원래 생성 일자 + pspUpdatedAt?: Date | string; // 원래 업데이트 일자 + }>(), + + // 프로젝트 시리즈 정보 스냅샷 + // 시리즈 정보는 배열 형태로 저장되며, 프로젝트의 모든 시리즈 정보를 포함 + // RFQ 생성 시점의 시리즈 정보를 스냅샷으로 보존함으로써 후속 변경에 영향을 받지 않음 + seriesSnapshot: jsonb("series_snapshot").$type<Array<{ + pspid: string; // 견적프로젝트번호 + sersNo: string; // 시리즈번호 + scDt?: string; // Steel Cutting Date + klDt?: string; // Keel Laying Date + lcDt?: string; // Launching Date + dlDt?: string; // Delivery Date + dockNo?: string; // 도크코드 + dockNm?: string; // 도크명 + projNo?: string; // SN공사번호(계약후) + post1?: string; // SN공사명(계약후) + }>>(), +}); + +// 기술영업 첨부파일 테이블 (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(() => vendors.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<Date>(), + + // === [끝] 견적 응답 정보 === + // 상태 관리 + status: varchar("status", { length: 30 }) + .$type<TechSalesQuotationStatus>() + .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(), + } +); + +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(() => vendors.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(() => vendors.id, { + onDelete: "set null", + }), + uploadedAt: timestamp("uploaded_at").defaultNow().notNull(), +}); + + +// 타입 정의 +export type TechSalesVendorQuotations = + typeof techSalesVendorQuotations.$inferSelect; + +// Relations 정의 +export const techSalesRfqsRelations = relations(techSalesRfqs, ({ one, many }) => ({ + // 아이템 관계 + item: one(items, { + fields: [techSalesRfqs.itemId], + references: [items.id], + }), + + // 프로젝트 관계 + 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", + }), + + // 하위 관계들 + vendorQuotations: many(techSalesVendorQuotations), + attachments: many(techSalesAttachments), + comments: many(techSalesRfqComments), +})); + +export const techSalesVendorQuotationsRelations = relations(techSalesVendorQuotations, ({ one, many }) => ({ + // 상위 RFQ 관계 + rfq: one(techSalesRfqs, { + fields: [techSalesVendorQuotations.rfqId], + references: [techSalesRfqs.id], + }), + + // 벤더 관계 + vendor: one(vendors, { + fields: [techSalesVendorQuotations.vendorId], + references: [vendors.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), +})); + +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(vendors, { + fields: [techSalesRfqComments.vendorId], + references: [vendors.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(vendors, { + fields: [techSalesRfqCommentAttachments.vendorId], + references: [vendors.id], + }), +}));
\ No newline at end of file |
