From dcb7f38467ae9c06ef007791d34e335f5d969cad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:44:41 +0000 Subject: [PATCH] fix(funding): treat products past their application end date as closed evaluateProduct ignored the application period, so a reviewed product recorded as OPEN stayed a current candidate after its end date until the next review date, against AGENTS.md section 3 item 8. A product whose end date is before the as-of date is now CLOSED whatever its observed status; the end date itself still counts as open, and a missing end date keeps the observed status. The candidate, funding-match and loan-assumption API tests judge with the real KST date, and their fixtures ended on 2026-12-31, so they would fail from 2027. Their end dates now use FAR_FUTURE like nextReviewAt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- app/src/features/funding/candidates.test.ts | 30 ++++++++++++++++++- app/src/features/funding/eligibility.ts | 7 +++++ .../features/plans/funding-matches.test.ts | 2 +- .../features/plans/loan-assumption.test.ts | 2 +- docs/domains/funding.md | 1 + 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/src/features/funding/candidates.test.ts b/app/src/features/funding/candidates.test.ts index 4c449b5..d23ccd1 100644 --- a/app/src/features/funding/candidates.test.ts +++ b/app/src/features/funding/candidates.test.ts @@ -56,7 +56,7 @@ const BASE_PRODUCT: Record = { region: { scope: 'SEOUL', districtCodes: [], note: null }, purpose: { included: ['OPERATING_FUNDS'], excluded: [], note: null }, industryConditions: { scope: 'UNRESTRICTED', included: [], excluded: [], note: null }, - applicationPeriod: { start: '2026-09-01', end: '2026-12-31', note: null }, + applicationPeriod: { start: '2026-09-01', end: FAR_FUTURE, note: null }, observedApplicationStatus: 'OPEN', observedAt: TEST_DATE, reviewedAt: TEST_DATE, @@ -311,6 +311,34 @@ describe('후보 판정과 응답', () => { expect(payload.summary.CURRENT_CANDIDATE).toBe(0); }); + it('신청 종료일이 지나면 접수 중으로 기록된 상품도 접수 종료로 분리한다', async () => { + const { prisma } = stubPrisma({ + products: [ + membership(rawProduct({ applicationPeriod: { start: '2026-09-01', end: '2026-09-30', note: null } }), 0), + ], + }); + const lastDay = await candidatesOf(prisma, PRE_PROFILE, '2026-09-30'); + expect(lastDay.evaluations[0].candidateStatus).toBe('CURRENT_CANDIDATE'); + + const after = await candidatesOf(prisma, PRE_PROFILE, '2026-10-01'); + const [evaluation] = after.evaluations; + expect(evaluation.observedApplicationStatus).toBe('OPEN'); + expect(evaluation.candidateStatus).toBe('CLOSED'); + expect(evaluation.candidateReason).toContain('2026-09-30'); + expect(after.summary.CLOSED).toBe(1); + expect(after.summary.CURRENT_CANDIDATE).toBe(0); + }); + + it('신청 종료일이 없으면 관측 접수 상태를 따른다', async () => { + const { prisma } = stubPrisma({ + products: [ + membership(rawProduct({ applicationPeriod: { start: '2026-09-01', end: null, note: '예산 소진 시까지' } }), 0), + ], + }); + const payload = await candidatesOf(prisma, PRE_PROFILE, '2026-12-31'); + expect(payload.evaluations[0].candidateStatus).toBe('CURRENT_CANDIDATE'); + }); + it('검수 기한이 지난 상품은 접수 중이어도 검수 기한 경과로 분리한다', async () => { const { prisma } = stubPrisma({ products: [ diff --git a/app/src/features/funding/eligibility.ts b/app/src/features/funding/eligibility.ts index dc4847f..b724171 100644 --- a/app/src/features/funding/eligibility.ts +++ b/app/src/features/funding/eligibility.ts @@ -2,6 +2,7 @@ import type { FundingCatalog, FundingProduct } from './schema.ts'; import { REPAYABLE_SUPPORT_TYPES, combineVerdicts, + compareIsoDates, INDUSTRY_CODE_PATTERN, isPrimaryRetrievalMethod, isSupportedRepaymentMethod, @@ -342,6 +343,9 @@ export function evaluateProduct( const state = reviewState(product, options.asOfDate); const repayment = assessProductRepayment(product); const primaryEvidenceVerified = hasPrimaryEvidence(product); + // 관측 접수 상태가 OPEN·UNKNOWN이어도 신청 종료일이 지나면 접수가 끝난 것이다. 종료일 당일까지는 접수 중으로 본다. + const periodEnd = product.applicationPeriod.end; + const periodEnded = periodEnd !== null && compareIsoDates(periodEnd, options.asOfDate) < 0; let candidateStatus: FundingCandidateStatus; let candidateReason: string; @@ -351,6 +355,9 @@ export function evaluateProduct( } else if (product.observedApplicationStatus === 'CLOSED') { candidateStatus = 'CLOSED'; candidateReason = `${product.observedAt} 기준 접수 상태가 CLOSED입니다.`; + } else if (periodEnded) { + candidateStatus = 'CLOSED'; + candidateReason = `신청 종료일 ${periodEnd}이(가) 기준일 ${options.asOfDate} 이전이라 접수가 끝난 것으로 판정합니다.`; } else if (state === 'UNREVIEWED') { candidateStatus = 'NEEDS_CONFIRMATION'; candidateReason = diff --git a/app/src/features/plans/funding-matches.test.ts b/app/src/features/plans/funding-matches.test.ts index 3f71d03..fefe780 100644 --- a/app/src/features/plans/funding-matches.test.ts +++ b/app/src/features/plans/funding-matches.test.ts @@ -74,7 +74,7 @@ const PRODUCT: Record = { region: { scope: 'SEOUL', districtCodes: [], note: null }, purpose: { included: ['OPERATING_FUNDS'], excluded: [], note: null }, industryConditions: { scope: 'UNRESTRICTED', included: [], excluded: [], note: null }, - applicationPeriod: { start: '2026-09-01', end: '2026-12-31', note: null }, + applicationPeriod: { start: '2026-09-01', end: FAR_FUTURE, note: null }, observedApplicationStatus: 'OPEN', observedAt: TEST_DATE, reviewedAt: TEST_DATE, diff --git a/app/src/features/plans/loan-assumption.test.ts b/app/src/features/plans/loan-assumption.test.ts index ea56a82..8a60297 100644 --- a/app/src/features/plans/loan-assumption.test.ts +++ b/app/src/features/plans/loan-assumption.test.ts @@ -90,7 +90,7 @@ const BASE_PRODUCT: Record = { region: { scope: 'SEOUL', districtCodes: [], note: null }, purpose: { included: ['OPERATING_FUNDS'], excluded: [], note: null }, industryConditions: { scope: 'UNRESTRICTED', included: [], excluded: [], note: null }, - applicationPeriod: { start: '2026-09-01', end: '2026-12-31', note: null }, + applicationPeriod: { start: '2026-09-01', end: FAR_FUTURE, note: null }, observedApplicationStatus: 'OPEN', observedAt: TEST_DATE, reviewedAt: TEST_DATE, diff --git a/docs/domains/funding.md b/docs/domains/funding.md index c5fb5b3..4c68566 100644 --- a/docs/domains/funding.md +++ b/docs/domains/funding.md @@ -42,6 +42,7 @@ - 판정 규칙(`eligibility.ts`): 조건별로 PASS/FAIL/UNKNOWN을 계산한다. FAIL이 하나라도 있으면 FAIL, FAIL 없이 UNKNOWN이 있으면 UNKNOWN, 모두 PASS면 PASS다. - REGION·PURPOSE·INDUSTRY를 포함한 각 조건에 직접 확인한 공식 근거가 없으면 UNKNOWN이다. - 접수 상태 OPEN/CLOSED/UNKNOWN은 자격 판정과 별도로 유지한다. +- 신청 종료일이 판정 기준일보다 앞서면 관측 접수 상태가 OPEN·UNKNOWN이어도 접수 종료(CLOSED)로 판정한다. 종료일 당일까지는 접수 중으로 보고, 종료일이 없으면(예: 예산 소진 시까지) 관측 접수 상태를 따른다. - 검수 기한이 지난 상품, 검수일 또는 검수자가 없는 상품, 검색 결과 요약으로만 확인한 상품은 현재 후보로 확정하지 않는다. - 사업자등록 이후에만 검토할 수 있는 상품은 POST_REGISTRATION 상태로 현재 예비 창업자 후보와 분리한다. - 지원 유형 표시: GRANT 지원금, GUARANTEE 보증, LOAN 대출, SPACE 공간·보육, PROGRAM 프로그램.