diff options
| -rw-r--r-- | app/api/auth/signup-with-vendor/route.ts | 54 | ||||
| -rw-r--r-- | components/common/material/material-selector.tsx | 78 | ||||
| -rw-r--r-- | components/signup/join-form.tsx | 2 | ||||
| -rw-r--r-- | db/schema/vendors.ts | 42 | ||||
| -rw-r--r-- | lib/material/vendor-possible-material-service.ts | 207 |
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; + } +} |
