summaryrefslogtreecommitdiff
path: root/db/schema/techSales.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
commit5036cf2908792cef45f06256e71f10920f647f49 (patch)
tree3116e7419e872d45025d1d48e6ddaffe2ba2dd38 /db/schema/techSales.ts
parent7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff)
(김준회) 기술영업 조선 RFQ (SHI/벤더)
Diffstat (limited to 'db/schema/techSales.ts')
-rw-r--r--db/schema/techSales.ts473
1 files changed, 473 insertions, 0 deletions
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