From 3c5643f0a6774ec5270868308c4b18d03fcc89c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=83=9C=EA=B7=A0?= Date: Tue, 6 Oct 2026 11:05:22 +0900 Subject: [PATCH 1/8] feat: add reviewed customer duplicate merging --- .github/workflows/deploy-ec2-ssm.yml | 1 + .../workflows/verify-direct-integration.yml | 1 + app/(app)/admin/AdminClient.tsx | 180 +-------- app/(app)/admin/page.tsx | 5 + app/(app)/customers/CustomerListClient.tsx | 8 +- .../customers/[id]/CustomerDetailClient.tsx | 45 ++- app/(app)/customers/[id]/page.tsx | 10 +- app/(app)/customers/duplicates/page.tsx | 18 + app/(app)/customers/page.tsx | 6 +- app/api/agents/route.ts | 77 ++-- app/api/buildup-import/route.ts | 7 +- app/api/customers/[id]/activities/route.ts | 39 +- app/api/customers/[id]/contact-card/route.ts | 104 +++--- app/api/customers/[id]/documents/route.ts | 101 +++--- app/api/customers/[id]/route.ts | 224 ++++++------ app/api/deals/[id]/route.ts | 188 +++++----- app/api/external/deal-events/route.ts | 41 ++- app/api/import/execute/route.ts | 59 +-- app/api/migrate/find-duplicates/route.ts | 88 +---- app/api/migrate/merge-customers/route.ts | 79 +--- components/CustomerMergePanel.tsx | 199 ++++++++++ .../2026100601_add_customer_merge.sql | 37 ++ deploy/schema-migrations/manifest.json | 10 +- lib/customer-alias.ts | 68 ++++ lib/customer-merge-http.ts | 63 ++++ lib/customer-merge.test.ts | 342 ++++++++++++++++++ lib/customer-merge.ts | 256 +++++++++++++ lib/customer-mobile.ts | 8 + package-lock.json | 6 +- package.json | 3 +- prisma/schema.prisma | 14 + 31 files changed, 1529 insertions(+), 758 deletions(-) create mode 100644 app/(app)/customers/duplicates/page.tsx create mode 100644 components/CustomerMergePanel.tsx create mode 100644 deploy/schema-migrations/2026100601_add_customer_merge.sql create mode 100644 lib/customer-alias.ts create mode 100644 lib/customer-merge-http.ts create mode 100644 lib/customer-merge.test.ts create mode 100644 lib/customer-merge.ts create mode 100644 lib/customer-mobile.ts diff --git a/.github/workflows/deploy-ec2-ssm.yml b/.github/workflows/deploy-ec2-ssm.yml index 24fc7c2..d39e207 100644 --- a/.github/workflows/deploy-ec2-ssm.yml +++ b/.github/workflows/deploy-ec2-ssm.yml @@ -96,6 +96,7 @@ jobs: npm run test:external-lookup npm run test:buildup-import npm run test:marketing-inquiries + npm run test:customer-merge npm run test:integration-contract npm run typecheck npm run security:audit diff --git a/.github/workflows/verify-direct-integration.yml b/.github/workflows/verify-direct-integration.yml index e0d19d0..ff971cb 100644 --- a/.github/workflows/verify-direct-integration.yml +++ b/.github/workflows/verify-direct-integration.yml @@ -35,4 +35,5 @@ jobs: npm run test:external-lookup npm run test:buildup-import npm run test:marketing-inquiries + npm run test:customer-merge npm run test:integration-contract diff --git a/app/(app)/admin/AdminClient.tsx b/app/(app)/admin/AdminClient.tsx index 2ab4cff..bae5112 100644 --- a/app/(app)/admin/AdminClient.tsx +++ b/app/(app)/admin/AdminClient.tsx @@ -2,10 +2,10 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' +import CustomerMergePanel from '@/components/CustomerMergePanel' import { RefreshCw, Users, Database, Link2, Unlink, - GitMerge, Search, Phone, ChevronDown, ChevronUp, Trash2, UserPlus, X, Pencil, FolderPlus, PackagePlus, Car, CreditCard, MapPin, } from 'lucide-react' @@ -80,16 +80,6 @@ interface GarageRow { detail: string | null } -type CustInfo = { id: string; name: string; phone: string | null; status: string; leadCount: number; createdAt: string } -type DupGroup = { phone: string | null; name: string; customers: CustInfo[] } - -interface DupResult { - total: number - dupCount: number - groupCount: number - groups: DupGroup[] -} - interface ProductRow { id: string name: string @@ -106,10 +96,10 @@ const PRODUCT_CATEGORIES = ['냉동', '상온', '특장', '기타'] export default function AdminClient({ stats, users: initialUsers, teams: initialTeams, products: initialProducts, vehicles: initialVehicles, - garages: initialGarages, corporateCards: initialCards, canManageUsers, + garages: initialGarages, corporateCards: initialCards, canManageUsers, canMergeCustomers, }: { stats: Stats; users: UserRow[]; teams: TeamRow[]; products: ProductRow[]; vehicles: VehicleRow[] - garages: GarageRow[]; corporateCards: CardRow[]; canManageUsers: boolean + garages: GarageRow[]; corporateCards: CardRow[]; canManageUsers: boolean; canMergeCustomers: boolean }) { const router = useRouter() @@ -461,57 +451,6 @@ export default function AdminClient({ } finally { setEditLoading(false) } } - /* 중복 진단 */ - const [dupLoading, setDupLoading] = useState(false) - const [dupResult, setDupResult] = useState(null) - const [dupError, setDupError] = useState('') - const [expanded, setExpanded] = useState>(new Set()) - const [merging, setMerging] = useState(null) // removeId being merged - const [mergeMsg, setMergeMsg] = useState('') - - /* ── 핸들러: 중복 고객 검색 ── */ - const handleFindDups = async () => { - setDupLoading(true) - setDupError('') - setDupResult(null) - setMergeMsg('') - try { - const res = await fetch('/api/migrate/find-duplicates') - const json = await res.json() - if (!res.ok) { setDupError(json.error ?? '오류'); return } - setDupResult(json) - } catch { - setDupError('네트워크 오류가 발생했습니다') - } finally { - setDupLoading(false) - } - } - - /* ── 핸들러: 고객 통합 ── */ - const handleMerge = async (keepId: string, removeId: string) => { - setMerging(removeId) - setMergeMsg('') - try { - const res = await fetch('/api/migrate/merge-customers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ keepId, removeId }), - }) - const json = await res.json() - if (!res.ok) { setMergeMsg(`오류: ${json.error}`); return } - setMergeMsg(json.message) - // 중복 목록 갱신 - await handleFindDups() - router.refresh() - } catch { - setMergeMsg('네트워크 오류가 발생했습니다') - } finally { - setMerging(null) - } - } - - const toggleGroup = (idx: number) => - setExpanded(prev => { const s = new Set(prev); s.has(idx) ? s.delete(idx) : s.add(idx); return s }) /* ── 렌더 ── */ return ( @@ -1700,118 +1639,7 @@ export default function AdminClient({ ))} - {/* ══ 중복 고객 통합 ══ */} -
-
-
-
-

- 중복 고객 통합 -

-

동일 전화번호로 중복 등록된 고객을 찾아 하나로 합칩니다

