From 835420d810853e985f240a57f77078d92ff89c7d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:35:50 +0000 Subject: [PATCH] feat(funding): discover Bizinfo notices as unconfirmed candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Bizinfo (기업마당) Open API collector to funding:sync. Notices tagged 소상공인 or 창업벤처 in Seoul or without a region signal become automated products that stay NEEDS_CONFIRMATION until a person reviews them (ADR 0006 sections 1-2). - Add supportType UNKNOWN (catalog and Prisma enum, new migration) and treat an empty eligibleBusinessStages as an unconfirmed stage, so the collector records what Bizinfo does not tell us without guessing. - Bump the catalog schema to funding-catalog-v1.2.0 and keep reading v1.0.0 and v1.1.0 releases. - Hold discovery-only sources out of current candidates even when the policy loan switch is on. - Report notices that were not in the previous ACTIVE release, and mask BIZINFO_API_KEY in every error and CLI line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- app/.env.example | 2 + app/catalog/funding/README.md | 4 +- app/catalog/funding/catalog.json | 7 +- .../migration.sql | 2 + app/prisma/schema.prisma | 2 + app/scripts/sync-funding.ts | 43 +- app/src/app/globals.css | 4 + .../funding/FundingCandidatesPanel.tsx | 3 +- app/src/features/funding/eligibility.ts | 27 +- app/src/features/funding/funding.test.ts | 109 ++++- app/src/features/funding/schema.ts | 4 +- .../features/funding/sources/bizinfo.test.ts | 303 ++++++++++++ app/src/features/funding/sources/bizinfo.ts | 437 ++++++++++++++++++ app/src/features/funding/sync.test.ts | 78 ++++ app/src/features/funding/sync.ts | 23 +- app/src/features/funding/types.ts | 34 +- app/src/features/funding/validation.ts | 23 +- docs/ARCHITECTURE.md | 2 +- docs/DEVELOPMENT.md | 4 +- docs/domains/funding.md | 39 +- 20 files changed, 1099 insertions(+), 51 deletions(-) create mode 100644 app/prisma/migrations/20260928020000_funding_support_type_unknown/migration.sql create mode 100644 app/src/features/funding/sources/bizinfo.test.ts create mode 100644 app/src/features/funding/sources/bizinfo.ts diff --git a/app/.env.example b/app/.env.example index 390919d..5c8f716 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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 diff --git a/app/catalog/funding/README.md b/app/catalog/funding/README.md index 6fcda4d..4d94d1f 100644 --- a/app/catalog/funding/README.md +++ b/app/catalog/funding/README.md @@ -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`를 실행한다. @@ -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건이다. diff --git a/app/catalog/funding/catalog.json b/app/catalog/funding/catalog.json index 50ffb22..0d90ed6 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.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": [ @@ -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, diff --git a/app/prisma/migrations/20260928020000_funding_support_type_unknown/migration.sql b/app/prisma/migrations/20260928020000_funding_support_type_unknown/migration.sql new file mode 100644 index 0000000..db89651 --- /dev/null +++ b/app/prisma/migrations/20260928020000_funding_support_type_unknown/migration.sql @@ -0,0 +1,2 @@ +-- 원문이 지원 유형을 알려 주지 않는 공고(기업마당 등)를 추측 없이 기록한다(ADR 0006). +ALTER TYPE "FundingSupportType" ADD VALUE 'UNKNOWN'; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index a111c05..a70563b 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -298,6 +298,8 @@ enum FundingSupportType { LOAN SPACE PROGRAM + // 원문이 지원 유형을 알려 주지 않는 공고(기업마당 등). 상환 계산 대상이 아니다. + UNKNOWN } enum FundingApplicationStatus { diff --git a/app/scripts/sync-funding.ts b/app/scripts/sync-funding.ts index f01ce51..1406b46 100644 --- a/app/scripts/sync-funding.ts +++ b/app/scripts/sync-funding.ts @@ -2,7 +2,9 @@ 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'; @@ -10,14 +12,25 @@ const DEFAULT_CATALOG = fileURLToPath(new URL('../catalog/funding/catalog.json', 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/) @@ -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({ @@ -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) { @@ -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; }); diff --git a/app/src/app/globals.css b/app/src/app/globals.css index ca176bc..2ff1cb7 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -1024,6 +1024,10 @@ footer { background: #e6f0f2; color: #295c66; } +.support-unknown { + background: #ecebe6; + color: #4c4a3d; +} .candidate-status { background: #e3e0d4; color: #4c4a3d; diff --git a/app/src/features/funding/FundingCandidatesPanel.tsx b/app/src/features/funding/FundingCandidatesPanel.tsx index 9af818a..c48dadf 100644 --- a/app/src/features/funding/FundingCandidatesPanel.tsx +++ b/app/src/features/funding/FundingCandidatesPanel.tsx @@ -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 = { GRANT: '지원금(상환 없음)', GUARANTEE: '보증(대출 아님)', LOAN: '대출(상환 대상)', SPACE: '공간·보육(대출 아님)', PROGRAM: '프로그램(대출 아님)', + UNKNOWN: '유형 미확인(원 공고 확인)', }; const STATUS_LABELS: Record = { diff --git a/app/src/features/funding/eligibility.ts b/app/src/features/funding/eligibility.ts index 5b794ce..08b8c13 100644 --- a/app/src/features/funding/eligibility.ts +++ b/app/src/features/funding/eligibility.ts @@ -6,6 +6,7 @@ import { type FundingProduct, } from './schema.ts'; import { + DISCOVERY_ONLY_SOURCES, FUNDING_SOURCE_LABELS, POLICY_LOAN_SUPPORT_TYPES, REPAYABLE_SUPPORT_TYPES, @@ -16,7 +17,7 @@ import { isAutoReviewer, isPrimaryRetrievalMethod, isSupportedRepaymentMethod, - REQUIRED_EVIDENCE_SUBJECTS, + requiredEvidenceSubjects, reviewState, type BusinessStage, type EvidenceSubject, @@ -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(', '); @@ -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}은(는) 대출 원금·상환 일정으로 변환하지 않습니다.`); @@ -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)), ); } @@ -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 '지원사업은 자동으로 모아도 사람이 확인해야 현재 후보가 됩니다.'; } diff --git a/app/src/features/funding/funding.test.ts b/app/src/features/funding/funding.test.ts index db8e2dd..39e44ef 100644 --- a/app/src/features/funding/funding.test.ts +++ b/app/src/features/funding/funding.test.ts @@ -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 = {}) => + 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', () => { @@ -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 => { diff --git a/app/src/features/funding/schema.ts b/app/src/features/funding/schema.ts index 03ec959..dbc264c 100644 --- a/app/src/features/funding/schema.ts +++ b/app/src/features/funding/schema.ts @@ -96,7 +96,9 @@ export const fundingProductSchema = z.object({ name: z.string().trim().min(2).max(200), organization: z.string().trim().min(2).max(200), supportType: z.enum(SUPPORT_TYPES), - eligibleBusinessStages: z.array(z.enum(BUSINESS_STAGES)).min(1, '신청 가능한 사업단계를 하나 이상 기록해 주세요.'), + // 빈 배열은 원문에서 신청 가능한 사업 단계를 확인하지 못했다는 뜻이다. 사업 단계 판정은 + // 미확인이 되고 사업 단계 근거도 요구하지 않는다. 확인했으면 하나 이상 기록한다. + eligibleBusinessStages: z.array(z.enum(BUSINESS_STAGES)), region: regionSchema, purpose: purposeSchema, industryConditions: industryConditionSchema, diff --git a/app/src/features/funding/sources/bizinfo.test.ts b/app/src/features/funding/sources/bizinfo.test.ts new file mode 100644 index 0000000..d4223c3 --- /dev/null +++ b/app/src/features/funding/sources/bizinfo.test.ts @@ -0,0 +1,303 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { evaluateProduct } from '../eligibility.ts'; +import type { FundingSourceCollection } from '../sync.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION } from '../types.ts'; +import { validateFundingCatalog } from '../validation.ts'; +import { + BIZINFO_API_URL, + BIZINFO_SEARCH_COUNT, + bizinfoRegion, + createBizinfoAdapter, + mapBizinfoItems, + parseBizinfoPeriod, +} from './bizinfo.ts'; + +const AS_OF = '2026-09-28'; +const API_KEY = 'test-bizinfo-key-0123456789'; +const CHECKSUM = 'c'.repeat(64); + +function item(overrides: Record = {}): Record { + return { + pblancId: 'PBLN_000000000112345', + pblancNm: '[서울] 2026년 소상공인 디지털 전환 지원사업 공고', + pblancUrl: '/web/lay1/bbs/S1T122C128/AS/74/view.do?pblancId=PBLN_000000000112345', + jrsdInsttNm: '서울특별시', + excInsttNm: '서울신용보증재단', + reqstBeginEndDe: '2026-09-01 ~ 2026-10-31', + trgetNm: '소상공인', + pldirSportRealmLclasCodeNm: '경영', + totCnt: 1, + ...overrides, + }; +} + +type Call = { url: string }; + +function fakeFetch(responses: Array<{ status: number; body: string } | Error>) { + const calls: Call[] = []; + const fetchImpl = async (url: string) => { + calls.push({ url }); + const next = responses[Math.min(calls.length - 1, responses.length - 1)]; + if (next instanceof Error) throw next; + return { status: next.status, text: async () => next.body }; + }; + return { calls, fetchImpl }; +} + +function collect( + responses: Array<{ status: number; body: string } | Error>, + apiKey: string | undefined = API_KEY, +): { promise: Promise; calls: Call[]; logs: string[] } { + const { calls, fetchImpl } = fakeFetch(responses); + const logs: string[] = []; + const adapter = createBizinfoAdapter({ apiKey, fetchImpl, retryDelayMs: 0 }); + return { promise: adapter.collect({ asOfDate: AS_OF, log: (message) => logs.push(message) }), calls, logs }; +} + +function body(items: unknown[]): string { + return JSON.stringify({ jsonArray: items }); +} + +function catalogWith(products: unknown[], sourceSyncs: unknown[] = []) { + return { + catalogKey: 'test-bizinfo-catalog', + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + catalogVersion: `${AS_OF}.1`, + basisDate: AS_OF, + reviewer: 'tester', + notes: [], + automation: { policyLoanPromotion: true, repaymentPromotion: true, blockedSources: [], sourceSyncs }, + products, + }; +} + +describe('기업마당 신청기간·지역 해석', () => { + it('날짜 두 개로 적힌 신청기간만 날짜로 읽는다', () => { + expect(parseBizinfoPeriod('2026-09-01 ~ 2026-10-31')).toEqual({ start: '2026-09-01', end: '2026-10-31' }); + expect(parseBizinfoPeriod(' 2026-09-01~2026-09-01 ')).toEqual({ start: '2026-09-01', end: '2026-09-01' }); + expect(parseBizinfoPeriod('예산 소진시까지')).toBeNull(); + expect(parseBizinfoPeriod('상시 접수')).toBeNull(); + expect(parseBizinfoPeriod('2026-02-30 ~ 2026-03-10')).toBeNull(); + expect(parseBizinfoPeriod('2026-10-31 ~ 2026-09-01')).toBeNull(); + }); + + it('공고명 [지역] 표시와 소관기관으로 서울·자치구·다른 시·도를 가린다', () => { + expect(bizinfoRegion('[서울] 소상공인 지원', '중소벤처기업부')).toMatchObject({ scope: 'SEOUL' }); + expect(bizinfoRegion('[서울] 소상공인 지원', '서울특별시 중구')).toMatchObject({ + scope: 'DISTRICTS', + districtCodes: ['11140'], + }); + expect(bizinfoRegion('소상공인 지원', '서울특별시 동대문구')).toMatchObject({ + scope: 'DISTRICTS', + districtCodes: ['11230'], + }); + expect(bizinfoRegion('소상공인 지원', '서울경제진흥원')).toMatchObject({ scope: 'SEOUL' }); + expect(bizinfoRegion('[경기] 소상공인 지원', '경기도')).toEqual({ scope: 'OTHER' }); + expect(bizinfoRegion('[충북 청주] 소상공인 지원', '중소벤처기업부')).toEqual({ scope: 'OTHER' }); + expect(bizinfoRegion('소상공인 지원', '부산광역시 해운대구')).toEqual({ scope: 'OTHER' }); + // 서울 밖 같은 이름의 구는 자치구로 잡지 않는다. + expect(bizinfoRegion('소상공인 지원', '대구광역시 중구')).toEqual({ scope: 'OTHER' }); + // 지역 신호가 없으면 전국으로 추측하지 않는다. + expect(bizinfoRegion('소상공인 정책자금 공고', '중소벤처기업부')).toEqual({ scope: 'UNKNOWN' }); + expect(bizinfoRegion('[전국] 소상공인 정책자금 공고', '중소벤처기업부')).toEqual({ scope: 'UNKNOWN' }); + }); +}); + +describe('기업마당 공고를 카탈로그 상품으로 바꾸기', () => { + it('사업 단계·지원 유형을 추측하지 않고, 검증을 통과하는 추가 확인 상품을 만든다', () => { + const { products, skipped } = mapBizinfoItems([item()], { asOfDate: AS_OF, responseChecksum: CHECKSUM }); + expect(skipped).toEqual({ notTarget: 0, otherRegion: 0, ended: 0, malformed: 0, duplicate: 0 }); + expect(products).toHaveLength(1); + const [product] = products; + expect(product).toMatchObject({ + productKey: 'bizinfo-pbln-000000000112345', + name: '[서울] 2026년 소상공인 디지털 전환 지원사업 공고', + organization: '서울특별시 · 서울신용보증재단', + supportType: 'UNKNOWN', + eligibleBusinessStages: [], + region: { scope: 'SEOUL', districtCodes: [], note: null }, + purpose: { included: [], excluded: [] }, + industryConditions: { scope: 'UNKNOWN', included: [], excluded: [] }, + applicationPeriod: { start: '2026-09-01', end: '2026-10-31', note: null }, + observedApplicationStatus: 'OPEN', + observedAt: AS_OF, + reviewedAt: AS_OF, + nextReviewAt: null, + officialUrl: 'https://www.bizinfo.go.kr/web/lay1/bbs/S1T122C128/AS/74/view.do?pblancId=PBLN_000000000112345', + repaymentMethod: 'UNKNOWN', + publicLimit: null, + interestRateConfirmed: false, + sourceRef: { source: 'BIZINFO', externalId: 'PBLN_000000000112345' }, + }); + expect(product.additionalChecks).toContain('기업마당 지원대상 표시: 소상공인'); + expect(product.evidence.map((entry) => [entry.subject, entry.retrievalMethod, entry.checksum])).toEqual([ + ['IDENTITY', 'OFFICIAL_API', CHECKSUM], + ['APPLICATION_PERIOD', 'OFFICIAL_API', CHECKSUM], + ['REGION', 'OFFICIAL_API', CHECKSUM], + ]); + + const version = { ...product, version: '1.0.0', reviewer: AUTO_REVIEWER }; + const validation = validateFundingCatalog(catalogWith([version]), { asOfDate: AS_OF }); + expect(validation.issues).toEqual([]); + expect(validation.ok).toBe(true); + }); + + it('두 스위치가 켜지고 출처가 최신이어도 기업마당 상품은 현재 후보가 되지 않는다', () => { + const { products } = mapBizinfoItems([item()], { asOfDate: AS_OF, responseChecksum: CHECKSUM }); + const freshSync = { + source: 'BIZINFO', + status: 'SUCCEEDED', + attemptedAt: `${AS_OF}T01:00:00.000Z`, + lastSucceededOn: AS_OF, + fetchedCount: 1, + responseChecksum: CHECKSUM, + failureReason: null, + }; + const validation = validateFundingCatalog( + catalogWith([{ ...products[0], version: '1.0.0', reviewer: AUTO_REVIEWER }], [freshSync]), + { asOfDate: AS_OF }, + ); + const catalog = validation.catalog; + if (catalog === null) throw new Error('카탈로그 검증에 실패했습니다.'); + const evaluation = evaluateProduct( + catalog.products[0], + { businessStage: 'PRE_REGISTRATION', districtCode: '11140', industryCode: 'CS100010', purpose: 'STARTUP_COST' }, + { asOfDate: AS_OF, automation: catalog.automation }, + ); + expect(evaluation.reviewState).toBe('CURRENT'); + expect(evaluation.audience).toBe('UNKNOWN'); + expect(evaluation.primaryEvidenceVerified).toBe(true); + expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(evaluation.candidateReason).toContain('사업 단계를 확인하지 못했습니다'); + expect(evaluation.repayment.supported).toBe(false); + expect(evaluation.repayment.note).toContain('지원 유형이 확인되지 않은'); + }); + + it('소상공인·창업벤처 대상이 아닌 공고, 서울 밖 공고, 끝난 공고, 형식이 깨진 공고와 중복은 빼고 센다', () => { + const { products, skipped } = mapBizinfoItems( + [ + item({ pblancId: 'PBLN_A', trgetNm: '중소기업' }), + item({ pblancId: 'PBLN_B', pblancNm: '[경기] 소상공인 경영 개선 지원', jrsdInsttNm: '경기도' }), + item({ pblancId: 'PBLN_C', pblancNm: '소상공인 지원', jrsdInsttNm: '부산광역시' }), + item({ pblancId: 'PBLN_D', reqstBeginEndDe: '2026-08-01 ~ 2026-09-27' }), + item({ pblancId: '' }), + item({ pblancId: 'PBLN_E', pblancUrl: 'https://blog.example.com/notice' }), + 'not an object', + item({ pblancId: 'PBLN_F', trgetNm: '중소기업,창업벤처', jrsdInsttNm: '서울특별시 중구', excInsttNm: '' }), + item({ pblancId: 'PBLN_F' }), + item({ + pblancId: 'PBLN_G', + pblancNm: '소상공인 정책자금 변경 공고', + jrsdInsttNm: '중소벤처기업부', + excInsttNm: '소상공인시장진흥공단', + reqstBeginEndDe: '예산 소진시까지', + pldirSportRealmLclasCodeNm: '금융', + }), + item({ pblancId: 'PBLN_H', reqstBeginEndDe: '2026-10-01 ~ 2026-10-31' }), + ], + { asOfDate: AS_OF, responseChecksum: CHECKSUM }, + ); + expect(skipped).toEqual({ notTarget: 1, otherRegion: 2, ended: 1, malformed: 3, duplicate: 1 }); + const byId = new Map(products.map((product) => [product.sourceRef.externalId, product])); + expect([...byId.keys()]).toEqual(['PBLN_F', 'PBLN_G', 'PBLN_H']); + + expect(byId.get('PBLN_F')).toMatchObject({ + organization: '서울특별시 중구', + region: { scope: 'DISTRICTS', districtCodes: ['11140'], note: null }, + }); + const nationwide = byId.get('PBLN_G'); + expect(nationwide?.region).toMatchObject({ scope: 'UNKNOWN', districtCodes: [] }); + expect(nationwide?.evidence.map((entry) => entry.subject)).toEqual(['IDENTITY', 'APPLICATION_PERIOD']); + expect(nationwide?.applicationPeriod).toEqual({ start: null, end: null, note: '예산 소진시까지' }); + // 날짜가 없으면 접수 중으로 추측하지 않는다. + expect(nationwide?.observedApplicationStatus).toBe('UNKNOWN'); + expect(nationwide?.additionalChecks).toContain('기업마당 지원분야 표시: 금융'); + // 접수 시작 전인 공고도 접수 중이 아니다. + expect(byId.get('PBLN_H')?.observedApplicationStatus).toBe('UNKNOWN'); + + const versions = products.map((product) => ({ ...product, version: '1.0.0', reviewer: AUTO_REVIEWER })); + const validation = validateFundingCatalog(catalogWith(versions), { asOfDate: AS_OF }); + expect(validation.issues).toEqual([]); + }); + + it('공고명 엔티티를 풀고, http 주소는 https로 읽는다', () => { + const { products } = mapBizinfoItems( + [ + item({ + pblancNm: '[서울] 소상공인 & 예비창업자 ‘상생’ 지원', + pblancUrl: 'http://www.bizinfo.go.kr/web/lay1/bbs/S1T122C128/AS/74/view.do?pblancId=PBLN_000000000112345', + }), + ], + { asOfDate: AS_OF, responseChecksum: CHECKSUM }, + ); + expect(products[0].name).toBe('[서울] 소상공인 & 예비창업자 ‘상생’ 지원'); + expect(products[0].officialUrl.startsWith('https://www.bizinfo.go.kr/')).toBe(true); + }); +}); + +describe('기업마당 API 수집기', () => { + it('인증키로 전체 공고를 한 번에 받아 응답 checksum과 받은 공고 수를 남긴다', async () => { + const response = body([item({ totCnt: 2 }), item({ pblancId: 'PBLN_OTHER', trgetNm: '중소기업', totCnt: 2 })]); + const { promise, calls, logs } = collect([{ status: 200, body: response }]); + const result = await promise; + expect(calls).toHaveLength(1); + const url = new URL(calls[0].url); + expect(`${url.origin}${url.pathname}`).toBe(BIZINFO_API_URL); + expect(url.searchParams.get('crtfcKey')).toBe(API_KEY); + expect(url.searchParams.get('dataType')).toBe('json'); + expect(url.searchParams.get('searchCnt')).toBe(String(BIZINFO_SEARCH_COUNT)); + expect(result.fetchedCount).toBe(2); + expect(result.products).toHaveLength(1); + expect(result.responseChecksum).toBe(createHash('sha256').update(response, 'utf8').digest('hex')); + expect(result.products[0].evidence[0].checksum).toBe(result.responseChecksum); + expect(logs.join('\n')).toContain('공고 2건 중 1건'); + }); + + it('인증키가 없으면 요청하지 않고 실패한다', async () => { + const { promise, calls } = collect([{ status: 200, body: body([item()]) }], ' '); + await expect(promise).rejects.toThrow('BIZINFO_API_KEY'); + expect(calls).toHaveLength(0); + }); + + it('403·429는 다시 시도하지 않고 멈춘다', async () => { + for (const status of [403, 429]) { + const { promise, calls } = collect([{ status, body: `denied ${API_KEY}` }]); + await expect(promise).rejects.toThrow(`HTTP ${status}`); + expect(calls).toHaveLength(1); + } + }); + + it('서버 오류와 연결 오류는 두 번까지 다시 시도한다', async () => { + const recovered = collect([ + { status: 503, body: '' }, + new TypeError('fetch failed'), + { status: 200, body: body([item()]) }, + ]); + await expect(recovered.promise).resolves.toMatchObject({ fetchedCount: 1 }); + expect(recovered.calls).toHaveLength(3); + + const failed = collect([{ status: 500, body: '' }]); + await expect(failed.promise).rejects.toThrow('3번 실패'); + expect(failed.calls).toHaveLength(3); + }); + + it('오류 문장에 인증키를 남기지 않는다', async () => { + const { promise } = collect([{ status: 200, body: `invalid key ${API_KEY}` }]); + const error = await promise.then( + () => null, + (reason: unknown) => reason as Error, + ); + expect(error?.message).toContain('JSON이 아닙니다'); + expect(error?.message).not.toContain(API_KEY); + expect(error?.message).toContain('***'); + }); + + it('공고 목록이 없거나 전체 건수보다 적게 받은 응답은 실패로 본다', async () => { + const missing = collect([{ status: 200, body: JSON.stringify({ reqErr: 'invalid' }) }]); + await expect(missing.promise).rejects.toThrow('jsonArray'); + + const truncated = collect([{ status: 200, body: body([item({ totCnt: 1554 })]) }]); + await expect(truncated.promise).rejects.toThrow('1554건 중 1건'); + }); +}); diff --git a/app/src/features/funding/sources/bizinfo.ts b/app/src/features/funding/sources/bizinfo.ts new file mode 100644 index 0000000..cf1521a --- /dev/null +++ b/app/src/features/funding/sources/bizinfo.ts @@ -0,0 +1,437 @@ +import { createHash } from 'node:crypto'; +import { z } from 'zod'; +import { secretMasker, type FetchLike } from '../../market/seoul-api.ts'; +import { SEOUL_DISTRICTS } from '../districts.ts'; +import type { CollectedFundingProduct, FundingSourceAdapter, FundingSourceCollection } from '../sync.ts'; +import { compareIsoDates, isOfficialUrl, isRealDate } from '../types.ts'; + +// 기업마당 지원사업정보 Open API(중소벤처기업부). 새 공고를 찾는 데만 쓰며, 여기서 만든 상품은 +// 사업 단계·지원 유형을 알 수 없어 사람이 확인하기 전에는 추가 확인에 머문다(ADR 0006 1·2절). +export const BIZINFO_API_URL = 'https://www.bizinfo.go.kr/uss/rss/bizinfoApi.do'; +const BIZINFO_ORIGIN = 'https://www.bizinfo.go.kr'; + +// 요청 한 번으로 현재 공고 전체를 받는다(2026-09-28 기준 1,554건). 응답의 전체 건수(totCnt)보다 +// 적게 받으면 잘린 응답이라 실패로 기록한다. +export const BIZINFO_SEARCH_COUNT = 5000; + +// 수집 범위. 지원대상 태그(trgetNm)에 이 값이 있는 공고만 추가 확인 후보로 올린다. 음식점 창업과 +// 무관한 수출·기술개발 공고가 대부분인 중소기업 전용 공고를 빼기 위한 기계적인 거름이다. +export const BIZINFO_TARGET_TAGS: readonly string[] = ['소상공인', '창업벤처']; + +// 공고명 앞 [지역] 표시와 소관기관 이름으로 서울 밖 공고를 뺀다. +const OTHER_REGION_TAGS: readonly string[] = [ + '부산', + '대구', + '인천', + '광주', + '대전', + '울산', + '세종', + '경기', + '강원', + '충북', + '충남', + '전북', + '전남', + '경북', + '경남', + '제주', +]; +const OTHER_REGION_INSTITUTION_PREFIXES: readonly string[] = [ + '부산광역시', + '대구광역시', + '인천광역시', + '광주광역시', + '대전광역시', + '울산광역시', + '세종특별자치시', + '경기도', + '강원특별자치도', + '강원도', + '충청북도', + '충청남도', + '전북특별자치도', + '전라북도', + '전라남도', + '경상북도', + '경상남도', + '제주특별자치도', +]; + +const EVIDENCE_DOCUMENT_NAME = '기업마당 지원사업정보 API 응답'; + +const text = z + .union([z.string(), z.number(), z.null(), z.undefined()]) + .transform((value) => (value === null || value === undefined ? '' : String(value).trim())); + +const bizinfoItemSchema = z.object({ + pblancId: text, + pblancNm: text, + pblancUrl: text, + jrsdInsttNm: text, + excInsttNm: text, + reqstBeginEndDe: text, + trgetNm: text, + pldirSportRealmLclasCodeNm: text, + totCnt: text, +}); + +type BizinfoItem = z.infer; + +export type BizinfoSkipCounts = { + // 지원대상 태그에 소상공인·창업벤처가 없는 공고. + notTarget: number; + // 서울 밖 지역 공고. + otherRegion: number; + // 신청 종료일이 판정 기준일보다 앞선 공고. + ended: number; + // 공고 식별자·이름·주소·기관이 없거나 쓸 수 없는 공고. + malformed: number; + // 같은 공고 식별자가 앞에 이미 있던 공고. + duplicate: number; +}; + +export type BizinfoMapping = { products: CollectedFundingProduct[]; skipped: BizinfoSkipCounts }; + +export type BizinfoAdapterOptions = { + // BIZINFO_API_KEY. 없으면 수집하지 않고 그 출처를 실패로 기록한다. + apiKey: string | undefined; + fetchImpl?: FetchLike; + retries?: number; + retryDelayMs?: number; + timeoutMs?: number; +}; + +class RetryableBizinfoError extends Error {} + +const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', middot: '·' }; + +// 공고명·기관명에 남은 HTML 엔티티를 풀고 공백을 하나로 줄인다. +function cleanText(value: string): string { + return value + .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (match, entity: string) => { + if (entity.startsWith('#x') || entity.startsWith('#X')) + return String.fromCodePoint(parseInt(entity.slice(2), 16)); + if (entity.startsWith('#')) return String.fromCodePoint(Number(entity.slice(1))); + return ENTITIES[entity.toLowerCase()] ?? match; + }) + .replace(/\s+/g, ' ') + .trim(); +} + +// 신청기간 표기 "YYYY-MM-DD ~ YYYY-MM-DD"만 날짜로 읽는다. "예산 소진시까지" 같은 문장은 날짜로 추측하지 않는다. +export function parseBizinfoPeriod(raw: string): { start: string; end: string } | null { + const match = /^(\d{4}-\d{2}-\d{2})\s*~\s*(\d{4}-\d{2}-\d{2})$/.exec(raw.trim()); + if (!match) return null; + const [, start, end] = match; + if (!isRealDate(start) || !isRealDate(end) || compareIsoDates(end, start) < 0) return null; + return { start, end }; +} + +export type BizinfoRegion = + | { scope: 'SEOUL'; districtCodes: []; basis: string } + | { scope: 'DISTRICTS'; districtCodes: [string]; basis: string } + | { scope: 'UNKNOWN' } + | { scope: 'OTHER' }; + +function seoulDistrictCode(value: string): string | null { + const match = /^서울(?:특별시)?\s*(\S+?구)/.exec(value); + if (!match) return null; + return SEOUL_DISTRICTS.find((district) => district.name === match[1])?.code ?? null; +} + +// 공고명 앞 [지역] 표시가 있으면 그것으로, 없으면 소관기관 이름으로 지역을 정한다. 서울도 다른 +// 시·도도 아니면 전국으로 추측하지 않고 미확인으로 둔다. +export function bizinfoRegion(name: string, institution: string): BizinfoRegion { + const tag = /^\s*\[([^\]]+)\]/.exec(name)?.[1].trim() ?? null; + if (tag !== null) { + const districtCode = seoulDistrictCode(tag); + if (districtCode !== null) + return { scope: 'DISTRICTS', districtCodes: [districtCode], basis: `공고명 지역 표시 '[${tag}]'` }; + if (tag.startsWith('서울')) { + const institutionDistrict = seoulDistrictCode(institution); + if (institutionDistrict !== null) { + return { scope: 'DISTRICTS', districtCodes: [institutionDistrict], basis: `소관기관 '${institution}'` }; + } + return { scope: 'SEOUL', districtCodes: [], basis: `공고명 지역 표시 '[${tag}]'` }; + } + const firstWord = tag.split(/[\s·,]/)[0]; + if ( + OTHER_REGION_TAGS.includes(firstWord) || + OTHER_REGION_INSTITUTION_PREFIXES.some((prefix) => tag.startsWith(prefix)) + ) { + return { scope: 'OTHER' }; + } + } + const institutionDistrict = seoulDistrictCode(institution); + if (institutionDistrict !== null) { + return { scope: 'DISTRICTS', districtCodes: [institutionDistrict], basis: `소관기관 '${institution}'` }; + } + if (institution.startsWith('서울')) return { scope: 'SEOUL', districtCodes: [], basis: `소관기관 '${institution}'` }; + if (OTHER_REGION_INSTITUTION_PREFIXES.some((prefix) => institution.startsWith(prefix))) return { scope: 'OTHER' }; + return { scope: 'UNKNOWN' }; +} + +// 공고 주소는 기업마당 기준 상대 경로일 수 있다. 같은 호스트의 http 주소는 https로 읽는다. +function noticeUrl(raw: string): string | null { + let url: URL; + try { + url = new URL(raw, BIZINFO_ORIGIN); + } catch { + return null; + } + if (url.protocol === 'http:' && (url.hostname === 'www.bizinfo.go.kr' || url.hostname === 'bizinfo.go.kr')) { + url.protocol = 'https:'; + } + const value = url.toString(); + return isOfficialUrl(value) ? value : null; +} + +function productKeyFor(pblancId: string): string | null { + const slug = pblancId + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + const key = `bizinfo-${slug}`; + return slug.length > 0 && /^[a-z0-9][a-z0-9-]{2,63}$/.test(key) ? key : null; +} + +function organizationFor(item: BizinfoItem): string | null { + const jurisdiction = cleanText(item.jrsdInsttNm); + const executor = cleanText(item.excInsttNm); + const parts = [jurisdiction, executor].filter((part, index, all) => part.length > 0 && all.indexOf(part) === index); + const organization = parts.join(' · '); + if (organization.length < 2) return null; + return organization.length > 200 ? `${organization.slice(0, 199)}…` : organization; +} + +// 기업마당 응답의 공고를 카탈로그 상품으로 바꾼다. 원문이 주지 않는 사업 단계·지원 유형·용도·업종은 +// 추측하지 않고 미확인으로 남긴다. 판정 기준일에 이미 끝난 공고는 올리지 않는다. +export function mapBizinfoItems( + items: readonly unknown[], + context: { asOfDate: string; responseChecksum: string }, +): BizinfoMapping { + const skipped: BizinfoSkipCounts = { notTarget: 0, otherRegion: 0, ended: 0, malformed: 0, duplicate: 0 }; + const products: CollectedFundingProduct[] = []; + const seen = new Set(); + for (const raw of items) { + const parsed = bizinfoItemSchema.safeParse(raw); + if (!parsed.success) { + skipped.malformed += 1; + continue; + } + const item = parsed.data; + const name = cleanText(item.pblancNm); + const productKey = productKeyFor(item.pblancId); + const officialUrl = noticeUrl(item.pblancUrl); + const organization = organizationFor(item); + if (productKey === null || name.length < 2 || officialUrl === null || organization === null) { + skipped.malformed += 1; + continue; + } + if (seen.has(item.pblancId)) { + skipped.duplicate += 1; + continue; + } + seen.add(item.pblancId); + + const target = cleanText(item.trgetNm); + if (!BIZINFO_TARGET_TAGS.some((tag) => target.includes(tag))) { + skipped.notTarget += 1; + continue; + } + const jurisdiction = cleanText(item.jrsdInsttNm); + const region = bizinfoRegion(name, jurisdiction); + if (region.scope === 'OTHER') { + skipped.otherRegion += 1; + continue; + } + const periodText = cleanText(item.reqstBeginEndDe); + const period = parseBizinfoPeriod(periodText); + if (period !== null && compareIsoDates(period.end, context.asOfDate) < 0) { + skipped.ended += 1; + continue; + } + const open = + period !== null && + compareIsoDates(period.start, context.asOfDate) <= 0 && + compareIsoDates(context.asOfDate, period.end) <= 0; + const realm = cleanText(item.pldirSportRealmLclasCodeNm); + + const evidenceBase = { + sourceUrl: officialUrl, + sourceDocumentName: EVIDENCE_DOCUMENT_NAME, + observedAt: context.asOfDate, + retrievalMethod: 'OFFICIAL_API' as const, + checksum: context.responseChecksum, + }; + const evidence: CollectedFundingProduct['evidence'] = [ + { + ...evidenceBase, + id: 'bizinfo-identity', + subject: 'IDENTITY', + summary: `기업마당 공고 ${item.pblancId}: '${name}' (${organization})`, + }, + { + ...evidenceBase, + id: 'bizinfo-period', + subject: 'APPLICATION_PERIOD', + summary: `신청기간 표기: '${periodText === '' ? '없음' : periodText}'`, + }, + ]; + if (region.scope === 'SEOUL' || region.scope === 'DISTRICTS') { + evidence.push({ + ...evidenceBase, + id: 'bizinfo-region', + subject: 'REGION', + summary: `지역 근거: ${region.basis}`, + }); + } + + products.push({ + productKey, + name: name.length > 200 ? `${name.slice(0, 199)}…` : name, + organization, + supportType: 'UNKNOWN', + eligibleBusinessStages: [], + region: + region.scope === 'UNKNOWN' + ? { + scope: 'UNKNOWN', + districtCodes: [], + note: '기업마당 공고에서 대상 지역을 확인하지 못했습니다. 원 공고에서 확인해야 합니다.', + } + : { scope: region.scope, districtCodes: [...region.districtCodes], note: null }, + purpose: { + included: [], + excluded: [], + note: '기업마당 공고는 지원 용도를 알려 주지 않습니다. 원 공고에서 확인해야 합니다.', + }, + industryConditions: { + scope: 'UNKNOWN', + included: [], + excluded: [], + note: '기업마당 공고는 지원·제외 업종을 알려 주지 않습니다. 원 공고에서 확인해야 합니다.', + }, + applicationPeriod: + period === null + ? { start: null, end: null, note: periodText === '' ? '기업마당 공고에 신청기간이 없습니다.' : periodText } + : { start: period.start, end: period.end, note: null }, + observedApplicationStatus: open ? 'OPEN' : 'UNKNOWN', + observedAt: context.asOfDate, + reviewedAt: context.asOfDate, + nextReviewAt: null, + officialUrl, + sourceDocumentName: '기업마당 지원사업 공고', + sourceDocumentRetrieved: false, + sourceChecksum: null, + publicLimit: null, + interestCondition: null, + interestRateConfirmed: false, + interestRatePercent: null, + repaymentCondition: null, + repaymentMethod: 'UNKNOWN', + repaymentTermMonths: null, + repaymentGraceMonths: null, + unsupportedCalculationReasons: [ + '기업마당 공고는 지원 유형과 금리·기간·상환 조건을 알려 주지 않아 상환 계산 대상이 아닙니다.', + ], + additionalChecks: [ + `기업마당 지원대상 표시: ${target}`, + ...(realm === '' ? [] : [`기업마당 지원분야 표시: ${realm}`]), + '사업 단계·지원 유형·지원 용도·제외 업종은 원 공고에서 확인해야 합니다.', + ], + evidence, + sourceRef: { source: 'BIZINFO', externalId: item.pblancId }, + }); + } + return { products, skipped }; +} + +function snippet(body: string): string { + return body.replace(/\s+/g, ' ').trim().slice(0, 200); +} + +export function createBizinfoAdapter(options: BizinfoAdapterOptions): FundingSourceAdapter { + const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => fetch(url, init)); + const retries = options.retries ?? 2; + const retryDelayMs = options.retryDelayMs ?? 2000; + const timeoutMs = options.timeoutMs ?? 60000; + + async function request(apiKey: string): Promise { + const url = new URL(BIZINFO_API_URL); + url.searchParams.set('crtfcKey', apiKey); + url.searchParams.set('dataType', 'json'); + url.searchParams.set('searchCnt', String(BIZINFO_SEARCH_COUNT)); + let lastError = ''; + for (let attempt = 0; attempt <= retries; attempt += 1) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** (attempt - 1))); + try { + const response = await fetchImpl(url.toString(), { signal: AbortSignal.timeout(timeoutMs) }); + const body = await response.text(); + if (response.status === 403 || response.status === 429) { + // 차단은 우회하지 않고 다시 시도하지도 않는다(ADR 0006 3절). + throw new Error( + `기업마당이 요청을 거부했습니다(HTTP ${response.status}). 이 출처를 실패로 기록하고 멈춥니다.`, + ); + } + if (response.status >= 500) throw new RetryableBizinfoError(`HTTP ${response.status}`); + if (response.status >= 400) throw new Error(`기업마당 API가 HTTP ${response.status}로 응답했습니다.`); + return body; + } catch (error) { + if (!(error instanceof RetryableBizinfoError) && error instanceof Error && !isNetworkError(error)) throw error; + lastError = error instanceof Error ? error.message : String(error); + } + } + throw new Error(`기업마당 API 요청이 ${retries + 1}번 실패했습니다: ${lastError}`); + } + + return { + source: 'BIZINFO', + async collect({ asOfDate, log }): Promise { + const apiKey = options.apiKey?.trim() ?? ''; + if (apiKey === '') { + throw new Error('BIZINFO_API_KEY가 없어 기업마당 공고를 받지 못했습니다. 키 값은 Git에 추가하지 마세요.'); + } + // 요청 주소에 키가 들어가므로 어떤 오류 문장에도 키가 남지 않게 가린다. + const mask = secretMasker(apiKey); + try { + const body = await request(apiKey); + const responseChecksum = createHash('sha256').update(body, 'utf8').digest('hex'); + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + throw new Error(`기업마당 응답이 JSON이 아닙니다(인증키 오류일 수 있습니다): ${snippet(body)}`); + } + const items = (parsed as { jsonArray?: unknown } | null)?.jsonArray; + if (!Array.isArray(items)) { + const keys = + parsed !== null && typeof parsed === 'object' ? Object.keys(parsed).slice(0, 5).join(', ') : typeof parsed; + throw new Error(`기업마당 응답에 공고 목록(jsonArray)이 없습니다. 응답 항목: ${keys || '없음'}`); + } + const total = Number((items[0] as { totCnt?: unknown } | undefined)?.totCnt); + if (Number.isSafeInteger(total) && total > items.length) { + throw new Error( + `기업마당 전체 공고 ${total}건 중 ${items.length}건만 받았습니다. 잘린 응답은 실패로 기록합니다.`, + ); + } + const mapping = mapBizinfoItems(items, { asOfDate, responseChecksum }); + const { skipped } = mapping; + log( + mask( + `기업마당: 공고 ${items.length}건 중 ${mapping.products.length}건을 추가 확인 후보로 만들었습니다(대상 아님 ${skipped.notTarget}건, 서울 밖 ${skipped.otherRegion}건, 신청 종료 ${skipped.ended}건, 형식 오류 ${skipped.malformed}건, 중복 ${skipped.duplicate}건).`, + ), + ); + return { products: mapping.products, fetchedCount: items.length, responseChecksum }; + } catch (error) { + throw new Error(mask(error instanceof Error ? error.message : String(error))); + } + }, + }; +} + +// fetch가 응답을 받기 전에 실패한 경우(연결·시간 초과)만 다시 시도한다. +function isNetworkError(error: Error): boolean { + return error.name === 'TypeError' || error.name === 'TimeoutError' || error.name === 'AbortError'; +} diff --git a/app/src/features/funding/sync.test.ts b/app/src/features/funding/sync.test.ts index fd1bf34..29d4c61 100644 --- a/app/src/features/funding/sync.test.ts +++ b/app/src/features/funding/sync.test.ts @@ -4,7 +4,9 @@ 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 { evaluateCandidates } from './eligibility.ts'; import { findActiveFundingCatalog } from './read.ts'; +import { createBizinfoAdapter } from './sources/bizinfo.ts'; import { nextPatchVersion, productContentFingerprint, @@ -17,6 +19,8 @@ import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION } from './types.ts'; import { FundingCatalogError } from './validation.ts'; const CREATED_DIRS: string[] = []; +// 기업마당 테스트 공고의 상품 키 앞부분. 테스트 DB에서 지울 때 쓴다. +const BIZINFO_TEST_KEY_PREFIX = 'bizinfo-pbln-test'; const OLS_URL = 'https://ols.semas.or.kr/ols/man/SMAN018M/page.do'; function tempDir(): string { @@ -194,6 +198,7 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)('funding:sync와 PostgreSQL', () await db().fundingCatalogRelease.deleteMany({ where: { id: { in: staleIds } } }); } await db().fundingProductVersion.deleteMany({ where: { productKey: { startsWith: 'test-' } } }); + await db().fundingProductVersion.deleteMany({ where: { productKey: { startsWith: BIZINFO_TEST_KEY_PREFIX } } }); const baseline = await db().fundingCatalogRelease.findFirst({ where: { catalogKey: { not: { startsWith: 'test-' } }, status: { in: ['ACTIVE', 'SUPERSEDED'] } }, orderBy: { activatedAt: 'desc' }, @@ -210,6 +215,7 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)('funding:sync와 PostgreSQL', () 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-' } } }); + await db().fundingProductVersion.deleteMany({ where: { productKey: { startsWith: BIZINFO_TEST_KEY_PREFIX } } }); if (previousActiveId) { const previous = await db().fundingCatalogRelease.findUnique({ where: { id: previousActiveId } }); if (previous && previous.status !== 'ACTIVE') { @@ -231,6 +237,10 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)('funding:sync와 PostgreSQL', () expect(report.sources).toMatchObject([ { source: 'SEMAS_OLS', status: 'SUCCEEDED', lastSucceededOn: '2026-09-20', fetchedCount: 10, productCount: 2 }, ]); + expect(report.sources[0].addedProducts.map((product) => product.productKey)).toEqual([ + 'test-sync-fund-a', + 'test-sync-fund-b', + ]); const active = await findActiveFundingCatalog(db()); expect(active?.catalogKey).toBe('test-sync-catalog'); @@ -254,6 +264,8 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)('funding:sync와 PostgreSQL', () collection([collected('fund-a', '2026-09-21'), collected('fund-b', '2026-09-21', { publicLimit: '50000000' })]), }); expect(report.catalogVersion).toBe('2026-09-21.1'); + // 직전 릴리스에 있던 공고는 새 공고로 알리지 않는다. + expect(report.sources[0].addedProducts).toEqual([]); 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'); @@ -277,6 +289,7 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)('funding:sync와 PostgreSQL', () fetchedCount: 10, carriedOver: true, productCount: 2, + addedProducts: [], }); expect(report.sources[0].failureReason).toContain('출처 응답 403'); const active = await findActiveFundingCatalog(db()); @@ -354,6 +367,71 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)('funding:sync와 PostgreSQL', () expect(await db().fundingCatalogRelease.count()).toBe(before); }); + it('기업마당 공고를 유형·사업 단계 미확인 상품으로 적재하고 추가 확인으로 둔다', async () => { + const response = JSON.stringify({ + jsonArray: [ + { + pblancId: 'PBLN_TEST_0001', + pblancNm: '[서울] 소상공인 경영 개선 지원사업 공고', + pblancUrl: '/web/lay1/bbs/S1T122C128/AS/74/view.do?pblancId=PBLN_TEST_0001', + jrsdInsttNm: '서울특별시', + excInsttNm: '서울신용보증재단', + reqstBeginEndDe: '2026-09-01 ~ 2026-10-31', + trgetNm: '소상공인', + pldirSportRealmLclasCodeNm: '경영', + totCnt: 2, + }, + { + pblancId: 'PBLN_TEST_0002', + pblancNm: '[경기] 소상공인 지원사업 공고', + pblancUrl: '/web/lay1/bbs/S1T122C128/AS/74/view.do?pblancId=PBLN_TEST_0002', + jrsdInsttNm: '경기도', + excInsttNm: '', + reqstBeginEndDe: '2026-09-01 ~ 2026-10-31', + trgetNm: '소상공인', + pldirSportRealmLclasCodeNm: '경영', + totCnt: 2, + }, + ], + }); + const report = await runFundingSync({ + prisma: db(), + catalogPath: manualCatalogFile(), + adapters: [ + createBizinfoAdapter({ + apiKey: 'test-key', + fetchImpl: async () => ({ status: 200, text: async () => response }), + }), + ], + asOfDate: '2026-09-28', + outputDir: tempDir(), + now: () => new Date('2026-09-28T01:00:00.000Z'), + }); + expect(report.load?.outcome).toBe('ACTIVATED'); + expect(report.sources).toMatchObject([ + { source: 'BIZINFO', status: 'SUCCEEDED', lastSucceededOn: '2026-09-28', fetchedCount: 2, productCount: 1 }, + ]); + expect(report.sources[0].addedProducts).toEqual([ + { productKey: 'bizinfo-pbln-test-0001', name: '[서울] 소상공인 경영 개선 지원사업 공고' }, + ]); + + const stored = await db().fundingProductVersion.findUnique({ + where: { productKey_version: { productKey: 'bizinfo-pbln-test-0001', version: '1.0.0' } }, + }); + expect(stored?.supportType).toBe('UNKNOWN'); + expect(stored?.repaymentCalculationSupported).toBe(false); + + const active = await findActiveFundingCatalog(db()); + if (!active) throw new Error('ACTIVE 카탈로그가 없습니다.'); + const list = evaluateCandidates( + active.catalog, + { businessStage: 'PRE_REGISTRATION', districtCode: '11140', industryCode: 'CS100010', purpose: 'STARTUP_COST' }, + { asOfDate: '2026-09-28' }, + ); + const bizinfo = list.evaluations.find((evaluation) => evaluation.productKey === 'bizinfo-pbln-test-0001'); + expect(bizinfo).toMatchObject({ supportType: 'UNKNOWN', candidateStatus: 'NEEDS_CONFIRMATION' }); + }); + it('사람이 관리하는 카탈로그에 자동 검수자 상품이 있으면 거부한다', async () => { const catalogPath = manualCatalogFile([ manualProduct(), diff --git a/app/src/features/funding/sync.ts b/app/src/features/funding/sync.ts index 2c14515..4ab07c9 100644 --- a/app/src/features/funding/sync.ts +++ b/app/src/features/funding/sync.ts @@ -53,6 +53,8 @@ export type FundingSourceSyncReport = FundingSourceSync & { productCount: number; // 실패해서 직전 ACTIVE 릴리스의 상품을 그대로 가져왔는지. carriedOver: boolean; + // 직전 ACTIVE 릴리스에 없던 공고로 만든 상품. 운영자가 새 공고·변경공고를 살펴보는 데 쓴다. + addedProducts: Array<{ productKey: string; name: string }>; }; export type FundingSyncReport = { @@ -231,9 +233,17 @@ export async function runFundingSync(options: FundingSyncOptions): Promise entry.source === adapter.source) ?? null; const attemptedAt = now().toISOString(); + const previousProducts = (previous?.catalog.products ?? []).filter( + (product) => isAutoReviewer(product.reviewer) && product.sourceRef?.source === adapter.source, + ); try { const collected = await collectSource(adapter, { prisma, manual, previousSync, asOfDate, log }); automatedProducts.push(...collected.products); + const previousRefs = new Set( + previousProducts.flatMap((product) => + product.sourceRef === undefined ? [] : [sourceRefKey(product.sourceRef)], + ), + ); sources.push({ source: adapter.source, status: 'SUCCEEDED', @@ -244,15 +254,15 @@ export async function runFundingSync(options: FundingSyncOptions): Promise product.sourceRef !== undefined && !previousRefs.has(sourceRefKey(product.sourceRef))) + .map((product) => ({ productKey: product.productKey, name: product.name })), }); 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); + automatedProducts.push(...previousProducts); sources.push({ source: adapter.source, status: 'FAILED', @@ -261,11 +271,12 @@ export async function runFundingSync(options: FundingSyncOptions): Promise = { SEMAS_OLS: '소상공인시장진흥공단 정책자금 사이트', + BIZINFO: '기업마당 지원사업정보', }; +// 새 공고를 찾는 데만 쓰는 출처. 사업 단계·지원 유형을 알 수 없어, 이 출처만으로 확인된 +// 상품은 사람이 확인하기 전에는 현재 후보가 되지 않는다(ADR 0006 2절). +export const DISCOVERY_ONLY_SOURCES: readonly FundingSource[] = ['BIZINFO']; + // 출처의 마지막 성공 동기화가 이 일수를 넘기면 그 출처의 자동 상품은 현재 후보에서 빠진다. export const SOURCE_FRESHNESS_DAYS = 3; -export const SUPPORT_TYPES = ['GRANT', 'GUARANTEE', 'LOAN', 'SPACE', 'PROGRAM'] as const; +// UNKNOWN은 원문이 지원 유형을 알려 주지 않는 공고(예: 기업마당 공고)다. 지원금이나 +// 대출로 추측해 표시하지 않고, 원 공고에서 확인해야 하는 값으로 남긴다. +export const SUPPORT_TYPES = ['GRANT', 'GUARANTEE', 'LOAN', 'SPACE', 'PROGRAM', 'UNKNOWN'] as const; export type SupportType = (typeof SUPPORT_TYPES)[number]; // GRANT is a non-repayable subsidy. GUARANTEE, LOAN, SPACE and PROGRAM do not // repay the same way, so only LOAN products can ever reach repayment math. +// UNKNOWN은 대출인지 알 수 없으므로 상환 계산 대상이 아니다. export const REPAYABLE_SUPPORT_TYPES: readonly SupportType[] = ['LOAN']; // 정책자금 갈래(대출·보증). ADR 0006에 따라 자동 승격은 이 갈래에만 쓴다. 지원금·공간· @@ -112,11 +122,17 @@ export const EVIDENCE_SUBJECTS = [ ] as const; export type EvidenceSubject = (typeof EVIDENCE_SUBJECTS)[number]; -export const REQUIRED_EVIDENCE_SUBJECTS: readonly EvidenceSubject[] = [ - 'IDENTITY', - 'APPLICATION_PERIOD', - 'BUSINESS_STAGE', -]; +// 상품마다 공식 원문 근거가 있어야 하는 조건. 사업 단계는 원문에서 확인해 기록했을 때만, +// 금융 조건은 대출·보증일 때만 요구한다. 사업 단계를 빈 배열(미확인)로 두면 근거도 없다. +export function requiredEvidenceSubjects(product: { + supportType: SupportType; + eligibleBusinessStages: readonly BusinessStage[]; +}): EvidenceSubject[] { + const subjects: EvidenceSubject[] = ['IDENTITY', 'APPLICATION_PERIOD']; + if (product.eligibleBusinessStages.length > 0) subjects.push('BUSINESS_STAGE'); + if (POLICY_LOAN_SUPPORT_TYPES.includes(product.supportType)) subjects.push('FINANCIAL_CONDITION'); + return subjects; +} export const RETRIEVAL_METHODS = [ 'OFFICIAL_WEB_PAGE', diff --git a/app/src/features/funding/validation.ts b/app/src/features/funding/validation.ts index 2e3afe4..0293bf5 100644 --- a/app/src/features/funding/validation.ts +++ b/app/src/features/funding/validation.ts @@ -2,12 +2,12 @@ import { ZodError } from 'zod'; import { REPAYABLE_SUPPORT_TYPES, REVIEWER_UNASSIGNED, - REQUIRED_EVIDENCE_SUBJECTS, SOURCE_FRESHNESS_DAYS, compareIsoDates, isAutoReviewer, isSupportedRepaymentMethod, isPrimaryRetrievalMethod, + requiredEvidenceSubjects, reviewState, type EvidenceSubject, type SourceFreshness, @@ -231,8 +231,7 @@ function checkProduct( } } const subjects = new Set(evidence.map((entry) => entry.subject)); - const required: EvidenceSubject[] = [...REQUIRED_EVIDENCE_SUBJECTS]; - if (product.supportType === 'LOAN' || product.supportType === 'GUARANTEE') required.push('FINANCIAL_CONDITION'); + const required: EvidenceSubject[] = requiredEvidenceSubjects(product); if (product.region.scope !== 'UNKNOWN') required.push('REGION'); if (product.purpose.included.length > 0 || product.purpose.excluded.length > 0) required.push('PURPOSE'); if (product.industryConditions.scope !== 'UNKNOWN') required.push('INDUSTRY'); @@ -416,7 +415,23 @@ function checkProduct( product.interestRatePercent !== null || product.repaymentTermMonths !== null || product.repaymentGraceMonths !== null; - if (!repayable) { + if (product.supportType === 'UNKNOWN') { + // 지원 유형을 모르면 대출인지도 모른다. 상환방식은 NOT_APPLICABLE이 아니라 UNKNOWN이다. + if (product.repaymentMethod !== 'UNKNOWN') { + collector.add( + 'SUPPORT_TYPE_FINANCIAL_CONTRADICTION', + source, + '지원 유형이 확인되지 않았으면(UNKNOWN) 상환방식도 UNKNOWN이어야 합니다.', + ); + } + if (product.interestRateConfirmed || structuredTerms) { + collector.add( + 'SUPPORT_TYPE_FINANCIAL_CONTRADICTION', + source, + '지원 유형이 확인되지 않았는데 확정 금리·상환기간·거치 조건을 기록했습니다. 대출로 확인했으면 지원 유형을 LOAN으로 기록하세요.', + ); + } + } else if (!repayable) { if (product.interestCondition !== null || product.repaymentCondition !== null) { collector.add( 'SUPPORT_TYPE_FINANCIAL_CONTRADICTION', diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 577f8b1..d00295f 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:sync` | `funding.test.ts`, `candidates.test.ts`, `sync.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`, `sources/bizinfo.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 abc4325..6b434b8 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -26,7 +26,7 @@ npm --prefix app run dev | 파일 | 값 | |---|---| | `infra/.env` | `POSTGRES_PASSWORD`, `POSTGRES_PORT` | -| `app/.env.local` | `DATABASE_URL`, `BETTER_AUTH_SECRET`(32자 이상), `BETTER_AUTH_URL`, 상권 적재 시 `SEOUL_OPEN_API_KEY`, 점포·프랜차이즈 적재 시 `SEMAS_SERVICE_KEY`, 임대료 적재 시 `REB_API_KEY` | +| `app/.env.local` | `DATABASE_URL`, `BETTER_AUTH_SECRET`(32자 이상), `BETTER_AUTH_URL`, 상권 적재 시 `SEOUL_OPEN_API_KEY`, 점포·프랜차이즈 적재 시 `SEMAS_SERVICE_KEY`, 임대료 적재 시 `REB_API_KEY`, 자금 동기화 시 `BIZINFO_API_KEY` | | `app/.env.test.local` | `TEST_DATABASE_URL`(DB명 `trendbench_mvp_test`) | 직접 만들 때는 세 파일을 모두 만든다. 비밀번호는 같은 값을 쓰고 URL의 특수문자는 인코딩한다. 예시는 `infra/.env.example`과 `app/.env.example`에 있다. Prisma CLI와 적재 명령도 `app/.env.local`을 읽는다. @@ -43,7 +43,7 @@ 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#동기화)에 있다. +자동 수집 출처와 합쳐 자금 카탈로그를 적재하려면 `npm --prefix app run funding:sync`를 실행한다. 기업마당(www.bizinfo.go.kr)에서 지원사업정보 API 인증키를 발급받아 `BIZINFO_API_KEY`에 넣는다. 키가 없으면 기업마당 출처는 실패로 기록되고 직전 릴리스의 기업마당 상품이 그대로 남는다. `-- --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 2babbe8..339b11b 100644 --- a/docs/domains/funding.md +++ b/docs/domains/funding.md @@ -7,7 +7,7 @@ - `app/src/features/funding`: 판정 함수는 React·DB·외부 API에 의존하지 않는다. - 카탈로그 Zod 스키마(`schema.ts`), 규칙 검사(`validation.ts`), 고정 조건 판정(`eligibility.ts`) - 릴리스·상품 버전 적재, 활성 카탈로그 읽기(`read.ts`), 요청 스키마·응답 조립(`candidates.ts`) - - 출처별 수집 결과를 사람이 관리하는 카탈로그와 합쳐 적재하는 동기화(`sync.ts`) + - 출처별 수집 결과를 사람이 관리하는 카탈로그와 합쳐 적재하는 동기화(`sync.ts`), 출처별 수집기(`sources/`) - 페이지 단위 후보 화면(`FundingMatcher.tsx`), 두 화면이 함께 쓰는 결과 표시(`FundingCandidatesPanel.tsx`) - `app/catalog/funding`: 운영자가 공식 공고를 정리한 JSON 카탈로그와 갱신 절차 문서. @@ -30,6 +30,9 @@ - 스키마 `funding-catalog-v1.1.0`은 v1.0.0에 다음을 더했다. v1.0.0으로 적재된 릴리스도 읽으며, 그때 자동화 설정은 모두 꺼진 기본값이다. - 상품의 `sourceRef`(출처와 공고 식별자). funding:sync가 모은 상품에 붙고, 사람이 기록한 상품도 같은 공고를 가리키면 적는다. - 카탈로그의 `automation`: 정책자금 자동 승격(`policyLoanPromotion`)·상환 계산 자동 승격(`repaymentPromotion`) 스위치, 차단 목록(`blockedSources`), 출처별 동기화 결과(`sourceSyncs`). 스위치는 기본으로 꺼져 있다. +- 스키마 `funding-catalog-v1.2.0`은 v1.1.0에 원문이 알려 주지 않는 값을 추측 없이 기록하는 방법을 더했다. 이전 버전의 릴리스도 읽는다. + - 지원 유형 `UNKNOWN`(DB enum `FundingSupportType`에도 있다). 상환방식은 `UNKNOWN`이어야 하고 확정 금리·상환기간·거치를 기록할 수 없다. 금융 조건 근거를 요구하지 않는다. + - `eligibleBusinessStages`의 빈 배열은 사업 단계 미확인이다. 이때 사업 단계 근거를 요구하지 않는다. - 자동 상품은 검수자에 사람 대신 자동 규칙 버전(`AUTO:funding-auto-v1`)을 적는다. 규칙 검사는 다음을 오류로 본다: 출처 식별자 없는 자동 검수자, 같은 공고를 가리키는 상품 두 개, 같은 출처의 동기화 결과 두 개. 자동 상품에는 다음 검토일이 없어도 된다. ### 릴리스와 상품 버전 @@ -65,13 +68,34 @@ | 출처 | 대상 | 확인일 | 확인 결과 | 수집기 | |---|---|---|---|---| | `SEMAS_OLS` | 소진공 정책자금 사이트 `ols.semas.or.kr` | 2026-09-28 | robots.txt 없음(404). 약관에 자동 수집 금지 조항 없음. 담당자가 출처 표시 조건으로 수집 허용 | 아직 등록하지 않음 | +| `BIZINFO` | 기업마당 지원사업정보 Open API `www.bizinfo.go.kr/uss/rss/bizinfoApi.do` | 2026-09-28 | 인증키를 발급하는 공식 Open API. 실제 응답 확인(ADR 0006 Context) | `sources/bizinfo.ts` | + +#### 기업마당 공고 탐색 + +기업마당은 새 공고를 찾는 데만 쓴다(ADR 0006 1·2절). 여기서 만든 상품은 사업 단계·지원 유형을 알 수 없어 사람이 확인하기 전에는 추가 확인에 머문다. + +- 요청: `BIZINFO_API_KEY`로 하루 한 번, 요청 한 번(`searchCnt` 5000)에 현재 공고 전체를 JSON으로 받는다. 오류 문장과 명령 출력에서 키를 가린다. +- 실패: 키 없음, 403·429(다시 시도하지 않고 멈춘다), 서버 오류·연결 오류가 세 번 이어짐, JSON이 아닌 응답, 공고 목록(`jsonArray`) 없음, 받은 공고 수가 응답의 전체 건수(`totCnt`)보다 적음. 받은 공고 수(`fetchedCount`)는 거르기 전의 전체 공고 수다. +- 수집 범위: 지원대상(`trgetNm`)에 `소상공인` 또는 `창업벤처`가 있는 공고만 올린다. +- 빼는 공고: 서울 밖 공고, 신청 종료일이 판정 기준일보다 앞선 공고, 공고 식별자·이름·공식 주소·기관이 없는 공고, 같은 식별자의 두 번째 공고. 명령 출력에 사유별 개수를 남긴다. +- 지역: 공고명 앞 `[지역]` 표시가 있으면 그것으로, 없으면 소관기관(`jrsdInsttNm`) 이름으로 정한다. + - `서울특별시 ○○구`처럼 서울 자치구가 확인되면 DISTRICTS, 서울이면 SEOUL, 다른 시·도면 뺀다. + - 어느 것도 아니면 전국으로 추측하지 않고 UNKNOWN이다. +- 상품으로 옮기는 값: + - 상품 키는 `bizinfo-` + 공고 식별자(`pblancId`), 출처 식별자는 `BIZINFO:pblancId`다. + - 지원 유형 `UNKNOWN`, 사업 단계 빈 배열, 용도·업종 UNKNOWN, 금리·한도·상환 조건 `null`이다. + - 신청기간은 `YYYY-MM-DD ~ YYYY-MM-DD`만 날짜로 읽는다. 판정 기준일이 그 사이면 접수 상태가 OPEN, 아니면 UNKNOWN이다. "예산 소진시까지" 같은 문장은 날짜 없이 note에 그대로 남긴다. + - 근거는 공고명·기관, 신청기간 표기, 지역 표시(서울·자치구일 때)를 `OFFICIAL_API`와 응답 checksum으로 남긴다. + - 지원대상·지원분야 표시는 신청 전 확인 항목에 적는다. +- 명령 출력: 직전 ACTIVE 릴리스에 없던 공고를 출처별로 나열한다. 운영자가 서울시 육성자금 같은 사람 관리 상품의 새 공고·변경공고를 찾는 데 쓴다. +- 공고가 기업마당에서 사라지면 다음 동기화에서 빠지고, 신청 종료일이 지나면 그 전에도 접수 종료로 판정된다. ### 판정 - 판정 규칙(`eligibility.ts`): 조건별로 PASS/FAIL/UNKNOWN을 계산한다. FAIL이 하나라도 있으면 FAIL, FAIL 없이 UNKNOWN이 있으면 UNKNOWN, 모두 PASS면 PASS다. - REGION·PURPOSE·INDUSTRY를 포함한 각 조건에 직접 확인한 공식 근거가 없으면 UNKNOWN이다. - 조건별 UNKNOWN의 효과([ADR 0006](../decisions/0006-funding-discovery-automation.md)): - - 사업 단계·지역이 UNKNOWN이면 현재 후보로 확정하지 않고 추가 확인으로 분류한다. + - 사업 단계·지역이 UNKNOWN이면 현재 후보로 확정하지 않고 추가 확인으로 분류한다. 상품의 사업 단계가 빈 배열(원문 미확인)이면 사업 단계는 UNKNOWN이다. - 업종·용도가 UNKNOWN이면 현재 후보를 막지 않는다. 대신 "업종: …", "용도: …" 형태로 신청 전 추가 확인(`manualChecks`)에 더한다. 이때 자격 판정(`eligibilityVerdict`)은 그대로 UNKNOWN이다. - FAIL은 조건과 상관없이 조건 미충족이다. - 접수 상태 OPEN/CLOSED/UNKNOWN은 자격 판정과 별도로 유지한다. @@ -80,10 +104,10 @@ - 사업자등록 이후에만 검토할 수 있는 상품은 POST_REGISTRATION 상태로 현재 예비 창업자 후보와 분리한다. - 자동 상품(검수자 `AUTO:…`)의 추가 규칙(ADR 0006). 사람이 기록한 상품은 `sourceRef`가 있어도 지금 규칙만 따른다. - 다음 검토일 대신 출처의 마지막 성공 동기화일로 최신성을 본다. 그날에서 3일이 지났거나 성공 기록이 없으면 검수 기한 경과(`REVIEW_OVERDUE`)다. - - 차단 목록의 공고, 지원사업(지원금·공간·프로그램) 자동 상품, 정책자금 자동 승격이 꺼진 상태의 자동 상품은 현재 후보가 아니라 추가 확인이며 사유를 표시한다. + - 차단 목록의 공고, 새 공고를 찾는 데만 쓰는 출처(기업마당)의 자동 상품, 지원사업(지원금·공간·프로그램)과 유형 미확인 자동 상품, 정책자금 자동 승격이 꺼진 상태의 자동 상품은 현재 후보가 아니라 추가 확인이며 사유를 표시한다. - 상환 계산 자동 승격이 꺼져 있으면 자동 상품은 확정 조건이 있어도 상환 계산 대상이 아니다. -- 지원 유형 표시: GRANT 지원금, GUARANTEE 보증, LOAN 대출, SPACE 공간·보육, PROGRAM 프로그램. -- 지원금·보증·공간·프로그램 지원은 대출 원금이나 상환 일정으로 변환하지 않는다. +- 지원 유형 표시: GRANT 지원금, GUARANTEE 보증, LOAN 대출, SPACE 공간·보육, PROGRAM 프로그램, UNKNOWN 유형 미확인(원 공고 확인). +- 지원금·보증·공간·프로그램 지원과 유형 미확인 공고는 대출 원금이나 상환 일정으로 변환하지 않는다. - 대출의 상환 계산 대상 여부: - 공개 한도, 확정 금리(`interestRateConfirmed`와 `interestRatePercent`), 상환기간·거치(`repaymentTermMonths`·`repaymentGraceMonths`), 상환방식이 모두 확정되고, 계산 엔진이 지원하는 방식이며, 거치개월이 전체 상환개월보다 작아야 상환 계산 대상이다. - 하나라도 어긋나면 상환 계산 대상으로 표시하지 않고 사유를 남긴다. @@ -134,6 +158,7 @@ ## 테스트 - DB 없이: 카탈로그 스키마·규칙 위반, 실제 카탈로그 5건의 규칙 통과와 모두 상환 계산 대상이 아님(`supported=false`), 조건 판정, 상환 계산 가능 여부, 상품 조건과 사용자 원금의 결합. 후보 조회·계획 조건 판정·상품 조건 적용 API의 경계(상태 코드)는 Prisma를 mock해 DB 없이 검증한다. -- DB 없이: 자동 상품의 최신성·승격 보류·상환 계산 스위치, v1.0.0 릴리스 읽기, 자동 상품 규칙 검사, 동기화의 상품 버전 재사용 규칙. +- DB 없이: 자동 상품의 최신성·승격 보류·상환 계산 스위치, v1.0.0 릴리스 읽기, 자동 상품 규칙 검사, 동기화의 상품 버전 재사용 규칙, 사업 단계·지원 유형 미확인 상품의 규칙 검사와 판정. +- DB 없이(`sources/bizinfo.test.ts`): 기업마당 신청기간·지역 해석, 수집 범위와 사유별 제외, 만든 상품의 규칙 검사 통과와 추가 확인 판정, 요청 인자, 키 없음·403·429·재시도·JSON 아님·잘린 응답, 오류 문장의 키 가림. - `TEST_DATABASE_URL` 전용 DB: 카탈로그 적재, 실제 DB에서의 후보 조회·계획 조건 판정·상품 조건 적용, 출처 저장과 과거 결과 불변성. 합성 카탈로그만 만들고 끝나면 지우며 실행 전 ACTIVE 상태를 복원한다. -- `TEST_DATABASE_URL` 전용 DB의 동기화(`sync.test.ts`): 합성 수집기로 적재, 버전 재사용과 새 버전, 출처 실패 시 직전 상품 유지, 빈 응답·급감, 검증 실패, 사람 기록 우선, dry run. +- `TEST_DATABASE_URL` 전용 DB의 동기화(`sync.test.ts`): 합성 수집기로 적재, 버전 재사용과 새 버전, 출처 실패 시 직전 상품 유지, 빈 응답·급감, 검증 실패, 사람 기록 우선, dry run, 새 공고 목록, 기업마당 수집기(가짜 응답)로 유형 미확인 상품 적재와 추가 확인 판정.