feat: Re:Link MVP 초기 구현 - 도메인/서비스/프론트엔드 전체

- 모노레포 구조 (Turborepo + pnpm): @relink/domain, @relink/shared, @relink/infrastructure, @relink/database, @relink/web
- 도메인 레이어: 매장(store), 매칭(matching), 업체(vendor), 보조금(subsidy), 계약/에스크로(contract) TDD 완료 (158 단위 테스트)
- 서비스 레이어: 전 도메인 서비스 함수 + 통합 테스트 (58 테스트)
- 프론트엔드: Next.js 15 App Router, 13개 페이지 (사용자 6 + 관리자 7)
- 인프라: PostgreSQL 16 + PostGIS, Prisma ORM, Docker Compose, AuditLog + OutboxEvent 패턴
- .env 파일 포함 (로컬 개발 기본값만 포함, 실제 시크릿 없음)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-03-07 17:39:56 +09:00
commit 16bd2cb92a
170 changed files with 23628 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ['next/core-web-vitals'],
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@relink/ui', '@relink/shared'],
};
export default nextConfig;
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@relink/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf .next"
},
"dependencies": {
"@prisma/client": "^6.1.0",
"@relink/database": "workspace:*",
"@relink/domain": "workspace:*",
"@relink/infrastructure": "workspace:*",
"@relink/shared": "workspace:*",
"@relink/ui": "workspace:*",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"eslint": "^8.57.1",
"eslint-config-next": "^15.1.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
@@ -0,0 +1,721 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
createStoreDraftService,
submitStoreService,
reviewStoreService,
publishStoreService,
} from '../../services/store-service';
import {
createMatchRequestService,
acceptMatchRequestService,
} from '../../services/match-request-service';
import {
createContractService,
releaseEscrowService,
processEscrowWebhookService,
openDisputeService,
} from '../../services/contract-service';
describe('Contract Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createTestUser(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'owner@example.com',
emailNormalized: 'owner@example.com',
name: '매장 소유자',
primaryRole: 'CLOSING_OWNER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createOperator(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createPublishedStore(ownerUserId: bigint, operatorUserId: bigint) {
const createResult = await createStoreDraftService(prisma, {
ownerUserId: ownerUserId.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString());
await reviewStoreService(
prisma,
createResult.value.publicId,
'APPROVED',
operatorUserId.toString(),
);
const policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-test-hash',
effectiveFrom: new Date(),
},
});
await publishStoreService(
prisma,
createResult.value.publicId,
policyVersion.id.toString(),
operatorUserId.toString(),
);
return createResult.value.publicId;
}
async function createAcceptedMatchRequest(
storePublicId: string,
requesterUserId: bigint,
operatorUserId: bigint,
) {
const createResult = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requesterUserId.toString(),
message: '매장 인수 희망',
});
if (!createResult.ok) throw new Error('createMatchRequest failed');
const acceptResult = await acceptMatchRequestService(
prisma,
createResult.value.publicId,
operatorUserId.toString(),
);
if (!acceptResult.ok) throw new Error('acceptMatchRequest failed');
return createResult.value.publicId;
}
async function createContractPolicyVersion() {
return prisma.policyVersion.create({
data: {
policyType: 'CONTRACT_TEMPLATE',
versionCode: 'ct-v1.0',
contentHash: 'sha256-contract-template',
effectiveFrom: new Date(),
},
});
}
// ---------------------------------------------------------------------------
// I012: POST /api/v1/contracts - 계약 생성
// ---------------------------------------------------------------------------
describe('I012: createContractService', () => {
it('ACCEPTED 매칭에서 DRAFT 계약이 생성되고 AuditLog/OutboxEvent 기록', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업 희망자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const result = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('DRAFT');
expect(result.value.escrowStatus).toBe('NOT_STARTED');
expect(result.value.publicId).toBeTruthy();
// DB 저장 확인
const contract = await prisma.contract.findUnique({
where: { publicId: result.value.publicId },
});
expect(contract).not.toBeNull();
expect(contract!.contractType).toBe('ACQUISITION');
expect(contract!.templateCode).toBe('ACQ_STANDARD_V1');
expect(contract!.policyVersionId).toBe(contractPolicy.id);
expect(contract!.storeOwnerUserId).toBe(owner.id);
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: result.value.publicId, actionType: 'CONTRACT_CREATED' },
});
expect(auditLogs).toHaveLength(1);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.publicId, eventName: 'contract.created' },
});
expect(outboxEvents).toHaveLength(1);
});
it('OPEN 매칭에서는 계약 생성 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업 희망자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
// OPEN 상태 매칭 (수락하지 않음)
const matchResult = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
if (!matchResult.ok) throw new Error('createMatchRequest failed');
const contractPolicy = await createContractPolicyVersion();
const result = await createContractService(prisma, {
matchRequestPublicId: matchResult.value.publicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('MATCH_NOT_ACCEPTED');
});
it('존재하지 않는 매칭 요청으로는 계약 생성 불가', async () => {
const operator = await createOperator();
const contractPolicy = await createContractPolicyVersion();
const result = await createContractService(prisma, {
matchRequestPublicId: 'non-existent',
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
it('존재하지 않는 정책 버전으로는 계약 생성 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업 희망자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const result = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: '99999',
createdByUserId: operator.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
// ---------------------------------------------------------------------------
// I013: 에스크로 정산 해제
// ---------------------------------------------------------------------------
describe('I013: releaseEscrowService', () => {
async function createActiveContractWithHoldingEscrow(
owner: { id: bigint },
operator: { id: bigint },
requester: { id: bigint },
) {
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const contractResult = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
if (!contractResult.ok) throw new Error('createContract failed');
// 계약 상태를 ACTIVE로, 에스크로를 HOLDING으로 변경
const contract = await prisma.contract.findUnique({
where: { publicId: contractResult.value.publicId },
});
await prisma.contract.update({
where: { id: contract!.id },
data: { status: 'ACTIVE', escrowStatus: 'HOLDING' },
});
return contractResult.value.publicId;
}
it('검수 승인 완료 시 에스크로가 RELEASE_REVIEW로 전환되고 감사 로그 기록', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createActiveContractWithHoldingEscrow(
owner,
operator,
requester,
);
// 검수 승인 레코드 생성
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
await prisma.inspectionRecord.create({
data: {
contractId: contract!.id,
inspectionType: 'FINAL_COMPLETION',
status: 'APPROVED',
reviewedAt: new Date(),
},
});
const result = await releaseEscrowService(prisma, contractPublicId, operator.id.toString());
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.escrowStatus).toBe('RELEASE_REVIEW');
// DB 확인
const updated = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
expect(updated!.escrowStatus).toBe('RELEASE_REVIEW');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: contractPublicId, actionType: 'ESCROW_RELEASE_REQUESTED' },
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.escrowStatus).toBe('HOLDING');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.escrowStatus).toBe(
'RELEASE_REVIEW',
);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: contractPublicId, eventName: 'escrow.release_requested' },
});
expect(outboxEvents).toHaveLength(1);
});
it('검수 미승인 시 정산 해제 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createActiveContractWithHoldingEscrow(
owner,
operator,
requester,
);
// 검수 레코드 없음 (미승인)
const result = await releaseEscrowService(prisma, contractPublicId, operator.id.toString());
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INSPECTION_NOT_APPROVED');
});
it('열린 분쟁이 있으면 정산 해제 차단', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createActiveContractWithHoldingEscrow(
owner,
operator,
requester,
);
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
// 검수 승인 + 열린 분쟁 생성
await prisma.inspectionRecord.create({
data: {
contractId: contract!.id,
inspectionType: 'FINAL_COMPLETION',
status: 'APPROVED',
reviewedAt: new Date(),
},
});
await prisma.disputeCase.create({
data: {
contractId: contract!.id,
openedByUserId: owner.id,
status: 'OPEN',
reasonCode: 'QUALITY_ISSUE',
},
});
const result = await releaseEscrowService(prisma, contractPublicId, operator.id.toString());
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('DISPUTE_OPEN');
});
});
// ---------------------------------------------------------------------------
// I014: 에스크로 웹훅 멱등 처리
// ---------------------------------------------------------------------------
describe('I014: processEscrowWebhookService', () => {
async function createContractForWebhook(
owner: { id: bigint },
operator: { id: bigint },
requester: { id: bigint },
) {
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const contractResult = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
if (!contractResult.ok) throw new Error('createContract failed');
return contractResult.value.publicId;
}
it('새로운 웹훅 이벤트를 처리하고 에스크로 상태가 변경된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createContractForWebhook(owner, operator, requester);
const result = await processEscrowWebhookService(prisma, {
contractPublicId,
idempotencyKey: 'webhook-evt-001',
transactionType: 'DEPOSIT',
amount: 5000000,
providerCode: 'TOSS',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.isNew).toBe(true);
expect(result.value.escrowStatus).toBe('HOLDING');
// DB 확인
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
expect(contract!.escrowStatus).toBe('HOLDING');
// EscrowTransaction 확인
const transactions = await prisma.escrowTransaction.findMany({
where: { contractId: contract!.id },
});
expect(transactions).toHaveLength(1);
expect(transactions[0]!.idempotencyKey).toBe('webhook-evt-001');
expect(transactions[0]!.transactionType).toBe('DEPOSIT');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: contractPublicId, actionType: 'ESCROW_TRANSACTION_PROCESSED' },
});
expect(auditLogs).toHaveLength(1);
});
it('동일 idempotencyKey로 중복 요청 시 isNew=false 반환 (멱등)', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createContractForWebhook(owner, operator, requester);
// 1차 처리
await processEscrowWebhookService(prisma, {
contractPublicId,
idempotencyKey: 'webhook-evt-dup',
transactionType: 'DEPOSIT',
amount: 5000000,
});
// 2차 처리 (중복)
const result = await processEscrowWebhookService(prisma, {
contractPublicId,
idempotencyKey: 'webhook-evt-dup',
transactionType: 'DEPOSIT',
amount: 5000000,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.isNew).toBe(false);
// EscrowTransaction은 1건만 존재
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
const transactions = await prisma.escrowTransaction.findMany({
where: { contractId: contract!.id },
});
expect(transactions).toHaveLength(1);
});
it('빈 idempotencyKey는 실패', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createContractForWebhook(owner, operator, requester);
const result = await processEscrowWebhookService(prisma, {
contractPublicId,
idempotencyKey: '',
transactionType: 'DEPOSIT',
amount: 5000000,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('VALIDATION_ERROR');
});
});
// ---------------------------------------------------------------------------
// I015: 분쟁 접수
// ---------------------------------------------------------------------------
describe('I015: openDisputeService', () => {
async function createActiveContractWithHolding(
owner: { id: bigint },
operator: { id: bigint },
requester: { id: bigint },
) {
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const contractResult = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
if (!contractResult.ok) throw new Error('createContract failed');
const contract = await prisma.contract.findUnique({
where: { publicId: contractResult.value.publicId },
});
await prisma.contract.update({
where: { id: contract!.id },
data: { status: 'ACTIVE', escrowStatus: 'HOLDING' },
});
return contractResult.value.publicId;
}
it('ACTIVE 계약 + HOLDING 에스크로에서 분쟁 접수 성공', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createActiveContractWithHolding(owner, operator, requester);
const result = await openDisputeService(
prisma,
contractPublicId,
'QUALITY_ISSUE',
owner.id.toString(),
'시공 품질이 계약 내용과 다릅니다.',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.escrowStatus).toBe('DISPUTED');
// DB 확인
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
expect(contract!.escrowStatus).toBe('DISPUTED');
// DisputeCase 확인
const disputes = await prisma.disputeCase.findMany({
where: { contractId: contract!.id },
});
expect(disputes).toHaveLength(1);
expect(disputes[0]!.reasonCode).toBe('QUALITY_ISSUE');
expect(disputes[0]!.description).toBe('시공 품질이 계약 내용과 다릅니다.');
expect(disputes[0]!.status).toBe('OPEN');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: contractPublicId, actionType: 'DISPUTE_OPENED' },
});
expect(auditLogs).toHaveLength(1);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: contractPublicId, eventName: 'dispute.opened' },
});
expect(outboxEvents).toHaveLength(1);
});
it('DRAFT 계약에서는 분쟁 접수 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const contractResult = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
if (!contractResult.ok) throw new Error('createContract failed');
// DRAFT 상태 + NOT_STARTED 에스크로 (기본 상태 유지)
const result = await openDisputeService(
prisma,
contractResult.value.publicId,
'QUALITY_ISSUE',
owner.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INVALID_CONTRACT_STATUS');
});
it('존재하지 않는 계약에는 분쟁 접수 불가', async () => {
const owner = await createTestUser();
const result = await openDisputeService(
prisma,
'non-existent-contract',
'QUALITY_ISSUE',
owner.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
});
@@ -0,0 +1,419 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
createStoreDraftService,
submitStoreService,
reviewStoreService,
publishStoreService,
} from '../../services/store-service';
import {
createMatchRequestService,
acceptMatchRequestService,
searchStoresService,
} from '../../services/match-request-service';
describe('Match Request Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createTestUser(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'owner@example.com',
emailNormalized: 'owner@example.com',
name: '매장 소유자',
primaryRole: 'CLOSING_OWNER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createOperator(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createPublishedStore(ownerUserId: bigint, operatorUserId: bigint) {
const createResult = await createStoreDraftService(prisma, {
ownerUserId: ownerUserId.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString());
await reviewStoreService(prisma, createResult.value.publicId, 'APPROVED', operatorUserId.toString());
const policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-test-hash',
effectiveFrom: new Date(),
},
});
await publishStoreService(
prisma,
createResult.value.publicId,
policyVersion.id.toString(),
operatorUserId.toString(),
);
return createResult.value.publicId;
}
// ---------------------------------------------------------------------------
// I006: 매칭 요청 생성 통합 테스트
// ---------------------------------------------------------------------------
describe('I006: createMatchRequestService', () => {
it('공개 매장에 매칭 요청을 생성하고 AuditLog/OutboxEvent가 기록된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업 희망자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const result = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
message: '매장 인수 희망합니다.',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('OPEN');
expect(result.value.matchType).toBe('ACQUISITION');
expect(result.value.publicId).toBeTruthy();
// DB에 실제 저장 확인
const matchRequest = await prisma.matchRequest.findUnique({
where: { publicId: result.value.publicId },
});
expect(matchRequest).not.toBeNull();
expect(matchRequest!.message).toBe('매장 인수 희망합니다.');
expect(matchRequest!.requesterUserId).toBe(requester.id);
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: result.value.publicId, actionType: 'MATCH_REQUEST_CREATED' },
});
expect(auditLogs).toHaveLength(1);
expect(auditLogs[0]!.actorUserId).toBe(requester.id);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.publicId },
});
expect(outboxEvents).toHaveLength(1);
expect(outboxEvents[0]!.eventName).toBe('match_request.created');
});
it('비공개 매장에는 매칭 요청을 생성할 수 없다', async () => {
const owner = await createTestUser();
const requester = await createTestUser({
email: 'founder2@example.com',
emailNormalized: 'founder2@example.com',
name: '창업자2',
primaryRole: 'FOUNDER',
});
// DRAFT 상태 매장 (비공개)
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '비공개 매장',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
const result = await createMatchRequestService(prisma, {
storePublicId: createResult.value.publicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('STORE_NOT_PUBLISHED');
});
it('동일 사용자가 동일 매장에 열린 매칭 요청이 있으면 중복 생성 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder3@example.com',
emailNormalized: 'founder3@example.com',
name: '창업자3',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
// 1차 매칭 요청 성공
const first = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(first.ok).toBe(true);
// 2차 매칭 요청 실패 (중복)
const second = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.error.code).toBe('DUPLICATE_OPEN_REQUEST');
});
it('존재하지 않는 매장에는 매칭 요청 불가', async () => {
const requester = await createTestUser();
const result = await createMatchRequestService(prisma, {
storePublicId: 'non-existent-store',
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
it('운영자 추천 시 추천 사유 없으면 실패', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
const result = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'OPERATOR_RECOMMENDATION',
requesterUserId: operator.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('VALIDATION_ERROR');
});
});
// ---------------------------------------------------------------------------
// I007: 매칭 요청 수락 + 매장 검색 통합 테스트
// ---------------------------------------------------------------------------
describe('I007: acceptMatchRequestService', () => {
it('OPEN 상태의 매칭 요청을 ACCEPTED로 전환하고 감사 로그가 기록된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder4@example.com',
emailNormalized: 'founder4@example.com',
name: '창업자4',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const createResult = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
message: '인수 희망',
});
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
const acceptResult = await acceptMatchRequestService(
prisma,
createResult.value.publicId,
operator.id.toString(),
);
expect(acceptResult.ok).toBe(true);
if (!acceptResult.ok) return;
expect(acceptResult.value.status).toBe('ACCEPTED');
// DB 확인
const matchRequest = await prisma.matchRequest.findUnique({
where: { publicId: createResult.value.publicId },
});
expect(matchRequest!.status).toBe('ACCEPTED');
expect(matchRequest!.acceptedAt).not.toBeNull();
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: createResult.value.publicId, actionType: 'MATCH_REQUEST_ACCEPTED' },
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.status).toBe('OPEN');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.status).toBe('ACCEPTED');
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: createResult.value.publicId, eventName: 'match_request.accepted' },
});
expect(outboxEvents).toHaveLength(1);
});
it('이미 ACCEPTED된 매칭 요청은 다시 수락 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder5@example.com',
emailNormalized: 'founder5@example.com',
name: '창업자5',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const createResult = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'DEMOLITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
// 1차 수락
await acceptMatchRequestService(prisma, createResult.value.publicId, operator.id.toString());
// 2차 수락 시도
const secondAccept = await acceptMatchRequestService(
prisma,
createResult.value.publicId,
operator.id.toString(),
);
expect(secondAccept.ok).toBe(false);
if (secondAccept.ok) return;
expect(secondAccept.error.code).toBe('INVALID_STATUS_TRANSITION');
});
it('존재하지 않는 매칭 요청은 NOT_FOUND를 반환한다', async () => {
const operator = await createOperator();
const result = await acceptMatchRequestService(
prisma,
'non-existent-match',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
describe('I007: searchStoresService', () => {
it('공개 매장만 검색 결과에 포함된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
// 공개 매장 1개 생성
await createPublishedStore(owner.id, operator.id);
// 비공개 매장 1개 생성 (DRAFT)
await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '비공개 매장',
industryLeafCode: 'FNB.KOREAN',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 456',
});
const result = await searchStoresService(prisma, {});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.stores).toHaveLength(1);
expect(result.value.stores[0]!.listingTitle).toBe('강남역 카페 양도');
});
it('페이지네이션과 limit이 올바르게 적용된다', async () => {
const result = await searchStoresService(prisma, { page: 1, limit: 10 });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.page).toBe(1);
expect(result.value.limit).toBe(10);
});
it('limit이 100을 초과하면 100으로 제한된다', async () => {
const result = await searchStoresService(prisma, { limit: 500 });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.limit).toBe(100);
});
it('지역 코드로 필터링할 수 있다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
await createPublishedStore(owner.id, operator.id);
const result = await searchStoresService(prisma, {
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.stores).toHaveLength(1);
// 존재하지 않는 지역 코드
const emptyResult = await searchStoresService(prisma, {
regionClusterCode: 'KR.BUSAN',
});
expect(emptyResult.ok).toBe(true);
if (!emptyResult.ok) return;
expect(emptyResult.value.stores).toHaveLength(0);
});
});
});
@@ -0,0 +1,559 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
createStoreDraftService,
submitStoreService,
reviewStoreService,
publishStoreService,
getStoreForViewer,
} from '../../services/store-service';
describe('Store Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createTestUser() {
return prisma.user.create({
data: {
email: 'test@example.com',
emailNormalized: 'test@example.com',
name: '테스트 사용자',
primaryRole: 'CLOSING_OWNER',
status: 'ACTIVE',
},
});
}
// ---------------------------------------------------------------------------
// I001: POST /api/v1/stores - 매장 초안 생성
// ---------------------------------------------------------------------------
describe('I001: createStoreDraftService', () => {
it('매장 초안을 생성하고 AuditLog/OutboxEvent가 함께 기록된다', async () => {
const user = await createTestUser();
const result = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.reviewStatus).toBe('DRAFT');
expect(result.value.publicationStatus).toBe('PRIVATE');
expect(result.value.dealStatus).toBe('OPEN');
expect(result.value.publicId).toBeTruthy();
// DB에 실제 저장되었는지 확인
const store = await prisma.store.findUnique({
where: { publicId: result.value.publicId },
include: { industryLeaf: true, regionCluster: true },
});
expect(store).not.toBeNull();
expect(store!.listingTitle).toBe('강남역 카페 양도');
expect(store!.industryLeaf!.code).toBe('FNB.CAFE');
expect(store!.regionCluster!.code).toBe('KR.BETA.GANGNAM_CORE');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: result.value.publicId },
});
expect(auditLogs).toHaveLength(1);
expect(auditLogs[0]!.actionType).toBe('STORE_DRAFT_CREATED');
expect(auditLogs[0]!.actorUserId).toBe(user.id);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.publicId },
});
expect(outboxEvents).toHaveLength(1);
expect(outboxEvents[0]!.eventName).toBe('store.draft.created');
});
it('lease와 facility 정보를 포함한 매장을 생성한다', async () => {
const user = await createTestUser();
const result = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남 한식당',
industryLeafCode: 'FNB.KOREAN',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 역삼동 123',
lease: {
depositAmount: 50_000_000,
monthlyRentAmount: 3_000_000,
premiumAmount: 100_000_000,
},
facility: {
exclusiveAreaSqm: 66.12,
seatCount: 30,
floorLevel: 1,
hasGas: true,
},
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const store = await prisma.store.findUnique({
where: { publicId: result.value.publicId },
include: { lease: true, facility: true },
});
expect(store!.lease).not.toBeNull();
expect(Number(store!.lease!.depositAmount)).toBe(50_000_000);
expect(Number(store!.lease!.monthlyRentAmount)).toBe(3_000_000);
expect(Number(store!.lease!.premiumAmount)).toBe(100_000_000);
expect(store!.facility).not.toBeNull();
expect(Number(store!.facility!.exclusiveAreaSqm)).toBeCloseTo(66.12);
expect(store!.facility!.seatCount).toBe(30);
expect(store!.facility!.floorLevel).toBe(1);
expect(store!.facility!.hasGas).toBe(true);
});
it('베타 미지원 지역은 거부한다', async () => {
const user = await createTestUser();
const result = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '부산 카페',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BUSAN',
roadAddress: '부산시 해운대구 123',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('REGION_NOT_BETA_ENABLED');
});
it('미지원 업종 코드는 거부한다', async () => {
const user = await createTestUser();
const result = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남 뷰티샵',
industryLeafCode: 'BEAUTY.NAIL',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 123',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INDUSTRY_NOT_SUPPORTED');
});
});
// ---------------------------------------------------------------------------
// I002: POST /api/v1/stores/:id/submit - 매장 제출
// ---------------------------------------------------------------------------
describe('I002: submitStoreService', () => {
it('DRAFT 상태의 매장을 SUBMITTED로 전환한다', async () => {
const user = await createTestUser();
const createResult = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남역 카페',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
const submitResult = await submitStoreService(
prisma,
createResult.value.publicId,
user.id.toString(),
);
expect(submitResult.ok).toBe(true);
if (!submitResult.ok) return;
expect(submitResult.value.reviewStatus).toBe('SUBMITTED');
// AuditLog에 상태 전환 기록 확인
const auditLogs = await prisma.auditLog.findMany({
where: {
resourceId: createResult.value.publicId,
actionType: 'STORE_SUBMITTED',
},
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.reviewStatus).toBe('DRAFT');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.reviewStatus).toBe('SUBMITTED');
});
it('DRAFT가 아닌 매장은 제출을 거부한다', async () => {
const user = await createTestUser();
const createResult = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남역 카페',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
// 1차 제출
await submitStoreService(prisma, createResult.value.publicId, user.id.toString());
// 2차 제출 시도
const secondSubmit = await submitStoreService(
prisma,
createResult.value.publicId,
user.id.toString(),
);
expect(secondSubmit.ok).toBe(false);
if (secondSubmit.ok) return;
expect(secondSubmit.error.code).toBe('INVALID_STATUS_TRANSITION');
});
it('존재하지 않는 매장은 NOT_FOUND를 반환한다', async () => {
const result = await submitStoreService(prisma, 'non-existent-id', '1');
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
// ---------------------------------------------------------------------------
// I003: GET /api/v1/master-data - 마스터 데이터 조회
// ---------------------------------------------------------------------------
describe('I003: master-data 쿼리', () => {
it('베타 활성 지역과 리프 업종만 반환한다', async () => {
const [regions, industries] = await Promise.all([
prisma.regionHierarchy.findMany({
where: { isActive: true, isBetaEnabled: true },
select: {
code: true,
nameKo: true,
regionType: true,
parentId: true,
depth: true,
isBetaEnabled: true,
},
orderBy: [{ depth: 'asc' }, { sortOrder: 'asc' }],
}),
prisma.industryTaxonomy.findMany({
where: { isActive: true, isBetaEnabled: true, isLeaf: true },
select: {
code: true,
nameKo: true,
depth: true,
isLeaf: true,
isBetaEnabled: true,
},
orderBy: { sortOrder: 'asc' },
}),
]);
// seedTestMasterData 기준: 베타 클러스터 1개
expect(regions.length).toBeGreaterThanOrEqual(1);
const betaCluster = regions.find((r) => r.code === 'KR.BETA.GANGNAM_CORE');
expect(betaCluster).toBeDefined();
expect(betaCluster!.nameKo).toBe('강남권 베타 클러스터');
// seedTestMasterData 기준: 리프 업종 2개 (카페, 한식)
expect(industries.length).toBeGreaterThanOrEqual(2);
expect(industries.find((i) => i.code === 'FNB.CAFE')).toBeDefined();
expect(industries.find((i) => i.code === 'FNB.KOREAN')).toBeDefined();
// 부모 업종(FNB)은 isLeaf=false이므로 제외
expect(industries.find((i) => i.code === 'FNB')).toBeUndefined();
// 비베타 지역(KR.BUSAN)은 제외
expect(regions.find((r) => r.code === 'KR.BUSAN')).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// I004: 운영자 검토 API - 승인/반려 + 감사 로그
// ---------------------------------------------------------------------------
describe('I004: reviewStoreService / publishStoreService', () => {
async function createAndSubmitStore(userId: bigint) {
const createResult = await createStoreDraftService(prisma, {
ownerUserId: userId.toString(),
listingTitle: '강남역 카페',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
detailAddress: '4층 401호',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, userId.toString());
return createResult.value.publicId;
}
it('운영자가 매장을 승인하면 APPROVED로 전환되고 감사 로그가 기록된다', async () => {
const user = await createTestUser();
const operator = await prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
},
});
const storePublicId = await createAndSubmitStore(user.id);
const result = await reviewStoreService(
prisma,
storePublicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.reviewStatus).toBe('APPROVED');
// 감사 로그 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: storePublicId, actionType: 'STORE_APPROVED' },
});
expect(auditLogs).toHaveLength(1);
expect(auditLogs[0]!.actorUserId).toBe(operator.id);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.reviewStatus).toBe('SUBMITTED');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.reviewStatus).toBe('APPROVED');
});
it('운영자가 매장을 반려하면 REJECTED로 전환되고 사유가 기록된다', async () => {
const user = await createTestUser();
const operator = await prisma.user.create({
data: {
email: 'ops2@example.com',
emailNormalized: 'ops2@example.com',
name: '운영자2',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
},
});
const storePublicId = await createAndSubmitStore(user.id);
const result = await reviewStoreService(
prisma,
storePublicId,
'REJECTED',
operator.id.toString(),
'INCOMPLETE_INFO',
'시설 정보가 부족합니다.',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.reviewStatus).toBe('REJECTED');
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: storePublicId, actionType: 'STORE_REJECTED' },
});
expect(auditLogs).toHaveLength(1);
const afterJson = auditLogs[0]!.afterJson as Record<string, unknown>;
expect(afterJson.reasonCode).toBe('INCOMPLETE_INFO');
expect(afterJson.memo).toBe('시설 정보가 부족합니다.');
});
it('승인된 매장을 정책 버전과 함께 공개하면 PUBLISHED로 전환된다', async () => {
const user = await createTestUser();
const operator = await prisma.user.create({
data: {
email: 'ops3@example.com',
emailNormalized: 'ops3@example.com',
name: '운영자3',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
},
});
const storePublicId = await createAndSubmitStore(user.id);
await reviewStoreService(prisma, storePublicId, 'APPROVED', operator.id.toString());
// 정책 버전 생성
const policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-test-hash',
effectiveFrom: new Date(),
},
});
const publishResult = await publishStoreService(
prisma,
storePublicId,
policyVersion.id.toString(),
operator.id.toString(),
);
expect(publishResult.ok).toBe(true);
if (!publishResult.ok) return;
expect(publishResult.value.publicationStatus).toBe('PUBLISHED');
// DB에서 정책 버전 연결 확인
const store = await prisma.store.findUnique({ where: { publicId: storePublicId } });
expect(store!.policyVersionId).toBe(policyVersion.id);
expect(store!.publishedAt).not.toBeNull();
// 감사 로그 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: storePublicId, actionType: 'STORE_PUBLISHED' },
});
expect(auditLogs).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// I005: 공개 매장 조회 API - 제한 공개 정책 적용
// ---------------------------------------------------------------------------
describe('I005: getStoreForViewer - 제한 공개 정책', () => {
async function createPublishedStore() {
const owner = await createTestUser();
const operator = await prisma.user.create({
data: {
email: 'ops5@example.com',
emailNormalized: 'ops5@example.com',
name: '운영자5',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
phone: '010-9999-8888',
},
});
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
detailAddress: '4층 401호',
});
if (!createResult.ok) throw new Error('create failed');
await submitStoreService(prisma, createResult.value.publicId, owner.id.toString());
await reviewStoreService(
prisma,
createResult.value.publicId,
'APPROVED',
operator.id.toString(),
);
const policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-test-hash-2',
effectiveFrom: new Date(),
},
});
await publishStoreService(
prisma,
createResult.value.publicId,
policyVersion.id.toString(),
operator.id.toString(),
);
return { owner, operator, storePublicId: createResult.value.publicId };
}
it('소유자는 공개 매장의 상세 주소와 연락처를 볼 수 있다', async () => {
const { owner, storePublicId } = await createPublishedStore();
const result = await getStoreForViewer(prisma, storePublicId, {
userId: owner.publicId,
role: 'CLOSING_OWNER',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.detailAddress).toBe('4층 401호');
expect(result.value.ownerEmail).toBe('test@example.com');
});
it('운영자는 공개 매장의 상세 주소와 연락처를 볼 수 있다', async () => {
const { operator, storePublicId } = await createPublishedStore();
const result = await getStoreForViewer(prisma, storePublicId, {
userId: operator.publicId,
role: 'OPS_MANAGER',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.detailAddress).toBe('4층 401호');
});
it('일반 창업자는 공개 매장의 상세 주소와 연락처를 볼 수 없다', async () => {
const { storePublicId } = await createPublishedStore();
const result = await getStoreForViewer(prisma, storePublicId, {
userId: 'founder-random',
role: 'FOUNDER',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.detailAddress).toBeUndefined();
expect(result.value.ownerPhone).toBeUndefined();
expect(result.value.ownerEmail).toBeUndefined();
// 공개 정보는 그대로
expect(result.value.listingTitle).toBe('강남역 카페 양도');
expect(result.value.roadAddress).toBe('서울시 강남구 테헤란로 123');
});
it('비공개 매장은 일반 사용자에게 FORBIDDEN을 반환한다', async () => {
const owner = await createTestUser();
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '비공개 매장',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 123',
});
if (!createResult.ok) throw new Error('create failed');
const result = await getStoreForViewer(prisma, createResult.value.publicId, {
userId: 'founder-random',
role: 'FOUNDER',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('FORBIDDEN');
});
});
});
@@ -0,0 +1,403 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
createStoreDraftService,
submitStoreService,
reviewStoreService,
publishStoreService,
} from '../../services/store-service';
import {
createSubsidyCaseService,
reviewSubsidyCaseService,
} from '../../services/subsidy-case-service';
describe('Subsidy Case Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createTestUser(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'owner@example.com',
emailNormalized: 'owner@example.com',
name: '매장 소유자',
primaryRole: 'CLOSING_OWNER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createOperator(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createSubsidyPolicy() {
return prisma.policyVersion.create({
data: {
policyType: 'SUBSIDY_CHECKLIST',
versionCode: 'v1.0',
contentHash: 'sha256-subsidy-policy-hash',
effectiveFrom: new Date(),
isActive: true,
},
});
}
async function createPublishedStore(ownerUserId: bigint, operatorUserId: bigint) {
const createResult = await createStoreDraftService(prisma, {
ownerUserId: ownerUserId.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString());
await reviewStoreService(
prisma,
createResult.value.publicId,
'APPROVED',
operatorUserId.toString(),
);
const storePolicyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-store-hash',
effectiveFrom: new Date(),
},
});
await publishStoreService(
prisma,
createResult.value.publicId,
storePolicyVersion.id.toString(),
operatorUserId.toString(),
);
return createResult.value.publicId;
}
// ---------------------------------------------------------------------------
// I008: POST /api/v1/subsidies/cases - 케이스와 초기 체크리스트 생성
// ---------------------------------------------------------------------------
describe('I008: createSubsidyCaseService', () => {
it('공개 매장에 지원금 케이스를 생성하고 체크리스트/AuditLog/OutboxEvent가 기록된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
await createSubsidyPolicy();
const result = await createSubsidyCaseService(prisma, {
storePublicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('DRAFT');
expect(result.value.programCode).toBe('SMALL_BIZ_CLOSURE_2024');
expect(result.value.publicId).toBeTruthy();
// DB에 실제 저장 확인
const subsidyCase = await prisma.subsidyCase.findUnique({
where: { publicId: result.value.publicId },
include: { checklistItems: true },
});
expect(subsidyCase).not.toBeNull();
expect(subsidyCase!.checklistVersionCode).toBe('v1.0');
expect(subsidyCase!.policyVersionId).not.toBeNull();
// 체크리스트 항목 확인
expect(subsidyCase!.checklistItems.length).toBeGreaterThanOrEqual(2);
const requiredItems = subsidyCase!.checklistItems.filter(
(item: { isRequired: boolean }) => item.isRequired,
);
expect(requiredItems.length).toBeGreaterThanOrEqual(2);
expect(
subsidyCase!.checklistItems.every((item: { status: string }) => item.status === 'PENDING'),
).toBe(true);
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: result.value.publicId, actionType: 'SUBSIDY_CASE_CREATED' },
});
expect(auditLogs).toHaveLength(1);
expect(auditLogs[0]!.actorUserId).toBe(owner.id);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.publicId },
});
expect(outboxEvents).toHaveLength(1);
expect(outboxEvents[0]!.eventName).toBe('subsidy_case.created');
});
it('DRAFT 상태(비공개+미검토) 매장에서는 케이스 생성 불가', async () => {
const owner = await createTestUser();
await createSubsidyPolicy();
// DRAFT 매장 생성 (비공개)
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '비공개 매장',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
const result = await createSubsidyCaseService(prisma, {
storePublicId: createResult.value.publicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('STORE_NOT_ELIGIBLE');
});
it('SUBMITTED(검토 중) 매장은 케이스 생성 가능', async () => {
const owner = await createTestUser();
await createSubsidyPolicy();
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '제출된 매장',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 456',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, owner.id.toString());
const result = await createSubsidyCaseService(prisma, {
storePublicId: createResult.value.publicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('DRAFT');
});
it('존재하지 않는 매장에는 케이스 생성 불가', async () => {
const owner = await createTestUser();
await createSubsidyPolicy();
const result = await createSubsidyCaseService(prisma, {
storePublicId: 'non-existent-store',
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
it('활성 정책이 없으면 케이스 생성 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
// 정책 미생성
const result = await createSubsidyCaseService(prisma, {
storePublicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
// ---------------------------------------------------------------------------
// I009: PATCH /api/v1/subsidies/cases/:id/review - 상태 변경과 감사 로그
// ---------------------------------------------------------------------------
describe('I009: reviewSubsidyCaseService', () => {
async function createSubmittedCase() {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
await createSubsidyPolicy();
const createResult = await createSubsidyCaseService(prisma, {
storePublicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
if (!createResult.ok) throw new Error('createSubsidyCase failed');
// 상태를 SUBMITTED로 직접 변경 (검토 가능 상태로 만들기)
await prisma.subsidyCase.update({
where: { publicId: createResult.value.publicId },
data: { status: 'SUBMITTED', submittedAt: new Date() },
});
return { owner, operator, subsidyCasePublicId: createResult.value.publicId };
}
it('SUBMITTED 케이스를 승인하면 APPROVED로 전환되고 감사 로그가 기록된다', async () => {
const { operator, subsidyCasePublicId } = await createSubmittedCase();
const result = await reviewSubsidyCaseService(
prisma,
subsidyCasePublicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('APPROVED');
// DB 확인
const subsidyCase = await prisma.subsidyCase.findUnique({
where: { publicId: subsidyCasePublicId },
});
expect(subsidyCase!.status).toBe('APPROVED');
expect(subsidyCase!.reviewedAt).not.toBeNull();
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: subsidyCasePublicId, actionType: 'SUBSIDY_CASE_APPROVED' },
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.status).toBe('SUBMITTED');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.status).toBe('APPROVED');
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: subsidyCasePublicId, eventName: 'subsidy_case.approved' },
});
expect(outboxEvents).toHaveLength(1);
});
it('SUBMITTED 케이스를 반려하면 REJECTED로 전환되고 사유가 기록된다', async () => {
const { operator, subsidyCasePublicId } = await createSubmittedCase();
const result = await reviewSubsidyCaseService(
prisma,
subsidyCasePublicId,
'REJECTED',
operator.id.toString(),
'INCOMPLETE_DOCUMENTS',
'사업자등록증 만료. 갱신본 제출 필요.',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('REJECTED');
// DB 확인
const subsidyCase = await prisma.subsidyCase.findUnique({
where: { publicId: subsidyCasePublicId },
});
expect(subsidyCase!.rejectionReasonCode).toBe('INCOMPLETE_DOCUMENTS');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: subsidyCasePublicId, actionType: 'SUBSIDY_CASE_REJECTED' },
});
expect(auditLogs).toHaveLength(1);
const afterJson = auditLogs[0]!.afterJson as Record<string, unknown>;
expect(afterJson.rejectionReasonCode).toBe('INCOMPLETE_DOCUMENTS');
expect(afterJson.memo).toBe('사업자등록증 만료. 갱신본 제출 필요.');
});
it('반려 시 사유 코드 없으면 실패', async () => {
const { operator, subsidyCasePublicId } = await createSubmittedCase();
const result = await reviewSubsidyCaseService(
prisma,
subsidyCasePublicId,
'REJECTED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('REJECTION_REASON_REQUIRED');
});
it('DRAFT 상태에서는 검토 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
await createSubsidyPolicy();
const createResult = await createSubsidyCaseService(prisma, {
storePublicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
if (!createResult.ok) throw new Error('createSubsidyCase failed');
const result = await reviewSubsidyCaseService(
prisma,
createResult.value.publicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
});
it('존재하지 않는 케이스는 NOT_FOUND를 반환한다', async () => {
const operator = await createOperator();
const result = await reviewSubsidyCaseService(
prisma,
'non-existent-case',
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
});
@@ -0,0 +1,305 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
applyVendorCertificationService,
reviewVendorCertificationService,
} from '../../services/vendor-certification-service';
describe('Vendor Certification Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createVendorOwner(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'vendor-owner@example.com',
emailNormalized: 'vendor-owner@example.com',
name: '업체 대표',
primaryRole: 'VENDOR_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createOperator(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
// ---------------------------------------------------------------------------
// I010: POST /api/v1/vendors/certifications - 인증 신청과 검토 큐 생성
// ---------------------------------------------------------------------------
describe('I010: applyVendorCertificationService', () => {
it('업체 인증 신청 성공 시 APPLIED 상태로 생성되고 AuditLog/OutboxEvent 기록', async () => {
const owner = await createVendorOwner();
const result = await applyVendorCertificationService(prisma, {
ownerUserId: owner.id.toString(),
vendorType: 'DEMOLITION',
businessName: '(주)클린철거',
contactName: '김담당',
businessRegistrationNumber: '123-45-67890',
coverageRegionCodes: ['KR.BETA.GANGNAM_CORE'],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.certificationStatus).toBe('APPLIED');
expect(result.value.vendorPublicId).toBeTruthy();
// DB에 실제 저장 확인
const vendor = await prisma.vendor.findUnique({
where: { publicId: result.value.vendorPublicId },
include: { coverageRegions: true, certifications: true },
});
expect(vendor).not.toBeNull();
expect(vendor!.businessName).toBe('(주)클린철거');
expect(vendor!.vendorType).toBe('DEMOLITION');
// 서비스 권역 확인
expect(vendor!.coverageRegions).toHaveLength(1);
expect(vendor!.coverageRegions[0]!.isPrimary).toBe(true);
// 인증 이력 확인
expect(vendor!.certifications).toHaveLength(1);
expect(vendor!.certifications[0]!.status).toBe('APPLIED');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: {
resourceId: result.value.vendorPublicId,
actionType: 'VENDOR_CERTIFICATION_APPLIED',
},
});
expect(auditLogs).toHaveLength(1);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.vendorPublicId },
});
expect(outboxEvents).toHaveLength(1);
expect(outboxEvents[0]!.eventName).toBe('vendor.certification.applied');
});
it('사업자등록증 없으면 인증 신청 실패', async () => {
const owner = await createVendorOwner();
const result = await applyVendorCertificationService(prisma, {
ownerUserId: owner.id.toString(),
vendorType: 'INTERIOR',
businessName: '인테리어 스튜디오',
contactName: '이담당',
coverageRegionCodes: ['KR.BETA.GANGNAM_CORE'],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('MISSING_BUSINESS_REGISTRATION');
});
it('서비스 권역이 유효하지 않으면 인증 신청 실패', async () => {
const owner = await createVendorOwner();
const result = await applyVendorCertificationService(prisma, {
ownerUserId: owner.id.toString(),
vendorType: 'DEMOLITION',
businessName: '(주)클린철거',
contactName: '김담당',
businessRegistrationNumber: '123-45-67890',
coverageRegionCodes: ['INVALID_REGION'],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('MISSING_COVERAGE_REGION');
});
});
// ---------------------------------------------------------------------------
// I011: PATCH /api/v1/vendors/certifications/:id/approve - 인증 승인
// ---------------------------------------------------------------------------
describe('I011: reviewVendorCertificationService', () => {
async function createAppliedVendor() {
const owner = await createVendorOwner();
const operator = await createOperator();
const applyResult = await applyVendorCertificationService(prisma, {
ownerUserId: owner.id.toString(),
vendorType: 'DEMOLITION',
businessName: '(주)클린철거',
contactName: '김담당',
businessRegistrationNumber: '123-45-67890',
coverageRegionCodes: ['KR.BETA.GANGNAM_CORE'],
});
if (!applyResult.ok) throw new Error('apply failed');
// 서류 체크리스트 추가 (승인 필수 조건)
const vendor = await prisma.vendor.findUnique({
where: { publicId: applyResult.value.vendorPublicId },
});
const cert = await prisma.vendorCertification.findFirst({
where: { vendorId: vendor!.id },
});
if (cert) {
await prisma.vendorCertification.update({
where: { id: cert.id },
data: { documentChecklistJson: { businessLicense: true, insurance: true } },
});
}
return { owner, operator, vendorPublicId: applyResult.value.vendorPublicId };
}
it('APPLIED 업체를 승인하면 APPROVED로 전환되고 이력+감사 로그 기록', async () => {
const { operator, vendorPublicId } = await createAppliedVendor();
const result = await reviewVendorCertificationService(
prisma,
vendorPublicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.certificationStatus).toBe('APPROVED');
// DB 확인
const vendor = await prisma.vendor.findUnique({
where: { publicId: vendorPublicId },
include: { certifications: { orderBy: { createdAt: 'desc' } } },
});
expect(vendor!.certificationStatus).toBe('APPROVED');
// 인증 이력이 2개 (APPLIED + APPROVED)
expect(vendor!.certifications).toHaveLength(2);
expect(vendor!.certifications[0]!.status).toBe('APPROVED');
expect(vendor!.certifications[0]!.reviewedAt).not.toBeNull();
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: vendorPublicId, actionType: 'VENDOR_CERTIFICATION_APPROVED' },
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.certificationStatus).toBe(
'APPLIED',
);
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.certificationStatus).toBe(
'APPROVED',
);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: vendorPublicId, eventName: 'vendor.certification.approved' },
});
expect(outboxEvents).toHaveLength(1);
});
it('반려 시 사유 코드가 있으면 REJECTED로 전환', async () => {
const { operator, vendorPublicId } = await createAppliedVendor();
const result = await reviewVendorCertificationService(
prisma,
vendorPublicId,
'REJECTED',
operator.id.toString(),
'INCOMPLETE_DOCUMENTS',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.certificationStatus).toBe('REJECTED');
// 인증 이력에 사유 코드 기록
const vendor = await prisma.vendor.findUnique({
where: { publicId: vendorPublicId },
include: { certifications: { orderBy: { createdAt: 'desc' } } },
});
expect(vendor!.certifications[0]!.reasonCode).toBe('INCOMPLETE_DOCUMENTS');
});
it('반려 시 사유 코드 없으면 실패', async () => {
const { operator, vendorPublicId } = await createAppliedVendor();
const result = await reviewVendorCertificationService(
prisma,
vendorPublicId,
'REJECTED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('REASON_REQUIRED');
});
it('존재하지 않는 업체는 NOT_FOUND를 반환', async () => {
const operator = await createOperator();
const result = await reviewVendorCertificationService(
prisma,
'non-existent-vendor',
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
it('이미 APPROVED된 업체는 재검토 불가', async () => {
const { operator, vendorPublicId } = await createAppliedVendor();
// 1차 승인
await reviewVendorCertificationService(
prisma,
vendorPublicId,
'APPROVED',
operator.id.toString(),
);
// 2차 승인 시도
const result = await reviewVendorCertificationService(
prisma,
vendorPublicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
});
});
});
+103
View File
@@ -0,0 +1,103 @@
const SAMPLE_CONTRACTS = [
{ id: 'ac-1', storeTitle: '선릉역 한식당', type: '철거', status: 'ACTIVE', escrow: 'RELEASE_REVIEW', amount: '₩5,000,000', createdAt: '2026-03-02' },
{ id: 'ac-2', storeTitle: '강남역 카페', type: '시설인수', status: 'SIGNED', escrow: 'HOLDING', amount: '₩50,000,000', createdAt: '2026-03-05' },
{ id: 'ac-3', storeTitle: '홍대 디저트카페', type: '인테리어', status: 'ACTIVE', escrow: 'DISPUTED', amount: '₩8,000,000', createdAt: '2026-02-28' },
{ id: 'ac-4', storeTitle: '합정 베이커리', type: '철거', status: 'COMPLETED', escrow: 'RELEASED', amount: '₩3,500,000', createdAt: '2026-02-15' },
];
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-700' },
SIGNED: { label: '서명 완료', color: 'bg-indigo-100 text-indigo-700' },
ACTIVE: { label: '진행 중', color: 'bg-green-100 text-green-700' },
COMPLETED: { label: '완료', color: 'bg-emerald-100 text-emerald-700' },
};
const ESCROW_MAP: Record<string, { label: string; color: string }> = {
HOLDING: { label: '보관 중', color: 'bg-blue-100 text-blue-700' },
RELEASE_REVIEW: { label: '정산 검토', color: 'bg-purple-100 text-purple-700' },
RELEASED: { label: '정산 완료', color: 'bg-green-100 text-green-700' },
DISPUTED: { label: '분쟁 중', color: 'bg-red-100 text-red-700' },
};
export default function AdminContractsPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900">/ </h1>
<p className="mt-1 text-sm text-gray-500">
, ,
</p>
{/* 요약 카드 */}
<div className="mt-6 grid grid-cols-4 gap-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-gray-900">2</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-purple-600">1</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-red-600">1</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-gray-900">63M</p>
</div>
</div>
{/* 계약 테이블 */}
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"> </th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_CONTRACTS.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
const escrowInfo = ESCROW_MAP[c.escrow] ?? { label: c.escrow, color: 'bg-gray-100 text-gray-700' };
return (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{c.storeTitle}</td>
<td className="px-4 py-3 text-gray-600">{c.type}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${escrowInfo.color}`}>
{escrowInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-600">{c.amount}</td>
<td className="px-4 py-3 text-gray-400">{c.createdAt}</td>
<td className="px-4 py-3">
{c.escrow === 'RELEASE_REVIEW' && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"> </button>
<button className="text-sm text-orange-600 hover:underline"></button>
</div>
)}
{c.escrow === 'DISPUTED' && (
<button className="text-sm text-blue-600 hover:underline"> </button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</main>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from 'next/link';
const ADMIN_NAV = [
{ href: '/admin', label: '대시보드' },
{ href: '/admin/stores', label: '매장 검토' },
{ href: '/admin/vendors', label: '업체 인증' },
{ href: '/admin/subsidies', label: '지원금 검토' },
{ href: '/admin/contracts', label: '계약/정산' },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-[calc(100vh-4rem)]">
<aside className="w-56 border-r border-gray-200 bg-white">
<div className="p-4">
<h2 className="text-sm font-semibold text-gray-500"> </h2>
</div>
<nav className="space-y-1 px-2">
{ADMIN_NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className="block rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
{item.label}
</Link>
))}
</nav>
</aside>
<div className="flex-1">{children}</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import Link from 'next/link';
export default function AdminDashboardPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">Re:Link </p>
{/* KPI 요약 */}
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
<KPICard label="등록 매장" value="47" change="+3 이번 주" />
<KPICard label="매칭 요청" value="23" change="+5 이번 주" />
<KPICard label="활성 계약" value="12" change="+2 이번 주" />
<KPICard label="에스크로 보관 중" value="₩15.2M" change="" />
</div>
{/* 검토 큐 */}
<div className="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900"> </h2>
<Link href="/admin/stores" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="mt-4 space-y-3">
<QueueItem title="강남역 치킨집" type="SUBMITTED" time="2시간 전" />
<QueueItem title="선릉역 중식당" type="SUBMITTED" time="5시간 전" />
<QueueItem title="논현동 베이커리" type="SUBMITTED" time="1일 전" />
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900"> </h2>
<Link href="/admin/vendors" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="mt-4 space-y-3">
<QueueItem title="(주)클린철거" type="APPLIED" time="3시간 전" />
<QueueItem title="모던인테리어" type="APPLIED" time="1일 전" />
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900"> </h2>
<Link href="/admin/subsidies" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="mt-4 space-y-3">
<QueueItem title="강남역 카페 - 소상공인 지원금" type="SUBMITTED" time="4시간 전" />
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900"> </h2>
<Link href="/admin/contracts" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="mt-4 space-y-3">
<QueueItem title="선릉역 한식당 - 철거 계약" type="RELEASE_REVIEW" time="6시간 전" />
</div>
</div>
</div>
{/* 최근 활동 */}
<div className="mt-8 rounded-lg border border-gray-200 bg-white p-5">
<h2 className="font-semibold text-gray-900"> </h2>
<div className="mt-4 space-y-2">
{[
{ action: '매장 승인', target: '강남역 카페 양도', actor: '김운영', time: '10분 전' },
{ action: '업체 인증 승인', target: '(주)클린철거', actor: '이운영', time: '1시간 전' },
{ action: '에스크로 입금 확인', target: '선릉역 한식당 계약', actor: 'SYSTEM', time: '2시간 전' },
{ action: '매칭 요청 수락', target: '홍대 디저트카페', actor: '박운영', time: '3시간 전' },
].map((log, i) => (
<div key={i} className="flex items-center justify-between border-b border-gray-50 py-2 last:border-0">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-900">{log.action}</span>
<span className="text-sm text-gray-500">{log.target}</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>{log.actor}</span>
<span>{log.time}</span>
</div>
</div>
))}
</div>
</div>
</main>
);
}
function KPICard({ label, value, change }: { label: string; value: string; change: string }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500">{label}</p>
<p className="mt-1 text-2xl font-bold text-gray-900">{value}</p>
{change && <p className="mt-1 text-xs text-green-600">{change}</p>}
</div>
);
}
function QueueItem({ title, type, time }: { title: string; type: string; time: string }) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-yellow-400" />
<span className="text-sm text-gray-900">{title}</span>
<span className="text-xs text-gray-400">{type}</span>
</div>
<span className="text-xs text-gray-400">{time}</span>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
const SAMPLE_STORES = [
{ id: 's-1', title: '강남역 치킨집', region: '강남구', industry: '한식', status: 'SUBMITTED', owner: '김폐업', submittedAt: '2026-03-07 09:30' },
{ id: 's-2', title: '선릉역 중식당', region: '강남구', industry: '중식', status: 'SUBMITTED', owner: '이폐업', submittedAt: '2026-03-07 06:15' },
{ id: 's-3', title: '논현동 베이커리', region: '강남구', industry: '카페', status: 'SUBMITTED', owner: '박폐업', submittedAt: '2026-03-06 18:00' },
{ id: 's-4', title: '홍대 파스타집', region: '마포구', industry: '양식', status: 'APPROVED', owner: '최폐업', submittedAt: '2026-03-05 14:00' },
{ id: 's-5', title: '합정 디저트카페', region: '마포구', industry: '카페', status: 'REJECTED', owner: '정폐업', submittedAt: '2026-03-04 10:00' },
];
const STATUS_MAP: Record<string, { label: string; color: string }> = {
SUBMITTED: { label: '검토 대기', color: 'bg-yellow-100 text-yellow-700' },
APPROVED: { label: '승인', color: 'bg-green-100 text-green-700' },
REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' },
PUBLISHED: { label: '공개', color: 'bg-blue-100 text-blue-700' },
};
export default function AdminStoresPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
{/* 필터 */}
<div className="mt-6 flex gap-2">
{['전체', '검토 대기', '승인', '반려'].map((f) => (
<button
key={f}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100"
>
{f}
</button>
))}
</div>
{/* 매장 목록 */}
<div className="mt-4 overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_STORES.map((store) => {
const statusInfo = STATUS_MAP[store.status] ?? { label: store.status, color: 'bg-gray-100 text-gray-700' };
return (
<tr key={store.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{store.title}</td>
<td className="px-4 py-3 text-gray-600">{store.region}</td>
<td className="px-4 py-3 text-gray-600">{store.industry}</td>
<td className="px-4 py-3 text-gray-600">{store.owner}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-400">{store.submittedAt}</td>
<td className="px-4 py-3">
{store.status === 'SUBMITTED' && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"></button>
<button className="text-sm text-red-600 hover:underline"></button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</main>
);
}
+75
View File
@@ -0,0 +1,75 @@
const SAMPLE_CASES = [
{ id: 'asc-1', storeTitle: '강남역 카페', owner: '김폐업', status: 'SUBMITTED', checklist: '5/5', createdAt: '2026-03-06' },
{ id: 'asc-2', storeTitle: '선릉역 한식당', owner: '이폐업', status: 'REVIEWING', checklist: '5/5', createdAt: '2026-03-04' },
{ id: 'asc-3', storeTitle: '합정 베이커리', owner: '박폐업', status: 'APPROVED', checklist: '4/4', createdAt: '2026-03-01' },
{ id: 'asc-4', storeTitle: '논현동 분식점', owner: '최폐업', status: 'REJECTED', checklist: '3/5', createdAt: '2026-02-28' },
];
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DOCUMENTS_PENDING: { label: '서류 준비 중', color: 'bg-gray-100 text-gray-700' },
SUBMITTED: { label: '검토 대기', color: 'bg-yellow-100 text-yellow-700' },
REVIEWING: { label: '검토 중', color: 'bg-blue-100 text-blue-700' },
APPROVED: { label: '승인', color: 'bg-green-100 text-green-700' },
REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' },
};
export default function AdminSubsidiesPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
<div className="mt-6 flex gap-2">
{['전체', '검토 대기', '검토 중', '승인', '반려'].map((f) => (
<button
key={f}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100"
>
{f}
</button>
))}
</div>
<div className="mt-4 overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_CASES.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
return (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{c.storeTitle}</td>
<td className="px-4 py-3 text-gray-600">{c.owner}</td>
<td className="px-4 py-3 text-gray-600">{c.checklist}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-400">{c.createdAt}</td>
<td className="px-4 py-3">
{(c.status === 'SUBMITTED' || c.status === 'REVIEWING') && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"></button>
<button className="text-sm text-red-600 hover:underline"></button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</main>
);
}
+84
View File
@@ -0,0 +1,84 @@
const SAMPLE_VENDORS = [
{ id: 'v-1', name: '(주)클린철거', type: 'DEMOLITION', region: '강남권', status: 'APPLIED', contactName: '김담당', appliedAt: '2026-03-07 08:00' },
{ id: 'v-2', name: '모던인테리어', type: 'INTERIOR', region: '마포권', status: 'APPLIED', contactName: '이담당', appliedAt: '2026-03-06 14:30' },
{ id: 'v-3', name: '서울철거공사', type: 'DEMOLITION', region: '강남권, 마포권', status: 'APPROVED', contactName: '박담당', appliedAt: '2026-03-01 10:00' },
{ id: 'v-4', name: '그린인테리어', type: 'INTERIOR', region: '강남권', status: 'SUSPENDED', contactName: '최담당', appliedAt: '2026-02-25 09:00' },
];
const TYPE_LABELS: Record<string, string> = { DEMOLITION: '철거', INTERIOR: '인테리어', ACQUISITION: '시설인수' };
const STATUS_MAP: Record<string, { label: string; color: string }> = {
APPLIED: { label: '심사 대기', color: 'bg-yellow-100 text-yellow-700' },
REVIEWING: { label: '심사 중', color: 'bg-blue-100 text-blue-700' },
APPROVED: { label: '인증됨', color: 'bg-green-100 text-green-700' },
SUSPENDED: { label: '중지', color: 'bg-red-100 text-red-700' },
REJECTED: { label: '반려', color: 'bg-gray-100 text-gray-700' },
};
export default function AdminVendorsPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> ·· </p>
<div className="mt-6 flex gap-2">
{['전체', '심사 대기', '인증됨', '중지'].map((f) => (
<button
key={f}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100"
>
{f}
</button>
))}
</div>
<div className="mt-4 overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"> </th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_VENDORS.map((vendor) => {
const statusInfo = STATUS_MAP[vendor.status] ?? { label: vendor.status, color: 'bg-gray-100 text-gray-700' };
return (
<tr key={vendor.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{vendor.name}</td>
<td className="px-4 py-3 text-gray-600">{TYPE_LABELS[vendor.type] ?? vendor.type}</td>
<td className="px-4 py-3 text-gray-600">{vendor.region}</td>
<td className="px-4 py-3 text-gray-600">{vendor.contactName}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-400">{vendor.appliedAt}</td>
<td className="px-4 py-3">
{vendor.status === 'APPLIED' && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"></button>
<button className="text-sm text-red-600 hover:underline"></button>
</div>
)}
{vendor.status === 'APPROVED' && (
<button className="text-sm text-orange-600 hover:underline"></button>
)}
{vendor.status === 'SUSPENDED' && (
<button className="text-sm text-green-600 hover:underline"></button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</main>
);
}
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { createPrismaClient } from '@relink/database';
const prisma = createPrismaClient();
export async function GET() {
const [regions, industries] = await Promise.all([
prisma.regionHierarchy.findMany({
where: { isActive: true, isBetaEnabled: true },
select: {
code: true,
nameKo: true,
regionType: true,
parentId: true,
depth: true,
isBetaEnabled: true,
},
orderBy: [{ depth: 'asc' }, { sortOrder: 'asc' }],
}),
prisma.industryTaxonomy.findMany({
where: { isActive: true, isBetaEnabled: true, isLeaf: true },
select: {
code: true,
nameKo: true,
depth: true,
isLeaf: true,
isBetaEnabled: true,
},
orderBy: { sortOrder: 'asc' },
}),
]);
return NextResponse.json({
ok: true,
data: { regions, industries },
});
}
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createPrismaClient } from '@relink/database';
import { submitStoreService } from '@/services/store-service';
const prisma = createPrismaClient();
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const body = await request.json().catch(() => ({}));
const actorUserId = (body as Record<string, string>).actorUserId;
if (!actorUserId) {
return NextResponse.json(
{ ok: false, error: { code: 'VALIDATION_ERROR', message: 'actorUserId는 필수입니다.' } },
{ status: 400 },
);
}
const result = await submitStoreService(prisma, id, actorUserId);
if (!result.ok) {
const status = result.error.code === 'NOT_FOUND' ? 404 : 422;
return NextResponse.json({ ok: false, error: result.error }, { status });
}
return NextResponse.json({ ok: true, data: result.value });
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createPrismaClient } from '@relink/database';
import { createStoreDraftService } from '@/services/store-service';
import { z } from 'zod';
const prisma = createPrismaClient();
const createStoreSchema = z.object({
ownerUserId: z.string().min(1),
listingTitle: z.string().min(1),
industryLeafCode: z.string().min(1),
regionClusterCode: z.string().min(1),
roadAddress: z.string().min(1),
detailAddress: z.string().optional(),
lease: z
.object({
depositAmount: z.number().min(0),
monthlyRentAmount: z.number().min(0),
premiumAmount: z.number().min(0),
maintenanceFeeAmount: z.number().min(0).optional(),
remainingLeaseMonths: z.number().int().min(0).optional(),
transferable: z.boolean().optional(),
})
.optional(),
facility: z
.object({
exclusiveAreaSqm: z.number().positive(),
seatCount: z.number().int().min(0),
floorLevel: z.number().int().optional(),
hasGas: z.boolean().optional(),
hasDrainage: z.boolean().optional(),
hasDuct: z.boolean().optional(),
electricCapacityKw: z.number().min(0).optional(),
kitchenEquipmentSummary: z.string().optional(),
parkingCount: z.number().int().min(0).optional(),
})
.optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = createStoreSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: { code: 'VALIDATION_ERROR', message: parsed.error.message } },
{ status: 400 },
);
}
const result = await createStoreDraftService(prisma, parsed.data);
if (!result.ok) {
const status = result.error.code === 'VALIDATION_ERROR' ? 400 : 422;
return NextResponse.json({ ok: false, error: result.error }, { status });
}
return NextResponse.json({ ok: true, data: result.value }, { status: 201 });
}
+130
View File
@@ -0,0 +1,130 @@
import Link from 'next/link';
const SAMPLE_CONTRACTS = [
{
id: 'ct-1',
storeTitle: '강남역 카페 양도',
contractType: 'ACQUISITION',
status: 'DRAFT',
escrowStatus: 'NOT_STARTED',
createdAt: '2026-03-06',
},
{
id: 'ct-2',
storeTitle: '선릉역 한식당',
contractType: 'DEMOLITION',
status: 'ACTIVE',
escrowStatus: 'HOLDING',
createdAt: '2026-03-02',
},
{
id: 'ct-3',
storeTitle: '합정 베이커리',
contractType: 'INTERIOR',
status: 'COMPLETED',
escrowStatus: 'RELEASED',
createdAt: '2026-02-20',
},
];
const CONTRACT_TYPE_LABELS: Record<string, string> = {
ACQUISITION: '시설인수',
DEMOLITION: '철거',
INTERIOR: '인테리어',
};
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-700' },
GENERATED: { label: '생성됨', color: 'bg-blue-100 text-blue-700' },
SIGNING: { label: '서명 중', color: 'bg-yellow-100 text-yellow-700' },
SIGNED: { label: '서명 완료', color: 'bg-indigo-100 text-indigo-700' },
ACTIVE: { label: '진행 중', color: 'bg-green-100 text-green-700' },
COMPLETED: { label: '완료', color: 'bg-emerald-100 text-emerald-700' },
CANCELLED: { label: '취소', color: 'bg-red-100 text-red-700' },
};
const ESCROW_MAP: Record<string, { label: string; color: string }> = {
NOT_STARTED: { label: '미시작', color: 'text-gray-500' },
DEPOSIT_PENDING: { label: '입금 대기', color: 'text-yellow-600' },
HOLDING: { label: '보관 중', color: 'text-blue-600' },
RELEASE_REVIEW: { label: '정산 검토', color: 'text-purple-600' },
RELEASED: { label: '정산 완료', color: 'text-green-600' },
REFUNDED: { label: '환불됨', color: 'text-orange-600' },
DISPUTED: { label: '분쟁 중', color: 'text-red-600' },
};
export default function ContractsPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">, , </p>
{/* 요약 */}
<div className="mt-6 grid grid-cols-4 gap-4">
<SummaryCard label="전체 계약" value="3" />
<SummaryCard label="진행 중" value="1" />
<SummaryCard label="에스크로 보관" value="1" />
<SummaryCard label="완료" value="1" />
</div>
{/* 계약 목록 */}
<div className="mt-6 space-y-4">
{SAMPLE_CONTRACTS.map((contract) => {
const statusInfo = STATUS_MAP[contract.status] ?? { label: contract.status, color: 'bg-gray-100 text-gray-700' };
const escrowInfo = ESCROW_MAP[contract.escrowStatus] ?? { label: contract.escrowStatus, color: 'text-gray-500' };
return (
<div key={contract.id} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{contract.storeTitle}</h3>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{CONTRACT_TYPE_LABELS[contract.contractType] ?? contract.contractType}
</span>
</div>
<p className="mt-1 text-xs text-gray-400">: {contract.createdAt}</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
<div className="mt-3 flex items-center gap-6 text-sm">
<div>
<span className="text-gray-500">: </span>
<span className={`font-medium ${escrowInfo.color}`}>{escrowInfo.label}</span>
</div>
</div>
{contract.status === 'ACTIVE' && (
<div className="mt-4 flex gap-3 border-t border-gray-100 pt-3">
<button className="text-sm text-blue-600 hover:underline"> </button>
<button className="text-sm text-red-600 hover:underline"> </button>
</div>
)}
</div>
);
})}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/matching" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
</main>
);
}
function SummaryCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="mt-1 text-xs text-gray-500">{label}</p>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css';
export const metadata: Metadata = {
title: 'Re:Link',
description: 'Re:Link - 폐업 · 양도 · 창업을 잇는 중개 플랫폼',
};
function Navigation() {
return (
<nav className="border-b border-gray-200 bg-white">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<Link href="/" className="text-xl font-bold text-blue-600">
Re:Link
</Link>
<div className="flex items-center gap-6">
<Link href="/stores" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/stores/new" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/matching" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/subsidies" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/vendors" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/contracts" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link
href="/admin"
className="rounded-md bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
>
</Link>
</div>
</div>
</nav>
);
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body className="min-h-screen bg-gray-50">
<Navigation />
{children}
</body>
</html>
);
}
+95
View File
@@ -0,0 +1,95 @@
import Link from 'next/link';
const SAMPLE_REQUESTS = [
{
id: 'mr-1',
storeTitle: '강남역 카페 양도',
matchType: 'ACQUISITION',
status: 'OPEN',
createdAt: '2026-03-05',
message: '매장 인수 희망합니다.',
},
{
id: 'mr-2',
storeTitle: '선릉역 한식당 양도',
matchType: 'DEMOLITION',
status: 'ACCEPTED',
createdAt: '2026-03-03',
message: '철거 견적 요청드립니다.',
},
{
id: 'mr-3',
storeTitle: '홍대입구 디저트카페',
matchType: 'INTERIOR',
status: 'OPEN',
createdAt: '2026-03-06',
message: '인테리어 리모델링 상담 원합니다.',
},
];
const MATCH_TYPE_LABELS: Record<string, string> = {
ACQUISITION: '인수',
DEMOLITION: '철거',
INTERIOR: '인테리어',
};
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
OPEN: { label: '대기 중', color: 'bg-blue-100 text-blue-700' },
REVIEWING: { label: '검토 중', color: 'bg-yellow-100 text-yellow-700' },
ACCEPTED: { label: '수락됨', color: 'bg-green-100 text-green-700' },
REJECTED: { label: '거절됨', color: 'bg-red-100 text-red-700' },
};
export default function MatchingPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
<div className="mt-6 space-y-4">
{SAMPLE_REQUESTS.map((req) => {
const statusInfo = STATUS_LABELS[req.status] ?? { label: req.status, color: 'bg-gray-100 text-gray-700' };
return (
<div key={req.id} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{req.storeTitle}</h3>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{MATCH_TYPE_LABELS[req.matchType] ?? req.matchType}
</span>
</div>
<p className="mt-1 text-sm text-gray-600">{req.message}</p>
<p className="mt-2 text-xs text-gray-400">: {req.createdAt}</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{req.status === 'ACCEPTED' && (
<div className="mt-4 border-t border-gray-100 pt-3">
<Link
href={`/contracts?matchId=${req.id}`}
className="text-sm text-blue-600 hover:underline"
>
</Link>
</div>
)}
</div>
);
})}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/stores" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
</main>
);
}
+86
View File
@@ -0,0 +1,86 @@
import Link from 'next/link';
export default function HomePage() {
return (
<main className="mx-auto max-w-7xl px-4 py-16">
<div className="text-center">
<h1 className="text-5xl font-bold text-gray-900">Re:Link</h1>
<p className="mt-4 text-xl text-gray-600"> · · </p>
<p className="mt-2 text-gray-500">
1 ··
</p>
<div className="mt-8 flex justify-center gap-4">
<Link
href="/stores"
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
>
</Link>
<Link
href="/stores/new"
className="rounded-lg border border-gray-300 px-6 py-3 text-gray-700 hover:bg-gray-100"
>
</Link>
</div>
</div>
<div className="mt-20 grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="rounded-xl border border-gray-200 bg-white p-6">
<div className="mb-3 text-2xl">🏪</div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
<p className="mt-2 text-sm text-gray-600">
, , .
</p>
<Link href="/stores/new" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6">
<div className="mb-3 text-2xl">🔍</div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
<p className="mt-2 text-sm text-gray-600">
.
</p>
<Link href="/stores" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6">
<div className="mb-3 text-2xl">🏗</div>
<h3 className="text-lg font-semibold text-gray-900">· </h3>
<p className="mt-2 text-sm text-gray-600">
.
</p>
<Link href="/vendors" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
</div>
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900"> </h3>
<p className="mt-2 text-sm text-gray-600">
, .
</p>
<Link href="/subsidies" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900"> </h3>
<p className="mt-2 text-sm text-gray-600">
, , .
</p>
<Link href="/contracts" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
</div>
</main>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from 'next/link';
export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6">
<Link href="/stores" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-8">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> ID: {id}</p>
</div>
<span className="rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-700">
</span>
</div>
{/* 기본 정보 */}
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-gray-900"> </h2>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="지역" value="강남구 역삼동" />
<InfoItem label="업종" value="카페" />
<InfoItem label="도로명 주소" value="서울시 강남구 테헤란로 123" />
<InfoItem label="등록일" value="2026-03-07" />
</div>
</section>
{/* 임대 정보 */}
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-gray-900"> </h2>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="보증금" value="5,000만원" />
<InfoItem label="월세" value="300만원" />
<InfoItem label="권리금" value="3,000만원" />
<InfoItem label="임대 잔여 기간" value="18개월" />
</div>
</section>
{/* 시설 정보 */}
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-gray-900"> </h2>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="면적" value="25평" />
<InfoItem label="층수" value="1층" />
</div>
<div className="mt-3">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-sm text-gray-900">
, , . 2024 . 30.
</p>
</div>
</section>
{/* 액션 버튼 */}
<div className="mt-10 flex gap-3 border-t border-gray-200 pt-6">
<Link
href={`/matching?storeId=${id}`}
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</Link>
<Link
href={`/subsidies?storeId=${id}`}
className="rounded-lg border border-gray-300 px-6 py-2.5 text-sm text-gray-700 hover:bg-gray-50"
>
</Link>
</div>
</div>
</main>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-sm text-gray-500">{label}</p>
<p className="mt-0.5 text-sm font-medium text-gray-900">{value}</p>
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
import Link from 'next/link';
export default function NewStorePage() {
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-6">
<Link href="/stores" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">
, ,
</p>
<form className="mt-8 space-y-8">
{/* 기본 정보 */}
<section>
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="예: 강남역 카페 양도"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value=""> </option>
<option value="KR.BETA.GANGNAM_CORE"> (//)</option>
<option value="KR.BETA.MAPO_CORE"> (//)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value=""> </option>
<option value="FNB.CAFE"></option>
<option value="FNB.KOREAN"></option>
<option value="FNB.WESTERN"></option>
<option value="FNB.JAPANESE"></option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="예: 서울시 강남구 테헤란로 123"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</section>
{/* 임대 정보 */}
<section>
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
placeholder="50000000"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
placeholder="3000000"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
placeholder="30000000"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> </label>
<input
type="text"
placeholder="예: 18개월"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
</section>
{/* 시설 정보 */}
<section>
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
placeholder="25"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"></label>
<input
type="text"
placeholder="예: 1층"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> </label>
<textarea
rows={3}
placeholder="주방 시설, 인테리어 상태, 포함 장비 등을 자유롭게 작성해주세요"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</section>
{/* 제출 */}
<div className="flex gap-3">
<button
type="submit"
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</button>
<Link
href="/stores"
className="rounded-lg border border-gray-300 px-6 py-2.5 text-sm text-gray-700 hover:bg-gray-50"
>
</Link>
</div>
</form>
</main>
);
}
+134
View File
@@ -0,0 +1,134 @@
import Link from 'next/link';
const REGION_OPTIONS = [
{ value: '', label: '전체 지역' },
{ value: 'KR.BETA.GANGNAM_CORE', label: '강남권 (역삼/선릉/논현)' },
{ value: 'KR.BETA.MAPO_CORE', label: '마포권 (홍대/합정/연남)' },
];
const INDUSTRY_OPTIONS = [
{ value: '', label: '전체 업종' },
{ value: 'FNB.CAFE', label: '카페' },
{ value: 'FNB.KOREAN', label: '한식' },
{ value: 'FNB.WESTERN', label: '양식' },
{ value: 'FNB.JAPANESE', label: '일식' },
];
const STATUS_OPTIONS = [
{ value: '', label: '전체 상태' },
{ value: 'OPEN', label: '거래 가능' },
{ value: 'MATCHING', label: '매칭 중' },
{ value: 'CONTRACTED', label: '계약 진행 중' },
];
export default function StoresPage() {
return (
<main className="mx-auto max-w-7xl px-4 py-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
</div>
<Link
href="/stores/new"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
</Link>
</div>
{/* 필터 */}
<div className="mt-6 flex flex-wrap gap-3 rounded-lg border border-gray-200 bg-white p-4">
<select className="rounded-md border border-gray-300 px-3 py-2 text-sm">
{REGION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select className="rounded-md border border-gray-300 px-3 py-2 text-sm">
{INDUSTRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select className="rounded-md border border-gray-300 px-3 py-2 text-sm">
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button className="rounded-md bg-gray-100 px-4 py-2 text-sm text-gray-700 hover:bg-gray-200">
</button>
</div>
{/* 매장 목록 (샘플) */}
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[
{
id: 'sample-1',
title: '강남역 카페 양도',
region: '강남구 역삼동',
industry: '카페',
status: 'OPEN',
deposit: '5,000만원',
monthlyRent: '300만원',
},
{
id: 'sample-2',
title: '선릉역 한식당 양도',
region: '강남구 선릉동',
industry: '한식',
status: 'MATCHING',
deposit: '8,000만원',
monthlyRent: '450만원',
},
{
id: 'sample-3',
title: '홍대입구 디저트카페',
region: '마포구 서교동',
industry: '카페',
status: 'OPEN',
deposit: '3,000만원',
monthlyRent: '250만원',
},
].map((store) => (
<Link
key={store.id}
href={`/stores/${store.id}`}
className="block rounded-lg border border-gray-200 bg-white p-5 transition hover:border-blue-300 hover:shadow-md"
>
<div className="flex items-start justify-between">
<h3 className="font-semibold text-gray-900">{store.title}</h3>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
store.status === 'OPEN'
? 'bg-green-100 text-green-700'
: store.status === 'MATCHING'
? 'bg-yellow-100 text-yellow-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{store.status === 'OPEN' ? '거래 가능' : store.status === 'MATCHING' ? '매칭 중' : store.status}
</span>
</div>
<p className="mt-1 text-sm text-gray-500">
{store.region} · {store.industry}
</p>
<div className="mt-3 flex gap-4 text-sm text-gray-600">
<span> {store.deposit}</span>
<span> {store.monthlyRent}</span>
</div>
</Link>
))}
</div>
<div className="mt-8 text-center text-sm text-gray-400">
서비스: 강남권· F&B
</div>
</main>
);
}
+100
View File
@@ -0,0 +1,100 @@
import Link from 'next/link';
const SAMPLE_CASES = [
{
id: 'sc-1',
storeTitle: '강남역 카페',
status: 'DOCUMENTS_PENDING',
checklist: { total: 5, checked: 2 },
createdAt: '2026-03-04',
},
{
id: 'sc-2',
storeTitle: '선릉역 한식당',
status: 'SUBMITTED',
checklist: { total: 5, checked: 5 },
createdAt: '2026-03-01',
},
{
id: 'sc-3',
storeTitle: '합정 베이커리',
status: 'APPROVED',
checklist: { total: 4, checked: 4 },
createdAt: '2026-02-28',
},
];
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DOCUMENTS_PENDING: { label: '서류 준비 중', color: 'bg-yellow-100 text-yellow-700' },
READY_TO_SUBMIT: { label: '제출 준비 완료', color: 'bg-blue-100 text-blue-700' },
SUBMITTED: { label: '제출됨', color: 'bg-purple-100 text-purple-700' },
REVIEWING: { label: '검토 중', color: 'bg-orange-100 text-orange-700' },
APPROVED: { label: '승인', color: 'bg-green-100 text-green-700' },
REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' },
};
export default function SubsidiesPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">
</p>
{/* 안내 배너 */}
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 className="text-sm font-semibold text-blue-800"> </h3>
<p className="mt-1 text-sm text-blue-700">
Re:Link은 , · · ·
.
</p>
</div>
{/* 케이스 목록 */}
<div className="mt-6 space-y-4">
{SAMPLE_CASES.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
return (
<div key={c.id} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-gray-900">{c.storeTitle}</h3>
<p className="mt-1 text-xs text-gray-400">: {c.createdAt}</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{/* 체크리스트 진행률 */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600"> </span>
<span className="text-gray-900">
{c.checklist.checked}/{c.checklist.total}
</span>
</div>
<div className="mt-1 h-2 rounded-full bg-gray-200">
<div
className="h-2 rounded-full bg-blue-500"
style={{ width: `${(c.checklist.checked / c.checklist.total) * 100}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/stores" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
</main>
);
}
+112
View File
@@ -0,0 +1,112 @@
export default function VendorsPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">
</p>
{/* 인증 혜택 */}
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-2 text-xl"></div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="mt-1 text-sm text-gray-600"> </p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-2 text-xl">📋</div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="mt-1 text-sm text-gray-600"> </p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-2 text-xl">💰</div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="mt-1 text-sm text-gray-600"> </p>
</div>
</div>
{/* 인증 신청 폼 */}
<div className="mt-8">
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<form className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="예: (주)클린철거"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value=""></option>
<option value="DEMOLITION"></option>
<option value="INTERIOR"></option>
<option value="ACQUISITION"></option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="담당자 이름"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="123-45-67890"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<div className="mt-2 flex flex-wrap gap-2">
{['강남권 (역삼/선릉/논현)', '마포권 (홍대/합정/연남)'].map((region) => (
<label key={region} className="flex items-center gap-1.5 text-sm text-gray-700">
<input type="checkbox" className="rounded border-gray-300" />
{region}
</label>
))}
</div>
</div>
<div className="pt-2">
<button
type="submit"
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</button>
</div>
</form>
</div>
{/* 인증 현황 */}
<div className="mt-8">
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">()</h3>
<p className="mt-0.5 text-sm text-gray-500"> · </p>
</div>
<span className="rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-700">
</span>
</div>
<p className="mt-2 text-xs text-gray-400">신청일: 2026-03-05</p>
</div>
</div>
</main>
);
}
+408
View File
@@ -0,0 +1,408 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import {
createContract,
releaseEscrow,
checkIdempotency,
openDispute,
type ContractType,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
// ---------------------------------------------------------------------------
// I012: 계약 생성 서비스
// ---------------------------------------------------------------------------
export interface CreateContractServiceInput {
readonly matchRequestPublicId: string;
readonly contractType: ContractType;
readonly templateCode: string;
readonly policyVersionId: string;
readonly createdByUserId: string;
}
export interface ContractResult {
readonly publicId: string;
readonly status: string;
readonly escrowStatus: string;
}
export async function createContractService(
prisma: PrismaClient,
input: CreateContractServiceInput,
): Promise<Result<ContractResult, AppError>> {
const matchRequest = await prisma.matchRequest.findUnique({
where: { publicId: input.matchRequestPublicId },
include: { store: true },
});
if (!matchRequest) {
return failure(
appError('NOT_FOUND', '매칭 요청을 찾을 수 없습니다.', {
matchRequestPublicId: input.matchRequestPublicId,
}),
);
}
const policyVersion = await prisma.policyVersion.findFirst({
where: { id: BigInt(input.policyVersionId), isActive: true },
});
if (!policyVersion) {
return failure(
appError('NOT_FOUND', '정책 버전을 찾을 수 없습니다.', {
policyVersionId: input.policyVersionId,
}),
);
}
const domainResult = createContract({
matchRequestStatus: matchRequest.status,
contractType: input.contractType,
templateCode: input.templateCode,
policyVersionId: input.policyVersionId,
});
if (!domainResult.ok) {
return domainResult;
}
const contract = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.contract.create({
data: {
matchRequestId: matchRequest.id,
storeId: matchRequest.storeId,
contractType: input.contractType,
status: 'DRAFT',
escrowStatus: 'NOT_STARTED',
templateCode: input.templateCode,
policyVersionId: policyVersion.id,
createdByUserId: BigInt(input.createdByUserId),
storeOwnerUserId: matchRequest.store.ownerUserId,
},
});
await createAuditLog(tx, {
resourceType: 'CONTRACT',
resourceId: created.publicId,
actionType: 'CONTRACT_CREATED',
actorUserId: BigInt(input.createdByUserId),
actorRole: 'OPS_MANAGER',
afterJson: {
contractType: input.contractType,
templateCode: input.templateCode,
matchRequestPublicId: input.matchRequestPublicId,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'CONTRACT',
aggregateId: created.publicId,
eventName: 'contract.created',
payloadJson: {
contractPublicId: created.publicId,
contractType: input.contractType,
matchRequestPublicId: input.matchRequestPublicId,
},
});
return created;
});
return success({
publicId: contract.publicId,
status: contract.status,
escrowStatus: contract.escrowStatus,
});
}
// ---------------------------------------------------------------------------
// I013: 에스크로 정산 해제 서비스
// ---------------------------------------------------------------------------
export async function releaseEscrowService(
prisma: PrismaClient,
contractPublicId: string,
actorUserId: string,
): Promise<Result<ContractResult, AppError>> {
const contract = await prisma.contract.findUnique({
where: { publicId: contractPublicId },
include: {
inspections: { where: { status: 'APPROVED' }, take: 1 },
disputes: { where: { status: { in: ['OPEN', 'INVESTIGATING', 'MEDIATING'] } }, take: 1 },
},
});
if (!contract) {
return failure(appError('NOT_FOUND', '계약을 찾을 수 없습니다.', { contractPublicId }));
}
const domainResult = releaseEscrow({
currentEscrowStatus: contract.escrowStatus,
hasApprovedInspection: contract.inspections.length > 0,
hasOpenDispute: contract.disputes.length > 0,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.contract.update({
where: { id: contract.id },
data: { escrowStatus: 'RELEASE_REVIEW' },
});
await createAuditLog(tx, {
resourceType: 'CONTRACT',
resourceId: contract.publicId,
actionType: 'ESCROW_RELEASE_REQUESTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { escrowStatus: contract.escrowStatus },
afterJson: { escrowStatus: 'RELEASE_REVIEW' },
});
await enqueueOutboxEvent(tx, {
aggregateType: 'CONTRACT',
aggregateId: contract.publicId,
eventName: 'escrow.release_requested',
payloadJson: {
contractPublicId: contract.publicId,
previousEscrowStatus: contract.escrowStatus,
},
});
return result;
});
return success({
publicId: updated.publicId,
status: updated.status,
escrowStatus: updated.escrowStatus,
});
}
// ---------------------------------------------------------------------------
// I014: 에스크로 웹훅 처리 (멱등성 보장)
// ---------------------------------------------------------------------------
export interface ProcessEscrowWebhookInput {
readonly contractPublicId: string;
readonly idempotencyKey: string;
readonly transactionType: 'DEPOSIT' | 'RELEASE' | 'REFUND' | 'ADJUSTMENT' | 'HOLD';
readonly amount: number;
readonly providerCode?: string;
readonly providerTransactionId?: string;
}
export interface EscrowWebhookResult {
readonly isNew: boolean;
readonly contractPublicId: string;
readonly escrowStatus: string;
}
export async function processEscrowWebhookService(
prisma: PrismaClient,
input: ProcessEscrowWebhookInput,
): Promise<Result<EscrowWebhookResult, AppError>> {
const existing = await prisma.escrowTransaction.findUnique({
where: { idempotencyKey: input.idempotencyKey },
});
const idempotencyResult = checkIdempotency({
idempotencyKey: input.idempotencyKey,
alreadyProcessed: existing !== null,
});
if (!idempotencyResult.ok) {
return idempotencyResult;
}
if (!idempotencyResult.value.isNew) {
const contract = await prisma.contract.findUnique({
where: { publicId: input.contractPublicId },
});
return success({
isNew: false,
contractPublicId: input.contractPublicId,
escrowStatus: contract?.escrowStatus ?? 'NOT_STARTED',
});
}
const contract = await prisma.contract.findUnique({
where: { publicId: input.contractPublicId },
});
if (!contract) {
return failure(
appError('NOT_FOUND', '계약을 찾을 수 없습니다.', {
contractPublicId: input.contractPublicId,
}),
);
}
const newEscrowStatus = resolveEscrowStatus(input.transactionType, contract.escrowStatus);
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.escrowTransaction.create({
data: {
contractId: contract.id,
transactionType: input.transactionType,
status: 'COMPLETED',
amount: input.amount,
idempotencyKey: input.idempotencyKey,
providerCode: input.providerCode ?? null,
providerTransactionId: input.providerTransactionId ?? null,
occurredAt: new Date(),
},
});
const result = await tx.contract.update({
where: { id: contract.id },
data: { escrowStatus: newEscrowStatus },
});
await createAuditLog(tx, {
resourceType: 'CONTRACT',
resourceId: contract.publicId,
actionType: 'ESCROW_TRANSACTION_PROCESSED',
actorUserId: contract.createdByUserId,
actorRole: 'SYSTEM',
beforeJson: { escrowStatus: contract.escrowStatus },
afterJson: {
escrowStatus: newEscrowStatus,
transactionType: input.transactionType,
idempotencyKey: input.idempotencyKey,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'CONTRACT',
aggregateId: contract.publicId,
eventName: 'escrow.transaction.processed',
payloadJson: {
contractPublicId: contract.publicId,
transactionType: input.transactionType,
idempotencyKey: input.idempotencyKey,
amount: input.amount,
},
});
return result;
});
return success({
isNew: true,
contractPublicId: updated.publicId,
escrowStatus: updated.escrowStatus,
});
}
type EscrowStatusValue =
| 'NOT_STARTED'
| 'DEPOSIT_PENDING'
| 'HOLDING'
| 'RELEASE_REVIEW'
| 'RELEASED'
| 'REFUNDED'
| 'DISPUTED';
function resolveEscrowStatus(
transactionType: string,
currentStatus: EscrowStatusValue,
): EscrowStatusValue {
switch (transactionType) {
case 'DEPOSIT':
return 'HOLDING';
case 'RELEASE':
return 'RELEASED';
case 'REFUND':
return 'REFUNDED';
case 'HOLD':
return 'HOLDING';
default:
return currentStatus;
}
}
// ---------------------------------------------------------------------------
// I015: 분쟁 접수 서비스
// ---------------------------------------------------------------------------
export async function openDisputeService(
prisma: PrismaClient,
contractPublicId: string,
reasonCode: string,
openedByUserId: string,
description?: string,
): Promise<Result<ContractResult, AppError>> {
const contract = await prisma.contract.findUnique({
where: { publicId: contractPublicId },
});
if (!contract) {
return failure(appError('NOT_FOUND', '계약을 찾을 수 없습니다.', { contractPublicId }));
}
const domainResult = openDispute({
currentEscrowStatus: contract.escrowStatus,
contractStatus: contract.status,
reasonCode,
description,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.contract.update({
where: { id: contract.id },
data: { escrowStatus: 'DISPUTED' },
});
await tx.disputeCase.create({
data: {
contractId: contract.id,
openedByUserId: BigInt(openedByUserId),
status: 'OPEN',
reasonCode,
description: description ?? null,
},
});
await createAuditLog(tx, {
resourceType: 'CONTRACT',
resourceId: contract.publicId,
actionType: 'DISPUTE_OPENED',
actorUserId: BigInt(openedByUserId),
actorRole: 'CLOSING_OWNER',
beforeJson: { escrowStatus: contract.escrowStatus },
afterJson: {
escrowStatus: 'DISPUTED',
reasonCode,
description,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'CONTRACT',
aggregateId: contract.publicId,
eventName: 'dispute.opened',
payloadJson: {
contractPublicId: contract.publicId,
reasonCode,
previousEscrowStatus: contract.escrowStatus,
},
});
return result;
});
return success({
publicId: updated.publicId,
status: updated.status,
escrowStatus: updated.escrowStatus,
});
}
@@ -0,0 +1,223 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import {
createMatchRequest,
acceptMatchRequest,
buildSearchDefaults,
type MatchType,
type MatchSourceType,
type StoreSearchCriteria,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface CreateMatchRequestServiceInput {
readonly storePublicId: string;
readonly matchType: MatchType;
readonly sourceType: MatchSourceType;
readonly requesterUserId: string;
readonly message?: string;
readonly operatorRecommendationReason?: string;
}
export interface MatchRequestResult {
readonly publicId: string;
readonly status: string;
readonly matchType: string;
}
export async function createMatchRequestService(
prisma: PrismaClient,
input: CreateMatchRequestServiceInput,
): Promise<Result<MatchRequestResult, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: input.storePublicId },
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId: input.storePublicId }));
}
const openRequest = await prisma.matchRequest.findFirst({
where: {
storeId: store.id,
requesterUserId: BigInt(input.requesterUserId),
status: 'OPEN',
},
});
const domainResult = createMatchRequest({
storePublicationStatus: store.publicationStatus,
storeDealStatus: store.dealStatus,
matchType: input.matchType,
sourceType: input.sourceType,
requesterUserId: input.requesterUserId,
message: input.message,
hasOpenRequestBySameUser: openRequest !== null,
operatorRecommendationReason: input.operatorRecommendationReason,
});
if (!domainResult.ok) {
return domainResult;
}
const draft = domainResult.value;
const matchRequest = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.matchRequest.create({
data: {
storeId: store.id,
matchType: draft.matchType,
sourceType: draft.sourceType,
status: 'OPEN',
requesterUserId: BigInt(input.requesterUserId),
message: draft.message ?? null,
},
});
await createAuditLog(tx, {
resourceType: 'MATCH_REQUEST',
resourceId: created.publicId,
actionType: 'MATCH_REQUEST_CREATED',
actorUserId: BigInt(input.requesterUserId),
actorRole: 'CLOSING_OWNER',
afterJson: {
storePublicId: input.storePublicId,
matchType: draft.matchType,
sourceType: draft.sourceType,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'MATCH_REQUEST',
aggregateId: created.publicId,
eventName: 'match_request.created',
payloadJson: {
matchRequestPublicId: created.publicId,
storePublicId: input.storePublicId,
matchType: draft.matchType,
},
});
return created;
});
return success({
publicId: matchRequest.publicId,
status: matchRequest.status,
matchType: matchRequest.matchType,
});
}
export async function acceptMatchRequestService(
prisma: PrismaClient,
matchRequestPublicId: string,
actorUserId: string,
): Promise<Result<MatchRequestResult, AppError>> {
const matchRequest = await prisma.matchRequest.findUnique({
where: { publicId: matchRequestPublicId },
});
if (!matchRequest) {
return failure(appError('NOT_FOUND', '매칭 요청을 찾을 수 없습니다.', { matchRequestPublicId }));
}
const domainResult = acceptMatchRequest({
currentStatus: matchRequest.status,
actorUserId,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.matchRequest.update({
where: { id: matchRequest.id },
data: {
status: 'ACCEPTED',
acceptedAt: new Date(),
},
});
await createAuditLog(tx, {
resourceType: 'MATCH_REQUEST',
resourceId: matchRequest.publicId,
actionType: 'MATCH_REQUEST_ACCEPTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { status: matchRequest.status },
afterJson: { status: 'ACCEPTED' },
});
await enqueueOutboxEvent(tx, {
aggregateType: 'MATCH_REQUEST',
aggregateId: matchRequest.publicId,
eventName: 'match_request.accepted',
payloadJson: {
matchRequestPublicId: matchRequest.publicId,
previousStatus: matchRequest.status,
},
});
return result;
});
return success({
publicId: updated.publicId,
status: updated.status,
matchType: updated.matchType,
});
}
export async function searchStoresService(
prisma: PrismaClient,
criteria: StoreSearchCriteria,
): Promise<Result<{ stores: Array<{ publicId: string; listingTitle: string; dealStatus: string }>; total: number; page: number; limit: number }, AppError>> {
const defaults = buildSearchDefaults(criteria);
const where: Record<string, unknown> = {
publicationStatus: defaults.publicationStatus,
};
if (defaults.regionClusterCode) {
where['regionCluster'] = { code: defaults.regionClusterCode };
}
if (defaults.industryLeafCode) {
where['industryLeaf'] = { code: defaults.industryLeafCode };
}
if (defaults.dealStatus) {
where['dealStatus'] = defaults.dealStatus;
}
if (defaults.minDepositAmount !== undefined || defaults.maxDepositAmount !== undefined) {
const leaseFilter: Record<string, unknown> = {};
if (defaults.minDepositAmount !== undefined) {
leaseFilter['gte'] = defaults.minDepositAmount;
}
if (defaults.maxDepositAmount !== undefined) {
leaseFilter['lte'] = defaults.maxDepositAmount;
}
where['lease'] = { depositAmount: leaseFilter };
}
const [stores, total] = await Promise.all([
prisma.store.findMany({
where,
select: {
publicId: true,
listingTitle: true,
dealStatus: true,
},
skip: (defaults.page - 1) * defaults.limit,
take: defaults.limit,
orderBy: { createdAt: 'desc' },
}),
prisma.store.count({ where }),
]);
return success({
stores,
total,
page: defaults.page,
limit: defaults.limit,
});
}
+407
View File
@@ -0,0 +1,407 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import {
createStoreDraft,
reviewStore,
publishStore,
filterStoreForViewer,
type CreateStoreDraftInput,
type RegionChecker,
type IndustryChecker,
type ReviewDecision,
type ReviewableStatus,
type StoreData,
type ViewerContext,
type FilteredStoreData,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
function buildRegionChecker(regions: { code: string; isBetaEnabled: boolean }[]): RegionChecker {
const betaCodes = new Set(regions.filter((r) => r.isBetaEnabled).map((r) => r.code));
return { isBetaEnabled: (code: string) => betaCodes.has(code) };
}
function buildIndustryChecker(
industries: { code: string; isLeaf: boolean; isBetaEnabled: boolean }[],
): IndustryChecker {
const map = new Map(industries.map((i) => [i.code, i]));
return {
isSupported: (code) => {
const ind = map.get(code);
if (!ind) return { exists: false, isLeaf: false, isBetaEnabled: false };
return { exists: true, isLeaf: ind.isLeaf, isBetaEnabled: ind.isBetaEnabled };
},
};
}
export interface CreateStoreResult {
readonly publicId: string;
readonly reviewStatus: string;
readonly publicationStatus: string;
readonly dealStatus: string;
}
export async function createStoreDraftService(
prisma: PrismaClient,
input: CreateStoreDraftInput,
): Promise<Result<CreateStoreResult, AppError>> {
const [regions, industries] = await Promise.all([
prisma.regionHierarchy.findMany({
where: { isActive: true },
select: { id: true, code: true, isBetaEnabled: true },
}),
prisma.industryTaxonomy.findMany({
where: { isActive: true },
select: { id: true, code: true, isLeaf: true, isBetaEnabled: true },
}),
]);
const regionChecker = buildRegionChecker(regions);
const industryChecker = buildIndustryChecker(industries);
const draftResult = createStoreDraft(input, regionChecker, industryChecker);
if (!draftResult.ok) {
return draftResult;
}
const draft = draftResult.value;
const regionCluster = regions.find(
(r: { id: bigint; code: string }) => r.code === input.regionClusterCode,
);
const industryLeaf = industries.find(
(i: { id: bigint; code: string }) => i.code === input.industryLeafCode,
);
const store = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.store.create({
data: {
ownerUserId: BigInt(input.ownerUserId),
listingTitle: draft.listingTitle,
industryLeafId: industryLeaf?.id ?? null,
regionClusterId: regionCluster?.id ?? null,
roadAddress: draft.roadAddress,
detailAddress: draft.detailAddress ?? null,
reviewStatus: 'DRAFT',
publicationStatus: 'PRIVATE',
dealStatus: 'OPEN',
},
});
if (draft.lease) {
await tx.storeLease.create({
data: {
storeId: created.id,
depositAmount: draft.lease.depositAmount,
monthlyRentAmount: draft.lease.monthlyRentAmount,
premiumAmount: draft.lease.premiumAmount,
maintenanceFeeAmount: draft.lease.maintenanceFeeAmount ?? null,
remainingLeaseMonths: draft.lease.remainingLeaseMonths ?? null,
leaseExpiresAt: draft.lease.leaseExpiresAt ?? null,
transferable: draft.lease.transferable ?? null,
},
});
}
if (draft.facility) {
await tx.storeFacility.create({
data: {
storeId: created.id,
exclusiveAreaSqm: draft.facility.exclusiveAreaSqm,
floorLevel: draft.facility.floorLevel ?? null,
seatCount: draft.facility.seatCount,
hasGas: draft.facility.hasGas ?? null,
hasDrainage: draft.facility.hasDrainage ?? null,
hasDuct: draft.facility.hasDuct ?? null,
electricCapacityKw: draft.facility.electricCapacityKw ?? null,
kitchenEquipmentSummary: draft.facility.kitchenEquipmentSummary ?? null,
parkingCount: draft.facility.parkingCount ?? null,
},
});
}
await createAuditLog(tx, {
resourceType: 'STORE',
resourceId: created.publicId,
actionType: 'STORE_DRAFT_CREATED',
actorUserId: BigInt(input.ownerUserId),
actorRole: 'CLOSING_OWNER',
afterJson: {
listingTitle: draft.listingTitle,
regionClusterCode: input.regionClusterCode,
industryLeafCode: input.industryLeafCode,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'STORE',
aggregateId: created.publicId,
eventName: 'store.draft.created',
payloadJson: {
storePublicId: created.publicId,
ownerUserId: input.ownerUserId,
},
});
return created;
});
return success({
publicId: store.publicId,
reviewStatus: store.reviewStatus,
publicationStatus: store.publicationStatus,
dealStatus: store.dealStatus,
});
}
export async function submitStoreService(
prisma: PrismaClient,
storePublicId: string,
actorUserId: string,
): Promise<Result<{ publicId: string; reviewStatus: string }, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: storePublicId },
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId }));
}
if (store.reviewStatus !== 'DRAFT') {
return failure(
appError('INVALID_STATUS_TRANSITION', '초안(DRAFT) 상태의 매장만 제출할 수 있습니다.', {
currentStatus: store.reviewStatus,
}),
);
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.store.update({
where: { id: store.id },
data: { reviewStatus: 'SUBMITTED' },
});
await createAuditLog(tx, {
resourceType: 'STORE',
resourceId: store.publicId,
actionType: 'STORE_SUBMITTED',
actorUserId: BigInt(actorUserId),
actorRole: 'CLOSING_OWNER',
beforeJson: { reviewStatus: 'DRAFT' },
afterJson: { reviewStatus: 'SUBMITTED' },
});
await enqueueOutboxEvent(tx, {
aggregateType: 'STORE',
aggregateId: store.publicId,
eventName: 'store.submitted',
payloadJson: {
storePublicId: store.publicId,
ownerUserId: actorUserId,
},
});
return result;
});
return success({
publicId: updated.publicId,
reviewStatus: updated.reviewStatus,
});
}
export async function reviewStoreService(
prisma: PrismaClient,
storePublicId: string,
decision: ReviewDecision,
actorUserId: string,
reasonCode?: string,
memo?: string,
): Promise<Result<{ publicId: string; reviewStatus: string }, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: storePublicId },
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId }));
}
const domainResult = reviewStore({
currentReviewStatus: store.reviewStatus as ReviewableStatus,
decision,
reviewerUserId: actorUserId,
reasonCode,
memo,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.store.update({
where: { id: store.id },
data: {
reviewStatus: domainResult.value.reviewStatus,
...(domainResult.value.reviewStatus === 'APPROVED' ? { approvedAt: new Date() } : {}),
...(domainResult.value.reviewStatus === 'REJECTED' ? { rejectedAt: new Date() } : {}),
},
});
await createAuditLog(tx, {
resourceType: 'STORE',
resourceId: store.publicId,
actionType: decision === 'APPROVED' ? 'STORE_APPROVED' : 'STORE_REJECTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { reviewStatus: store.reviewStatus },
afterJson: {
reviewStatus: domainResult.value.reviewStatus,
reasonCode: domainResult.value.reasonCode,
memo: domainResult.value.memo,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'STORE',
aggregateId: store.publicId,
eventName: decision === 'APPROVED' ? 'store.approved' : 'store.rejected',
payloadJson: {
storePublicId: store.publicId,
decision,
reasonCode,
},
});
return result;
});
return success({
publicId: updated.publicId,
reviewStatus: updated.reviewStatus,
});
}
export async function publishStoreService(
prisma: PrismaClient,
storePublicId: string,
policyVersionId: string,
actorUserId: string,
): Promise<Result<{ publicId: string; publicationStatus: string }, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: storePublicId },
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId }));
}
const policyVersion = await prisma.policyVersion.findFirst({
where: { id: BigInt(policyVersionId) },
});
if (!policyVersion) {
return failure(appError('NOT_FOUND', '정책 버전을 찾을 수 없습니다.', { policyVersionId }));
}
const domainResult = publishStore({
currentReviewStatus: store.reviewStatus,
currentPublicationStatus: store.publicationStatus,
policyVersionId,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.store.update({
where: { id: store.id },
data: {
publicationStatus: 'PUBLISHED',
policyVersionId: policyVersion.id,
publishedAt: new Date(),
},
});
await createAuditLog(tx, {
resourceType: 'STORE',
resourceId: store.publicId,
actionType: 'STORE_PUBLISHED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { publicationStatus: store.publicationStatus },
afterJson: {
publicationStatus: 'PUBLISHED',
policyVersionId,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'STORE',
aggregateId: store.publicId,
eventName: 'store.published',
payloadJson: {
storePublicId: store.publicId,
policyVersionId,
},
});
return result;
});
return success({
publicId: updated.publicId,
publicationStatus: updated.publicationStatus,
});
}
export async function getStoreForViewer(
prisma: PrismaClient,
storePublicId: string,
viewer: ViewerContext,
): Promise<Result<FilteredStoreData, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: storePublicId },
include: {
ownerUser: { select: { publicId: true, phone: true, email: true } },
industryLeaf: { select: { nameKo: true } },
regionCluster: { select: { nameKo: true } },
lease: true,
},
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId }));
}
const storeData: StoreData = {
publicId: store.publicId,
listingTitle: store.listingTitle,
publicSummary: store.publicSummary ?? undefined,
roadAddress: store.roadAddress,
detailAddress: store.detailAddress ?? undefined,
ownerUserId: store.ownerUser.publicId,
ownerPhone: store.ownerUser.phone ?? undefined,
ownerEmail: store.ownerUser.email ?? undefined,
industryName: store.industryLeaf?.nameKo ?? undefined,
regionName: store.regionCluster?.nameKo ?? undefined,
reviewStatus: store.reviewStatus,
publicationStatus: store.publicationStatus,
dealStatus: store.dealStatus,
lease: store.lease
? {
depositAmount: Number(store.lease.depositAmount),
monthlyRentAmount: Number(store.lease.monthlyRentAmount),
premiumAmount: Number(store.lease.premiumAmount),
}
: undefined,
};
const filtered = filterStoreForViewer(storeData, viewer);
if (!filtered) {
return failure(appError('FORBIDDEN', '이 매장을 조회할 권한이 없습니다.', { storePublicId }));
}
return success(filtered);
}
@@ -0,0 +1,213 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import {
createSubsidyCase,
reviewSubsidyCase,
type ChecklistItemTemplate,
type SubsidyReviewDecision,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface CreateSubsidyCaseServiceInput {
readonly storePublicId: string;
readonly applicantUserId: string;
readonly programCode: string;
}
export interface SubsidyCaseResult {
readonly publicId: string;
readonly status: string;
readonly programCode: string;
}
export async function createSubsidyCaseService(
prisma: PrismaClient,
input: CreateSubsidyCaseServiceInput,
): Promise<Result<SubsidyCaseResult, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: input.storePublicId },
});
if (!store) {
return failure(
appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId: input.storePublicId }),
);
}
// 최신 정책 버전 조회
const policyVersion = await prisma.policyVersion.findFirst({
where: { policyType: 'SUBSIDY_CHECKLIST', isActive: true },
orderBy: { effectiveFrom: 'desc' },
});
if (!policyVersion) {
return failure(appError('NOT_FOUND', '활성화된 지원금 정책을 찾을 수 없습니다.'));
}
// 프로그램별 체크리스트 항목 (정책 버전에 따라 다름)
const checklistItems: ChecklistItemTemplate[] = getChecklistForProgram(input.programCode);
const domainResult = createSubsidyCase({
storeReviewStatus: store.reviewStatus,
storePublicationStatus: store.publicationStatus,
programCode: input.programCode,
policyVersionId: policyVersion.id.toString(),
checklistVersionCode: policyVersion.versionCode,
checklistItems,
});
if (!domainResult.ok) {
return domainResult;
}
const draft = domainResult.value;
const subsidyCase = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.subsidyCase.create({
data: {
storeId: store.id,
applicantUserId: BigInt(input.applicantUserId),
programCode: draft.programCode,
status: 'DRAFT',
policyVersionId: policyVersion.id,
checklistVersionCode: draft.checklistVersionCode,
},
});
// 체크리스트 항목 일괄 생성
for (const item of draft.checklistItems) {
await tx.subsidyChecklistItem.create({
data: {
subsidyCaseId: created.id,
itemCode: item.itemCode,
isRequired: item.isRequired,
status: 'PENDING',
},
});
}
await createAuditLog(tx, {
resourceType: 'SUBSIDY_CASE',
resourceId: created.publicId,
actionType: 'SUBSIDY_CASE_CREATED',
actorUserId: BigInt(input.applicantUserId),
actorRole: 'CLOSING_OWNER',
afterJson: {
storePublicId: input.storePublicId,
programCode: draft.programCode,
checklistVersionCode: draft.checklistVersionCode,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'SUBSIDY_CASE',
aggregateId: created.publicId,
eventName: 'subsidy_case.created',
payloadJson: {
subsidyCasePublicId: created.publicId,
storePublicId: input.storePublicId,
programCode: draft.programCode,
},
});
return created;
});
return success({
publicId: subsidyCase.publicId,
status: subsidyCase.status,
programCode: subsidyCase.programCode,
});
}
export async function reviewSubsidyCaseService(
prisma: PrismaClient,
subsidyCasePublicId: string,
decision: SubsidyReviewDecision,
actorUserId: string,
rejectionReasonCode?: string,
memo?: string,
): Promise<Result<{ publicId: string; status: string }, AppError>> {
const subsidyCase = await prisma.subsidyCase.findUnique({
where: { publicId: subsidyCasePublicId },
});
if (!subsidyCase) {
return failure(
appError('NOT_FOUND', '지원금 케이스를 찾을 수 없습니다.', { subsidyCasePublicId }),
);
}
const domainResult = reviewSubsidyCase({
currentStatus: subsidyCase.status,
decision,
rejectionReasonCode,
memo,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.subsidyCase.update({
where: { id: subsidyCase.id },
data: {
status: domainResult.value.status,
reviewedAt: new Date(),
rejectionReasonCode: domainResult.value.rejectionReasonCode ?? null,
},
});
await createAuditLog(tx, {
resourceType: 'SUBSIDY_CASE',
resourceId: subsidyCase.publicId,
actionType: decision === 'APPROVED' ? 'SUBSIDY_CASE_APPROVED' : 'SUBSIDY_CASE_REJECTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { status: subsidyCase.status },
afterJson: {
status: domainResult.value.status,
rejectionReasonCode: domainResult.value.rejectionReasonCode,
memo: domainResult.value.memo,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'SUBSIDY_CASE',
aggregateId: subsidyCase.publicId,
eventName: decision === 'APPROVED' ? 'subsidy_case.approved' : 'subsidy_case.rejected',
payloadJson: {
subsidyCasePublicId: subsidyCase.publicId,
decision,
rejectionReasonCode,
},
});
return result;
});
return success({
publicId: updated.publicId,
status: updated.status,
});
}
function getChecklistForProgram(programCode: string): ChecklistItemTemplate[] {
// 프로그램별 기본 체크리스트 (실제로는 DB/정책에서 로드)
const checklists: Record<string, ChecklistItemTemplate[]> = {
SMALL_BIZ_CLOSURE_2024: [
{ itemCode: 'BUSINESS_LICENSE', isRequired: true },
{ itemCode: 'LEASE_CONTRACT', isRequired: true },
{ itemCode: 'CLOSURE_REPORT', isRequired: true },
{ itemCode: 'BANK_STATEMENT', isRequired: false },
],
};
return (
checklists[programCode] ?? [
{ itemCode: 'BUSINESS_LICENSE', isRequired: true },
{ itemCode: 'LEASE_CONTRACT', isRequired: true },
]
);
}
@@ -0,0 +1,197 @@
import { Prisma } from '@prisma/client';
import type { PrismaClient } from '@prisma/client';
import {
applyVendorCertification,
approveVendorCertification,
type VendorType,
type VendorCertificationDecision,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface ApplyVendorCertificationServiceInput {
readonly ownerUserId: string;
readonly vendorType: VendorType;
readonly businessName: string;
readonly contactName: string;
readonly businessRegistrationNumber?: string;
readonly coverageRegionCodes: readonly string[];
}
export interface VendorCertificationResult {
readonly vendorPublicId: string;
readonly certificationStatus: string;
}
export async function applyVendorCertificationService(
prisma: PrismaClient,
input: ApplyVendorCertificationServiceInput,
): Promise<Result<VendorCertificationResult, AppError>> {
// 서비스 권역 지역 ID 조회
const regions = await prisma.regionHierarchy.findMany({
where: { code: { in: [...input.coverageRegionCodes] }, isActive: true },
select: { id: true, code: true },
});
const domainResult = applyVendorCertification({
vendorType: input.vendorType,
businessName: input.businessName,
contactName: input.contactName,
hasBusinessRegistration: !!input.businessRegistrationNumber?.trim(),
coverageRegionCount: regions.length,
});
if (!domainResult.ok) {
return domainResult;
}
const vendor = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.vendor.create({
data: {
ownerUserId: BigInt(input.ownerUserId),
vendorType: input.vendorType,
businessName: input.businessName,
contactName: input.contactName,
businessRegistrationNumber: input.businessRegistrationNumber ?? null,
certificationStatus: 'APPLIED',
},
});
// 서비스 권역 등록
for (const region of regions) {
await tx.vendorCoverageRegion.create({
data: {
vendorId: created.id,
regionId: region.id,
isPrimary: region.code === input.coverageRegionCodes[0],
},
});
}
// 인증 이력 생성
await tx.vendorCertification.create({
data: {
vendorId: created.id,
status: 'APPLIED',
},
});
await createAuditLog(tx, {
resourceType: 'VENDOR',
resourceId: created.publicId,
actionType: 'VENDOR_CERTIFICATION_APPLIED',
actorUserId: BigInt(input.ownerUserId),
actorRole: 'VENDOR_MANAGER',
afterJson: {
vendorType: input.vendorType,
businessName: input.businessName,
coverageRegions: input.coverageRegionCodes,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'VENDOR',
aggregateId: created.publicId,
eventName: 'vendor.certification.applied',
payloadJson: {
vendorPublicId: created.publicId,
vendorType: input.vendorType,
},
});
return created;
});
return success({
vendorPublicId: vendor.publicId,
certificationStatus: vendor.certificationStatus,
});
}
export async function reviewVendorCertificationService(
prisma: PrismaClient,
vendorPublicId: string,
decision: VendorCertificationDecision,
actorUserId: string,
reasonCode?: string,
): Promise<Result<VendorCertificationResult, AppError>> {
const vendor = await prisma.vendor.findUnique({
where: { publicId: vendorPublicId },
include: {
coverageRegions: true,
certifications: { where: { documentChecklistJson: { not: Prisma.DbNull } }, take: 1 },
},
});
if (!vendor) {
return failure(appError('NOT_FOUND', '업체를 찾을 수 없습니다.', { vendorPublicId }));
}
const domainResult = approveVendorCertification({
currentStatus: vendor.certificationStatus,
decision,
hasDocumentChecklist: vendor.certifications.length > 0,
coverageRegionCount: vendor.coverageRegions.length,
reasonCode,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.vendor.update({
where: { id: vendor.id },
data: {
certificationStatus: domainResult.value.status,
},
});
// U020: 인증 상태 변경 이력 기록
await tx.vendorCertification.create({
data: {
vendorId: vendor.id,
status: domainResult.value.status,
reviewedAt: new Date(),
reviewedByUserId: BigInt(actorUserId),
reasonCode: domainResult.value.reasonCode ?? null,
},
});
await createAuditLog(tx, {
resourceType: 'VENDOR',
resourceId: vendor.publicId,
actionType:
decision === 'APPROVED'
? 'VENDOR_CERTIFICATION_APPROVED'
: decision === 'SUSPENDED'
? 'VENDOR_CERTIFICATION_SUSPENDED'
: 'VENDOR_CERTIFICATION_REJECTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { certificationStatus: vendor.certificationStatus },
afterJson: {
certificationStatus: domainResult.value.status,
reasonCode: domainResult.value.reasonCode,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'VENDOR',
aggregateId: vendor.publicId,
eventName: `vendor.certification.${decision.toLowerCase()}`,
payloadJson: {
vendorPublicId: vendor.publicId,
decision,
reasonCode,
},
});
return result;
});
return success({
vendorPublicId: updated.publicId,
certificationStatus: updated.certificationStatus,
});
}
+24
View File
@@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"noEmit": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"verbatimModuleSyntax": false
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
name: '@relink/web',
globals: true,
environment: 'node',
testTimeout: 30000,
hookTimeout: 30000,
fileParallelism: false,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
});