diff --git a/AGENTS.md b/AGENTS.md index eaa72cf..1d5ba90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ 5. 매출의 시간 단위와 점포당 분모가 공식 확정되기 전에는 월 환산·점포당 매출을 [ADR 0002](docs/decisions/0002-provisional-market-sales.md)의 잠정 해석으로만 제공한다. "잠정"과 근거·원값을 함께 표시하고, 계산 결과에 저장하지 않는다. 6. 계획의 소유자는 서버에서 검사한다. 없는 리소스와 타인 소유 리소스는 모두 404다. 계획 변경은 revision을 검사하고 충돌은 409다. 7. 저장된 계산 결과는 append-only다. 과거 결과를 덮어쓰거나 자동 재계산하지 않는다. -8. 미검수·종료·근거 부족 자금 상품을 현재 신청 가능 또는 상환 계산 가능으로 표시하지 않는다. +8. 미검수·종료·근거 부족 자금 상품을 현재 신청 가능 또는 상환 계산 가능으로 표시하지 않는다. 업종·용도만 확인되지 않은 상품은 [ADR 0006](docs/decisions/0006-funding-discovery-automation.md)에 따라 신청 전 확인 항목과 함께 현재 후보로 표시한다. 9. 운영 데이터는 `PENDING → ACTIVE` 검증을 거친다. 새 적재가 실패하면 기존 ACTIVE를 유지한다. 10. 출처 URL, 기준기간, 릴리스 키, checksum, 적용 시점 등 provenance를 보존한다. diff --git a/app/src/features/funding/FundingCandidatesPanel.tsx b/app/src/features/funding/FundingCandidatesPanel.tsx index 336ccad..9af818a 100644 --- a/app/src/features/funding/FundingCandidatesPanel.tsx +++ b/app/src/features/funding/FundingCandidatesPanel.tsx @@ -54,13 +54,13 @@ const GROUPS: { status: FundingCandidateStatus; title: string; note: string; sep { status: 'CURRENT_CANDIDATE', title: '현재 검토 후보', - note: '확인된 조건이 모두 충족되고 접수 중으로 확인된 상품입니다. 승인 확정이 아닙니다.', + note: '사업 단계·지역 조건이 충족되고 접수 중으로 확인된 상품입니다. 확인되지 않은 업종·용도는 신청 전 추가 확인에 적었습니다. 승인 확정이 아닙니다.', separated: false, }, { status: 'NEEDS_CONFIRMATION', title: '추가 확인 필요', - note: '확인되지 않은 조건, 원문 근거 부족, 검수 미완료 중 하나 이상이 있습니다.', + note: '사업 단계·지역 미확인, 원문 근거 부족, 검수 미완료 중 하나 이상이 있습니다.', separated: false, }, { diff --git a/app/src/features/funding/candidates.test.ts b/app/src/features/funding/candidates.test.ts index d23ccd1..b79a71a 100644 --- a/app/src/features/funding/candidates.test.ts +++ b/app/src/features/funding/candidates.test.ts @@ -265,21 +265,39 @@ describe('후보 판정과 응답', () => { expect(payload.summary.NOT_ELIGIBLE).toBe(1); }); - it('FAIL 없이 UNKNOWN만 있으면 추가 확인으로 분류한다', async () => { + it('업종·용도만 확인되지 않으면 신청 전 확인 항목과 함께 검토 후보로 분류한다', async () => { const { prisma } = stubPrisma({ products: [ membership( rawProduct({ industryConditions: { scope: 'UNKNOWN', included: [], excluded: [], note: '업종 매핑 미검수' }, + additionalChecks: ['사업계획서 제출 여부'], }), 0, ), ], }); - const payload = await candidatesOf(prisma); - expect(payload.evaluations[0].eligibilityVerdict).toBe('UNKNOWN'); - expect(payload.evaluations[0].candidateStatus).toBe('NEEDS_CONFIRMATION'); - expect(payload.summary.CURRENT_CANDIDATE).toBe(0); + const payload = await candidatesOf(prisma, { ...PRE_PROFILE, purpose: 'UNKNOWN' }); + const [evaluation] = payload.evaluations; + expect(evaluation.eligibilityVerdict).toBe('UNKNOWN'); + expect(evaluation.candidateStatus).toBe('CURRENT_CANDIDATE'); + expect(evaluation.manualChecks).toEqual([ + '사업계획서 제출 여부', + '용도: 사업 용도가 입력되지 않았습니다.', + '업종: 업종 매핑 미검수', + ]); + expect(evaluation.candidateReason).toContain('신청 전 3건'); + expect(payload.summary.CURRENT_CANDIDATE).toBe(1); + }); + + it('사업 단계나 지역이 확인되지 않으면 추가 확인으로 분류한다', async () => { + const { prisma } = stubPrisma({ products: [membership(rawProduct(), 0)] }); + const noDistrict = await candidatesOf(prisma, { ...PRE_PROFILE, districtCode: null }); + expect(noDistrict.evaluations[0].candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(noDistrict.evaluations[0].candidateReason).toContain('자치구'); + const noStage = await candidatesOf(prisma, { ...PRE_PROFILE, businessStage: 'UNKNOWN' }); + expect(noStage.evaluations[0].candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(noStage.summary.CURRENT_CANDIDATE).toBe(0); }); it('검수자가 지정되지 않은 상품은 검토 후보가 되지 않는다', async () => { diff --git a/app/src/features/funding/eligibility.ts b/app/src/features/funding/eligibility.ts index b724171..2b12308 100644 --- a/app/src/features/funding/eligibility.ts +++ b/app/src/features/funding/eligibility.ts @@ -35,6 +35,9 @@ export const UNKNOWN_FUNDING_PROFILE: FundingProfile = { export type FundingConditionKey = 'BUSINESS_STAGE' | 'REGION' | 'PURPOSE' | 'INDUSTRY'; +// 공식 출처가 구조화해 주지 않는 조건. UNKNOWN이면 현재 후보를 막지 않고 신청 전 확인 항목이 된다. +const PRE_APPLICATION_CHECK_KEYS: ReadonlySet = new Set(['PURPOSE', 'INDUSTRY']); + export type FundingConditionResult = { key: FundingConditionKey; label: string; @@ -346,6 +349,17 @@ export function evaluateProduct( // 관측 접수 상태가 OPEN·UNKNOWN이어도 신청 종료일이 지나면 접수가 끝난 것이다. 종료일 당일까지는 접수 중으로 본다. const periodEnd = product.applicationPeriod.end; const periodEnded = periodEnd !== null && compareIsoDates(periodEnd, options.asOfDate) < 0; + // 업종·용도가 확인되지 않은 것은 현재 후보를 막지 않고 신청 전 확인 항목으로 남긴다(ADR 0006). + // 사업 단계·지역이 확인되지 않으면 지금처럼 추가 확인으로 둔다. + const blockingUnknown = conditions.find( + (condition) => condition.verdict === 'UNKNOWN' && !PRE_APPLICATION_CHECK_KEYS.has(condition.key), + ); + const manualChecks = [ + ...product.additionalChecks, + ...conditions + .filter((condition) => condition.verdict === 'UNKNOWN' && PRE_APPLICATION_CHECK_KEYS.has(condition.key)) + .map((condition) => `${condition.label}: ${condition.detail}`), + ]; let candidateStatus: FundingCandidateStatus; let candidateReason: string; @@ -373,16 +387,15 @@ export function evaluateProduct( candidateStatus = 'NEEDS_CONFIRMATION'; candidateReason = '검색 결과 요약으로만 확인되어 공식 원문 페이지·첨부를 직접 확인한 근거가 없습니다. 재검수 전에는 현재 후보로 확정하지 않습니다.'; - } else if (eligibilityVerdict === 'UNKNOWN') { + } else if (blockingUnknown) { candidateStatus = 'NEEDS_CONFIRMATION'; - candidateReason = - conditions.find((condition) => condition.verdict === 'UNKNOWN')?.detail ?? '확인되지 않은 조건이 있습니다.'; + candidateReason = blockingUnknown.detail; } else if (product.observedApplicationStatus === 'OPEN') { candidateStatus = 'CURRENT_CANDIDATE'; candidateReason = - product.additionalChecks.length === 0 + manualChecks.length === 0 ? '확인된 조건이 모두 충족되고 접수 상태가 OPEN입니다. 승인 확정은 아닙니다.' - : `확인된 조건은 충족하지만 신청 전 ${product.additionalChecks.length}건을 더 확인해야 합니다. 승인 확정은 아닙니다.`; + : `확인된 조건은 충족하지만 신청 전 ${manualChecks.length}건을 더 확인해야 합니다. 승인 확정은 아닙니다.`; } else { candidateStatus = 'NEEDS_CONFIRMATION'; candidateReason = '접수 상태가 OPEN으로 확인되지 않아 현재 신청 가능 후보로 확정하지 않습니다.'; @@ -406,7 +419,7 @@ export function evaluateProduct( eligibilityVerdict, candidateStatus, candidateReason, - manualChecks: [...product.additionalChecks], + manualChecks, unsupportedCalculationReasons: [...product.unsupportedCalculationReasons], repayment, }; diff --git a/app/src/features/funding/funding.test.ts b/app/src/features/funding/funding.test.ts index 4bf8281..6d6e6bf 100644 --- a/app/src/features/funding/funding.test.ts +++ b/app/src/features/funding/funding.test.ts @@ -459,14 +459,23 @@ describe('condition verdicts and candidate status', () => { expect(evaluation.candidateStatus).toBe('NOT_ELIGIBLE'); }); - it('returns UNKNOWN when no condition fails but one is unknown', () => { + it('keeps an unknown industry as a pre-application check instead of blocking the candidate', () => { const unknownScope = product({ industryConditions: { scope: 'UNKNOWN', included: [], excluded: [], note: '업종 매핑 미검수' }, }); const parsed = catalogFrom(validate(catalog([unknownScope]))); const evaluation = evaluateProduct(parsed.products[0], preProfile, { asOfDate: REVIEW_DATE }); expect(evaluation.eligibilityVerdict).toBe('UNKNOWN'); + expect(evaluation.candidateStatus).toBe('CURRENT_CANDIDATE'); + expect(evaluation.manualChecks).toContain('업종: 업종 매핑 미검수'); + }); + + it('still blocks a candidate whose region is unknown', () => { + const unknownRegion = product({ region: { scope: 'UNKNOWN', districtCodes: [], note: '지역 조건 미확인' } }); + const parsed = catalogFrom(validate(catalog([unknownRegion]))); + const evaluation = evaluateProduct(parsed.products[0], preProfile, { asOfDate: REVIEW_DATE }); expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(evaluation.candidateReason).toBe('지역 조건 미확인'); }); it('keeps a post-registration product out of the pre-registration candidate group', () => { diff --git a/docs/domains/funding.md b/docs/domains/funding.md index 4c68566..55e5d3f 100644 --- a/docs/domains/funding.md +++ b/docs/domains/funding.md @@ -41,6 +41,10 @@ - 판정 규칙(`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이면 현재 후보를 막지 않는다. 대신 "업종: …", "용도: …" 형태로 신청 전 추가 확인(`manualChecks`)에 더한다. 이때 자격 판정(`eligibilityVerdict`)은 그대로 UNKNOWN이다. + - FAIL은 조건과 상관없이 조건 미충족이다. - 접수 상태 OPEN/CLOSED/UNKNOWN은 자격 판정과 별도로 유지한다. - 신청 종료일이 판정 기준일보다 앞서면 관측 접수 상태가 OPEN·UNKNOWN이어도 접수 종료(CLOSED)로 판정한다. 종료일 당일까지는 접수 중으로 보고, 종료일이 없으면(예: 예산 소진 시까지) 관측 접수 상태를 따른다. - 검수 기한이 지난 상품, 검수일 또는 검수자가 없는 상품, 검색 결과 요약으로만 확인한 상품은 현재 후보로 확정하지 않는다.