From 6322ee8e02d1fe6fb8c415f47c343a7af9722317 Mon Sep 17 00:00:00 2001 From: Minseok Kang <0815km@naver.com> Date: Tue, 29 Sep 2026 16:48:15 +0900 Subject: [PATCH] feat(funding): show funding candidates as a recommendation report Rebuild /funding as a report: summary cards (current candidates, needing confirmation, lowest confirmed rate, excluded), numbered candidate cards with the reason, checks and announced terms, and an excluded list with reasons. Evaluations now carry the catalog's announced limit, rate and repayment text and application period for display only; they are not used for eligibility or repayment math. Only confirmed rates are compared. Co-Authored-By: Claude Opus 5.5 --- app/src/app/globals.css | 182 +++++++++- .../funding/FundingCandidatesPanel.tsx | 74 ++-- app/src/features/funding/FundingMatcher.tsx | 340 ++++++++++-------- app/src/features/funding/FundingReport.tsx | 336 +++++++++++++++++ app/src/features/funding/eligibility.ts | 16 + app/src/features/funding/funding.test.ts | 20 ++ docs/PRODUCT.md | 2 +- docs/domains/funding.md | 4 +- 8 files changed, 779 insertions(+), 195 deletions(-) create mode 100644 app/src/features/funding/FundingReport.tsx diff --git a/app/src/app/globals.css b/app/src/app/globals.css index 89cac00..3ae6163 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -1735,7 +1735,9 @@ footer { margin-top: 6px; } .basis-links a, -.report-details summary { +.report-details summary, +.fund-columns a, +.excluded-list a { color: var(--report-accent); } .report-details { @@ -1759,6 +1761,170 @@ footer { font-weight: 800; } +/* 정책자금 보고서 */ +.fund-list { + display: grid; + gap: 16px; +} +.fund-card { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 24px; + padding: 26px; + background: #fff; + border: 1px solid var(--report-line); + border-radius: 18px; +} +.fund-main { + min-width: 0; +} +.fund-kicker { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0; + color: var(--muted); + font-size: 12px; +} +.fund-badge { + border-radius: 999px; + padding: 4px 10px; + background: #eceee9; + color: #4d5d57; + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} +.fund-badge.status-current_candidate { + background: #e3f1ea; + color: #1f6b4c; +} +.fund-badge.status-needs_confirmation, +.fund-badge.status-post_registration { + background: #fbefd9; + color: #935612; +} +.fund-badge.status-not_eligible, +.fund-badge.status-closed, +.fund-badge.status-review_overdue { + background: #f7e6e1; + color: #9a3e24; +} +.fund-card h3 { + margin: 12px 0 14px; + font-size: 19px; + letter-spacing: -0.4px; +} +.fund-reason { + padding: 14px 16px; + border-radius: 12px; + background: var(--report-soft); + font-size: 13px; +} +.fund-reason.confirm { + background: var(--report-warm); +} +.fund-reason strong { + display: block; + margin-bottom: 4px; + font-size: 12px; +} +.fund-reason p, +.fund-reason ul { + margin: 0; + color: var(--report-text); +} +.fund-reason ul { + margin-top: 8px; + padding-left: 18px; +} +.fund-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-top: 18px; + font-size: 13px; +} +.fund-columns h4 { + margin: 0 0 6px; + font-size: 12px; +} +.fund-columns p, +.fund-columns ul { + margin: 0 0 4px; + color: var(--report-text); +} +.fund-columns ul { + display: grid; + gap: 4px; + padding-left: 18px; +} +.fund-columns a { + font-size: 12px; +} +.fund-terms { + display: grid; + align-content: start; + gap: 10px; + margin: 0; +} +.fund-terms > div { + padding: 14px 16px; + background: var(--report-panel); + border: 1px solid var(--report-line); + border-radius: 12px; +} +.fund-terms dt { + color: var(--muted); + font-size: 12px; +} +.fund-terms dd { + margin: 4px 0 0; + font-size: 15px; + font-weight: 800; +} +.fund-terms small { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 11px; +} +.excluded-list { + list-style: none; + margin: 14px 0 0; + padding: 6px 26px; + background: #fff; + border: 1px solid var(--report-line); + border-radius: 18px; +} +.excluded-list li { + display: grid; + grid-template-columns: minmax(0, 280px) minmax(0, 1fr); + gap: 24px; + padding: 18px 0; + border-bottom: 1px solid var(--report-line); +} +.excluded-list li:last-child { + border-bottom: 0; +} +.excluded-list li > div:first-child strong { + display: block; + font-size: 14px; +} +.excluded-list li > div:first-child span { + color: var(--muted); + font-size: 12px; +} +.excluded-list p { + margin: 6px 0 4px; + color: var(--report-text); + font-size: 13px; +} +.excluded-list a { + font-size: 12px; +} + /* 상권 분석 보고서 */ .report-split { display: grid; @@ -2065,8 +2231,13 @@ footer { .report-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .report-split { + .report-split, + .fund-card { + grid-template-columns: 1fr; + } + .excluded-list li { grid-template-columns: 1fr; + gap: 8px; } } @media (max-width: 560px) { @@ -2095,6 +2266,10 @@ footer { .report-section { padding: 20px 16px; } + .fund-card { + padding: 18px; + } + .fund-columns, .report-stats { grid-template-columns: 1fr; } @@ -2102,4 +2277,7 @@ footer { flex-direction: column; align-items: stretch; } + .excluded-list { + padding: 4px 16px; + } } diff --git a/app/src/features/funding/FundingCandidatesPanel.tsx b/app/src/features/funding/FundingCandidatesPanel.tsx index 336ccad..e1209bc 100644 --- a/app/src/features/funding/FundingCandidatesPanel.tsx +++ b/app/src/features/funding/FundingCandidatesPanel.tsx @@ -18,7 +18,7 @@ const numberFormat = new Intl.NumberFormat('ko-KR'); // 지원 유형은 대출과 다른 성격을 이름부터 구분한다. GRANT·SPACE·PROGRAM을 // 대출처럼 보이게 하는 표현을 쓰지 않는다. -const SUPPORT_TYPE_LABELS: Record = { +export const SUPPORT_TYPE_LABELS: Record = { GRANT: '지원금(상환 없음)', GUARANTEE: '보증(대출 아님)', LOAN: '대출(상환 대상)', @@ -26,7 +26,7 @@ const SUPPORT_TYPE_LABELS: Record = { PROGRAM: '프로그램(대출 아님)', }; -const STATUS_LABELS: Record = { +export const STATUS_LABELS: Record = { CURRENT_CANDIDATE: '검토 후보', NEEDS_CONFIRMATION: '추가 확인 필요', NOT_ELIGIBLE: '조건 미충족', @@ -35,15 +35,15 @@ const STATUS_LABELS: Record = { REVIEW_OVERDUE: '검수 기한 경과', }; -const VERDICT_LABELS: Record = { PASS: '충족', FAIL: '미충족', UNKNOWN: '미확인' }; +export const VERDICT_LABELS: Record = { PASS: '충족', FAIL: '미충족', UNKNOWN: '미확인' }; -const APPLICATION_STATUS_LABELS: Record = { +export const APPLICATION_STATUS_LABELS: Record = { OPEN: '접수 중으로 확인', CLOSED: '접수 종료로 확인', UNKNOWN: '접수 상태 미확인', }; -const REVIEW_STATE_LABELS: Record = { +export const REVIEW_STATE_LABELS: Record = { CURRENT: '검수 최신', UNREVIEWED: '검수자 미지정', REVIEW_OVERDUE: '검수 기한 경과', @@ -296,6 +296,40 @@ export function FundingCandidatesPanel({ ); } +export function ConditionTable({ evaluation }: { readonly evaluation: FundingCandidateEvaluation }) { + return ( +
+ + + + + + + + + + + + {evaluation.conditions.map((condition) => ( + + + + + + + ))} + +
조건별 판정과 이유
조건판정이유근거
{condition.label} + + {VERDICT_LABELS[condition.verdict]} + + {condition.detail} + {condition.evidenceIds.length === 0 ? '기록 없음' : condition.evidenceIds.join(', ')} +
+
+ ); +} + function CandidateCard({ evaluation, apply, @@ -359,35 +393,7 @@ function CandidateCard({ -
- - - - - - - - - - - - {evaluation.conditions.map((condition) => ( - - - - - - - ))} - -
조건별 판정과 이유
조건판정이유근거
{condition.label} - - {VERDICT_LABELS[condition.verdict]} - - {condition.detail} - {condition.evidenceIds.length === 0 ? '기록 없음' : condition.evidenceIds.join(', ')} -
-
+
diff --git a/app/src/features/funding/FundingMatcher.tsx b/app/src/features/funding/FundingMatcher.tsx index 1b4c3ab..ead4a0f 100644 --- a/app/src/features/funding/FundingMatcher.tsx +++ b/app/src/features/funding/FundingMatcher.tsx @@ -2,17 +2,19 @@ import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import ReportNav from '@/components/ReportNav'; import { authClient } from '@/lib/auth-client'; import type { FundingCandidatesResponse } from './candidates.ts'; -import { SEOUL_DISTRICTS } from './districts.ts'; -import { industryCodeIssue, purposeLabel, stageLabel } from './eligibility.ts'; +import { SEOUL_DISTRICTS, districtName } from './districts.ts'; +import { industryCodeIssue, purposeLabel, stageLabel, type FundingProfile } from './eligibility.ts'; import { FundingCandidatesErrorPanel, - FundingCandidatesPanel, describeFundingError, + formatFundingDate, fundingProfileSummary, type FundingLoadError, } from './FundingCandidatesPanel.tsx'; +import FundingReport from './FundingReport.tsx'; import { PURPOSES, type BusinessStage, type Purpose } from './types.ts'; type IndustryOption = { code: string; displayName: string }; @@ -92,169 +94,193 @@ export default function FundingMatcher() { [businessStage, districtCode, industryCode, purpose], ); - const profileSummary = useMemo( - () => - fundingProfileSummary({ - businessStage, - districtCode: districtCode === '' ? null : districtCode, - industryCode: industryCode.trim() === '' ? null : industryCode.trim().toUpperCase(), - purpose, - }), - [businessStage, districtCode, industryCode, purpose], - ); + // 제목·조건 칩은 입력 중인 값이 아니라 판정에 실제로 쓴 조건(result.profile)을 따른다. + const profile: FundingProfile | null = result?.profile ?? null; + const industryName = (code: string) => industries.find((industry) => industry.code === code)?.displayName ?? code; + const industryLabel = profile?.industryCode ? industryName(profile.industryCode) : null; + const districtLabel = profile?.districtCode ? (districtName(profile.districtCode) ?? profile.districtCode) : null; + const profileSummary = useMemo(() => (profile ? fundingProfileSummary(profile) : ''), [profile]); return ( -
-
- - TrendBench창업 준비의 기준 - -
- - 내 창업 계획 - - - 상권 탐색 - -
-
- -
-

자금 후보 조회

-

- 공식 공고만, -
- 상태와 이유까지. -

-

- 사업단계·자치구·업종·용도를 입력하면 활성 자금 카탈로그를 기준으로 검토 후보, 추가 확인, 등록 이후 검토, 종료, - 검수 만료, 미충족을 이유와 함께 보여줍니다. 후보가 0건이어도 정상 결과이며, 지원금·보증·공간 지원은 대출 - 상환으로 계산하지 않습니다. -

-
- - {isPending && ( -

- 로그인 상태를 확인하고 있습니다. -

- )} + <> + +
+
+

+ + {result ? `카탈로그 ${result.release.catalogVersion}` : '공식 공고 기준'} + + 금리·한도는 운영자가 정리한 공고 요약이며 바뀔 수 있어요. 신청 전 기관 공고를 꼭 확인하세요. +

+

+ {profile ? ( + <> + 서울시{districtLabel ? ` ${districtLabel}` : ''} + {industryLabel ? ( + <> + {' '} + {industryLabel} + + ) : null}{' '} + 창업 정책자금 추천 보고서 + + ) : ( + '정책자금 추천 보고서' + )} +

+ {profile ? ( +
    +
  • + 지역 + {districtLabel ? `서울시 ${districtLabel}` : '미입력'} +
  • +
  • + 업종 + {industryLabel ?? '미입력'} +
  • +
  • + 단계 + {profile.businessStage === 'UNKNOWN' ? '미입력' : stageLabel(profile.businessStage)} +
  • +
  • + 용도 + {profile.purpose === 'UNKNOWN' ? '미입력' : purposeLabel(profile.purpose)} +
  • +
+ ) : ( +

+ 조건을 고르면 활성 자금 카탈로그로 검토 후보·조건 확인·제외를 이유와 함께 정리해요. 검토 후보는 승인 + 확정이 아니에요. +

+ )} +
- {!isPending && !session && ( -
-

로그인이 필요합니다.

-

- 자금 후보 조회는 로그인한 사용자만 사용할 수 있습니다. 내 창업 계획에서 로그인한 뒤 - 다시 열어 주세요. + {isPending && ( +

+ 로그인 상태를 확인하고 있습니다.

-
- )} + )} - {!isPending && session && ( - <> -
-

이 결과가 무엇이고 무엇이 아닌지

-
    -
  • 검토 후보는 확인된 조건을 충족했다는 뜻이며 선정·승인이 확정된 것이 아닙니다.
  • -
  • 지원금·보증·공간·프로그램 지원은 대출 원금이나 월 상환액으로 바꾸지 않습니다.
  • -
  • 접수 상태(접수 중·종료·미확인)는 자격 판정과 별개로 저장된 관측값입니다.
  • -
  • 입력하지 않은 값은 0으로 채우지 않고 미확인(UNKNOWN)으로 남깁니다.
  • -
-
+ {!isPending && !session && ( +
+

로그인이 필요합니다.

+

+ 자금 후보 조회는 로그인한 사용자만 사용할 수 있습니다. 내 창업 계획에서 로그인한 뒤 + 다시 열어 주세요. +

+
+ )} + + {!isPending && session && ( + <> +
+

조건 선택

+
+
+ + + + +
+ + {industries.map((industry) => ( + + ))} + + {formError && ( +

+ {formError} +

+ )} +
+

+ 판정 기준일은 서버의 한국 시간 오늘이며 바꿀 수 없어요. 입력하지 않은 값은 0이 아니라 미확인으로 + 남아요. +

+ +
+
+
-
-

내 조건 입력

-
-
- - - - -
- - {industries.map((industry) => ( - - ))} - - {formError && ( -

- {formError} +

+ {loading && ( +

+ 활성 카탈로그를 기준으로 후보를 판정하는 중…

)} -
- -
-

- 판정 기준일은 서버의 한국 시간 오늘이며 요청에서 바꿀 수 없습니다. 업종 목록은 활성 상권 릴리스가 있을 - 때만 제안되고, 없어도 코드를 직접 입력할 수 있습니다. -

- -
+ {!loading && error && } + {!loading && !error && !result && ( +

조건을 고르고 보고서 만들기를 누르면 상태별 자금과 이유를 표시합니다.

+ )} + {!loading && !error && result && ( + + )} +
-
- {loading && ( -

- 활성 카탈로그를 기준으로 후보를 판정하는 중… -

- )} - {!loading && error && } - {!loading && !error && !result && ( -

조건을 입력하고 후보 조회를 누르면 상태별 후보와 이유를 표시합니다.

- )} - {!loading && !error && result && } -
- - )} +
+

이 결과가 무엇이고 무엇이 아닌지

+
    +
  • 검토 후보는 확인된 조건을 충족했다는 뜻이며 선정·승인이 확정된 것이 아닙니다.
  • +
  • 지원금·보증·공간·프로그램 지원은 대출 원금이나 월 상환액으로 바꾸지 않습니다.
  • +
  • 접수 상태(접수 중·종료·미확인)는 자격 판정과 별개로 저장된 관측값입니다.
  • +
  • 한도·금리·상환 조건 문장은 공고 요약이며, 확정 숫자로 기록된 금리만 비교합니다.
  • +
  • 입력하지 않은 값은 0으로 채우지 않고 미확인(UNKNOWN)으로 남깁니다.
  • +
+
+ + )} -
TrendBench · 공식 공고 기반 자금 카탈로그 · 내부 MVP · 공개 출시 준비 완료 상태가 아닙니다.
- +
TrendBench · 공식 공고 기반 자금 카탈로그 · 내부 MVP · 공개 출시 준비 완료 상태가 아닙니다.
+ + ); } diff --git a/app/src/features/funding/FundingReport.tsx b/app/src/features/funding/FundingReport.tsx new file mode 100644 index 0000000..1b08a03 --- /dev/null +++ b/app/src/features/funding/FundingReport.tsx @@ -0,0 +1,336 @@ +'use client'; + +import Decimal from 'decimal.js'; +import { formatKoreanWon } from '@/lib/format-won'; +import type { FundingCandidatesResponse } from './candidates.ts'; +import type { FundingCandidateEvaluation, FundingCandidateStatus } from './eligibility.ts'; +import { + APPLICATION_STATUS_LABELS, + ConditionTable, + REVIEW_STATE_LABELS, + STATUS_LABELS, + SUPPORT_TYPE_LABELS, + formatFundingDate, + isEmptyFundingProfile, +} from './FundingCandidatesPanel.tsx'; + +// /funding의 보고서 표시. 판정은 서버 결과를 그대로 쓰고, 여기서는 상태별로 묶어 +// 읽기 쉽게 배치만 한다. 계획 화면은 같은 판정을 FundingCandidatesPanel로 보여 준다. + +const numberFormat = new Intl.NumberFormat('ko-KR'); + +const REVIEWABLE: readonly FundingCandidateStatus[] = ['CURRENT_CANDIDATE', 'NEEDS_CONFIRMATION']; +const EXCLUDED: readonly FundingCandidateStatus[] = ['POST_REGISTRATION', 'NOT_ELIGIBLE', 'CLOSED', 'REVIEW_OVERDUE']; + +// 대출·보증만 금리와 상환 조건이 의미가 있다. 나머지는 금리 칸을 비워 두지 않고 대출이 +// 아니라고 쓴다. +const LENDING_TYPES = new Set(['LOAN', 'GUARANTEE']); + +function lowestConfirmedRate(evaluations: readonly FundingCandidateEvaluation[]) { + let best: { rate: string; name: string } | null = null; + for (const evaluation of evaluations) { + if (evaluation.candidateStatus !== 'CURRENT_CANDIDATE' || !evaluation.repayment.terms) continue; + const rate = evaluation.repayment.terms.annualInterestRatePercent; + if (best === null || new Decimal(rate).lessThan(best.rate)) best = { rate, name: evaluation.name }; + } + return best; +} + +function periodText(period: FundingCandidateEvaluation['announcement']['applicationPeriod']): string { + if (period.start === null && period.end === null) return '기간 기록 없음'; + return `${period.start ? formatFundingDate(period.start) : '시작일 기록 없음'} ~ ${ + period.end ? formatFundingDate(period.end) : '종료일 기록 없음' + }`; +} + +export default function FundingReport({ + result, + industryLabel, + profileSummary, +}: { + readonly result: FundingCandidatesResponse; + readonly industryLabel: string | null; + readonly profileSummary: string; +}) { + const { summary } = result; + const reviewable = REVIEWABLE.flatMap((status) => + result.evaluations.filter((evaluation) => evaluation.candidateStatus === status), + ); + const excluded = EXCLUDED.flatMap((status) => + result.evaluations.filter((evaluation) => evaluation.candidateStatus === status), + ); + const lowest = lowestConfirmedRate(result.evaluations); + const emptyProfile = isEmptyFundingProfile(result.profile); + + if (summary.total === 0) { + return ( +
+

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

+

카탈로그에 적재된 상품이 없습니다. 후보 0건과 카탈로그 없음은 다른 상태입니다.

+
+ ); + } + + return ( + <> +
+
+ 현재 검토 후보 + {numberFormat.format(summary.CURRENT_CANDIDATE)}개 + 확인된 조건을 충족하고 접수 중으로 확인된 자금이에요. 승인 확정이 아니에요. +
+
+ 조건 확인이 필요한 자금 + {numberFormat.format(summary.NEEDS_CONFIRMATION)}개 + 미확인 조건, 원문 근거 부족, 검수 미완료 중 하나가 있어요. +
+
+ 검토 후보 중 가장 낮은 확정 금리 + {lowest ? ( + <> + 연 {lowest.rate}% + {lowest.name} + + ) : ( + <> + 확정 금리 없음 + 금리가 확정 숫자로 기록된 검토 후보가 없어요. 금리 범위·변동금리 문장은 비교하지 않아요. + + )} +
+
+ 제외·분리한 자금 + {numberFormat.format(excluded.length)}개 + + 제외 이유 보기 + +
+
+ +
+

01 · 검토할 정책자금

+

+ {industryLabel ? `${industryLabel} 창업에 검토할 수 있는 정책자금` : '검토할 수 있는 정책자금'} +

+

+ 검토 후보를 먼저, 조건 확인이 필요한 자금을 다음에 정리했어요. 한도·금리·기간은 카탈로그에 기록한 공고 + 요약이에요. +

+ {summary.CURRENT_CANDIDATE === 0 && ( +
+

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

+

+ 후보 0건은 정상 결과입니다. 접수 중으로 확인된 상품이 없거나, 확인된 조건이 모두 충족되지 않았거나, 원문 + 근거·검수가 부족해 추가 확인으로 남은 상태입니다. + {emptyProfile && + ' 입력한 조건이 없어 대부분의 조건이 미확인으로 남았습니다. 사업 단계·자치구·업종·용도를 입력하면 판정이 달라집니다.'} +

+
+ )} + {reviewable.length === 0 ? ( +

이번 조건에서 검토하거나 확인할 자금이 없어요.

+ ) : ( +
+ {reviewable.map((evaluation, index) => ( + + ))} +
+ )} +
+ +
+

02 · 해당되지 않는 자금

+

이번 조건에서는 제외했어요

+ {excluded.length === 0 ? ( +

제외하거나 분리한 자금이 없어요.

+ ) : ( +
    + {excluded.map((evaluation) => ( +
  • +
    + {evaluation.name} + {evaluation.organization} +
    +
    + + {STATUS_LABELS[evaluation.candidateStatus]} + +

    {evaluation.candidateReason}

    + + 공식 원문 열기 + +
    +
  • + ))} +
+ )} +
+ +
+

판정 기준

+
+
+
카탈로그
+
+ {result.release.catalogKey}@{result.release.catalogVersion} +
+
+
+
기준일
+
{formatFundingDate(result.release.basisDate)}
+
+
+
판정 기준일
+
{formatFundingDate(result.asOfDate)}
+
+
+
검수일
+
{formatFundingDate(result.release.reviewedAt)}
+
+
+
검수자
+
{result.release.reviewer}
+
+
+
적재 스키마
+
{result.release.schemaVersion}
+
+
+
활성화 시각
+
+ {result.release.activatedAt ? new Date(result.release.activatedAt).toLocaleString('ko-KR') : '기록 없음'} +
+
+
+
상품 수
+
{numberFormat.format(result.release.productCount)}건
+
+
+

판정에 쓴 조건: {profileSummary}

+
+ + ); +} + +function FundCard({ evaluation, order }: { readonly evaluation: FundingCandidateEvaluation; readonly order: number }) { + const { announcement, repayment } = evaluation; + const current = evaluation.candidateStatus === 'CURRENT_CANDIDATE'; + const lending = LENDING_TYPES.has(evaluation.supportType); + const unknownConditions = evaluation.conditions.filter((condition) => condition.verdict === 'UNKNOWN'); + return ( +
+
+

+ + {current ? '검토 후보' : '조건 확인'} + + {evaluation.organization} · {SUPPORT_TYPE_LABELS[evaluation.supportType]} +

+

+ {order}. {evaluation.name} +

+ +
+ {current ? '검토 이유' : '확인할 조건'} +

{evaluation.candidateReason}

+ {!current && unknownConditions.length > 0 && ( +
    + {unknownConditions.map((condition) => ( +
  • + {condition.label}: {condition.detail} +
  • + ))} +
+ )} +
+ +
+
+

신청처

+

{evaluation.organization}

+ + 공식 원문 열기 + +
+
+

신청 전 확인·유의사항

+ {evaluation.manualChecks.length === 0 ? ( +

기록된 추가 확인 항목이 없어요.

+ ) : ( +
    + {evaluation.manualChecks.map((check) => ( +
  • {check}
  • + ))} +
+ )} +
+
+ +
+ 판정 근거 자세히 +
+
+
상품 버전
+
+ {evaluation.productKey}@{evaluation.version} +
+
+
+
검수 상태
+
+ {REVIEW_STATE_LABELS[evaluation.reviewState]} · 검수일 {formatFundingDate(evaluation.reviewedAt)} · 다음 + 검토일 {formatFundingDate(evaluation.nextReviewAt)} +
+
+
+ +

+ {repayment.supported ? repayment.note : `상환 계산 대상이 아니에요. ${repayment.reasons.join(' ')}`} +

+
+
+ +
+
+
공개 한도
+
+ {announcement.publicLimitKrw === null + ? '공고에서 확인하지 못했어요' + : `최대 ${formatKoreanWon(announcement.publicLimitKrw)}`} +
+
+
+
금리
+
+ {repayment.terms + ? `연 ${repayment.terms.annualInterestRatePercent}% (확정)` + : !lending + ? '해당 없음 · 대출이 아니에요' + : (announcement.interestCondition ?? '공고에서 확정하지 못했어요')} +
+ {lending && !repayment.terms && 확정 숫자가 아니어서 상환 계산에 쓰지 않아요.} +
+
+
상환 조건
+
+ {!lending + ? '해당 없음 · 상환하지 않아요' + : (announcement.repaymentCondition ?? '공고에서 확정하지 못했어요')} +
+
+
+
접수
+
{APPLICATION_STATUS_LABELS[evaluation.observedApplicationStatus]}
+ + {periodText(announcement.applicationPeriod)} · {formatFundingDate(evaluation.observedAt)} 확인 + {announcement.applicationPeriod.note ? ` · ${announcement.applicationPeriod.note}` : ''} + +
+
+
+ ); +} diff --git a/app/src/features/funding/eligibility.ts b/app/src/features/funding/eligibility.ts index dc4847f..03be797 100644 --- a/app/src/features/funding/eligibility.ts +++ b/app/src/features/funding/eligibility.ts @@ -92,6 +92,16 @@ export type FundingCandidateEvaluation = { manualChecks: string[]; unsupportedCalculationReasons: string[]; repayment: ProductRepaymentAvailability; + // 카탈로그에 기록한 공고 요약 그대로다. 판정과 상환 계산에 쓰지 않고 화면 표시에만 + // 쓴다. 조건 문장은 숫자로 해석하지 않는다. + announcement: FundingAnnouncement; +}; + +export type FundingAnnouncement = { + publicLimitKrw: string | null; + interestCondition: string | null; + repaymentCondition: string | null; + applicationPeriod: { start: string | null; end: string | null; note: string | null }; }; export type FundingCandidateSummary = Record & { total: number }; @@ -402,6 +412,12 @@ export function evaluateProduct( manualChecks: [...product.additionalChecks], unsupportedCalculationReasons: [...product.unsupportedCalculationReasons], repayment, + announcement: { + publicLimitKrw: product.publicLimit, + interestCondition: product.interestCondition, + repaymentCondition: product.repaymentCondition, + applicationPeriod: { ...product.applicationPeriod }, + }, }; } diff --git a/app/src/features/funding/funding.test.ts b/app/src/features/funding/funding.test.ts index 4bf8281..b56e05c 100644 --- a/app/src/features/funding/funding.test.ts +++ b/app/src/features/funding/funding.test.ts @@ -420,6 +420,26 @@ describe('the reviewed 2026-09-20 catalog', () => { expect(repayment.reasons.length).toBeGreaterThan(0); } }); + + it('shows the announced limit as recorded without turning it into a repayment term', () => { + const parsed = catalogFrom(validate(REAL_CATALOG)); + const startupFund = parsed.products.find((entry) => entry.productKey === 'seoul-fund-2026-startup-company')!; + const evaluation = evaluateProduct( + startupFund, + { businessStage: 'POST_REGISTRATION', districtCode: '11440', industryCode: 'CS100001', purpose: 'UNKNOWN' }, + { asOfDate: '2026-09-20' }, + ); + expect(evaluation.announcement).toEqual({ + publicLimitKrw: startupFund.publicLimit, + interestCondition: startupFund.interestCondition, + repaymentCondition: startupFund.repaymentCondition, + applicationPeriod: startupFund.applicationPeriod, + }); + expect(evaluation.announcement.publicLimitKrw).toBe('100000000'); + expect(evaluation.repayment.publicLimitKrw).toBeNull(); + expect(evaluation.repayment.supported).toBe(false); + expect(evaluation.candidateStatus).not.toBe('CURRENT_CANDIDATE'); + }); }); describe('condition verdicts and candidate status', () => { diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 67ab277..5051eb2 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -23,7 +23,7 @@ | 프랜차이즈 참고 | 업종별 브랜드 검색, 가맹점 수·개폐점, 연 평균매출·3.3㎡당 평균매출, 정보공개서 창업 금액(가맹금·교육비·보증금·기타), 연도별 추이 | 공정위 정보공개서(공공데이터포털 가맹정보), 최근 3개 기준년도(각각 전년도 실적). 전국 평균이며 재무 입력에 자동으로 넣지 않음 | | 재무 계획(`/`, 로그인) | 초기 비용, 자기자금, 매출·비용 가정, 부족자금, 운영수지, 손익분기, 상환 일정, 3개 시나리오 | 사용자가 입력한 가정만 계산 | | 계산 결과 | 서버 재계산, 입력·버전·출처를 보존한 불변 결과 | 과거 결과를 수정하지 않음 | -| 자금 후보(`/funding`, 로그인) | 사업 단계·지역·업종·용도로 검토 후보·추가 확인 필요·조건 미충족·사업자등록 이후 검토·접수 종료·검수 기한 경과로 분류 | 운영자가 적재한 카탈로그만 사용. 검수자 미지정 상품은 현재 후보로 표시하지 않음 | +| 자금 후보(`/funding`, 로그인) | 사업 단계·지역·업종·용도로 검토 후보·추가 확인 필요·조건 미충족·사업자등록 이후 검토·접수 종료·검수 기한 경과로 분류하고, 공고 요약(한도·금리·상환 조건)과 함께 보고서로 표시 | 운영자가 적재한 카탈로그만 사용. 검수자 미지정 상품은 현재 후보로 표시하지 않음 | | 대출 가정 적용 | 확정 조건을 갖춘 현재 후보를 사용자가 선택해 재무계획에 적용 | 미확정·비대출 상품은 불가 사유 표시 | 상위 업종은 한식(`CS100001`)과 커피·음료(`CS100010`)만 노출한다. diff --git a/docs/domains/funding.md b/docs/domains/funding.md index c5fb5b3..febce6f 100644 --- a/docs/domains/funding.md +++ b/docs/domains/funding.md @@ -7,7 +7,8 @@ - `app/src/features/funding`: 판정 함수는 React·DB·외부 API에 의존하지 않는다. - 카탈로그 Zod 스키마(`schema.ts`), 규칙 검사(`validation.ts`), 고정 조건 판정(`eligibility.ts`) - 릴리스·상품 버전 적재, 활성 카탈로그 읽기(`read.ts`), 요청 스키마·응답 조립(`candidates.ts`) - - 페이지 단위 후보 화면(`FundingMatcher.tsx`), 두 화면이 함께 쓰는 결과 표시(`FundingCandidatesPanel.tsx`) + - `/funding` 보고서(`FundingMatcher.tsx`, `FundingReport.tsx`), 계획 화면의 결과 표시(`FundingCandidatesPanel.tsx`). 두 화면은 라벨과 조건별 판정표를 함께 쓴다. + - `/funding` 보고서의 "가장 낮은 확정 금리"는 현재 검토 후보 중 확정 금리(`interestRatePercent`)가 있는 상품만 비교한다. 금리 문장은 비교하지 않는다. - `app/catalog/funding`: 운영자가 공식 공고를 정리한 JSON 카탈로그와 갱신 절차 문서. ## 카탈로그 @@ -66,6 +67,7 @@ - 상품명·기관·지원 유형·후보 상태, 포함·제외·추가 확인 이유 - 조건별 PASS/FAIL/UNKNOWN과 근거 id, 공식 링크·접수·검수 상태 - 상환 계산 가능 여부와 불가 사유 + - 카탈로그에 기록한 공고 요약(`announcement`): 공개 한도, 금리·상환 조건 문장, 신청기간. 화면 표시에만 쓰고 판정·상환 계산에는 쓰지 않는다. ### 계획에 저장한 조건으로 판정