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 app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ SEOUL_OPEN_API_KEY=
SEMAS_SERVICE_KEY=
# 임대료 적재(rent:load)에만 필요하다. 부동산통계정보시스템(R-ONE) Open API 인증키를 넣는다.
REB_API_KEY=
# 자금 동기화(funding:sync)의 기업마당 공고 탐색에 필요하다. 기업마당 지원사업정보 API 인증키를 넣는다.
BIZINFO_API_KEY=

# TEST_DATABASE_URL은 이 파일이 아니라 app/.env.test.local에 둔다. 하네스와 Vitest는 환경변수나 그 파일에서만 읽는다.
# TEST_DATABASE_URL=postgresql://trendbench:change-this-local-password@127.0.0.1:5433/trendbench_mvp_test
4 changes: 2 additions & 2 deletions app/catalog/funding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ npm --prefix app run funding:load

1. 공식 기관 원문(기업마당, 중소벤처기업부, 서울시, 서울신보, K-Startup 등)에서 공고명·기관·접수기간·접수 상태·대상 사업단계·지역·용도·업종·금리·한도·상환 조건을 확인한다.
2. 확인한 날짜를 `observedAt`, 사람이 검수한 날짜를 `reviewedAt`, 다음 재확인 날짜를 `nextReviewAt`에 기록한다.
3. 원문에서 확정하지 못한 값은 0이나 추정값으로 채우지 않고 `null` 또는 `UNKNOWN`으로 남기고, 왜 확정할 수 없는지 `unsupportedCalculationReasons`·`additionalChecks`·`note`에 적는다.
3. 원문에서 확정하지 못한 값은 0이나 추정값으로 채우지 않고 `null` 또는 `UNKNOWN`으로 남기고, 왜 확정할 수 없는지 `unsupportedCalculationReasons`·`additionalChecks`·`note`에 적는다. 지원 유형을 확인하지 못했으면 `supportType`을 `UNKNOWN`, 신청 가능한 사업 단계를 확인하지 못했으면 `eligibleBusinessStages`를 빈 배열로 둔다.
4. 조건별로 `evidence`에 공식 URL·문서명·확인일·확인 방법을 남긴다. 지역·용도·업종을 PASS 또는 FAIL로 판정하려면 각각 REGION·PURPOSE·INDUSTRY의 공식 원문 근거가 필요하다. 검색 결과 요약만 확인한 경우 `retrievalMethod`를 `SEARCH_RESULT_SUMMARY`로 기록하며 해당 조건은 UNKNOWN으로 남는다.
5. 내용이 바뀌면 기존 항목을 덮어쓰지 않고 `version`을 올려 새 항목으로 추가하고, 카탈로그 `catalogVersion`도 올린다.
6. `npm --prefix app run funding:validate`로 확인한 뒤 `funding:load`를 실행한다.
Expand All @@ -39,7 +39,7 @@ npm --prefix app run funding:load

## 2026-09-20 검수 범위

현재 카탈로그 `2026-09-28.1`(상품 버전 `1.0.1`)에 적용되는 검수 내용이다. `2026-09-28.1`은 `2026-09-23.1`에서 스키마를 v1.1.0으로 올리고 자동화 설정을 더한 것이며, 상품 내용과 검수 내용은 같다.
현재 카탈로그 `2026-09-28.2`(상품 버전 `1.0.1`)에 적용되는 검수 내용이다. `2026-09-28.1`은 `2026-09-23.1`에서 스키마를 v1.1.0으로 올리고 자동화 설정을 더한 것이고, `2026-09-28.2`는 스키마를 v1.2.0으로 올린 것이다. 상품 내용과 검수 내용은 같다.

