summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/api/auth/signup-with-vendor/route.ts54
-rw-r--r--components/common/material/material-selector.tsx78
-rw-r--r--components/signup/join-form.tsx2
-rw-r--r--db/schema/vendors.ts42
-rw-r--r--lib/material/vendor-possible-material-service.ts207
5 files changed, 344 insertions, 39 deletions
diff --git a/app/api/auth/signup-with-vendor/route.ts b/app/api/auth/signup-with-vendor/route.ts
index 1274d59b..fcc06ee8 100644
--- a/app/api/auth/signup-with-vendor/route.ts
+++ b/app/api/auth/signup-with-vendor/route.ts
@@ -15,9 +15,11 @@ import {
consentLogs,
policyVersions
} from '@/db/schema'
+import { vendorPossibleMateirals } from '@/db/schema/vendors'
import { insertVendor } from '@/lib/vendors/repository'
import { getErrorMessage } from '@/lib/handle-error'
import { saveFile, SaveFileResult } from '@/lib/file-stroage'
+import { MaterialSearchItem } from '@/lib/material/material-group-service'
// Types
interface AccountData {
@@ -38,7 +40,7 @@ interface VendorData {
status?: string
taxId: string
vendorTypeId: number
- items?: string
+ items?: MaterialSearchItem[]
representativeName?: string
representativeBirth?: string
representativeEmail?: string
@@ -126,6 +128,42 @@ async function storeVendorFiles(
}
}
+// 벤더 자재 정보 저장 함수
+async function saveVendorMaterials(
+ tx: PgTransaction<any, any, any>,
+ vendorId: number,
+ materials: MaterialSearchItem[],
+ userId: number,
+ userName: string
+) {
+ console.log(`벤더 자재 정보 저장: vendorId=${vendorId}, 자재수=${materials.length}`)
+
+ if (!materials || materials.length === 0) {
+ console.log('저장할 자재 정보가 없습니다.')
+ return
+ }
+
+ for (const material of materials) {
+ await tx.insert(vendorPossibleMateirals).values({
+ vendorId,
+ itemCode: material.materialGroupCode,
+ itemName: material.materialName,
+ registerUserId: userId,
+ registerUserName: userName,
+ isConfirmed: false, // 업체 입력 정보이므로 확정되지 않음
+ // 확정정보가 아니므로 부가정보들은 null
+ recentPoNo: null,
+ recentPoDate: null,
+ recentDeliveryDate: null,
+ recentOrderDate: null,
+ recentOrderUserName: null,
+ purchaseGroupCode: null,
+ })
+ }
+
+ console.log(`벤더 자재 정보 저장 완료: ${materials.length}개 자재`)
+}
+
// 사용자 계정 생성 함수 (승인 대기 상태)
async function createUserAccount(
tx: PgTransaction<any, any, any>,
@@ -245,7 +283,7 @@ function validateVendorData(vendor: VendorData): string[] {
if (!vendor.vendorName?.trim()) errors.push('업체명은 필수입니다.')
if (!vendor.vendorTypeId) errors.push('업체 유형은 필수입니다.')
- if (!vendor.items?.trim()) errors.push('공급품목은 필수입니다.')
+ if (!vendor.items || vendor.items.length === 0) errors.push('공급품목은 필수입니다.')
if (!vendor.taxId?.trim()) errors.push('사업자등록번호는 필수입니다.')
if (!vendor.country?.trim()) errors.push('국가는 필수입니다.')
if (!vendor.phone?.trim()) errors.push('대표 전화번호는 필수입니다.')
@@ -407,7 +445,6 @@ export async function POST(request: NextRequest) {
status: "PENDING_REVIEW", // 관리자 승인 대기
taxId: vendor.taxId,
vendorTypeId: vendor.vendorTypeId,
- items: vendor.items || null,
// 한국 사업자 정보
representativeName: vendor.representativeName || null,
representativeBirth: vendor.representativeBirth || null,
@@ -420,10 +457,15 @@ export async function POST(request: NextRequest) {
// 2. 사용자 계정 생성 (업체 ID와 연결)
newUser = await createUserAccount(tx, account, newVendor.id)
- // 3. 동의 정보 저장
+ // 3. 자재 정보 저장 (vendor와 user 생성 후)
+ if (vendor.items && vendor.items.length > 0) {
+ await saveVendorMaterials(tx, newVendor.id, vendor.items, newUser.id, newUser.name)
+ }
+
+ // 4. 동의 정보 저장
await saveUserConsents(tx, newUser.id, consents, clientIP, userAgent)
- // 4. 파일 저장 (보안 강화된 파일 저장 함수 사용)
+ // 5. 파일 저장 (보안 강화된 파일 저장 함수 사용)
if (businessRegistrationFiles.length > 0) {
await storeVendorFiles(tx, newVendor.id, businessRegistrationFiles, FILE_TYPES.BUSINESS_REGISTRATION, newUser.id)
}
@@ -440,7 +482,7 @@ export async function POST(request: NextRequest) {
await storeVendorFiles(tx, newVendor.id, bankAccountFiles, FILE_TYPES.BANK_ACCOUNT_COPY, newUser.id)
}
- // 5. 담당자 정보 저장
+ // 6. 담당자 정보 저장
for (const [index, contact] of vendor.contacts.entries()) {
await tx.insert(vendorContacts).values({
vendorId: newVendor.id,
diff --git a/components/common/material/material-selector.tsx b/components/common/material/material-selector.tsx
index aa68d2b5..b24a2f4f 100644
--- a/components/common/material/material-selector.tsx
+++ b/components/common/material/material-selector.tsx
@@ -32,6 +32,7 @@ interface MaterialSelectorProps {
maxSelections?: number;
className?: string;
closeOnSelect?: boolean;
+ excludeMaterialCodes?: Set<string>; // 제외할 자재그룹코드들
}
export function MaterialSelector({
@@ -43,7 +44,8 @@ export function MaterialSelector({
disabled = false,
maxSelections,
className,
- closeOnSelect = true
+ closeOnSelect = true,
+ excludeMaterialCodes
}: MaterialSelectorProps) {
const [open, setOpen] = useState(false);
@@ -203,19 +205,23 @@ export function MaterialSelector({
<Badge
key={`${material.materialGroupCode}-${material.materialName}`}
variant="secondary"
- className="gap-1"
+ className="gap-1 pr-1"
>
<span className="max-w-[200px] truncate">
{material.displayText}
</span>
{!disabled && (
- <X
- className="h-3 w-3 cursor-pointer hover:text-red-500"
+ <button
+ type="button"
+ className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center"
onClick={(e) => {
+ e.preventDefault();
e.stopPropagation();
handleRemoveMaterial(material);
}}
- />
+ >
+ <X className="h-3 w-3 hover:text-red-500" />
+ </button>
)}
</Badge>
))
@@ -255,26 +261,54 @@ export function MaterialSelector({
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
) : (
<CommandGroup>
- {searchResults.map((material) => (
- <CommandItem
- key={`${material.materialGroupCode}-${material.materialName}`}
- onSelect={() => handleMaterialSelect(material)}
- className="cursor-pointer"
- >
- <Check
+ {searchResults.map((material) => {
+ const isExcluded = excludeMaterialCodes?.has(material.materialGroupCode);
+ const isSelected = isMaterialSelected(material);
+
+ return (
+ <CommandItem
+ key={`${material.materialGroupCode}-${material.materialName}`}
+ onSelect={() => {
+ if (!isExcluded) {
+ handleMaterialSelect(material);
+ }
+ }}
className={cn(
- "mr-2 h-4 w-4",
- isMaterialSelected(material) ? "opacity-100" : "opacity-0"
+ "cursor-pointer",
+ isExcluded && "opacity-50 cursor-not-allowed bg-muted"
)}
- />
- <div className="flex-1">
- <div className="font-medium">{material.materialName}</div>
- <div className="text-xs text-muted-foreground">
- 코드: {material.materialGroupCode}
+ >
+ <div className="mr-2 h-4 w-4 flex items-center justify-center">
+ {isExcluded ? (
+ <span className="text-xs text-muted-foreground">✓</span>
+ ) : (
+ <Check
+ className={cn(
+ "h-4 w-4",
+ isSelected ? "opacity-100" : "opacity-0"
+ )}
+ />
+ )}
+ </div>
+ <div className="flex-1">
+ <div className={cn(
+ "font-medium",
+ isExcluded && "text-muted-foreground"
+ )}>
+ {material.materialName}
+ {isExcluded && (
+ <span className="ml-2 text-xs bg-red-100 text-red-600 px-2 py-1 rounded">
+ 이미 등록됨
+ </span>
+ )}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 코드: {material.materialGroupCode}
+ </div>
</div>
- </div>
- </CommandItem>
- ))}
+ </CommandItem>
+ );
+ })}
</CommandGroup>
)}
</ScrollArea>
diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx
index 4ee05c9b..f81518eb 100644
--- a/components/signup/join-form.tsx
+++ b/components/signup/join-form.tsx
@@ -1071,7 +1071,7 @@ function CompleteVendorForm({
},
vendor: {
...data,
- items: JSON.stringify(data.items), // 자재 배열을 JSON 문자열로 변환
+ items: data.items, // 자재 배열을 그대로 전달 (서버에서 vendor_possible_materials 테이블에 저장)
phone: normalizedVendorPhone,
representativePhone: normalizedRepresentativePhone,
contacts: normalizedContacts,
diff --git a/db/schema/vendors.ts b/db/schema/vendors.ts
index d53fb674..e2b00632 100644
--- a/db/schema/vendors.ts
+++ b/db/schema/vendors.ts
@@ -1,8 +1,7 @@
// db/schema/vendors.ts
-import { pgTable, serial, varchar, text, timestamp, boolean, integer ,pgView} from "drizzle-orm/pg-core";
+import { pgTable, serial, varchar, text, timestamp, boolean, integer ,pgView } from "drizzle-orm/pg-core";
import { items, materials } from "./items";
import { sql, eq, relations } from "drizzle-orm";
-import { users } from "./users";
// vendorTypes 테이블 생성
@@ -58,7 +57,7 @@ export const vendors = pgTable("vendors", {
corporateRegistrationNumber: varchar("corporate_registration_number", {
length: 100,
}),
- items: text("items"),
+ items: text("items"), // 이전 입력값들이 필요 없을 때 제거할 것. 벤더의 공급품목은 vendorPossibleMaterials 테이블에서 관리함.
creditAgency: varchar("credit_agency", { length: 50 }),
creditRating: varchar("credit_rating", { length: 50 }),
@@ -119,15 +118,39 @@ export const vendorPossibleItems = pgTable("vendor_possible_items", {
});
+// MDG 자재마스터에 대해 벤더의 공급품목 정보를 저장하는 테이블
+// 업체 회원 가입시 입력 정보는 업체입력정보(notConfirmed), 구매담당자가 PQ, 실사, 정규업체등록요청 했을 때, PO I/F 통해 받은 자재그룹코드는 확정정보(Confirmed)임
+// MDG 자재마스터와의 정규화 레벨은 의도적으로 낮춤. (스캔 비용)
export const vendorPossibleMateirals = pgTable("vendor_possible_materials", {
+ // 인공키
id: serial("id").primaryKey(),
+ // 벤더 아이디
vendorId: integer("vendor_id").notNull().references(() => vendors.id),
- // itemId: integer("item_id"), // 별도 item 테이블 연동시
- itemCode: varchar("item_code", { length: 100 })
- .notNull()
- .references(() => materials.itemCode, { onDelete: "cascade" }),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
+
+ // 자재그룹코드 (MATKL)
+ itemCode: varchar("item_code", { length: 100 }),
+ // 자재그룹명 (= 자재명)
+ itemName: varchar("item_name", { length: 255 }),
+ // 업체유형 (vendorType에서 가져오거나 강미경프로가 제공하겠다는 자재그룹코드별 업체유형 정보를 저장. confirm 건들은 강미경프로 제공 데이터를, notConfirmed 건들은 vendorType 정보 사용)
+ vendorType: varchar("vendor_type", { length: 255 }),
+
+ registerUserId: integer("register_user_id"), // 등록자ID (추후 필요시 FK 세팅)
+ registerUserName: varchar("register_user_name", { length: 255 }), // 등록자명
+
+ // 확정정보 여부 (업체입력시 확정정보 아님, 구매담당자가 PQ, 실사, 정규업체등록요청 했을 때, PO I/F 통해 받은 자재그룹코드는 확정정보임)
+ isConfirmed: boolean("is_confirmed").default(false),
+
+ // 확정정보에 한해 필요한 부가정보 (시작)
+ recentPoNo: varchar("recent_po_no", { length: 100 }), // 최근 Po. No
+ recentPoDate: timestamp("recent_po_date"), // 최근 Po일
+ recentDeliveryDate: timestamp("recent_delivery_date"), // 최근 납품일
+ recentOrderDate: timestamp("recent_order_date"), // 최근 발주일
+ recentOrderUserName: varchar("recent_order_user_name", { length: 255 }), // 최근 발주자명
+ purchaseGroupCode: varchar("purchase_group_code", { length: 10 }), // 구매그룹코드
+ // 확정정보에 한해 필요한 부가정보 (끝)
+
+ createdAt: timestamp("created_at").defaultNow().notNull(), // 등록일
+ updatedAt: timestamp("updated_at").defaultNow().notNull(), // 수정일
});
export const vendorItemsView = pgView("vendor_items_view").as((qb) => {
@@ -439,7 +462,6 @@ export const vendorsWithTypesView = pgView("vendors_with_types").as((qb) => {
representativeEmail: sql<string>`${vendors.representativeEmail}`.as("representative_email"),
representativePhone: sql<string>`${vendors.representativePhone}`.as("representative_phone"),
corporateRegistrationNumber: sql<string>`${vendors.corporateRegistrationNumber}`.as("corporate_registration_number"),
- items: sql<string>`${vendors.items}`.as("items"),
creditAgency: sql<string>`${vendors.creditAgency}`.as("credit_agency"),
creditRating: sql<string>`${vendors.creditRating}`.as("credit_rating"),
cashFlowRating: sql<string>`${vendors.cashFlowRating}`.as("cash_flow_rating"),
diff --git a/lib/material/vendor-possible-material-service.ts b/lib/material/vendor-possible-material-service.ts
new file mode 100644
index 00000000..119f4277
--- /dev/null
+++ b/lib/material/vendor-possible-material-service.ts
@@ -0,0 +1,207 @@
+"use server";
+
+import { unstable_noStore } from "next/cache";
+import { and, desc, eq, count } from "drizzle-orm";
+import db from "@/db/db";
+import { vendorPossibleMateirals, vendors, vendorTypes } from "@/db/schema/vendors";
+
+// 타입 정의
+export interface VendorPossibleMaterial {
+ id: number;
+ vendorId: number;
+ itemCode: string | null;
+ itemName: string | null;
+ registerUserId: number | null;
+ registerUserName: string | null;
+ isConfirmed: boolean | null;
+ recentPoNo: string | null;
+ recentPoDate: Date | null;
+ recentDeliveryDate: Date | null;
+ recentOrderDate: Date | null;
+ recentOrderUserName: string | null;
+ purchaseGroupCode: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ // 업체유형 정보 추가
+ vendorTypeNameEn?: string | null;
+ vendorTypeName?: string | null;
+}
+
+export interface GetVendorMaterialsParams {
+ vendorId: number;
+ page?: number;
+ perPage?: number;
+ isConfirmed?: boolean; // true: 확정정보, false: 업체입력정보
+}
+
+export interface VendorMaterialsResult {
+ data: VendorPossibleMaterial[];
+ total: number;
+ pageCount: number;
+}
+
+/**
+ * 확정정보 공급품목 전체 조회 (클라이언트 사이드 처리용)
+ */
+export async function getAllConfirmedMaterials(
+ vendorId: number
+): Promise<VendorPossibleMaterial[]> {
+ unstable_noStore();
+
+ try {
+ const baseConditions = [
+ eq(vendorPossibleMateirals.vendorId, vendorId),
+ eq(vendorPossibleMateirals.isConfirmed, true)
+ ];
+
+ const whereCondition = and(...baseConditions);
+
+ const data = await db
+ .select({
+ id: vendorPossibleMateirals.id,
+ vendorId: vendorPossibleMateirals.vendorId,
+ itemCode: vendorPossibleMateirals.itemCode,
+ itemName: vendorPossibleMateirals.itemName,
+ registerUserId: vendorPossibleMateirals.registerUserId,
+ registerUserName: vendorPossibleMateirals.registerUserName,
+ isConfirmed: vendorPossibleMateirals.isConfirmed,
+ recentPoNo: vendorPossibleMateirals.recentPoNo,
+ recentPoDate: vendorPossibleMateirals.recentPoDate,
+ recentDeliveryDate: vendorPossibleMateirals.recentDeliveryDate,
+ recentOrderDate: vendorPossibleMateirals.recentOrderDate,
+ recentOrderUserName: vendorPossibleMateirals.recentOrderUserName,
+ purchaseGroupCode: vendorPossibleMateirals.purchaseGroupCode,
+ createdAt: vendorPossibleMateirals.createdAt,
+ updatedAt: vendorPossibleMateirals.updatedAt,
+ vendorTypeNameEn: vendorTypes.nameEn,
+ vendorTypeName: vendorTypes.nameKo,
+ })
+ .from(vendorPossibleMateirals)
+ .leftJoin(vendors, eq(vendorPossibleMateirals.vendorId, vendors.id))
+ .leftJoin(vendorTypes, eq(vendors.vendorTypeId, vendorTypes.id))
+ .where(whereCondition)
+ .orderBy(desc(vendorPossibleMateirals.createdAt));
+
+ return data;
+ } catch (error) {
+ console.error("확정정보 공급품목 조회 실패:", error);
+ return [];
+ }
+}
+
+/**
+ * 업체입력정보 공급품목 전체 조회 (클라이언트 사이드 처리용)
+ */
+export async function getAllVendorInputMaterials(
+ vendorId: number
+): Promise<VendorPossibleMaterial[]> {
+ unstable_noStore();
+
+ try {
+ const baseConditions = [
+ eq(vendorPossibleMateirals.vendorId, vendorId),
+ eq(vendorPossibleMateirals.isConfirmed, false)
+ ];
+
+ const whereCondition = and(...baseConditions);
+
+ const data = await db
+ .select({
+ id: vendorPossibleMateirals.id,
+ vendorId: vendorPossibleMateirals.vendorId,
+ itemCode: vendorPossibleMateirals.itemCode,
+ itemName: vendorPossibleMateirals.itemName,
+ registerUserId: vendorPossibleMateirals.registerUserId,
+ registerUserName: vendorPossibleMateirals.registerUserName,
+ isConfirmed: vendorPossibleMateirals.isConfirmed,
+ recentPoNo: vendorPossibleMateirals.recentPoNo,
+ recentPoDate: vendorPossibleMateirals.recentPoDate,
+ recentDeliveryDate: vendorPossibleMateirals.recentDeliveryDate,
+ recentOrderDate: vendorPossibleMateirals.recentOrderDate,
+ recentOrderUserName: vendorPossibleMateirals.recentOrderUserName,
+ purchaseGroupCode: vendorPossibleMateirals.purchaseGroupCode,
+ createdAt: vendorPossibleMateirals.createdAt,
+ updatedAt: vendorPossibleMateirals.updatedAt,
+ vendorTypeNameEn: vendorTypes.nameEn,
+ vendorTypeName: vendorTypes.nameKo,
+ })
+ .from(vendorPossibleMateirals)
+ .leftJoin(vendors, eq(vendorPossibleMateirals.vendorId, vendors.id))
+ .leftJoin(vendorTypes, eq(vendors.vendorTypeId, vendorTypes.id))
+ .where(whereCondition)
+ .orderBy(desc(vendorPossibleMateirals.createdAt));
+
+ return data;
+ } catch (error) {
+ console.error("업체입력정보 공급품목 조회 실패:", error);
+ return [];
+ }
+}
+
+/**
+ * 확정정보 자재 추가 (구매담당자용)
+ */
+export async function addConfirmedMaterial(
+ vendorId: number,
+ materialData: {
+ itemCode: string;
+ itemName: string;
+ recentPoNo?: string;
+ recentPoDate?: Date;
+ recentDeliveryDate?: Date;
+ recentOrderDate?: Date;
+ recentOrderUserName?: string;
+ purchaseGroupCode?: string;
+ },
+ registerUserId: number,
+ registerUserName: string
+) {
+ unstable_noStore();
+
+ try {
+ // 중복 확인 - 같은 vendorId와 itemCode 조합이 이미 확정정보에 있는지 체크
+ const existingMaterial = await db
+ .select()
+ .from(vendorPossibleMateirals)
+ .where(
+ and(
+ eq(vendorPossibleMateirals.vendorId, vendorId),
+ eq(vendorPossibleMateirals.itemCode, materialData.itemCode),
+ eq(vendorPossibleMateirals.isConfirmed, true)
+ )
+ )
+ .limit(1);
+
+ if (existingMaterial.length > 0) {
+ throw new Error(`자재그룹코드 ${materialData.itemCode}는 이미 확정정보에 등록되어 있습니다.`);
+ }
+
+ // 새 확정정보 추가
+ const result = await db
+ .insert(vendorPossibleMateirals)
+ .values({
+ vendorId,
+ itemCode: materialData.itemCode,
+ itemName: materialData.itemName,
+ registerUserId,
+ registerUserName,
+ isConfirmed: true,
+ recentPoNo: materialData.recentPoNo || null,
+ recentPoDate: materialData.recentPoDate || null,
+ recentDeliveryDate: materialData.recentDeliveryDate || null,
+ recentOrderDate: materialData.recentOrderDate || null,
+ recentOrderUserName: materialData.recentOrderUserName || null,
+ purchaseGroupCode: materialData.purchaseGroupCode || null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ console.log(`확정정보 자재 추가 성공: vendorId=${vendorId}, itemCode=${materialData.itemCode}, 등록자=${registerUserName}`);
+
+ return result[0];
+ } catch (error) {
+ console.error("확정정보 자재 추가 실패:", error);
+ throw error;
+ }
+}