diff --git a/.gitignore b/.gitignore index 36f3f9e..c80fb5e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ next-env.d.ts # 공공 원본 데이터. 준비 방법은 docs/DEVELOPMENT.md의 로컬 데이터 준비를 따른다. data/raw/ +# funding:sync가 합친 자금 카탈로그. 적재한 릴리스의 원본이며 저장소에 넣지 않는다. +data/funding-sync/ # Claude Code 개인 설정. 팀 공통 설정은 .claude/settings.json에 둔다. .claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index 1d5ba90..d8e1c97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ 5. 매출의 시간 단위와 점포당 분모가 공식 확정되기 전에는 월 환산·점포당 매출을 [ADR 0002](docs/decisions/0002-provisional-market-sales.md)의 잠정 해석으로만 제공한다. "잠정"과 근거·원값을 함께 표시하고, 계산 결과에 저장하지 않는다. 6. 계획의 소유자는 서버에서 검사한다. 없는 리소스와 타인 소유 리소스는 모두 404다. 계획 변경은 revision을 검사하고 충돌은 409다. 7. 저장된 계산 결과는 append-only다. 과거 결과를 덮어쓰거나 자동 재계산하지 않는다. -8. 미검수·종료·근거 부족 자금 상품을 현재 신청 가능 또는 상환 계산 가능으로 표시하지 않는다. 업종·용도만 확인되지 않은 상품은 [ADR 0006](docs/decisions/0006-funding-discovery-automation.md)에 따라 신청 전 확인 항목과 함께 현재 후보로 표시한다. +8. 사람 검수나 [ADR 0006](docs/decisions/0006-funding-discovery-automation.md)의 자동 확인을 거치지 않은 자금 상품, 종료·근거 부족 자금 상품을 현재 신청 가능 또는 상환 계산 가능으로 표시하지 않는다. 업종·용도만 확인되지 않은 상품은 ADR 0006에 따라 신청 전 확인 항목과 함께 현재 후보로 표시한다. 9. 운영 데이터는 `PENDING → ACTIVE` 검증을 거친다. 새 적재가 실패하면 기존 ACTIVE를 유지한다. 10. 출처 URL, 기준기간, 릴리스 키, checksum, 적용 시점 등 provenance를 보존한다. diff --git a/app/catalog/funding/README.md b/app/catalog/funding/README.md index 7b8e210..6fcda4d 100644 --- a/app/catalog/funding/README.md +++ b/app/catalog/funding/README.md @@ -13,6 +13,7 @@ npm --prefix app run funding:load ``` - `funding:validate`는 DB 없이 검사 결과만 출력한다. `funding:load`는 같은 검사를 통과한 카탈로그만 적재한다. +- 자동 수집 출처가 있으면 `funding:load` 대신 `npm --prefix app run funding:sync`로 이 카탈로그와 자동 상품을 합쳐 적재한다. `funding:load`로 이 파일만 적재하면 자동 상품이 빠진 릴리스가 ACTIVE가 된다. - 검사 항목, 오류·주의 구분, 적재·활성화 규칙은 [funding.md](../../../docs/domains/funding.md#카탈로그)에 있다. ## 갱신 절차 @@ -38,7 +39,7 @@ npm --prefix app run funding:load ## 2026-09-20 검수 범위 -현재 카탈로그 `2026-09-23.1`(상품 버전 `1.0.1`)에 적용되는 검수 내용이다. +현재 카탈로그 `2026-09-28.1`(상품 버전 `1.0.1`)에 적용되는 검수 내용이다. `2026-09-28.1`은 `2026-09-23.1`에서 스키마를 v1.1.0으로 올리고 자동화 설정을 더한 것이며, 상품 내용과 검수 내용은 같다. - 접수 상태가 CLOSED로 확인된 상품 1건(예비창업패키지 2차), 접수 상태를 확정할 수 없어 UNKNOWN으로 남긴 상품 4건을 기록했다. - 같은 날 접수 상태가 OPEN으로 확인된 상품은 없어 현재 신청 가능 후보로 확정되는 상품은 0건이다. @@ -46,3 +47,12 @@ npm --prefix app run funding:load - 2026-09-09에 확인했던 강북창업지원센터 입주 모집은 다시 확인하지 않아 검수 기한 경과 상태로 남겼고, 현재 후보에서 제외된다. - 서울여성 창업아이디어 공모전 등 2026-09-09 검토 항목은 이번 검수에서 다시 확인하지 않아 카탈로그에 넣지 않았다. - 검수자(`reviewer`)는 아직 지정되지 않아 `UNASSIGNED`다(#13). 근거 일부는 공식 원문이 아니라 검색 결과 요약이다. + +## 자동화 설정 + +`catalog.json`의 `automation`은 funding:sync가 모은 자동 상품에만 적용된다. 규칙은 [funding.md](../../../docs/domains/funding.md#동기화)와 [ADR 0006](../../../docs/decisions/0006-funding-discovery-automation.md)에 있다. + +- `policyLoanPromotion`, `repaymentPromotion`: 정책자금 자동 승격과 상환 계산 자동 승격 스위치다. 기본으로 꺼져 있고, 켜고 끄는 일은 ADR 0006의 Owner가 정한다. 바꾼 뒤 funding:sync를 실행하면 새 릴리스에 반영된다. +- `blockedSources`: 자동 승격을 막을 공고. `{ "source": "SEMAS_OLS", "externalId": "…", "reason": "…" }`처럼 출처·식별자·사유를 적는다. +- `sourceSyncs`: funding:sync가 채운다. 이 파일에는 비워 둔다. +- 자동 상품(검수자 `AUTO:…`)은 이 파일에 넣지 않는다. 같은 공고를 사람이 기록하려면 그 상품에 `sourceRef`를 적는다. 그러면 사람 기록이 자동 상품보다 우선한다. diff --git a/app/catalog/funding/catalog.json b/app/catalog/funding/catalog.json index 5d752f9..50ffb22 100644 --- a/app/catalog/funding/catalog.json +++ b/app/catalog/funding/catalog.json @@ -1,7 +1,7 @@ { "catalogKey": "trendbench-funding-catalog", - "schemaVersion": "funding-catalog-v1.0.0", - "catalogVersion": "2026-09-23.1", + "schemaVersion": "funding-catalog-v1.1.0", + "catalogVersion": "2026-09-28.1", "basisDate": "2026-09-20", "reviewer": "UNASSIGNED", "notes": [ @@ -10,8 +10,15 @@ "공고문 원문에서 확정 숫자로 확인되지 않은 금리·기간·한도는 0으로 채우지 않고 null 또는 UNKNOWN으로 보존했습니다.", "검수자가 지정되지 않아(UNASSIGNED) 상태이며, 운영 공개 전에 실제 검수자와 다음 검토일을 지정해야 합니다.", "2026-09-23: 2026-09-20.1 적재 뒤 조건별 공식 근거(evidence)가 추가되어 catalogVersion을 2026-09-23.1, 상품 버전을 1.0.1로 올렸습니다. 검수 내용과 검수일은 바뀌지 않았습니다.", - "2026-09-09에 확인했던 서울여성 창업아이디어 공모전 등은 2026-09-20에 다시 확인하지 않아 카탈로그에 넣지 않았습니다. 접수 상태가 OPEN으로 확인된 상품도 이번 검수 범위에는 없습니다." + "2026-09-09에 확인했던 서울여성 창업아이디어 공모전 등은 2026-09-20에 다시 확인하지 않아 카탈로그에 넣지 않았습니다. 접수 상태가 OPEN으로 확인된 상품도 이번 검수 범위에는 없습니다.", + "2026-09-28: 스키마를 funding-catalog-v1.1.0으로 올리고 자동화 설정(automation)을 더해 catalogVersion을 2026-09-28.1로 올렸습니다. 두 자동 승격 스위치는 꺼져 있고, 상품 내용과 상품 버전(1.0.1)은 바뀌지 않았습니다." ], + "automation": { + "policyLoanPromotion": false, + "repaymentPromotion": false, + "blockedSources": [], + "sourceSyncs": [] + }, "products": [ { "productKey": "pre-startup-package-2026-2nd", diff --git a/app/package.json b/app/package.json index 29bdcec..2b1b1e2 100644 --- a/app/package.json +++ b/app/package.json @@ -29,7 +29,8 @@ "rent:load": "node --env-file-if-exists=.env.local scripts/load-rent-benchmarks.ts", "franchise:load": "node --env-file-if-exists=.env.local scripts/load-franchises.ts", "funding:validate": "node --env-file-if-exists=.env.local scripts/validate-funding-catalog.ts", - "funding:load": "node --env-file-if-exists=.env.local scripts/load-funding-catalog.ts" + "funding:load": "node --env-file-if-exists=.env.local scripts/load-funding-catalog.ts", + "funding:sync": "node --env-file-if-exists=.env.local scripts/sync-funding.ts" }, "packageManager": "npm@11.13.0", "engines": { diff --git a/app/prisma/migrations/20260928010000_funding_release_automation/migration.sql b/app/prisma/migrations/20260928010000_funding_release_automation/migration.sql new file mode 100644 index 0000000..01dfe52 --- /dev/null +++ b/app/prisma/migrations/20260928010000_funding_release_automation/migration.sql @@ -0,0 +1,3 @@ +-- 자동 승격 스위치·차단 목록·출처별 동기화 결과(ADR 0006). 이전 카탈로그 릴리스는 값이 없어 NULL로 남는다. +-- AlterTable +ALTER TABLE "funding_catalog_releases" ADD COLUMN "automation" JSONB; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 82f3484..a111c05 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -322,6 +322,8 @@ model FundingCatalogRelease { reviewer String sourceDocuments Json @db.JsonB productVersions Json @db.JsonB + // 자동 승격 스위치·차단 목록·출처별 동기화 결과(ADR 0006). v1.0.0 카탈로그 릴리스는 NULL이다. + automation Json? @db.JsonB validationSummary Json @db.JsonB productCount Int @default(0) activatedAt DateTime? diff --git a/app/scripts/sync-funding.ts b/app/scripts/sync-funding.ts new file mode 100644 index 0000000..f01ce51 --- /dev/null +++ b/app/scripts/sync-funding.ts @@ -0,0 +1,120 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../src/generated/prisma/client.ts'; +import { todayInKst } from '../src/features/funding/dates.ts'; +import { runFundingSync, type FundingSourceAdapter } from '../src/features/funding/sync.ts'; +import { FundingCatalogError } from '../src/features/funding/validation.ts'; + +const DEFAULT_CATALOG = fileURLToPath(new URL('../catalog/funding/catalog.json', import.meta.url)); +const DEFAULT_OUTPUT_DIR = fileURLToPath(new URL('../../data/funding-sync/', import.meta.url)); + +// funding:sync가 공고를 모으는 출처별 수집기. ADR 0006의 출처 확인(robots.txt·약관)을 거친 출처만 등록한다. +// 등록된 수집기가 없으면 사람이 관리하는 카탈로그만 새 릴리스로 적재한다. +const SOURCE_ADAPTERS: readonly FundingSourceAdapter[] = []; + +const USAGE = `공식 출처에서 자금 공고를 모아 사람이 관리하는 카탈로그와 합치고 새 릴리스로 적재합니다(ADR 0006). + +사용법: + npm run funding:sync -- [옵션] + +옵션: + --catalog <경로> 사람이 관리하는 카탈로그 JSON (기본값: app/catalog/funding/catalog.json) + --output-dir <경로> 합친 카탈로그를 쓸 폴더 (기본값: data/funding-sync/) + --dry-run 합친 카탈로그를 쓰고 검증만 하며 적재하지 않습니다 + -h, --help 이 도움말 + +동작: + - 웹 요청 중에는 실행하지 않습니다. 운영자나 정기 실행이 하루 한 번 실행합니다. + - 출처가 실패하면(장애, 빈 응답, 직전 성공 대비 절반 미만, 검증 실패) 그 출처의 상품은 직전 ACTIVE 릴리스의 것을 그대로 두고 마지막 성공일도 그대로 둡니다. + - 자동 상품은 출처의 마지막 성공 동기화가 3일을 넘기면 현재 후보에서 빠집니다. + - 사람이 기록한 상품이 같은 공고를 가리키면 사람 기록이 우선합니다. + - 내용이 같은 자동 상품은 이미 적재된 상품 버전을 재사용하고, 바뀌면 새 버전을 만듭니다. + - 합친 카탈로그는 funding:load와 같은 검사를 거쳐 PENDING → ACTIVE로 적재합니다.`; + +type CliOptions = { catalogPath: string; outputDir: string; dryRun: boolean; help: boolean }; + +function parseArgs(argv: readonly string[]): CliOptions { + const options: CliOptions = { + catalogPath: DEFAULT_CATALOG, + outputDir: DEFAULT_OUTPUT_DIR, + dryRun: false, + help: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--help' || argument === '-h') options.help = true; + else if (argument === '--dry-run') options.dryRun = true; + else if (argument === '--catalog' || argument === '--output-dir') { + const value = argv[index + 1]; + if (!value) throw new Error(`${argument} 뒤에 경로가 필요합니다.`); + if (argument === '--catalog') options.catalogPath = resolve(value); + else options.outputDir = resolve(value); + index += 1; + } else { + throw new Error(`알 수 없는 옵션입니다: ${argument}`); + } + } + return options; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(USAGE); + return; + } + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('DATABASE_URL이 없습니다. app/.env.local을 준비하거나 DATABASE_URL을 지정해 주세요.'); + } + if (SOURCE_ADAPTERS.length === 0) { + console.log('등록된 출처 수집기가 없습니다. 사람이 관리하는 카탈로그만 합쳐 적재합니다.'); + } + const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: databaseUrl }) }); + try { + const report = await runFundingSync({ + prisma, + catalogPath: options.catalogPath, + adapters: SOURCE_ADAPTERS, + asOfDate: todayInKst(), + outputDir: options.outputDir, + dryRun: options.dryRun, + log: (message) => console.log(message), + }); + console.log(''); + for (const source of report.sources) { + const state = source.status === 'SUCCEEDED' ? '성공' : `실패(${source.failureReason ?? '사유 없음'})`; + console.log( + `출처 ${source.source}: ${state} · 상품 ${source.productCount}건${source.carriedOver ? '(직전 릴리스에서 유지)' : ''} · 마지막 성공일 ${source.lastSucceededOn ?? '없음'}`, + ); + } + console.log(`사람이 기록한 상품 ${report.manualProductCount}건 · 자동 상품 ${report.automatedProductCount}건`); + if (report.overriddenByManual.length > 0) { + console.log(`사람 기록이 우선해 뺀 자동 상품: ${report.overriddenByManual.join(', ')}`); + } + console.log(`합친 카탈로그: ${report.catalogFile}`); + if (report.load === null) { + console.log('dry run이라 적재하지 않았습니다.'); + } else { + for (const warning of report.load.warnings) { + console.warn(`주의 [${warning.code}] ${warning.source}: ${warning.detail}`); + } + console.log(`결과: ${report.load.outcome}`); + console.log(`카탈로그: ${report.load.catalogKey}@${report.load.catalogVersion} (${report.load.releaseId})`); + console.log(`checksum: ${report.load.catalogChecksum}`); + } + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error: unknown) => { + if (error instanceof FundingCatalogError) { + console.error(`오류 [${error.code}] ${error.message}`); + for (const issue of error.issues ?? []) console.error(` - [${issue.code}] ${issue.source}: ${issue.detail}`); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + process.exitCode = 1; +}); diff --git a/app/src/app/api/plans/[planId]/loan-assumption/route.ts b/app/src/app/api/plans/[planId]/loan-assumption/route.ts index 24521cc..8ac6929 100644 --- a/app/src/app/api/plans/[planId]/loan-assumption/route.ts +++ b/app/src/app/api/plans/[planId]/loan-assumption/route.ts @@ -78,7 +78,10 @@ export async function POST(request: Request, context: Context) { '저장된 자금 조건이 없거나 현재 스키마와 맞지 않습니다. 자금 조건을 저장한 뒤 다시 조회해 주세요.', ); } - const evaluation = evaluateProduct(product, parsedProfile.data, { asOfDate: todayInKst() }); + const evaluation = evaluateProduct(product, parsedProfile.data, { + asOfDate: todayInKst(), + automation: active.automation, + }); if (evaluation.candidateStatus !== 'CURRENT_CANDIDATE') { return apiError( 400, @@ -115,6 +118,16 @@ export async function POST(request: Request, context: Context) { ); } + // 자동 수집 상품은 상환 계산 자동 승격 스위치까지 반영한 판정 결과로 확인한다. + if (!evaluation.repayment.supported) { + const reasons = + evaluation.repayment.reasons.length > 0 ? evaluation.repayment.reasons.join(' / ') : evaluation.repayment.note; + return apiError( + 400, + 'INVALID_INPUT', + `이 상품은 상환 계산 대상이 아니어서 대출 가정으로 적용할 수 없습니다. ${reasons}`, + ); + } const loanAssumption = loanAssumptionFromProduct( product, { catalogKey: active.catalogKey, catalogVersion: active.catalogVersion }, diff --git a/app/src/features/funding/candidates.test.ts b/app/src/features/funding/candidates.test.ts index b79a71a..6bfd57f 100644 --- a/app/src/features/funding/candidates.test.ts +++ b/app/src/features/funding/candidates.test.ts @@ -14,7 +14,7 @@ import { POST as postCandidates } from '@/app/api/funding/candidates/route'; import { listFundingCandidates, type FundingCandidatesResponse } from './candidates.ts'; import { loadFundingCatalog } from './loader.ts'; import { findActiveFundingCatalog } from './read.ts'; -import { FUNDING_CATALOG_SCHEMA_VERSION } from './types.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION } from './types.ts'; import type { FundingProfile } from './eligibility.ts'; const TEST_DATE = '2026-09-20'; @@ -93,8 +93,11 @@ type StubMembership = { position: number; productVersion: { productJson: unknown type StubReleaseInput = { catalogVersion?: string; + schemaVersion?: string; basisDate?: string; reviewer?: string; + // 릴리스의 automation 열. 비어 있으면 실제 DB처럼 null이다. + automation?: unknown; products: StubMembership[]; }; @@ -107,11 +110,12 @@ function stubPrisma(release: StubReleaseInput | null) { id: 'release-1', catalogKey: 'test-catalog', catalogVersion: release.catalogVersion ?? '2026-09-20.1', - schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + schemaVersion: release.schemaVersion ?? FUNDING_CATALOG_SCHEMA_VERSION, basisDate: new Date(`${release.basisDate ?? TEST_DATE}T00:00:00.000Z`), reviewedAt: new Date(`${TEST_DATE}T00:00:00.000Z`), activatedAt: new Date('2026-09-20T03:00:00.000Z'), reviewer: release.reviewer ?? 'test-reviewer', + automation: release.automation ?? null, products: release.products, }, ); @@ -357,6 +361,16 @@ describe('후보 판정과 응답', () => { expect(payload.evaluations[0].candidateStatus).toBe('CURRENT_CANDIDATE'); }); + it('v1.0.0 스키마로 적재된 릴리스도 자동화 설정 없이 읽는다', async () => { + const { prisma } = stubPrisma({ + schemaVersion: 'funding-catalog-v1.0.0', + products: [membership(rawProduct(), 0)], + }); + const payload = await candidatesOf(prisma); + expect(payload.release.schemaVersion).toBe('funding-catalog-v1.0.0'); + expect(payload.evaluations[0].candidateStatus).toBe('CURRENT_CANDIDATE'); + }); + it('검수 기한이 지난 상품은 접수 중이어도 검수 기한 경과로 분리한다', async () => { const { prisma } = stubPrisma({ products: [ @@ -519,6 +533,103 @@ describe('후보 판정과 응답', () => { }); }); +describe('자동으로 모은 상품(ADR 0006)', () => { + const SOURCE_REF = { source: 'SEMAS_OLS', externalId: 'test-fund' }; + const autoProduct = (overrides: Record = {}) => + rawProduct({ reviewer: AUTO_REVIEWER, sourceRef: SOURCE_REF, nextReviewAt: null, ...overrides }); + const sync = (lastSucceededOn: string | null) => ({ + source: 'SEMAS_OLS', + status: lastSucceededOn === null ? 'FAILED' : 'SUCCEEDED', + attemptedAt: '2026-09-20T01:00:00.000Z', + lastSucceededOn, + fetchedCount: lastSucceededOn === null ? null : 5, + responseChecksum: null, + failureReason: lastSucceededOn === null ? '테스트 실패' : null, + }); + const automation = (overrides: Record = {}) => ({ + policyLoanPromotion: true, + repaymentPromotion: false, + blockedSources: [], + sourceSyncs: [sync(TEST_DATE)], + ...overrides, + }); + const evaluate = async (product: Record, automationValue: unknown, asOfDate: string = TEST_DATE) => { + const { prisma } = stubPrisma({ automation: automationValue, products: [membership(product, 0)] }); + return (await candidatesOf(prisma, PRE_PROFILE, asOfDate)).evaluations[0]; + }; + + it('출처의 마지막 성공 동기화가 3일 안이면 다음 검토일 없이도 현재 후보가 된다', async () => { + const lastDay = await evaluate(autoProduct(), automation(), '2026-09-23'); + expect(lastDay.reviewState).toBe('CURRENT'); + expect(lastDay.candidateStatus).toBe('CURRENT_CANDIDATE'); + }); + + it('마지막 성공 동기화가 3일을 넘기거나 없으면 검수 기한 경과로 내린다', async () => { + const stale = await evaluate(autoProduct(), automation(), '2026-09-24'); + expect(stale.candidateStatus).toBe('REVIEW_OVERDUE'); + expect(stale.candidateReason).toContain('2026-09-20'); + const neverSucceeded = await evaluate(autoProduct(), automation({ sourceSyncs: [sync(null)] })); + expect(neverSucceeded.candidateStatus).toBe('REVIEW_OVERDUE'); + const noAutomation = await evaluate(autoProduct(), null); + expect(noAutomation.candidateStatus).toBe('REVIEW_OVERDUE'); + }); + + it('정책자금 자동 승격이 꺼져 있으면 추가 확인에 머문다', async () => { + const evaluation = await evaluate(autoProduct(), automation({ policyLoanPromotion: false })); + expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(evaluation.candidateReason).toContain('자동 승격이 꺼져'); + }); + + it('차단 목록의 공고는 사유와 함께 추가 확인에 머문다', async () => { + const evaluation = await evaluate( + autoProduct(), + automation({ blockedSources: [{ ...SOURCE_REF, reason: '첨부에 음식점 제외 조항' }] }), + ); + expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(evaluation.candidateReason).toContain('첨부에 음식점 제외 조항'); + }); + + it('지원사업 자동 상품은 조건을 모두 채워도 현재 후보가 되지 않는다', async () => { + const evaluation = await evaluate( + autoProduct({ + supportType: 'GRANT', + repaymentMethod: 'NOT_APPLICABLE', + unsupportedCalculationReasons: ['지원금은 상환 계산 대상이 아닙니다.'], + }), + automation(), + ); + expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(evaluation.candidateReason).toContain('사람이 확인해야'); + }); + + it('상환 계산 자동 승격이 꺼져 있으면 확정 조건이 있어도 상환 계산 대상이 아니다', async () => { + const confirmed = autoProduct({ + interestCondition: '연 3.0% 고정', + interestRateConfirmed: true, + interestRatePercent: '3', + repaymentCondition: '5년(거치 1년 포함) 원금균등', + repaymentMethod: 'EQUAL_PRINCIPAL', + repaymentTermMonths: 60, + repaymentGraceMonths: 12, + unsupportedCalculationReasons: [], + }); + const off = await evaluate(confirmed, automation()); + expect(off.candidateStatus).toBe('CURRENT_CANDIDATE'); + expect(off.repayment.supported).toBe(false); + expect(off.repayment.reasons.join(' ')).toContain('상환 계산 자동 승격'); + const on = await evaluate(confirmed, automation({ repaymentPromotion: true })); + expect(on.repayment.supported).toBe(true); + }); + + it('사람이 기록한 상품은 자동화 설정과 상관없이 지금 규칙을 따른다', async () => { + const evaluation = await evaluate( + rawProduct({ sourceRef: SOURCE_REF }), + automation({ policyLoanPromotion: false, sourceSyncs: [] }), + ); + expect(evaluation.candidateStatus).toBe('CURRENT_CANDIDATE'); + }); +}); + describe.skipIf(!process.env.TEST_DATABASE_URL)('PostgreSQL에서 활성 카탈로그 후보 조회', () => { let client: PrismaClient | null = null; let startedAt = new Date(0); diff --git a/app/src/features/funding/eligibility.ts b/app/src/features/funding/eligibility.ts index 2b12308..5b794ce 100644 --- a/app/src/features/funding/eligibility.ts +++ b/app/src/features/funding/eligibility.ts @@ -1,9 +1,19 @@ -import type { FundingCatalog, FundingProduct } from './schema.ts'; import { + defaultFundingAutomation, + sourceRefKey, + type FundingAutomation, + type FundingCatalog, + type FundingProduct, +} from './schema.ts'; +import { + FUNDING_SOURCE_LABELS, + POLICY_LOAN_SUPPORT_TYPES, REPAYABLE_SUPPORT_TYPES, + SOURCE_FRESHNESS_DAYS, combineVerdicts, compareIsoDates, INDUSTRY_CODE_PATTERN, + isAutoReviewer, isPrimaryRetrievalMethod, isSupportedRepaymentMethod, REQUIRED_EVIDENCE_SUBJECTS, @@ -330,11 +340,40 @@ export function hasPrimaryEvidence(product: FundingProduct): boolean { ); } +// 자동 수집 상품을 현재 후보로 올리지 못하게 막는 사유. 사람이 기록한 상품이면 null이다. +function automationHold(product: FundingProduct, automation: FundingAutomation): string | null { + if (!isAutoReviewer(product.reviewer) || product.sourceRef === undefined) return null; + const refKey = sourceRefKey(product.sourceRef); + const blocked = automation.blockedSources.find((entry) => sourceRefKey(entry) === refKey); + if (blocked) return `운영자가 이 공고의 자동 승격을 막았습니다: ${blocked.reason}`; + if (!POLICY_LOAN_SUPPORT_TYPES.includes(product.supportType)) { + return '지원사업은 자동으로 모아도 사람이 확인해야 현재 후보가 됩니다.'; + } + if (!automation.policyLoanPromotion) { + return '정책자금 자동 승격이 꺼져 있어 자동으로 모은 상품을 현재 후보로 표시하지 않습니다.'; + } + return null; +} + +// 상환 계산 자동 승격이 꺼져 있으면 자동 수집 상품의 조건으로 상환액을 계산하지 않는다. +function repaymentFor(product: FundingProduct, automation: FundingAutomation): ProductRepaymentAvailability { + const repayment = assessProductRepayment(product); + if (!repayment.supported || !isAutoReviewer(product.reviewer) || automation.repaymentPromotion) return repayment; + return { + supported: false, + publicLimitKrw: null, + terms: null, + reasons: ['상환 계산 자동 승격이 꺼져 있어 자동으로 모은 조건으로 상환액을 계산하지 않습니다.'], + note: '자동으로 모은 금리·기간·상환방식은 운영자가 상환 계산 자동 승격을 켠 뒤에만 계산에 씁니다.', + }; +} + export function evaluateProduct( product: FundingProduct, profile: FundingProfile, - options: { asOfDate: string }, + options: { asOfDate: string; automation?: FundingAutomation }, ): FundingCandidateEvaluation { + const automation = options.automation ?? defaultFundingAutomation(); const conditions = [ stageCondition(product, profile), regionCondition(product, profile), @@ -343,8 +382,10 @@ export function evaluateProduct( ]; const eligibilityVerdict = combineVerdicts(conditions.map((condition) => condition.verdict)); const audience = productAudience(product); - const state = reviewState(product, options.asOfDate); - const repayment = assessProductRepayment(product); + const state = reviewState(product, options.asOfDate, automation.sourceSyncs); + const automated = isAutoReviewer(product.reviewer); + const hold = automationHold(product, automation); + const repayment = repaymentFor(product, automation); const primaryEvidenceVerified = hasPrimaryEvidence(product); // 관측 접수 상태가 OPEN·UNKNOWN이어도 신청 종료일이 지나면 접수가 끝난 것이다. 종료일 당일까지는 접수 중으로 본다. const periodEnd = product.applicationPeriod.end; @@ -363,7 +404,12 @@ export function evaluateProduct( let candidateStatus: FundingCandidateStatus; let candidateReason: string; - if (state === 'REVIEW_OVERDUE') { + if (state === 'REVIEW_OVERDUE' && automated) { + const source = product.sourceRef?.source; + const lastSucceededOn = automation.sourceSyncs.find((entry) => entry.source === source)?.lastSucceededOn ?? '없음'; + candidateStatus = 'REVIEW_OVERDUE'; + candidateReason = `자동으로 모은 출처(${source ? FUNDING_SOURCE_LABELS[source] : '없음'})의 마지막 성공 확인일 ${lastSucceededOn}이(가) 기준일 ${options.asOfDate}보다 ${SOURCE_FRESHNESS_DAYS}일 넘게 지났거나 없어 재확인 전에는 현재 후보로 표시하지 않습니다.`; + } else if (state === 'REVIEW_OVERDUE') { candidateStatus = 'REVIEW_OVERDUE'; candidateReason = `다음 검토일 ${product.nextReviewAt ?? '없음'}이(가) 기준일 ${options.asOfDate} 이전이라 재확인 전에는 현재 후보로 표시하지 않습니다.`; } else if (product.observedApplicationStatus === 'CLOSED') { @@ -390,6 +436,9 @@ export function evaluateProduct( } else if (blockingUnknown) { candidateStatus = 'NEEDS_CONFIRMATION'; candidateReason = blockingUnknown.detail; + } else if (hold !== null) { + candidateStatus = 'NEEDS_CONFIRMATION'; + candidateReason = hold; } else if (product.observedApplicationStatus === 'OPEN') { candidateStatus = 'CURRENT_CANDIDATE'; candidateReason = @@ -430,7 +479,9 @@ export function evaluateCandidates( profile: FundingProfile, options: { asOfDate: string }, ): FundingCandidateList { - const evaluations = catalog.products.map((product) => evaluateProduct(product, profile, options)); + const evaluations = catalog.products.map((product) => + evaluateProduct(product, profile, { asOfDate: options.asOfDate, automation: catalog.automation }), + ); const summary = { total: evaluations.length, CURRENT_CANDIDATE: 0, diff --git a/app/src/features/funding/funding.test.ts b/app/src/features/funding/funding.test.ts index 6d6e6bf..db8e2dd 100644 --- a/app/src/features/funding/funding.test.ts +++ b/app/src/features/funding/funding.test.ts @@ -8,7 +8,7 @@ import { PrismaClient } from '../../generated/prisma/client.ts'; import { assessProductRepayment, evaluateCandidates, evaluateProduct, industryCodeIssue } from './eligibility.ts'; import { loadFundingCatalog, readFundingCatalogFile } from './loader.ts'; import { productVersionKey, type FundingCatalog } from './schema.ts'; -import { combineVerdicts, type Verdict } from './types.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION, combineVerdicts, type Verdict } from './types.ts'; import { FundingCatalogError, validateFundingCatalog, type FundingCatalogValidation } from './validation.ts'; const REAL_CATALOG_PATH = fileURLToPath(new URL('../../../catalog/funding/catalog.json', import.meta.url)); @@ -88,7 +88,7 @@ function catalog( ): Record { return { catalogKey: 'test-catalog', - schemaVersion: 'funding-catalog-v1.0.0', + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, catalogVersion: '2026-09-20.1', basisDate: REVIEW_DATE, reviewer: 'tester', @@ -337,6 +337,73 @@ describe('funding catalog schema and rules', () => { }); }); +describe('automated products in the catalog (ADR 0006)', () => { + const sourceRef = { source: 'SEMAS_OLS', externalId: 'test-fund' }; + const succeededSync = { + source: 'SEMAS_OLS', + status: 'SUCCEEDED', + attemptedAt: '2026-09-20T01:00:00.000Z', + lastSucceededOn: REVIEW_DATE, + fetchedCount: 1, + responseChecksum: null, + failureReason: null, + }; + const automation = (overrides: Record = {}) => ({ + policyLoanPromotion: false, + repaymentPromotion: false, + blockedSources: [], + sourceSyncs: [succeededSync], + ...overrides, + }); + + it('checks an automated product against source freshness instead of a next review date', () => { + const validation = validate( + catalog([product({ reviewer: AUTO_REVIEWER, sourceRef, nextReviewAt: null })], { automation: automation() }), + ); + expect(issueCodes(validation)).not.toContain('NEXT_REVIEW_AT_MISSING'); + expect(validation.ok).toBe(true); + expect(validation.reviewOverdue).toEqual([]); + + const stale = validate( + catalog([product({ reviewer: AUTO_REVIEWER, sourceRef, nextReviewAt: null })], { + automation: automation({ sourceSyncs: [] }), + }), + ); + expect(stale.ok).toBe(true); + expect(warningCodes(stale)).toContain('REVIEW_OVERDUE'); + expect(stale.reviewOverdue).toEqual(['test-loan@1.0.0']); + }); + + it('rejects an automated reviewer without a source reference', () => { + const validation = validate(catalog([product({ reviewer: AUTO_REVIEWER, nextReviewAt: null })])); + expect(issueCodes(validation)).toContain('AUTO_REVIEWER_WITHOUT_SOURCE'); + }); + + it('rejects two products that point at the same source notice', () => { + const validation = validate( + catalog([product({ sourceRef }), product({ productKey: 'test-loan-copy', sourceRef })]), + ); + expect(issueCodes(validation)).toContain('DUPLICATE_SOURCE_REF'); + }); + + it('rejects two sync results for the same source', () => { + const validation = validate( + catalog([product()], { automation: automation({ sourceSyncs: [succeededSync, succeededSync] }) }), + ); + expect(issueCodes(validation)).toContain('DUPLICATE_SOURCE_SYNC'); + }); + + it('defaults a catalog without an automation block to every switch off', () => { + const parsed = catalogFrom(validate(catalog([product()]))); + expect(parsed.automation).toEqual({ + policyLoanPromotion: false, + repaymentPromotion: false, + blockedSources: [], + sourceSyncs: [], + }); + }); +}); + describe('the reviewed 2026-09-20 catalog', () => { it('passes every rule check and reports the review state of each product', () => { const validation = validate(REAL_CATALOG); @@ -633,7 +700,7 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)('loading the funding catalog int let client: PrismaClient | null = null; let startedAt = new Date(0); let previousActiveId: string | null = null; - const REAL_VERSION = '2026-09-23.1'; + const REAL_VERSION = '2026-09-28.1'; const REVIEWED_CATALOG_PATH = reviewedCatalogFile(); const db = (): PrismaClient => { diff --git a/app/src/features/funding/loader.ts b/app/src/features/funding/loader.ts index 4f27451..3056ab7 100644 --- a/app/src/features/funding/loader.ts +++ b/app/src/features/funding/loader.ts @@ -180,6 +180,7 @@ export async function loadFundingCatalog(options: FundingLoadOptions): Promise fundingProductSchema.parse(membership.productVersion.productJson), ); + const automation = + release.automation === null ? defaultFundingAutomation() : fundingAutomationSchema.parse(release.automation); return { releaseId: release.id, @@ -66,6 +78,7 @@ export async function findActiveFundingCatalog(prisma: PrismaClient): Promise; export type FundingCatalog = z.infer; +export type FundingAutomation = z.infer; +export type FundingSourceSync = z.infer; +export type FundingSourceRef = z.infer; + +export function sourceRefKey(ref: FundingSourceRef): string { + return `${ref.source}:${ref.externalId}`; +} export function productVersionKey(product: { productKey: string; version: string }): string { return `${product.productKey}@${product.version}`; } diff --git a/app/src/features/funding/sync.test.ts b/app/src/features/funding/sync.test.ts new file mode 100644 index 0000000..fd1bf34 --- /dev/null +++ b/app/src/features/funding/sync.test.ts @@ -0,0 +1,371 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PrismaClient } from '../../generated/prisma/client.ts'; +import { findActiveFundingCatalog } from './read.ts'; +import { + nextPatchVersion, + productContentFingerprint, + runFundingSync, + type CollectedFundingProduct, + type FundingSourceAdapter, + type FundingSourceCollection, +} from './sync.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION } from './types.ts'; +import { FundingCatalogError } from './validation.ts'; + +const CREATED_DIRS: string[] = []; +const OLS_URL = 'https://ols.semas.or.kr/ols/man/SMAN018M/page.do'; + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'trendbench-sync-')); + CREATED_DIRS.push(dir); + return dir; +} + +function evidence(id: string, subject: string, observedAt: string): Record { + return { + id, + subject, + summary: '공식 페이지에서 해당 조건을 확인했습니다.', + sourceUrl: OLS_URL, + sourceDocumentName: '정책자금 한눈에 보기', + observedAt, + retrievalMethod: 'OFFICIAL_WEB_PAGE', + checksum: null, + }; +} + +// 수집기가 만드는 모양 그대로의 정책자금 상품. 버전과 검수자는 동기화가 정한다. +function collected(externalId: string, observedAt: string, overrides: Record = {}) { + return { + productKey: `test-sync-${externalId}`, + name: `테스트 정책자금 ${externalId}`, + organization: '소상공인시장진흥공단', + supportType: 'LOAN', + eligibleBusinessStages: ['POST_REGISTRATION'], + region: { scope: 'NATIONWIDE', districtCodes: [], note: null }, + purpose: { included: ['OPERATING_FUNDS'], excluded: [], note: null }, + industryConditions: { scope: 'UNKNOWN', included: [], excluded: [], note: '업종 조건은 공고 본문에서 확인합니다.' }, + applicationPeriod: { start: null, end: null, note: '예산 소진 시까지 접수합니다.' }, + observedApplicationStatus: 'OPEN', + observedAt, + reviewedAt: observedAt, + nextReviewAt: null, + officialUrl: OLS_URL, + sourceDocumentName: '정책자금 한눈에 보기', + sourceDocumentRetrieved: false, + sourceChecksum: null, + publicLimit: '70000000', + interestCondition: '정책자금 기준금리 + 0.6%p', + interestRateConfirmed: false, + interestRatePercent: null, + repaymentCondition: '5년(거치 2년 포함)', + repaymentMethod: 'UNKNOWN', + repaymentTermMonths: null, + repaymentGraceMonths: null, + unsupportedCalculationReasons: ['금리가 기준금리 연동이라 확정 숫자가 아닙니다.'], + additionalChecks: [], + evidence: [ + evidence('identity', 'IDENTITY', observedAt), + evidence('period', 'APPLICATION_PERIOD', observedAt), + evidence('stage', 'BUSINESS_STAGE', observedAt), + evidence('region', 'REGION', observedAt), + evidence('purpose', 'PURPOSE', observedAt), + evidence('financial', 'FINANCIAL_CONDITION', observedAt), + ], + sourceRef: { source: 'SEMAS_OLS', externalId }, + ...overrides, + } as unknown as CollectedFundingProduct; +} + +function adapter(result: () => FundingSourceCollection): FundingSourceAdapter { + return { source: 'SEMAS_OLS', collect: async () => result() }; +} + +function collection(products: CollectedFundingProduct[], fetchedCount = 10): FundingSourceCollection { + return { products, fetchedCount, responseChecksum: 'a'.repeat(64) }; +} + +// 사람이 관리하는 카탈로그. 자동 상품과 겹치지 않는 상품 하나를 둔다. +function manualCatalogFile(products: Record[] = [manualProduct()]): string { + const path = join(tempDir(), 'catalog.json'); + writeFileSync( + path, + JSON.stringify({ + catalogKey: 'test-sync-catalog', + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + catalogVersion: '2026-09-20.1', + basisDate: '2026-09-20', + reviewer: 'tester', + notes: [], + automation: { policyLoanPromotion: true, repaymentPromotion: false, blockedSources: [], sourceSyncs: [] }, + products, + }), + 'utf8', + ); + return path; +} + +function manualProduct(overrides: Record = {}): Record { + const product = collected('manual', '2026-09-20') as unknown as Record; + delete product.sourceRef; + return { + ...product, + productKey: 'test-sync-manual', + version: '1.0.0', + name: '사람이 검수한 테스트 상품', + reviewer: 'tester', + nextReviewAt: '2099-01-01', + ...overrides, + }; +} + +afterAll(() => { + for (const dir of CREATED_DIRS) rmSync(dir, { recursive: true, force: true }); +}); + +describe('상품 버전 재사용 규칙', () => { + it('날짜와 근거 checksum만 다른 상품은 같은 내용으로 본다', () => { + const first = collected('fund-a', '2026-09-20'); + const later = collected('fund-a', '2026-09-21', { + evidence: [ + { ...evidence('identity', 'IDENTITY', '2026-09-21'), checksum: 'b'.repeat(64) }, + evidence('period', 'APPLICATION_PERIOD', '2026-09-21'), + evidence('stage', 'BUSINESS_STAGE', '2026-09-21'), + evidence('region', 'REGION', '2026-09-21'), + evidence('purpose', 'PURPOSE', '2026-09-21'), + evidence('financial', 'FINANCIAL_CONDITION', '2026-09-21'), + ], + }); + expect(productContentFingerprint({ ...first, reviewer: AUTO_REVIEWER })).toBe( + productContentFingerprint({ ...later, reviewer: AUTO_REVIEWER }), + ); + const changed = collected('fund-a', '2026-09-20', { publicLimit: '50000000' }); + expect(productContentFingerprint({ ...changed, reviewer: AUTO_REVIEWER })).not.toBe( + productContentFingerprint({ ...first, reviewer: AUTO_REVIEWER }), + ); + }); + + it('가장 높은 버전의 PATCH를 올린다', () => { + expect(nextPatchVersion([])).toBe('1.0.0'); + expect(nextPatchVersion(['1.0.0', '1.0.2', '1.0.1'])).toBe('1.0.3'); + expect(nextPatchVersion(['1.0.9', '1.1.0'])).toBe('1.1.1'); + }); +}); + +describe.skipIf(!process.env.TEST_DATABASE_URL)('funding:sync와 PostgreSQL', () => { + let client: PrismaClient | null = null; + let startedAt = new Date(0); + let previousActiveId: string | null = null; + + const db = (): PrismaClient => { + if (!client) throw new Error('PrismaClient가 초기화되지 않았습니다.'); + return client; + }; + + const sync = (options: { + asOfDate: string; + result: () => FundingSourceCollection; + catalogPath?: string; + dryRun?: boolean; + }) => + runFundingSync({ + prisma: db(), + catalogPath: options.catalogPath ?? manualCatalogFile(), + adapters: [adapter(options.result)], + asOfDate: options.asOfDate, + outputDir: tempDir(), + dryRun: options.dryRun, + now: () => new Date(`${options.asOfDate}T01:00:00.000Z`), + }); + + beforeAll(async () => { + client = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.TEST_DATABASE_URL }) }); + const stale = await db().fundingCatalogRelease.findMany({ + where: { catalogKey: { startsWith: 'test-' } }, + select: { id: true }, + }); + if (stale.length > 0) { + const staleIds = stale.map((release) => release.id); + await db().fundingCatalogProduct.deleteMany({ where: { catalogReleaseId: { in: staleIds } } }); + await db().fundingCatalogRelease.deleteMany({ where: { id: { in: staleIds } } }); + } + await db().fundingProductVersion.deleteMany({ where: { productKey: { startsWith: 'test-' } } }); + const baseline = await db().fundingCatalogRelease.findFirst({ + where: { catalogKey: { not: { startsWith: 'test-' } }, status: { in: ['ACTIVE', 'SUPERSEDED'] } }, + orderBy: { activatedAt: 'desc' }, + }); + if (baseline) { + await db().fundingCatalogRelease.updateMany({ where: { status: 'ACTIVE' }, data: { status: 'SUPERSEDED' } }); + await db().fundingCatalogRelease.update({ where: { id: baseline.id }, data: { status: 'ACTIVE' } }); + } + startedAt = new Date(); + previousActiveId = (await db().fundingCatalogRelease.findFirst({ where: { status: 'ACTIVE' } }))?.id ?? null; + }); + + afterAll(async () => { + const releases = await db().fundingCatalogRelease.findMany({ where: { createdAt: { gte: startedAt } } }); + for (const release of releases) await db().fundingCatalogRelease.delete({ where: { id: release.id } }); + await db().fundingProductVersion.deleteMany({ where: { productKey: { startsWith: 'test-' } } }); + if (previousActiveId) { + const previous = await db().fundingCatalogRelease.findUnique({ where: { id: previousActiveId } }); + if (previous && previous.status !== 'ACTIVE') { + await db().fundingCatalogRelease.updateMany({ where: { status: 'ACTIVE' }, data: { status: 'SUPERSEDED' } }); + await db().fundingCatalogRelease.update({ where: { id: previousActiveId }, data: { status: 'ACTIVE' } }); + } + } + await db().$disconnect(); + }); + + it('출처 상품을 사람이 관리하는 상품과 합쳐 ACTIVE로 적재한다', async () => { + const report = await sync({ + asOfDate: '2026-09-20', + result: () => collection([collected('fund-a', '2026-09-20'), collected('fund-b', '2026-09-20')]), + }); + expect(report.load?.outcome).toBe('ACTIVATED'); + // 사람이 관리하는 카탈로그의 버전(2026-09-20.1)과 겹치지 않게 번호를 올린다. + expect(report.catalogVersion).toBe('2026-09-20.2'); + expect(report.sources).toMatchObject([ + { source: 'SEMAS_OLS', status: 'SUCCEEDED', lastSucceededOn: '2026-09-20', fetchedCount: 10, productCount: 2 }, + ]); + + const active = await findActiveFundingCatalog(db()); + expect(active?.catalogKey).toBe('test-sync-catalog'); + expect(active?.schemaVersion).toBe(FUNDING_CATALOG_SCHEMA_VERSION); + expect(active?.catalog.products.map((product) => `${product.productKey}@${product.version}`)).toEqual([ + 'test-sync-manual@1.0.0', + 'test-sync-fund-a@1.0.0', + 'test-sync-fund-b@1.0.0', + ]); + expect(active?.catalog.products[1].reviewer).toBe(AUTO_REVIEWER); + expect(active?.automation.policyLoanPromotion).toBe(true); + expect(active?.automation.sourceSyncs).toMatchObject([ + { source: 'SEMAS_OLS', status: 'SUCCEEDED', lastSucceededOn: '2026-09-20', failureReason: null }, + ]); + }); + + it('내용이 같은 상품은 버전을 재사용하고, 바뀐 상품은 새 버전을 만든다', async () => { + const report = await sync({ + asOfDate: '2026-09-21', + result: () => + collection([collected('fund-a', '2026-09-21'), collected('fund-b', '2026-09-21', { publicLimit: '50000000' })]), + }); + expect(report.catalogVersion).toBe('2026-09-21.1'); + const active = await findActiveFundingCatalog(db()); + const byKey = new Map(active?.catalog.products.map((product) => [product.productKey, product])); + expect(byKey.get('test-sync-fund-a')?.version).toBe('1.0.0'); + expect(byKey.get('test-sync-fund-a')?.observedAt).toBe('2026-09-20'); + expect(byKey.get('test-sync-fund-b')?.version).toBe('1.0.1'); + expect(byKey.get('test-sync-fund-b')?.publicLimit).toBe('50000000'); + expect(active?.automation.sourceSyncs[0].lastSucceededOn).toBe('2026-09-21'); + }); + + it('출처가 실패하면 직전 상품과 마지막 성공일을 그대로 두고 실패 사유를 남긴다', async () => { + const report = await sync({ + asOfDate: '2026-09-22', + result: () => { + throw new Error('출처 응답 403'); + }, + }); + expect(report.load?.outcome).toBe('ACTIVATED'); + expect(report.sources[0]).toMatchObject({ + status: 'FAILED', + lastSucceededOn: '2026-09-21', + fetchedCount: 10, + carriedOver: true, + productCount: 2, + }); + expect(report.sources[0].failureReason).toContain('출처 응답 403'); + const active = await findActiveFundingCatalog(db()); + expect(active?.catalog.products.map((product) => `${product.productKey}@${product.version}`)).toEqual([ + 'test-sync-manual@1.0.0', + 'test-sync-fund-a@1.0.0', + 'test-sync-fund-b@1.0.1', + ]); + expect(active?.automation.sourceSyncs[0]).toMatchObject({ status: 'FAILED', lastSucceededOn: '2026-09-21' }); + }); + + it('빈 응답과 직전 성공 대비 절반 미만의 공고 수는 실패로 기록한다', async () => { + const empty = await sync({ asOfDate: '2026-09-23', result: () => collection([], 0) }); + expect(empty.sources[0].status).toBe('FAILED'); + expect(empty.sources[0].failureReason).toContain('공고 없음'); + + const dropped = await sync({ + asOfDate: '2026-09-23', + result: () => collection([collected('fund-a', '2026-09-23')], 4), + }); + expect(dropped.catalogVersion).toBe('2026-09-23.2'); + expect(dropped.sources[0].status).toBe('FAILED'); + expect(dropped.sources[0].failureReason).toContain('절반 미만'); + expect(dropped.sources[0].productCount).toBe(2); + }); + + it('검증을 통과하지 못한 상품이 있으면 그 출처만 실패로 기록한다', async () => { + const report = await sync({ + asOfDate: '2026-09-24', + result: () => + collection([ + collected('fund-a', '2026-09-24'), + collected('fund-b', '2026-09-24', { applicationPeriod: { start: null, end: null, note: null } }), + ]), + }); + expect(report.load?.outcome).toBe('ACTIVATED'); + expect(report.sources[0].status).toBe('FAILED'); + expect(report.sources[0].failureReason).toContain('APPLICATION_PERIOD_NOTE_MISSING'); + expect(report.manualProductCount).toBe(1); + expect(report.automatedProductCount).toBe(2); + }); + + it('사람이 기록한 상품이 같은 공고를 가리키면 자동 상품을 뺀다', async () => { + const catalogPath = manualCatalogFile([ + manualProduct(), + manualProduct({ + productKey: 'test-sync-manual-fund-a', + sourceRef: { source: 'SEMAS_OLS', externalId: 'fund-a' }, + }), + ]); + const report = await sync({ + asOfDate: '2026-09-25', + catalogPath, + result: () => collection([collected('fund-a', '2026-09-25'), collected('fund-b', '2026-09-25')]), + }); + expect(report.sources[0].status).toBe('SUCCEEDED'); + expect(report.overriddenByManual).toEqual(['SEMAS_OLS:fund-a']); + const active = await findActiveFundingCatalog(db()); + expect(active?.catalog.products.map((product) => product.productKey)).toEqual([ + 'test-sync-manual', + 'test-sync-manual-fund-a', + 'test-sync-fund-b', + ]); + }); + + it('dry run은 합친 카탈로그를 쓰고 검증만 하며 적재하지 않는다', async () => { + const before = await db().fundingCatalogRelease.count(); + const report = await sync({ + asOfDate: '2026-09-26', + dryRun: true, + result: () => collection([collected('fund-a', '2026-09-26'), collected('fund-b', '2026-09-26')]), + }); + expect(report.load).toBeNull(); + expect(existsSync(report.catalogFile)).toBe(true); + expect(await db().fundingCatalogRelease.count()).toBe(before); + }); + + it('사람이 관리하는 카탈로그에 자동 검수자 상품이 있으면 거부한다', async () => { + const catalogPath = manualCatalogFile([ + manualProduct(), + manualProduct({ + productKey: 'test-sync-manual-auto', + reviewer: AUTO_REVIEWER, + nextReviewAt: null, + sourceRef: { source: 'SEMAS_OLS', externalId: 'fund-z' }, + }), + ]); + await expect( + sync({ asOfDate: '2026-09-27', catalogPath, result: () => collection([collected('fund-a', '2026-09-27')]) }), + ).rejects.toBeInstanceOf(FundingCatalogError); + }); +}); diff --git a/app/src/features/funding/sync.ts b/app/src/features/funding/sync.ts new file mode 100644 index 0000000..2c14515 --- /dev/null +++ b/app/src/features/funding/sync.ts @@ -0,0 +1,339 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { PrismaClient } from '../../generated/prisma/client.ts'; +import { canonicalJson } from './canonical.ts'; +import { loadFundingCatalog, readFundingCatalogFile, type FundingLoadReport } from './loader.ts'; +import { findActiveFundingCatalog } from './read.ts'; +import { + fundingProductSchema, + sourceRefKey, + type FundingCatalog, + type FundingProduct, + type FundingSourceRef, + type FundingSourceSync, +} from './schema.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION, isAutoReviewer, type FundingSource } from './types.ts'; +import { FundingCatalogError, validateFundingCatalog } from './validation.ts'; + +// 출처별 수집기가 만든 상품. 상품 버전과 검수자는 동기화가 정한다(ADR 0006 2절). +export type CollectedFundingProduct = Omit & { + sourceRef: FundingSourceRef; +}; + +export type FundingSourceCollection = { + products: CollectedFundingProduct[]; + // 출처에서 받은 공고·상품 수. 직전 성공 대비 급감 판정에 쓴다. + fetchedCount: number; + // 이번 수집에서 받은 응답 전체의 SHA-256. + responseChecksum: string; +}; + +export type FundingSourceAdapter = { + source: FundingSource; + collect(context: { asOfDate: string; log: (message: string) => void }): Promise; +}; + +export type FundingSyncOptions = { + prisma: PrismaClient; + // 사람이 관리하는 카탈로그(app/catalog/funding/catalog.json). + catalogPath: string; + adapters: readonly FundingSourceAdapter[]; + // 판정 기준일(한국 시간). 운영 명령은 서버의 한국 시간 날짜를 넘긴다. + asOfDate: string; + // 합친 카탈로그 파일을 쓰는 폴더. 적재한 릴리스의 원본으로 남는다. + outputDir: string; + // true면 합친 카탈로그를 쓰고 검증만 하며 적재하지 않는다. + dryRun?: boolean; + now?: () => Date; + log?: (message: string) => void; +}; + +export type FundingSourceSyncReport = FundingSourceSync & { + // 이번 릴리스에 들어간 이 출처의 자동 상품 수. + productCount: number; + // 실패해서 직전 ACTIVE 릴리스의 상품을 그대로 가져왔는지. + carriedOver: boolean; +}; + +export type FundingSyncReport = { + catalogFile: string; + catalogKey: string; + catalogVersion: string; + sources: FundingSourceSyncReport[]; + manualProductCount: number; + automatedProductCount: number; + // 사람이 기록한 상품이 같은 공고를 가리켜 빠진 자동 상품의 출처 식별자. + overriddenByManual: string[]; + // 적재 결과. dryRun이면 null이다. + load: FundingLoadReport | null; +}; + +class SourceSyncFailure extends Error {} + +// 날짜처럼 동기화마다 바뀌는 값을 뺀 상품 내용. 이 값이 같으면 이미 적재된 불변 상품 버전을 재사용해 +// 상품 버전이 날마다 늘지 않게 한다. +export function productContentFingerprint(product: Omit | FundingProduct): string { + const content: Record = { ...product }; + delete content.version; + delete content.observedAt; + delete content.reviewedAt; + delete content.nextReviewAt; + content.evidence = product.evidence.map((entry) => { + const stable: Record = { ...entry }; + delete stable.observedAt; + delete stable.checksum; + return stable; + }); + return canonicalJson(content); +} + +// 가장 높은 MAJOR.MINOR.PATCH의 PATCH를 하나 올린다. 버전이 없으면 1.0.0이다. +export function nextPatchVersion(versions: readonly string[]): string { + const parsed = versions + .map((version) => version.split('.').map((part) => Number(part))) + .filter((parts) => parts.length === 3 && parts.every((part) => Number.isInteger(part) && part >= 0)); + if (parsed.length === 0) return '1.0.0'; + parsed.sort((left, right) => left[0] - right[0] || left[1] - right[1] || left[2] - right[2]); + const [major, minor, patch] = parsed[parsed.length - 1]; + return `${major}.${minor}.${patch + 1}`; +} + +async function assignVersion(prisma: PrismaClient, product: Omit): Promise { + const stored = await prisma.fundingProductVersion.findMany({ + where: { productKey: product.productKey }, + select: { version: true, productJson: true }, + }); + const fingerprint = productContentFingerprint(product); + for (const row of stored) { + const parsed = fundingProductSchema.safeParse(row.productJson); + if (parsed.success && productContentFingerprint(parsed.data) === fingerprint) return parsed.data; + } + return fundingProductSchema.parse({ ...product, version: nextPatchVersion(stored.map((row) => row.version)) }); +} + +// 같은 날 여러 번 동기화해도 catalogKey + catalogVersion이 겹치지 않게 날짜 뒤 번호를 올린다. +// 사람이 관리하는 카탈로그의 버전도 피해서, 그 파일을 funding:load로 적재할 때 버전이 충돌하지 않게 한다. +async function nextCatalogVersion(prisma: PrismaClient, manual: FundingCatalog, asOfDate: string): Promise { + const releases = await prisma.fundingCatalogRelease.findMany({ + where: { catalogKey: manual.catalogKey, catalogVersion: { startsWith: `${asOfDate}.` } }, + select: { catalogVersion: true }, + }); + const used = [...releases.map((release) => release.catalogVersion), manual.catalogVersion] + .filter((version) => version.startsWith(`${asOfDate}.`)) + .map((version) => Number(version.slice(asOfDate.length + 1))) + .filter((value) => Number.isInteger(value)); + return `${asOfDate}.${Math.max(0, ...used) + 1}`; +} + +function describeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.slice(0, 2000); +} + +async function collectSource( + adapter: FundingSourceAdapter, + context: { + prisma: PrismaClient; + manual: FundingCatalog; + previousSync: FundingSourceSync | null; + asOfDate: string; + log: (message: string) => void; + }, +): Promise<{ products: FundingProduct[]; fetchedCount: number; responseChecksum: string }> { + const collection = await adapter.collect({ asOfDate: context.asOfDate, log: context.log }); + if (collection.fetchedCount === 0 || collection.products.length === 0) { + throw new SourceSyncFailure( + '출처에서 받은 공고가 없습니다. 빈 응답은 "공고 없음"으로 해석하지 않고 실패로 기록합니다.', + ); + } + const previousCount = context.previousSync?.fetchedCount ?? null; + if (previousCount !== null && collection.fetchedCount * 2 < previousCount) { + throw new SourceSyncFailure( + `받은 공고 수 ${collection.fetchedCount}건이 직전 성공 ${previousCount}건의 절반 미만입니다. 급감은 실패로 기록합니다.`, + ); + } + const manualKeys = new Set(context.manual.products.map((product) => product.productKey)); + const refs = new Set(); + for (const product of collection.products) { + if (product.sourceRef.source !== adapter.source) { + throw new SourceSyncFailure( + `${adapter.source} 수집기가 다른 출처(${product.sourceRef.source})의 상품을 만들었습니다.`, + ); + } + if (manualKeys.has(product.productKey)) { + throw new SourceSyncFailure(`자동 상품 키 ${product.productKey}가 사람이 기록한 상품 키와 겹칩니다.`); + } + const refKey = sourceRefKey(product.sourceRef); + if (refs.has(refKey)) throw new SourceSyncFailure(`같은 공고(${refKey})가 두 번 수집되었습니다.`); + refs.add(refKey); + } + + const products: FundingProduct[] = []; + for (const product of collection.products) { + products.push(await assignVersion(context.prisma, { ...product, reviewer: AUTO_REVIEWER })); + } + + // 수집한 상품만으로 카탈로그 검증을 먼저 돌린다. 파서가 필수 항목을 못 찾은 상품이 있으면 + // 그 출처만 실패로 기록하고, 사람이 관리하는 상품과 다른 출처는 그대로 적재한다. + const validation = validateFundingCatalog( + { + catalogKey: context.manual.catalogKey, + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + catalogVersion: `${context.asOfDate}.1`, + basisDate: context.asOfDate, + reviewer: context.manual.reviewer, + notes: [], + automation: context.manual.automation, + products, + }, + { asOfDate: context.asOfDate }, + ); + if (!validation.ok) { + const issues = validation.issues + .slice(0, 5) + .map((issue) => `[${issue.code}] ${issue.source}: ${issue.detail}`) + .join(' / '); + throw new SourceSyncFailure(`수집한 상품이 카탈로그 검증을 통과하지 못했습니다: ${issues}`); + } + return { products, fetchedCount: collection.fetchedCount, responseChecksum: collection.responseChecksum }; +} + +// 출처별로 공고를 모아 사람이 관리하는 카탈로그와 합치고, 기존 적재기로 새 릴리스를 적재한다(ADR 0006). +// 한 출처가 실패하면 그 출처의 상품은 직전 ACTIVE 릴리스의 것을 그대로 가져오고 마지막 성공일도 +// 그대로 둔다. 그 상품은 마지막 성공일에서 SOURCE_FRESHNESS_DAYS가 지나면 판정에서 현재 후보가 아니다. +export async function runFundingSync(options: FundingSyncOptions): Promise { + const log = options.log ?? (() => {}); + const now = options.now ?? (() => new Date()); + const { prisma, asOfDate } = options; + + const manual = readFundingCatalogFile(options.catalogPath, asOfDate).catalog; + const automatedInManual = manual.products.filter((product) => isAutoReviewer(product.reviewer)); + if (automatedInManual.length > 0) { + throw new FundingCatalogError( + 'MANUAL_CATALOG_HAS_AUTOMATED_PRODUCT', + `사람이 관리하는 카탈로그에 자동 검수자 상품이 있습니다: ${automatedInManual.map((product) => product.productKey).join(', ')}. 자동 상품은 funding:sync만 만듭니다.`, + ); + } + const seenSources = new Set(); + for (const adapter of options.adapters) { + if (seenSources.has(adapter.source)) { + throw new FundingCatalogError( + 'DUPLICATE_SOURCE_ADAPTER', + `출처 ${adapter.source}의 수집기가 두 번 등록되었습니다.`, + ); + } + seenSources.add(adapter.source); + } + + const previous = await findActiveFundingCatalog(prisma); + const sources: FundingSourceSyncReport[] = []; + const automatedProducts: FundingProduct[] = []; + for (const adapter of options.adapters) { + const previousSync = previous?.automation.sourceSyncs.find((entry) => entry.source === adapter.source) ?? null; + const attemptedAt = now().toISOString(); + try { + const collected = await collectSource(adapter, { prisma, manual, previousSync, asOfDate, log }); + automatedProducts.push(...collected.products); + sources.push({ + source: adapter.source, + status: 'SUCCEEDED', + attemptedAt, + lastSucceededOn: asOfDate, + fetchedCount: collected.fetchedCount, + responseChecksum: collected.responseChecksum, + failureReason: null, + productCount: collected.products.length, + carriedOver: false, + }); + log( + `출처 ${adapter.source}: 공고 ${collected.fetchedCount}건을 받아 상품 ${collected.products.length}건을 만들었습니다.`, + ); + } catch (error) { + const carried = (previous?.catalog.products ?? []).filter( + (product) => isAutoReviewer(product.reviewer) && product.sourceRef?.source === adapter.source, + ); + automatedProducts.push(...carried); + sources.push({ + source: adapter.source, + status: 'FAILED', + attemptedAt, + lastSucceededOn: previousSync?.lastSucceededOn ?? null, + fetchedCount: previousSync?.fetchedCount ?? null, + responseChecksum: previousSync?.responseChecksum ?? null, + failureReason: describeError(error), + productCount: carried.length, + carriedOver: true, + }); + log( + `출처 ${adapter.source} 동기화 실패: ${describeError(error)} 직전 ACTIVE 릴리스의 상품 ${carried.length}건을 그대로 둡니다.`, + ); + } + } + + // 사람이 기록한 상품이 같은 공고를 가리키면 사람 기록이 우선한다. + const manualRefs = new Set( + manual.products.flatMap((product) => (product.sourceRef === undefined ? [] : [sourceRefKey(product.sourceRef)])), + ); + const overriddenByManual: string[] = []; + const keptAutomated = automatedProducts + .filter((product) => { + const refKey = product.sourceRef === undefined ? null : sourceRefKey(product.sourceRef); + if (refKey !== null && manualRefs.has(refKey)) { + overriddenByManual.push(refKey); + return false; + } + return true; + }) + .sort((left, right) => (left.productKey < right.productKey ? -1 : left.productKey > right.productKey ? 1 : 0)); + + const catalogVersion = await nextCatalogVersion(prisma, manual, asOfDate); + const composed: FundingCatalog = { + catalogKey: manual.catalogKey, + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + catalogVersion, + basisDate: asOfDate, + reviewer: manual.reviewer, + notes: [ + ...manual.notes, + `funding:sync가 ${asOfDate}에 사람이 관리하는 카탈로그 ${manual.catalogVersion}과 자동 상품 ${keptAutomated.length}건을 합쳤습니다.`, + ], + automation: { + ...manual.automation, + sourceSyncs: sources.map((entry) => ({ + source: entry.source, + status: entry.status, + attemptedAt: entry.attemptedAt, + lastSucceededOn: entry.lastSucceededOn, + fetchedCount: entry.fetchedCount, + responseChecksum: entry.responseChecksum, + failureReason: entry.failureReason, + })), + }, + products: [...manual.products, ...keptAutomated], + }; + + mkdirSync(options.outputDir, { recursive: true }); + const catalogFile = join(options.outputDir, `${composed.catalogKey}@${catalogVersion}.json`); + writeFileSync(catalogFile, `${JSON.stringify(composed, null, 2)}\n`, 'utf8'); + log(`합친 카탈로그를 썼습니다: ${catalogFile}`); + + let load: FundingLoadReport | null = null; + if (options.dryRun) { + // 적재와 같은 검사만 돌린다. 통과하지 못하면 FundingCatalogError를 던진다. + readFundingCatalogFile(catalogFile, asOfDate); + log('dry run: 검증만 하고 적재하지 않았습니다.'); + } else { + load = await loadFundingCatalog({ catalogPath: catalogFile, prisma, asOfDate, log }); + } + + return { + catalogFile, + catalogKey: composed.catalogKey, + catalogVersion, + sources, + manualProductCount: manual.products.length, + automatedProductCount: keptAutomated.length, + overriddenByManual, + load, + }; +} diff --git a/app/src/features/funding/types.ts b/app/src/features/funding/types.ts index ee1783a..5af37ed 100644 --- a/app/src/features/funding/types.ts +++ b/app/src/features/funding/types.ts @@ -2,13 +2,41 @@ // stored as-is in the JSON catalog and mirrored by the Prisma enums, so a name // change here is a catalog schema change too. -export const FUNDING_CATALOG_SCHEMA_VERSION = 'funding-catalog-v1.0.0'; +export const FUNDING_CATALOG_SCHEMA_VERSION = 'funding-catalog-v1.1.0'; + +// 이미 적재된 릴리스를 읽을 수 있는 스키마 버전. v1.1.0은 v1.0.0에 출처 식별자와 +// 자동화 설정을 더했을 뿐이라 v1.0.0 릴리스도 그대로 읽는다. +export const READABLE_FUNDING_CATALOG_SCHEMA_VERSIONS: readonly string[] = [ + 'funding-catalog-v1.0.0', + FUNDING_CATALOG_SCHEMA_VERSION, +]; // A product whose reviewer is still unassigned is kept in the catalog but the // operator commands warn: a real person must own the next review before the // catalog drives anything user-facing. export const REVIEWER_UNASSIGNED = 'UNASSIGNED'; +// funding:sync가 공식 출처에서 모은 상품의 검수자. 사람 대신 자동 규칙 버전을 적고, +// 규칙을 바꾸면 버전을 올린다(ADR 0006). +export const AUTO_REVIEWER_PREFIX = 'AUTO:'; +export const FUNDING_AUTOMATION_RULES_VERSION = 'funding-auto-v1'; +export const AUTO_REVIEWER = `${AUTO_REVIEWER_PREFIX}${FUNDING_AUTOMATION_RULES_VERSION}`; + +export function isAutoReviewer(reviewer: string): boolean { + return reviewer.startsWith(AUTO_REVIEWER_PREFIX); +} + +// funding:sync가 상품을 모으는 공식 출처. +export const FUNDING_SOURCES = ['SEMAS_OLS'] as const; +export type FundingSource = (typeof FUNDING_SOURCES)[number]; + +export const FUNDING_SOURCE_LABELS: Record = { + SEMAS_OLS: '소상공인시장진흥공단 정책자금 사이트', +}; + +// 출처의 마지막 성공 동기화가 이 일수를 넘기면 그 출처의 자동 상품은 현재 후보에서 빠진다. +export const SOURCE_FRESHNESS_DAYS = 3; + export const SUPPORT_TYPES = ['GRANT', 'GUARANTEE', 'LOAN', 'SPACE', 'PROGRAM'] as const; export type SupportType = (typeof SUPPORT_TYPES)[number]; @@ -16,6 +44,10 @@ export type SupportType = (typeof SUPPORT_TYPES)[number]; // repay the same way, so only LOAN products can ever reach repayment math. export const REPAYABLE_SUPPORT_TYPES: readonly SupportType[] = ['LOAN']; +// 정책자금 갈래(대출·보증). ADR 0006에 따라 자동 승격은 이 갈래에만 쓴다. 지원금·공간· +// 프로그램(지원사업)은 자동으로 모아도 사람이 확인해야 현재 후보가 된다. +export const POLICY_LOAN_SUPPORT_TYPES: readonly SupportType[] = ['LOAN', 'GUARANTEE']; + export const BUSINESS_STAGES = ['PRE_REGISTRATION', 'POST_REGISTRATION'] as const; export type BusinessStage = (typeof BUSINESS_STAGES)[number]; @@ -154,14 +186,44 @@ export function compareIsoDates(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } +export function addDaysToIsoDate(isoDate: string, days: number): string { + const [year, month, day] = isoDate.split('-').map((part) => Number(part)); + return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10); +} + export type ReviewState = 'CURRENT' | 'UNREVIEWED' | 'REVIEW_OVERDUE'; +// 출처별 마지막 성공 동기화일. 자동 상품의 최신성은 사람이 정한 다음 검토일 대신 이 날짜로 판단한다. +export type SourceFreshness = { source: FundingSource; lastSucceededOn: string | null }; + +// 자동 상품은 출처의 마지막 성공 동기화가 SOURCE_FRESHNESS_DAYS 안이어야 최신이다. +// 동기화 기록이 없거나 성공한 적이 없으면 최신이 아니다. +export function isSourceFresh( + source: FundingSource, + sourceSyncs: readonly SourceFreshness[], + asOfDate: string, +): boolean { + const sync = sourceSyncs.find((entry) => entry.source === source); + if (!sync || sync.lastSucceededOn === null) return false; + return compareIsoDates(addDaysToIsoDate(sync.lastSucceededOn, SOURCE_FRESHNESS_DAYS), asOfDate) >= 0; +} + // A product with no review date at all is treated as overdue: it can never be -// presented as currently applicable. +// presented as currently applicable. 자동 상품은 다음 검토일 대신 출처 최신성으로 판단한다. export function reviewState( - product: { reviewer: string; reviewedAt: string | null; nextReviewAt: string | null }, + product: { + reviewer: string; + reviewedAt: string | null; + nextReviewAt: string | null; + sourceRef?: { source: FundingSource } | undefined; + }, asOfDate: string, + sourceSyncs: readonly SourceFreshness[] = [], ): ReviewState { + if (isAutoReviewer(product.reviewer)) { + if (product.reviewedAt === null || product.sourceRef === undefined) return 'REVIEW_OVERDUE'; + return isSourceFresh(product.sourceRef.source, sourceSyncs, asOfDate) ? 'CURRENT' : 'REVIEW_OVERDUE'; + } if (product.reviewedAt === null || product.nextReviewAt === null) return 'REVIEW_OVERDUE'; if (compareIsoDates(product.nextReviewAt, asOfDate) < 0) return 'REVIEW_OVERDUE'; return product.reviewer === REVIEWER_UNASSIGNED ? 'UNREVIEWED' : 'CURRENT'; diff --git a/app/src/features/funding/validation.ts b/app/src/features/funding/validation.ts index 58a0de3..2e3afe4 100644 --- a/app/src/features/funding/validation.ts +++ b/app/src/features/funding/validation.ts @@ -3,13 +3,22 @@ import { REPAYABLE_SUPPORT_TYPES, REVIEWER_UNASSIGNED, REQUIRED_EVIDENCE_SUBJECTS, + SOURCE_FRESHNESS_DAYS, compareIsoDates, + isAutoReviewer, isSupportedRepaymentMethod, isPrimaryRetrievalMethod, reviewState, type EvidenceSubject, + type SourceFreshness, } from './types.ts'; -import { fundingCatalogSchema, productVersionKey, type FundingCatalog, type FundingProduct } from './schema.ts'; +import { + fundingCatalogSchema, + productVersionKey, + sourceRefKey, + type FundingCatalog, + type FundingProduct, +} from './schema.ts'; export type FundingIssueCode = | 'SCHEMA_INVALID' @@ -34,6 +43,9 @@ export type FundingIssueCode = | 'REVIEW_PERIOD_CONTRADICTION' | 'REVIEW_OVERDUE' | 'REVIEWER_UNASSIGNED' + | 'AUTO_REVIEWER_WITHOUT_SOURCE' + | 'DUPLICATE_SOURCE_REF' + | 'DUPLICATE_SOURCE_SYNC' | 'REGION_DISTRICT_CONTRADICTION' | 'REGION_DISTRICTS_MISSING' | 'INDUSTRY_SCOPE_CONTRADICTION' @@ -100,6 +112,9 @@ const CATEGORY_BY_CODE: Record = { REVIEW_PERIOD_CONTRADICTION: 'review', REVIEW_OVERDUE: 'review', REVIEWER_UNASSIGNED: 'review', + AUTO_REVIEWER_WITHOUT_SOURCE: 'review', + DUPLICATE_SOURCE_REF: 'duplicates', + DUPLICATE_SOURCE_SYNC: 'duplicates', REGION_DISTRICT_CONTRADICTION: 'support', REGION_DISTRICTS_MISSING: 'support', INDUSTRY_SCOPE_CONTRADICTION: 'support', @@ -166,8 +181,14 @@ function overlap(left: readonly string[], right: readonly string[]): string[] { return left.filter((value) => right.includes(value)); } -function checkProduct(product: FundingProduct, basisDate: string, collector: Collector): void { +function checkProduct( + product: FundingProduct, + basisDate: string, + sourceSyncs: readonly SourceFreshness[], + collector: Collector, +): void { const source = productVersionKey(product); + const automated = isAutoReviewer(product.reviewer); const { applicationPeriod, evidence } = product; // 근거: 근거 id 중복, 필수 근거 주제 누락, 근거 관측일. @@ -320,13 +341,18 @@ function checkProduct(product: FundingProduct, basisDate: string, collector: Col ); } } - if (product.nextReviewAt === null) { + // 자동 상품은 다음 검토일 대신 출처의 마지막 성공 동기화로 최신성을 판단한다. + if (product.nextReviewAt === null && !automated) { collector.add( 'NEXT_REVIEW_AT_MISSING', source, 'nextReviewAt이 없습니다. 다음 검토일 없는 상품은 현재 후보로 취급하지 않습니다.', ); - } else if (product.reviewedAt !== null && compareIsoDates(product.nextReviewAt, product.reviewedAt) < 0) { + } else if ( + product.nextReviewAt !== null && + product.reviewedAt !== null && + compareIsoDates(product.nextReviewAt, product.reviewedAt) < 0 + ) { collector.add( 'REVIEW_PERIOD_CONTRADICTION', source, @@ -488,11 +514,20 @@ function checkProduct(product: FundingProduct, basisDate: string, collector: Col '검수자가 지정되지 않았습니다(UNASSIGNED). 운영 공개 전에 실제 검수자를 지정해야 합니다.', ); } - if (reviewState(product, basisDate) === 'REVIEW_OVERDUE') { + if (automated && product.sourceRef === undefined) { + collector.add( + 'AUTO_REVIEWER_WITHOUT_SOURCE', + source, + `자동 검수자(${product.reviewer})는 funding:sync가 모은 상품에만 씁니다. 출처 식별자(sourceRef)가 없습니다.`, + ); + } + if (reviewState(product, basisDate, sourceSyncs) === 'REVIEW_OVERDUE') { collector.warn( 'REVIEW_OVERDUE', source, - `다음 검토일 ${product.nextReviewAt ?? '없음'}이(가) 기준일 ${basisDate} 이전입니다. 현재 후보에서 제외하고 재확인 대상으로만 남깁니다.`, + automated + ? `출처의 마지막 성공 동기화가 기준일 ${basisDate}보다 ${SOURCE_FRESHNESS_DAYS}일 넘게 지났거나 없습니다. 현재 후보에서 제외하고 재확인 대상으로만 남깁니다.` + : `다음 검토일 ${product.nextReviewAt ?? '없음'}이(가) 기준일 ${basisDate} 이전입니다. 현재 후보에서 제외하고 재확인 대상으로만 남깁니다.`, ); } } @@ -525,8 +560,17 @@ export function validateFundingCatalog(raw: unknown, options: { asOfDate: string } const catalog = parsed.data; + const { sourceSyncs } = catalog.automation; const seen = new Set(); + const seenSourceRefs = new Set(); const reviewOverdue: string[] = []; + const syncedSources = new Set(); + for (const sync of sourceSyncs) { + if (syncedSources.has(sync.source)) { + collector.add('DUPLICATE_SOURCE_SYNC', 'catalog', `출처 ${sync.source}의 동기화 결과가 두 번 있습니다.`); + } + syncedSources.add(sync.source); + } for (const product of catalog.products) { const key = productVersionKey(product); if (seen.has(key)) { @@ -538,8 +582,19 @@ export function validateFundingCatalog(raw: unknown, options: { asOfDate: string continue; } seen.add(key); - checkProduct(product, catalog.basisDate, collector); - if (reviewState(product, options.asOfDate) === 'REVIEW_OVERDUE') reviewOverdue.push(key); + if (product.sourceRef !== undefined) { + const refKey = sourceRefKey(product.sourceRef); + if (seenSourceRefs.has(refKey)) { + collector.add( + 'DUPLICATE_SOURCE_REF', + key, + `같은 출처 공고(${refKey})를 가리키는 상품이 두 개 있습니다. 한 공고는 한 상품으로만 기록합니다.`, + ); + } + seenSourceRefs.add(refKey); + } + checkProduct(product, catalog.basisDate, sourceSyncs, collector); + if (reviewState(product, options.asOfDate, sourceSyncs) === 'REVIEW_OVERDUE') reviewOverdue.push(key); } if (catalog.reviewer === REVIEWER_UNASSIGNED) { collector.warn('REVIEWER_UNASSIGNED', 'catalog', '카탈로그 검수자가 지정되지 않았습니다(UNASSIGNED).'); diff --git a/app/src/features/plans/funding-matches.test.ts b/app/src/features/plans/funding-matches.test.ts index d4bb33b..5f4829c 100644 --- a/app/src/features/plans/funding-matches.test.ts +++ b/app/src/features/plans/funding-matches.test.ts @@ -120,6 +120,7 @@ function releaseRow(products: Membership[]) { reviewedAt: new Date(TEST_DATE + 'T00:00:00.000Z'), activatedAt: new Date('2026-09-20T03:00:00.000Z'), reviewer: 'test-reviewer', + automation: null, products, }; } diff --git a/app/src/features/plans/loan-assumption.test.ts b/app/src/features/plans/loan-assumption.test.ts index 8a60297..89f6f0c 100644 --- a/app/src/features/plans/loan-assumption.test.ts +++ b/app/src/features/plans/loan-assumption.test.ts @@ -21,7 +21,8 @@ import type { FinanceInput } from '@/features/finance/types'; import { assessProductRepayment } from '@/features/funding/eligibility.ts'; import { loadFundingCatalog } from '@/features/funding/loader.ts'; import { fundingProductSchema } from '@/features/funding/schema.ts'; -import { FUNDING_CATALOG_SCHEMA_VERSION } from '@/features/funding/types.ts'; +import { todayInKst } from '@/features/funding/dates.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION } from '@/features/funding/types.ts'; import { loanAssumptionFromProduct, planLoanAssumptionSchema, @@ -156,7 +157,7 @@ function membership(product: Record, position: number): Members return { position, productVersion: { productJson: product } }; } -function releaseRow(products: Membership[]) { +function releaseRow(products: Membership[], automation: unknown = null) { return { id: 'release-1', catalogKey: 'test-catalog', @@ -167,6 +168,7 @@ function releaseRow(products: Membership[]) { activatedAt: new Date('2026-09-20T03:00:00.000Z'), reviewer: 'test-reviewer', productCount: products.length, + automation, products, }; } @@ -346,13 +348,16 @@ describe('POST /api/plans/{planId}/loan-assumption 경계', () => { updateCount?: number; conflictRevision?: number; updated?: Record; + automation?: unknown; }) { const findPlan = vi.fn(); findPlan.mockResolvedValueOnce(options.plan ?? null); if (options.conflictRevision !== undefined) findPlan.mockResolvedValueOnce({ revision: options.conflictRevision }); const updateMany = vi.fn().mockResolvedValue({ count: options.updateCount ?? 1 }); const findUniqueOrThrow = vi.fn().mockResolvedValue(options.updated ?? { id: 'plan-a', revision: 2 }); - const findRelease = vi.fn().mockResolvedValue(options.products ? releaseRow(options.products) : null); + const findRelease = vi + .fn() + .mockResolvedValue(options.products ? releaseRow(options.products, options.automation ?? null) : null); return { prisma: { plan: { findFirst: findPlan, updateMany, findUniqueOrThrow }, @@ -442,6 +447,49 @@ describe('POST /api/plans/{planId}/loan-assumption 경계', () => { expect(grant.updateMany).not.toHaveBeenCalled(); }); + it('자동으로 모은 대출은 상환 계산 자동 승격이 켜져 있을 때만 적용한다', async () => { + const automated = [ + membership( + rawProduct({ + ...CONFIRMED_TERMS, + reviewer: AUTO_REVIEWER, + sourceRef: { source: 'SEMAS_OLS', externalId: 'test-fund' }, + nextReviewAt: null, + }), + 0, + ), + ]; + const automation = (repaymentPromotion: boolean) => ({ + policyLoanPromotion: true, + repaymentPromotion, + blockedSources: [], + sourceSyncs: [ + { + source: 'SEMAS_OLS', + status: 'SUCCEEDED', + attemptedAt: new Date().toISOString(), + lastSucceededOn: todayInKst(), + fetchedCount: 1, + responseChecksum: null, + failureReason: null, + }, + ], + }); + + const off = stubApply({ plan: planRow, products: automated, automation: automation(false) }); + mocks.getPrisma.mockReturnValue(off.prisma); + const rejected = await applyLoanAssumption(applyRequest(body), context); + expect(rejected.status).toBe(400); + expect((await rejected.json()).error.message).toContain('상환 계산 자동 승격'); + expect(off.updateMany).not.toHaveBeenCalled(); + + const on = stubApply({ plan: planRow, products: automated, automation: automation(true) }); + mocks.getPrisma.mockReturnValue(on.prisma); + const applied = await applyLoanAssumption(applyRequest(body), context); + expect(applied.status).toBe(200); + expect(on.updateMany).toHaveBeenCalledTimes(1); + }); + it('상환 조건이 확정돼도 현재 계획의 CURRENT_CANDIDATE가 아니면 적용을 거부한다', async () => { const closed = stubApply({ plan: planRow, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 19b73a9..577f8b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,7 +33,7 @@ Operator load commands (*:load) | 임대료 참고 | `app/src/features/rent-benchmark` | rent-benchmarks API, `/`의 임대료 참고 패널 | `rent-benchmark.test.ts`, `rent-benchmark.spec.ts` | [rent-benchmark.md](domains/rent-benchmark.md) | | 프랜차이즈 참고 | `app/src/features/franchise` | franchises API, `/`의 프랜차이즈 참고 패널 | `franchise.test.ts`, `franchise.spec.ts` | [franchise.md](domains/franchise.md) | | 사업 조건 | `app/src/features/business-profile` | 계획 business-profile API, 입력 플로우 | `business-profile.test.ts`, `form.test.ts` | [business-profile.md](domains/business-profile.md) | -| 자금 카탈로그·후보 | `app/src/features/funding`, `app/catalog/funding` | `/funding`, candidates API | `funding.test.ts`, `candidates.test.ts` | [funding.md](domains/funding.md) | +| 자금 카탈로그·후보 | `app/src/features/funding`, `app/catalog/funding` | `/funding`, candidates API, `funding:sync` | `funding.test.ts`, `candidates.test.ts`, `sync.test.ts` | [funding.md](domains/funding.md) | | 인증·요청 보안 | `app/src/lib/auth.ts`, `session.ts`, `request-security.ts`, `rate-limit.ts` | `/api/auth/*`, 업무 API 공통 경계 | `request-security.test.ts`, 계획 API 테스트 | [security.md](domains/security.md) | ## 그 밖의 위치 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e6256a2..abc4325 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -43,6 +43,8 @@ npm --prefix app run dev | 공정위 프랜차이즈 | `SEMAS_SERVICE_KEY`(공공데이터포털 인증키)로 [가맹점 현황](https://www.data.go.kr/data/15110241/openapi.do)·[창업 금액](https://www.data.go.kr/data/15110265/openapi.do)을 활용신청한다. 약 20~30초 걸린다 | `npm --prefix app run franchise:load` | | 자금 카탈로그 | 준비 없음. 원본은 `app/catalog/funding/catalog.json`이다 | `npm --prefix app run funding:load` | +자동 수집 출처와 합쳐 자금 카탈로그를 적재하려면 `npm --prefix app run funding:sync`를 실행한다. `-- --dry-run`을 붙이면 합친 카탈로그를 쓰고 검증만 한다. 합친 카탈로그는 `data/funding-sync/`(Git 제외)에 남는다. 규칙은 [funding.md](domains/funding.md#동기화)에 있다. + 2026-09-09 검증본 ZIP으로 적재하려면 [verification/README.md](verification/README.md)의 파일 5개를 `data/raw/`(Git 제외)에 두고 `npm --prefix app run market:load -- --source-dir ../data/raw`를 실행한다. `data/raw/`가 없으면 원본 대조 테스트 6개가 건너뛰어진다. ## 명령 diff --git a/docs/domains/funding.md b/docs/domains/funding.md index 55e5d3f..2babbe8 100644 --- a/docs/domains/funding.md +++ b/docs/domains/funding.md @@ -7,6 +7,7 @@ - `app/src/features/funding`: 판정 함수는 React·DB·외부 API에 의존하지 않는다. - 카탈로그 Zod 스키마(`schema.ts`), 규칙 검사(`validation.ts`), 고정 조건 판정(`eligibility.ts`) - 릴리스·상품 버전 적재, 활성 카탈로그 읽기(`read.ts`), 요청 스키마·응답 조립(`candidates.ts`) + - 출처별 수집 결과를 사람이 관리하는 카탈로그와 합쳐 적재하는 동기화(`sync.ts`) - 페이지 단위 후보 화면(`FundingMatcher.tsx`), 두 화면이 함께 쓰는 결과 표시(`FundingCandidatesPanel.tsx`) - `app/catalog/funding`: 운영자가 공식 공고를 정리한 JSON 카탈로그와 갱신 절차 문서. @@ -26,6 +27,10 @@ - 다음은 오류가 아니라 주의로 기록한다: 검수 기한 경과, 검수자 미지정(UNASSIGNED), 공식 원문이 아닌 요약 근거, 신청기간 종료일 전의 CLOSED 기록(조기 마감이면 근거를 추가한다). - 검수 기능이 생기기 전까지 `funding:load`는 검수자가 UNASSIGNED여도 적재한다. 그런 상품은 판정에서 현재 후보가 되지 않고 추가 확인으로 분류된다. - 검증을 우회하는 명령행 옵션은 없다. 조건이 불명확한 상품을 강제로 활성화하는 경로를 만들지 않는다. +- 스키마 `funding-catalog-v1.1.0`은 v1.0.0에 다음을 더했다. v1.0.0으로 적재된 릴리스도 읽으며, 그때 자동화 설정은 모두 꺼진 기본값이다. + - 상품의 `sourceRef`(출처와 공고 식별자). funding:sync가 모은 상품에 붙고, 사람이 기록한 상품도 같은 공고를 가리키면 적는다. + - 카탈로그의 `automation`: 정책자금 자동 승격(`policyLoanPromotion`)·상환 계산 자동 승격(`repaymentPromotion`) 스위치, 차단 목록(`blockedSources`), 출처별 동기화 결과(`sourceSyncs`). 스위치는 기본으로 꺼져 있다. +- 자동 상품은 검수자에 사람 대신 자동 규칙 버전(`AUTO:funding-auto-v1`)을 적는다. 규칙 검사는 다음을 오류로 본다: 출처 식별자 없는 자동 검수자, 같은 공고를 가리키는 상품 두 개, 같은 출처의 동기화 결과 두 개. 자동 상품에는 다음 검토일이 없어도 된다. ### 릴리스와 상품 버전 @@ -36,6 +41,30 @@ - 릴리스와 상품 버전은 `funding_catalog_products` 연결 테이블로 묶어, 같은 불변 버전을 여러 릴리스가 재사용할 수 있다. - 적재는 PENDING으로 시작해 적재 후 검증까지 통과한 뒤 한 트랜잭션으로 ACTIVE가 된다. 활성화는 상태 전환만 하며 과거 릴리스와 상품 버전을 지우지 않는다. - 실패하면 이번 적재가 만든 상품 버전만 삭제하고, 이전에 이미 저장된 상품 버전은 그대로 둔다. 릴리스는 `FAILED`와 실패 사유(`오류 코드 + 메시지`)로 남는다. +- 릴리스의 `automation`(JSONB)에 자동화 설정과 출처별 동기화 결과를 저장한다. v1.0.0 릴리스는 NULL이다. + +### 동기화 + +[ADR 0006](../decisions/0006-funding-discovery-automation.md)의 동기화다. 실행 명령은 [DEVELOPMENT.md](../DEVELOPMENT.md#로컬-데이터-준비)에 있다. + +- 흐름: 출처별 수집기(`FundingSourceAdapter`)로 공고를 받아 상품을 만들고, 사람이 관리하는 `catalog.json`의 상품과 합친 뒤, 위 적재기와 같은 검사로 `PENDING → ACTIVE` 적재한다. +- 합친 카탈로그: + - catalogKey와 검수자·스위치·차단 목록은 `catalog.json`을 따른다. 기준일은 동기화한 날(한국 시간)이다. + - catalogVersion은 `동기화한 날.번호`다. 같은 날 적재된 릴리스와 `catalog.json`의 버전을 피해 번호를 올린다. + - 적재한 카탈로그 파일은 `data/funding-sync/`(Git 제외)에 남는다. +- 상품 버전: 날짜와 근거 checksum을 뺀 내용이 이미 적재된 버전과 같으면 그 버전을 재사용한다. 다르면 가장 높은 버전의 PATCH를 올린다. +- 출처가 실패하면 그 출처의 상품은 직전 ACTIVE 릴리스의 것을 그대로 두고, 마지막 성공일(`lastSucceededOn`)과 공고 수도 그대로 둔다. 다음은 실패다. + - 수집기 오류(장애·인증 오류·차단 등) + - 빈 응답. "공고 없음"으로 해석하지 않는다 + - 받은 공고 수가 직전 성공의 절반 미만 + - 수집한 상품이 카탈로그 규칙 검사를 통과하지 못함, 다른 출처의 상품, 같은 공고 두 번, 사람이 기록한 상품 키와 겹침 +- 사람이 기록한 상품이 같은 공고(`sourceRef`)를 가리키면 자동 상품을 빼고 사람 기록을 쓴다. +- `catalog.json`에 자동 검수자 상품이 있으면 거부한다. 자동 상품은 funding:sync만 만든다. +- 수집 출처와 허용 확인 기록. 출처는 robots.txt와 약관을 확인한 뒤에만 수집기를 등록한다(ADR 0006 3절). + +| 출처 | 대상 | 확인일 | 확인 결과 | 수집기 | +|---|---|---|---|---| +| `SEMAS_OLS` | 소진공 정책자금 사이트 `ols.semas.or.kr` | 2026-09-28 | robots.txt 없음(404). 약관에 자동 수집 금지 조항 없음. 담당자가 출처 표시 조건으로 수집 허용 | 아직 등록하지 않음 | ### 판정 @@ -49,6 +78,10 @@ - 신청 종료일이 판정 기준일보다 앞서면 관측 접수 상태가 OPEN·UNKNOWN이어도 접수 종료(CLOSED)로 판정한다. 종료일 당일까지는 접수 중으로 보고, 종료일이 없으면(예: 예산 소진 시까지) 관측 접수 상태를 따른다. - 검수 기한이 지난 상품, 검수일 또는 검수자가 없는 상품, 검색 결과 요약으로만 확인한 상품은 현재 후보로 확정하지 않는다. - 사업자등록 이후에만 검토할 수 있는 상품은 POST_REGISTRATION 상태로 현재 예비 창업자 후보와 분리한다. +- 자동 상품(검수자 `AUTO:…`)의 추가 규칙(ADR 0006). 사람이 기록한 상품은 `sourceRef`가 있어도 지금 규칙만 따른다. + - 다음 검토일 대신 출처의 마지막 성공 동기화일로 최신성을 본다. 그날에서 3일이 지났거나 성공 기록이 없으면 검수 기한 경과(`REVIEW_OVERDUE`)다. + - 차단 목록의 공고, 지원사업(지원금·공간·프로그램) 자동 상품, 정책자금 자동 승격이 꺼진 상태의 자동 상품은 현재 후보가 아니라 추가 확인이며 사유를 표시한다. + - 상환 계산 자동 승격이 꺼져 있으면 자동 상품은 확정 조건이 있어도 상환 계산 대상이 아니다. - 지원 유형 표시: GRANT 지원금, GUARANTEE 보증, LOAN 대출, SPACE 공간·보육, PROGRAM 프로그램. - 지원금·보증·공간·프로그램 지원은 대출 원금이나 상환 일정으로 변환하지 않는다. - 대출의 상환 계산 대상 여부: @@ -86,7 +119,7 @@ ## 상품 확정 조건 → 대출 가정 적용 - 상품 확정 조건을 계획의 신규 대출 가정으로 옮기는 일은 사용자가 계획 화면에서 명시적으로 실행할 때만 일어난다. 후보 조회·판정·계획 불러오기·초안 저장 시점에는 신규 대출 입력을 자동으로 채우지 않는다. 화면은 상품 확정 조건과 사용자가 입력한 가정을 구분해 표시한다. -- 적용 대상은 서버가 저장된 계획 조건으로 다시 판정한 `CURRENT_CANDIDATE`이면서 `assessProductRepayment(...).supported === true`인 대출 상품뿐이다. 그 밖의 상품은 "상환 계산 대상 아님 + 사유"를 유지하며 0원 상환으로 대체하지 않는다. +- 적용 대상은 서버가 저장된 계획 조건과 활성 릴리스의 자동화 설정으로 다시 판정한 `CURRENT_CANDIDATE`이면서, 그 판정의 상환 계산 가능(`repayment.supported`)이 true인 대출 상품뿐이다. 그 밖의 상품은 "상환 계산 대상 아님 + 사유"를 유지하며 0원 상환으로 대체하지 않는다. - 실행: `POST /api/plans/{planId}/loan-assumption`(본문 `{ productKey, version, revision }`). - 서버가 활성 카탈로그에서 상품 버전과 현재 후보 여부를 다시 확인한다. - 사용자가 초안에 저장한 `newLoan.principal`에 상품의 확정 금리·기간·거치·상환방식만 결합한다. @@ -101,4 +134,6 @@ ## 테스트 - DB 없이: 카탈로그 스키마·규칙 위반, 실제 카탈로그 5건의 규칙 통과와 모두 상환 계산 대상이 아님(`supported=false`), 조건 판정, 상환 계산 가능 여부, 상품 조건과 사용자 원금의 결합. 후보 조회·계획 조건 판정·상품 조건 적용 API의 경계(상태 코드)는 Prisma를 mock해 DB 없이 검증한다. +- DB 없이: 자동 상품의 최신성·승격 보류·상환 계산 스위치, v1.0.0 릴리스 읽기, 자동 상품 규칙 검사, 동기화의 상품 버전 재사용 규칙. - `TEST_DATABASE_URL` 전용 DB: 카탈로그 적재, 실제 DB에서의 후보 조회·계획 조건 판정·상품 조건 적용, 출처 저장과 과거 결과 불변성. 합성 카탈로그만 만들고 끝나면 지우며 실행 전 ACTIVE 상태를 복원한다. +- `TEST_DATABASE_URL` 전용 DB의 동기화(`sync.test.ts`): 합성 수집기로 적재, 버전 재사용과 새 버전, 출처 실패 시 직전 상품 유지, 빈 응답·급감, 검증 실패, 사람 기록 우선, dry run.