summaryrefslogtreecommitdiff
path: root/db/schema/rfqVendor.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-02 09:52:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-02 09:52:21 +0000
commitfccb00d15466cd0b2d861163663a5070c768ff77 (patch)
tree4b14b27417ebeb873a9d4b4d7b5c64f6e1d78135 /db/schema/rfqVendor.ts
parent72f212f717f136e875e7623404a5ddd4c5268901 (diff)
(대표님) OCR 박진석프로 요청 대응, rfq 변경된 요구사항 구현
Diffstat (limited to 'db/schema/rfqVendor.ts')
-rw-r--r--db/schema/rfqVendor.ts382
1 files changed, 382 insertions, 0 deletions
diff --git a/db/schema/rfqVendor.ts b/db/schema/rfqVendor.ts
new file mode 100644
index 00000000..e92b1e10
--- /dev/null
+++ b/db/schema/rfqVendor.ts
@@ -0,0 +1,382 @@
+import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, jsonb } from "drizzle-orm/pg-core";
+import { eq, sql, relations } from "drizzle-orm";
+import { rfqsLast, rfqLastDetails, rfqPrItems } from "./rfqLast";
+import { users } from "./users";
+import { vendors } from "./vendors";
+import { incoterms, paymentTerms } from "./procurementRFQ";
+
+// ==========================================
+// 1. 벤더 응답 메인 테이블 (견적서 헤더)
+// ==========================================
+export const rfqLastVendorResponses = pgTable(
+ "rfq_last_vendor_responses",
+ {
+ id: serial("id").primaryKey(),
+
+ // RFQ 및 벤더 참조
+ rfqsLastId: integer("rfqs_last_id")
+ .notNull()
+ .references(() => rfqsLast.id, { onDelete: "cascade" }),
+ rfqLastDetailsId: integer("rfq_last_details_id")
+ .notNull()
+ .references(() => rfqLastDetails.id, { onDelete: "cascade" }),
+ vendorId: integer("vendor_id")
+ .notNull()
+ .references(() => vendors.id, { onDelete: "cascade" }),
+
+ // 응답 버전 관리
+ responseVersion: integer("response_version").notNull().default(1),
+ isLatest: boolean("is_latest").notNull().default(true),
+
+ // 응답 상태
+ status: varchar("status", { length: 30 })
+ .$type<"작성중" | "제출완료" | "수정요청" | "최종확정" | "취소">()
+ .notNull()
+ .default("작성중"),
+
+ // 제출 정보
+ submittedAt: timestamp("submitted_at"),
+ submittedBy: integer("submitted_by")
+ .references(() => users.id, { onDelete: "set null" }),
+
+ // 총액 정보
+ totalAmount: numeric("total_amount", { precision: 15, scale: 2 }).$type<number>(),
+ currency: varchar("currency", { length: 10 }).default("USD"),
+
+ // 벤더 제안 조건 (구매자 제시와 다른 경우)
+ vendorCurrency: varchar("vendor_currency", { length: 10 }),
+ vendorPaymentTermsCode: varchar("vendor_payment_terms_code", { length: 50 })
+ .references(() => paymentTerms.code, { onDelete: "set null" }),
+ vendorIncotermsCode: varchar("vendor_incoterms_code", { length: 20 })
+ .references(() => incoterms.code, { onDelete: "set null" }),
+ vendorIncotermsDetail: varchar("vendor_incoterms_detail", { length: 255 }),
+ vendorDeliveryDate: date("vendor_delivery_date", { mode: "date" }).$type<Date | null>(),
+ vendorContractDuration: varchar("vendor_contract_duration", { length: 255 }),
+ vendorTaxCode: varchar("vendor_tax_code", { length: 255 }),
+ vendorPlaceOfShipping: varchar("vendor_place_of_shipping", { length: 255 }),
+ vendorPlaceOfDestination: varchar("vendor_place_of_destination", { length: 255 }),
+
+ // 초도품관리 응답
+ vendorFirstYn: boolean("vendor_first_yn"),
+ vendorFirstDescription: text("vendor_first_description"),
+ vendorFirstAcceptance: varchar("vendor_first_acceptance", { length: 50 })
+ .$type<"수용" | "부분수용" | "거부" | null>(),
+
+ // Spare part 응답
+ vendorSparepartYn: boolean("vendor_sparepart_yn"),
+ vendorSparepartDescription: text("vendor_sparepart_description"),
+ vendorSparepartAcceptance: varchar("vendor_sparepart_acceptance", { length: 50 })
+ .$type<"수용" | "부분수용" | "거부" | null>(),
+
+ // 연동제 적용 응답
+ vendorMaterialPriceRelatedYn: boolean("vendor_material_price_related_yn"),
+ vendorMaterialPriceRelatedReason: text("vendor_material_price_related_reason"),
+
+ // 각 조건별 변경 사유 (구매자 제시와 다른 경우)
+ currencyReason: text("currency_reason"),
+ paymentTermsReason: text("payment_terms_reason"),
+ deliveryDateReason: text("delivery_date_reason"),
+ incotermsReason: text("incoterms_reason"),
+ taxReason: text("tax_reason"),
+ shippingReason: text("shipping_reason"),
+
+ // 전체 비고 및 특이사항
+ generalRemark: text("general_remark"),
+ technicalProposal: text("technical_proposal"),
+
+ // 감사 필드
+ createdBy: integer("created_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "set null" }),
+ updatedBy: integer("updated_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "set null" }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ }
+);
+
+// ==========================================
+// 2. 벤더 견적 아이템 테이블 (PR 아이템별 가격)
+// ==========================================
+export const rfqLastVendorQuotationItems = pgTable(
+ "rfq_last_vendor_quotation_items",
+ {
+ id: serial("id").primaryKey(),
+ vendorResponseId: integer("vendor_response_id")
+ .notNull()
+ .references(() => rfqLastVendorResponses.id, { onDelete: "cascade" }),
+ rfqPrItemId: integer("rfq_pr_item_id")
+ .notNull()
+ .references(() => rfqPrItems.id, { onDelete: "cascade" }),
+
+ // PR 아이템 정보 (캐시)
+ prNo: varchar("pr_no", { length: 50 }),
+ materialCode: varchar("material_code", { length: 255 }),
+ materialDescription: varchar("material_description", { length: 255 }),
+
+ // 견적 정보
+ quantity: numeric("quantity", { precision: 12, scale: 2 })
+ .$type<number>()
+ .notNull(),
+ uom: varchar("uom", { length: 50 }),
+
+ // 가격 정보
+ unitPrice: numeric("unit_price", { precision: 15, scale: 4 })
+ .$type<number>()
+ .notNull(),
+ totalPrice: numeric("total_price", { precision: 15, scale: 2 })
+ .$type<number>()
+ .notNull(),
+ currency: varchar("currency", { length: 10 }).default("USD"),
+
+ // 납기 정보
+ vendorDeliveryDate: date("vendor_delivery_date", { mode: "date" }).$type<Date | null>(),
+ leadTime: integer("lead_time"), // days
+
+ // 제조사 정보
+ manufacturer: varchar("manufacturer", { length: 255 }),
+ manufacturerCountry: varchar("manufacturer_country", { length: 100 }),
+ modelNo: varchar("model_no", { length: 255 }),
+
+ // 기술 사양
+ technicalCompliance: boolean("technical_compliance").default(true),
+ alternativeProposal: text("alternative_proposal"),
+
+ // 할인 정보
+ discountRate: numeric("discount_rate", { precision: 5, scale: 2 }).$type<number>(),
+ discountAmount: numeric("discount_amount", { precision: 15, scale: 2 }).$type<number>(),
+
+ // 비고
+ itemRemark: text("item_remark"),
+ deviationReason: text("deviation_reason"), // 요구사항과 다른 경우 사유
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ }
+);
+
+// ==========================================
+// 4. 벤더 첨부파일 테이블
+// ==========================================
+export const rfqLastVendorAttachments = pgTable(
+ "rfq_last_vendor_attachments",
+ {
+ id: serial("id").primaryKey(),
+ vendorResponseId: integer("vendor_response_id")
+ .notNull()
+ .references(() => rfqLastVendorResponses.id, { onDelete: "cascade" }),
+
+ // 첨부파일 구분
+ attachmentType: varchar("attachment_type", { length: 50 }).notNull(),
+ // "견적서", "기술제안서", "인증서", "카탈로그", "도면", "테스트성적서", "기타"
+
+ documentNo: varchar("document_no", { length: 100 }), // 문서번호
+
+ // 파일 정보
+ 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: text("description"),
+
+ // 유효기간 (인증서 등)
+ validFrom: date("valid_from", { mode: "date" }).$type<Date | null>(),
+ validTo: date("valid_to", { mode: "date" }).$type<Date | null>(),
+
+ // 감사 필드
+ uploadedBy: integer("uploaded_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "set null" }),
+ uploadedAt: timestamp("uploaded_at").defaultNow().notNull(),
+ }
+);
+
+// ==========================================
+// 4. 벤더 응답 이력 테이블 (변경 이력 관리)
+// ==========================================
+export const rfqLastVendorResponseHistory = pgTable(
+ "rfq_last_vendor_response_history",
+ {
+ id: serial("id").primaryKey(),
+ vendorResponseId: integer("vendor_response_id")
+ .notNull()
+ .references(() => rfqLastVendorResponses.id, { onDelete: "cascade" }),
+
+ action: varchar("action", { length: 50 }).notNull(),
+ // "생성", "수정", "제출", "철회", "재제출", "승인", "반려"
+
+ previousStatus: varchar("previous_status", { length: 30 }),
+ newStatus: varchar("new_status", { length: 30 }),
+
+ changeDetails: jsonb("change_details"), // 변경된 필드들
+ changeReason: text("change_reason"),
+
+ performedBy: integer("performed_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "set null" }),
+ performedAt: timestamp("performed_at").defaultNow().notNull(),
+ }
+);
+
+// ==========================================
+// Views
+// ==========================================
+
+// 벤더 응답 종합 뷰
+export const vendorResponseSummaryView = pgView("vendor_response_summary_view").as((qb) => {
+ const responseTable = alias(rfqLastVendorResponses, "response");
+ const rfqTable = alias(rfqsLast, "rfq");
+ const detailTable = alias(rfqLastDetails, "detail");
+ const vendorTable = alias(vendors, "vendor");
+ const submittedByUser = alias(users, "submitted_by_user");
+
+ return qb
+ .select({
+ // 응답 기본 정보
+ responseId: sql<number>`${responseTable.id}`.as("response_id"),
+ rfqId: sql<number>`${rfqTable.id}`.as("rfq_id"),
+ rfqCode: sql<string>`${rfqTable.rfqCode}`.as("rfq_code"),
+
+ // 벤더 정보
+ vendorId: sql<number>`${vendorTable.id}`.as("vendor_id"),
+ vendorName: sql<string>`${vendorTable.vendorName}`.as("vendor_name"),
+ vendorCode: sql<string>`${vendorTable.vendorCode}`.as("vendor_code"),
+
+ // 응답 상태
+ responseVersion: sql<number>`${responseTable.responseVersion}`.as("response_version"),
+ status: sql<string>`${responseTable.status}`.as("status"),
+ isLatest: sql<boolean>`${responseTable.isLatest}`.as("is_latest"),
+
+ // 제출 정보
+ submittedAt: sql<Date | null>`${responseTable.submittedAt}`.as("submitted_at"),
+ submittedByName: sql<string | null>`${submittedByUser.name}`.as("submitted_by_name"),
+
+ // 금액 정보
+ totalAmount: sql<number | null>`${responseTable.totalAmount}`.as("total_amount"),
+ currency: sql<string>`${responseTable.currency}`.as("currency"),
+
+ // 주요 조건 차이
+ deliveryDateMatch: sql<boolean>`
+ CASE
+ WHEN ${detailTable.deliveryDate} = ${responseTable.vendorDeliveryDate} THEN true
+ ELSE false
+ END
+ `.as("delivery_date_match"),
+
+ currencyMatch: sql<boolean>`
+ CASE
+ WHEN ${detailTable.currency} = ${responseTable.vendorCurrency} THEN true
+ ELSE false
+ END
+ `.as("currency_match"),
+
+ // 아이템 수
+ quotedItemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM rfq_last_vendor_quotation_items
+ WHERE vendor_response_id = ${responseTable.id}
+ )`.as("quoted_item_count"),
+
+ // 첨부파일 수
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM rfq_last_vendor_attachments
+ WHERE vendor_response_id = ${responseTable.id}
+ )`.as("attachment_count"),
+
+ // 타임스탬프
+ createdAt: sql<Date>`${responseTable.createdAt}`.as("created_at"),
+ updatedAt: sql<Date>`${responseTable.updatedAt}`.as("updated_at"),
+ })
+ .from(responseTable)
+ .leftJoin(rfqTable, eq(responseTable.rfqsLastId, rfqTable.id))
+ .leftJoin(detailTable, eq(responseTable.rfqLastDetailsId, detailTable.id))
+ .leftJoin(vendorTable, eq(responseTable.vendorId, vendorTable.id))
+ .leftJoin(submittedByUser, eq(responseTable.submittedBy, submittedByUser.id));
+});
+
+// 벤더 견적 아이템 상세 뷰
+export const vendorQuotationItemsDetailView = pgView("vendor_quotation_items_detail_view").as((qb) => {
+ const quotationTable = alias(rfqLastVendorQuotationItems, "quotation");
+ const prItemTable = alias(rfqPrItems, "pr_item");
+ const responseTable = alias(rfqLastVendorResponses, "response");
+ const rfqTable = alias(rfqsLast, "rfq");
+
+ return qb
+ .select({
+ // 견적 아이템 정보
+ quotationItemId: sql<number>`${quotationTable.id}`.as("quotation_item_id"),
+ vendorResponseId: sql<number>`${quotationTable.vendorResponseId}`.as("vendor_response_id"),
+
+ // RFQ 정보
+ rfqCode: sql<string>`${rfqTable.rfqCode}`.as("rfq_code"),
+
+ // PR 아이템 정보
+ prNo: sql<string | null>`${prItemTable.prNo}`.as("pr_no"),
+ prItem: sql<string | null>`${prItemTable.prItem}`.as("pr_item"),
+ materialCode: sql<string | null>`${prItemTable.materialCode}`.as("material_code"),
+ materialDescription: sql<string | null>`${prItemTable.materialDescription}`.as("material_description"),
+ originalQuantity: sql<number | null>`${prItemTable.quantity}`.as("original_quantity"),
+ originalDeliveryDate: sql<Date | null>`${prItemTable.deliveryDate}`.as("original_delivery_date"),
+
+ // 벤더 견적 정보
+ quotedQuantity: sql<number>`${quotationTable.quantity}`.as("quoted_quantity"),
+ unitPrice: sql<number>`${quotationTable.unitPrice}`.as("unit_price"),
+ totalPrice: sql<number>`${quotationTable.totalPrice}`.as("total_price"),
+ currency: sql<string>`${quotationTable.currency}`.as("currency"),
+ vendorDeliveryDate: sql<Date | null>`${quotationTable.vendorDeliveryDate}`.as("vendor_delivery_date"),
+ leadTime: sql<number | null>`${quotationTable.leadTime}`.as("lead_time"),
+
+ // 제조사 정보
+ manufacturer: sql<string | null>`${quotationTable.manufacturer}`.as("manufacturer"),
+ modelNo: sql<string | null>`${quotationTable.modelNo}`.as("model_no"),
+
+ // 할인 정보
+ discountRate: sql<number | null>`${quotationTable.discountRate}`.as("discount_rate"),
+ discountAmount: sql<number | null>`${quotationTable.discountAmount}`.as("discount_amount"),
+
+ // 기술 준수
+ technicalCompliance: sql<boolean>`${quotationTable.technicalCompliance}`.as("technical_compliance"),
+ alternativeProposal: sql<string | null>`${quotationTable.alternativeProposal}`.as("alternative_proposal"),
+
+ // 비고
+ itemRemark: sql<string | null>`${quotationTable.itemRemark}`.as("item_remark"),
+ deviationReason: sql<string | null>`${quotationTable.deviationReason}`.as("deviation_reason"),
+ })
+ .from(quotationTable)
+ .leftJoin(prItemTable, eq(quotationTable.rfqPrItemId, prItemTable.id))
+ .leftJoin(responseTable, eq(quotationTable.vendorResponseId, responseTable.id))
+ .leftJoin(rfqTable, eq(responseTable.rfqsLastId, rfqTable.id));
+});
+
+// Relations
+export const vendorResponseRelations = relations(
+ rfqLastVendorResponses,
+ ({ one, many }) => ({
+ rfq: one(rfqsLast, {
+ fields: [rfqLastVendorResponses.rfqsLastId],
+ references: [rfqsLast.id],
+ }),
+ rfqDetail: one(rfqLastDetails, {
+ fields: [rfqLastVendorResponses.rfqLastDetailsId],
+ references: [rfqLastDetails.id],
+ }),
+ vendor: one(vendors, {
+ fields: [rfqLastVendorResponses.vendorId],
+ references: [vendors.id],
+ }),
+ quotationItems: many(rfqLastVendorQuotationItems),
+ attachments: many(rfqLastVendorAttachments),
+ history: many(rfqLastVendorResponseHistory),
+ })
+);
+
+// Type exports
+export type VendorResponse = typeof rfqLastVendorResponses.$inferSelect;
+export type VendorQuotationItem = typeof rfqLastVendorQuotationItems.$inferSelect;
+export type VendorAttachment = typeof rfqLastVendorAttachments.$inferSelect;
+export type VendorResponseHistory = typeof rfqLastVendorResponseHistory.$inferSelect; \ No newline at end of file