From 835420d810853e985f240a57f77078d92ff89c7d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:35:50 +0000 Subject: [PATCH 1/2] 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, 새 공고 목록, 기업마당 수집기(가짜 응답)로 유형 미확인 상품 적재와 추가 확인 판정. From cc201a97998a7bd15e88d4bb4687e9b4b2f7c9e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:49:07 +0000 Subject: [PATCH 2/2] feat(funding): split funding candidates into policy funds and support programs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the candidate results in two branches as ADR 0006 section 1 asks: policy funds (LOAN, GUARANTEE) and support programs (GRANT, SPACE, PROGRAM). Notices whose support type is UNKNOWN are not guessed into either branch and appear in a separate "유형 미확인" section only when there are any. - Add branch and automation (source, label, last successful check) to each evaluation and per-branch status counts to the candidate list. The branch is derived from supportType and never stored. - Render a branch overview and one section per branch with its status counts and non-empty status groups on /funding and the plan screen. - Mark automatically collected products with "자동 확인", their source and last confirmation date instead of the human review line (ADR 0006 section 7). - Split the funding row in PRODUCT.md and narrow the exclusion to AI-based notice interpretation (ADR 0006 sections 8 and 9). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- app/src/app/globals.css | 93 ++++++++- .../funding/FundingCandidatesPanel.tsx | 181 +++++++++++++----- app/src/features/funding/candidates.test.ts | 5 + app/src/features/funding/eligibility.ts | 66 +++++-- app/src/features/funding/funding.test.ts | 123 +++++++++++- app/src/features/funding/types.ts | 10 + docs/PRODUCT.md | 5 +- docs/domains/funding.md | 11 +- 8 files changed, 428 insertions(+), 66 deletions(-) diff --git a/app/src/app/globals.css b/app/src/app/globals.css index 2ff1cb7..2e7e0d1 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -921,6 +921,83 @@ footer { .status-count.count-total { border-left: 4px solid var(--ink); } +.branch-overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; + margin-bottom: 22px; +} +.branch-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + background: var(--paper); + border: 1px solid var(--line); + border-top: 4px solid var(--ink); + border-radius: 12px; + color: inherit; + text-decoration: none; +} +.branch-card span { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} +.branch-card strong { + font-size: 19px; + letter-spacing: -0.5px; +} +.branch-card small { + color: var(--muted); + font-size: 12px; +} +.branch-card.branch-policy_fund { + border-top-color: #8a5320; +} +.branch-card.branch-support_program { + border-top-color: #245c39; +} +.branch-card.branch-unclassified { + border-top-color: #8f9c93; +} +.funding-branch { + margin: 34px 0 12px; + scroll-margin-top: 16px; +} +.branch-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding-bottom: 10px; + border-bottom: 2px solid var(--ink); +} +.branch-head h2 { + margin: 0; + font-size: 22px; + word-break: keep-all; +} +.branch-head span { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} +.branch-note { + margin: 10px 0 14px; + color: var(--muted); + font-size: 13px; +} +.status-counts.branch-counts { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 16px; +} +.status-counts.branch-counts .status-count { + padding: 10px 14px; +} +.status-counts.branch-counts strong { + font-size: 16px; +} .candidate-group { background: var(--paper); border: 1px solid var(--line); @@ -938,9 +1015,9 @@ footer { justify-content: space-between; gap: 12px; } -.group-head h2 { +.group-head h3 { margin: 0; - font-size: 20px; + font-size: 19px; } .group-head span { color: var(--muted); @@ -978,7 +1055,7 @@ footer { gap: 14px; align-items: flex-start; } -.candidate-head h3 { +.candidate-head h4 { margin: 0; font-size: 17px; word-break: keep-all; @@ -996,6 +1073,7 @@ footer { flex: 0 0 auto; } .support-badge, +.auto-badge, .candidate-status { display: inline-block; padding: 4px 11px; @@ -1028,6 +1106,10 @@ footer { background: #ecebe6; color: #4c4a3d; } +.auto-badge { + background: #e7edf6; + color: #2c4a72; +} .candidate-status { background: #e3e0d4; color: #4c4a3d; @@ -1106,7 +1188,7 @@ footer { gap: 20px; margin-top: 18px; } -.candidate-detail h4 { +.candidate-detail h5 { margin: 0 0 8px; font-size: 14px; } @@ -1143,6 +1225,9 @@ footer { .status-counts { grid-template-columns: 1fr; } + .status-counts.branch-counts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } .candidate-group { padding: 18px; } diff --git a/app/src/features/funding/FundingCandidatesPanel.tsx b/app/src/features/funding/FundingCandidatesPanel.tsx index c48dadf..11bd3af 100644 --- a/app/src/features/funding/FundingCandidatesPanel.tsx +++ b/app/src/features/funding/FundingCandidatesPanel.tsx @@ -9,7 +9,7 @@ import { type FundingCandidateStatus, type FundingProfile, } from './eligibility.ts'; -import type { ObservedApplicationStatus, ReviewState, SupportType, Verdict } from './types.ts'; +import type { FundingBranch, ObservedApplicationStatus, ReviewState, SupportType, Verdict } from './types.ts'; // 페이지 단위 입력(/funding)과 저장된 계획 조건(/plans)이 같은 판정 결과를 같은 // 모양으로 보여주도록 결과 표시를 한 곳에 둔다. 두 화면은 조회 진입점만 다르다. @@ -50,6 +50,40 @@ const REVIEW_STATE_LABELS: Record = { REVIEW_OVERDUE: '검수 기한 경과', }; +// 자동으로 모은 상품은 사람의 검수 대신 출처의 마지막 성공 확인으로 최신성을 판단한다. +const AUTO_REVIEW_STATE_LABELS: Record = { + CURRENT: '출처 확인 최신', + UNREVIEWED: '검수자 미지정', + REVIEW_OVERDUE: '출처 확인 끊김', +}; + +// 정책자금과 지원사업을 나눠 보여 준다(ADR 0006 1절). 유형을 모르는 공고는 어느 쪽으로도 +// 추측하지 않고 따로 보여 주며, 그런 공고가 있을 때만 표시한다. +const BRANCHES: { branch: FundingBranch; title: string; note: string; hideWhenEmpty: boolean }[] = [ + { + branch: 'POLICY_FUND', + title: '정책자금(대출·보증)', + note: '정책 대출과 보증입니다. 대출은 금리·기간·상환방식이 모두 확정된 상품만 상환 계산에 씁니다. 자동으로 모은 공고는 운영자가 자동 승격을 켰을 때만 검토 후보가 됩니다.', + hideWhenEmpty: false, + }, + { + branch: 'SUPPORT_PROGRAM', + title: '지원사업(지원금·공간·프로그램)', + note: '상환하지 않는 지원금과 공간·교육 프로그램입니다. 조달액이나 대출 상환으로 계산하지 않습니다. 자동으로 찾은 공고는 사람이 확인해야 검토 후보가 됩니다.', + hideWhenEmpty: false, + }, + { + branch: 'UNCLASSIFIED', + title: '유형 미확인', + note: '공고가 지원 유형을 알려 주지 않아 정책자금·지원사업 어느 쪽으로도 나누지 않았습니다. 원 공고에서 유형과 대상을 확인해 주세요.', + hideWhenEmpty: true, + }, +]; + +function branchAnchor(branch: FundingBranch): string { + return `funding-branch-${branch.toLowerCase().replace('_', '-')}`; +} + // 결과는 현재 후보와 그렇지 않은 상태를 섞지 않고 나눠서 보여준다. const GROUPS: { status: FundingCandidateStatus; title: string; note: string; separated: boolean }[] = [ { @@ -188,6 +222,7 @@ export function FundingCandidatesPanel({ }) { const { summary } = result; const emptyProfile = isEmptyFundingProfile(result.profile); + const branches = BRANCHES.filter((entry) => !entry.hideWhenEmpty || result.summaryByBranch[entry.branch].total > 0); return ( <>
@@ -234,19 +269,6 @@ export function FundingCandidatesPanel({

판정에 쓴 조건: {profileSummary}

-
- {GROUPS.map((group) => ( -
- {group.title} - {numberFormat.format(summary[group.status])}건 -
- ))} -
- 전체 상품 - {numberFormat.format(summary.total)}건 -
-
- {summary.total === 0 && (

활성 카탈로그에 상품이 없습니다.

@@ -254,6 +276,18 @@ export function FundingCandidatesPanel({
)} + {summary.total > 0 && ( + + )} + {summary.total > 0 && summary.CURRENT_CANDIDATE === 0 && (

현재 신청 가능한 후보가 0건입니다.

@@ -266,20 +300,68 @@ export function FundingCandidatesPanel({
)} - {GROUPS.map((group) => { - const items = result.evaluations.filter((evaluation) => evaluation.candidateStatus === group.status); - return ( -
-
-

{group.title}

- {numberFormat.format(items.length)}건 -
-

- {group.separated && 현재 후보와 분리} {group.note} -

- {items.length === 0 ? ( -

해당하는 상품이 없습니다.

- ) : ( + {summary.total > 0 && + branches.map(({ branch, title, note }) => ( + evaluation.branch === branch)} + branchSummary={result.summaryByBranch[branch]} + apply={apply} + /> + ))} + + ); +} + +function BranchSection({ + branch, + title, + note, + evaluations, + branchSummary, + apply, +}: { + readonly branch: FundingBranch; + readonly title: string; + readonly note: string; + readonly evaluations: readonly FundingCandidateEvaluation[]; + readonly branchSummary: FundingCandidatesResponse['summary']; + readonly apply?: FundingApplyContext; +}) { + const anchor = branchAnchor(branch); + return ( +
+
+

{title}

+ {numberFormat.format(branchSummary.total)}건 +
+

{note}

+
+ {GROUPS.map((group) => ( +
+ {group.title} + {numberFormat.format(branchSummary[group.status])}건 +
+ ))} +
+ {evaluations.length === 0 ? ( +

이 갈래에 적재된 상품이 없습니다.

+ ) : ( + GROUPS.map((group) => { + const items = evaluations.filter((evaluation) => evaluation.candidateStatus === group.status); + if (items.length === 0) return null; + return ( +
+
+

{group.title}

+ {numberFormat.format(items.length)}건 +
+

+ {group.separated && 현재 후보와 분리} {group.note} +

{items.map((evaluation) => ( ))}
- )} -
- ); - })} - + + ); + }) + )} +
); } @@ -318,10 +400,11 @@ function CandidateCard({
-

{evaluation.name}

+

{evaluation.name}

{evaluation.organization}

+ {evaluation.automation && 자동 확인} {SUPPORT_TYPE_LABELS[evaluation.supportType]} @@ -347,13 +430,23 @@ function CandidateCard({ {formatFundingDate(evaluation.observedAt)} 확인
-
-
검수 상태
-
- {REVIEW_STATE_LABELS[evaluation.reviewState]} · 검수일 {formatFundingDate(evaluation.reviewedAt)} · 다음 - 검토일 {formatFundingDate(evaluation.nextReviewAt)} -
-
+ {evaluation.automation ? ( +
+
확인 방식
+
+ 자동 확인({AUTO_REVIEW_STATE_LABELS[evaluation.reviewState]}) · 출처 {evaluation.automation.sourceLabel} · + 마지막 확인일 {formatFundingDate(evaluation.automation.lastSucceededOn)} +
+
+ ) : ( +
+
검수 상태
+
+ {REVIEW_STATE_LABELS[evaluation.reviewState]} · 검수일 {formatFundingDate(evaluation.reviewedAt)} · 다음 + 검토일 {formatFundingDate(evaluation.nextReviewAt)} +
+
+ )}
자격 판정
{VERDICT_LABELS[evaluation.eligibilityVerdict]}
@@ -392,7 +485,7 @@ function CandidateCard({
-

신청 전 추가 확인

+
신청 전 추가 확인
{evaluation.manualChecks.length === 0 ? (

기록된 추가 확인 항목이 없습니다.

) : ( @@ -404,7 +497,7 @@ function CandidateCard({ )}
-

상환 계산 가능 여부

+
상환 계산 가능 여부
{repayment.supported && repayment.terms ? ( <>

@@ -468,7 +561,7 @@ function CandidateCard({

- 공식 원문 열기 + {evaluation.automation ? '원 공고 열기' : '공식 원문 열기'}

diff --git a/app/src/features/funding/candidates.test.ts b/app/src/features/funding/candidates.test.ts index 6bfd57f..adf0d53 100644 --- a/app/src/features/funding/candidates.test.ts +++ b/app/src/features/funding/candidates.test.ts @@ -720,7 +720,12 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)('PostgreSQL에서 활성 카탈 expect(payload.evaluations.map((evaluation) => evaluation.productKey)).toEqual(SOURCE_ORDER); for (const evaluation of payload.evaluations) { expect(evaluation.repayment.supported).toBe(false); + expect(evaluation.automation).toBeNull(); } + // 서울시 육성자금 대출 3건은 정책자금, 예비창업패키지(지원금)와 창업센터 입주(공간)는 지원사업이다. + expect(payload.summaryByBranch.POLICY_FUND).toMatchObject({ total: 3, POST_REGISTRATION: 3 }); + expect(payload.summaryByBranch.SUPPORT_PROGRAM).toMatchObject({ total: 2, CLOSED: 1, REVIEW_OVERDUE: 1 }); + expect(payload.summaryByBranch.UNCLASSIFIED.total).toBe(0); }); it('사업자등록 이후 프로필에서는 등록 이후 검토 상품이 추가 확인으로 바뀐다', async () => { diff --git a/app/src/features/funding/eligibility.ts b/app/src/features/funding/eligibility.ts index 08b8c13..44093ce 100644 --- a/app/src/features/funding/eligibility.ts +++ b/app/src/features/funding/eligibility.ts @@ -7,12 +7,14 @@ import { } from './schema.ts'; import { DISCOVERY_ONLY_SOURCES, + FUNDING_BRANCHES, FUNDING_SOURCE_LABELS, POLICY_LOAN_SUPPORT_TYPES, REPAYABLE_SUPPORT_TYPES, SOURCE_FRESHNESS_DAYS, combineVerdicts, compareIsoDates, + fundingBranch, INDUSTRY_CODE_PATTERN, isAutoReviewer, isPrimaryRetrievalMethod, @@ -21,6 +23,8 @@ import { reviewState, type BusinessStage, type EvidenceSubject, + type FundingBranch, + type FundingSource, type ObservedApplicationStatus, type Purpose, type ReviewState, @@ -84,12 +88,24 @@ export type ProductLoanTerms = { repaymentMethod: 'EQUAL_INSTALLMENT' | 'EQUAL_PRINCIPAL'; }; +// 자동으로 모은 상품의 출처 표시. 화면은 "자동 확인"과 함께 출처와 마지막 성공 확인일을 보여 준다. +export type FundingEvaluationAutomation = { + source: FundingSource; + sourceLabel: string; + // 출처의 마지막 성공 동기화일(한국 시간). 한 번도 성공하지 못했으면 null이다. + lastSucceededOn: string | null; +}; + export type FundingCandidateEvaluation = { productKey: string; version: string; name: string; organization: string; supportType: SupportType; + // 정책자금·지원사업·유형 미확인 갈래. 지원 유형에서 정한다(ADR 0006 1절). + branch: FundingBranch; + // 자동으로 모은 상품이면 출처 정보, 사람이 기록한 상품이면 null이다. + automation: FundingEvaluationAutomation | null; observedApplicationStatus: ObservedApplicationStatus; observedAt: string; reviewedAt: string | null; @@ -119,6 +135,8 @@ export type FundingCandidateList = { profile: FundingProfile; evaluations: FundingCandidateEvaluation[]; summary: FundingCandidateSummary; + // 갈래별 상태 개수. 모든 갈래의 합이 summary다. + summaryByBranch: Record; }; const STAGE_LABELS: Record = { @@ -365,6 +383,9 @@ function automationHold(product: FundingProduct, automation: FundingAutomation): if (DISCOVERY_ONLY_SOURCES.includes(product.sourceRef.source)) { return `${FUNDING_SOURCE_LABELS[product.sourceRef.source]}에서 찾은 공고는 사업 단계·지원 유형을 알 수 없어 사람이 확인해야 현재 후보가 됩니다.`; } + if (product.supportType === 'UNKNOWN') { + return '지원 유형이 확인되지 않은 공고는 자동으로 모아도 사람이 확인해야 현재 후보가 됩니다.'; + } if (!POLICY_LOAN_SUPPORT_TYPES.includes(product.supportType)) { return '지원사업은 자동으로 모아도 사람이 확인해야 현재 후보가 됩니다.'; } @@ -403,6 +424,11 @@ export function evaluateProduct( const audience = productAudience(product); const state = reviewState(product, options.asOfDate, automation.sourceSyncs); const automated = isAutoReviewer(product.reviewer); + const source = automated ? product.sourceRef?.source : undefined; + const lastSucceededOn = + source === undefined + ? null + : (automation.sourceSyncs.find((entry) => entry.source === source)?.lastSucceededOn ?? null); const hold = automationHold(product, automation); const repayment = repaymentFor(product, automation); const primaryEvidenceVerified = hasPrimaryEvidence(product); @@ -424,10 +450,8 @@ export function evaluateProduct( let candidateStatus: FundingCandidateStatus; let candidateReason: string; if (state === 'REVIEW_OVERDUE' && automated) { - const source = product.sourceRef?.source; - const lastSucceededOn = automation.sourceSyncs.find((entry) => entry.source === source)?.lastSucceededOn ?? '없음'; candidateStatus = 'REVIEW_OVERDUE'; - candidateReason = `자동으로 모은 출처(${source ? FUNDING_SOURCE_LABELS[source] : '없음'})의 마지막 성공 확인일 ${lastSucceededOn}이(가) 기준일 ${options.asOfDate}보다 ${SOURCE_FRESHNESS_DAYS}일 넘게 지났거나 없어 재확인 전에는 현재 후보로 표시하지 않습니다.`; + candidateReason = `자동으로 모은 출처(${source ? FUNDING_SOURCE_LABELS[source] : '없음'})의 마지막 성공 확인일 ${lastSucceededOn ?? '없음'}이(가) 기준일 ${options.asOfDate}보다 ${SOURCE_FRESHNESS_DAYS}일 넘게 지났거나 없어 재확인 전에는 현재 후보로 표시하지 않습니다.`; } else if (state === 'REVIEW_OVERDUE') { candidateStatus = 'REVIEW_OVERDUE'; candidateReason = `다음 검토일 ${product.nextReviewAt ?? '없음'}이(가) 기준일 ${options.asOfDate} 이전이라 재확인 전에는 현재 후보로 표시하지 않습니다.`; @@ -475,6 +499,8 @@ export function evaluateProduct( name: product.name, organization: product.organization, supportType: product.supportType, + branch: fundingBranch(product.supportType), + automation: source === undefined ? null : { source, sourceLabel: FUNDING_SOURCE_LABELS[source], lastSucceededOn }, observedApplicationStatus: product.observedApplicationStatus, observedAt: product.observedAt, reviewedAt: product.reviewedAt, @@ -501,16 +527,17 @@ export function evaluateCandidates( const evaluations = catalog.products.map((product) => evaluateProduct(product, profile, { asOfDate: options.asOfDate, automation: catalog.automation }), ); - const summary = { - total: evaluations.length, - CURRENT_CANDIDATE: 0, - NEEDS_CONFIRMATION: 0, - NOT_ELIGIBLE: 0, - POST_REGISTRATION: 0, - CLOSED: 0, - REVIEW_OVERDUE: 0, - } satisfies FundingCandidateSummary; - for (const evaluation of evaluations) summary[evaluation.candidateStatus] += 1; + const summary = emptySummary(); + const summaryByBranch = Object.fromEntries(FUNDING_BRANCHES.map((branch) => [branch, emptySummary()])) as Record< + FundingBranch, + FundingCandidateSummary + >; + for (const evaluation of evaluations) { + for (const target of [summary, summaryByBranch[evaluation.branch]]) { + target.total += 1; + target[evaluation.candidateStatus] += 1; + } + } return { asOfDate: options.asOfDate, basisDate: catalog.basisDate, @@ -519,5 +546,18 @@ export function evaluateCandidates( profile, evaluations, summary, + summaryByBranch, + }; +} + +function emptySummary(): FundingCandidateSummary { + return { + total: 0, + CURRENT_CANDIDATE: 0, + NEEDS_CONFIRMATION: 0, + NOT_ELIGIBLE: 0, + POST_REGISTRATION: 0, + CLOSED: 0, + REVIEW_OVERDUE: 0, }; } diff --git a/app/src/features/funding/funding.test.ts b/app/src/features/funding/funding.test.ts index 39e44ef..e772356 100644 --- a/app/src/features/funding/funding.test.ts +++ b/app/src/features/funding/funding.test.ts @@ -5,7 +5,13 @@ import { fileURLToPath } from 'node:url'; import { PrismaPg } from '@prisma/adapter-pg'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PrismaClient } from '../../generated/prisma/client.ts'; -import { assessProductRepayment, evaluateCandidates, evaluateProduct, industryCodeIssue } from './eligibility.ts'; +import { + UNKNOWN_FUNDING_PROFILE, + assessProductRepayment, + evaluateCandidates, + evaluateProduct, + industryCodeIssue, +} from './eligibility.ts'; import { loadFundingCatalog, readFundingCatalogFile } from './loader.ts'; import { productVersionKey, type FundingCatalog } from './schema.ts'; import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION, combineVerdicts, type Verdict } from './types.ts'; @@ -511,6 +517,121 @@ describe('notices whose business stage or support type is unknown', () => { }); }); +describe('policy fund and support program branches (ADR 0006)', () => { + const grant = (overrides: Record = {}) => + product({ + productKey: 'test-grant', + supportType: 'GRANT', + publicLimit: null, + repaymentMethod: 'NOT_APPLICABLE', + unsupportedCalculationReasons: ['지원금은 상환하지 않습니다.'], + evidence: [ + evidence('identity', 'IDENTITY'), + evidence('period', 'APPLICATION_PERIOD'), + evidence('stage', 'BUSINESS_STAGE'), + evidence('region', 'REGION'), + evidence('purpose', 'PURPOSE'), + evidence('industry', 'INDUSTRY'), + ], + ...overrides, + }); + const unknownType = product({ + productKey: 'test-unknown-type', + 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'), + ], + }); + + it('derives the branch from the support type and counts each branch separately', () => { + const parsed = catalogFrom( + validate( + catalog([ + product(), + product({ productKey: 'test-guarantee', supportType: 'GUARANTEE', repaymentMethod: 'NOT_APPLICABLE' }), + grant(), + grant({ productKey: 'test-space', supportType: 'SPACE' }), + grant({ productKey: 'test-program', supportType: 'PROGRAM' }), + unknownType, + ]), + ), + ); + const list = evaluateCandidates(parsed, UNKNOWN_FUNDING_PROFILE, { asOfDate: REVIEW_DATE }); + expect( + Object.fromEntries(list.evaluations.map((evaluation) => [evaluation.productKey, evaluation.branch])), + ).toEqual({ + 'test-loan': 'POLICY_FUND', + 'test-guarantee': 'POLICY_FUND', + 'test-grant': 'SUPPORT_PROGRAM', + 'test-space': 'SUPPORT_PROGRAM', + 'test-program': 'SUPPORT_PROGRAM', + 'test-unknown-type': 'UNCLASSIFIED', + }); + expect(list.summaryByBranch.POLICY_FUND.total).toBe(2); + expect(list.summaryByBranch.SUPPORT_PROGRAM.total).toBe(3); + expect(list.summaryByBranch.UNCLASSIFIED.total).toBe(1); + for (const status of Object.keys(list.summary) as Array) { + expect( + list.summaryByBranch.POLICY_FUND[status] + + list.summaryByBranch.SUPPORT_PROGRAM[status] + + list.summaryByBranch.UNCLASSIFIED[status], + ).toBe(list.summary[status]); + } + }); + + it('shows the source and last confirmation only for automatically collected products', () => { + const parsed = catalogFrom( + validate( + catalog( + [ + product(), + product({ + productKey: 'test-auto-loan', + reviewer: AUTO_REVIEWER, + nextReviewAt: null, + sourceRef: { source: 'SEMAS_OLS', externalId: 'test-fund' }, + }), + ], + { + automation: { + policyLoanPromotion: false, + repaymentPromotion: false, + blockedSources: [], + sourceSyncs: [ + { + source: 'SEMAS_OLS', + status: 'FAILED', + attemptedAt: '2026-09-20T01:00:00.000Z', + lastSucceededOn: '2026-09-19', + fetchedCount: 1, + responseChecksum: null, + failureReason: '출처 응답 500', + }, + ], + }, + }, + ), + ), + ); + const list = evaluateCandidates(parsed, UNKNOWN_FUNDING_PROFILE, { asOfDate: REVIEW_DATE }); + const byKey = new Map(list.evaluations.map((evaluation) => [evaluation.productKey, evaluation])); + expect(byKey.get('test-loan')?.automation).toBeNull(); + expect(byKey.get('test-auto-loan')?.automation).toEqual({ + source: 'SEMAS_OLS', + sourceLabel: '소상공인시장진흥공단 정책자금 사이트', + lastSucceededOn: '2026-09-19', + }); + }); +}); + describe('the reviewed 2026-09-20 catalog', () => { it('passes every rule check and reports the review state of each product', () => { const validation = validate(REAL_CATALOG); diff --git a/app/src/features/funding/types.ts b/app/src/features/funding/types.ts index 81148c8..7d585ea 100644 --- a/app/src/features/funding/types.ts +++ b/app/src/features/funding/types.ts @@ -58,6 +58,16 @@ export const REPAYABLE_SUPPORT_TYPES: readonly SupportType[] = ['LOAN']; // 프로그램(지원사업)은 자동으로 모아도 사람이 확인해야 현재 후보가 된다. export const POLICY_LOAN_SUPPORT_TYPES: readonly SupportType[] = ['LOAN', 'GUARANTEE']; +// 후보 API와 화면의 갈래(ADR 0006 1절). 저장하지 않고 지원 유형에서 정하므로 카탈로그에는 없다. +// 지원 유형을 모르는 공고는 정책자금·지원사업 어느 쪽으로도 추측하지 않고 따로 둔다. +export const FUNDING_BRANCHES = ['POLICY_FUND', 'SUPPORT_PROGRAM', 'UNCLASSIFIED'] as const; +export type FundingBranch = (typeof FUNDING_BRANCHES)[number]; + +export function fundingBranch(supportType: SupportType): FundingBranch { + if (POLICY_LOAN_SUPPORT_TYPES.includes(supportType)) return 'POLICY_FUND'; + return supportType === 'UNKNOWN' ? 'UNCLASSIFIED' : 'SUPPORT_PROGRAM'; +} + export const BUSINESS_STAGES = ['PRE_REGISTRATION', 'POST_REGISTRATION'] as const; export type BusinessStage = (typeof BUSINESS_STAGES)[number]; diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index be22a32..1b9969b 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -23,7 +23,8 @@ | 프랜차이즈 참고 | 업종별 브랜드 검색, 가맹점 수·개폐점, 연 평균매출·3.3㎡당 평균매출, 정보공개서 창업 금액(가맹금·교육비·보증금·기타), 연도별 추이 | 공정위 정보공개서(공공데이터포털 가맹정보), 최근 3개 기준년도(각각 전년도 실적). 전국 평균이며 재무 입력에 자동으로 넣지 않음 | | 재무 계획(`/`, 로그인) | 초기 비용, 자기자금, 매출·비용 가정, 부족자금, 운영수지, 손익분기, 상환 일정, 3개 시나리오 | 사용자가 입력한 가정만 계산 | | 계산 결과 | 서버 재계산, 입력·버전·출처를 보존한 불변 결과 | 과거 결과를 수정하지 않음 | -| 자금 후보(`/funding`, 로그인) | 사업 단계·지역·업종·용도로 검토 후보·추가 확인 필요·조건 미충족·사업자등록 이후 검토·접수 종료·검수 기한 경과로 분류 | 운영자가 적재한 카탈로그만 사용. 검수자 미지정 상품은 현재 후보로 표시하지 않음 | +| 자금 후보(`/funding`, 로그인) | 정책자금(대출·보증)과 지원사업(지원금·공간·프로그램)을 나눠, 사업 단계·지역·업종·용도로 검토 후보·추가 확인 필요·조건 미충족·사업자등록 이후 검토·접수 종료·검수 기한 경과로 분류. 지원 유형을 알 수 없는 공고는 "유형 미확인"으로 따로 표시 | 운영자가 적재한 카탈로그와 운영자가 실행한 `funding:sync`의 자동 수집 공고만 사용. 검수자 미지정 상품은 현재 후보로 표시하지 않음 | +| 자동 수집 공고 | "자동 확인" 표시와 출처·마지막 확인일·원 공고 링크 | 지원사업과 기업마당에서 찾은 공고는 사람이 확인해야 현재 후보가 됨. 정책자금은 운영자가 자동 승격을 켠 경우에만 현재 후보가 되고, 출처 확인이 3일 넘게 끊기면 빠짐([ADR 0006](decisions/0006-funding-discovery-automation.md)) | | 대출 가정 적용 | 확정 조건을 갖춘 현재 후보를 사용자가 선택해 재무계획에 적용 | 미확정·비대출 상품은 불가 사유 표시 | 상위 업종은 한식(`CS100001`)과 커피·음료(`CS100010`)만 노출한다. @@ -34,4 +35,4 @@ - 월 환산과 점포당 매출: 공식 정의가 확정되지 않아 아직 제공하지 않는다. 확정 전에 제공할 때는 [ADR 0002](decisions/0002-provisional-market-sales.md)의 잠정 해석을 따른다. - 특정 점포의 임대료 시세 추정: 부동산원 값은 조사 상권의 표본 평균이다. - 프랜차이즈 개인 예상매출과 창업 금액 세부 내역: 공정위 수치는 전국 평균이고, 기타 금액의 세부 항목과 기준 점포 면적은 정보공개서 본문에만 있다. -- POS 연동, 예측 ML, 종합 점수, 신용 판단, 자동 승인, 지도, 주소 자동 매핑, 전국 확대, 자동 공고 해석 +- POS 연동, 예측 ML, 종합 점수, 신용 판단, 자동 승인, 지도, 주소 자동 매핑, 전국 확대, AI 기반 공고 해석(공고 본문·첨부를 AI로 읽어 조건을 채우는 일) diff --git a/docs/domains/funding.md b/docs/domains/funding.md index 339b11b..6d22847 100644 --- a/docs/domains/funding.md +++ b/docs/domains/funding.md @@ -107,6 +107,7 @@ - 차단 목록의 공고, 새 공고를 찾는 데만 쓰는 출처(기업마당)의 자동 상품, 지원사업(지원금·공간·프로그램)과 유형 미확인 자동 상품, 정책자금 자동 승격이 꺼진 상태의 자동 상품은 현재 후보가 아니라 추가 확인이며 사유를 표시한다. - 상환 계산 자동 승격이 꺼져 있으면 자동 상품은 확정 조건이 있어도 상환 계산 대상이 아니다. - 지원 유형 표시: GRANT 지원금, GUARANTEE 보증, LOAN 대출, SPACE 공간·보육, PROGRAM 프로그램, UNKNOWN 유형 미확인(원 공고 확인). +- 갈래(`branch`, ADR 0006 1절): 저장하지 않고 지원 유형에서 정한다. LOAN·GUARANTEE는 정책자금(`POLICY_FUND`), GRANT·SPACE·PROGRAM은 지원사업(`SUPPORT_PROGRAM`)이다. UNKNOWN은 어느 쪽으로도 추측하지 않고 유형 미확인(`UNCLASSIFIED`)으로 둔다. - 지원금·보증·공간·프로그램 지원과 유형 미확인 공고는 대출 원금이나 상환 일정으로 변환하지 않는다. - 대출의 상환 계산 대상 여부: - 공개 한도, 확정 금리(`interestRateConfirmed`와 `interestRatePercent`), 상환기간·거치(`repaymentTermMonths`·`repaymentGraceMonths`), 상환방식이 모두 확정되고, 계산 엔진이 지원하는 방식이며, 거치개월이 전체 상환개월보다 작아야 상환 계산 대상이다. @@ -125,9 +126,15 @@ - 상태 구분: 미로그인 401 `UNAUTHORIZED`, 잘못된 프로필 400 `INVALID_INPUT`, 활성 카탈로그 릴리스 없음 503 `CATALOG_UNAVAILABLE`. 활성 릴리스가 없을 때 빈 후보가 아니라 503으로 알린다. - 응답에 담는 것: - 카탈로그 버전·기준일·판정 기준일·검수자·스키마 버전·활성화 시각, 입력 프로필, 상태별 개수 - - 상품명·기관·지원 유형·후보 상태, 포함·제외·추가 확인 이유 + - 갈래별 상태 개수(`summaryByBranch`). 모든 갈래를 더하면 전체 개수(`summary`)다 + - 상품명·기관·지원 유형·갈래·후보 상태, 포함·제외·추가 확인 이유 + - 자동 상품의 출처·출처 이름·마지막 성공 확인일(`automation`). 사람이 기록한 상품은 `null`이다 - 조건별 PASS/FAIL/UNKNOWN과 근거 id, 공식 링크·접수·검수 상태 - 상환 계산 가능 여부와 불가 사유 +- 결과 화면(`FundingCandidatesPanel.tsx`, `/funding`과 계획 화면 공통): + - 갈래 요약(갈래별 검토 후보 수·전체 수)을 먼저 보여 주고, 정책자금·지원사업 순으로 갈래마다 상태별 개수와 상품을 나눠 보여 준다. 유형 미확인은 그런 상품이 있을 때만 마지막에 보여 준다. + - 갈래 안에서는 상품이 있는 상태만 목록으로 보여 준다. 갈래에 상품이 없으면 그렇다고 표시한다. + - 자동 상품 카드에는 "자동 확인" 표시, 출처, 마지막 확인일, 원 공고 링크를 함께 보여 준다(ADR 0006 7절). 검수 상태 대신 출처 확인이 최신인지 끊겼는지를 보여 준다. ### 계획에 저장한 조건으로 판정 @@ -158,7 +165,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, 새 공고 목록, 기업마당 수집기(가짜 응답)로 유형 미확인 상품 적재와 추가 확인 판정.