- 접수 상태가 CLOSED로 확인된 상품 1건(예비창업패키지 2차), 접수 상태를 확정할 수 없어 UNKNOWN으로 남긴 상품 4건을 기록했다.
- 같은 날 접수 상태가 OPEN으로 확인된 상품은 없어 현재 신청 가능 후보로 확정되는 상품은 0건이다.
Expand Down
7 changes: 4 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.1.0",
"catalogVersion": "2026-09-28.1",
"schemaVersion": "funding-catalog-v1.2.0",
"catalogVersion": "2026-09-28.2",
"basisDate": "2026-09-20",
"reviewer": "UNASSIGNED",
"notes": [
Expand All @@ -11,7 +11,8 @@
"검수자가 지정되지 않아(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-28: 스키마를 funding-catalog-v1.1.0으로 올리고 자동화 설정(automation)을 더해 catalogVersion을 2026-09-28.1로 올렸습니다. 두 자동 승격 스위치는 꺼져 있고, 상품 내용과 상품 버전(1.0.1)은 바뀌지 않았습니다."
"2026-09-28: 스키마를 funding-catalog-v1.1.0으로 올리고 자동화 설정(automation)을 더해 catalogVersion을 2026-09-28.1로 올렸습니다. 두 자동 승격 스위치는 꺼져 있고, 상품 내용과 상품 버전(1.0.1)은 바뀌지 않았습니다.",
"2026-09-28: 기업마당 공고 탐색을 위해 스키마를 funding-catalog-v1.2.0(지원 유형 UNKNOWN, 사업 단계 미확인)으로 올려 catalogVersion을 2026-09-28.2로 올렸습니다. 상품 내용과 상품 버전(1.0.1)은 바뀌지 않았습니다."
],
"automation": {
"policyLoanPromotion": false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- 원문이 지원 유형을 알려 주지 않는 공고(기업마당 등)를 추측 없이 기록한다(ADR 0006).
ALTER TYPE "FundingSupportType" ADD VALUE 'UNKNOWN';
2 changes: 2 additions & 0 deletions app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,8 @@ enum FundingSupportType {
LOAN
SPACE
PROGRAM
// 원문이 지원 유형을 알려 주지 않는 공고(기업마당 등). 상환 계산 대상이 아니다.
UNKNOWN
}

enum FundingApplicationStatus {
Expand Down
43 changes: 33 additions & 10 deletions app/scripts/sync-funding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,35 @@ 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 { secretMasker } from '../src/features/market/seoul-api.ts';
import { todayInKst } from '../src/features/funding/dates.ts';
import { createBizinfoAdapter } from '../src/features/funding/sources/bizinfo.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 SOURCE_ADAPTERS: readonly FundingSourceAdapter[] = [
createBizinfoAdapter({ apiKey: process.env.BIZINFO_API_KEY }),
];

// 출력에 인증키가 남지 않게 가린다.
const mask = secretMasker(process.env.BIZINFO_API_KEY ?? '');
// 새 공고 이름은 이 수까지만 출력한다.
const MAX_LISTED_NEW_PRODUCTS = 30;

const USAGE = `공식 출처에서 자금 공고를 모아 사람이 관리하는 카탈로그와 합치고 새 릴리스로 적재합니다(ADR 0006).

사용법:
npm run funding:sync -- [옵션]

환경변수:
DATABASE_URL PostgreSQL 연결 문자열
BIZINFO_API_KEY 기업마당 지원사업정보 API 인증키. 없으면 기업마당 출처는 실패로 기록됩니다

옵션:
--catalog <경로> 사람이 관리하는 카탈로그 JSON (기본값: app/catalog/funding/catalog.json)
--output-dir <경로> 합친 카탈로그를 쓸 폴더 (기본값: data/funding-sync/)
Expand Down Expand Up @@ -68,9 +81,6 @@ async function main() {
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({
Expand All @@ -80,14 +90,27 @@ async function main() {
asOfDate: todayInKst(),
outputDir: options.outputDir,
dryRun: options.dryRun,
log: (message) => console.log(message),
log: (message) => console.log(mask(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 ?? '없음'}`,
mask(
`출처 ${source.source}: ${state} · 상품 ${source.productCount}건${source.carriedOver ? '(직전 릴리스에서 유지)' : ''} · 마지막 성공일 ${source.lastSucceededOn ?? '없음'}`,
),
);
if (source.addedProducts.length > 0) {
console.log(` 직전 릴리스에 없던 공고 ${source.addedProducts.length}건:`);
for (const product of source.addedProducts.slice(0, MAX_LISTED_NEW_PRODUCTS)) {
console.log(` - ${product.name} (${product.productKey})`);
}
if (source.addedProducts.length > MAX_LISTED_NEW_PRODUCTS) {
console.log(
` - 외 ${source.addedProducts.length - MAX_LISTED_NEW_PRODUCTS}건은 ${report.catalogFile}에서 확인하세요.`,
);
}
}
}
console.log(`사람이 기록한 상품 ${report.manualProductCount}건 · 자동 상품 ${report.automatedProductCount}건`);
if (report.overriddenByManual.length > 0) {
Expand All @@ -111,10 +134,10 @@ async function main() {

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}`);
console.error(mask(`오류 [${error.code}] ${error.message}`));
for (const issue of error.issues ?? []) console.error(mask(` - [${issue.code}] ${issue.source}: ${issue.detail}`));
} else {
console.error(error instanceof Error ? error.message : String(error));
console.error(mask(error instanceof Error ? error.message : String(error)));
}
process.exitCode = 1;
});
4 changes: 4 additions & 0 deletions app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,10 @@ footer {
background: #e6f0f2;
color: #295c66;
}
.support-unknown {
background: #ecebe6;
color: #4c4a3d;
}
.candidate-status {
background: #e3e0d4;
color: #4c4a3d;
Expand Down
3 changes: 2 additions & 1 deletion app/src/features/funding/FundingCandidatesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ import type { ObservedApplicationStatus, ReviewState, SupportType, Verdict } fro
const numberFormat = new Intl.NumberFormat('ko-KR');

// 지원 유형은 대출과 다른 성격을 이름부터 구분한다. GRANT·SPACE·PROGRAM을
// 대출처럼 보이게 하는 표현을 쓰지 않는다.
// 대출처럼 보이게 하는 표현을 쓰지 않는다. 유형을 모르는 공고는 어느 쪽으로도 추측하지 않는다.
const SUPPORT_TYPE_LABELS: Record<SupportType, string> = {
GRANT: '지원금(상환 없음)',
GUARANTEE: '보증(대출 아님)',
LOAN: '대출(상환 대상)',
SPACE: '공간·보육(대출 아님)',
PROGRAM: '프로그램(대출 아님)',
UNKNOWN: '유형 미확인(원 공고 확인)',
};

const STATUS_LABELS: Record<FundingCandidateStatus, string> = {
Expand Down
27 changes: 23 additions & 4 deletions app/src/features/funding/eligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type FundingProduct,
} from './schema.ts';
import {
DISCOVERY_ONLY_SOURCES,
FUNDING_SOURCE_LABELS,
POLICY_LOAN_SUPPORT_TYPES,
REPAYABLE_SUPPORT_TYPES,
Expand All @@ -16,7 +17,7 @@ import {
isAutoReviewer,
isPrimaryRetrievalMethod,
isSupportedRepaymentMethod,
REQUIRED_EVIDENCE_SUBJECTS,
requiredEvidenceSubjects,
reviewState,
type BusinessStage,
type EvidenceSubject,
Expand Down Expand Up @@ -182,6 +183,13 @@ function stageCondition(product: FundingProduct, profile: FundingProfile): Fundi
if (profile.businessStage === 'UNKNOWN') {
return { ...base, verdict: 'UNKNOWN', detail: '사업 단계가 입력되지 않아 대상 여부를 판정하지 않았습니다.' };
}
if (product.eligibleBusinessStages.length === 0) {
return {
...base,
verdict: 'UNKNOWN',
detail: '원문에서 신청 가능한 사업 단계를 확인하지 못했습니다. 원 공고에서 대상 여부를 확인해야 합니다.',
};
}
const unverified = unverifiedCondition(product, 'BUSINESS_STAGE', base);
if (unverified) return unverified;
const stages = product.eligibleBusinessStages.map((stage) => STAGE_LABELS[stage]).join(', ');
Expand Down Expand Up @@ -283,6 +291,16 @@ function industryCondition(product: FundingProduct, profile: FundingProfile): Fu

export function assessProductRepayment(product: FundingProduct): ProductRepaymentAvailability {
const reasons = [...product.unsupportedCalculationReasons];
if (product.supportType === 'UNKNOWN') {
if (reasons.length === 0) reasons.push('지원 유형이 확인되지 않았습니다.');
return {
supported: false,
publicLimitKrw: null,
terms: null,
reasons: [...new Set(reasons)],
note: '지원 유형이 확인되지 않은 공고는 조달액으로 자동 반영하지 않으며 상환 계산 대상이 아닙니다.',
};
}
if (!REPAYABLE_SUPPORT_TYPES.includes(product.supportType)) {
if (reasons.length === 0) {
reasons.push(`지원 유형 ${product.supportType}은(는) 대출 원금·상환 일정으로 변환하지 않습니다.`);
Expand Down Expand Up @@ -333,9 +351,7 @@ export function productAudience(product: FundingProduct): ProductAudience {
}

export function hasPrimaryEvidence(product: FundingProduct): boolean {
const subjects: EvidenceSubject[] = [...REQUIRED_EVIDENCE_SUBJECTS];
if (product.supportType === 'LOAN' || product.supportType === 'GUARANTEE') subjects.push('FINANCIAL_CONDITION');
return subjects.every((subject) =>
return requiredEvidenceSubjects(product).every((subject) =>
product.evidence.some((entry) => entry.subject === subject && isPrimaryRetrievalMethod(entry.retrievalMethod)),
);
}
Expand All @@ -346,6 +362,9 @@ function automationHold(product: FundingProduct, automation: FundingAutomation):
const refKey = sourceRefKey(product.sourceRef);
const blocked = automation.blockedSources.find((entry) => sourceRefKey(entry) === refKey);
if (blocked) return `운영자가 이 공고의 자동 승격을 막았습니다: ${blocked.reason}`;
if (DISCOVERY_ONLY_SOURCES.includes(product.sourceRef.source)) {
return `${FUNDING_SOURCE_LABELS[product.sourceRef.source]}에서 찾은 공고는 사업 단계·지원 유형을 알 수 없어 사람이 확인해야 현재 후보가 됩니다.`;
}
if (!POLICY_LOAN_SUPPORT_TYPES.includes(product.supportType)) {
return '지원사업은 자동으로 모아도 사람이 확인해야 현재 후보가 됩니다.';
}
Expand Down
109 changes: 108 additions & 1 deletion app/src/features/funding/funding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,113 @@ describe('automated products in the catalog (ADR 0006)', () => {
sourceSyncs: [],
});
});

it('keeps a notice found only through Bizinfo out of current candidates even when every switch is on', () => {
const bizinfoSync = { ...succeededSync, source: 'BIZINFO' };
const parsed = catalogFrom(
validate(
catalog(
[
product({
reviewer: AUTO_REVIEWER,
nextReviewAt: null,
sourceRef: { source: 'BIZINFO', externalId: 'PBLN_TEST' },
industryConditions: { scope: 'UNKNOWN', included: [], excluded: [], note: '업종 미확인' },
}),
],
{
automation: automation({ policyLoanPromotion: true, repaymentPromotion: true, sourceSyncs: [bizinfoSync] }),
},
),
),
);
const evaluation = evaluateProduct(
parsed.products[0],
{
businessStage: 'PRE_REGISTRATION',
districtCode: '11200',
industryCode: 'CS100010',
purpose: 'OPERATING_FUNDS',
},
{ asOfDate: REVIEW_DATE, automation: parsed.automation },
);
expect(evaluation.reviewState).toBe('CURRENT');
expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION');
expect(evaluation.candidateReason).toContain('기업마당 지원사업정보에서 찾은 공고');
});
});

describe('notices whose business stage or support type is unknown', () => {
const preProfile = {
businessStage: 'PRE_REGISTRATION',
districtCode: '11200',
industryCode: 'CS100010',
purpose: 'OPERATING_FUNDS',
} as const;
const unknownType = (overrides: Record<string, unknown> = {}) =>
product({
supportType: 'UNKNOWN',
publicLimit: null,
repaymentMethod: 'UNKNOWN',
unsupportedCalculationReasons: ['지원 유형이 확인되지 않았습니다.'],
evidence: [
evidence('identity', 'IDENTITY'),
evidence('period', 'APPLICATION_PERIOD'),
evidence('stage', 'BUSINESS_STAGE'),
evidence('region', 'REGION'),
evidence('purpose', 'PURPOSE'),
evidence('industry', 'INDUSTRY'),
],
...overrides,
});

it('accepts an empty stage list without stage evidence and keeps the stage unconfirmed', () => {
const validation = validate(
catalog([
product({
eligibleBusinessStages: [],
evidence: [
evidence('identity', 'IDENTITY'),
evidence('period', 'APPLICATION_PERIOD'),
evidence('financial', 'FINANCIAL_CONDITION'),
evidence('region', 'REGION'),
evidence('purpose', 'PURPOSE'),
evidence('industry', 'INDUSTRY'),
],
}),
]),
);
expect(validation.issues).toEqual([]);
const evaluation = evaluateProduct(catalogFrom(validation).products[0], preProfile, { asOfDate: REVIEW_DATE });
expect(evaluation.audience).toBe('UNKNOWN');
expect(evaluation.primaryEvidenceVerified).toBe(true);
expect(evaluation.conditions.find((condition) => condition.key === 'BUSINESS_STAGE')?.verdict).toBe('UNKNOWN');
expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION');
expect(evaluation.candidateReason).toContain('사업 단계를 확인하지 못했습니다');
});

it('accepts an unknown support type without financial evidence and never calculates its repayment', () => {
const validation = validate(catalog([unknownType()]));
expect(validation.issues).toEqual([]);
const repayment = assessProductRepayment(catalogFrom(validation).products[0]);
expect(repayment).toMatchObject({ supported: false, publicLimitKrw: null, terms: null });
expect(repayment.note).toContain('지원 유형이 확인되지 않은');
});

it('rejects an unknown support type recorded as not repayable or with confirmed lending terms', () => {
expect(issueCodes(validate(catalog([unknownType({ repaymentMethod: 'NOT_APPLICABLE' })])))).toContain(
'SUPPORT_TYPE_FINANCIAL_CONTRADICTION',
);
expect(
issueCodes(
validate(
catalog([
unknownType({ interestRateConfirmed: true, interestCondition: '연 3% 고정', interestRatePercent: '3' }),
]),
),
),
).toContain('SUPPORT_TYPE_FINANCIAL_CONTRADICTION');
});
});

describe('the reviewed 2026-09-20 catalog', () => {
Expand Down Expand Up @@ -700,7 +807,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-28.1';
const REAL_VERSION = '2026-09-28.2';
const REVIEWED_CATALOG_PATH = reviewedCatalogFile();

const db = (): PrismaClient => {
Expand Down
Loading
Loading