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, 새 공고 목록, 기업마당 수집기(가짜 응답)로 유형 미확인 상품 적재와 추가 확인 판정.