Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 보존한다.

Expand Down
12 changes: 11 additions & 1 deletion app/catalog/funding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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#카탈로그)에 있다.

## 갱신 절차
Expand All @@ -38,11 +39,20 @@ 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건이다.
- 예비 창업자가 아니라 사업자등록 이후에만 검토할 수 있는 상품 3건(서울시 중소기업육성자금 창업기업자금·포용금융자금·긴급자영업자금)을 별도 상태로 분리했다.
- 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`를 적는다. 그러면 사람 기록이 자동 상품보다 우선한다.
13 changes: 10 additions & 3 deletions app/catalog/funding/catalog.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 자동 승격 스위치·차단 목록·출처별 동기화 결과(ADR 0006). 이전 카탈로그 릴리스는 값이 없어 NULL로 남는다.
-- AlterTable
ALTER TABLE "funding_catalog_releases" ADD COLUMN "automation" JSONB;
2 changes: 2 additions & 0 deletions app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
120 changes: 120 additions & 0 deletions app/scripts/sync-funding.ts
Original file line number Diff line number Diff line change
@@ -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;
});
15 changes: 14 additions & 1 deletion app/src/app/api/plans/[planId]/loan-assumption/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down
Loading
Loading