-
- -
-
- -
- {dupError &&

{dupError}

} - {mergeMsg && ( -
- ✓ {mergeMsg} -
- )} - - {!dupResult && !dupLoading && ( -

- "중복 검색" 버튼을 누르면 전화번호가 동일한 고객을 찾아줍니다 -

- )} - - {dupResult && ( - <> - {/* 요약 */} -
0 - ? 'bg-red-50 border-red-200 text-red-700' - : 'bg-emerald-50 border-emerald-200 text-emerald-700' - }`}> - {dupResult.dupCount > 0 - ? `전체 ${dupResult.total}명 중 중복 ${dupResult.groupCount}건 발견 — 제거 가능 레코드 ${dupResult.dupCount}개` - : `전체 ${dupResult.total}명 검사 완료 — 중복 없음`} -
- - {/* 중복 그룹 목록 */} - {dupResult.groups.length > 0 && ( -
- {dupResult.groups.map((group, idx) => ( -
- {/* 그룹 헤더 */} - - - {/* 펼친 상태: 통합 선택 */} - {expanded.has(idx) && ( -
-
-

- 아래에서 "남길 고객"을 결정하고, 나머지의 "통합" 버튼을 누르세요. - 리드·활동이 모두 남긴 고객으로 이전됩니다. -

-
- {group.customers.map((c, ci) => { - const isFirst = ci === 0 - const otherIds = group.customers.filter(x => x.id !== c.id).map(x => x.id) - const keepId = isFirst ? c.id : group.customers[0].id - return ( -
-
- {isFirst && 기준} -
-

{c.name}

-

- {c.phone ?? '전화 없음'} · {c.status} · - 리드 {c.leadCount}건 · {c.createdAt.slice(0, 10)} 생성 -

-
-
- {!isFirst && ( - - )} -
- ) - })} -
- )} -
- ))} -
- )} - - )} -
-
+ {canMergeCustomers && } ) diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx index 0faa442..bf05444 100644 --- a/app/(app)/admin/page.tsx +++ b/app/(app)/admin/page.tsx @@ -6,6 +6,10 @@ import AdminClient from './AdminClient' export default async function AdminPage() { const session = await auth() const canManageUsersFlag = await canManageUsers((session?.user as any)?.id) + const actor = session?.user?.id ? await prisma.user.findUnique({ + where: { id: session.user.id }, select: { role: true, employmentType: true }, + }) : null + const canMergeCustomers = !!actor && ['admin', 'ceo'].includes(actor.role) && actor.employmentType !== '사외' const [ totalCustomers, linkedDeals, unlinkedDeals, customersWithDetail, @@ -57,6 +61,7 @@ export default async function AdminPage() { garages={garages} corporateCards={corporateCards.map(c => ({ ...c, createdAt: c.createdAt.toISOString() }))} canManageUsers={canManageUsersFlag} + canMergeCustomers={canMergeCustomers} /> ) } diff --git a/app/(app)/customers/CustomerListClient.tsx b/app/(app)/customers/CustomerListClient.tsx index c0c2ee2..8028773 100644 --- a/app/(app)/customers/CustomerListClient.tsx +++ b/app/(app)/customers/CustomerListClient.tsx @@ -175,9 +175,9 @@ function fmtDate(iso: string | null) { return new Date(iso).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', '') } -interface Props { customers: Customer[] } +interface Props { customers: Customer[]; canMergeCustomers?: boolean } -export default function CustomerListClient({ customers: initial }: Props) { +export default function CustomerListClient({ customers: initial, canMergeCustomers = false }: Props) { const router = useRouter() const [customers, setCustomers] = useState(initial) const [seg, setSeg] = useState('all') @@ -326,6 +326,10 @@ export default function CustomerListClient({ customers: initial }: Props) { buildup에서 불러오기 + {canMergeCustomers && + 중복 고객 확인 + } {/* 신규 고객 추가 */} diff --git a/app/(app)/customers/[id]/CustomerDetailClient.tsx b/app/(app)/customers/[id]/CustomerDetailClient.tsx index 8bf58c5..d84c78a 100644 --- a/app/(app)/customers/[id]/CustomerDetailClient.tsx +++ b/app/(app)/customers/[id]/CustomerDetailClient.tsx @@ -175,14 +175,23 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c fd.append('type', docType) const res = await fetch(`/api/customers/${customer.id}/documents`, { method: 'POST', body: fd }) const data = await res.json() - setDocs(prev => [...prev.filter(d => d.type !== docType), data.doc]) + if (!res.ok) { setMsg(data.error ?? '파일을 업로드하지 못했습니다.'); return } + setDocs(prev => Array.isArray(data.docs) ? data.docs : [...prev, data.doc]) + } catch { + setMsg('파일 업로드 결과를 확인하지 못했습니다. 새로고침 후 확인해 주세요.') } finally { setUploading(null) } } const handleDocDelete = async (docType: string) => { if (!confirm(`"${docType}" 파일을 삭제할까요?`)) return - await fetch(`/api/customers/${customer.id}/documents?type=${encodeURIComponent(docType)}`, { method: 'DELETE' }) - setDocs(prev => prev.filter(d => d.type !== docType)) + try { + const res = await fetch(`/api/customers/${customer.id}/documents?type=${encodeURIComponent(docType)}`, { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) { setMsg(data.error ?? '파일을 삭제하지 못했습니다.'); return } + setDocs(prev => prev.filter(d => d.type !== docType)) + } catch { + setMsg('파일 삭제 결과를 확인하지 못했습니다. 새로고침 후 확인해 주세요.') + } } /* B2B 보유차량 목록 */ @@ -210,13 +219,22 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c fd.append('file', file) const res = await fetch(`/api/customers/${customer.id}/contact-card`, { method: 'POST', body: fd }) const data = await res.json() + if (!res.ok) { setMsg(data.error ?? '명함을 업로드하지 못했습니다.'); return } setMainCardUrl(data.url) + } catch { + setMsg('명함 업로드 결과를 확인하지 못했습니다. 새로고침 후 확인해 주세요.') } finally { setMainCardUploading(false) } } const handleMainCardDelete = async () => { if (!confirm('명함 이미지를 삭제할까요?')) return - await fetch(`/api/customers/${customer.id}/contact-card`, { method: 'DELETE' }) - setMainCardUrl(null) + try { + const res = await fetch(`/api/customers/${customer.id}/contact-card`, { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) { setMsg(data.error ?? '명함을 삭제하지 못했습니다.'); return } + setMainCardUrl(null) + } catch { + setMsg('명함 삭제 결과를 확인하지 못했습니다. 새로고침 후 확인해 주세요.') + } } /* B2B 관계자 목록 */ @@ -356,7 +374,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c companyAddress: f.companyAddress || null, companyPhone: f.companyPhone || null, employeeCount: f.employeeCount ? parseInt(f.employeeCount.replace(/,/g, ''), 10) : null, - contactsJson: f.customerSegment === 'B2B' ? JSON.stringify(contacts) : null, + contactsJson: f.customerSegment === 'B2B' ? JSON.stringify(contacts) : customer.contactsJson, b2bRevenue1: f.b2bRevenue1 || null, b2bRevenue2: f.b2bRevenue2 || null, b2bRevenue3: f.b2bRevenue3 || null, @@ -365,7 +383,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c totalMileage: f.totalMileage ? parseInt(f.totalMileage.replace(/,/g, ''), 10) : null, vehicleListJson: f.customerSegment === 'B2B' ? JSON.stringify(vehicleList.filter(r => r.name || r.count)) - : null, + : customer.vehicleListJson, vehicleMaker: (makerChip === '직접입력' ? makerCustom : makerChip) || null, vehicleName: f.vehicleName || null, vehiclePlateNo: f.vehiclePlateNo || null, @@ -1101,6 +1119,19 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c )} + {docs.length > 0 && ( +
+ {sectionHead('slate', '전체 첨부자료')} +
+

같은 종류의 파일도 각각 보관합니다. 아래에서 원본을 확인할 수 있습니다.

+ {docs.map((doc, i) =>
+ {doc.type} + {doc.name} + {doc.uploadedAt?.slice(0, 10)} +
)} +
+
+ )} {/* 메모 (공통) */}
{sectionHead('slate', '메모')} diff --git a/app/(app)/customers/[id]/page.tsx b/app/(app)/customers/[id]/page.tsx index 0f18e43..463bf8d 100644 --- a/app/(app)/customers/[id]/page.tsx +++ b/app/(app)/customers/[id]/page.tsx @@ -1,6 +1,7 @@ -import { notFound } from 'next/navigation' +import { notFound, redirect } from 'next/navigation' import { prisma } from '@/lib/db' import { auth } from '@/auth' +import { resolveCustomerId } from '@/lib/customer-alias' import CustomerDetailClient from './CustomerDetailClient' export default async function CustomerDetailPage({ @@ -10,7 +11,8 @@ export default async function CustomerDetailPage({ params: Promise<{ id: string }> searchParams: Promise<{ returnTo?: string }> }) { - const { id } = await params + const { id: requestedId } = await params + const id = await resolveCustomerId(prisma, requestedId) const { returnTo } = await searchParams const session = await auth() const me = session?.user as any @@ -23,5 +25,9 @@ export default async function CustomerDetailPage({ }) if (!customer) notFound() if (me?.employmentType === '사외' && customer.assignee !== me?.name) notFound() + if (id !== requestedId) { + const query = returnTo ? `?${new URLSearchParams({ returnTo })}` : '' + redirect(`/customers/${encodeURIComponent(id)}${query}`) + } return } diff --git a/app/(app)/customers/duplicates/page.tsx b/app/(app)/customers/duplicates/page.tsx new file mode 100644 index 0000000..f1a1e93 --- /dev/null +++ b/app/(app)/customers/duplicates/page.tsx @@ -0,0 +1,18 @@ +import Link from 'next/link' +import { auth } from '@/auth' +import { prisma } from '@/lib/db' +import CustomerMergePanel from '@/components/CustomerMergePanel' + +export default async function CustomerDuplicatesPage() { + const session = await auth() + const actor = session?.user?.id ? await prisma.user.findUnique({ + where: { id: session.user.id }, select: { role: true, employmentType: true }, + }) : null + if (!actor || !['admin', 'ceo'].includes(actor.role) || actor.employmentType === '사외') { + return
중복 고객 통합은 관리자·대표 계정에서 사용할 수 있습니다.
+ } + return
+ ← 고객 관리로 돌아가기 + +
+} diff --git a/app/(app)/customers/page.tsx b/app/(app)/customers/page.tsx index bba27f1..bf7a6d2 100644 --- a/app/(app)/customers/page.tsx +++ b/app/(app)/customers/page.tsx @@ -7,6 +7,10 @@ export default async function CustomersPage() { const session = await auth() const me = session?.user as any const isExternal = me?.employmentType === '사외' + const actor = me?.id ? await prisma.user.findUnique({ + where: { id: me.id }, select: { role: true, employmentType: true }, + }) : null + const canMergeCustomers = !!actor && ['admin', 'ceo'].includes(actor.role) && actor.employmentType !== '사외' const customers = await prisma.customer.findMany({ where: isExternal ? { assignee: me?.name ?? '__none__' } : undefined, @@ -30,7 +34,7 @@ export default async function CustomersPage() { {/* buildup 미처리 이벤트 배지 — 이 화면에 온 사람에게만 보인다 (#27) */} - +
) } diff --git a/app/api/agents/route.ts b/app/api/agents/route.ts index b10f888..e2fd16f 100644 --- a/app/api/agents/route.ts +++ b/app/api/agents/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' +import { customerWrite, customerErrorResponse, requireCurrentCustomerId } from '@/lib/customer-alias' // GET /api/agents?q=검색어 — Agent + 임직원 + isAgent 고객 통합 검색 export async function GET(req: NextRequest) { @@ -121,48 +122,50 @@ export async function POST(req: NextRequest) { try { const body = await req.json() const { name, phone, email, company, type, memo, userId, customerId } = body + return await customerWrite(prisma, async prisma => { + if (customerId) await requireCurrentCustomerId(prisma, customerId) - if (!name?.trim()) { - return NextResponse.json({ error: '이름은 필수입니다.' }, { status: 400 }) - } + if (!name?.trim()) { + return NextResponse.json({ error: '이름은 필수입니다.' }, { status: 400 }) + } - // 이미 등록된 Agent 여부 확인 (raw SQL: libSQL 어댑터 호환) - if (userId) { - type ARow = { id: string; name: string; type: string } - const rows = await prisma.$queryRaw`SELECT id, name, type FROM "Agent" WHERE userId = ${userId} LIMIT 1` - if (rows.length > 0) { - return NextResponse.json({ error: '이미 등록된 내부 소개자입니다.', agent: rows[0] }, { status: 409 }) + // 이미 등록된 Agent 여부 확인 (raw SQL: libSQL 어댑터 호환) + if (userId) { + type ARow = { id: string; name: string; type: string } + const rows = await prisma.$queryRaw`SELECT id, name, type FROM "Agent" WHERE userId = ${userId} LIMIT 1` + if (rows.length > 0) { + return NextResponse.json({ error: '이미 등록된 내부 소개자입니다.', agent: rows[0] }, { status: 409 }) + } } - } - if (customerId) { - type ARow = { id: string; name: string; type: string } - const rows = await prisma.$queryRaw`SELECT id, name, type FROM "Agent" WHERE customerId = ${customerId} LIMIT 1` - if (rows.length > 0) { - return NextResponse.json({ error: '이미 등록된 외부 소개자입니다.', agent: rows[0] }, { status: 409 }) + if (customerId) { + type ARow = { id: string; name: string; type: string } + const rows = await prisma.$queryRaw`SELECT id, name, type FROM "Agent" WHERE customerId = ${customerId} LIMIT 1` + if (rows.length > 0) { + return NextResponse.json({ error: '이미 등록된 외부 소개자입니다.', agent: rows[0] }, { status: 409 }) + } } - } - // libSQL 어댑터 호환: ORM create 대신 raw SQL INSERT 사용 - const id = crypto.randomUUID() - const now = new Date().toISOString() - const agentType = type || '외부' - await prisma.$executeRaw` - INSERT INTO "Agent" (id, name, phone, email, company, type, memo, userId, customerId, createdAt, updatedAt) - VALUES ( - ${id}, ${name.trim()}, ${phone || null}, ${email || null}, - ${company || null}, ${agentType}, ${memo || null}, - ${userId || null}, ${customerId || null}, ${now}, ${now} - ) - ` - - // 외부 고객을 Agent로 등록한 경우 Customer.isAgent = 1 업데이트 - if (customerId) { - await prisma.$executeRaw`UPDATE "Customer" SET "isAgent" = 1 WHERE id = ${customerId}` - } + // libSQL 어댑터 호환: ORM create 대신 raw SQL INSERT 사용 + const id = crypto.randomUUID() + const now = new Date().toISOString() + const agentType = type || '외부' + await prisma.$executeRaw` + INSERT INTO "Agent" (id, name, phone, email, company, type, memo, userId, customerId, createdAt, updatedAt) + VALUES ( + ${id}, ${name.trim()}, ${phone || null}, ${email || null}, + ${company || null}, ${agentType}, ${memo || null}, + ${userId || null}, ${customerId || null}, ${now}, ${now} + ) + ` - return NextResponse.json({ id, name: name.trim(), type: agentType }, { status: 201 }) - } catch (e: any) { - console.error('[agents POST] 에러:', e?.message) - return NextResponse.json({ error: e?.message ?? '등록 실패' }, { status: 500 }) + // 외부 고객을 Agent로 등록한 경우 Customer.isAgent = 1 업데이트 + if (customerId) { + await prisma.$executeRaw`UPDATE "Customer" SET "isAgent" = 1 WHERE id = ${customerId}` + } + + return NextResponse.json({ id, name: name.trim(), type: agentType }, { status: 201 }) + }) + } catch (error) { + return customerErrorResponse(error) } } diff --git a/app/api/buildup-import/route.ts b/app/api/buildup-import/route.ts index ce285f1..cb01b3d 100644 --- a/app/api/buildup-import/route.ts +++ b/app/api/buildup-import/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' +import { resolveCustomerId } from '@/lib/customer-alias' import { BuildupImportError, fetchBuildupCustomers, @@ -37,6 +38,9 @@ export async function GET() { fetchBuildupCustomers(), prisma.customer.findMany({ select: WARP_MATCH_SELECT }) as Promise, ]) + for (const customer of buildupCustomers) { + if (customer.warp_customer_id) customer.warp_customer_id = await resolveCustomerId(prisma, customer.warp_customer_id) + } const items = buildupCustomers.map(b => ({ customer: b, classification: classifyCustomer(b, warpCustomers) })) const counts = { new: items.filter(i => i.classification.kind === 'new').length, @@ -94,7 +98,8 @@ export async function POST(req: NextRequest) { links.push({ id: b.id, warp_customer_id: customer.id }) created++ } else { - const exists = await prisma.customer.findUnique({ where: { id: d.warpCustomerId! }, select: { id: true } }) + const canonicalId = await resolveCustomerId(prisma, d.warpCustomerId!) + const exists = await prisma.customer.findUnique({ where: { id: canonicalId }, select: { id: true } }) if (!exists) { skipped.push(d.buildupId); continue } links.push({ id: b.id, warp_customer_id: exists.id }) linked++ diff --git a/app/api/customers/[id]/activities/route.ts b/app/api/customers/[id]/activities/route.ts index 39b221f..1d54fdd 100644 --- a/app/api/customers/[id]/activities/route.ts +++ b/app/api/customers/[id]/activities/route.ts @@ -1,24 +1,28 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' +import { customerWrite, customerErrorResponse, requireCurrentCustomerId } from '@/lib/customer-alias' export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params try { const b = await req.json() - const activity = await prisma.customerActivity.create({ - data: { - customerId: id, - type: b.type || '기타', - date: b.date ? new Date(b.date) : new Date(), - content: b.content || null, - result: b.result || null, - nextAction: b.nextAction || null, - assignee: b.assignee || null, - }, + return await customerWrite(prisma, async prisma => { + await requireCurrentCustomerId(prisma, id) + const activity = await prisma.customerActivity.create({ + data: { + customerId: id, + type: b.type || '기타', + date: b.date ? new Date(b.date) : new Date(), + content: b.content || null, + result: b.result || null, + nextAction: b.nextAction || null, + assignee: b.assignee || null, + }, + }) + return NextResponse.json(activity, { status: 201 }) }) - return NextResponse.json(activity, { status: 201 }) - } catch { - return NextResponse.json({ error: '생성 실패' }, { status: 500 }) + } catch (error) { + return customerErrorResponse(error) } } @@ -27,6 +31,11 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i const { searchParams } = new URL(req.url) const activityId = searchParams.get('activityId') if (!activityId) return NextResponse.json({ error: 'activityId 필요' }, { status: 400 }) - await prisma.customerActivity.deleteMany({ where: { id: activityId, customerId } }) - return NextResponse.json({ ok: true }) + try { + return await customerWrite(prisma, async tx => { + await requireCurrentCustomerId(tx, customerId) + await tx.customerActivity.deleteMany({ where: { id: activityId, customerId } }) + return NextResponse.json({ ok: true }) + }) + } catch (error) { return customerErrorResponse(error) } } diff --git a/app/api/customers/[id]/contact-card/route.ts b/app/api/customers/[id]/contact-card/route.ts index 34ccea2..36f0fef 100644 --- a/app/api/customers/[id]/contact-card/route.ts +++ b/app/api/customers/[id]/contact-card/route.ts @@ -1,75 +1,55 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' +import { customerWrite, customerErrorResponse, CustomerMergeError, requireCurrentCustomerId } from '@/lib/customer-alias' +import { parseCustomerArray } from '@/lib/customer-merge' import path from 'path' import fs from 'fs/promises' +import { randomUUID } from 'node:crypto' const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads') - -type Contact = { id: string; name: string; phone: string; title: string; role: string; email: string; note: string; cardUrl: string | null } - -function getContacts(customer: { contactsJson: string | null }): Contact[] { - if (!customer.contactsJson) return [] - try { return JSON.parse(customer.contactsJson) } catch { return [] } -} - -async function unlinkByPath(filePath: string) { - const relPath = filePath.startsWith('/api/uploads/') ? filePath.slice('/api/uploads/'.length) : null - const absPath = relPath - ? path.join(/* turbopackIgnore: true */ UPLOADS_DIR, relPath) - : path.join(process.cwd(), 'public', filePath) - await fs.unlink(absPath).catch(() => {}) +type Contact = { id: string; cardUrl: string | null } + +async function saveCard(id: string, contactId: string | null, url: string | null) { + return customerWrite(prisma, async tx => { + await requireCurrentCustomerId(tx, id) + const customer = await tx.customer.findUniqueOrThrow({ where: { id } }) + if (contactId) { + const contacts = parseCustomerArray(customer.contactsJson, '법인 관계자') as Contact[] + if (contacts.filter(c => c?.id === contactId).length !== 1) { + throw new CustomerMergeError(409, '관계자 정보가 중복되거나 변경되었습니다. 목록을 먼저 확인해 주세요.') + } + await tx.customer.update({ where: { id }, data: { + contactsJson: JSON.stringify(contacts.map(c => c?.id === contactId ? { ...c, cardUrl: url } : c)), + } }) + } else { + await tx.customer.update({ where: { id }, data: { mainContactCardUrl: url } }) + } + // Preserve previous files, including paths referenced by archived customer snapshots. + }) } export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - - const customer = await prisma.customer.findUnique({ where: { id } }) - if (!customer) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - - const form = await req.formData() - const file = form.get('file') as File | null - const contactId = form.get('contactId') as string | null // 없으면 대표 거래처 담당자 명함 - if (!file) return NextResponse.json({ error: 'Missing file' }, { status: 400 }) - - const uploadDir = path.join(UPLOADS_DIR, 'customers', id, 'contacts') - await fs.mkdir(uploadDir, { recursive: true }) - - const safeName = `${Date.now()}_${file.name.replace(/[^\w.\-]/g, '_')}` - const absPath = path.join(uploadDir, safeName) - await fs.writeFile(absPath, Buffer.from(await file.arrayBuffer())) - const url = `/api/uploads/customers/${id}/contacts/${safeName}` - - if (contactId) { - const contacts = getContacts(customer) - const target = contacts.find(c => c.id === contactId) - if (target?.cardUrl) await unlinkByPath(target.cardUrl) - const updated = contacts.map(c => c.id === contactId ? { ...c, cardUrl: url } : c) - await prisma.customer.update({ where: { id }, data: { contactsJson: JSON.stringify(updated) } }) - } else { - if (customer.mainContactCardUrl) await unlinkByPath(customer.mainContactCardUrl) - await prisma.customer.update({ where: { id }, data: { mainContactCardUrl: url } }) - } - - return NextResponse.json({ url }) + try { + const { id } = await params + await requireCurrentCustomerId(prisma, id) + const form = await req.formData() + const file = form.get('file') as File | null + const contactId = form.get('contactId') as string | null + if (!file) return NextResponse.json({ error: '파일이 필요합니다.' }, { status: 400 }) + const uploadDir = path.join(UPLOADS_DIR, 'customers', id, 'contacts') + await fs.mkdir(uploadDir, { recursive: true }) + const safeName = `${randomUUID()}_${file.name.replace(/[^\w.\-]/g, '_')}` + await fs.writeFile(path.join(uploadDir, safeName), Buffer.from(await file.arrayBuffer())) + const url = `/api/uploads/customers/${id}/contacts/${safeName}` + await saveCard(id, contactId, url) + return NextResponse.json({ url }) + } catch (error) { return customerErrorResponse(error) } } export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const contactId = req.nextUrl.searchParams.get('contactId') - - const customer = await prisma.customer.findUnique({ where: { id } }) - if (!customer) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - - if (contactId) { - const contacts = getContacts(customer) - const target = contacts.find(c => c.id === contactId) - if (target?.cardUrl) await unlinkByPath(target.cardUrl) - const updated = contacts.map(c => c.id === contactId ? { ...c, cardUrl: null } : c) - await prisma.customer.update({ where: { id }, data: { contactsJson: JSON.stringify(updated) } }) - } else { - if (customer.mainContactCardUrl) await unlinkByPath(customer.mainContactCardUrl) - await prisma.customer.update({ where: { id }, data: { mainContactCardUrl: null } }) - } - - return NextResponse.json({ ok: true }) + try { + const { id } = await params + await saveCard(id, req.nextUrl.searchParams.get('contactId'), null) + return NextResponse.json({ ok: true }) + } catch (error) { return customerErrorResponse(error) } } diff --git a/app/api/customers/[id]/documents/route.ts b/app/api/customers/[id]/documents/route.ts index 76d8425..d3d2a68 100644 --- a/app/api/customers/[id]/documents/route.ts +++ b/app/api/customers/[id]/documents/route.ts @@ -1,72 +1,55 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' +import { customerWrite, customerErrorResponse, CustomerMergeError, requireCurrentCustomerId } from '@/lib/customer-alias' +import { parseCustomerArray } from '@/lib/customer-merge' import path from 'path' import fs from 'fs/promises' +import { randomUUID } from 'node:crypto' const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads') - type DocMeta = { type: string; name: string; path: string; size: number; uploadedAt: string } -function getDocs(customer: { documentsJson: string | null }): DocMeta[] { - if (!customer.documentsJson) return [] - try { return JSON.parse(customer.documentsJson) } catch { return [] } -} - export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - - const customer = await prisma.customer.findUnique({ where: { id } }) - if (!customer) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - - const form = await req.formData() - const file = form.get('file') as File | null - const docType = form.get('type') as string | null - if (!file || !docType) return NextResponse.json({ error: 'Missing file or type' }, { status: 400 }) - - const uploadDir = path.join(UPLOADS_DIR, 'customers', id) - await fs.mkdir(uploadDir, { recursive: true }) - - const safeName = `${Date.now()}_${file.name.replace(/[^\w.\-]/g, '_')}` - const absPath = path.join(uploadDir, safeName) - await fs.writeFile(absPath, Buffer.from(await file.arrayBuffer())) - - const docs = getDocs(customer).filter(d => d.type !== docType) - const doc: DocMeta = { - type: docType, - name: file.name, - path: `/api/uploads/customers/${id}/${safeName}`, - size: file.size, - uploadedAt: new Date().toISOString(), - } - docs.push(doc) - - await prisma.customer.update({ where: { id }, data: { documentsJson: JSON.stringify(docs) } }) - return NextResponse.json({ doc }) + try { + const { id } = await params + await requireCurrentCustomerId(prisma, id) + const form = await req.formData() + const file = form.get('file') as File | null + const docType = form.get('type') as string | null + if (!file || !docType) return NextResponse.json({ error: '파일과 문서 유형이 필요합니다.' }, { status: 400 }) + const uploadDir = path.join(UPLOADS_DIR, 'customers', id) + await fs.mkdir(uploadDir, { recursive: true }) + const safeName = `${randomUUID()}_${file.name.replace(/[^\w.\-]/g, '_')}` + await fs.writeFile(path.join(uploadDir, safeName), Buffer.from(await file.arrayBuffer())) + const doc: DocMeta = { type: docType, name: file.name, path: `/api/uploads/customers/${id}/${safeName}`, + size: file.size, uploadedAt: new Date().toISOString() } + return await customerWrite(prisma, async tx => { + // Re-read after upload; another writer may have merged the customer or appended metadata. + await requireCurrentCustomerId(tx, id) + const customer = await tx.customer.findUniqueOrThrow({ where: { id } }) + const docs = parseCustomerArray(customer.documentsJson, '첨부 문서') + const updated = [...docs, doc] + await tx.customer.update({ where: { id }, data: { documentsJson: JSON.stringify(updated) } }) + return NextResponse.json({ doc, docs: updated }) + }) + } catch (error) { return customerErrorResponse(error) } } export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const docType = req.nextUrl.searchParams.get('type') - if (!docType) return NextResponse.json({ error: 'Missing type' }, { status: 400 }) - - const customer = await prisma.customer.findUnique({ where: { id } }) - if (!customer) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - - const docs = getDocs(customer) - const target = docs.find(d => d.type === docType) - - if (target) { - // 신규 경로(/api/uploads/...)는 UPLOADS_DIR 기준, 구 경로(/uploads/...)는 public/ 기준으로 해석 - const relPath = target.path.startsWith('/api/uploads/') - ? target.path.slice('/api/uploads/'.length) - : null - const absPath = relPath - ? path.join(/* turbopackIgnore: true */ UPLOADS_DIR, relPath) - : path.join(process.cwd(), 'public', target.path) - await fs.unlink(absPath).catch(() => {}) - } - - const updated = docs.filter(d => d.type !== docType) - await prisma.customer.update({ where: { id }, data: { documentsJson: JSON.stringify(updated) } }) - return NextResponse.json({ ok: true }) + try { + const { id } = await params + const docType = req.nextUrl.searchParams.get('type') + if (!docType) return NextResponse.json({ error: '문서 유형이 필요합니다.' }, { status: 400 }) + return await customerWrite(prisma, async tx => { + await requireCurrentCustomerId(tx, id) + const customer = await tx.customer.findUniqueOrThrow({ where: { id } }) + const docs = parseCustomerArray(customer.documentsJson, '첨부 문서') as DocMeta[] + if (docs.filter(d => d?.type === docType).length > 1) { + throw new CustomerMergeError(409, '같은 유형의 문서가 여러 개입니다. 개별 문서를 확인해 주세요.') + } + await tx.customer.update({ where: { id }, data: { documentsJson: JSON.stringify(docs.filter(d => d?.type !== docType)) } }) + // Physical files may be referenced by immutable merge snapshots; retain their original paths. + return NextResponse.json({ ok: true }) + }) + } catch (error) { return customerErrorResponse(error) } } diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 3c8924a..b65a7a4 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { auth } from '@/auth' +import { customerWrite, customerErrorResponse, CustomerMergeError, requireCurrentCustomerId, resolveCustomerId } from '@/lib/customer-alias' export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params + const { id: requestedId } = await params + const id = await resolveCustomerId(prisma, requestedId) const customer = await prisma.customer.findUnique({ where: { id }, include: { @@ -19,120 +21,134 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: const { id } = await params try { const [b, session] = await Promise.all([req.json(), auth()]) - const me = session?.user as any - if (me?.employmentType === '사외') { - const owner = await prisma.customer.findUnique({ where: { id }, select: { assignee: true } }) - if (owner?.assignee !== me?.name) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - const n = (v: unknown) => (v === undefined ? undefined : v ?? null) + return await customerWrite(prisma, async prisma => { + await requireCurrentCustomerId(prisma, id) + const me = session?.user as any + if (me?.employmentType === '사외') { + const owner = await prisma.customer.findUnique({ where: { id }, select: { assignee: true } }) + if (owner?.assignee !== me?.name) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const n = (v: unknown) => (v === undefined ? undefined : v ?? null) - // 동일 전화번호의 다른 고객이 이미 있으면 중복 생성 방지를 위해 저장 자체를 차단한다. - if (b.phone !== undefined && b.phone) { - const dup = await prisma.customer.findFirst({ - where: { phone: b.phone, NOT: { id } }, - select: { id: true, name: true }, - }) - if (dup) { - return NextResponse.json( - { error: `이미 등록된 전화번호입니다 (기존 고객: ${dup.name || '이름없음'})` }, - { status: 409 }, - ) + // 동일 전화번호의 다른 고객이 이미 있으면 중복 생성 방지를 위해 저장 자체를 차단한다. + if (b.phone !== undefined && b.phone) { + const dup = await prisma.customer.findFirst({ + where: { phone: b.phone, NOT: { id } }, + select: { id: true, name: true }, + }) + if (dup) { + return NextResponse.json( + { error: `이미 등록된 전화번호입니다 (기존 고객: ${dup.name || '이름없음'})` }, + { status: 409 }, + ) + } } - } - const customer = await prisma.customer.update({ - where: { id }, - data: { - /* ── 기본 정보 ── */ - ...(b.name !== undefined && { name: b.name }), - ...(b.phone !== undefined && { phone: n(b.phone) }), - ...(b.email !== undefined && { email: n(b.email) }), - ...(b.customerSegment !== undefined && { customerSegment: n(b.customerSegment) }), - ...(b.customerCategory !== undefined && { customerCategory: n(b.customerCategory) }), - ...(b.status !== undefined && { status: b.status }), - ...(b.grade !== undefined && { grade: n(b.grade) }), - ...(b.tags !== undefined && { tags: n(b.tags) }), - ...(b.source !== undefined && { source: n(b.source) }), - ...(b.collectedAt !== undefined && { collectedAt: b.collectedAt ? new Date(b.collectedAt) : null }), - ...(b.assignee !== undefined && { assignee: n(b.assignee) }), - ...(b.memo !== undefined && { memo: n(b.memo) }), - ...(b.regionCity !== undefined && { regionCity: n(b.regionCity) }), - ...(b.regionDist !== undefined && { regionDist: n(b.regionDist) }), + const customer = await prisma.customer.update({ + where: { id }, + data: { + /* ── 기본 정보 ── */ + ...(b.name !== undefined && { name: b.name }), + ...(b.phone !== undefined && { phone: n(b.phone) }), + ...(b.email !== undefined && { email: n(b.email) }), + ...(b.customerSegment !== undefined && { customerSegment: n(b.customerSegment) }), + ...(b.customerCategory !== undefined && { customerCategory: n(b.customerCategory) }), + ...(b.status !== undefined && { status: b.status }), + ...(b.grade !== undefined && { grade: n(b.grade) }), + ...(b.tags !== undefined && { tags: n(b.tags) }), + ...(b.source !== undefined && { source: n(b.source) }), + ...(b.collectedAt !== undefined && { collectedAt: b.collectedAt ? new Date(b.collectedAt) : null }), + ...(b.assignee !== undefined && { assignee: n(b.assignee) }), + ...(b.memo !== undefined && { memo: n(b.memo) }), + ...(b.regionCity !== undefined && { regionCity: n(b.regionCity) }), + ...(b.regionDist !== undefined && { regionDist: n(b.regionDist) }), - /* ── B2C 개인 정보 ── */ - ...(b.gender !== undefined && { gender: n(b.gender) }), - ...(b.birthYear !== undefined && { birthYear: n(b.birthYear) }), - ...(b.birthInfo !== undefined && { birthInfo: n(b.birthInfo) }), - ...(b.maritalStatus !== undefined && { maritalStatus: n(b.maritalStatus) }), - ...(b.childrenCount !== undefined && { childrenCount: b.childrenCount ?? null }), - ...(b.addressDetail !== undefined && { addressDetail: n(b.addressDetail) }), - ...(b.isSoleProprietor !== undefined && { isSoleProprietor: b.isSoleProprietor ?? null }), - ...(b.soleBusinessName !== undefined && { soleBusinessName: n(b.soleBusinessName) }), - ...(b.soleBusinessNo !== undefined && { soleBusinessNo: n(b.soleBusinessNo) }), - ...(b.soleBusinessType !== undefined && { soleBusinessType: n(b.soleBusinessType) }), + /* ── B2C 개인 정보 ── */ + ...(b.gender !== undefined && { gender: n(b.gender) }), + ...(b.birthYear !== undefined && { birthYear: n(b.birthYear) }), + ...(b.birthInfo !== undefined && { birthInfo: n(b.birthInfo) }), + ...(b.maritalStatus !== undefined && { maritalStatus: n(b.maritalStatus) }), + ...(b.childrenCount !== undefined && { childrenCount: b.childrenCount ?? null }), + ...(b.addressDetail !== undefined && { addressDetail: n(b.addressDetail) }), + ...(b.isSoleProprietor !== undefined && { isSoleProprietor: b.isSoleProprietor ?? null }), + ...(b.soleBusinessName !== undefined && { soleBusinessName: n(b.soleBusinessName) }), + ...(b.soleBusinessNo !== undefined && { soleBusinessNo: n(b.soleBusinessNo) }), + ...(b.soleBusinessType !== undefined && { soleBusinessType: n(b.soleBusinessType) }), - /* ── B2B 법인 정보 ── */ - ...(b.b2bCategory !== undefined && { b2bCategory: n(b.b2bCategory) }), - ...(b.companyName !== undefined && { companyName: n(b.companyName) }), - ...(b.businessRegNo !== undefined && { businessRegNo: n(b.businessRegNo) }), - ...(b.contactTitle !== undefined && { contactTitle: n(b.contactTitle) }), - ...(b.industry !== undefined && { industry: n(b.industry) }), - ...(b.companyAddress !== undefined && { companyAddress: n(b.companyAddress) }), - ...(b.companyPhone !== undefined && { companyPhone: n(b.companyPhone) }), - ...(b.employeeCount !== undefined && { employeeCount: b.employeeCount ?? null }), - ...(b.contactsJson !== undefined && { contactsJson: n(b.contactsJson) }), + /* ── B2B 법인 정보 ── */ + ...(b.b2bCategory !== undefined && { b2bCategory: n(b.b2bCategory) }), + ...(b.companyName !== undefined && { companyName: n(b.companyName) }), + ...(b.businessRegNo !== undefined && { businessRegNo: n(b.businessRegNo) }), + ...(b.contactTitle !== undefined && { contactTitle: n(b.contactTitle) }), + ...(b.industry !== undefined && { industry: n(b.industry) }), + ...(b.companyAddress !== undefined && { companyAddress: n(b.companyAddress) }), + ...(b.companyPhone !== undefined && { companyPhone: n(b.companyPhone) }), + ...(b.employeeCount !== undefined && { employeeCount: b.employeeCount ?? null }), + ...(b.contactsJson !== undefined && { contactsJson: n(b.contactsJson) }), - /* ── B2B 법인 매출 ── */ - ...(b.b2bRevenue1 !== undefined && { b2bRevenue1: n(b.b2bRevenue1) }), - ...(b.b2bRevenue2 !== undefined && { b2bRevenue2: n(b.b2bRevenue2) }), - ...(b.b2bRevenue3 !== undefined && { b2bRevenue3: n(b.b2bRevenue3) }), + /* ── B2B 법인 매출 ── */ + ...(b.b2bRevenue1 !== undefined && { b2bRevenue1: n(b.b2bRevenue1) }), + ...(b.b2bRevenue2 !== undefined && { b2bRevenue2: n(b.b2bRevenue2) }), + ...(b.b2bRevenue3 !== undefined && { b2bRevenue3: n(b.b2bRevenue3) }), - /* ── 차량 정보 ── */ - ...(b.hasVehicle !== undefined && { hasVehicle: b.hasVehicle ?? null }), - ...(b.vehicleMaker !== undefined && { vehicleMaker: n(b.vehicleMaker) }), - ...(b.vehicleName !== undefined && { vehicleName: n(b.vehicleName) }), - ...(b.vehiclePlateNo !== undefined && { vehiclePlateNo: n(b.vehiclePlateNo) }), - ...(b.vehicleYear !== undefined && { vehicleYear: n(b.vehicleYear) }), - ...(b.totalMileage !== undefined && { totalMileage: b.totalMileage ?? null }), - ...(b.vehicleListJson !== undefined && { vehicleListJson: n(b.vehicleListJson) }), - ...(b.truckType1 !== undefined && { truckType1: n(b.truckType1) }), - ...(b.truckType2 !== undefined && { truckType2: n(b.truckType2) }), - ...(b.truckType3 !== undefined && { truckType3: n(b.truckType3) }), - ...(b.truckType4 !== undefined && { truckType4: n(b.truckType4) }), + /* ── 차량 정보 ── */ + ...(b.hasVehicle !== undefined && { hasVehicle: b.hasVehicle ?? null }), + ...(b.vehicleMaker !== undefined && { vehicleMaker: n(b.vehicleMaker) }), + ...(b.vehicleName !== undefined && { vehicleName: n(b.vehicleName) }), + ...(b.vehiclePlateNo !== undefined && { vehiclePlateNo: n(b.vehiclePlateNo) }), + ...(b.vehicleYear !== undefined && { vehicleYear: n(b.vehicleYear) }), + ...(b.totalMileage !== undefined && { totalMileage: b.totalMileage ?? null }), + ...(b.vehicleListJson !== undefined && { vehicleListJson: n(b.vehicleListJson) }), + ...(b.truckType1 !== undefined && { truckType1: n(b.truckType1) }), + ...(b.truckType2 !== undefined && { truckType2: n(b.truckType2) }), + ...(b.truckType3 !== undefined && { truckType3: n(b.truckType3) }), + ...(b.truckType4 !== undefined && { truckType4: n(b.truckType4) }), - /* ── 화주 정보 ── */ - ...(b.shipperName !== undefined && { shipperName: n(b.shipperName) }), - ...(b.cargoType !== undefined && { cargoType: n(b.cargoType) }), - ...(b.deliveryCity !== undefined && { deliveryCity: n(b.deliveryCity) }), - ...(b.deliveryDist !== undefined && { deliveryDist: n(b.deliveryDist) }), - ...(b.deliveryFreq !== undefined && { deliveryFreq: n(b.deliveryFreq) }), - ...(b.workShift !== undefined && { workShift: n(b.workShift) }), - ...(b.monthlyIncome !== undefined && { monthlyIncome: n(b.monthlyIncome) }), - ...(b.cargoNote !== undefined && { cargoNote: n(b.cargoNote) }), - }, + /* ── 화주 정보 ── */ + ...(b.shipperName !== undefined && { shipperName: n(b.shipperName) }), + ...(b.cargoType !== undefined && { cargoType: n(b.cargoType) }), + ...(b.deliveryCity !== undefined && { deliveryCity: n(b.deliveryCity) }), + ...(b.deliveryDist !== undefined && { deliveryDist: n(b.deliveryDist) }), + ...(b.deliveryFreq !== undefined && { deliveryFreq: n(b.deliveryFreq) }), + ...(b.workShift !== undefined && { workShift: n(b.workShift) }), + ...(b.monthlyIncome !== undefined && { monthlyIncome: n(b.monthlyIncome) }), + ...(b.cargoNote !== undefined && { cargoNote: n(b.cargoNote) }), + }, + }) + // libSQL Boolean 호환: isAgent는 raw SQL로 별도 업데이트 + if (b.isAgent !== undefined) { + await prisma.$executeRaw`UPDATE "Customer" SET "isAgent" = ${b.isAgent ? 1 : 0} WHERE id = ${id}` + } + // 고객명이 바뀌면 이 고객에서 생성된 리드(SalesDeal)의 이름도 함께 갱신 + if (b.name !== undefined && b.name?.trim()) { + await prisma.salesDeal.updateMany({ where: { customerId: id }, data: { name: b.name.trim() } }) + } + return NextResponse.json(customer) }) - // libSQL Boolean 호환: isAgent는 raw SQL로 별도 업데이트 - if (b.isAgent !== undefined) { - await prisma.$executeRaw`UPDATE "Customer" SET "isAgent" = ${b.isAgent ? 1 : 0} WHERE id = ${id}` - } - // 고객명이 바뀌면 이 고객에서 생성된 리드(SalesDeal)의 이름도 함께 갱신 - if (b.name !== undefined && b.name?.trim()) { - await prisma.salesDeal.updateMany({ where: { customerId: id }, data: { name: b.name.trim() } }) - } - return NextResponse.json(customer) - } catch { - return NextResponse.json({ error: '수정 실패' }, { status: 500 }) + } catch (error) { + return customerErrorResponse(error) } } export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const session = await auth() - const me = session?.user as any - if (me?.employmentType === '사외') { - const owner = await prisma.customer.findUnique({ where: { id }, select: { assignee: true } }) - if (owner?.assignee !== me?.name) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - await prisma.customer.delete({ where: { id } }) - return NextResponse.json({ ok: true }) + try { + const { id } = await params + const session = await auth() + const me = session?.user + if (!me?.id) throw new CustomerMergeError(401, '로그인이 필요합니다.') + return await customerWrite(prisma, async tx => { + await requireCurrentCustomerId(tx, id) + const actor = await tx.user.findUnique({ where: { id: me.id }, select: { name: true, employmentType: true } }) + if (!actor) throw new CustomerMergeError(403, '권한이 없습니다.') + const customer = await tx.customer.findUnique({ where: { id }, select: { assignee: true } }) + if (actor.employmentType === '사외' && customer?.assignee !== actor.name) { + throw new CustomerMergeError(403, '권한이 없습니다.') + } + if (await tx.customerMerge.count({ where: { targetId: id } })) { + throw new CustomerMergeError(409, '통합 이력이 있는 고객은 삭제할 수 없습니다.') + } + await tx.customer.delete({ where: { id } }) + return NextResponse.json({ ok: true }) + }) + } catch (error) { return customerErrorResponse(error) } } diff --git a/app/api/deals/[id]/route.ts b/app/api/deals/[id]/route.ts index 90105b5..431ff1d 100644 --- a/app/api/deals/[id]/route.ts +++ b/app/api/deals/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { auth } from '@/auth' import { canViewAllLeads } from '@/lib/permissions' +import { customerWrite, customerErrorResponse, requireCurrentCustomerId } from '@/lib/customer-alias' import { logStageChange } from '@/lib/stageHistory' const toDate = (v: unknown) => (v ? new Date(v as string) : null) @@ -38,96 +39,99 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } const before = b.stageCode !== undefined ? ownerCheck : null - const deal = await prisma.salesDeal.update({ - where: { id }, - data: { - // agentId는 FK 필드 — libSQL 어댑터 호환을 위해 ORM update에서 제외, 아래 $executeRaw로 처리 - ...(b.stage !== undefined && { stage: b.stage }), - ...(b.name !== undefined && { name: b.name.trim() }), - ...(b.phone !== undefined && { phone: b.phone || null }), - ...(b.birthYear !== undefined && { birthYear: b.birthYear ?? null }), - ...(b.regionCity !== undefined && { regionCity: b.regionCity || null }), - ...(b.regionDist !== undefined && { regionDist: b.regionDist || null }), - ...(b.leadType !== undefined && { leadType: b.leadType || null }), - ...(b.source !== undefined && { source: b.source || null }), - ...(b.collectedAt !== undefined && { collectedAt: toDate(b.collectedAt) }), - ...(b.referrer !== undefined && { referrer: b.referrer || null }), - ...(b.phoneConsultedAt !== undefined && { phoneConsultedAt: toDate(b.phoneConsultedAt) }), - ...(b.currentVehicle !== undefined && { currentVehicle: b.currentVehicle || null }), - ...(b.tradeIn !== undefined && { tradeIn: b.tradeIn || null }), - ...(b.switchReason !== undefined && { switchReason: b.switchReason || null }), - ...(b.purchaseTiming !== undefined && { purchaseTiming: b.purchaseTiming || null }), - ...(b.faceConsultedAt !== undefined && { faceConsultedAt: toDate(b.faceConsultedAt) }), - ...(b.customerType !== undefined && { customerType: b.customerType || null }), - ...(b.purchaseMethod !== undefined && { purchaseMethod: b.purchaseMethod || null }), - ...(b.capitalCheckedAt !== undefined && { capitalCheckedAt: toDate(b.capitalCheckedAt) }), - ...(b.capitalResult !== undefined && { capitalResult: b.capitalResult || null }), - ...(b.contractedAt !== undefined && { contractedAt: toDate(b.contractedAt) }), - ...(b.vehicleModel !== undefined && { vehicleModel: b.vehicleModel || null }), - ...(b.vehicleCount !== undefined && { vehicleCount: b.vehicleCount ?? null }), - ...(b.bodyType !== undefined && { bodyType: b.bodyType || null }), - ...(b.tempType !== undefined && { tempType: b.tempType || null }), - ...(b.bodyOptions !== undefined && { bodyOptions: b.bodyOptions || null }), - ...(b.vehiclePrice !== undefined && { vehiclePrice: b.vehiclePrice ?? null }), - ...(b.subsidyAmount !== undefined && { subsidyAmount: b.subsidyAmount ?? null }), - ...(b.totalPrice !== undefined && { totalPrice: b.totalPrice ?? null }), - ...(b.downPayment !== undefined && { downPayment: b.downPayment ?? null }), - ...(b.monthlyPayment !== undefined && { monthlyPayment: b.monthlyPayment ?? null }), - ...(b.loanMonths !== undefined && { loanMonths: b.loanMonths ?? null }), - ...(b.deliveredAt !== undefined && { deliveredAt: toDate(b.deliveredAt) }), - ...(b.assignee !== undefined && { assignee: b.assignee || null }), - ...(b.memo !== undefined && { memo: b.memo || null }), - ...(b.purchaseGoal !== undefined && { purchaseGoal: b.purchaseGoal || null }), - ...(b.keyFactors !== undefined && { keyFactors: b.keyFactors || null }), - ...(b.lostReason !== undefined && { lostReason: b.lostReason || null }), - ...(b.holdReason !== undefined && { holdReason: b.holdReason || null }), - ...(b.stageCode !== undefined && { stageCode: b.stageCode || null, - stageChangedAt: new Date() }), - ...(b.checklistJson !== undefined && { checklistJson: b.checklistJson || null }), - ...(b.salesStatus !== undefined && { salesStatus: b.salesStatus || null, - ...(['완료','이탈'].includes(b.salesStatus) && { closedAt: new Date() }) }), - ...(b.customerId !== undefined && { customerId: b.customerId || null }), - // CRM 고객 세그먼트 - ...(b.customerSegment !== undefined && { customerSegment: b.customerSegment || null }), - ...(b.customerCategory !== undefined && { customerCategory: b.customerCategory || null }), - // CRM 기본정보 (B2C) - ...(b.email !== undefined && { email: b.email || null }), - ...(b.gender !== undefined && { gender: b.gender || null }), - ...(b.birthInfo !== undefined && { birthInfo: b.birthInfo || null }), - ...(b.maritalStatus !== undefined && { maritalStatus: b.maritalStatus || null }), - ...(b.childrenCount !== undefined && { childrenCount: b.childrenCount ?? null }), - ...(b.addressDetail !== undefined && { addressDetail: b.addressDetail || null }), - ...(b.isSoleProprietor !== undefined && { isSoleProprietor: b.isSoleProprietor ?? null }), - ...(b.soleBusinessName !== undefined && { soleBusinessName: b.soleBusinessName || null }), - ...(b.soleBusinessNo !== undefined && { soleBusinessNo: b.soleBusinessNo || null }), - ...(b.soleBusinessType !== undefined && { soleBusinessType: b.soleBusinessType || null }), - // CRM 법인정보 (B2B) - ...(b.b2bCategory !== undefined && { b2bCategory: b.b2bCategory || null }), - ...(b.companyName !== undefined && { companyName: b.companyName || null }), - ...(b.businessRegNo !== undefined && { businessRegNo: b.businessRegNo || null }), - ...(b.contactTitle !== undefined && { contactTitle: b.contactTitle || null }), - ...(b.industry !== undefined && { industry: b.industry || null }), - ...(b.companyAddress !== undefined && { companyAddress: b.companyAddress || null }), - ...(b.companyPhone !== undefined && { companyPhone: b.companyPhone || null }), - // CRM 차량정보 - ...(b.hasVehicle !== undefined && { hasVehicle: b.hasVehicle ?? null }), - ...(b.vehicleMaker !== undefined && { vehicleMaker: b.vehicleMaker || null }), - ...(b.vehicleName !== undefined && { vehicleName: b.vehicleName || null }), - ...(b.vehicleYear !== undefined && { vehicleYear: b.vehicleYear || null }), - ...(b.totalMileage !== undefined && { totalMileage: b.totalMileage ?? null }), - ...(b.truckType1 !== undefined && { truckType1: b.truckType1 || null }), - ...(b.truckType2 !== undefined && { truckType2: b.truckType2 || null }), - ...(b.truckType3 !== undefined && { truckType3: b.truckType3 || null }), - // CRM 화주정보 - ...(b.shipperName !== undefined && { shipperName: b.shipperName || null }), - ...(b.cargoType !== undefined && { cargoType: b.cargoType || null }), - ...(b.deliveryCity !== undefined && { deliveryCity: b.deliveryCity || null }), - ...(b.deliveryDist !== undefined && { deliveryDist: b.deliveryDist || null }), - ...(b.deliveryFreq !== undefined && { deliveryFreq: b.deliveryFreq || null }), - ...(b.workShift !== undefined && { workShift: b.workShift || null }), - ...(b.monthlyIncome !== undefined && { monthlyIncome: b.monthlyIncome || null }), - ...(b.cargoNote !== undefined && { cargoNote: b.cargoNote || null }), - }, + const deal = await customerWrite(prisma, async tx => { + if (b.customerId) await requireCurrentCustomerId(tx, b.customerId) + return tx.salesDeal.update({ + where: { id }, + data: { + // agentId는 FK 필드 — libSQL 어댑터 호환을 위해 ORM update에서 제외, 아래 $executeRaw로 처리 + ...(b.stage !== undefined && { stage: b.stage }), + ...(b.name !== undefined && { name: b.name.trim() }), + ...(b.phone !== undefined && { phone: b.phone || null }), + ...(b.birthYear !== undefined && { birthYear: b.birthYear ?? null }), + ...(b.regionCity !== undefined && { regionCity: b.regionCity || null }), + ...(b.regionDist !== undefined && { regionDist: b.regionDist || null }), + ...(b.leadType !== undefined && { leadType: b.leadType || null }), + ...(b.source !== undefined && { source: b.source || null }), + ...(b.collectedAt !== undefined && { collectedAt: toDate(b.collectedAt) }), + ...(b.referrer !== undefined && { referrer: b.referrer || null }), + ...(b.phoneConsultedAt !== undefined && { phoneConsultedAt: toDate(b.phoneConsultedAt) }), + ...(b.currentVehicle !== undefined && { currentVehicle: b.currentVehicle || null }), + ...(b.tradeIn !== undefined && { tradeIn: b.tradeIn || null }), + ...(b.switchReason !== undefined && { switchReason: b.switchReason || null }), + ...(b.purchaseTiming !== undefined && { purchaseTiming: b.purchaseTiming || null }), + ...(b.faceConsultedAt !== undefined && { faceConsultedAt: toDate(b.faceConsultedAt) }), + ...(b.customerType !== undefined && { customerType: b.customerType || null }), + ...(b.purchaseMethod !== undefined && { purchaseMethod: b.purchaseMethod || null }), + ...(b.capitalCheckedAt !== undefined && { capitalCheckedAt: toDate(b.capitalCheckedAt) }), + ...(b.capitalResult !== undefined && { capitalResult: b.capitalResult || null }), + ...(b.contractedAt !== undefined && { contractedAt: toDate(b.contractedAt) }), + ...(b.vehicleModel !== undefined && { vehicleModel: b.vehicleModel || null }), + ...(b.vehicleCount !== undefined && { vehicleCount: b.vehicleCount ?? null }), + ...(b.bodyType !== undefined && { bodyType: b.bodyType || null }), + ...(b.tempType !== undefined && { tempType: b.tempType || null }), + ...(b.bodyOptions !== undefined && { bodyOptions: b.bodyOptions || null }), + ...(b.vehiclePrice !== undefined && { vehiclePrice: b.vehiclePrice ?? null }), + ...(b.subsidyAmount !== undefined && { subsidyAmount: b.subsidyAmount ?? null }), + ...(b.totalPrice !== undefined && { totalPrice: b.totalPrice ?? null }), + ...(b.downPayment !== undefined && { downPayment: b.downPayment ?? null }), + ...(b.monthlyPayment !== undefined && { monthlyPayment: b.monthlyPayment ?? null }), + ...(b.loanMonths !== undefined && { loanMonths: b.loanMonths ?? null }), + ...(b.deliveredAt !== undefined && { deliveredAt: toDate(b.deliveredAt) }), + ...(b.assignee !== undefined && { assignee: b.assignee || null }), + ...(b.memo !== undefined && { memo: b.memo || null }), + ...(b.purchaseGoal !== undefined && { purchaseGoal: b.purchaseGoal || null }), + ...(b.keyFactors !== undefined && { keyFactors: b.keyFactors || null }), + ...(b.lostReason !== undefined && { lostReason: b.lostReason || null }), + ...(b.holdReason !== undefined && { holdReason: b.holdReason || null }), + ...(b.stageCode !== undefined && { stageCode: b.stageCode || null, + stageChangedAt: new Date() }), + ...(b.checklistJson !== undefined && { checklistJson: b.checklistJson || null }), + ...(b.salesStatus !== undefined && { salesStatus: b.salesStatus || null, + ...(['완료','이탈'].includes(b.salesStatus) && { closedAt: new Date() }) }), + ...(b.customerId !== undefined && { customerId: b.customerId || null }), + // CRM 고객 세그먼트 + ...(b.customerSegment !== undefined && { customerSegment: b.customerSegment || null }), + ...(b.customerCategory !== undefined && { customerCategory: b.customerCategory || null }), + // CRM 기본정보 (B2C) + ...(b.email !== undefined && { email: b.email || null }), + ...(b.gender !== undefined && { gender: b.gender || null }), + ...(b.birthInfo !== undefined && { birthInfo: b.birthInfo || null }), + ...(b.maritalStatus !== undefined && { maritalStatus: b.maritalStatus || null }), + ...(b.childrenCount !== undefined && { childrenCount: b.childrenCount ?? null }), + ...(b.addressDetail !== undefined && { addressDetail: b.addressDetail || null }), + ...(b.isSoleProprietor !== undefined && { isSoleProprietor: b.isSoleProprietor ?? null }), + ...(b.soleBusinessName !== undefined && { soleBusinessName: b.soleBusinessName || null }), + ...(b.soleBusinessNo !== undefined && { soleBusinessNo: b.soleBusinessNo || null }), + ...(b.soleBusinessType !== undefined && { soleBusinessType: b.soleBusinessType || null }), + // CRM 법인정보 (B2B) + ...(b.b2bCategory !== undefined && { b2bCategory: b.b2bCategory || null }), + ...(b.companyName !== undefined && { companyName: b.companyName || null }), + ...(b.businessRegNo !== undefined && { businessRegNo: b.businessRegNo || null }), + ...(b.contactTitle !== undefined && { contactTitle: b.contactTitle || null }), + ...(b.industry !== undefined && { industry: b.industry || null }), + ...(b.companyAddress !== undefined && { companyAddress: b.companyAddress || null }), + ...(b.companyPhone !== undefined && { companyPhone: b.companyPhone || null }), + // CRM 차량정보 + ...(b.hasVehicle !== undefined && { hasVehicle: b.hasVehicle ?? null }), + ...(b.vehicleMaker !== undefined && { vehicleMaker: b.vehicleMaker || null }), + ...(b.vehicleName !== undefined && { vehicleName: b.vehicleName || null }), + ...(b.vehicleYear !== undefined && { vehicleYear: b.vehicleYear || null }), + ...(b.totalMileage !== undefined && { totalMileage: b.totalMileage ?? null }), + ...(b.truckType1 !== undefined && { truckType1: b.truckType1 || null }), + ...(b.truckType2 !== undefined && { truckType2: b.truckType2 || null }), + ...(b.truckType3 !== undefined && { truckType3: b.truckType3 || null }), + // CRM 화주정보 + ...(b.shipperName !== undefined && { shipperName: b.shipperName || null }), + ...(b.cargoType !== undefined && { cargoType: b.cargoType || null }), + ...(b.deliveryCity !== undefined && { deliveryCity: b.deliveryCity || null }), + ...(b.deliveryDist !== undefined && { deliveryDist: b.deliveryDist || null }), + ...(b.deliveryFreq !== undefined && { deliveryFreq: b.deliveryFreq || null }), + ...(b.workShift !== undefined && { workShift: b.workShift || null }), + ...(b.monthlyIncome !== undefined && { monthlyIncome: b.monthlyIncome || null }), + ...(b.cargoNote !== undefined && { cargoNote: b.cargoNote || null }), + }, + }) }) // DateTime/문자열 핵심 필드: libSQL 어댑터 호환 — $executeRaw로 별도 보장 @@ -177,8 +181,8 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: } return NextResponse.json({ ...deal, agentId: b.agentId !== undefined ? (b.agentId || null) : undefined }) - } catch { - return NextResponse.json({ error: '수정 실패' }, { status: 500 }) + } catch (error) { + return customerErrorResponse(error) } } diff --git a/app/api/external/deal-events/route.ts b/app/api/external/deal-events/route.ts index 728b709..46521fd 100644 --- a/app/api/external/deal-events/route.ts +++ b/app/api/external/deal-events/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' +import { customerWrite, customerErrorResponse, resolveCustomerId } from '@/lib/customer-alias' import { ExternalLookupConfigurationError, readLookupApiKey, @@ -36,22 +37,26 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'bad_payload' }, { status: 400, headers: NO_STORE }) } - const fields = { - type: payload.type, - buildupQuoteId: payload.quote.id, - quoteNo: payload.quote.quote_no, - customerName: payload.customer?.name ?? null, - warpCustomerId: payload.customer?.warp_customer_id ?? null, - payloadJson: JSON.stringify(payload), - } - const saved = await prisma.buildupEvent.upsert({ - where: { eventKey: payload.event_key }, - // 같은 키 재수신 = 내용이 바뀌었다는 뜻(buildup 은 액션당 1회만 보낸다). - // 이미 「확인 완료」였어도 **대기로 되돌려** 재확인을 요구한다 — 그래야 - // 견적 수정·재계약이 조용히 묻히지 않는다. - update: { ...fields, status: 'pending', confirmedBy: null, confirmedAt: null }, - create: { eventKey: payload.event_key, ...fields }, - }) - console.info(`[deal-events] ${payload.type} quote=${payload.quote.id} → ${saved.status}`) - return NextResponse.json({ ok: true }, { headers: NO_STORE }) + try { + await customerWrite(prisma, async prisma => { + const fields = { + type: payload.type, + buildupQuoteId: payload.quote.id, + quoteNo: payload.quote.quote_no, + customerName: payload.customer?.name ?? null, + warpCustomerId: payload.customer?.warp_customer_id + ? await resolveCustomerId(prisma, payload.customer.warp_customer_id) : null, + payloadJson: JSON.stringify(payload), + } + await prisma.buildupEvent.upsert({ + where: { eventKey: payload.event_key }, + // 같은 키 재수신 = 내용이 바뀌었다는 뜻(buildup 은 액션당 1회만 보낸다). + // 이미 「확인 완료」였어도 **대기로 되돌려** 재확인을 요구한다 — 그래야 + // 견적 수정·재계약이 조용히 묻히지 않는다. + update: { ...fields, status: 'pending', confirmedBy: null, confirmedAt: null }, + create: { eventKey: payload.event_key, ...fields }, + }) + }) + return NextResponse.json({ ok: true }, { headers: NO_STORE }) + } catch (error) { return customerErrorResponse(error) } } diff --git a/app/api/import/execute/route.ts b/app/api/import/execute/route.ts index 10277d2..fd07165 100644 --- a/app/api/import/execute/route.ts +++ b/app/api/import/execute/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' +import { customerWrite, requireCurrentCustomerId, resolveCustomerId } from '@/lib/customer-alias' export interface ExecuteRow { action: 'update' | 'create' | 'link' | 'skip' @@ -54,37 +55,41 @@ export async function POST(req: NextRequest) { }) updated++ } else { - let customerId: string + await customerWrite(prisma, async prisma => { + let customerId: string - if (row.action === 'create') { - const customer = await prisma.customer.create({ - data: { - name: row.name.trim(), - phone: row.phone || null, - customerSegment: row.customerSegment || null, - source: row.source || null, - companyName: row.companyName || null, - assignee: row.assignee || null, - status: '잠재고객', - collectedAt: row.collectedAt ? new Date(row.collectedAt) : new Date(), - regionCity: row.regionCity || null, - regionDist: row.regionDist || null, - }, - }) - customerId = customer.id - created++ - } else { - // link: 기존 고객에 새 딜 연결 - customerId = row.linkCustomerId! - linked++ - } + if (row.action === 'create') { + const customer = await prisma.customer.create({ + data: { + name: row.name.trim(), + phone: row.phone || null, + customerSegment: row.customerSegment || null, + source: row.source || null, + companyName: row.companyName || null, + assignee: row.assignee || null, + status: '잠재고객', + collectedAt: row.collectedAt ? new Date(row.collectedAt) : new Date(), + regionCity: row.regionCity || null, + regionDist: row.regionDist || null, + }, + }) + customerId = customer.id + + } else { + // link: 기존 고객에 새 딜 연결 + customerId = await resolveCustomerId(prisma, row.linkCustomerId!) + await requireCurrentCustomerId(prisma, customerId) + } - await prisma.salesDeal.create({ - data: { ...dealFields, customerId }, + await prisma.salesDeal.create({ + data: { ...dealFields, customerId }, + }) }) + if (row.action === 'create') created++ + else linked++ } - } catch (e) { - errors.push(`${row.name}: ${(e as Error).message}`) + } catch { + errors.push('고객 연결 또는 저장에 실패했습니다. 현재 고객 정보를 확인해 주세요.') } } diff --git a/app/api/migrate/find-duplicates/route.ts b/app/api/migrate/find-duplicates/route.ts index 062ecca..76a216d 100644 --- a/app/api/migrate/find-duplicates/route.ts +++ b/app/api/migrate/find-duplicates/route.ts @@ -1,86 +1,10 @@ -import { NextResponse } from 'next/server' +import { auth } from '@/auth' import { prisma } from '@/lib/db' - -export type DupGroup = { - phone: string | null - name: string - customers: { - id: string - name: string - phone: string | null - status: string - leadCount: number - createdAt: string - }[] -} +import { customerErrorResponse, findCustomerDuplicates } from '@/lib/customer-merge' export async function GET() { - const all = await prisma.customer.findMany({ - include: { leads: { select: { id: true, stageCode: true, salesStatus: true } } }, - orderBy: { createdAt: 'asc' }, - }) - - /* 전화번호 기준으로 그루핑 (null 제외) */ - const byPhone = new Map() - const noPhone: typeof all = [] - - for (const c of all) { - const key = c.phone?.replace(/\D/g, '') // 숫자만 추출해서 비교 - if (key && key.length >= 9) { - const group = byPhone.get(key) ?? [] - group.push(c) - byPhone.set(key, group) - } else { - noPhone.push(c) - } - } - - /* 같은 전화번호가 2개 이상 → 중복 */ - const dupGroups: DupGroup[] = [] - for (const [, group] of byPhone) { - if (group.length < 2) continue - dupGroups.push({ - phone: group[0].phone, - name: group[0].name, - customers: group.map(c => ({ - id: c.id, - name: c.name, - phone: c.phone, - status: c.status ?? '잠재고객', - leadCount: c.leads.length, - createdAt: c.createdAt.toISOString(), - })), - }) - } - - /* 이름 기준 추가 중복 체크 (전화번호 없는 경우) */ - const byName = new Map() - for (const c of noPhone) { - const key = c.name.trim() - const group = byName.get(key) ?? [] - group.push(c) - byName.set(key, group) - } - for (const [, group] of byName) { - if (group.length < 2) continue - dupGroups.push({ - phone: null, - name: group[0].name, - customers: group.map(c => ({ - id: c.id, - name: c.name, - phone: c.phone, - status: c.status ?? '잠재고객', - leadCount: c.leads.length, - createdAt: c.createdAt.toISOString(), - })), - }) - } - - return NextResponse.json({ - total: all.length, - dupCount: dupGroups.reduce((s, g) => s + g.customers.length - 1, 0), // 제거 대상 수 - groupCount: dupGroups.length, - groups: dupGroups, - }) + try { + const session = await auth() + return Response.json(await findCustomerDuplicates(prisma, session?.user?.id), { headers: { 'Cache-Control': 'no-store' } }) + } catch (error) { return customerErrorResponse(error) } } diff --git a/app/api/migrate/merge-customers/route.ts b/app/api/migrate/merge-customers/route.ts index 56b2af8..864e6a3 100644 --- a/app/api/migrate/merge-customers/route.ts +++ b/app/api/migrate/merge-customers/route.ts @@ -1,69 +1,14 @@ -import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' import { prisma } from '@/lib/db' - -/* - POST body: { keepId: string, removeId: string } - - keepId : 남길 고객 - - removeId: 제거할 고객 (리드·활동 모두 keepId로 이전) -*/ -export async function POST(req: NextRequest) { - const { keepId, removeId } = await req.json() - if (!keepId || !removeId || keepId === removeId) - return NextResponse.json({ error: '잘못된 요청입니다' }, { status: 400 }) - - const [keep, remove] = await Promise.all([ - prisma.customer.findUnique({ where: { id: keepId } }), - prisma.customer.findUnique({ where: { id: removeId }, include: { leads: true, activities: true } }), - ]) - if (!keep || !remove) - return NextResponse.json({ error: '고객을 찾을 수 없습니다' }, { status: 404 }) - - /* 1. 제거 고객의 리드를 유지 고객으로 이전 */ - const movedLeads = await prisma.salesDeal.updateMany({ - where: { customerId: removeId }, - data: { customerId: keepId }, - }) - - /* 2. 제거 고객의 활동을 유지 고객으로 이전 */ - const movedActivities = await prisma.customerActivity.updateMany({ - where: { customerId: removeId }, - data: { customerId: keepId }, - }) - - /* 3. 유지 고객에 없는 상세 정보를 제거 고객에서 병합 */ - const mergeData: Record = {} - const fields = [ - 'email','gender','birthInfo','birthYear','maritalStatus','childrenCount', - 'addressDetail','regionCity','regionDist','isSoleProprietor','soleBusinessName', - 'soleBusinessNo','soleBusinessType','b2bCategory','companyName','businessRegNo', - 'contactTitle','industry','companyAddress','companyPhone', - 'hasVehicle','vehicleMaker','vehicleName','vehicleYear','totalMileage', - 'truckType1','truckType2','truckType3', - 'shipperName','cargoType','deliveryCity','deliveryDist','deliveryFreq', - 'workShift','monthlyIncome','cargoNote', - 'customerSegment','customerCategory','source','referrer','leadType','assignee','memo', - ] as const - - for (const f of fields) { - const ka = keep as Record - const ra = remove as Record - if ((ka[f] === null || ka[f] === undefined) && ra[f] != null) { - mergeData[f] = ra[f] - } - } - - if (Object.keys(mergeData).length > 0) { - await prisma.customer.update({ where: { id: keepId }, data: mergeData }) - } - - /* 4. 제거 고객 삭제 */ - await prisma.customer.delete({ where: { id: removeId } }) - - return NextResponse.json({ - ok: true, - movedLeads: movedLeads.count, - movedActivities: movedActivities.count, - mergedFields: Object.keys(mergeData), - message: `'${remove.name}' 고객이 '${keep.name}'으로 통합되었습니다`, - }) +import { handleCustomerMergeRequest } from '@/lib/customer-merge-http' +import { customerErrorResponse } from '@/lib/customer-alias' + +async function handle(req: Request) { + try { + const session = await auth() + return await handleCustomerMergeRequest(req, prisma, session?.user?.id) + } catch (error) { return customerErrorResponse(error) } } + +export const GET = handle +export const POST = handle diff --git a/components/CustomerMergePanel.tsx b/components/CustomerMergePanel.tsx new file mode 100644 index 0000000..2bc57b4 --- /dev/null +++ b/components/CustomerMergePanel.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { GitMerge, Search } from 'lucide-react' + +type Customer = { id: string; name: string; phone: string | null; status: string; leadCount: number; createdAt: string } +type Group = { phone: string; name: string; customers: Customer[] } +type DuplicateResult = { + total: number; dupCount: number; groupCount: number; groups: Group[] + leadGroups?: { phone: string; associationMismatch?: boolean; crossCustomer?: boolean; + leads: { id: string; name: string; stageCode: string | null; salesStatus: string | null }[] }[] +} +type Field = { + key: string; label: string; keepValue: unknown; removeValue: unknown; resultValue: unknown + mode: 'same' | 'fill' | 'automatic' | 'conflict'; requiresChoice: boolean +} +type Preview = { + keep: { id: string; name: string }; remove: { id: string; name: string }; previewToken: string + fields: Field[]; counts: { leads: number; activities: number; agents: number; events: number }; warnings: string[] +} + +function display(value: unknown): string { + if (value == null || value === '') return '미입력' + if (typeof value === 'boolean') return value ? '예' : '아니오' + if (typeof value === 'object') return JSON.stringify(value, null, 2) + return String(value) +} + +export default function CustomerMergePanel() { + const router = useRouter() + const [result, setResult] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [message, setMessage] = useState('') + const [keepIds, setKeepIds] = useState>({}) + const [preview, setPreview] = useState(null) + const [choices, setChoices] = useState>({}) + const [confirmed, setConfirmed] = useState(false) + const [showSame, setShowSame] = useState(false) + const previewRef = useRef(null) + useEffect(() => { + if (preview) { + previewRef.current?.focus() + previewRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }) + } + }, [preview]) + + async function loadDuplicates() { + const response = await fetch('/api/migrate/find-duplicates', { cache: 'no-store' }) + const data = await response.json() + if (!response.ok) throw new Error(data.error || '중복 목록을 불러오지 못했습니다.') + setResult(data) + setKeepIds({}) + } + + async function search() { + setBusy(true); setError(''); setMessage(''); setPreview(null) + try { await loadDuplicates() } + catch (e) { setError(e instanceof Error ? e.message : '연결 상태를 확인해 주세요.') } + finally { setBusy(false) } + } + + async function compare(keepId: string, removeId: string) { + setBusy(true); setError(''); setMessage(''); setPreview(null); setChoices({}); setConfirmed(false) + setShowSame(false) + try { + const query = new URLSearchParams({ keepId, removeId }) + const response = await fetch(`/api/migrate/merge-customers?${query}`, { cache: 'no-store' }) + const data = await response.json() + if (!response.ok) throw new Error(data.error || '고객 정보를 비교하지 못했습니다.') + setPreview(data) + } catch (e) { setError(e instanceof Error ? e.message : '연결 상태를 확인해 주세요.') } + finally { setBusy(false) } + } + + async function merge() { + if (!preview || !confirmed) return + setBusy(true); setError(''); setMessage('') + try { + const response = await fetch('/api/migrate/merge-customers', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keepId: preview.keep.id, removeId: preview.remove.id, + previewToken: preview.previewToken, choices, confirmed: true }), + }) + const data = await response.json() + if (!response.ok) { + if (response.status === 409) { setPreview(null); setConfirmed(false) } + throw new Error(data.error || '병합하지 못했습니다. 다시 비교해 주세요.') + } + setPreview(null); setConfirmed(false) + setMessage(data.message || '고객 정보가 병합되었습니다. 리드와 상담 이력은 보존했습니다.') + router.refresh() + try { await loadDuplicates() } + catch { setResult(null); setError('병합은 완료됐지만 목록 갱신에 실패했습니다. 중복 검색으로 다시 확인해 주세요.') } + } catch (e) { setError(e instanceof Error ? e.message : '응답을 확인하지 못했습니다. 다시 검색해 결과를 확인해 주세요.') } + finally { setBusy(false) } + } + + const conflicts = preview?.fields.filter(f => f.requiresChoice) ?? [] + const unresolved = conflicts.filter(f => !choices[f.key]).length + const actionStyle = 'rounded-lg bg-slate-900 px-4 py-2 text-xs font-semibold text-white hover:bg-slate-700 disabled:opacity-40' + + return
+
+
+

중복 고객 통합

+

휴대폰 번호로 비교 · 고객 하나에 리드와 상담 이력 함께 보관

+
+ +
+
+ {error &&

{error}

} + {message &&

{message}

} + {!result &&

중복 검색으로 고객과 리드를 확인하세요. 검색만으로 데이터가 변경되지는 않습니다.

} + {result && <> +

전체 고객 {result.total}건 · 중복 번호 {result.groupCount}개 · 병합 후보 {result.dupCount}건

+ {result.groups.length === 0 &&

같은 휴대폰 번호로 등록된 고객이 없습니다.

} + {result.groups.map(group => { + const keepId = keepIds[group.phone] ?? group.customers[0].id + return
+
+

{group.phone} · 고객 {group.customers.length}건

+ +
+
    + {group.customers.map(c =>
  • +
    {c.name || '이름 미입력'} +

    {c.status} · 리드 {c.leadCount}건 · {c.createdAt.slice(0, 10)} 생성

    +
    + {c.id === keepId ? 남길 고객 + : } +
  • )} +
+
+ })} + {!!result.leadGroups?.length &&
+ 동일 번호의 리드 {result.leadGroups.length}그룹 확인 +

재구매·다른 차량 상담일 수 있습니다. 고객 병합 시 리드는 삭제하거나 단계를 변경하지 않습니다.

+
    {result.leadGroups.map(g =>
  • + {g.phone} · 리드 {g.leads.length}건 + {g.associationMismatch &&

    리드와 연결된 고객의 휴대폰 번호가 다릅니다. 고객 연결을 확인해 주세요.

    } + {g.crossCustomer &&

    같은 번호의 리드가 서로 다른 고객에 연결되어 있습니다.

    } +
    {g.leads.map(d => + {d.name || '이름 미입력'} · {d.stageCode ?? '단계 미입력'} · {d.salesStatus ?? '상태 미입력'} + )}
    +
  • )}
+
} + } + {preview &&
+

병합 결과 미리보기

+

{preview.keep.name || '이름 미입력'} 고객을 남기고 {preview.remove.name || '이름 미입력'} 고객의 이력을 연결합니다.

+

이전할 리드 {preview.counts.leads}건 · 상담 {preview.counts.activities}건 · 소개자 {preview.counts.agents}건 · 연동 이벤트 {preview.counts.events}건

+ {preview.warnings.map((w, i) =>

{w}

)} + +
+ + {['항목', '남길 고객 정보', '합칠 고객 정보', '병합 결과'].map(h => )} + {preview.fields.filter(f => showSame || f.mode !== 'same').map(f => { + const value = choices[f.key] === 'remove' ? f.removeValue : choices[f.key] === 'keep' ? f.keepValue : f.resultValue + return + + + + + + })} +
{h}
{f.label}{f.requiresChoice && 선택 필요}{display(f.keepValue)}{display(f.removeValue)} + {f.requiresChoice &&
{f.label} 병합 값 선택 + {(['keep', 'remove'] as const).map(side => )} +
} +

{f.requiresChoice && !choices[f.key] ? '값을 선택해 주세요.' : display(value)}

+ {!f.requiresChoice && f.mode !== 'same' && {f.mode === 'fill' ? '빈칸 자동 보완' : '보존 규칙 적용'}} +
+
+ {unresolved > 0 &&

서로 다른 정보 {unresolved}개를 선택하면 병합할 수 있습니다.

} + +
+ + +
+
} +
+
+} diff --git a/deploy/schema-migrations/2026100601_add_customer_merge.sql b/deploy/schema-migrations/2026100601_add_customer_merge.sql new file mode 100644 index 0000000..11fc37c --- /dev/null +++ b/deploy/schema-migrations/2026100601_add_customer_merge.sql @@ -0,0 +1,37 @@ +CREATE TABLE "CustomerMerge" ( + "sourceId" TEXT NOT NULL PRIMARY KEY, + "targetId" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "actorName" TEXT NOT NULL, + "previewToken" TEXT NOT NULL, + "choicesJson" TEXT NOT NULL, + "snapshotJson" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ("sourceId" <> "targetId") +); + +CREATE INDEX "CustomerMerge_targetId_idx" ON "CustomerMerge"("targetId"); + +-- Keep canonical customers reachable; a subsequent archived merge may remove an intermediate. +CREATE TRIGGER "CustomerMerge_protect_target" BEFORE DELETE ON "Customer" +WHEN EXISTS (SELECT 1 FROM "CustomerMerge" WHERE "targetId" = OLD."id") + AND NOT EXISTS (SELECT 1 FROM "CustomerMerge" WHERE "sourceId" = OLD."id") +BEGIN + SELECT RAISE(ABORT, 'merged_customer_delete_refused'); +END; + +CREATE TRIGGER "CustomerMerge_no_source_reuse" BEFORE INSERT ON "Customer" +WHEN EXISTS (SELECT 1 FROM "CustomerMerge" WHERE "sourceId" = NEW."id") +BEGIN + SELECT RAISE(ABORT, 'merged_customer_id_reserved'); +END; + +CREATE TRIGGER "CustomerMerge_no_update" BEFORE UPDATE ON "CustomerMerge" +BEGIN + SELECT RAISE(ABORT, 'customer_merge_audit_immutable'); +END; + +CREATE TRIGGER "CustomerMerge_no_delete" BEFORE DELETE ON "CustomerMerge" +BEGIN + SELECT RAISE(ABORT, 'customer_merge_audit_immutable'); +END; diff --git a/deploy/schema-migrations/manifest.json b/deploy/schema-migrations/manifest.json index 84a6207..143e28a 100644 --- a/deploy/schema-migrations/manifest.json +++ b/deploy/schema-migrations/manifest.json @@ -1,8 +1,9 @@ { - "prismaSchemaSha256": "6c974ca06f215552665fd17549c49d51127312cfe9bfaa54df1483505e613f57", + "prismaSchemaSha256": "bf52d41ddd61fe88ce4b1a93279df4102e661500bdbf4f53402f4364e6ea8c83", "requiredTables": [ "Announcement", "BuildupEvent", + "CustomerMerge", "CardUsage", "CompanyScheduleRule", "Garage", @@ -15,9 +16,14 @@ }, "requiredIndexes": [ "BuildupEvent_eventKey_key", - "BuildupEvent_status_idx" + "BuildupEvent_status_idx", + "CustomerMerge_targetId_idx" ], "requiredColumns": { + "CustomerMerge": [ + "sourceId", "targetId", "actorId", "actorName", "previewToken", + "choicesJson", "snapshotJson", "createdAt" + ], "Announcement": [ "id", "title", diff --git a/lib/customer-alias.ts b/lib/customer-alias.ts new file mode 100644 index 0000000..b16ddf4 --- /dev/null +++ b/lib/customer-alias.ts @@ -0,0 +1,68 @@ +import type { Prisma, PrismaClient } from '@/app/generated/prisma/client' + +export type CustomerDb = Prisma.TransactionClient + +export class CustomerMergeError extends Error { + constructor(public status: number, message: string, public customerId?: string) { + super(message) + } +} + +export function customerErrorResponse(error: unknown) { + const known = error instanceof CustomerMergeError + return Response.json({ + error: known ? error.message : '고객 처리에 실패했습니다. 잠시 후 다시 시도해 주세요.', + ...(known && error.customerId ? { + customerId: error.customerId, + redirectTo: `/customers/${encodeURIComponent(error.customerId)}`, + } : {}), + }, { status: known ? error.status : 503, headers: { 'Cache-Control': 'no-store' } }) +} + +export async function resolveCustomerId(db: CustomerDb, id: string): Promise { + const seen = new Set() + let current = id + while (!seen.has(current)) { + seen.add(current) + const alias = await db.customerMerge.findUnique({ where: { sourceId: current }, select: { targetId: true } }) + if (!alias) return current + current = alias.targetId + } + throw new CustomerMergeError(409, '고객 연결을 확인해 주세요.') +} + +export async function requireCurrentCustomerId(db: CustomerDb, id: unknown): Promise { + if (typeof id !== 'string' || !id || id.length > 128) { + throw new CustomerMergeError(400, '고객 ID를 확인해 주세요.') + } + const canonical = await resolveCustomerId(db, id) + if (canonical !== id) throw new CustomerMergeError(409, '통합된 고객입니다. 현재 고객으로 이동한 뒤 다시 시도해 주세요.', canonical) + if (!await db.customer.findUnique({ where: { id }, select: { id: true } })) { + throw new CustomerMergeError(404, '고객을 찾을 수 없습니다.') + } + return id +} + +function isBusy(error: unknown): boolean { + // Inspect only for retry classification; never expose/log database diagnostics. + if (!error || typeof error !== 'object') return false + const e = error as { code?: string; message?: string; cause?: unknown; meta?: unknown } + return e.code === 'P2034' || /SQLITE_BUSY|database is locked/.test(e.message ?? '') + || (e.cause !== error && isBusy(e.cause)) || (e.meta !== error && isBusy(e.meta)) +} + +export async function customerWrite(db: PrismaClient, work: (tx: CustomerDb) => Promise): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await db.$transaction(async tx => { + await tx.$executeRaw`PRAGMA busy_timeout = 0` + // Reserve SQLite's single writer BEFORE any snapshot/authorization reads. + await tx.$executeRaw`UPDATE "Customer" SET "id" = "id" WHERE 0` + return work(tx) + }, { timeout: 15000 }) + } catch (error) { + if (!isBusy(error) || attempt >= 5) throw error + await new Promise(resolve => setTimeout(resolve, 20 * (attempt + 1))) + } + } +} diff --git a/lib/customer-merge-http.ts b/lib/customer-merge-http.ts new file mode 100644 index 0000000..fe53203 --- /dev/null +++ b/lib/customer-merge-http.ts @@ -0,0 +1,63 @@ +import type { PrismaClient } from '@/app/generated/prisma/client' +import { confirmCustomerMerge, customerErrorResponse, CustomerMergeError, previewCustomerMerge, requireMergeActor } from './customer-merge' + +const headers = { 'Cache-Control': 'no-store' } +const MAX_BODY_BYTES = 16 * 1024 + +async function boundedJson(req: Request): Promise { + if (req.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') { + throw new CustomerMergeError(415, 'JSON 형식으로 요청해 주세요.') + } + if (Number(req.headers.get('content-length')) > MAX_BODY_BYTES) { + throw new CustomerMergeError(413, '요청 내용이 너무 큽니다.') + } + const reader = req.body?.getReader() + if (!reader) throw new CustomerMergeError(400, '요청 내용이 필요합니다.') + const bytes = new Uint8Array(MAX_BODY_BYTES) + let size = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (size + value.byteLength > MAX_BODY_BYTES) { + await reader.cancel() + throw new CustomerMergeError(413, '요청 내용이 너무 큽니다.') + } + bytes.set(value, size); size += value.byteLength + } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size))) + } catch (error) { + if (error instanceof CustomerMergeError) throw error + throw new CustomerMergeError(400, 'JSON 요청 내용을 확인해 주세요.') + } finally { reader.releaseLock() } +} + +type OriginEnvironment = Readonly> + +function publicOrigin(req: Request, env: OriginEnvironment) { + const configured = env.AUTH_URL ?? env.NEXTAUTH_URL + if (configured === undefined) return new URL(req.url).origin + try { + const url = new URL(configured) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error() + return url.origin + } catch { throw new CustomerMergeError(503, '서비스 공개 주소 설정을 확인해 주세요.') } +} + +export async function handleCustomerMergeRequest( + req: Request, db: PrismaClient, actorId: string | undefined, env: OriginEnvironment = process.env, +) { + try { + await requireMergeActor(db, actorId) + if (req.method === 'GET') { + const query = new URL(req.url).searchParams + return Response.json(await previewCustomerMerge(db, actorId, query.get('keepId') ?? '', query.get('removeId') ?? ''), { headers }) + } + if (req.method !== 'POST') throw new CustomerMergeError(405, '지원하지 않는 요청 방식입니다.') + // Session cookies alone are insufficient: reject cross-site and origin-less write requests. + if (req.headers.get('origin') !== publicOrigin(req, env) || req.headers.get('sec-fetch-site') === 'cross-site') { + throw new CustomerMergeError(403, '현재 화면에서 미리보기를 확인한 뒤 요청해 주세요.') + } + return Response.json(await confirmCustomerMerge(db, actorId, await boundedJson(req)), { headers }) + } catch (error) { return customerErrorResponse(error) } +} diff --git a/lib/customer-merge.test.ts b/lib/customer-merge.test.ts new file mode 100644 index 0000000..0063b06 --- /dev/null +++ b/lib/customer-merge.test.ts @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { createClient } from '@libsql/client' +import { PrismaLibSql } from '@prisma/adapter-libsql' +import { PrismaClient } from '@/app/generated/prisma/client' +import { confirmCustomerMerge, customerMergeFields, findCustomerDuplicates, previewCustomerMerge, requireMergeActor } from './customer-merge' +import { customerWrite, CustomerMergeError, requireCurrentCustomerId, resolveCustomerId } from './customer-alias' +import { normalizeCustomerMobile } from './customer-mobile' +import { handleCustomerMergeRequest } from './customer-merge-http' +import { handleMarketingInquiryRequest } from './marketing-inquiries' + +// Never import lib/db (its singleton can open a checkout DB). All connections and CLI config are private temp files. +const directory = mkdtempSync(path.join(tmpdir(), 'warp-customer-merge-test-')) +const schema = path.resolve('prisma/schema.prisma') +const config = path.join(directory, 'prisma.config.ts') +writeFileSync(config, `export default ${JSON.stringify({ schema, datasource: { url: `file:${directory}/unused.db` } })}`) +const ddl = execFileSync(process.execPath, [path.resolve('node_modules/prisma/build/index.js'), + 'migrate', 'diff', '--from-empty', '--to-schema', schema, '--script', '--config', config], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +const migration = readFileSync('deploy/schema-migrations/2026100601_add_customer_merge.sql', 'utf8') +let db: PrismaClient +let url: string +let sequence = 0 +const clients: PrismaClient[] = [] +function connect() { + const client = new PrismaClient({ adapter: new PrismaLibSql({ url }) }) + clients.push(client) + return client +} + +test.beforeEach(async () => { + url = `file:${directory}/${sequence++}.db` + const sqlite = createClient({ url }) + await sqlite.executeMultiple(ddl) + // Apply the real forward migration, including the deletion/audit guards. + await sqlite.executeMultiple('DROP TABLE CustomerMerge;\n' + migration) + sqlite.close() + db = connect() + await db.user.create({ data: { id: 'admin', name: '테스트 관리자', email: 'admin@example.invalid', password: 'unused', role: 'admin' } }) + await db.customer.create({ data: { id: 'keep', name: '김보존', phone: '010-1234-5678', memo: '원본 유지', source: '전시회', assignee: '담당A' } }) + await db.customer.create({ data: { id: 'remove', name: '김통합', phone: '+82 10 1234 5678', memo: '원본 통합', source: '마케팅', assignee: '담당B' } }) +}) +test.afterEach(async () => { await Promise.all(clients.splice(0).map(client => client.$disconnect())) }) +test.after(() => rmSync(directory, { recursive: true, force: true })) + +async function confirmation(keepId = 'keep', removeId = 'remove') { + const p = await previewCustomerMerge(db, 'admin', keepId, removeId) + return { keepId, removeId, previewToken: p.previewToken, + choices: Object.fromEntries(p.fields.filter(f => f.requiresChoice).map(f => [f.key, 'keep' as const])), confirmed: true as const } +} +async function rejectsStatus(work: Promise, status: number) { + await assert.rejects(work, (error: unknown) => error instanceof CustomerMergeError && error.status === status) +} + +async function relations() { + await db.salesDeal.create({ data: { id: 'deal', customerId: 'remove', name: '개별 리드 원본명', phone: '01012345678', + stage: '계약', stageCode: '3-1', salesStatus: '진행중', totalPrice: 7123, vehiclePrice: 5000, memo: '독립 메모', + meetings: { create: { id: 'meeting', type: '전화', meetingAt: new Date(), filesJson: '[{"path":"/api/uploads/deal/file.pdf"}]' } }, + stageHistory: { create: { id: 'history', fromStageCode: '2-1', toStageCode: '3-1' } }, + documents: { create: { id: 'document', stageCode: '3-1', docKey: 'contract', docLabel: '계약', fileName: '원본.pdf', storedName: 'original.pdf', filePath: '/old/file.pdf', fileSize: 50, mimeType: 'application/pdf' } }, + shares: { create: { id: 'share', targetUser: '담당A' } }, + } }) + await db.customerActivity.create({ data: { id: 'receipt', customerId: 'remove', type: '이벤트', date: new Date(), content: '원문', result: '접수' } }) + await db.agent.create({ data: { id: 'agent', customerId: 'remove', name: '소개자' } }) + await db.buildupEvent.create({ data: { id: 'event', eventKey: 'quote_created:1', type: 'quote_created', buildupQuoteId: 1, warpCustomerId: 'remove', payloadJson: '{"original":true}' } }) + await db.salesDeal.create({ data: { id: 'kept-deal', customerId: 'keep', name: '유지 리드', phone: '01012345678', stageCode: '1-1' } }) +} + +const include = { meetings: true, stageHistory: true, documents: true, shares: true } + +test('domestic mobile normalization includes legacy mobiles and excludes names, missing phones and landlines', async () => { + for (const input of ['010-1234-5678', '010 1234 5678', '+82 10-1234-5678', '0082 01012345678', '+82 01012345678']) { + assert.equal(normalizeCustomerMobile(input), '01012345678') + } + for (const input of ['0111234567', '+82 16 123 4567', '0082 19 1234 5678']) assert.ok(normalizeCustomerMobile(input)) + for (const input of [null, '', '0101234567', '010123456789', '0212345678', '07012345678', '010abc12345678', '010.1234.5678']) { + assert.equal(normalizeCustomerMobile(input), null) + } + await db.customer.createMany({ data: [ + { id: 'empty1', name: '이름 같음' }, { id: 'empty2', name: '이름 같음', phone: '' }, + { id: 'land1', name: '회사', phone: '02-1234-5678' }, { id: 'land2', name: '회사', phone: '0212345678' }, + ] }) + await relations() + await db.salesDeal.create({ data: { id: 'unlinked', name: '독립', phone: '0082 10 1234 5678' } }) + await db.salesDeal.create({ data: { id: 'mismatch', name: '잘못된 연결', phone: '01099998888', customerId: 'keep' } }) + const result = await findCustomerDuplicates(db, 'admin') + assert.equal(result.total, 6) + assert.equal(result.dupCount, 1) + assert.equal(result.groupCount, 1) + assert.equal(result.groups[0].phone, '01012345678') + assert.equal(result.leadTotal, 4) + assert.equal(result.leadGroupCount, 2) + assert.equal(result.leadGroups.find(g => g.phone === '01012345678')?.leads.length, 3) + assert.equal(result.leadGroups.find(g => g.phone === '01099998888')?.associationMismatch, true) +}) + +test('current DB role gates lookup, preview and confirmation, including external admin and deleted/demoted sessions', async () => { + await rejectsStatus(findCustomerDuplicates(db, undefined), 401) + await rejectsStatus(previewCustomerMerge(db, 'missing', 'keep', 'remove'), 403) + const body = await confirmation() + for (const data of [{ role: 'user' }, { role: 'admin', employmentType: '사외' }, { role: 'ceo', employmentType: '사외' }]) { + await db.user.update({ where: { id: 'admin' }, data }) + await rejectsStatus(findCustomerDuplicates(db, 'admin'), 403) + await rejectsStatus(previewCustomerMerge(db, 'admin', 'keep', 'remove'), 403) + await rejectsStatus(confirmCustomerMerge(db, 'admin', body), 403) + } + await db.user.update({ where: { id: 'admin' }, data: { role: 'ceo', employmentType: '사내' } }) + assert.equal((await requireMergeActor(db, 'admin')).role, 'ceo') + await db.user.delete({ where: { id: 'admin' } }) + await rejectsStatus(confirmCustomerMerge(db, 'admin', body), 403) +}) + +test('all business scalars are previewed; empty fill, zero/false conflicts and unknown-name preference are explicit', async () => { + await db.customer.update({ where: { id: 'keep' }, data: { name: '신원미상(서울 📞 5678)', companyName: ' ', birthYear: 0, hasVehicle: false, grade: '실버', customerSegment: 'B2C' } }) + await db.customer.update({ where: { id: 'remove' }, data: { companyName: '회사명', birthYear: 1985, hasVehicle: true, grade: '골드', customerSegment: 'B2B' } }) + const p = await previewCustomerMerge(db, 'admin', 'keep', 'remove') + const fields = new Map(p.fields.map(f => [f.key, f])) + const customer = await db.customer.findUniqueOrThrow({ where: { id: 'keep' } }) + assert.deepEqual([...fields.keys()].sort(), Object.keys(customer).filter(key => !['id', 'createdAt', 'updatedAt'].includes(key)).sort()) + assert.equal(fields.get('name')?.resultValue, '김통합') + assert.equal(fields.get('name')?.mode, 'automatic') + assert.equal(fields.get('companyName')?.mode, 'fill') + for (const key of ['birthYear', 'hasVehicle', 'grade', 'customerSegment', 'assignee', 'source'] as const) assert.equal(fields.get(key)?.requiresChoice, true) + const [a, b] = await Promise.all(['keep', 'remove'].map(id => db.customer.findUniqueOrThrow({ where: { id } }))) + for (const name of ['미상', '미상(📞 1234)', '미상(🚚 5678)', '신원미상']) { + assert.equal(customerMergeFields({ ...a, name }, b).find(f => f.key === 'name')?.resultValue, b.name) + } + assert.equal(customerMergeFields({ ...a, name: '김미상' }, b).find(f => f.key === 'name')?.mode, 'conflict') +}) + +test('merge preserves all related records, both original snapshots, conflicting metadata objects and original file paths', async () => { + await relations() + await db.customer.update({ where: { id: 'keep' }, data: { contactsJson: '[{"id":"contact","name":"첫째","phone":"01011112222"}]', + documentsJson: '[{"type":"사업자","path":"/api/uploads/customers/keep/a.pdf"}]', tags: '["기존"]', vehicleListJson: '[{"name":"PV5","count":1}]' } }) + await db.customer.update({ where: { id: 'remove' }, data: { contactsJson: '[{"id":"contact","name":"둘째","phone":"01033334444"}]', + documentsJson: '[{"type":"사업자","path":"/api/uploads/customers/remove/b.pdf"}]', tags: '["추가","기존"]', vehicleListJson: '[{"name":"PV5","count":2}]' } }) + const originalKeep = await db.customer.findUniqueOrThrow({ where: { id: 'keep' } }) + const originalRemove = await db.customer.findUniqueOrThrow({ where: { id: 'remove' } }) + const deal = await db.salesDeal.findUniqueOrThrow({ where: { id: 'deal' }, include }) + const activity = await db.customerActivity.findUniqueOrThrow({ where: { id: 'receipt' } }) + const agent = await db.agent.findUniqueOrThrow({ where: { id: 'agent' } }) + const event = await db.buildupEvent.findUniqueOrThrow({ where: { id: 'event' } }) + const p = await previewCustomerMerge(db, 'admin', 'keep', 'remove') + assert.deepEqual(p.counts, { leads: 1, activities: 1, agents: 1, events: 1 }) + assert.deepEqual(await confirmCustomerMerge(db, 'admin', await confirmation()), { ok: true, message: '고객 통합이 완료되었습니다.', customerId: 'keep' }) + assert.equal(await db.customer.count(), 1) + const kept = await db.customer.findUniqueOrThrow({ where: { id: 'keep' } }) + assert.match(kept.memo!, /원본 유지/); assert.match(kept.memo!, /원본 통합/) + assert.match(kept.memo!, /통합 고객:/) + assert.deepEqual(JSON.parse(kept.documentsJson!), [...JSON.parse(originalKeep.documentsJson!), ...JSON.parse(originalRemove.documentsJson!)]) + assert.deepEqual(JSON.parse(kept.vehicleListJson!), [{ name: 'PV5', count: 1 }, { name: 'PV5', count: 2 }]) + assert.deepEqual(JSON.parse(kept.tags!), ['기존', '추가']) + const contacts = JSON.parse(kept.contactsJson!) + assert.equal(contacts.length, 2); assert.notEqual(contacts[0].id, contacts[1].id) + assert.deepEqual(contacts[1], { ...JSON.parse(originalRemove.contactsJson!)[0], id: contacts[1].id }) + assert.equal(p.fields.find(f => f.key === 'contactsJson')?.resultValue, kept.contactsJson) + assert.deepEqual(await db.salesDeal.findUnique({ where: { id: 'deal' }, include }), { ...deal, customerId: 'keep' }) + assert.deepEqual(await db.customerActivity.findUnique({ where: { id: 'receipt' } }), { ...activity, customerId: 'keep' }) + assert.deepEqual(await db.agent.findUnique({ where: { id: 'agent' } }), { ...agent, customerId: 'keep' }) + assert.deepEqual(await db.buildupEvent.findUnique({ where: { id: 'event' } }), { ...event, warpCustomerId: 'keep' }) + const audit = await db.customerMerge.findUniqueOrThrow({ where: { sourceId: 'remove' } }) + const archived = JSON.parse(audit.snapshotJson) + assert.deepEqual(archived.keep, JSON.parse(JSON.stringify(originalKeep))) + assert.deepEqual(archived.remove, JSON.parse(JSON.stringify(originalRemove))) + assert.equal(archived.leads.length, 2) + assert.equal(audit.actorId, 'admin'); assert.ok(audit.createdAt) + assert.equal(archived.remove.contactsJson, originalRemove.contactsJson) + await assert.rejects(db.customerMerge.update({ where: { sourceId: 'remove' }, data: { actorName: '변경' } })) + await assert.rejects(db.customerMerge.delete({ where: { sourceId: 'remove' } })) +}) + +test('requires all conflict choices, rejects arbitrary patch keys, invalid choices and invalid pair/phones', async () => { + const body = await confirmation() + await rejectsStatus(confirmCustomerMerge(db, 'admin', { ...body, choices: {} }), 400) + await rejectsStatus(confirmCustomerMerge(db, 'admin', { ...body, choices: { ...body.choices, id: 'remove' } }), 400) + await rejectsStatus(confirmCustomerMerge(db, 'admin', { ...body, choices: { ...body.choices, name: ['keep'] } }), 400) + await rejectsStatus(confirmCustomerMerge(db, 'admin', { ...body, name: '공격' }), 400) + await rejectsStatus(confirmCustomerMerge(db, 'admin', { ...body, confirmed: false }), 400) + await rejectsStatus(confirmCustomerMerge(db, 'admin', { ...body, previewToken: 'invalid' }), 409) + await rejectsStatus(previewCustomerMerge(db, 'admin', 'keep', 'keep'), 400) + await rejectsStatus(previewCustomerMerge(db, 'admin', 'keep', 'missing'), 409) + for (const phone of [null, '0212345678', '01099998888']) { + await db.customer.update({ where: { id: 'remove' }, data: { phone } }) + await rejectsStatus(previewCustomerMerge(db, 'admin', 'keep', 'remove'), 409) + } + assert.equal(await db.customer.count(), 2) +}) + +test('invalid serialized arrays refuse preview/confirm without erasing either original', async () => { + for (const key of ['tags', 'contactsJson', 'documentsJson', 'vehicleListJson']) { + for (const bad of ['{', '{}', 'null', '"text"', ' ']) { + const b = await confirmation() + await db.customer.update({ where: { id: 'remove' }, data: { [key]: bad } }) + await rejectsStatus(previewCustomerMerge(db, 'admin', 'keep', 'remove'), 409) + await rejectsStatus(confirmCustomerMerge(db, 'admin', b), 409) + assert.equal((await db.customer.findUniqueOrThrow({ where: { id: 'remove' } }))[key as 'tags'], bad) + await db.customer.update({ where: { id: 'remove' }, data: { [key]: null } }) + } + } + assert.equal(await db.customerMerge.count(), 0) +}) + +test('stale preview detects same-count edits and added dependencies including uploads, meetings and histories', async () => { + await relations() + const edits = [ + () => db.customer.update({ where: { id: 'keep' }, data: { documentsJson: '[{"path":"/new.pdf"}]' } }), + () => db.customer.update({ where: { id: 'remove' }, data: { contactsJson: '[{"id":"new"}]' } }), + () => db.salesDeal.update({ where: { id: 'deal' }, data: { totalPrice: 9876 } }), + () => db.customerActivity.update({ where: { id: 'receipt' }, data: { content: '수정' } }), + () => db.agent.update({ where: { id: 'agent' }, data: { memo: '변경' } }), + () => db.buildupEvent.update({ where: { id: 'event' }, data: { payloadJson: '{"changed":true}' } }), + () => db.leadMeeting.update({ where: { id: 'meeting' }, data: { filesJson: '[{"path":"/new-meeting.pdf"}]' } }), + () => db.stageHistory.update({ where: { id: 'history' }, data: { toStageCode: '4-1' } }), + () => db.dealDocument.update({ where: { id: 'document' }, data: { filePath: '/new-contract.pdf' } }), + () => db.dealShare.update({ where: { id: 'share' }, data: { targetUser: '담당B' } }), + () => db.customerActivity.create({ data: { customerId: 'keep', type: '이벤트', date: new Date() } }), + ] + for (const edit of edits) { + const body = await confirmation() + await edit() + await rejectsStatus(confirmCustomerMerge(db, 'admin', body), 409) + } + assert.equal(await db.customerMerge.count(), 0) + assert.equal(await db.customer.count(), 2) +}) + +test('injected failure after archive and relinks rolls the entire merge back', async () => { + await relations() + const before = await db.customer.findMany({ orderBy: { id: 'asc' } }) + await db.$executeRawUnsafe(`CREATE TRIGGER reject_test_merge BEFORE DELETE ON Customer WHEN OLD.id = 'remove' BEGIN SELECT RAISE(ABORT, 'injected_failure'); END`) + await assert.rejects(confirmCustomerMerge(db, 'admin', await confirmation())) + assert.deepEqual(await db.customer.findMany({ orderBy: { id: 'asc' } }), before) + assert.equal(await db.customerMerge.count(), 0) + assert.equal((await db.salesDeal.findUniqueOrThrow({ where: { id: 'deal' } })).customerId, 'remove') + assert.equal((await db.customerActivity.findUniqueOrThrow({ where: { id: 'receipt' } })).customerId, 'remove') + assert.equal((await db.agent.findUniqueOrThrow({ where: { id: 'agent' } })).customerId, 'remove') + assert.equal((await db.buildupEvent.findUniqueOrThrow({ where: { id: 'event' } })).warpCustomerId, 'remove') +}) + +test('idempotent replay, alias chains, immutable source IDs and canonical deletion guards', async () => { + const body = await confirmation() + await confirmCustomerMerge(db, 'admin', body) + await confirmCustomerMerge(db, 'admin', body) + assert.equal(await db.customerMerge.count(), 1) + await rejectsStatus(confirmCustomerMerge(db, 'admin', { ...body, choices: { ...body.choices, name: 'remove' } }), 409) + await rejectsStatus(requireCurrentCustomerId(db, 'remove'), 409) + await assert.rejects(db.customer.delete({ where: { id: 'keep' } })) + await assert.rejects(db.customer.create({ data: { id: 'remove', name: '재사용' } })) + await db.customer.create({ data: { id: 'last', name: '최종', phone: '01012345678' } }) + await confirmCustomerMerge(db, 'admin', await confirmation('last', 'keep')) + assert.equal(await resolveCustomerId(db, 'remove'), 'last') + assert.equal((await confirmCustomerMerge(db, 'admin', body)).customerId, 'last') + await assert.rejects(db.customer.delete({ where: { id: 'last' } })) +}) + +test('existing marketing sourceId replay keeps activity ID and returns retained customer', async () => { + const payload = { source: 'mleverage-admin', companyScopeId: '55f9a8bb-73f9-4316-bcd7-7dcdce0bdcc3', + homepageScopeId: '5c7a6115-a0a9-4e8d-bf65-efce52195fa4', sourceId: '00000000-0000-4000-8000-000000000001', + inquiryDate: '2026-09-15', inquiryTime: '09:07', sourceStatus: '상담대기', name: '신규문의', phone: '010-1234-5678' } + const key = 'private-test-key-at-least-32-characters' + const send = () => handleMarketingInquiryRequest(new Request('https://warp.test/api/external/marketing-inquiries', { + method: 'POST', headers: { 'x-api-key': key }, body: JSON.stringify(payload), + }), db, { WARP_MARKETING_API_KEY: key }) + // Temporarily use an unrelated phone for the survivor so the initial receipt has one match. + await db.customer.update({ where: { id: 'keep' }, data: { phone: '01099998888' } }) + await db.customer.update({ where: { id: 'remove' }, data: { phone: '01012345678' } }) + const first = await (await send()).json() + assert.equal(first.customerId, 'remove') + await db.customer.update({ where: { id: 'keep' }, data: { phone: '01012345678' } }) + await confirmCustomerMerge(db, 'admin', await confirmation()) + const replay = await (await send()).json() + assert.equal(replay.activityId, first.activityId) + assert.equal(replay.customerId, 'keep'); assert.equal(replay.duplicate, true) + assert.equal(await db.customerActivity.count(), 1) +}) + +test('separate SQLite clients serialize identical confirmations and reject competing/stale writes', async () => { + const other = connect() + const body = await confirmation() + const results = await Promise.all([confirmCustomerMerge(db, 'admin', body), confirmCustomerMerge(other, 'admin', body)]) + assert.deepEqual(results[0], results[1]) + assert.equal(await db.customerMerge.count(), 1) + await db.customer.create({ data: { id: 'third', name: '세번째', phone: '01012345678' } }) + const next = await confirmation('keep', 'third') + let release!: () => void + let ready!: () => void + const started = new Promise(resolve => { ready = resolve }) + const gate = new Promise(resolve => { release = resolve }) + const concurrentUpload = customerWrite(other, async tx => { + await tx.customer.update({ where: { id: 'keep' }, data: { documentsJson: '[{"path":"/concurrent.pdf"}]' } }) + ready(); await gate + }) + await started + const waiting = confirmCustomerMerge(db, 'admin', next) + setTimeout(release, 50) + await concurrentUpload + await rejectsStatus(waiting, 409) + assert.equal(await db.customer.count(), 2) +}) + +test('HTTP boundaries enforce same origin, bounded JSON, current authorization and opaque Korean failures', async () => { + const url = 'https://warp.test/api/migrate/merge-customers' + const body = await confirmation() + const request = (payload: string, extra: Record = {}) => new Request(url, { method: 'POST', + headers: { origin: 'https://warp.test', 'content-type': 'application/json', ...extra }, body: payload }) + for (const [req, status] of [ + [request(JSON.stringify(body), { origin: 'https://evil.test' }), 403], + [request(JSON.stringify(body), { origin: '' }), 403], + [request(JSON.stringify(body), { 'content-type': 'text/plain' }), 415], + [request('x'.repeat(17000)), 413], [request('{'), 400], + [request(JSON.stringify({ ...body, previewToken: '' })), 409], + ] as const) { + const response = await handleCustomerMergeRequest(req, db, 'admin', {}) + assert.equal(response.status, status) + assert.equal(response.headers.get('cache-control'), 'no-store') + const message = await response.json() + assert.ok(message.error.length < 120) + } + assert.equal((await handleCustomerMergeRequest(request(JSON.stringify(body)), db, undefined, {})).status, 401) + await db.$executeRawUnsafe(`CREATE TRIGGER http_fail BEFORE DELETE ON Customer BEGIN SELECT RAISE(ABORT, 'secret-diagnostic'); END`) + const error = await handleCustomerMergeRequest(request(JSON.stringify(body)), db, 'admin', {}) + assert.equal(error.status, 503) + assert.doesNotMatch(await error.text(), /secret-diagnostic|INSERT|DELETE|Prisma|김보존/) +}) + + +test('HTTP public-origin configuration supports reverse proxies and fails closed for invalid config or forwarded host spoofing', async () => { + const body = await confirmation() + const request = (origin: string) => new Request('http://localhost:3000/api/migrate/merge-customers', { + method: 'POST', headers: { origin, 'content-type': 'application/json', 'x-forwarded-host': 'evil.test', 'x-forwarded-proto': 'https' }, + body: JSON.stringify(body), + }) + assert.equal((await handleCustomerMergeRequest(request('https://evil.test'), db, 'admin', { AUTH_URL: 'https://warp.test' })).status, 403) + assert.equal((await handleCustomerMergeRequest(request('https://warp.test'), db, 'admin', { AUTH_URL: 'broken' })).status, 503) + assert.equal((await handleCustomerMergeRequest(request('https://warp.test'), db, 'admin', { AUTH_URL: '' })).status, 503) + assert.equal((await handleCustomerMergeRequest(request('https://warp.test'), db, 'admin', { AUTH_URL: 'ftp://warp.test' })).status, 503) + assert.equal((await handleCustomerMergeRequest(request('https://warp.test'), db, 'admin', { NEXTAUTH_URL: 'https://warp.test' })).status, 200) + assert.equal((await handleCustomerMergeRequest(request('https://warp.test'), db, 'admin', { AUTH_URL: 'https://warp.test', NEXTAUTH_URL: 'https://old.test' })).status, 200) +}) diff --git a/lib/customer-merge.ts b/lib/customer-merge.ts new file mode 100644 index 0000000..d2c1997 --- /dev/null +++ b/lib/customer-merge.ts @@ -0,0 +1,256 @@ +import { createHash } from 'node:crypto' +import type { Customer, PrismaClient } from '@/app/generated/prisma/client' +import { CustomerMergeError, customerWrite, resolveCustomerId, type CustomerDb } from './customer-alias' +import { normalizeCustomerMobile } from './customer-mobile' + +export { CustomerMergeError, customerErrorResponse } from './customer-alias' + +export async function requireMergeActor(db: CustomerDb, userId: string | undefined) { + if (!userId) throw new CustomerMergeError(401, '로그인이 필요합니다.') + const actor = await db.user.findUnique({ + where: { id: userId }, select: { id: true, name: true, role: true, employmentType: true }, + }) + if (!actor || actor.employmentType === '사외' || !['admin', 'ceo'].includes(actor.role)) { + throw new CustomerMergeError(403, '사내 관리자 또는 대표만 고객 통합을 사용할 수 있습니다.') + } + return actor +} + +type BusinessKey = Exclude +const LABELS = { + name: '고객명', phone: '휴대전화', email: '이메일', customerSegment: 'B2B/B2C', customerCategory: '고객 유형', + status: '고객 상태', grade: '등급', tags: '태그', source: '유입 경로', collectedAt: '수집일', referrer: '추천인', + leadType: '리드 유형', assignee: '담당자', isAgent: '소개자 여부', memo: '메모', birthYear: '출생연도', + regionCity: '시도', regionDist: '시군구', gender: '성별', birthInfo: '생년 정보', maritalStatus: '혼인 상태', + childrenCount: '자녀 수', addressDetail: '상세 주소', isSoleProprietor: '개인사업자 여부', soleBusinessName: '상호', + soleBusinessNo: '개인사업자 등록번호', soleBusinessType: '개인사업자 업종', b2bCategory: '법인 구분', + companyName: '회사명', businessRegNo: '사업자 등록번호', contactTitle: '직책', industry: '업종', + companyAddress: '회사 주소', companyPhone: '회사 전화', employeeCount: '직원 수', mainContactCardUrl: '대표 명함', + contactsJson: '법인 관계자', hasVehicle: '차량 보유 여부', vehicleMaker: '제작사', vehicleName: '차량명', + vehiclePlateNo: '차량번호', vehicleYear: '연식', totalMileage: '주행거리', truckType1: '특장 구분 1', + truckType2: '특장 구분 2', truckType3: '특장 구분 3', truckType4: '특장 구분 4', vehicleCount: '차량 대수', + vehicleListJson: '차량 목록', documentsJson: '첨부 문서', b2bRevenue1: '1년 전 매출', b2bRevenue2: '2년 전 매출', + b2bRevenue3: '3년 전 매출', shipperName: '화주명', cargoType: '화물 유형', deliveryCity: '배송 시도', + deliveryDist: '배송 시군구', deliveryFreq: '배송 빈도', workShift: '근무 시간', monthlyIncome: '월 수익', cargoNote: '화물 메모', +} satisfies Record +const ARRAY_KEYS = new Set(['tags', 'contactsJson', 'vehicleListJson', 'documentsJson']) + +function stable(value: unknown): string { + if (value instanceof Date) return JSON.stringify(value.toISOString()) + if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']' + if (value !== null && typeof value === 'object') { + return '{' + Object.entries(value).sort(([a], [b]) => a.localeCompare(b)) + .map(([key, val]) => JSON.stringify(key) + ':' + stable(val)).join(',') + '}' + } + return JSON.stringify(value) +} +const empty = (v: unknown) => v == null || (typeof v === 'string' && !v.trim()) +const unknownName = (name: string) => /^(?:신원\s*)?미상(?:\s*\([^]*\))?$/.test(name.trim()) + +export function parseCustomerArray(value: string | null, label: string): unknown[] { + if (value == null || value === '') return [] + try { + const array: unknown = JSON.parse(value) + if (Array.isArray(array)) return array + } catch { /* fail closed; do not replace corrupted metadata with an empty array */ } + throw new CustomerMergeError(409, `${label} 형식이 올바르지 않습니다. 원본을 확인한 뒤 다시 시도해 주세요.`) +} + +export type MergeField = { + key: BusinessKey; label: string; keepValue: unknown; removeValue: unknown; resultValue: unknown + mode: 'same' | 'fill' | 'automatic' | 'conflict'; requiresChoice: boolean +} + +export function customerMergeFields(keep: Customer, remove: Customer): MergeField[] { + return (Object.keys(LABELS) as BusinessKey[]).map(key => { + const a = keep[key], b = remove[key] + let resultValue: unknown = a + let mode: MergeField['mode'] = 'same' + if (ARRAY_KEYS.has(key)) { + const left = parseCustomerArray(a as string | null, LABELS[key]) + const right = parseCustomerArray(b as string | null, LABELS[key]) + const union = [...left] + const seen = new Set(left.map(stable)) + for (const item of right) if (!seen.has(stable(item))) { seen.add(stable(item)); union.push(item) } + if (key === 'contactsJson') { + const ids = new Set() + for (let index = 0; index < union.length; index++) { + const item = union[index] + if (!item || typeof item !== 'object' || Array.isArray(item)) continue + const contact = item as Record + if (typeof contact.id !== 'string') continue + let id = contact.id + if (ids.has(id)) { + const suffix = createHash('sha256').update(stable({ contact, source: remove.id })).digest('hex').slice(0, 16) + id = `${contact.id}-merge-${suffix}` + while (ids.has(id)) id += '-2' + union[index] = { ...contact, id } + } + ids.add(id) + } + } + if (stable(union) !== stable(left)) { + mode = left.length ? 'automatic' : 'fill' + resultValue = JSON.stringify(union) + } + } else if (stable(a) === stable(b) || empty(b)) { + // Retain the exact stored value, including false and zero. + } else if (empty(a)) { + resultValue = b; mode = 'fill' + } else if (key === 'phone') { + mode = 'automatic' // equality of normalized mobile was checked before this function + } else if (key === 'name' && unknownName(keep.name) !== unknownName(remove.name)) { + resultValue = unknownName(keep.name) ? b : a; mode = 'automatic' + } else if (key === 'memo' || key === 'cargoNote') { + resultValue = `[유지 고객: ${keep.name} / ${keep.id}]\n${a}\n\n[통합 고객: ${remove.name} / ${remove.id}]\n${b}` + mode = 'automatic' + } else { + resultValue = null; mode = 'conflict' + } + return { key, label: LABELS[key], keepValue: a, removeValue: b, resultValue, mode, requiresChoice: mode === 'conflict' } + }) +} + +function validIds(keepId: unknown, removeId: unknown): asserts keepId is string { + if (typeof keepId !== 'string' || typeof removeId !== 'string' || !keepId || !removeId + || keepId.length > 128 || removeId.length > 128 || keepId === removeId) { + throw new CustomerMergeError(400, '서로 다른 두 고객을 선택해 주세요.') + } +} + +async function snapshot(db: CustomerDb, keepId: string, removeId: string) { + const keep = await db.customer.findUnique({ where: { id: keepId } }) + const remove = await db.customer.findUnique({ where: { id: removeId } }) + if (!keep || !remove) throw new CustomerMergeError(409, '고객 정보가 변경되었습니다. 미리보기를 다시 확인해 주세요.') + const mobile = normalizeCustomerMobile(keep.phone) + if (!mobile || mobile !== normalizeCustomerMobile(remove.phone)) { + throw new CustomerMergeError(409, '같은 유효한 휴대전화 번호의 고객만 통합할 수 있습니다.') + } + const where = { customerId: { in: [keepId, removeId] } } + const orderBy = { id: 'asc' as const } + const leads = await db.salesDeal.findMany({ where, orderBy, include: { + meetings: { orderBy }, stageHistory: { orderBy }, documents: { orderBy }, shares: { orderBy }, + } }) + const activities = await db.customerActivity.findMany({ where, orderBy }) + const agents = await db.agent.findMany({ where, orderBy }) + const events = await db.buildupEvent.findMany({ where: { warpCustomerId: { in: [keepId, removeId] } }, orderBy }) + const aliases = await db.customerMerge.findMany({ + where: { targetId: { in: [keepId, removeId] } }, orderBy: { sourceId: 'asc' }, + select: { sourceId: true, targetId: true }, + }) + return { keep, remove, leads, activities, agents, events, aliases } +} +type Snapshot = Awaited> +const tokenFor = (state: Snapshot, actorId: string) => 'v1.' + createHash('sha256').update(stable({ actorId, state })).digest('hex') + +function preview(state: Snapshot, actorId: string) { + return { + keep: { id: state.keep.id, name: state.keep.name }, remove: { id: state.remove.id, name: state.remove.name }, + previewToken: tokenFor(state, actorId), fields: customerMergeFields(state.keep, state.remove), + counts: { + leads: state.leads.filter(row => row.customerId === state.remove.id).length, + activities: state.activities.filter(row => row.customerId === state.remove.id).length, + agents: state.agents.filter(row => row.customerId === state.remove.id).length, + events: state.events.filter(row => row.warpCustomerId === state.remove.id).length, + }, + warnings: ['리드는 각각 유지되며 고객 연결만 변경됩니다. 단계·가격·상담·활동 이력은 보존됩니다.', + '양쪽 고객 원본과 선택 결과는 관리자용 감사 기록에 보존됩니다. 첨부 파일 경로는 변경하지 않습니다.', + '서로 다른 배열 항목은 모두 보존하며, 충돌하는 관계자 ID는 새 ID로 구분합니다. 관계자·첨부 목록을 확인해 주세요.'], + } +} + +export async function previewCustomerMerge(db: PrismaClient, actorId: string | undefined, keepId: string, removeId: string) { + return customerWrite(db, async tx => { + const actor = await requireMergeActor(tx, actorId) + validIds(keepId, removeId) + return preview(await snapshot(tx, keepId, removeId), actor.id) + }) +} + +type ConfirmBody = { keepId: string; removeId: string; previewToken: string; choices: Record; confirmed: true } +function confirmBody(body: unknown): ConfirmBody { + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new CustomerMergeError(400, '요청 내용을 확인해 주세요.') + const b = body as Record + validIds(b.keepId, b.removeId) + if (typeof b.previewToken !== 'string' || !/^v1\.[a-f0-9]{64}$/.test(b.previewToken)) { + throw new CustomerMergeError(409, '미리보기가 유효하지 않습니다. 다시 확인해 주세요.') + } + if (b.confirmed !== true || !b.choices || typeof b.choices !== 'object' || Array.isArray(b.choices) + || Object.keys(b).some(key => !['keepId', 'removeId', 'previewToken', 'choices', 'confirmed'].includes(key)) + || Object.entries(b.choices).some(([key, value]) => !Object.hasOwn(LABELS, key) || (value !== 'keep' && value !== 'remove'))) { + throw new CustomerMergeError(400, '필드 선택과 통합 확인이 필요합니다.') + } + return b as ConfirmBody +} + +export async function confirmCustomerMerge(db: PrismaClient, actorId: string | undefined, body: unknown) { + return customerWrite(db, async tx => { + const actor = await requireMergeActor(tx, actorId) + const b = confirmBody(body) + const old = await tx.customerMerge.findUnique({ where: { sourceId: b.removeId } }) + if (old) { + if (old.targetId !== b.keepId || old.actorId !== actor.id || old.previewToken !== b.previewToken || old.choicesJson !== stable(b.choices)) { + throw new CustomerMergeError(409, '이미 통합된 고객입니다. 고객 목록을 다시 확인해 주세요.') + } + return { ok: true, message: '고객 통합이 완료되었습니다.', customerId: await resolveCustomerId(tx, old.targetId) } + } + const state = await snapshot(tx, b.keepId, b.removeId) + if (b.previewToken !== tokenFor(state, actor.id)) throw new CustomerMergeError(409, '미리보기 이후 정보가 변경되었습니다. 다시 확인해 주세요.') + const fields = customerMergeFields(state.keep, state.remove) + if (fields.some(field => field.requiresChoice && !Object.hasOwn(b.choices, field.key)) + || Object.keys(b.choices).some(key => !fields.some(field => field.key === key && field.requiresChoice))) { + throw new CustomerMergeError(400, '충돌하는 모든 필드에서 유지할 값을 선택해 주세요.') + } + const data = Object.fromEntries(fields.map(field => [field.key, field.requiresChoice + ? (b.choices[field.key] === 'keep' ? field.keepValue : field.removeValue) : field.resultValue])) + // Archive BOTH original scalar records and complete related state before any mutation. + await tx.customerMerge.create({ data: { + sourceId: b.removeId, targetId: b.keepId, actorId: actor.id, actorName: actor.name, + previewToken: b.previewToken, choicesJson: stable(b.choices), snapshotJson: stable(state), + } }) + await tx.customer.update({ where: { id: b.keepId }, data }) + // Raw FK-only updates intentionally preserve deal/agent updatedAt and every business field. + await tx.$executeRaw`UPDATE "SalesDeal" SET "customerId" = ${b.keepId} WHERE "customerId" = ${b.removeId}` + await tx.$executeRaw`UPDATE "CustomerActivity" SET "customerId" = ${b.keepId} WHERE "customerId" = ${b.removeId}` + await tx.$executeRaw`UPDATE "Agent" SET "customerId" = ${b.keepId} WHERE "customerId" = ${b.removeId}` + await tx.$executeRaw`UPDATE "BuildupEvent" SET "warpCustomerId" = ${b.keepId} WHERE "warpCustomerId" = ${b.removeId}` + await tx.customer.delete({ where: { id: b.removeId } }) + return { ok: true, message: '고객 통합이 완료되었습니다.', customerId: b.keepId } + }) +} + +export async function findCustomerDuplicates(db: PrismaClient, actorId: string | undefined) { + return db.$transaction(async tx => { + await requireMergeActor(tx, actorId) + const customers = await tx.customer.findMany({ + select: { id: true, name: true, phone: true, status: true, createdAt: true, _count: { select: { leads: true } } }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }) + const leads = await tx.salesDeal.findMany({ + select: { id: true, name: true, phone: true, customerId: true, stageCode: true, stage: true, salesStatus: true, createdAt: true }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }) + function grouped(rows: T[]) { + const result = new Map() + for (const row of rows) { + const phone = normalizeCustomerMobile(row.phone) + if (phone) result.set(phone, [...(result.get(phone) ?? []), row]) + } + return result + } + const groups = [...grouped(customers)].filter(([, rows]) => rows.length > 1).map(([phone, rows]) => ({ + phone, name: rows[0].name, customers: rows.map(({ _count, ...row }) => ({ ...row, leadCount: _count.leads })), + })) + const byId = new Map(customers.map(row => [row.id, row])) + const leadGroups = [...grouped(leads)].filter(([phone, rows]) => rows.length > 1 || rows.some(row => + row.customerId && normalizeCustomerMobile(byId.get(row.customerId)?.phone) !== phone)).map(([phone, rows]) => ({ + phone, name: rows[0].name, informational: true, classification: 'repeated_leads' as const, + crossCustomer: new Set(rows.map(row => row.customerId).filter(Boolean)).size > 1, + associationMismatch: rows.some(row => row.customerId && normalizeCustomerMobile(byId.get(row.customerId)?.phone) !== phone), + leads: rows, + })) + return { total: customers.length, dupCount: groups.reduce((sum, group) => sum + group.customers.length - 1, 0), + groupCount: groups.length, groups, leadTotal: leads.length, leadGroupCount: leadGroups.length, + leadDupCount: leadGroups.reduce((sum, group) => sum + Math.max(0, group.leads.length - 1), 0), leadGroups } + }) +} diff --git a/lib/customer-mobile.ts b/lib/customer-mobile.ts new file mode 100644 index 0000000..5c4ad88 --- /dev/null +++ b/lib/customer-mobile.ts @@ -0,0 +1,8 @@ +/** Domestic mobiles, including legacy 011/016/017/018/019; punctuation other than spaces/hyphens is invalid. */ +export function normalizeCustomerMobile(value: string | null | undefined): string | null { + if (!value) return null + let phone = value.replace(/[\s-]/g, '') + if (phone.startsWith('+82')) phone = '0' + phone.slice(3).replace(/^0/, '') + else if (phone.startsWith('0082')) phone = '0' + phone.slice(4).replace(/^0/, '') + return /^(?:010\d{8}|01[16789]\d{7,8})$/.test(phone) ? phone : null +} diff --git a/package-lock.json b/package-lock.json index da95c19..4401f5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8654,9 +8654,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index a7f475a..95b52c1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "test:buildup-import": "tsx --test lib/buildup-import/*.test.ts", "security:audit": "python3 deploy/audit_production_dependencies.py", "account-evidence:dispatch": "tsx scripts/dispatch-account-evidence.ts", - "account-control:hq-probe": "tsx scripts/hq-local-integration-probe.ts" + "account-control:hq-probe": "tsx scripts/hq-local-integration-probe.ts", + "test:customer-merge": "tsx --test lib/customer-merge.test.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.105.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36809b9..e276f37 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -314,6 +314,20 @@ model Customer { updatedAt DateTime @updatedAt } +// Immutable merge audit and old-ID alias. No FK: intermediate targets may themselves merge. +model CustomerMerge { + sourceId String @id + targetId String + actorId String + actorName String + previewToken String + choicesJson String + snapshotJson String + createdAt DateTime @default(now()) + + @@index([targetId]) +} + // buildup-ev 딜 이벤트 수신함 (#27) — 견적작성·계약체결 알림을 쌓아두고 // 담당자가 확인 후 파이프라인에 직접 기입한다(자동 기입 없음). // 전역 알림에는 싣지 않는다 — 고객관리·파이프라인 화면에서만 배지로 보인다. From 34b4b25d99cc95c3af97ece3bcec2e5347f8918d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=83=9C=EA=B7=A0?= Date: Tue, 6 Oct 2026 11:06:27 +0900 Subject: [PATCH 2/8] ci: check customer merge changes on pull requests --- .github/workflows/verify-direct-integration.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/verify-direct-integration.yml b/.github/workflows/verify-direct-integration.yml index ff971cb..a1e4d84 100644 --- a/.github/workflows/verify-direct-integration.yml +++ b/.github/workflows/verify-direct-integration.yml @@ -6,11 +6,22 @@ on: branches: [main] paths: - app/api/external/** + - app/api/customers/** + - app/api/migrate/find-duplicates/** + - app/api/migrate/merge-customers/** + - app/(app)/customers/** + - components/CustomerMergePanel.tsx + - lib/customer-merge*.ts + - lib/customer-alias.ts + - lib/customer-mobile.ts + - prisma/schema.prisma + - deploy/schema-migrations/** - lib/buildup-import/** - lib/marketing-inquiries*.ts - docs/integrations/** - scripts/verify-direct-integration-contract.mjs - package.json + - package-lock.json - .github/workflows/verify-direct-integration.yml permissions: From b11f63a41e0dac9603a32e03a480164c5aab6878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=83=9C=EA=B7=A0?= Date: Tue, 6 Oct 2026 11:21:32 +0900 Subject: [PATCH 3/8] feat: show pending customer duplicate count badge --- app/(app)/customers/CustomerListClient.tsx | 28 +++++++++++++++++----- lib/customer-merge.test.ts | 10 +++++++- lib/customer-merge.ts | 4 ++-- lib/customer-mobile.ts | 13 ++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/app/(app)/customers/CustomerListClient.tsx b/app/(app)/customers/CustomerListClient.tsx index 8028773..2c5d345 100644 --- a/app/(app)/customers/CustomerListClient.tsx +++ b/app/(app)/customers/CustomerListClient.tsx @@ -1,10 +1,11 @@ 'use client' -import { useState, useMemo, useEffect, useRef } from 'react' +import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { Phone, Truck } from 'lucide-react' import { BuildupImportModal } from '@/components/BuildupImportModal' +import { countDuplicateCustomers } from '@/lib/customer-mobile' /** * "미상(...)" 이름에 박아둔 이모지 아이콘(📞/🚚)을 전화/차량번호 바로 앞에 @@ -188,10 +189,19 @@ export default function CustomerListClient({ customers: initial, canMergeCustome const [importOpen, setImportOpen] = useState(false) /** buildup 가져오기 승인 후 목록 재조회 — props 초기값이라 router.refresh 로는 안 바뀐다 */ - const reloadCustomers = async () => { - const res = await fetch('/api/customers') - if (res.ok) setCustomers(await res.json()) - } + const reloadCustomers = useCallback(async () => { + try { + const res = await fetch('/api/customers', { cache: 'no-store' }) + if (res.ok) setCustomers(await res.json()) + } catch { /* Retain the last known list when offline. */ } + }, []) + + useEffect(() => { + if (!canMergeCustomers) return + void reloadCustomers() + window.addEventListener('focus', reloadCustomers) + return () => window.removeEventListener('focus', reloadCustomers) + }, [canMergeCustomers, reloadCustomers]) /* 모달 없이 바로 고객상세 페이지로 이동 — 이름은 비워둔 채 생성 후 상세페이지에서 채움 */ const handleQuickAdd = async () => { @@ -232,6 +242,7 @@ export default function CustomerListClient({ customers: initial, canMergeCustome const b2cCount = useMemo(() => customers.filter(c => (c.customerSegment ?? 'B2C') === 'B2C').length, [customers]) const b2bCount = useMemo(() => customers.filter(c => c.customerSegment === 'B2B').length, [customers]) + const duplicateCount = useMemo(() => countDuplicateCustomers(customers), [customers]) const filtered = useMemo(() => { return customers.filter(c => { @@ -327,8 +338,13 @@ export default function CustomerListClient({ customers: initial, canMergeCustome {canMergeCustomers && + title={`처리할 중복 고객 ${duplicateCount}건`} + className="relative rounded-lg border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50"> 중복 고객 확인 + {duplicateCount > 0 && + {duplicateCount > 99 ? '99+' : duplicateCount} + } } {/* 신규 고객 추가 */} { + assert.equal(countDuplicateCustomers([]), 0) + assert.equal(countDuplicateCustomers([ + { phone: '010-1234-5678' }, { phone: '+82 10 1234 5678' }, { phone: '0082 10 1234 5678' }, + { phone: '01099998888' }, { phone: '010-9999-8888' }, + { phone: null }, { phone: '' }, { phone: '02-1234-5678' }, { phone: '0212345678' }, + ]), 3) for (const input of ['010-1234-5678', '010 1234 5678', '+82 10-1234-5678', '0082 01012345678', '+82 01012345678']) { assert.equal(normalizeCustomerMobile(input), '01012345678') } @@ -145,7 +151,9 @@ test('merge preserves all related records, both original snapshots, conflicting const event = await db.buildupEvent.findUniqueOrThrow({ where: { id: 'event' } }) const p = await previewCustomerMerge(db, 'admin', 'keep', 'remove') assert.deepEqual(p.counts, { leads: 1, activities: 1, agents: 1, events: 1 }) + assert.equal((await findCustomerDuplicates(db, 'admin')).dupCount, 1) assert.deepEqual(await confirmCustomerMerge(db, 'admin', await confirmation()), { ok: true, message: '고객 통합이 완료되었습니다.', customerId: 'keep' }) + assert.equal((await findCustomerDuplicates(db, 'admin')).dupCount, 0) assert.equal(await db.customer.count(), 1) const kept = await db.customer.findUniqueOrThrow({ where: { id: 'keep' } }) assert.match(kept.memo!, /원본 유지/); assert.match(kept.memo!, /원본 통합/) diff --git a/lib/customer-merge.ts b/lib/customer-merge.ts index d2c1997..70b801a 100644 --- a/lib/customer-merge.ts +++ b/lib/customer-merge.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import type { Customer, PrismaClient } from '@/app/generated/prisma/client' import { CustomerMergeError, customerWrite, resolveCustomerId, type CustomerDb } from './customer-alias' -import { normalizeCustomerMobile } from './customer-mobile' +import { countDuplicateCustomers, normalizeCustomerMobile } from './customer-mobile' export { CustomerMergeError, customerErrorResponse } from './customer-alias' @@ -249,7 +249,7 @@ export async function findCustomerDuplicates(db: PrismaClient, actorId: string | associationMismatch: rows.some(row => row.customerId && normalizeCustomerMobile(byId.get(row.customerId)?.phone) !== phone), leads: rows, })) - return { total: customers.length, dupCount: groups.reduce((sum, group) => sum + group.customers.length - 1, 0), + return { total: customers.length, dupCount: countDuplicateCustomers(customers), groupCount: groups.length, groups, leadTotal: leads.length, leadGroupCount: leadGroups.length, leadDupCount: leadGroups.reduce((sum, group) => sum + Math.max(0, group.leads.length - 1), 0), leadGroups } }) diff --git a/lib/customer-mobile.ts b/lib/customer-mobile.ts index 5c4ad88..618170e 100644 --- a/lib/customer-mobile.ts +++ b/lib/customer-mobile.ts @@ -6,3 +6,16 @@ export function normalizeCustomerMobile(value: string | null | undefined): strin else if (phone.startsWith('0082')) phone = '0' + phone.slice(4).replace(/^0/, '') return /^(?:010\d{8}|01[16789]\d{7,8})$/.test(phone) ? phone : null } + +/** Count excess customer records: three customers with one mobile require two merges. */ +export function countDuplicateCustomers(customers: readonly { phone: string | null }[]): number { + const seen = new Set() + let count = 0 + for (const customer of customers) { + const phone = normalizeCustomerMobile(customer.phone) + if (!phone) continue + if (seen.has(phone)) count++ + else seen.add(phone) + } + return count +} From 1a9dfbcff7bfd6700dd44709a48da9ca41015469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=83=9C=EA=B7=A0?= Date: Tue, 6 Oct 2026 11:38:59 +0900 Subject: [PATCH 4/8] feat: support customer merge workflow on mobile --- .../workflows/verify-direct-integration.yml | 3 + .../customers/[id]/CustomerDetailClient.tsx | 33 ++-- .../m/customers/MobileCustomersClient.tsx | 165 ++++++++++++++++++ app/(mobile)/m/customers/[id]/page.tsx | 39 +++++ app/(mobile)/m/customers/duplicates/page.tsx | 25 +++ app/(mobile)/m/customers/page.tsx | 44 +++++ app/(mobile)/m/layout.tsx | 9 +- components/CustomerMergePanel.tsx | 47 ++--- proxy.ts | 5 + 9 files changed, 331 insertions(+), 39 deletions(-) create mode 100644 app/(mobile)/m/customers/MobileCustomersClient.tsx create mode 100644 app/(mobile)/m/customers/[id]/page.tsx create mode 100644 app/(mobile)/m/customers/duplicates/page.tsx create mode 100644 app/(mobile)/m/customers/page.tsx diff --git a/.github/workflows/verify-direct-integration.yml b/.github/workflows/verify-direct-integration.yml index a1e4d84..af68239 100644 --- a/.github/workflows/verify-direct-integration.yml +++ b/.github/workflows/verify-direct-integration.yml @@ -10,6 +10,9 @@ on: - app/api/migrate/find-duplicates/** - app/api/migrate/merge-customers/** - app/(app)/customers/** + - app/(mobile)/m/customers/** + - app/(mobile)/m/layout.tsx + - proxy.ts - components/CustomerMergePanel.tsx - lib/customer-merge*.ts - lib/customer-alias.ts diff --git a/app/(app)/customers/[id]/CustomerDetailClient.tsx b/app/(app)/customers/[id]/CustomerDetailClient.tsx index d84c78a..fcb3d3e 100644 --- a/app/(app)/customers/[id]/CustomerDetailClient.tsx +++ b/app/(app)/customers/[id]/CustomerDetailClient.tsx @@ -86,7 +86,9 @@ type Customer = { leads: Lead[]; activities: Activity[] } -export default function CustomerDetailClient({ customer, returnTo, myName }: { customer: Customer; returnTo?: string; myName?: string }) { +export default function CustomerDetailClient({ customer, returnTo, myName, backHref = '/customers' }: { + customer: Customer; returnTo?: string; myName?: string; backHref?: string +}) { const router = useRouter() const [f, setF] = useState({ @@ -456,7 +458,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c }), }) const deal = await res.json() - router.push(`/funnel/${deal.id}`) + router.push(`${backHref.startsWith('/m/') ? '/m/pipeline' : '/funnel'}/${deal.id}`) } catch { setConverting(false) } @@ -490,7 +492,7 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c ) return ( -
+
{msg && (
✓ {msg} @@ -514,12 +516,12 @@ export default function CustomerDetailClient({ customer, returnTo, myName }: { c )} {/* 헤더 */} -
-
- ← 고객 목록 -
-

{f.name || '이름 미입력'}

-
+
+
+ ← 고객 목록 +
+

{f.name || '이름 미입력'}

+
{customer.phone && {customer.phone}}
-
+
+ ))} +
+
+ +
+ {filtered.length === 0 && ( +
+ 조건에 맞는 고객이 없습니다. +
+ )} + {filtered.map(customer => ( + +
+
+ {customer.name || '이름 미입력'} + + {customer.customerSegment ?? 'B2C'} + +
+
+ + {customer.phone || '연락처 없음'} +
+
+ {customer.status} + 리드 {customer.leads.length}건 +
+
+ + + ))} +
+
+ ) +} diff --git a/app/(mobile)/m/customers/[id]/page.tsx b/app/(mobile)/m/customers/[id]/page.tsx new file mode 100644 index 0000000..9b72b1a --- /dev/null +++ b/app/(mobile)/m/customers/[id]/page.tsx @@ -0,0 +1,39 @@ +import { notFound, redirect } from 'next/navigation' +import { auth } from '@/auth' +import { prisma } from '@/lib/db' +import { resolveCustomerId } from '@/lib/customer-alias' +import CustomerDetailClient from '@/app/(app)/customers/[id]/CustomerDetailClient' + +export default async function MobileCustomerDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id: requestedId } = await params + const id = await resolveCustomerId(prisma, requestedId) + const session = await auth() + if (!session?.user?.id) redirect('/login') + const me = await prisma.user.findUnique({ + where: { id: session.user.id }, select: { name: true, employmentType: true }, + }) + if (!me) redirect('/login') + const customer = await prisma.customer.findUnique({ + where: { id }, + include: { + leads: { orderBy: { createdAt: 'desc' } }, + activities: { orderBy: { date: 'desc' } }, + }, + }) + + if (!customer) notFound() + if (me.employmentType === '사외' && customer.assignee !== me.name) notFound() + if (id !== requestedId) redirect(`/m/customers/${encodeURIComponent(id)}`) + + return ( + + ) +} diff --git a/app/(mobile)/m/customers/duplicates/page.tsx b/app/(mobile)/m/customers/duplicates/page.tsx new file mode 100644 index 0000000..e35b4bf --- /dev/null +++ b/app/(mobile)/m/customers/duplicates/page.tsx @@ -0,0 +1,25 @@ +import Link from 'next/link' +import { auth } from '@/auth' +import { prisma } from '@/lib/db' +import CustomerMergePanel from '@/components/CustomerMergePanel' + +export default async function MobileCustomerDuplicatesPage() { + const session = await auth() + const actor = session?.user?.id ? await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true, employmentType: true }, + }) : null + + if (!actor || !['admin', 'ceo'].includes(actor.role) || actor.employmentType === '사외') { + return
중복 고객 통합은 관리자·대표 계정에서 사용할 수 있습니다.
+ } + + return ( +
+ + ← 고객 관리로 돌아가기 + + +
+ ) +} diff --git a/app/(mobile)/m/customers/page.tsx b/app/(mobile)/m/customers/page.tsx new file mode 100644 index 0000000..bf0783b --- /dev/null +++ b/app/(mobile)/m/customers/page.tsx @@ -0,0 +1,44 @@ +import { redirect } from 'next/navigation' +import { auth } from '@/auth' +import { prisma } from '@/lib/db' +import MobileCustomersClient from './MobileCustomersClient' + +export const dynamic = 'force-dynamic' + +export default async function MobileCustomersPage() { + const session = await auth() + if (!session?.user) redirect('/login') + + const me = session.user as { id?: string; name?: string; employmentType?: string } + const actor = me.id ? await prisma.user.findUnique({ + where: { id: me.id }, + select: { name: true, role: true, employmentType: true }, + }) : null + if (!actor) redirect('/login') + const isExternal = actor.employmentType === '사외' + const canMergeCustomers = !!actor + && ['admin', 'ceo'].includes(actor.role) + && actor.employmentType !== '사외' + + const customers = await prisma.customer.findMany({ + where: isExternal ? { assignee: actor.name } : undefined, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + phone: true, + status: true, + customerSegment: true, + assignee: true, + leads: { select: { id: true } }, + }, + }) + + return ( + + ) +} diff --git a/app/(mobile)/m/layout.tsx b/app/(mobile)/m/layout.tsx index 6189866..ff1da91 100644 --- a/app/(mobile)/m/layout.tsx +++ b/app/(mobile)/m/layout.tsx @@ -2,11 +2,12 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Home, Filter, PlusCircle, Car } from 'lucide-react' +import { Home, Filter, PlusCircle, Car, Users } from 'lucide-react' const NAV = [ { href: '/m/', icon: Home, label: '홈' }, { href: '/m/pipeline', icon: Filter, label: '파이프라인' }, + { href: '/m/customers', icon: Users, label: '고객관리' }, { href: '/m/activity', icon: PlusCircle, label: '활동추가' }, { href: '/m/vehicle', icon: Car, label: '차량신청' }, ] @@ -33,7 +34,7 @@ export default function MobileLayout({ children }: { children: React.ReactNode } window.location.href = '/funnel' }} style={{ fontSize: 10, color: 'rgba(255,255,255,0.35)', letterSpacing: '0.03em' }} - className="active:opacity-60" + className="min-h-11 px-2 active:opacity-60" > PC 버전 @@ -45,7 +46,7 @@ export default function MobileLayout({ children }: { children: React.ReactNode } {/* 하단 네비게이션 */} -