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:
@@ -0,0 +1,5 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['next/core-web-vitals'],
|
||||
};
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@relink/ui', '@relink/shared'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+112
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user