From 861cc4da96fd02de2917ebd187fac8fdf90637a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:10:15 +0000 Subject: [PATCH 1/9] docs(funding): propose automating funding discovery and recheck Draft ADR 0006 (Proposed) for issue #5. Discovery from the K-Startup and Bizinfo Open APIs produces catalog drafts with reviewer UNASSIGNED; demotions (application end date passed, source changed, removed or closed) apply automatically; promotion to a current candidate still needs a human-reviewed catalog release. AI interpretation of announcement text stays out of scope. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- .../0006-funding-discovery-automation.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/decisions/0006-funding-discovery-automation.md diff --git a/docs/decisions/0006-funding-discovery-automation.md b/docs/decisions/0006-funding-discovery-automation.md new file mode 100644 index 0000000..1276618 --- /dev/null +++ b/docs/decisions/0006-funding-discovery-automation.md @@ -0,0 +1,121 @@ +# ADR 0006: 자금 공고 탐색과 재확인을 자동화하고 승격만 사람이 승인 + +- Status: Proposed +- Date: 2026-09-25 +- Owners: 미정 (Accepted 전에 검수 책임자와 함께 지정) +- Related issue/PR: #5, #13, #15 +- Supersedes: 없음 + +## Context + +자금 후보 기능은 동작하지만 보여 줄 후보가 없다. 2026-09-25 기준 카탈로그 `2026-09-23.1`의 상태는 다음과 같다. + +- **현재 후보 0건:** 상품 5개 모두 검수자가 `UNASSIGNED`라서, 어떤 계획에서도 현재 후보가 되지 않는다(AGENTS.md §3의 8번). +- **상환 계산 가능 0건:** 대출 3개 모두 금리가 확정되지 않아(`interestRateConfirmed=false`) 계획의 대출 가정으로 적용할 수 없다. +- **곧 기한 경과:** 서울시 중소기업육성자금 3개의 재검수일이 2026-10-20이다. 강북창업지원센터는 이미 기한이 지났고, 예비창업패키지는 접수가 끝났다. +- **병목은 사람의 시간:** 운영자가 기관 사이트를 돌며 공고를 찾고, 필드를 JSON에 옮겨 적고, 기한마다 다시 확인한다. 검수 경고(검수자 미지정·기한 경과·1차 원문 아님)는 이미 `funding:validate`에 있다. 없는 것은 이 일을 계속할 사람이다. + +공고를 구조화된 형태로 주는 공식 Open API가 두 개 있다. 아래 항목은 공개된 이용 사례에서 확인한 것이며, 실제 응답은 구현 전에 다시 확인한다. + +- **K-Startup 공고 정보**(창업진흥원, `apis.data.go.kr/B552735/kisedKstartupService01`, `getAnnouncementInformation01`) + - 주는 값: 공고명, 접수 시작·종료일, 지원 지역, 신청 대상, 창업 업력, 모집 진행 여부, 상세 페이지 URL. 하루 한 번 갱신된다. + - 키: 공공데이터포털 인증키(지금 `SEMAS_SERVICE_KEY`)를 쓰며, 이 API에 대한 활용신청이 따로 필요하다. + - 한계: 지역 조건을 서버가 적용하지 않고 다른 지역 공고가 섞여 오는 사례가 보고됐다. +- **기업마당 지원사업정보**(중소벤처기업부, `www.bizinfo.go.kr/uss/rss/bizinfoApi.do`) + - 주는 값: 공고 ID, 공고명, 소관·수행기관, 신청기간(`YYYYMMDD ~ YYYYMMDD` 문자열), 지원대상(자유 서술), 사업개요, 공고 URL, 분야·지역 해시태그. 중앙부처와 지자체 공고를 함께 모은다. + - 키: 기업마당에서 발급하는 별도 인증키가 필요하다. + +두 API 모두 금리·한도·상환기간·거치·업종 제한·자금 용도를 구조화된 값으로 주지 않는다. 이 조건은 공고문 본문과 첨부(PDF·HWP)에만 있다. + +현재 판정에는 빈틈이 하나 있다. `evaluateProduct`는 신청기간을 보지 않는다. 그래서 검수를 마치고 OPEN으로 기록한 상품은 신청 종료일이 지나도 재검수일까지 현재 후보로 남는다. 지금은 OPEN 상품이 없어 드러나지 않았다. + +지켜야 할 제약은 다음과 같다. + +- 웹 요청 중에는 외부 데이터를 가져오지 않는다(AGENTS.md §2). +- 미검수·종료·근거 부족 상품은 현재 후보로 표시하지 않는다(§3의 8번). +- 적재는 `PENDING → ACTIVE` 검증을 거치고(§3의 9번), provenance를 보존한다(§3의 10번). +- `docs/PRODUCT.md`는 "자동 공고 해석"을 제외 범위로 둔다. + +## Decision + +기본 원칙: **상품을 "덜 현재적인" 상태로 바꾸는 강등은 자동으로 적용한다. 현재 후보나 상환 계산 가능 상태로 올리는 승격은 사람이 승인해야만 일어난다.** + +### 1. 자동 탐색: `funding:discover` + +- 운영자 명령이 K-Startup과 기업마당에서 공고를 받는다. 그중 제품 범위에 들 가능성이 있는 공고만 검수 대기 목록으로 만든다. 기준은 지역이 서울 또는 전국이고, 대상이 예비 창업자·소상공인·초기 창업기업인 공고다. +- 공고마다 구조화 필드로 채울 수 있는 값만 채운 카탈로그 상품 초안을 만든다. 형식은 기존 `fundingProductSchema`를 그대로 따른다. + - 채우는 값: 이름, 기관, 공식 URL, 신청기간, 접수 상태. K-Startup이 구조화해 주는 경우에는 사업 단계와 지역도 채운다. + - 근거: `retrievalMethod: OFFICIAL_API`로 기록하고, 출처 API·공고 ID·조회일·응답 checksum을 남긴다. + - 채우지 않는 값: 금리·한도·상환·업종·용도는 `null` 또는 `UNKNOWN`으로 둔다. + - `reviewer`는 `UNASSIGNED`로 둔다. 그래서 초안은 기존 규칙대로 현재 후보가 되지 않는다. +- 자유 서술 필드(지원대상, 사업개요)는 키워드로 분류해 검수자에게 "힌트"로만 보여 준다. 조건 판정(PASS·FAIL)의 근거로는 쓰지 않는다. +- 이미 카탈로그에 있는 상품은 출처 공고 ID로 맞춰 보고, 바뀐 필드만 보여 준다. +- 초안은 파일로 출력한다. 검수자가 초안을 `app/catalog/funding/catalog.json`에 옮기는 PR이 검수 기록이 된다. 새 DB 테이블이나 관리자 화면은 만들지 않는다. + +### 2. 자동 강등: 사람 없이 적용 + +1. **판정 시점 날짜 규칙(순수 함수):** + - 신청 종료일이 판정 기준일보다 앞서면, 관측 상태가 OPEN이어도 CLOSED로 판정한다. + - 신청 시작일이 판정 기준일보다 뒤면 현재 후보가 아니다. +2. **출처 재확인: `funding:recheck`** + - 운영자 명령이 ACTIVE 카탈로그의 상품을 출처 공고 ID로 다시 조회한다. + - 결과는 `SAME`·`CHANGED`·`REMOVED`·`CLOSED`·`FAILED` 중 하나로, append-only 재확인 기록에 남긴다. 기록에는 상품 버전, 확인 시각, 출처, 응답 checksum을 함께 둔다. + - 판정은 상품별 가장 최근 기록을 읽는다. `CLOSED`면 CLOSED, `CHANGED`·`REMOVED`면 추가 확인으로 강등한다. +3. **실패는 강등 근거가 아니다:** + - API 장애·인증 오류·빈 응답은 `FAILED`로 기록하고 판정에 쓰지 않는다. 빈 응답을 "공고 없음"(`REMOVED`)으로 바꾸지 않는다(§3의 2번). + - 재확인 기록이 없거나 전부 `FAILED`면 강등하지 않는다. 이때는 기존 검수 기한 규칙(`nextReviewAt`)이 안전망이다. + +### 3. 승격: 사람 승인 + +- 현재 후보가 되려면 지금처럼 검수자가 지정된 카탈로그 버전이 `funding:load`로 ACTIVE가 되어야 한다. 판정 규칙(`eligibility.ts`)의 승격 조건은 바꾸지 않는다. +- 검수자가 하는 일은 두 가지다. + - 초안에 자동으로 채운 값을 공식 원문과 대조한다. + - 자동으로 채울 수 없는 조건(업종·용도·금리·한도·상환)을 원문에서 확인해 기록한다. +- `OFFICIAL_API` 근거는 지금처럼 1차 근거로 인정한다. 단, 구조화 필드에서 온 값에만 해당하며, 자유 서술 필드의 키워드 분류는 근거가 아니다. +- **검수 주기:** + - 자동 재확인: 주 1회 이상. + - 사람 재검수: 재확인이 `CHANGED`·`REMOVED`를 알릴 때마다, 그리고 적어도 90일마다(`nextReviewAt`은 `reviewedAt`에서 90일 이내). +- **검수 책임자:** 이 ADR을 Accepted로 바꾸기 전에 이름을 적는다. 비어 있으면 Accepted로 바꾸지 않는다. + +### 4. 범위 밖 + +- 공고문 본문·첨부를 AI로 해석해 조건을 확정하는 일. `docs/PRODUCT.md`의 "자동 공고 해석" 제외는 "공고문 본문의 자동 해석"으로 좁혀서 유지한다. 초안 작성 보조로 쓰는 방안은 필요해지면 별도 ADR로 다룬다. +- 기관 웹사이트 스크래핑. Open API가 있는 두 출처부터 시작한다. +- 사람 승인 없는 승격, 웹 요청 중의 외부 조회, 공고 원문 파일의 저장소 보관. +- 정기 실행 위치(배포 플랫폼 cron, GitHub Actions 예약 실행 등)와 결과 전달 방식. #15에서 배포 환경과 함께 정한다. 그 전까지는 운영자가 두 명령을 직접 실행한다. + +## Alternatives considered + +- **사람 검수만 유지(#5의 선택지 1):** 규칙 변경은 없다. 하지만 병목이 사람의 시간이라 상품 수를 늘릴 수 없고, 5개도 기한 안에 유지하지 못하고 있다. +- **검수 관리 화면 구현(#5의 선택지 3):** 관리자 역할이라는 새 권한 모델이 필요하다. 지금 인증에는 역할이 없다. 카탈로그 PR이 이미 변경 내용·검수자·시점을 남기므로, 화면 없이도 검수 기록이 된다. +- **공식 API 필드만으로 사람 없이 승격:** 지원대상은 자유 서술이고, 지역 조건을 서버가 적용하지 않는 사례도 있어 잘못 판정할 위험이 있다. 게다가 업종·용도는 API에 없어서 어차피 `UNKNOWN`으로 남으므로, 현재 후보가 되지 않는다. +- **AI로 공고문을 해석해 금리·기간을 채움:** 한 번 잘못 읽으면 곧바로 틀린 상환액이 된다(§3의 1·8번). 근거 없는 정밀도를 만들지 않는다는 원칙(PRODUCT.md 원칙 7)과도 맞지 않는다. +- **출시에서 숨기거나 베타로 표시(#5의 선택지 4):** 이 ADR은 기능을 줄이기보다 운영 비용을 낮추는 쪽을 제안한다. 첫 검수가 끝나기 전에 출시하게 되면 그때 화면 처리를 #15 출시 점검에서 정한다. + +## Consequences + +- **장점:** + - 검수자의 일이 "찾고 옮겨 적기"에서 "원문과 대조하고 승인하기"로 줄어든다. + - 마감·변경·공고 삭제가 사람 없이 판정에 반영되어, 종료된 상품을 현재 후보로 보여 줄 위험이 줄어든다. +- **비용·위험:** + - 외부 API 두 개에 의존한다. API가 멈추면 탐색이 멈추고, 재확인은 `FAILED`로 남아 기존 기한 규칙으로 돌아간다. + - 인증키가 하나(기업마당) 늘어난다. + - 사람 검수가 없으면 현재 후보는 여전히 0건이다. 자동화가 검수 책임자를 대신하지 않는다. +- **migration·호환성:** + - 카탈로그 상품에 출처 공고 식별자(출처와 공고 ID) 필드를 추가하고 `schemaVersion`을 올린다. 식별자가 없는 기존 상품은 재확인 대상에서 빠진다. + - 재확인 기록 테이블을 새 migration으로 추가한다. 기존 릴리스·상품 버전 테이블은 바꾸지 않는다. +- **운영·보안·데이터 영향:** + - 키는 `app/.env.local`과 운영 비밀값에만 둔다. + - 웹 요청은 DB만 읽는다. + - 재확인 기록은 판정 근거이므로 지우지 않는다. + +## Validation + +- 신청 종료일이 판정 기준일보다 앞서면 OPEN 상품도 CLOSED로 판정하는 단위 테스트. +- `funding:discover` 초안이 `funding:validate`를 통과하고, `reviewer`가 `UNASSIGNED`이며, 현재 후보가 0건인지 확인하는 테스트. +- 재확인 기록 테스트: + - 최근 기록이 `CHANGED`·`REMOVED`인 상품은 현재 후보가 되지 않는다. + - `FAILED`만 있거나 기록이 없으면 판정이 바뀌지 않는다. + - API 오류와 빈 응답은 `REMOVED`로 기록되지 않는다. +- 첫 실제 실행 뒤 탐색 공고 수, 검수 초안 수, 검수 후 현재 후보 수를 이 ADR에 기록한다. +- **중단 조건:** 두 번 연속 검수 주기 동안 탐색 초안 중 검수를 통과한 상품이 0건이면 출처와 필터를 다시 검토한다. 그래도 없으면 #5의 선택지 4(출시에서 숨김)로 돌아간다. From d7a570a1cc6c0ac4f389e896176e7bbb06a32255 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:08:03 +0000 Subject: [PATCH 2/9] docs(funding): automate both promotion and demotion in ADR 0006 Rework the draft per the decision to automate both directions: funding:sync builds the catalog from the K-Startup and Bizinfo APIs and promotes non-loan announcements whose dates, stage and region come from structured fields. Industry and purpose UNKNOWN become pre-application checks instead of blocking. Loans and guarantees stay human-reviewed. Source failures keep the current release, and auto products drop out when syncs stop for more than three days. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- .../0006-funding-discovery-automation.md | 216 +++++++++++------- 1 file changed, 138 insertions(+), 78 deletions(-) diff --git a/docs/decisions/0006-funding-discovery-automation.md b/docs/decisions/0006-funding-discovery-automation.md index 1276618..8364f6d 100644 --- a/docs/decisions/0006-funding-discovery-automation.md +++ b/docs/decisions/0006-funding-discovery-automation.md @@ -1,8 +1,8 @@ -# ADR 0006: 자금 공고 탐색과 재확인을 자동화하고 승격만 사람이 승인 +# ADR 0006: 자금 후보의 승격·강등을 공식 API로 자동화 - Status: Proposed - Date: 2026-09-25 -- Owners: 미정 (Accepted 전에 검수 책임자와 함께 지정) +- Owners: 미정 (자동 규칙과 차단 목록 책임자. Accepted 전에 지정) - Related issue/PR: #5, #13, #15 - Supersedes: 없음 @@ -11,111 +11,171 @@ 자금 후보 기능은 동작하지만 보여 줄 후보가 없다. 2026-09-25 기준 카탈로그 `2026-09-23.1`의 상태는 다음과 같다. - **현재 후보 0건:** 상품 5개 모두 검수자가 `UNASSIGNED`라서, 어떤 계획에서도 현재 후보가 되지 않는다(AGENTS.md §3의 8번). -- **상환 계산 가능 0건:** 대출 3개 모두 금리가 확정되지 않아(`interestRateConfirmed=false`) 계획의 대출 가정으로 적용할 수 없다. +- **상환 계산 가능 0건:** 대출 3개 모두 금리가 확정되지 않아 계획의 대출 가정으로 적용할 수 없다. - **곧 기한 경과:** 서울시 중소기업육성자금 3개의 재검수일이 2026-10-20이다. 강북창업지원센터는 이미 기한이 지났고, 예비창업패키지는 접수가 끝났다. -- **병목은 사람의 시간:** 운영자가 기관 사이트를 돌며 공고를 찾고, 필드를 JSON에 옮겨 적고, 기한마다 다시 확인한다. 검수 경고(검수자 미지정·기한 경과·1차 원문 아님)는 이미 `funding:validate`에 있다. 없는 것은 이 일을 계속할 사람이다. +- **병목은 사람의 시간:** 운영자가 기관 사이트를 돌며 공고를 찾고, JSON에 옮겨 적고, 기한마다 다시 확인한다. 이 방식으로는 5개도 기한 안에 유지하지 못하고 있다. -공고를 구조화된 형태로 주는 공식 Open API가 두 개 있다. 아래 항목은 공개된 이용 사례에서 확인한 것이며, 실제 응답은 구현 전에 다시 확인한다. +공고를 구조화된 형태로 주는 공식 Open API가 두 개 있고, 2026-09-25에 두 API 모두 활용신청했다. 아래 항목은 공개된 이용 사례에서 확인한 것이며, 실제 응답은 구현 전에 다시 확인한다. - **K-Startup 공고 정보**(창업진흥원, `apis.data.go.kr/B552735/kisedKstartupService01`, `getAnnouncementInformation01`) - - 주는 값: 공고명, 접수 시작·종료일, 지원 지역, 신청 대상, 창업 업력, 모집 진행 여부, 상세 페이지 URL. 하루 한 번 갱신된다. - - 키: 공공데이터포털 인증키(지금 `SEMAS_SERVICE_KEY`)를 쓰며, 이 API에 대한 활용신청이 따로 필요하다. - - 한계: 지역 조건을 서버가 적용하지 않고 다른 지역 공고가 섞여 오는 사례가 보고됐다. + - 주는 값: 공고명, 접수 시작·종료일, 지원 지역, 신청 대상, 창업 업력, 지원 분야, 모집 진행 여부, 상세 페이지 URL. 하루 한 번 갱신된다. + - 키: 공공데이터포털 인증키(지금 `SEMAS_SERVICE_KEY`)를 쓴다. + - 한계: 지역 조건을 서버가 적용하지 않고 다른 지역 공고가 섞여 오는 사례가 보고됐다. 요청 조건 대신 응답 항목의 지역 값으로 거른다. - **기업마당 지원사업정보**(중소벤처기업부, `www.bizinfo.go.kr/uss/rss/bizinfoApi.do`) - 주는 값: 공고 ID, 공고명, 소관·수행기관, 신청기간(`YYYYMMDD ~ YYYYMMDD` 문자열), 지원대상(자유 서술), 사업개요, 공고 URL, 분야·지역 해시태그. 중앙부처와 지자체 공고를 함께 모은다. - 키: 기업마당에서 발급하는 별도 인증키가 필요하다. -두 API 모두 금리·한도·상환기간·거치·업종 제한·자금 용도를 구조화된 값으로 주지 않는다. 이 조건은 공고문 본문과 첨부(PDF·HWP)에만 있다. +두 API 모두 업종 제한·자금 용도·금리·한도·상환기간·거치를 구조화된 값으로 주지 않는다. 이 조건은 공고문 본문과 첨부(PDF·HWP)에만 있다. -현재 판정에는 빈틈이 하나 있다. `evaluateProduct`는 신청기간을 보지 않는다. 그래서 검수를 마치고 OPEN으로 기록한 상품은 신청 종료일이 지나도 재검수일까지 현재 후보로 남는다. 지금은 OPEN 상품이 없어 드러나지 않았다. +지금 판정 규칙(`eligibility.ts`)으로는 자동화된 상품이 현재 후보가 될 수 없다. 막는 곳은 네 군데다. + +1. **검수자:** 사람 검수자가 있어야 현재 후보가 된다. +2. **업종·용도:** 둘 중 하나라도 `UNKNOWN`이면 추가 확인으로 분류된다. 그런데 API에는 두 값이 없다. +3. **대출·보증:** 금융 조건의 1차 근거가 있어야 현재 후보가 된다. +4. **ACTIVE 릴리스:** ACTIVE 릴리스는 하나뿐이다(`read.ts`). 따라서 자동으로 모은 상품과 사람이 기록한 상품을 한 릴리스로 합쳐야 한다. + +현재 판정에는 빈틈도 하나 있다. `evaluateProduct`는 신청기간을 보지 않는다. 그래서 OPEN으로 기록한 상품은 신청 종료일이 지나도 재검수일까지 현재 후보로 남는다. 지금은 OPEN 상품이 없어 드러나지 않았다. 지켜야 할 제약은 다음과 같다. - 웹 요청 중에는 외부 데이터를 가져오지 않는다(AGENTS.md §2). -- 미검수·종료·근거 부족 상품은 현재 후보로 표시하지 않는다(§3의 8번). - 적재는 `PENDING → ACTIVE` 검증을 거치고(§3의 9번), provenance를 보존한다(§3의 10번). -- `docs/PRODUCT.md`는 "자동 공고 해석"을 제외 범위로 둔다. +- 자료 부족이나 장애를 빈 성공으로 위장하지 않는다(§3의 2번). +- 사용자에게 승인처럼 말하지 않는다(PRODUCT.md 원칙 5). ## Decision -기본 원칙: **상품을 "덜 현재적인" 상태로 바꾸는 강등은 자동으로 적용한다. 현재 후보나 상환 계산 가능 상태로 올리는 승격은 사람이 승인해야만 일어난다.** - -### 1. 자동 탐색: `funding:discover` - -- 운영자 명령이 K-Startup과 기업마당에서 공고를 받는다. 그중 제품 범위에 들 가능성이 있는 공고만 검수 대기 목록으로 만든다. 기준은 지역이 서울 또는 전국이고, 대상이 예비 창업자·소상공인·초기 창업기업인 공고다. -- 공고마다 구조화 필드로 채울 수 있는 값만 채운 카탈로그 상품 초안을 만든다. 형식은 기존 `fundingProductSchema`를 그대로 따른다. - - 채우는 값: 이름, 기관, 공식 URL, 신청기간, 접수 상태. K-Startup이 구조화해 주는 경우에는 사업 단계와 지역도 채운다. - - 근거: `retrievalMethod: OFFICIAL_API`로 기록하고, 출처 API·공고 ID·조회일·응답 checksum을 남긴다. - - 채우지 않는 값: 금리·한도·상환·업종·용도는 `null` 또는 `UNKNOWN`으로 둔다. - - `reviewer`는 `UNASSIGNED`로 둔다. 그래서 초안은 기존 규칙대로 현재 후보가 되지 않는다. -- 자유 서술 필드(지원대상, 사업개요)는 키워드로 분류해 검수자에게 "힌트"로만 보여 준다. 조건 판정(PASS·FAIL)의 근거로는 쓰지 않는다. -- 이미 카탈로그에 있는 상품은 출처 공고 ID로 맞춰 보고, 바뀐 필드만 보여 준다. -- 초안은 파일로 출력한다. 검수자가 초안을 `app/catalog/funding/catalog.json`에 옮기는 PR이 검수 기록이 된다. 새 DB 테이블이나 관리자 화면은 만들지 않는다. - -### 2. 자동 강등: 사람 없이 적용 - -1. **판정 시점 날짜 규칙(순수 함수):** - - 신청 종료일이 판정 기준일보다 앞서면, 관측 상태가 OPEN이어도 CLOSED로 판정한다. - - 신청 시작일이 판정 기준일보다 뒤면 현재 후보가 아니다. -2. **출처 재확인: `funding:recheck`** - - 운영자 명령이 ACTIVE 카탈로그의 상품을 출처 공고 ID로 다시 조회한다. - - 결과는 `SAME`·`CHANGED`·`REMOVED`·`CLOSED`·`FAILED` 중 하나로, append-only 재확인 기록에 남긴다. 기록에는 상품 버전, 확인 시각, 출처, 응답 checksum을 함께 둔다. - - 판정은 상품별 가장 최근 기록을 읽는다. `CLOSED`면 CLOSED, `CHANGED`·`REMOVED`면 추가 확인으로 강등한다. -3. **실패는 강등 근거가 아니다:** - - API 장애·인증 오류·빈 응답은 `FAILED`로 기록하고 판정에 쓰지 않는다. 빈 응답을 "공고 없음"(`REMOVED`)으로 바꾸지 않는다(§3의 2번). - - 재확인 기록이 없거나 전부 `FAILED`면 강등하지 않는다. 이때는 기존 검수 기한 규칙(`nextReviewAt`)이 안전망이다. - -### 3. 승격: 사람 승인 - -- 현재 후보가 되려면 지금처럼 검수자가 지정된 카탈로그 버전이 `funding:load`로 ACTIVE가 되어야 한다. 판정 규칙(`eligibility.ts`)의 승격 조건은 바꾸지 않는다. -- 검수자가 하는 일은 두 가지다. - - 초안에 자동으로 채운 값을 공식 원문과 대조한다. - - 자동으로 채울 수 없는 조건(업종·용도·금리·한도·상환)을 원문에서 확인해 기록한다. -- `OFFICIAL_API` 근거는 지금처럼 1차 근거로 인정한다. 단, 구조화 필드에서 온 값에만 해당하며, 자유 서술 필드의 키워드 분류는 근거가 아니다. -- **검수 주기:** - - 자동 재확인: 주 1회 이상. - - 사람 재검수: 재확인이 `CHANGED`·`REMOVED`를 알릴 때마다, 그리고 적어도 90일마다(`nextReviewAt`은 `reviewedAt`에서 90일 이내). -- **검수 책임자:** 이 ADR을 Accepted로 바꾸기 전에 이름을 적는다. 비어 있으면 Accepted로 바꾸지 않는다. - -### 4. 범위 밖 - -- 공고문 본문·첨부를 AI로 해석해 조건을 확정하는 일. `docs/PRODUCT.md`의 "자동 공고 해석" 제외는 "공고문 본문의 자동 해석"으로 좁혀서 유지한다. 초안 작성 보조로 쓰는 방안은 필요해지면 별도 ADR로 다룬다. -- 기관 웹사이트 스크래핑. Open API가 있는 두 출처부터 시작한다. -- 사람 승인 없는 승격, 웹 요청 중의 외부 조회, 공고 원문 파일의 저장소 보관. -- 정기 실행 위치(배포 플랫폼 cron, GitHub Actions 예약 실행 등)와 결과 전달 방식. #15에서 배포 환경과 함께 정한다. 그 전까지는 운영자가 두 명령을 직접 실행한다. +기본 원칙은 세 가지다. + +- 공식 API의 구조화 값으로 확인되는 조건은 자동으로 판정하고, 그 결과에 따라 현재 후보로 올리고 내리는 일을 사람 없이 한다. +- 구조화 값이 없는 조건은 추측하지 않고 "신청 전 확인"으로 표시한다. +- 확인이 끊기면 자동으로 내린다. + +### 1. 동기화 명령: `funding:sync` + +운영자 명령 하나가 다음을 차례로 한다. + +1. K-Startup과 기업마당에서 공고를 받는다. +2. 자동 규칙으로 카탈로그 상품을 만든다. +3. 사람이 관리하는 `app/catalog/funding/catalog.json`의 상품과 합친다. +4. `funding:validate`와 같은 검사를 거쳐 새 릴리스로 적재한다(`PENDING → ACTIVE`). + +세부 규칙은 다음과 같다. + +- **실행 주기:** 하루 한 번 실행한다(K-Startup의 갱신 주기). +- **실행 위치:** 정기 실행 위치(배포 플랫폼 cron, GitHub Actions 예약 실행 등)는 #15에서 배포 환경과 함께 정한다. 그 전까지는 운영자가 직접 실행한다. +- **상품 식별과 재사용:** 자동 상품은 출처와 공고 ID로 식별한다. 내용이 같으면 기존 불변 상품 버전을 재사용하므로 상품 버전이 날마다 늘지 않는다. +- **같은 공고가 두 번 있을 때:** 사람이 기록한 상품이 같은 출처 공고를 가리키면 사람 기록이 우선한다. +- **근거와 검수자 기록:** + - 자동 상품의 근거는 `retrievalMethod: OFFICIAL_API`이고, 출처 API·공고 ID·조회 시각·응답 checksum을 남긴다. + - `reviewer`에는 사람 이름 대신 자동 규칙 버전(예: `AUTO:funding-auto-v1`)을 적는다. 규칙을 바꾸면 버전을 올린다. + +### 2. 자동 승격 조건 + +자동 상품은 다음을 모두 만족할 때만 현재 후보가 된다. + +1. **지원 유형:** 구조화된 지원 분야에서 지원금·공간·프로그램(`GRANT`·`SPACE`·`PROGRAM`)으로 정해진다. 대출·보증은 자동 승격하지 않는다(4절). +2. **신청기간:** 시작일과 종료일이 모두 날짜로 확인되고, 판정 기준일이 그 사이에 있으며, 출처의 모집 진행 표시와 모순이 없다. "상시", "예산 소진 시까지" 같은 문구는 날짜가 아니므로 승격하지 않는다. +3. **사업 단계와 지역:** 구조화 값으로 PASS다. 지역은 서울 또는 전국만 해당한다. 자치구 한정 공고는 API로 구분할 수 없으므로, 서울 공고는 지금처럼 "서울 대상, 자치구 추가 확인"으로 표시한다. +4. **제외 신호 없음:** 지원대상·사업개요 자유 서술에 제외 신호가 없다. 예를 들어 음식점·외식·유흥 제외, 제조·기술 등 특정 업종 한정 같은 키워드다. 키워드는 내리는 데만 쓰고, 올리는 근거로는 쓰지 않는다. +5. **차단 목록:** 사람이 관리하는 차단 목록에 없다. +6. **최신성:** 마지막으로 성공한 동기화가 판정 기준일로부터 3일 이내다. + +### 3. 업종·용도 규칙 변경 + +- 업종·용도가 `UNKNOWN`이어도 현재 후보를 막지 않는다. 대신 "신청 전 확인: 업종 제한과 자금 용도는 공고문에서 확인하세요"를 후보에 붙인다. +- 이 규칙은 자동 상품과 사람이 기록한 상품에 똑같이 적용한다. +- `FAIL`(명시된 제외)은 지금처럼 막는다. 사업 단계·지역·신청기간이 `UNKNOWN`이면 지금처럼 막는다. + +### 4. 대출·보증과 상환 계산 + +- 대출·보증은 금리·한도가 판단의 핵심인데, 구조화 출처에 없다. 그래서 자동 승격하지 않고 추가 확인으로 둔다. +- 사람이 금융 조건 근거를 기록하면 기존 규칙대로 현재 후보가 되고, 조건이 확정되면 상환 계산도 할 수 있다. +- "상환 계산 가능"으로의 자동 승격은 이 ADR의 범위가 아니다. + +### 5. 자동 강등 + +다음은 사람 없이 적용한다. + +- **신청 종료:** 신청 종료일이 판정 기준일보다 앞서면, 관측 상태가 OPEN이어도 CLOSED로 판정한다. 사람이 기록한 상품에도 적용한다. +- **출처 변경:** 출처가 모집 종료를 표시하면 CLOSED로 바꾼다. 공고가 사라지거나 내용이 바뀌면 다음 동기화에서 다시 판정한다. +- **제외 신호:** 제외 신호가 새로 보이면 추가 확인으로 내린다. +- **최신성 초과:** 동기화가 3일 넘게 성공하지 않으면 자동 상품은 현재 후보에서 빠진다. 파이프라인이 멈추면 후보가 저절로 내려가는 안전장치다. + +### 6. 실패 처리 + +- **새 릴리스를 만들지 않는 경우:** 출처 하나라도 장애·인증 오류이거나, 빈 응답이거나, 받은 공고 수가 직전 성공 대비 절반 미만으로 줄면 새 릴리스를 만들지 않는다. 이때 기존 ACTIVE를 유지한다(§3의 9번). +- **"공고 없음"으로 해석하지 않는다:** 실패를 "공고 없음"으로 해석해 상품을 지우지 않는다. +- **실패가 이어질 때:** 최신성 규칙(5절)에 따라 자동 상품은 3일 뒤 현재 후보에서 빠진다. + +### 7. 사람의 역할과 표시 + +- **사람이 하는 일:** + - 자동 규칙과 차단 목록을 관리한다. 차단 목록에는 출처·공고 ID·사유를 적고 `catalog.json`에 둔다. + - 대출·보증의 금융 조건을 기록한다. + - 오판 신고를 처리한다. +- **사람이 기록한 상품:** 지금처럼 `nextReviewAt` 규칙을 따른다. +- **자동 승격 끄기:** 스위치를 하나 둔다. 끄면 자동 상품은 추가 확인으로만 표시되고, 강등은 계속 동작한다. +- **화면 표시:** 자동 상품에는 "자동 확인" 표시, 마지막 확인 시각, 출처 API, 원 공고 링크, 신청 전 확인 항목을 함께 보여 준다. 후보는 검토 시작점이지 선정·승인이 아니라는 안내를 유지한다. + +### 8. 범위 밖 + +- 공고문 본문·첨부를 AI로 해석해 조건을 확정하는 일. `docs/PRODUCT.md`의 "자동 공고 해석" 제외는 "공고문 본문의 자동 해석"으로 좁혀서 유지한다. +- 기관 웹사이트 스크래핑. +- 웹 요청 중의 외부 조회, 공고 원문 파일의 저장소 보관. + +### 9. Accepted 뒤 함께 고칠 문서 + +- AGENTS.md §3의 8번: "미검수"를 "사람 검수 또는 ADR 0006의 자동 확인을 거치지 않은"으로 고친다. 업종·용도 `UNKNOWN`의 처리를 이 ADR에 연결한다. +- `docs/PRODUCT.md`: 자금 후보 행과 제외 범위를 고친다. +- `docs/domains/funding.md`: 동기화·승격·강등 규칙을 적는다. +- `docs/DEVELOPMENT.md`: 기업마당 키 `BIZINFO_API_KEY`와 `funding:sync`를 적는다. ## Alternatives considered -- **사람 검수만 유지(#5의 선택지 1):** 규칙 변경은 없다. 하지만 병목이 사람의 시간이라 상품 수를 늘릴 수 없고, 5개도 기한 안에 유지하지 못하고 있다. -- **검수 관리 화면 구현(#5의 선택지 3):** 관리자 역할이라는 새 권한 모델이 필요하다. 지금 인증에는 역할이 없다. 카탈로그 PR이 이미 변경 내용·검수자·시점을 남기므로, 화면 없이도 검수 기록이 된다. -- **공식 API 필드만으로 사람 없이 승격:** 지원대상은 자유 서술이고, 지역 조건을 서버가 적용하지 않는 사례도 있어 잘못 판정할 위험이 있다. 게다가 업종·용도는 API에 없어서 어차피 `UNKNOWN`으로 남으므로, 현재 후보가 되지 않는다. -- **AI로 공고문을 해석해 금리·기간을 채움:** 한 번 잘못 읽으면 곧바로 틀린 상환액이 된다(§3의 1·8번). 근거 없는 정밀도를 만들지 않는다는 원칙(PRODUCT.md 원칙 7)과도 맞지 않는다. -- **출시에서 숨기거나 베타로 표시(#5의 선택지 4):** 이 ADR은 기능을 줄이기보다 운영 비용을 낮추는 쪽을 제안한다. 첫 검수가 끝나기 전에 출시하게 되면 그때 화면 처리를 #15 출시 점검에서 정한다. +- **사람 검수만 유지(#5의 선택지 1):** 규칙은 바뀌지 않는다. 하지만 병목이 사람의 시간이라 상품 수를 늘릴 수 없다. +- **자동 탐색 + 사람 승인으로 승격(이 ADR의 첫 초안):** 사람의 일은 줄지만, 병목이 그대로 남는다. 검수 책임자가 없으면 현재 후보가 계속 0건이다. +- **검수 관리 화면 구현(#5의 선택지 3):** 관리자 역할이라는 새 권한 모델이 필요하다. 사람 검수를 전제로 하므로 병목이 남는다. +- **업종·용도 `UNKNOWN`은 계속 막기:** 두 API에 업종·용도가 없으므로 자동 승격이 되어도 현재 후보는 0건이다. 결과적으로 선택지 1과 같다. +- **AI로 공고문을 해석해 업종·용도·금리를 채움:** 한 번 잘못 읽으면 틀린 자격 판정이나 틀린 상환액이 된다(§3의 1·8번, PRODUCT.md 원칙 7). 필요해지면 별도 ADR로 다룬다. +- **출시에서 숨기거나 베타로 표시(#5의 선택지 4):** 이 ADR은 기능을 줄이기보다 운영을 자동화하는 쪽을 제안한다. ## Consequences - **장점:** - - 검수자의 일이 "찾고 옮겨 적기"에서 "원문과 대조하고 승인하기"로 줄어든다. - - 마감·변경·공고 삭제가 사람 없이 판정에 반영되어, 종료된 상품을 현재 후보로 보여 줄 위험이 줄어든다. + - 사람 없이 현재 후보가 생긴다. + - 마감·변경·삭제가 날마다 반영된다. + - 파이프라인이 멈추면 후보가 저절로 내려간다. - **비용·위험:** - - 외부 API 두 개에 의존한다. API가 멈추면 탐색이 멈추고, 재확인은 `FAILED`로 남아 기존 기한 규칙으로 돌아간다. - - 인증키가 하나(기업마당) 늘어난다. - - 사람 검수가 없으면 현재 후보는 여전히 0건이다. 자동화가 검수 책임자를 대신하지 않는다. + - **자동 오판:** 첨부 공고문에만 음식점 제외가 적혀 있으면, 그 공고가 현재 후보로 보일 수 있다. 다음 네 가지로 줄인다. + - 업종 "신청 전 확인" 표시 + - 제외 신호 강등 + - 차단 목록 + - 표본 점검과 자동 승격 스위치(Validation) + - **현재 후보의 의미 변화:** 업종·용도를 확인하지 않은 상품도 현재 후보가 된다. 화면이 이 차이를 분명히 보여 주어야 한다. + - **외부 API 의존:** 외부 API 두 개에 의존하고, 인증키가 하나(기업마당) 늘어난다. - **migration·호환성:** - - 카탈로그 상품에 출처 공고 식별자(출처와 공고 ID) 필드를 추가하고 `schemaVersion`을 올린다. 식별자가 없는 기존 상품은 재확인 대상에서 빠진다. - - 재확인 기록 테이블을 새 migration으로 추가한다. 기존 릴리스·상품 버전 테이블은 바꾸지 않는다. + - 카탈로그 상품에 출처 공고 식별자를 추가하고, 카탈로그에 차단 목록을 추가하고, `schemaVersion`을 올린다. + - 릴리스·상품 버전 테이블은 그대로 쓴다. 최신성은 릴리스 기준일로 판단하므로 새 테이블이 필요 없다. + - 릴리스는 하루 하나씩 늘어난다(연 약 365개). - **운영·보안·데이터 영향:** - 키는 `app/.env.local`과 운영 비밀값에만 둔다. - 웹 요청은 DB만 읽는다. - - 재확인 기록은 판정 근거이므로 지우지 않는다. + - 판정 규칙이 바뀌므로, 같은 상품이라도 규칙 변경 전후의 후보 상태가 다를 수 있다. + - 자금 후보는 저장하지 않는 조회 결과라, 과거 계산 결과는 바뀌지 않는다. ## Validation -- 신청 종료일이 판정 기준일보다 앞서면 OPEN 상품도 CLOSED로 판정하는 단위 테스트. -- `funding:discover` 초안이 `funding:validate`를 통과하고, `reviewer`가 `UNASSIGNED`이며, 현재 후보가 0건인지 확인하는 테스트. -- 재확인 기록 테스트: - - 최근 기록이 `CHANGED`·`REMOVED`인 상품은 현재 후보가 되지 않는다. - - `FAILED`만 있거나 기록이 없으면 판정이 바뀌지 않는다. - - API 오류와 빈 응답은 `REMOVED`로 기록되지 않는다. -- 첫 실제 실행 뒤 탐색 공고 수, 검수 초안 수, 검수 후 현재 후보 수를 이 ADR에 기록한다. -- **중단 조건:** 두 번 연속 검수 주기 동안 탐색 초안 중 검수를 통과한 상품이 0건이면 출처와 필터를 다시 검토한다. 그래도 없으면 #5의 선택지 4(출시에서 숨김)로 돌아간다. +- **단위 테스트:** + - 신청 종료일이 지나면 OPEN 상품도 CLOSED다. + - 업종·용도 `UNKNOWN`은 현재 후보를 막지 않고 신청 전 확인으로 표시된다. `FAIL`은 막는다. + - 자동 승격 조건 여섯 개를 각각 하나씩 어기면 현재 후보가 되지 않는다. + - 대출·보증 자동 상품은 현재 후보가 되지 않는다. + - 동기화가 3일을 넘기면 자동 상품은 현재 후보에서 빠진다. +- **동기화 테스트:** + - 출처 장애·빈 응답·급감 때 새 릴리스가 만들어지지 않고 기존 ACTIVE가 유지된다. + - 같은 공고의 사람 기록이 자동 상품보다 우선한다. + - 차단 목록의 공고는 현재 후보가 되지 않는다. +- **첫 실행 기록:** 첫 실제 실행 뒤 받은 공고 수, 자동 상품 수, 현재 후보 수를 이 ADR에 기록한다. +- **표본 점검:** 첫 4주 동안 매주 자동 현재 후보를 최대 10건씩 사람이 원문과 대조해 오판(실제로는 서울 예비 음식점·카페 창업자가 신청할 수 없는 공고)을 기록한다. +- **중단 조건:** 어느 한 주라도 오판이 표본의 20%를 넘으면 자동 승격 스위치를 끈다. 규칙을 고쳐 다시 점검할 때까지 강등만 유지한다. From 57f6d8829cc79018f897bb93f1a59ec7d4b5aee4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:40:51 +0000 Subject: [PATCH 3/9] docs(funding): split ADR 0006 into support programs and policy loans Support programs (grant, space, program) are automated from the K-Startup API. Policy loans and guarantees have no official API, so they are automated by crawling the SEMAS and Seoul Shinbo pages under explicit rules: robots.txt and terms checked per source, public pages only, once a day, no evasion when blocked. Repayment calculation is auto-promoted only when the page states a single fixed rate, term, grace and method; variable-rate loans stay non-calculable. Each track has its own promotion switch, and a failing source keeps its last products until they go stale after three days. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- .../0006-funding-discovery-automation.md | 271 +++++++++++------- 1 file changed, 168 insertions(+), 103 deletions(-) diff --git a/docs/decisions/0006-funding-discovery-automation.md b/docs/decisions/0006-funding-discovery-automation.md index 8364f6d..a7fdcdc 100644 --- a/docs/decisions/0006-funding-discovery-automation.md +++ b/docs/decisions/0006-funding-discovery-automation.md @@ -1,8 +1,8 @@ -# ADR 0006: 자금 후보의 승격·강등을 공식 API로 자동화 +# ADR 0006: 지원사업과 정책자금 후보를 자동으로 올리고 내린다 - Status: Proposed - Date: 2026-09-25 -- Owners: 미정 (자동 규칙과 차단 목록 책임자. Accepted 전에 지정) +- Owners: 미정 (자동 규칙·수집 출처·차단 목록 책임자. Accepted 전에 지정) - Related issue/PR: #5, #13, #15 - Supersedes: 없음 @@ -11,32 +11,47 @@ 자금 후보 기능은 동작하지만 보여 줄 후보가 없다. 2026-09-25 기준 카탈로그 `2026-09-23.1`의 상태는 다음과 같다. - **현재 후보 0건:** 상품 5개 모두 검수자가 `UNASSIGNED`라서, 어떤 계획에서도 현재 후보가 되지 않는다(AGENTS.md §3의 8번). -- **상환 계산 가능 0건:** 대출 3개 모두 금리가 확정되지 않아 계획의 대출 가정으로 적용할 수 없다. -- **곧 기한 경과:** 서울시 중소기업육성자금 3개의 재검수일이 2026-10-20이다. 강북창업지원센터는 이미 기한이 지났고, 예비창업패키지는 접수가 끝났다. +- **상환 계산 가능 0건:** 대출 3개 모두 금리가 확정되지 않았다. +- **곧 기한 경과:** 서울시 중소기업육성자금 3개의 재검수일이 2026-10-20이다. 나머지 둘은 이미 기한이 지났거나 접수가 끝났다. - **병목은 사람의 시간:** 운영자가 기관 사이트를 돌며 공고를 찾고, JSON에 옮겨 적고, 기한마다 다시 확인한다. 이 방식으로는 5개도 기한 안에 유지하지 못하고 있다. -공고를 구조화된 형태로 주는 공식 Open API가 두 개 있고, 2026-09-25에 두 API 모두 활용신청했다. 아래 항목은 공개된 이용 사례에서 확인한 것이며, 실제 응답은 구현 전에 다시 확인한다. +자금 상품은 성격이 다른 두 갈래이고, 자동으로 가져올 수 있는 출처도 다르다. -- **K-Startup 공고 정보**(창업진흥원, `apis.data.go.kr/B552735/kisedKstartupService01`, `getAnnouncementInformation01`) - - 주는 값: 공고명, 접수 시작·종료일, 지원 지역, 신청 대상, 창업 업력, 지원 분야, 모집 진행 여부, 상세 페이지 URL. 하루 한 번 갱신된다. - - 키: 공공데이터포털 인증키(지금 `SEMAS_SERVICE_KEY`)를 쓴다. - - 한계: 지역 조건을 서버가 적용하지 않고 다른 지역 공고가 섞여 오는 사례가 보고됐다. 요청 조건 대신 응답 항목의 지역 값으로 거른다. +### 지원사업 (지원금·공간·프로그램): 공식 Open API가 있다 + +2026-09-25에 두 API 모두 활용신청했다. 아래 항목은 공개된 이용 사례에서 확인한 것이며, 실제 응답은 구현 전에 다시 확인한다. + +- **K-Startup 공고 정보**(창업진흥원, `apis.data.go.kr/B552735/kisedKstartupService01`) + - 주는 값: 공고명, 접수 시작·종료일(`pbanc_rcpt_bgng_dt`·`pbanc_rcpt_end_dt`), 모집 진행 여부(`rcrt_prgs_yn`), 지원 지역(`supt_regin`), 창업 업력(`biz_enyy`: 예비창업자, 1년 미만 …), 대상 연령(`biz_trgt_age`), 지원 분야(`supt_biz_clsfc`), 신청 대상·제외 대상 서술(`aply_trgt_ctnt`·`aply_excl_trgt_ctnt`), 상세 URL. + - 갱신: 하루 한 번 갱신된다. + - 한계: 지역 필터를 서버가 적용하지 않는 사례가 보고됐다. - **기업마당 지원사업정보**(중소벤처기업부, `www.bizinfo.go.kr/uss/rss/bizinfoApi.do`) - - 주는 값: 공고 ID, 공고명, 소관·수행기관, 신청기간(`YYYYMMDD ~ YYYYMMDD` 문자열), 지원대상(자유 서술), 사업개요, 공고 URL, 분야·지역 해시태그. 중앙부처와 지자체 공고를 함께 모은다. - - 키: 기업마당에서 발급하는 별도 인증키가 필요하다. + - 주는 값: 공고 ID·공고명·기관, 신청기간 문자열, 지원분야 대분류, 해시태그, 공고 URL. + - 한계: 지원대상(`trgetNm`)이 "중소기업" 같은 단순 태그라서 사업 단계를 구조적으로 알 수 없다. 대신 중앙부처와 지자체 공고를 함께 모으므로 새 공고를 찾는 데 유용하다. -두 API 모두 업종 제한·자금 용도·금리·한도·상환기간·거치를 구조화된 값으로 주지 않는다. 이 조건은 공고문 본문과 첨부(PDF·HWP)에만 있다. +### 정책자금 (대출·보증): 공식 API가 없다 -지금 판정 규칙(`eligibility.ts`)으로는 자동화된 상품이 현재 후보가 될 수 없다. 막는 곳은 네 군데다. +예비 음식점 창업자에게 핵심인 정책자금은 다음과 같다. 두 기관 모두 공고·접수 상태·조건을 웹 페이지로만 공개한다. -1. **검수자:** 사람 검수자가 있어야 현재 후보가 된다. -2. **업종·용도:** 둘 중 하나라도 `UNKNOWN`이면 추가 확인으로 분류된다. 그런데 API에는 두 값이 없다. -3. **대출·보증:** 금융 조건의 1차 근거가 있어야 현재 후보가 된다. -4. **ACTIVE 릴리스:** ACTIVE 릴리스는 하나뿐이다(`read.ts`). 따라서 자동으로 모은 상품과 사람이 기록한 상품을 한 릴리스로 합쳐야 한다. +- 소상공인시장진흥공단 정책자금: 소상공인24 `www.sbiz24.kr`, 신청 사이트 `ols.semas.or.kr` +- 서울시 중소기업육성자금: 서울신용보증재단 `www.seoulshinbo.co.kr`, 서울시 `news.seoul.go.kr` + +관련 공공데이터는 다음 셋이 있지만, 어느 것도 이 상품들의 공고·조건을 주지 않는다. + +- **서울시 은행별 대출금리**(OA-21098): 은행별 실행 금리 통계다. +- **중진공 정책자금 현황**: 파일로 제공되는 연간 집행 실적이다. +- **서민금융상품기본정보**: 미소금융·햇살론 같은 서민금융 상품만 담는다. -현재 판정에는 빈틈도 하나 있다. `evaluateProduct`는 신청기간을 보지 않는다. 그래서 OPEN으로 기록한 상품은 신청 종료일이 지나도 재검수일까지 현재 후보로 남는다. 지금은 OPEN 상품이 없어 드러나지 않았다. +금리는 대부분 "정책자금 기준금리 + 가산"이나 "은행 금리 − 이차보전"처럼 기준금리에 따라 바뀌는 변동금리다. -지켜야 할 제약은 다음과 같다. +### 지금 판정 규칙이 자동화를 막는 곳 + +1. **검수자:** 사람 검수자가 있어야 현재 후보가 된다. +2. **업종·용도:** 둘 중 하나라도 `UNKNOWN`이면 추가 확인으로 분류된다. 그런데 두 API에는 두 값이 없다. +3. **ACTIVE 릴리스:** ACTIVE 릴리스는 하나뿐이다(`read.ts`). 자동 상품과 사람이 기록한 상품을 한 릴리스로 합쳐야 한다. +4. **신청 종료일:** `evaluateProduct`는 신청기간을 보지 않는다. 그래서 OPEN으로 기록한 상품은 종료일이 지나도 재검수일까지 현재 후보로 남는다. 지금은 OPEN 상품이 없어 드러나지 않았다. + +### 지켜야 할 제약 - 웹 요청 중에는 외부 데이터를 가져오지 않는다(AGENTS.md §2). - 적재는 `PENDING → ACTIVE` 검증을 거치고(§3의 9번), provenance를 보존한다(§3의 10번). @@ -47,135 +62,185 @@ 기본 원칙은 세 가지다. -- 공식 API의 구조화 값으로 확인되는 조건은 자동으로 판정하고, 그 결과에 따라 현재 후보로 올리고 내리는 일을 사람 없이 한다. -- 구조화 값이 없는 조건은 추측하지 않고 "신청 전 확인"으로 표시한다. +- 공식 출처에서 기계적으로 확인되는 조건은 자동으로 판정하고, 그 결과에 따라 현재 후보로 올리고 내리는 일을 사람 없이 한다. +- 확인되지 않은 조건은 추측하지 않고 "신청 전 확인"으로 표시한다. - 확인이 끊기면 자동으로 내린다. -### 1. 동기화 명령: `funding:sync` +### 1. 두 갈래 + +- **지원사업:** 지원 유형이 `GRANT`·`SPACE`·`PROGRAM`인 상품이다. 공식 Open API(K-Startup)로 자동화한다. +- **정책자금:** 지원 유형이 `LOAN`·`GUARANTEE`인 상품이다. 기관 웹 페이지 크롤링으로 자동화한다. +- **적재와 표시:** 두 갈래는 한 카탈로그 릴리스에 함께 적재한다. 후보 API 응답과 화면은 두 갈래를 나눠 보여 준다. 갈래는 기존 `supportType`으로 정하므로 새 enum을 만들지 않는다. + +### 2. 동기화 명령: `funding:sync` 운영자 명령 하나가 다음을 차례로 한다. -1. K-Startup과 기업마당에서 공고를 받는다. -2. 자동 규칙으로 카탈로그 상품을 만든다. +1. 출처별로 공고를 받는다. +2. 출처별 규칙으로 카탈로그 상품을 만든다. 3. 사람이 관리하는 `app/catalog/funding/catalog.json`의 상품과 합친다. 4. `funding:validate`와 같은 검사를 거쳐 새 릴리스로 적재한다(`PENDING → ACTIVE`). 세부 규칙은 다음과 같다. -- **실행 주기:** 하루 한 번 실행한다(K-Startup의 갱신 주기). -- **실행 위치:** 정기 실행 위치(배포 플랫폼 cron, GitHub Actions 예약 실행 등)는 #15에서 배포 환경과 함께 정한다. 그 전까지는 운영자가 직접 실행한다. -- **상품 식별과 재사용:** 자동 상품은 출처와 공고 ID로 식별한다. 내용이 같으면 기존 불변 상품 버전을 재사용하므로 상품 버전이 날마다 늘지 않는다. -- **같은 공고가 두 번 있을 때:** 사람이 기록한 상품이 같은 출처 공고를 가리키면 사람 기록이 우선한다. +- **실행 주기와 위치:** 하루 한 번 실행한다. 정기 실행 위치는 #15에서 배포 환경과 함께 정한다. 그 전까지는 운영자가 직접 실행한다. +- **상품 식별과 재사용:** 자동 상품은 출처와 공고 식별자로 식별한다. 내용이 같으면 기존 불변 상품 버전을 재사용한다. +- **같은 공고가 두 번 있을 때:** 사람이 기록한 상품이 같은 공고를 가리키면 사람 기록이 우선한다. - **근거와 검수자 기록:** - - 자동 상품의 근거는 `retrievalMethod: OFFICIAL_API`이고, 출처 API·공고 ID·조회 시각·응답 checksum을 남긴다. - - `reviewer`에는 사람 이름 대신 자동 규칙 버전(예: `AUTO:funding-auto-v1`)을 적는다. 규칙을 바꾸면 버전을 올린다. - -### 2. 자동 승격 조건 - -자동 상품은 다음을 모두 만족할 때만 현재 후보가 된다. - -1. **지원 유형:** 구조화된 지원 분야에서 지원금·공간·프로그램(`GRANT`·`SPACE`·`PROGRAM`)으로 정해진다. 대출·보증은 자동 승격하지 않는다(4절). -2. **신청기간:** 시작일과 종료일이 모두 날짜로 확인되고, 판정 기준일이 그 사이에 있으며, 출처의 모집 진행 표시와 모순이 없다. "상시", "예산 소진 시까지" 같은 문구는 날짜가 아니므로 승격하지 않는다. -3. **사업 단계와 지역:** 구조화 값으로 PASS다. 지역은 서울 또는 전국만 해당한다. 자치구 한정 공고는 API로 구분할 수 없으므로, 서울 공고는 지금처럼 "서울 대상, 자치구 추가 확인"으로 표시한다. -4. **제외 신호 없음:** 지원대상·사업개요 자유 서술에 제외 신호가 없다. 예를 들어 음식점·외식·유흥 제외, 제조·기술 등 특정 업종 한정 같은 키워드다. 키워드는 내리는 데만 쓰고, 올리는 근거로는 쓰지 않는다. -5. **차단 목록:** 사람이 관리하는 차단 목록에 없다. -6. **최신성:** 마지막으로 성공한 동기화가 판정 기준일로부터 3일 이내다. - -### 3. 업종·용도 규칙 변경 - -- 업종·용도가 `UNKNOWN`이어도 현재 후보를 막지 않는다. 대신 "신청 전 확인: 업종 제한과 자금 용도는 공고문에서 확인하세요"를 후보에 붙인다. + - 근거에는 출처 URL·조회 시각·응답(페이지) checksum을 남긴다. `retrievalMethod`는 API면 `OFFICIAL_API`, 크롤링이면 `OFFICIAL_WEB_PAGE`다. + - `reviewer`에는 자동 규칙 버전(예: `AUTO:funding-auto-v1`)을 적는다. 규칙을 바꾸면 버전을 올린다. +- **출처별 동기화 결과:** 릴리스에 출처마다 결과·마지막 성공 시각·checksum을 JSON으로 저장한다. 열 추가가 필요하면 새 migration으로 한다. +- **실패한 출처만 따로 처리한다:** + - 한 출처가 실패하면, 그 출처의 상품은 직전 ACTIVE 릴리스의 것을 그대로 가져온다. 마지막 성공 시각도 그대로 둔다. 다른 출처는 정상 갱신한다. + - 다음 경우는 실패로 본다: 장애·인증 오류, 빈 응답, 받은 공고 수가 직전 성공 대비 절반 미만으로 줄어든 경우, 크롤링 파서가 필수 항목을 찾지 못한 경우. + - 실패를 "공고 없음"으로 해석해 상품을 지우지 않는다. +- **기업마당의 역할:** 두 갈래 모두에서 새 공고를 찾는 데 쓴다. 기업마당만으로 확인된 상품은 사업 단계를 알 수 없으므로 추가 확인에 머문다. 사람이나 다른 출처가 확인하면 승격될 수 있다. + +### 3. 정책자금 크롤링 규칙 + +- **출처를 켜기 전:** + - robots.txt가 해당 경로를 허용하는지, 이용약관이 자동 수집을 금지하지 않는지 확인한다. + - 확인 날짜와 결과를 `docs/domains/funding.md`의 출처 목록에 기록한다. + - 금지하면 그 출처는 켜지 않고 사람 관리로 둔다. +- **수집 방식:** + - 로그인 없이 공개된 페이지만 읽는다. + - 출처당 하루 한 번, 요청 사이 1초 이상 간격을 둔다. + - User-Agent에 서비스 이름과 연락처를 밝힌다. +- **차단되면 멈춘다:** + - 403·429·CAPTCHA를 만나거나 robots.txt가 바뀌면 그 출처를 실패로 기록하고 멈춘다. + - 차단을 우회하지 않는다(IP 교체, 헤더 위장, CAPTCHA 풀이 금지). +- **읽는 범위:** + - 출처마다 전용 파서를 두고, 공고 HTML 본문과 표에서 값을 규칙으로 뽑는다. + - 첨부(PDF·HWP)는 링크와 checksum만 남기고 v1에서는 해석하지 않는다. + - AI로 본문을 해석하지 않는다. +- **보관:** 뽑은 값과 URL·checksum만 저장한다. 페이지 원문과 첨부 파일은 저장소에 넣지 않는다. + +### 4. 자동 승격 조건 + +**두 갈래 공통.** 자동 상품은 다음을 모두 만족할 때만 현재 후보가 된다. + +1. **신청기간:** 시작일과 종료일이 날짜로 확인되고, 판정 기준일이 그 사이에 있으며, 출처의 접수 상태 표시와 모순이 없다. "상시", "예산 소진 시까지"처럼 날짜가 없으면 출처의 접수 상태가 "접수 중"으로 확인될 때만 인정한다. +2. **사업 단계와 지역:** 구조화 값이나 출처별 파서의 명시적 규칙으로 PASS다. 지역은 서울 또는 전국이다. +3. **제외 신호 없음:** 제외 대상·제외 업종에 음식점·외식 등 이 제품의 업종이 들어 있지 않다. 이 제외 신호는 내리는 데만 쓴다. +4. **차단 목록:** 사람이 관리하는 차단 목록(출처·식별자·사유, `catalog.json`)에 없다. +5. **최신성:** 그 출처의 마지막 성공 동기화가 판정 기준일로부터 3일 이내다. + +**정책자금 추가 조건.** + +- 금리·한도·상환 조건을 설명하는 문장이 공고 본문에서 확인되어야 한다(`FINANCIAL_CONDITION` 근거). +- 이 조건은 현재 후보가 되기 위한 것이다. 상환 계산 가능 여부는 5절이 따로 정한다. + +**업종·용도 규칙 변경.** + +- 업종·용도가 `UNKNOWN`이어도 현재 후보를 막지 않는다. 대신 "신청 전 확인"으로 표시한다. +- 연령 조건이 있는 공고도 같은 방식으로 "신청 전 확인: 연령"을 붙인다. 계획의 자금 조건에는 나이가 없기 때문이다. - 이 규칙은 자동 상품과 사람이 기록한 상품에 똑같이 적용한다. -- `FAIL`(명시된 제외)은 지금처럼 막는다. 사업 단계·지역·신청기간이 `UNKNOWN`이면 지금처럼 막는다. +- `FAIL`(명시된 제외)은 지금처럼 막는다. -### 4. 대출·보증과 상환 계산 +### 5. 상환 계산 가능으로의 자동 승격 (정책자금) -- 대출·보증은 금리·한도가 판단의 핵심인데, 구조화 출처에 없다. 그래서 자동 승격하지 않고 추가 확인으로 둔다. -- 사람이 금융 조건 근거를 기록하면 기존 규칙대로 현재 후보가 되고, 조건이 확정되면 상환 계산도 할 수 있다. -- "상환 계산 가능"으로의 자동 승격은 이 ADR의 범위가 아니다. +- **조건:** 출처별 파서가 공고 본문에서 다음을 모두 명시적인 숫자로 뽑았을 때만 상환 계산 가능으로 올린다. + - 공개 한도 + - 단일 고정 연 금리 + - 전체 상환개월과 거치개월 + - 원리금균등 또는 원금균등 상환방식 +- **검증:** 뽑은 값은 기존 스키마의 범위 검사와 "거치 < 전체 기간" 검사를 통과해야 한다. +- **올리지 않는 경우:** 범위 금리, 변동금리(기준금리 연동), 은행별로 다른 금리는 확정 숫자가 아니다. 지금처럼 `null`로 두고 "상환 계산 대상 아님 + 사유"를 표시한다. 따라서 소진공·서울시 정책자금 대부분은 자동화 뒤에도 상환 계산 대상이 아니다. +- **범위 밖:** 변동금리 상품의 상환 계산은 계산 계약의 변경이다. 필요하면 기준금리 관측값을 쓰는 방식을 별도 ADR로 다룬다. +- **사용자 적용 절차:** 상품 조건을 계획에 적용하는 일은 지금처럼 사용자가 명시적으로 실행할 때만 일어난다(§3의 3번). -### 5. 자동 강등 +### 6. 자동 강등 다음은 사람 없이 적용한다. - **신청 종료:** 신청 종료일이 판정 기준일보다 앞서면, 관측 상태가 OPEN이어도 CLOSED로 판정한다. 사람이 기록한 상품에도 적용한다. -- **출처 변경:** 출처가 모집 종료를 표시하면 CLOSED로 바꾼다. 공고가 사라지거나 내용이 바뀌면 다음 동기화에서 다시 판정한다. +- **출처 변경:** 출처가 접수 마감을 표시하면 CLOSED로 바꾼다. 예산 소진으로 조기 마감한 경우도 포함한다. 공고가 사라지거나 내용이 바뀌면 다음 동기화에서 다시 판정한다. - **제외 신호:** 제외 신호가 새로 보이면 추가 확인으로 내린다. -- **최신성 초과:** 동기화가 3일 넘게 성공하지 않으면 자동 상품은 현재 후보에서 빠진다. 파이프라인이 멈추면 후보가 저절로 내려가는 안전장치다. - -### 6. 실패 처리 - -- **새 릴리스를 만들지 않는 경우:** 출처 하나라도 장애·인증 오류이거나, 빈 응답이거나, 받은 공고 수가 직전 성공 대비 절반 미만으로 줄면 새 릴리스를 만들지 않는다. 이때 기존 ACTIVE를 유지한다(§3의 9번). -- **"공고 없음"으로 해석하지 않는다:** 실패를 "공고 없음"으로 해석해 상품을 지우지 않는다. -- **실패가 이어질 때:** 최신성 규칙(5절)에 따라 자동 상품은 3일 뒤 현재 후보에서 빠진다. +- **최신성 초과:** 출처의 마지막 성공이 3일을 넘으면 그 출처의 자동 상품은 현재 후보와 상환 계산 가능에서 빠진다. -### 7. 사람의 역할과 표시 +### 7. 사람의 역할과 스위치 - **사람이 하는 일:** - - 자동 규칙과 차단 목록을 관리한다. 차단 목록에는 출처·공고 ID·사유를 적고 `catalog.json`에 둔다. - - 대출·보증의 금융 조건을 기록한다. + - 자동 규칙, 수집 출처와 그 허용 확인, 차단 목록을 관리한다. - 오판 신고를 처리한다. - **사람이 기록한 상품:** 지금처럼 `nextReviewAt` 규칙을 따른다. -- **자동 승격 끄기:** 스위치를 하나 둔다. 끄면 자동 상품은 추가 확인으로만 표시되고, 강등은 계속 동작한다. -- **화면 표시:** 자동 상품에는 "자동 확인" 표시, 마지막 확인 시각, 출처 API, 원 공고 링크, 신청 전 확인 항목을 함께 보여 준다. 후보는 검토 시작점이지 선정·승인이 아니라는 안내를 유지한다. +- **스위치 세 개:** 켜고 끌 수 있는 스위치를 따로 둔다. + - 지원사업 자동 승격 + - 정책자금 자동 승격 + - 정책자금 상환 계산 자동 승격 + - 어느 것을 꺼도 강등은 계속 동작한다. +- **화면 표시:** 자동 상품에는 "자동 확인" 표시, 마지막 확인 시각, 출처, 원 공고 링크, 신청 전 확인 항목을 함께 보여 준다. 후보는 검토 시작점이지 선정·승인이 아니라는 안내를 유지한다. ### 8. 범위 밖 -- 공고문 본문·첨부를 AI로 해석해 조건을 확정하는 일. `docs/PRODUCT.md`의 "자동 공고 해석" 제외는 "공고문 본문의 자동 해석"으로 좁혀서 유지한다. -- 기관 웹사이트 스크래핑. -- 웹 요청 중의 외부 조회, 공고 원문 파일의 저장소 보관. +- 공고문 본문·첨부를 AI로 해석하는 일. PRODUCT.md의 "자동 공고 해석" 제외는 "AI 기반 공고 해석"으로 좁혀서 유지한다. +- 첨부(PDF·HWP) 해석, 로그인이 필요한 페이지, 차단 우회. +- 웹 요청 중의 외부 조회. -### 9. Accepted 뒤 함께 고칠 문서 +### 9. Accepted 뒤 함께 고칠 것 -- AGENTS.md §3의 8번: "미검수"를 "사람 검수 또는 ADR 0006의 자동 확인을 거치지 않은"으로 고친다. 업종·용도 `UNKNOWN`의 처리를 이 ADR에 연결한다. -- `docs/PRODUCT.md`: 자금 후보 행과 제외 범위를 고친다. -- `docs/domains/funding.md`: 동기화·승격·강등 규칙을 적는다. -- `docs/DEVELOPMENT.md`: 기업마당 키 `BIZINFO_API_KEY`와 `funding:sync`를 적는다. +- **AGENTS.md §3의 8번:** "미검수"를 "사람 검수 또는 ADR 0006의 자동 확인을 거치지 않은"으로 고친다. 업종·용도·연령 `UNKNOWN`의 처리를 이 ADR에 연결한다. +- **`docs/PRODUCT.md`:** 자금 후보 행을 지원사업·정책자금 두 갈래로 나누고, 제외 범위를 고친다. +- **`docs/domains/funding.md`:** 동기화·승격·강등 규칙과 수집 출처 목록(허용 확인 기록 포함)을 적는다. +- **`docs/DEVELOPMENT.md`:** 기업마당 키 `BIZINFO_API_KEY`와 `funding:sync`를 적는다. +- **공식 출처 호스트 목록:** `OFFICIAL_URL_HOST_SUFFIXES`에 `sbiz24.kr`을 더한다. +- **HTML 파서 의존성:** 추가한다면 `app/package.json` 단일 PR 규칙을 따른다. ## Alternatives considered -- **사람 검수만 유지(#5의 선택지 1):** 규칙은 바뀌지 않는다. 하지만 병목이 사람의 시간이라 상품 수를 늘릴 수 없다. -- **자동 탐색 + 사람 승인으로 승격(이 ADR의 첫 초안):** 사람의 일은 줄지만, 병목이 그대로 남는다. 검수 책임자가 없으면 현재 후보가 계속 0건이다. -- **검수 관리 화면 구현(#5의 선택지 3):** 관리자 역할이라는 새 권한 모델이 필요하다. 사람 검수를 전제로 하므로 병목이 남는다. -- **업종·용도 `UNKNOWN`은 계속 막기:** 두 API에 업종·용도가 없으므로 자동 승격이 되어도 현재 후보는 0건이다. 결과적으로 선택지 1과 같다. -- **AI로 공고문을 해석해 업종·용도·금리를 채움:** 한 번 잘못 읽으면 틀린 자격 판정이나 틀린 상환액이 된다(§3의 1·8번, PRODUCT.md 원칙 7). 필요해지면 별도 ADR로 다룬다. +- **사람 검수만 유지(#5의 선택지 1):** 병목이 사람의 시간이라 상품 수를 늘릴 수 없다. +- **자동 탐색 + 사람 승인(이 ADR의 첫 초안):** 병목이 그대로 남는다. +- **지원사업만 자동화하고 정책자금은 사람 관리(두 번째 초안):** 제품의 핵심인 정책자금이 계속 비어 있거나 낡는다. +- **정책자금 대신 서민금융 API 사용:** 대출 조건을 구조화해 주지만, 서민금융 상품이라 소진공·서울시 정책자금이 아니다. +- **AI로 공고문을 해석해 조건을 채움:** 잘못 읽으면 틀린 자격 판정이나 틀린 상환액이 된다(§3의 1·8번, PRODUCT.md 원칙 7). 필요해지면 별도 ADR로 다룬다. - **출시에서 숨기거나 베타로 표시(#5의 선택지 4):** 이 ADR은 기능을 줄이기보다 운영을 자동화하는 쪽을 제안한다. ## Consequences - **장점:** - - 사람 없이 현재 후보가 생긴다. - - 마감·변경·삭제가 날마다 반영된다. - - 파이프라인이 멈추면 후보가 저절로 내려간다. -- **비용·위험:** - - **자동 오판:** 첨부 공고문에만 음식점 제외가 적혀 있으면, 그 공고가 현재 후보로 보일 수 있다. 다음 네 가지로 줄인다. - - 업종 "신청 전 확인" 표시 - - 제외 신호 강등 - - 차단 목록 - - 표본 점검과 자동 승격 스위치(Validation) - - **현재 후보의 의미 변화:** 업종·용도를 확인하지 않은 상품도 현재 후보가 된다. 화면이 이 차이를 분명히 보여 주어야 한다. - - **외부 API 의존:** 외부 API 두 개에 의존하고, 인증키가 하나(기업마당) 늘어난다. + - 사람 없이 두 갈래 모두 현재 후보가 생긴다. + - 마감·조기 마감·변경이 날마다 반영된다. + - 출처가 멈추면 그 출처의 후보가 저절로 내려간다. +- **자동 오판:** 첨부에만 음식점 제외가 적힌 공고가 현재 후보로 보일 수 있다. 다음 네 가지로 줄인다. + - 신청 전 확인 표시 + - 제외 신호 강등 + - 차단 목록 + - 표본 점검과 스위치(Validation) +- **크롤링 위험:** + - 기관 사이트 구조가 바뀌면 파서가 깨진다. 이때 그 출처는 실패로 남고, 3일 뒤 그 출처의 후보가 빠진다. 파서 유지보수 비용이 계속 든다. + - 이용약관이 바뀌면 그 출처를 끈다. +- **현재 후보의 의미 변화:** 업종·용도·연령을 확인하지 않은 상품도 현재 후보가 된다. 화면이 이 차이를 분명히 보여 주어야 한다. +- **상환 계산의 한계:** 변동금리 상품이 대부분이라, 상환 계산 가능 상품은 자동화 뒤에도 적을 수 있다. - **migration·호환성:** - - 카탈로그 상품에 출처 공고 식별자를 추가하고, 카탈로그에 차단 목록을 추가하고, `schemaVersion`을 올린다. - - 릴리스·상품 버전 테이블은 그대로 쓴다. 최신성은 릴리스 기준일로 판단하므로 새 테이블이 필요 없다. + - 카탈로그 상품에 출처 식별자를 추가하고, 카탈로그에 차단 목록을 추가하고, `schemaVersion`을 올린다. + - 릴리스에 출처별 동기화 결과를 저장한다(필요하면 새 migration). - 릴리스는 하루 하나씩 늘어난다(연 약 365개). - **운영·보안·데이터 영향:** - 키는 `app/.env.local`과 운영 비밀값에만 둔다. - 웹 요청은 DB만 읽는다. - - 판정 규칙이 바뀌므로, 같은 상품이라도 규칙 변경 전후의 후보 상태가 다를 수 있다. - - 자금 후보는 저장하지 않는 조회 결과라, 과거 계산 결과는 바뀌지 않는다. + - 판정 규칙이 바뀌므로 같은 상품의 후보 상태가 규칙 변경 전후로 다를 수 있다. 자금 후보는 저장하지 않는 조회 결과라, 과거 계산 결과는 바뀌지 않는다. + - 대출 가정으로 이미 적용된 상품 조건은 `loanAssumptionJson`에 출처와 함께 남는다. ## Validation -- **단위 테스트:** +- **판정 단위 테스트:** - 신청 종료일이 지나면 OPEN 상품도 CLOSED다. - - 업종·용도 `UNKNOWN`은 현재 후보를 막지 않고 신청 전 확인으로 표시된다. `FAIL`은 막는다. - - 자동 승격 조건 여섯 개를 각각 하나씩 어기면 현재 후보가 되지 않는다. - - 대출·보증 자동 상품은 현재 후보가 되지 않는다. - - 동기화가 3일을 넘기면 자동 상품은 현재 후보에서 빠진다. + - 업종·용도·연령 `UNKNOWN`은 신청 전 확인으로 표시되고, `FAIL`은 막는다. + - 자동 승격 조건을 하나씩 어기면 현재 후보가 되지 않는다. + - 3일 넘게 성공하지 못한 출처의 상품은 현재 후보에서 빠진다. +- **정책자금 파서 테스트:** + - 출처별 HTML 조각 fixture로 필수 항목 추출을 검사한다. + - 필수 항목이 없는 페이지는 실패로 처리된다. + - 범위 금리·변동금리·"은행별 상이"는 상환 계산 가능으로 올라가지 않는다. - **동기화 테스트:** - - 출처 장애·빈 응답·급감 때 새 릴리스가 만들어지지 않고 기존 ACTIVE가 유지된다. - - 같은 공고의 사람 기록이 자동 상품보다 우선한다. - - 차단 목록의 공고는 현재 후보가 되지 않는다. -- **첫 실행 기록:** 첫 실제 실행 뒤 받은 공고 수, 자동 상품 수, 현재 후보 수를 이 ADR에 기록한다. -- **표본 점검:** 첫 4주 동안 매주 자동 현재 후보를 최대 10건씩 사람이 원문과 대조해 오판(실제로는 서울 예비 음식점·카페 창업자가 신청할 수 없는 공고)을 기록한다. -- **중단 조건:** 어느 한 주라도 오판이 표본의 20%를 넘으면 자동 승격 스위치를 끈다. 규칙을 고쳐 다시 점검할 때까지 강등만 유지한다. + - 출처 하나가 실패해도 그 출처의 직전 상품이 유지되고 다른 출처는 갱신된다. + - 403·429를 만나면 그 출처가 멈춘다. + - 사람 기록이 자동 상품보다 우선한다. + - 차단 목록의 공고는 승격되지 않는다. +- **첫 실행 기록:** 첫 실제 실행 뒤 출처별 받은 공고 수, 자동 상품 수, 갈래별 현재 후보 수, 상환 계산 가능 수를 이 ADR에 기록한다. +- **표본 점검:** 첫 4주 동안 매주 원문과 대조한다. + - 지원사업: 자동 현재 후보를 최대 10건. + - 정책자금: 자동 현재 후보 전부. +- **중단 조건:** + - 한 주라도 갈래별 오판이 표본의 20%를 넘으면 그 갈래의 자동 승격 스위치를 끈다. + - 상환 계산 가능으로 올린 상품에서 금리·기간·한도 오추출이 한 건이라도 나오면, 상환 계산 자동 승격 스위치를 즉시 끈다. From 1f586021b71be46d850e717e411091e29f7e5421 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:43:47 +0000 Subject: [PATCH 4/9] docs(funding): set jhjh0512 as ADR 0006 owner Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- docs/decisions/0006-funding-discovery-automation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0006-funding-discovery-automation.md b/docs/decisions/0006-funding-discovery-automation.md index a7fdcdc..74e9e47 100644 --- a/docs/decisions/0006-funding-discovery-automation.md +++ b/docs/decisions/0006-funding-discovery-automation.md @@ -2,7 +2,7 @@ - Status: Proposed - Date: 2026-09-25 -- Owners: 미정 (자동 규칙·수집 출처·차단 목록 책임자. Accepted 전에 지정) +- Owners: jhjh0512 (자동 규칙·수집 출처·차단 목록 책임자) - Related issue/PR: #5, #13, #15 - Supersedes: 없음 From cad48b1a92364d961f8ee19f257e7d6a654d0ace Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:26:24 +0000 Subject: [PATCH 5/9] docs(funding): accept ADR 0006 Mark ADR 0006 Accepted with jhjh0512 as owner, and state that the follow-up doc changes in section 9 land with the implementation PRs (AGENTS.md section 7), not with this decision. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- docs/decisions/0006-funding-discovery-automation.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0006-funding-discovery-automation.md b/docs/decisions/0006-funding-discovery-automation.md index 74e9e47..3325a51 100644 --- a/docs/decisions/0006-funding-discovery-automation.md +++ b/docs/decisions/0006-funding-discovery-automation.md @@ -1,9 +1,9 @@ # ADR 0006: 지원사업과 정책자금 후보를 자동으로 올리고 내린다 -- Status: Proposed -- Date: 2026-09-25 +- Status: Accepted +- Date: 2026-09-28 - Owners: jhjh0512 (자동 규칙·수집 출처·차단 목록 책임자) -- Related issue/PR: #5, #13, #15 +- Related issue/PR: #5, #13, #15, #33 - Supersedes: 없음 ## Context @@ -177,7 +177,10 @@ - 첨부(PDF·HWP) 해석, 로그인이 필요한 페이지, 차단 우회. - 웹 요청 중의 외부 조회. -### 9. Accepted 뒤 함께 고칠 것 +### 9. 구현 PR에서 함께 고칠 것 + +아래 문서와 설정은 해당 동작을 구현하는 PR에서 함께 고친다(AGENTS.md §7). 이 결정만으로는 바꾸지 않는다. + - **AGENTS.md §3의 8번:** "미검수"를 "사람 검수 또는 ADR 0006의 자동 확인을 거치지 않은"으로 고친다. 업종·용도·연령 `UNKNOWN`의 처리를 이 ADR에 연결한다. - **`docs/PRODUCT.md`:** 자금 후보 행을 지원사업·정책자금 두 갈래로 나누고, 제외 범위를 고친다. From 361da2659f493c9e01c2b07846541c77f5682a25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:26:41 +0000 Subject: [PATCH 6/9] docs(funding): drop a stray blank line in ADR 0006 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- docs/decisions/0006-funding-discovery-automation.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/decisions/0006-funding-discovery-automation.md b/docs/decisions/0006-funding-discovery-automation.md index 3325a51..5a7b8f8 100644 --- a/docs/decisions/0006-funding-discovery-automation.md +++ b/docs/decisions/0006-funding-discovery-automation.md @@ -181,7 +181,6 @@ 아래 문서와 설정은 해당 동작을 구현하는 PR에서 함께 고친다(AGENTS.md §7). 이 결정만으로는 바꾸지 않는다. - - **AGENTS.md §3의 8번:** "미검수"를 "사람 검수 또는 ADR 0006의 자동 확인을 거치지 않은"으로 고친다. 업종·용도·연령 `UNKNOWN`의 처리를 이 ADR에 연결한다. - **`docs/PRODUCT.md`:** 자금 후보 행을 지원사업·정책자금 두 갈래로 나누고, 제외 범위를 고친다. - **`docs/domains/funding.md`:** 동기화·승격·강등 규칙과 수집 출처 목록(허용 확인 기록 포함)을 적는다. From e7af9cc37506e5430fd5f076276324c1e672a550 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:20:24 +0000 Subject: [PATCH 7/9] feat(funding): keep unknown industry and purpose as pre-application checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ADR 0006, industry and purpose are conditions no official source gives in structured form, so an UNKNOWN verdict on them no longer keeps a product out of the current candidates. They are added to manualChecks as "업종: ..." / "용도: ..." and counted in the candidate reason, while eligibilityVerdict stays UNKNOWN. An UNKNOWN business stage or region still means NEEDS_CONFIRMATION, and any FAIL still means NOT_ELIGIBLE. AGENTS.md section 3 item 8 now points to ADR 0006 for this case, and funding.md and the candidate group notes describe the new split. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- AGENTS.md | 2 +- .../funding/FundingCandidatesPanel.tsx | 4 +-- app/src/features/funding/candidates.test.ts | 28 +++++++++++++++---- app/src/features/funding/eligibility.ts | 25 +++++++++++++---- app/src/features/funding/funding.test.ts | 11 +++++++- docs/domains/funding.md | 4 +++ 6 files changed, 59 insertions(+), 15 deletions(-) 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)로 판정한다. 종료일 당일까지는 접수 중으로 보고, 종료일이 없으면(예: 예산 소진 시까지) 관측 접수 상태를 따른다. - 검수 기한이 지난 상품, 검수일 또는 검수자가 없는 상품, 검색 결과 요약으로만 확인한 상품은 현재 후보로 확정하지 않는다. From e71981a216f42a189918c244142f322d0ab7b360 Mon Sep 17 00:00:00 2001 From: jhjh0512 Date: Mon, 28 Sep 2026 19:35:15 +0900 Subject: [PATCH 8/9] docs(funding): record source checks and drop K-Startup in ADR 0006 Record the 2026-09-28 checks of real API responses, robots.txt and terms. K-Startup would surface 116 mostly unrelated current candidates for a Seoul pre-founder, so support programs are discovered through Bizinfo and promoted only by a person. Policy loans are crawled from the SEMAS policy fund site, including its notice list JSON to locate notice pages. Sbiz24's internal API is not used, and Seoul development funds stay human-managed because their terms exist only in HWP attachments. Co-Authored-By: Claude Opus 5.5 --- .../0006-funding-discovery-automation.md | 97 +++++++++++++------ 1 file changed, 68 insertions(+), 29 deletions(-) diff --git a/docs/decisions/0006-funding-discovery-automation.md b/docs/decisions/0006-funding-discovery-automation.md index 5a7b8f8..6a1149f 100644 --- a/docs/decisions/0006-funding-discovery-automation.md +++ b/docs/decisions/0006-funding-discovery-automation.md @@ -1,9 +1,9 @@ -# ADR 0006: 지원사업과 정책자금 후보를 자동으로 올리고 내린다 +# ADR 0006: 정책자금 후보는 자동으로 올리고 내리고, 지원사업은 자동으로 찾는다 - Status: Accepted - Date: 2026-09-28 - Owners: jhjh0512 (자동 규칙·수집 출처·차단 목록 책임자) -- Related issue/PR: #5, #13, #15, #33 +- Related issue/PR: #5, #13, #15, #33, #36 - Supersedes: 없음 ## Context @@ -15,26 +15,44 @@ - **곧 기한 경과:** 서울시 중소기업육성자금 3개의 재검수일이 2026-10-20이다. 나머지 둘은 이미 기한이 지났거나 접수가 끝났다. - **병목은 사람의 시간:** 운영자가 기관 사이트를 돌며 공고를 찾고, JSON에 옮겨 적고, 기한마다 다시 확인한다. 이 방식으로는 5개도 기한 안에 유지하지 못하고 있다. -자금 상품은 성격이 다른 두 갈래이고, 자동으로 가져올 수 있는 출처도 다르다. +자금 상품은 성격이 다른 두 갈래이고, 자동으로 가져올 수 있는 출처도 다르다. 아래 출처는 2026-09-28에 실제 응답과 robots.txt·약관을 확인했다. ### 지원사업 (지원금·공간·프로그램): 공식 Open API가 있다 -2026-09-25에 두 API 모두 활용신청했다. 아래 항목은 공개된 이용 사례에서 확인한 것이며, 실제 응답은 구현 전에 다시 확인한다. - - **K-Startup 공고 정보**(창업진흥원, `apis.data.go.kr/B552735/kisedKstartupService01`) - - 주는 값: 공고명, 접수 시작·종료일(`pbanc_rcpt_bgng_dt`·`pbanc_rcpt_end_dt`), 모집 진행 여부(`rcrt_prgs_yn`), 지원 지역(`supt_regin`), 창업 업력(`biz_enyy`: 예비창업자, 1년 미만 …), 대상 연령(`biz_trgt_age`), 지원 분야(`supt_biz_clsfc`), 신청 대상·제외 대상 서술(`aply_trgt_ctnt`·`aply_excl_trgt_ctnt`), 상세 URL. - - 갱신: 하루 한 번 갱신된다. - - 한계: 지역 필터를 서버가 적용하지 않는 사례가 보고됐다. + - 기존 공공데이터포털 키로 호출된다. 모집 중 필터(`cond[rcrt_prgs_yn::EQ]=Y`)를 서버가 적용해, 전체 30,168건 중 220건만 받으면 된다. + - 구조화된 값: 창업 업력(`biz_enyy`: "예비창업자", "1년미만" …), 지역(`supt_regin`: 서울·전국 등), 대상 연령(`biz_trgt_age`: 구간 3개), 접수 시작·종료일(`yyyyMMdd`), 지원 분야(`supt_biz_clsfc`). 상세 URL은 모두 `www.k-startup.go.kr`이다. + - 제외 대상(`aply_excl_trgt_ctnt`)은 자유 서술이다. 창업지원법의 표준 제외 업종(유흥주점업 등)이 자주 나오고, "첨부 참조"만 적힌 공고도 많다. + - 이 ADR의 승격 규칙을 그대로 적용하면, 서울 예비 창업자에게 현재 후보가 116건 나온다. 대부분 음식점 창업과 무관하다(기술 창업 보육, 해커톤, 투자 행사 등). 외식·소상공인 관련 신호가 있는 공고는 약 10건이다. + - "전국"으로 표시된 후보 96건 중 20건은 공고명·기관·신청 대상에 다른 지역이 적혀 있다(예: "경기도 거주 … 도내 창업"). 창업자가 아니라 운영기관을 모집하는 공고도 섞여 있다. - **기업마당 지원사업정보**(중소벤처기업부, `www.bizinfo.go.kr/uss/rss/bizinfoApi.do`) - - 주는 값: 공고 ID·공고명·기관, 신청기간 문자열, 지원분야 대분류, 해시태그, 공고 URL. - - 한계: 지원대상(`trgetNm`)이 "중소기업" 같은 단순 태그라서 사업 단계를 구조적으로 알 수 없다. 대신 중앙부처와 지자체 공고를 함께 모으므로 새 공고를 찾는 데 유용하다. + - 요청 한 번(`searchCnt`)으로 현재 공고 1,554건을 모두 받는다. 공고 URL은 모두 `www.bizinfo.go.kr`이다. + - 신청기간(`reqstBeginEndDe`)의 84%는 `YYYY-MM-DD ~ YYYY-MM-DD`다. 나머지는 "예산 소진시까지", "상시 접수", "세부사업별 상이", "모집 완료시" 같은 문장이다. + - 지역은 공고명 앞의 `[서울]` 같은 표시나 소관기관(`jrsdInsttNm`)으로 알 수 있다. + - 한계: 지원대상(`trgetNm`)이 "중소기업"·"소상공인"·"창업벤처" 같은 단순 태그라서 사업 단계를 구조적으로 알 수 없다. + - 대신 중앙부처와 지자체 공고를 함께 모은다. 지원분야 "금융" 235건에 서울시 육성자금 변경공고, 자치구 육성기금(중구·서초·관악·용산), 소진공 정책자금 변경공고가 있다. 두 갈래 모두에서 새 공고를 찾는 데 유용하다. ### 정책자금 (대출·보증): 공식 API가 없다 -예비 음식점 창업자에게 핵심인 정책자금은 다음과 같다. 두 기관 모두 공고·접수 상태·조건을 웹 페이지로만 공개한다. - -- 소상공인시장진흥공단 정책자금: 소상공인24 `www.sbiz24.kr`, 신청 사이트 `ols.semas.or.kr` -- 서울시 중소기업육성자금: 서울신용보증재단 `www.seoulshinbo.co.kr`, 서울시 `news.seoul.go.kr` +예비 음식점 창업자에게 핵심인 정책자금은 소상공인시장진흥공단 정책자금과 서울시 중소기업육성자금이다. 두 기관 모두 공고·접수 상태·조건을 웹 페이지로만 공개한다. + +- **소진공 정책자금 사이트 `ols.semas.or.kr`** + - "정책자금 한눈에 보기"(`/ols/man/SMAN018M/page.do`)는 서버가 그린 HTML 표다. 직접대출 6종과 대리대출 7종의 자금명·신청요건·대출기간(거치 포함)·한도·금리, 분기별 정책자금 기준금리(2026년 3분기 3.85%)가 있다. + - 금리는 대부분 "기준금리+0.6%P"처럼 분기마다 바뀌는 기준금리에 가산하는 방식이다. 고정금리 3종은 "고정금리(2.0%P)"처럼 표기되어 연 금리인지 모호하다. 상환방식 열은 없다. + - 공통 자격은 「소상공인 보호 및 지원에 관한 법률」상 소상공인(상시근로자 5인 미만 등)이다. 즉 사업자등록 이후 대상이다. + - 접수 상태는 표에 없다("접수 순서대로 처리, 예산 소진 시 마감"). 자금별 분기 접수 안내는 공지사항에 있다. + - 공지 상세(`/ols/man/SMAN052M/page.do`, 폼 POST)는 서버가 그린 HTML이다. 본문에 "□ 접수기간 : 2026. 10. 6.(화) 10:00 ~ 예산소진 시까지" 같은 문장이 있다. + - 공지 목록은 페이지 스크립트가 JSON 요청(`/ols/man/SMAN051M/search.do`)으로 불러온다. 로그인 없이 읽힌다(2026-09-28 기준 78건). + - robots.txt가 없다(404). 약관에 자동 수집을 금지하는 조항은 없다. + - 약관 13조 3항은 "허락 없이 다른 홈페이지에 사용 또는 인용"을 금지한다. 반면 같은 사이트의 통합 저작권정책은 공단이 저작재산권 전부를 가진 콘텐츠를 출처 표시 조건으로 자유이용하게 한다(저작권법 24조의2). 담당자는 2026-09-28에 출처 표시 조건으로 수집을 허용하기로 했다. +- **소상공인24 `www.sbiz24.kr`** + - 화면을 스크립트로만 그린다. 공고와 전국 정책자금 상품(서울시 육성자금 포함)을 내부 JSON API(`/api/sbr/selectLoanProduct/anonymousUser` 등)로 불러오며, 호출 전에 익명 토큰을 받는다. + - 공개 문서가 없는 내부 API이고, 약관 13조 3항도 같다. +- **서울시 중소기업육성자금** + - `news.seoul.go.kr/economy/rearing-funds`는 robots.txt가 허용하는 HTML이다. 자금 이름 목록, 서울시 직접융자는 고정금리·시중은행 협력자금은 변동금리라는 구분, 공통 자격("서울특별시 소재 사업자등록을 필한 중소기업 및 소상공인")이 있다. + - 자금별 금리·한도·상환 조건은 HWP 첨부(공고문, 별표 1~3)에만 있다. `www.seoul.go.kr`의 공고 게시물도 본문 없이 같은 첨부만 있다. + - 서울신용보증재단 `www.seoulshinbo.co.kr` 첫 화면은 포털 선택 화면이다. 소상공인 종합지원 포털 `www.seoulsbdc.or.kr`은 robots.txt가 모든 경로를 막는다. +- 소진공 본사 `www.semas.or.kr`도 robots.txt가 모든 경로를 막는다. 관련 공공데이터는 다음 셋이 있지만, 어느 것도 이 상품들의 공고·조건을 주지 않는다. @@ -47,9 +65,9 @@ ### 지금 판정 규칙이 자동화를 막는 곳 1. **검수자:** 사람 검수자가 있어야 현재 후보가 된다. -2. **업종·용도:** 둘 중 하나라도 `UNKNOWN`이면 추가 확인으로 분류된다. 그런데 두 API에는 두 값이 없다. +2. **업종·용도:** 둘 중 하나라도 `UNKNOWN`이면 추가 확인으로 분류된다. 그런데 자동 출처에는 두 값이 없다. 3. **ACTIVE 릴리스:** ACTIVE 릴리스는 하나뿐이다(`read.ts`). 자동 상품과 사람이 기록한 상품을 한 릴리스로 합쳐야 한다. -4. **신청 종료일:** `evaluateProduct`는 신청기간을 보지 않는다. 그래서 OPEN으로 기록한 상품은 종료일이 지나도 재검수일까지 현재 후보로 남는다. 지금은 OPEN 상품이 없어 드러나지 않았다. +4. **신청 종료일:** `evaluateProduct`는 신청기간을 보지 않는다. 그래서 OPEN으로 기록한 상품은 종료일이 지나도 재검수일까지 현재 후보로 남는다. 지금은 OPEN 상품이 없어 드러나지 않았다. (#34에서 고쳤다.) ### 지켜야 할 제약 @@ -68,8 +86,13 @@ ### 1. 두 갈래 -- **지원사업:** 지원 유형이 `GRANT`·`SPACE`·`PROGRAM`인 상품이다. 공식 Open API(K-Startup)로 자동화한다. +- **지원사업:** 지원 유형이 `GRANT`·`SPACE`·`PROGRAM`인 상품이다. + - 자동으로 승격하지 않는다. 기업마당으로 새 공고를 찾아 추가 확인으로 올리고, 강등(6절)만 자동으로 한다. + - 현재 후보가 되려면 사람이 확인한다(#13). + - K-Startup 공고 API는 쓰지 않는다(Alternatives). - **정책자금:** 지원 유형이 `LOAN`·`GUARANTEE`인 상품이다. 기관 웹 페이지 크롤링으로 자동화한다. + - v1 크롤링 출처는 소진공 정책자금 사이트(`ols.semas.or.kr`) 하나다. + - 서울시 육성자금은 조건이 첨부에만 있어 사람 관리로 둔다(#13). 기업마당으로 새 공고와 변경공고를 찾는 일은 자동으로 한다. - **적재와 표시:** 두 갈래는 한 카탈로그 릴리스에 함께 적재한다. 후보 API 응답과 화면은 두 갈래를 나눠 보여 준다. 갈래는 기존 `supportType`으로 정하므로 새 enum을 만들지 않는다. ### 2. 동기화 명령: `funding:sync` @@ -94,7 +117,7 @@ - 한 출처가 실패하면, 그 출처의 상품은 직전 ACTIVE 릴리스의 것을 그대로 가져온다. 마지막 성공 시각도 그대로 둔다. 다른 출처는 정상 갱신한다. - 다음 경우는 실패로 본다: 장애·인증 오류, 빈 응답, 받은 공고 수가 직전 성공 대비 절반 미만으로 줄어든 경우, 크롤링 파서가 필수 항목을 찾지 못한 경우. - 실패를 "공고 없음"으로 해석해 상품을 지우지 않는다. -- **기업마당의 역할:** 두 갈래 모두에서 새 공고를 찾는 데 쓴다. 기업마당만으로 확인된 상품은 사업 단계를 알 수 없으므로 추가 확인에 머문다. 사람이나 다른 출처가 확인하면 승격될 수 있다. +- **기업마당의 역할:** 두 갈래 모두에서 새 공고를 찾는 데 쓴다. 기업마당만으로 확인된 상품은 사업 단계를 알 수 없으므로 추가 확인에 머문다. 사람이 확인하면 승격될 수 있다. ### 3. 정책자금 크롤링 규칙 @@ -102,6 +125,10 @@ - robots.txt가 해당 경로를 허용하는지, 이용약관이 자동 수집을 금지하지 않는지 확인한다. - 확인 날짜와 결과를 `docs/domains/funding.md`의 출처 목록에 기록한다. - 금지하면 그 출처는 켜지 않고 사람 관리로 둔다. +- **2026-09-28 확인 결과(Context):** + - 켠다: 소진공 정책자금 사이트 `ols.semas.or.kr`. 수집한 값을 보여 줄 때 출처를 표시한다. + - 소진공 공지 목록 JSON(`/ols/man/SMAN051M/search.do`)은 공지 상세 페이지를 찾는 데만 읽는다. 로그인·토큰이 필요 없는 요청이다. 조건 값은 상세 HTML에서만 뽑는다. + - 쓰지 않는다: 소상공인24 내부 JSON API, robots.txt가 막는 `www.seoulsbdc.or.kr`·`www.semas.or.kr`. - **수집 방식:** - 로그인 없이 공개된 페이지만 읽는다. - 출처당 하루 한 번, 요청 사이 1초 이상 간격을 둔다. @@ -117,7 +144,7 @@ ### 4. 자동 승격 조건 -**두 갈래 공통.** 자동 상품은 다음을 모두 만족할 때만 현재 후보가 된다. +자동 상품은 다음을 모두 만족할 때만 현재 후보가 된다. 지원사업 자동 상품은 1절에 따라 승격하지 않으므로, 이 조건은 정책자금 자동 상품에만 쓰인다. 1. **신청기간:** 시작일과 종료일이 날짜로 확인되고, 판정 기준일이 그 사이에 있으며, 출처의 접수 상태 표시와 모순이 없다. "상시", "예산 소진 시까지"처럼 날짜가 없으면 출처의 접수 상태가 "접수 중"으로 확인될 때만 인정한다. 2. **사업 단계와 지역:** 구조화 값이나 출처별 파서의 명시적 규칙으로 PASS다. 지역은 서울 또는 전국이다. @@ -146,6 +173,8 @@ - 원리금균등 또는 원금균등 상환방식 - **검증:** 뽑은 값은 기존 스키마의 범위 검사와 "거치 < 전체 기간" 검사를 통과해야 한다. - **올리지 않는 경우:** 범위 금리, 변동금리(기준금리 연동), 은행별로 다른 금리는 확정 숫자가 아니다. 지금처럼 `null`로 두고 "상환 계산 대상 아님 + 사유"를 표시한다. 따라서 소진공·서울시 정책자금 대부분은 자동화 뒤에도 상환 계산 대상이 아니다. + - 소진공 표의 "고정금리(2.0%P)"처럼 연 금리인지 가산폭인지 모호한 표기도 확정 숫자로 보지 않는다. + - 소진공 표에는 상환방식이 없으므로, 표만으로는 상환 계산 가능으로 올리지 않는다. - **범위 밖:** 변동금리 상품의 상환 계산은 계산 계약의 변경이다. 필요하면 기준금리 관측값을 쓰는 방식을 별도 ADR로 다룬다. - **사용자 적용 절차:** 상품 조건을 계획에 적용하는 일은 지금처럼 사용자가 명시적으로 실행할 때만 일어난다(§3의 3번). @@ -164,8 +193,7 @@ - 자동 규칙, 수집 출처와 그 허용 확인, 차단 목록을 관리한다. - 오판 신고를 처리한다. - **사람이 기록한 상품:** 지금처럼 `nextReviewAt` 규칙을 따른다. -- **스위치 세 개:** 켜고 끌 수 있는 스위치를 따로 둔다. - - 지원사업 자동 승격 +- **스위치 두 개:** 켜고 끌 수 있는 스위치를 따로 둔다. 지원사업은 자동 승격하지 않으므로 스위치가 없다. - 정책자금 자동 승격 - 정책자금 상환 계산 자동 승격 - 어느 것을 꺼도 강등은 계속 동작한다. @@ -175,6 +203,7 @@ - 공고문 본문·첨부를 AI로 해석하는 일. PRODUCT.md의 "자동 공고 해석" 제외는 "AI 기반 공고 해석"으로 좁혀서 유지한다. - 첨부(PDF·HWP) 해석, 로그인이 필요한 페이지, 차단 우회. +- K-Startup 공고 API, 소상공인24 내부 JSON API. - 웹 요청 중의 외부 조회. ### 9. 구현 PR에서 함께 고칠 것 @@ -185,7 +214,7 @@ - **`docs/PRODUCT.md`:** 자금 후보 행을 지원사업·정책자금 두 갈래로 나누고, 제외 범위를 고친다. - **`docs/domains/funding.md`:** 동기화·승격·강등 규칙과 수집 출처 목록(허용 확인 기록 포함)을 적는다. - **`docs/DEVELOPMENT.md`:** 기업마당 키 `BIZINFO_API_KEY`와 `funding:sync`를 적는다. -- **공식 출처 호스트 목록:** `OFFICIAL_URL_HOST_SUFFIXES`에 `sbiz24.kr`을 더한다. +- **공식 출처 호스트 목록:** 더할 호스트가 없다. `ols.semas.or.kr`은 기존 `semas.or.kr`로 통과하고, 소상공인24는 쓰지 않으므로 `sbiz24.kr`을 더하지 않는다. - **HTML 파서 의존성:** 추가한다면 `app/package.json` 단일 PR 규칙을 따른다. ## Alternatives considered @@ -193,6 +222,11 @@ - **사람 검수만 유지(#5의 선택지 1):** 병목이 사람의 시간이라 상품 수를 늘릴 수 없다. - **자동 탐색 + 사람 승인(이 ADR의 첫 초안):** 병목이 그대로 남는다. - **지원사업만 자동화하고 정책자금은 사람 관리(두 번째 초안):** 제품의 핵심인 정책자금이 계속 비어 있거나 낡는다. +- **K-Startup 공고 API로 지원사업 자동 승격(2026-09-28까지의 결정):** + - 실제 응답에 승격 규칙을 적용하면 서울 예비 창업자의 현재 후보가 116건이고, 대부분 음식점 창업과 무관하다. + - "전국" 표시도 믿기 어렵다(후보 96건 중 20건에 다른 지역 신호). + - 외식·소상공인 관련 신호로 거르면 약 10건이 남지만, 업종 `UNKNOWN`을 막지 않는다는 4절과 충돌하고 키워드 오탐도 있다. +- **소상공인24 내부 JSON API:** 서울시 육성자금까지 구조화된 조건을 주지만, 공개 문서가 없는 내부 API이고 익명 토큰을 받아 호출해야 한다. 3절의 "공고 HTML 본문과 표"에서 값을 뽑는 방식과 다르고, 약관 13조 3항도 걸린다. - **정책자금 대신 서민금융 API 사용:** 대출 조건을 구조화해 주지만, 서민금융 상품이라 소진공·서울시 정책자금이 아니다. - **AI로 공고문을 해석해 조건을 채움:** 잘못 읽으면 틀린 자격 판정이나 틀린 상환액이 된다(§3의 1·8번, PRODUCT.md 원칙 7). 필요해지면 별도 ADR로 다룬다. - **출시에서 숨기거나 베타로 표시(#5의 선택지 4):** 이 ADR은 기능을 줄이기보다 운영을 자동화하는 쪽을 제안한다. @@ -200,9 +234,15 @@ ## Consequences - **장점:** - - 사람 없이 두 갈래 모두 현재 후보가 생긴다. + - 정책자금은 사람 없이 현재 후보가 될 수 있다. 지원사업은 새 공고가 자동으로 모인다. - 마감·조기 마감·변경이 날마다 반영된다. - 출처가 멈추면 그 출처의 후보가 저절로 내려간다. +- **예비 창업자에게 주는 효과는 작다:** + - 소진공 대출은 모두 사업자등록 이후 대상이라, 예비 창업자 프로필에서는 사업자등록 이후 그룹에 나온다. + - 지원사업은 사람이 확인해야 현재 후보가 된다. +- **소진공 접수 상태:** 표에는 접수 상태가 없어, 4절 1번(신청기간)은 공지 상세 HTML의 접수 안내로 채운다. + - 공지를 찾으려면 목록 JSON을 읽어야 한다. 담당자는 2026-09-28에 상세를 찾는 용도로만 읽도록 허용했다(3절). + - 목록 JSON의 형식이 바뀌면 파서가 필수 항목을 찾지 못한 경우로 보고 그 출처를 실패로 기록한다(2절). - **자동 오판:** 첨부에만 음식점 제외가 적힌 공고가 현재 후보로 보일 수 있다. 다음 네 가지로 줄인다. - 신청 전 확인 표시 - 제외 신호 강등 @@ -212,7 +252,7 @@ - 기관 사이트 구조가 바뀌면 파서가 깨진다. 이때 그 출처는 실패로 남고, 3일 뒤 그 출처의 후보가 빠진다. 파서 유지보수 비용이 계속 든다. - 이용약관이 바뀌면 그 출처를 끈다. - **현재 후보의 의미 변화:** 업종·용도·연령을 확인하지 않은 상품도 현재 후보가 된다. 화면이 이 차이를 분명히 보여 주어야 한다. -- **상환 계산의 한계:** 변동금리 상품이 대부분이라, 상환 계산 가능 상품은 자동화 뒤에도 적을 수 있다. +- **상환 계산의 한계:** 변동금리 상품이 대부분이라, 상환 계산 가능 상품은 자동화 뒤에도 적을 수 있다. 소진공 표에는 상환방식이 없어 표만으로는 0건이다. - **migration·호환성:** - 카탈로그 상품에 출처 식별자를 추가하고, 카탈로그에 차단 목록을 추가하고, `schemaVersion`을 올린다. - 릴리스에 출처별 동기화 결과를 저장한다(필요하면 새 migration). @@ -229,20 +269,19 @@ - 신청 종료일이 지나면 OPEN 상품도 CLOSED다. - 업종·용도·연령 `UNKNOWN`은 신청 전 확인으로 표시되고, `FAIL`은 막는다. - 자동 승격 조건을 하나씩 어기면 현재 후보가 되지 않는다. + - 지원사업 자동 상품은 조건을 모두 채워도 현재 후보가 되지 않는다. - 3일 넘게 성공하지 못한 출처의 상품은 현재 후보에서 빠진다. - **정책자금 파서 테스트:** - 출처별 HTML 조각 fixture로 필수 항목 추출을 검사한다. - 필수 항목이 없는 페이지는 실패로 처리된다. - - 범위 금리·변동금리·"은행별 상이"는 상환 계산 가능으로 올라가지 않는다. + - 범위 금리·변동금리·"은행별 상이"·"고정금리(2.0%P)" 같은 모호한 표기는 상환 계산 가능으로 올라가지 않는다. - **동기화 테스트:** - 출처 하나가 실패해도 그 출처의 직전 상품이 유지되고 다른 출처는 갱신된다. - 403·429를 만나면 그 출처가 멈춘다. - 사람 기록이 자동 상품보다 우선한다. - 차단 목록의 공고는 승격되지 않는다. - **첫 실행 기록:** 첫 실제 실행 뒤 출처별 받은 공고 수, 자동 상품 수, 갈래별 현재 후보 수, 상환 계산 가능 수를 이 ADR에 기록한다. -- **표본 점검:** 첫 4주 동안 매주 원문과 대조한다. - - 지원사업: 자동 현재 후보를 최대 10건. - - 정책자금: 자동 현재 후보 전부. +- **표본 점검:** 첫 4주 동안 매주 정책자금 자동 현재 후보 전부를 원문과 대조한다. - **중단 조건:** - - 한 주라도 갈래별 오판이 표본의 20%를 넘으면 그 갈래의 자동 승격 스위치를 끈다. + - 한 주라도 정책자금 오판이 표본의 20%를 넘으면 정책자금 자동 승격 스위치를 끈다. - 상환 계산 가능으로 올린 상품에서 금리·기간·한도 오추출이 한 건이라도 나오면, 상환 계산 자동 승격 스위치를 즉시 끈다. From ba3ac4648655c37b46727c97752dd1f394d2cb5b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:04:35 +0000 Subject: [PATCH 9/9] feat(funding): add funding:sync with automation settings and source freshness Implements the source-agnostic half of ADR 0006 so a policy-fund crawler can plug in as a FundingSourceAdapter. - Catalog schema funding-catalog-v1.1.0 adds sourceRef on products and an automation block (policy loan and repayment promotion switches, blocked sources, per-source sync results). v1.0.0 releases stay readable with every switch off. The release stores automation in a new JSONB column. - Automated products (reviewer AUTO:funding-auto-v1) are judged by the source's last successful sync instead of nextReviewAt and drop to REVIEW_OVERDUE after three days. Blocked notices, automated support programs, and promotion switched off keep them in NEEDS_CONFIRMATION with the reason. The repayment switch gates repayment support, and the loan-assumption route now checks the evaluation instead of the product. - funding:sync collects per source, reuses immutable product versions when only dates change, keeps the previous products and last success date when a source fails (error, empty, drop below half, validation), lets a manual record override the same notice, and loads the merged catalog through the existing PENDING to ACTIVE loader. No source adapter is registered yet. - catalog.json moves to v1.1.0 as 2026-09-28.1 with both switches off; product versions are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LCE4DgT5qdHP8y8V5qtbL --- .gitignore | 2 + AGENTS.md | 2 +- app/catalog/funding/README.md | 12 +- app/catalog/funding/catalog.json | 13 +- app/package.json | 3 +- .../migration.sql | 3 + app/prisma/schema.prisma | 2 + app/scripts/sync-funding.ts | 120 ++++++ .../plans/[planId]/loan-assumption/route.ts | 15 +- app/src/features/funding/candidates.test.ts | 115 +++++- app/src/features/funding/eligibility.ts | 63 ++- app/src/features/funding/funding.test.ts | 73 +++- app/src/features/funding/loader.ts | 1 + app/src/features/funding/read.ts | 20 +- app/src/features/funding/schema.ts | 49 +++ app/src/features/funding/sync.test.ts | 371 ++++++++++++++++++ app/src/features/funding/sync.ts | 339 ++++++++++++++++ app/src/features/funding/types.ts | 68 +++- app/src/features/funding/validation.ts | 71 +++- .../features/plans/funding-matches.test.ts | 1 + .../features/plans/loan-assumption.test.ts | 54 ++- docs/ARCHITECTURE.md | 2 +- docs/DEVELOPMENT.md | 2 + docs/domains/funding.md | 37 +- 24 files changed, 1401 insertions(+), 37 deletions(-) create mode 100644 app/prisma/migrations/20260928010000_funding_release_automation/migration.sql create mode 100644 app/scripts/sync-funding.ts create mode 100644 app/src/features/funding/sync.test.ts create mode 100644 app/src/features/funding/sync.ts diff --git a/.gitignore b/.gitignore index 36f3f9e..c80fb5e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ next-env.d.ts # 공공 원본 데이터. 준비 방법은 docs/DEVELOPMENT.md의 로컬 데이터 준비를 따른다. data/raw/ +# funding:sync가 합친 자금 카탈로그. 적재한 릴리스의 원본이며 저장소에 넣지 않는다. +data/funding-sync/ # Claude Code 개인 설정. 팀 공통 설정은 .claude/settings.json에 둔다. .claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index 1d5ba90..d8e1c97 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. 미검수·종료·근거 부족 자금 상품을 현재 신청 가능 또는 상환 계산 가능으로 표시하지 않는다. 업종·용도만 확인되지 않은 상품은 [ADR 0006](docs/decisions/0006-funding-discovery-automation.md)에 따라 신청 전 확인 항목과 함께 현재 후보로 표시한다. +8. 사람 검수나 [ADR 0006](docs/decisions/0006-funding-discovery-automation.md)의 자동 확인을 거치지 않은 자금 상품, 종료·근거 부족 자금 상품을 현재 신청 가능 또는 상환 계산 가능으로 표시하지 않는다. 업종·용도만 확인되지 않은 상품은 ADR 0006에 따라 신청 전 확인 항목과 함께 현재 후보로 표시한다. 9. 운영 데이터는 `PENDING → ACTIVE` 검증을 거친다. 새 적재가 실패하면 기존 ACTIVE를 유지한다. 10. 출처 URL, 기준기간, 릴리스 키, checksum, 적용 시점 등 provenance를 보존한다. diff --git a/app/catalog/funding/README.md b/app/catalog/funding/README.md index 7b8e210..6fcda4d 100644 --- a/app/catalog/funding/README.md +++ b/app/catalog/funding/README.md @@ -13,6 +13,7 @@ npm --prefix app run funding:load ``` - `funding:validate`는 DB 없이 검사 결과만 출력한다. `funding:load`는 같은 검사를 통과한 카탈로그만 적재한다. +- 자동 수집 출처가 있으면 `funding:load` 대신 `npm --prefix app run funding:sync`로 이 카탈로그와 자동 상품을 합쳐 적재한다. `funding:load`로 이 파일만 적재하면 자동 상품이 빠진 릴리스가 ACTIVE가 된다. - 검사 항목, 오류·주의 구분, 적재·활성화 규칙은 [funding.md](../../../docs/domains/funding.md#카탈로그)에 있다. ## 갱신 절차 @@ -38,7 +39,7 @@ npm --prefix app run funding:load ## 2026-09-20 검수 범위 -현재 카탈로그 `2026-09-23.1`(상품 버전 `1.0.1`)에 적용되는 검수 내용이다. +현재 카탈로그 `2026-09-28.1`(상품 버전 `1.0.1`)에 적용되는 검수 내용이다. `2026-09-28.1`은 `2026-09-23.1`에서 스키마를 v1.1.0으로 올리고 자동화 설정을 더한 것이며, 상품 내용과 검수 내용은 같다. - 접수 상태가 CLOSED로 확인된 상품 1건(예비창업패키지 2차), 접수 상태를 확정할 수 없어 UNKNOWN으로 남긴 상품 4건을 기록했다. - 같은 날 접수 상태가 OPEN으로 확인된 상품은 없어 현재 신청 가능 후보로 확정되는 상품은 0건이다. @@ -46,3 +47,12 @@ npm --prefix app run funding:load - 2026-09-09에 확인했던 강북창업지원센터 입주 모집은 다시 확인하지 않아 검수 기한 경과 상태로 남겼고, 현재 후보에서 제외된다. - 서울여성 창업아이디어 공모전 등 2026-09-09 검토 항목은 이번 검수에서 다시 확인하지 않아 카탈로그에 넣지 않았다. - 검수자(`reviewer`)는 아직 지정되지 않아 `UNASSIGNED`다(#13). 근거 일부는 공식 원문이 아니라 검색 결과 요약이다. + +## 자동화 설정 + +`catalog.json`의 `automation`은 funding:sync가 모은 자동 상품에만 적용된다. 규칙은 [funding.md](../../../docs/domains/funding.md#동기화)와 [ADR 0006](../../../docs/decisions/0006-funding-discovery-automation.md)에 있다. + +- `policyLoanPromotion`, `repaymentPromotion`: 정책자금 자동 승격과 상환 계산 자동 승격 스위치다. 기본으로 꺼져 있고, 켜고 끄는 일은 ADR 0006의 Owner가 정한다. 바꾼 뒤 funding:sync를 실행하면 새 릴리스에 반영된다. +- `blockedSources`: 자동 승격을 막을 공고. `{ "source": "SEMAS_OLS", "externalId": "…", "reason": "…" }`처럼 출처·식별자·사유를 적는다. +- `sourceSyncs`: funding:sync가 채운다. 이 파일에는 비워 둔다. +- 자동 상품(검수자 `AUTO:…`)은 이 파일에 넣지 않는다. 같은 공고를 사람이 기록하려면 그 상품에 `sourceRef`를 적는다. 그러면 사람 기록이 자동 상품보다 우선한다. diff --git a/app/catalog/funding/catalog.json b/app/catalog/funding/catalog.json index 5d752f9..50ffb22 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.0.0", - "catalogVersion": "2026-09-23.1", + "schemaVersion": "funding-catalog-v1.1.0", + "catalogVersion": "2026-09-28.1", "basisDate": "2026-09-20", "reviewer": "UNASSIGNED", "notes": [ @@ -10,8 +10,15 @@ "공고문 원문에서 확정 숫자로 확인되지 않은 금리·기간·한도는 0으로 채우지 않고 null 또는 UNKNOWN으로 보존했습니다.", "검수자가 지정되지 않아(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-09에 확인했던 서울여성 창업아이디어 공모전 등은 2026-09-20에 다시 확인하지 않아 카탈로그에 넣지 않았습니다. 접수 상태가 OPEN으로 확인된 상품도 이번 검수 범위에는 없습니다.", + "2026-09-28: 스키마를 funding-catalog-v1.1.0으로 올리고 자동화 설정(automation)을 더해 catalogVersion을 2026-09-28.1로 올렸습니다. 두 자동 승격 스위치는 꺼져 있고, 상품 내용과 상품 버전(1.0.1)은 바뀌지 않았습니다." ], + "automation": { + "policyLoanPromotion": false, + "repaymentPromotion": false, + "blockedSources": [], + "sourceSyncs": [] + }, "products": [ { "productKey": "pre-startup-package-2026-2nd", diff --git a/app/package.json b/app/package.json index 29bdcec..2b1b1e2 100644 --- a/app/package.json +++ b/app/package.json @@ -29,7 +29,8 @@ "rent:load": "node --env-file-if-exists=.env.local scripts/load-rent-benchmarks.ts", "franchise:load": "node --env-file-if-exists=.env.local scripts/load-franchises.ts", "funding:validate": "node --env-file-if-exists=.env.local scripts/validate-funding-catalog.ts", - "funding:load": "node --env-file-if-exists=.env.local scripts/load-funding-catalog.ts" + "funding:load": "node --env-file-if-exists=.env.local scripts/load-funding-catalog.ts", + "funding:sync": "node --env-file-if-exists=.env.local scripts/sync-funding.ts" }, "packageManager": "npm@11.13.0", "engines": { diff --git a/app/prisma/migrations/20260928010000_funding_release_automation/migration.sql b/app/prisma/migrations/20260928010000_funding_release_automation/migration.sql new file mode 100644 index 0000000..01dfe52 --- /dev/null +++ b/app/prisma/migrations/20260928010000_funding_release_automation/migration.sql @@ -0,0 +1,3 @@ +-- 자동 승격 스위치·차단 목록·출처별 동기화 결과(ADR 0006). 이전 카탈로그 릴리스는 값이 없어 NULL로 남는다. +-- AlterTable +ALTER TABLE "funding_catalog_releases" ADD COLUMN "automation" JSONB; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 82f3484..a111c05 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -322,6 +322,8 @@ model FundingCatalogRelease { reviewer String sourceDocuments Json @db.JsonB productVersions Json @db.JsonB + // 자동 승격 스위치·차단 목록·출처별 동기화 결과(ADR 0006). v1.0.0 카탈로그 릴리스는 NULL이다. + automation Json? @db.JsonB validationSummary Json @db.JsonB productCount Int @default(0) activatedAt DateTime? diff --git a/app/scripts/sync-funding.ts b/app/scripts/sync-funding.ts new file mode 100644 index 0000000..f01ce51 --- /dev/null +++ b/app/scripts/sync-funding.ts @@ -0,0 +1,120 @@ +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 { todayInKst } from '../src/features/funding/dates.ts'; +import { runFundingSync, type FundingSourceAdapter } from '../src/features/funding/sync.ts'; +import { FundingCatalogError } from '../src/features/funding/validation.ts'; + +const DEFAULT_CATALOG = fileURLToPath(new URL('../catalog/funding/catalog.json', import.meta.url)); +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 USAGE = `공식 출처에서 자금 공고를 모아 사람이 관리하는 카탈로그와 합치고 새 릴리스로 적재합니다(ADR 0006). + +사용법: + npm run funding:sync -- [옵션] + +옵션: + --catalog <경로> 사람이 관리하는 카탈로그 JSON (기본값: app/catalog/funding/catalog.json) + --output-dir <경로> 합친 카탈로그를 쓸 폴더 (기본값: data/funding-sync/) + --dry-run 합친 카탈로그를 쓰고 검증만 하며 적재하지 않습니다 + -h, --help 이 도움말 + +동작: + - 웹 요청 중에는 실행하지 않습니다. 운영자나 정기 실행이 하루 한 번 실행합니다. + - 출처가 실패하면(장애, 빈 응답, 직전 성공 대비 절반 미만, 검증 실패) 그 출처의 상품은 직전 ACTIVE 릴리스의 것을 그대로 두고 마지막 성공일도 그대로 둡니다. + - 자동 상품은 출처의 마지막 성공 동기화가 3일을 넘기면 현재 후보에서 빠집니다. + - 사람이 기록한 상품이 같은 공고를 가리키면 사람 기록이 우선합니다. + - 내용이 같은 자동 상품은 이미 적재된 상품 버전을 재사용하고, 바뀌면 새 버전을 만듭니다. + - 합친 카탈로그는 funding:load와 같은 검사를 거쳐 PENDING → ACTIVE로 적재합니다.`; + +type CliOptions = { catalogPath: string; outputDir: string; dryRun: boolean; help: boolean }; + +function parseArgs(argv: readonly string[]): CliOptions { + const options: CliOptions = { + catalogPath: DEFAULT_CATALOG, + outputDir: DEFAULT_OUTPUT_DIR, + dryRun: false, + help: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--help' || argument === '-h') options.help = true; + else if (argument === '--dry-run') options.dryRun = true; + else if (argument === '--catalog' || argument === '--output-dir') { + const value = argv[index + 1]; + if (!value) throw new Error(`${argument} 뒤에 경로가 필요합니다.`); + if (argument === '--catalog') options.catalogPath = resolve(value); + else options.outputDir = resolve(value); + index += 1; + } else { + throw new Error(`알 수 없는 옵션입니다: ${argument}`); + } + } + return options; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(USAGE); + return; + } + const databaseUrl = process.env.DATABASE_URL; + 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({ + prisma, + catalogPath: options.catalogPath, + adapters: SOURCE_ADAPTERS, + asOfDate: todayInKst(), + outputDir: options.outputDir, + dryRun: options.dryRun, + log: (message) => console.log(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 ?? '없음'}`, + ); + } + console.log(`사람이 기록한 상품 ${report.manualProductCount}건 · 자동 상품 ${report.automatedProductCount}건`); + if (report.overriddenByManual.length > 0) { + console.log(`사람 기록이 우선해 뺀 자동 상품: ${report.overriddenByManual.join(', ')}`); + } + console.log(`합친 카탈로그: ${report.catalogFile}`); + if (report.load === null) { + console.log('dry run이라 적재하지 않았습니다.'); + } else { + for (const warning of report.load.warnings) { + console.warn(`주의 [${warning.code}] ${warning.source}: ${warning.detail}`); + } + console.log(`결과: ${report.load.outcome}`); + console.log(`카탈로그: ${report.load.catalogKey}@${report.load.catalogVersion} (${report.load.releaseId})`); + console.log(`checksum: ${report.load.catalogChecksum}`); + } + } finally { + await prisma.$disconnect(); + } +} + +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}`); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + process.exitCode = 1; +}); diff --git a/app/src/app/api/plans/[planId]/loan-assumption/route.ts b/app/src/app/api/plans/[planId]/loan-assumption/route.ts index 24521cc..8ac6929 100644 --- a/app/src/app/api/plans/[planId]/loan-assumption/route.ts +++ b/app/src/app/api/plans/[planId]/loan-assumption/route.ts @@ -78,7 +78,10 @@ export async function POST(request: Request, context: Context) { '저장된 자금 조건이 없거나 현재 스키마와 맞지 않습니다. 자금 조건을 저장한 뒤 다시 조회해 주세요.', ); } - const evaluation = evaluateProduct(product, parsedProfile.data, { asOfDate: todayInKst() }); + const evaluation = evaluateProduct(product, parsedProfile.data, { + asOfDate: todayInKst(), + automation: active.automation, + }); if (evaluation.candidateStatus !== 'CURRENT_CANDIDATE') { return apiError( 400, @@ -115,6 +118,16 @@ export async function POST(request: Request, context: Context) { ); } + // 자동 수집 상품은 상환 계산 자동 승격 스위치까지 반영한 판정 결과로 확인한다. + if (!evaluation.repayment.supported) { + const reasons = + evaluation.repayment.reasons.length > 0 ? evaluation.repayment.reasons.join(' / ') : evaluation.repayment.note; + return apiError( + 400, + 'INVALID_INPUT', + `이 상품은 상환 계산 대상이 아니어서 대출 가정으로 적용할 수 없습니다. ${reasons}`, + ); + } const loanAssumption = loanAssumptionFromProduct( product, { catalogKey: active.catalogKey, catalogVersion: active.catalogVersion }, diff --git a/app/src/features/funding/candidates.test.ts b/app/src/features/funding/candidates.test.ts index b79a71a..6bfd57f 100644 --- a/app/src/features/funding/candidates.test.ts +++ b/app/src/features/funding/candidates.test.ts @@ -14,7 +14,7 @@ import { POST as postCandidates } from '@/app/api/funding/candidates/route'; import { listFundingCandidates, type FundingCandidatesResponse } from './candidates.ts'; import { loadFundingCatalog } from './loader.ts'; import { findActiveFundingCatalog } from './read.ts'; -import { FUNDING_CATALOG_SCHEMA_VERSION } from './types.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION } from './types.ts'; import type { FundingProfile } from './eligibility.ts'; const TEST_DATE = '2026-09-20'; @@ -93,8 +93,11 @@ type StubMembership = { position: number; productVersion: { productJson: unknown type StubReleaseInput = { catalogVersion?: string; + schemaVersion?: string; basisDate?: string; reviewer?: string; + // 릴리스의 automation 열. 비어 있으면 실제 DB처럼 null이다. + automation?: unknown; products: StubMembership[]; }; @@ -107,11 +110,12 @@ function stubPrisma(release: StubReleaseInput | null) { id: 'release-1', catalogKey: 'test-catalog', catalogVersion: release.catalogVersion ?? '2026-09-20.1', - schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + schemaVersion: release.schemaVersion ?? FUNDING_CATALOG_SCHEMA_VERSION, basisDate: new Date(`${release.basisDate ?? TEST_DATE}T00:00:00.000Z`), reviewedAt: new Date(`${TEST_DATE}T00:00:00.000Z`), activatedAt: new Date('2026-09-20T03:00:00.000Z'), reviewer: release.reviewer ?? 'test-reviewer', + automation: release.automation ?? null, products: release.products, }, ); @@ -357,6 +361,16 @@ describe('후보 판정과 응답', () => { expect(payload.evaluations[0].candidateStatus).toBe('CURRENT_CANDIDATE'); }); + it('v1.0.0 스키마로 적재된 릴리스도 자동화 설정 없이 읽는다', async () => { + const { prisma } = stubPrisma({ + schemaVersion: 'funding-catalog-v1.0.0', + products: [membership(rawProduct(), 0)], + }); + const payload = await candidatesOf(prisma); + expect(payload.release.schemaVersion).toBe('funding-catalog-v1.0.0'); + expect(payload.evaluations[0].candidateStatus).toBe('CURRENT_CANDIDATE'); + }); + it('검수 기한이 지난 상품은 접수 중이어도 검수 기한 경과로 분리한다', async () => { const { prisma } = stubPrisma({ products: [ @@ -519,6 +533,103 @@ describe('후보 판정과 응답', () => { }); }); +describe('자동으로 모은 상품(ADR 0006)', () => { + const SOURCE_REF = { source: 'SEMAS_OLS', externalId: 'test-fund' }; + const autoProduct = (overrides: Record = {}) => + rawProduct({ reviewer: AUTO_REVIEWER, sourceRef: SOURCE_REF, nextReviewAt: null, ...overrides }); + const sync = (lastSucceededOn: string | null) => ({ + source: 'SEMAS_OLS', + status: lastSucceededOn === null ? 'FAILED' : 'SUCCEEDED', + attemptedAt: '2026-09-20T01:00:00.000Z', + lastSucceededOn, + fetchedCount: lastSucceededOn === null ? null : 5, + responseChecksum: null, + failureReason: lastSucceededOn === null ? '테스트 실패' : null, + }); + const automation = (overrides: Record = {}) => ({ + policyLoanPromotion: true, + repaymentPromotion: false, + blockedSources: [], + sourceSyncs: [sync(TEST_DATE)], + ...overrides, + }); + const evaluate = async (product: Record, automationValue: unknown, asOfDate: string = TEST_DATE) => { + const { prisma } = stubPrisma({ automation: automationValue, products: [membership(product, 0)] }); + return (await candidatesOf(prisma, PRE_PROFILE, asOfDate)).evaluations[0]; + }; + + it('출처의 마지막 성공 동기화가 3일 안이면 다음 검토일 없이도 현재 후보가 된다', async () => { + const lastDay = await evaluate(autoProduct(), automation(), '2026-09-23'); + expect(lastDay.reviewState).toBe('CURRENT'); + expect(lastDay.candidateStatus).toBe('CURRENT_CANDIDATE'); + }); + + it('마지막 성공 동기화가 3일을 넘기거나 없으면 검수 기한 경과로 내린다', async () => { + const stale = await evaluate(autoProduct(), automation(), '2026-09-24'); + expect(stale.candidateStatus).toBe('REVIEW_OVERDUE'); + expect(stale.candidateReason).toContain('2026-09-20'); + const neverSucceeded = await evaluate(autoProduct(), automation({ sourceSyncs: [sync(null)] })); + expect(neverSucceeded.candidateStatus).toBe('REVIEW_OVERDUE'); + const noAutomation = await evaluate(autoProduct(), null); + expect(noAutomation.candidateStatus).toBe('REVIEW_OVERDUE'); + }); + + it('정책자금 자동 승격이 꺼져 있으면 추가 확인에 머문다', async () => { + const evaluation = await evaluate(autoProduct(), automation({ policyLoanPromotion: false })); + expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(evaluation.candidateReason).toContain('자동 승격이 꺼져'); + }); + + it('차단 목록의 공고는 사유와 함께 추가 확인에 머문다', async () => { + const evaluation = await evaluate( + autoProduct(), + automation({ blockedSources: [{ ...SOURCE_REF, reason: '첨부에 음식점 제외 조항' }] }), + ); + expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(evaluation.candidateReason).toContain('첨부에 음식점 제외 조항'); + }); + + it('지원사업 자동 상품은 조건을 모두 채워도 현재 후보가 되지 않는다', async () => { + const evaluation = await evaluate( + autoProduct({ + supportType: 'GRANT', + repaymentMethod: 'NOT_APPLICABLE', + unsupportedCalculationReasons: ['지원금은 상환 계산 대상이 아닙니다.'], + }), + automation(), + ); + expect(evaluation.candidateStatus).toBe('NEEDS_CONFIRMATION'); + expect(evaluation.candidateReason).toContain('사람이 확인해야'); + }); + + it('상환 계산 자동 승격이 꺼져 있으면 확정 조건이 있어도 상환 계산 대상이 아니다', async () => { + const confirmed = autoProduct({ + interestCondition: '연 3.0% 고정', + interestRateConfirmed: true, + interestRatePercent: '3', + repaymentCondition: '5년(거치 1년 포함) 원금균등', + repaymentMethod: 'EQUAL_PRINCIPAL', + repaymentTermMonths: 60, + repaymentGraceMonths: 12, + unsupportedCalculationReasons: [], + }); + const off = await evaluate(confirmed, automation()); + expect(off.candidateStatus).toBe('CURRENT_CANDIDATE'); + expect(off.repayment.supported).toBe(false); + expect(off.repayment.reasons.join(' ')).toContain('상환 계산 자동 승격'); + const on = await evaluate(confirmed, automation({ repaymentPromotion: true })); + expect(on.repayment.supported).toBe(true); + }); + + it('사람이 기록한 상품은 자동화 설정과 상관없이 지금 규칙을 따른다', async () => { + const evaluation = await evaluate( + rawProduct({ sourceRef: SOURCE_REF }), + automation({ policyLoanPromotion: false, sourceSyncs: [] }), + ); + expect(evaluation.candidateStatus).toBe('CURRENT_CANDIDATE'); + }); +}); + describe.skipIf(!process.env.TEST_DATABASE_URL)('PostgreSQL에서 활성 카탈로그 후보 조회', () => { let client: PrismaClient | null = null; let startedAt = new Date(0); diff --git a/app/src/features/funding/eligibility.ts b/app/src/features/funding/eligibility.ts index 2b12308..5b794ce 100644 --- a/app/src/features/funding/eligibility.ts +++ b/app/src/features/funding/eligibility.ts @@ -1,9 +1,19 @@ -import type { FundingCatalog, FundingProduct } from './schema.ts'; import { + defaultFundingAutomation, + sourceRefKey, + type FundingAutomation, + type FundingCatalog, + type FundingProduct, +} from './schema.ts'; +import { + FUNDING_SOURCE_LABELS, + POLICY_LOAN_SUPPORT_TYPES, REPAYABLE_SUPPORT_TYPES, + SOURCE_FRESHNESS_DAYS, combineVerdicts, compareIsoDates, INDUSTRY_CODE_PATTERN, + isAutoReviewer, isPrimaryRetrievalMethod, isSupportedRepaymentMethod, REQUIRED_EVIDENCE_SUBJECTS, @@ -330,11 +340,40 @@ export function hasPrimaryEvidence(product: FundingProduct): boolean { ); } +// 자동 수집 상품을 현재 후보로 올리지 못하게 막는 사유. 사람이 기록한 상품이면 null이다. +function automationHold(product: FundingProduct, automation: FundingAutomation): string | null { + if (!isAutoReviewer(product.reviewer) || product.sourceRef === undefined) return null; + const refKey = sourceRefKey(product.sourceRef); + const blocked = automation.blockedSources.find((entry) => sourceRefKey(entry) === refKey); + if (blocked) return `운영자가 이 공고의 자동 승격을 막았습니다: ${blocked.reason}`; + if (!POLICY_LOAN_SUPPORT_TYPES.includes(product.supportType)) { + return '지원사업은 자동으로 모아도 사람이 확인해야 현재 후보가 됩니다.'; + } + if (!automation.policyLoanPromotion) { + return '정책자금 자동 승격이 꺼져 있어 자동으로 모은 상품을 현재 후보로 표시하지 않습니다.'; + } + return null; +} + +// 상환 계산 자동 승격이 꺼져 있으면 자동 수집 상품의 조건으로 상환액을 계산하지 않는다. +function repaymentFor(product: FundingProduct, automation: FundingAutomation): ProductRepaymentAvailability { + const repayment = assessProductRepayment(product); + if (!repayment.supported || !isAutoReviewer(product.reviewer) || automation.repaymentPromotion) return repayment; + return { + supported: false, + publicLimitKrw: null, + terms: null, + reasons: ['상환 계산 자동 승격이 꺼져 있어 자동으로 모은 조건으로 상환액을 계산하지 않습니다.'], + note: '자동으로 모은 금리·기간·상환방식은 운영자가 상환 계산 자동 승격을 켠 뒤에만 계산에 씁니다.', + }; +} + export function evaluateProduct( product: FundingProduct, profile: FundingProfile, - options: { asOfDate: string }, + options: { asOfDate: string; automation?: FundingAutomation }, ): FundingCandidateEvaluation { + const automation = options.automation ?? defaultFundingAutomation(); const conditions = [ stageCondition(product, profile), regionCondition(product, profile), @@ -343,8 +382,10 @@ export function evaluateProduct( ]; const eligibilityVerdict = combineVerdicts(conditions.map((condition) => condition.verdict)); const audience = productAudience(product); - const state = reviewState(product, options.asOfDate); - const repayment = assessProductRepayment(product); + const state = reviewState(product, options.asOfDate, automation.sourceSyncs); + const automated = isAutoReviewer(product.reviewer); + const hold = automationHold(product, automation); + const repayment = repaymentFor(product, automation); const primaryEvidenceVerified = hasPrimaryEvidence(product); // 관측 접수 상태가 OPEN·UNKNOWN이어도 신청 종료일이 지나면 접수가 끝난 것이다. 종료일 당일까지는 접수 중으로 본다. const periodEnd = product.applicationPeriod.end; @@ -363,7 +404,12 @@ export function evaluateProduct( let candidateStatus: FundingCandidateStatus; let candidateReason: string; - if (state === 'REVIEW_OVERDUE') { + 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}일 넘게 지났거나 없어 재확인 전에는 현재 후보로 표시하지 않습니다.`; + } else if (state === 'REVIEW_OVERDUE') { candidateStatus = 'REVIEW_OVERDUE'; candidateReason = `다음 검토일 ${product.nextReviewAt ?? '없음'}이(가) 기준일 ${options.asOfDate} 이전이라 재확인 전에는 현재 후보로 표시하지 않습니다.`; } else if (product.observedApplicationStatus === 'CLOSED') { @@ -390,6 +436,9 @@ export function evaluateProduct( } else if (blockingUnknown) { candidateStatus = 'NEEDS_CONFIRMATION'; candidateReason = blockingUnknown.detail; + } else if (hold !== null) { + candidateStatus = 'NEEDS_CONFIRMATION'; + candidateReason = hold; } else if (product.observedApplicationStatus === 'OPEN') { candidateStatus = 'CURRENT_CANDIDATE'; candidateReason = @@ -430,7 +479,9 @@ export function evaluateCandidates( profile: FundingProfile, options: { asOfDate: string }, ): FundingCandidateList { - const evaluations = catalog.products.map((product) => evaluateProduct(product, profile, options)); + const evaluations = catalog.products.map((product) => + evaluateProduct(product, profile, { asOfDate: options.asOfDate, automation: catalog.automation }), + ); const summary = { total: evaluations.length, CURRENT_CANDIDATE: 0, diff --git a/app/src/features/funding/funding.test.ts b/app/src/features/funding/funding.test.ts index 6d6e6bf..db8e2dd 100644 --- a/app/src/features/funding/funding.test.ts +++ b/app/src/features/funding/funding.test.ts @@ -8,7 +8,7 @@ import { PrismaClient } from '../../generated/prisma/client.ts'; import { assessProductRepayment, evaluateCandidates, evaluateProduct, industryCodeIssue } from './eligibility.ts'; import { loadFundingCatalog, readFundingCatalogFile } from './loader.ts'; import { productVersionKey, type FundingCatalog } from './schema.ts'; -import { combineVerdicts, type Verdict } from './types.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION, combineVerdicts, type Verdict } from './types.ts'; import { FundingCatalogError, validateFundingCatalog, type FundingCatalogValidation } from './validation.ts'; const REAL_CATALOG_PATH = fileURLToPath(new URL('../../../catalog/funding/catalog.json', import.meta.url)); @@ -88,7 +88,7 @@ function catalog( ): Record { return { catalogKey: 'test-catalog', - schemaVersion: 'funding-catalog-v1.0.0', + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, catalogVersion: '2026-09-20.1', basisDate: REVIEW_DATE, reviewer: 'tester', @@ -337,6 +337,73 @@ describe('funding catalog schema and rules', () => { }); }); +describe('automated products in the catalog (ADR 0006)', () => { + const sourceRef = { source: 'SEMAS_OLS', externalId: 'test-fund' }; + const succeededSync = { + source: 'SEMAS_OLS', + status: 'SUCCEEDED', + attemptedAt: '2026-09-20T01:00:00.000Z', + lastSucceededOn: REVIEW_DATE, + fetchedCount: 1, + responseChecksum: null, + failureReason: null, + }; + const automation = (overrides: Record = {}) => ({ + policyLoanPromotion: false, + repaymentPromotion: false, + blockedSources: [], + sourceSyncs: [succeededSync], + ...overrides, + }); + + it('checks an automated product against source freshness instead of a next review date', () => { + const validation = validate( + catalog([product({ reviewer: AUTO_REVIEWER, sourceRef, nextReviewAt: null })], { automation: automation() }), + ); + expect(issueCodes(validation)).not.toContain('NEXT_REVIEW_AT_MISSING'); + expect(validation.ok).toBe(true); + expect(validation.reviewOverdue).toEqual([]); + + const stale = validate( + catalog([product({ reviewer: AUTO_REVIEWER, sourceRef, nextReviewAt: null })], { + automation: automation({ sourceSyncs: [] }), + }), + ); + expect(stale.ok).toBe(true); + expect(warningCodes(stale)).toContain('REVIEW_OVERDUE'); + expect(stale.reviewOverdue).toEqual(['test-loan@1.0.0']); + }); + + it('rejects an automated reviewer without a source reference', () => { + const validation = validate(catalog([product({ reviewer: AUTO_REVIEWER, nextReviewAt: null })])); + expect(issueCodes(validation)).toContain('AUTO_REVIEWER_WITHOUT_SOURCE'); + }); + + it('rejects two products that point at the same source notice', () => { + const validation = validate( + catalog([product({ sourceRef }), product({ productKey: 'test-loan-copy', sourceRef })]), + ); + expect(issueCodes(validation)).toContain('DUPLICATE_SOURCE_REF'); + }); + + it('rejects two sync results for the same source', () => { + const validation = validate( + catalog([product()], { automation: automation({ sourceSyncs: [succeededSync, succeededSync] }) }), + ); + expect(issueCodes(validation)).toContain('DUPLICATE_SOURCE_SYNC'); + }); + + it('defaults a catalog without an automation block to every switch off', () => { + const parsed = catalogFrom(validate(catalog([product()]))); + expect(parsed.automation).toEqual({ + policyLoanPromotion: false, + repaymentPromotion: false, + blockedSources: [], + sourceSyncs: [], + }); + }); +}); + 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); @@ -633,7 +700,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-23.1'; + const REAL_VERSION = '2026-09-28.1'; const REVIEWED_CATALOG_PATH = reviewedCatalogFile(); const db = (): PrismaClient => { diff --git a/app/src/features/funding/loader.ts b/app/src/features/funding/loader.ts index 4f27451..3056ab7 100644 --- a/app/src/features/funding/loader.ts +++ b/app/src/features/funding/loader.ts @@ -180,6 +180,7 @@ export async function loadFundingCatalog(options: FundingLoadOptions): Promise fundingProductSchema.parse(membership.productVersion.productJson), ); + const automation = + release.automation === null ? defaultFundingAutomation() : fundingAutomationSchema.parse(release.automation); return { releaseId: release.id, @@ -66,6 +78,7 @@ export async function findActiveFundingCatalog(prisma: PrismaClient): Promise; export type FundingCatalog = z.infer; +export type FundingAutomation = z.infer; +export type FundingSourceSync = z.infer; +export type FundingSourceRef = z.infer; + +export function sourceRefKey(ref: FundingSourceRef): string { + return `${ref.source}:${ref.externalId}`; +} export function productVersionKey(product: { productKey: string; version: string }): string { return `${product.productKey}@${product.version}`; } diff --git a/app/src/features/funding/sync.test.ts b/app/src/features/funding/sync.test.ts new file mode 100644 index 0000000..fd1bf34 --- /dev/null +++ b/app/src/features/funding/sync.test.ts @@ -0,0 +1,371 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +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 { findActiveFundingCatalog } from './read.ts'; +import { + nextPatchVersion, + productContentFingerprint, + runFundingSync, + type CollectedFundingProduct, + type FundingSourceAdapter, + type FundingSourceCollection, +} from './sync.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION } from './types.ts'; +import { FundingCatalogError } from './validation.ts'; + +const CREATED_DIRS: string[] = []; +const OLS_URL = 'https://ols.semas.or.kr/ols/man/SMAN018M/page.do'; + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'trendbench-sync-')); + CREATED_DIRS.push(dir); + return dir; +} + +function evidence(id: string, subject: string, observedAt: string): Record { + return { + id, + subject, + summary: '공식 페이지에서 해당 조건을 확인했습니다.', + sourceUrl: OLS_URL, + sourceDocumentName: '정책자금 한눈에 보기', + observedAt, + retrievalMethod: 'OFFICIAL_WEB_PAGE', + checksum: null, + }; +} + +// 수집기가 만드는 모양 그대로의 정책자금 상품. 버전과 검수자는 동기화가 정한다. +function collected(externalId: string, observedAt: string, overrides: Record = {}) { + return { + productKey: `test-sync-${externalId}`, + name: `테스트 정책자금 ${externalId}`, + organization: '소상공인시장진흥공단', + supportType: 'LOAN', + eligibleBusinessStages: ['POST_REGISTRATION'], + region: { scope: 'NATIONWIDE', districtCodes: [], note: null }, + purpose: { included: ['OPERATING_FUNDS'], excluded: [], note: null }, + industryConditions: { scope: 'UNKNOWN', included: [], excluded: [], note: '업종 조건은 공고 본문에서 확인합니다.' }, + applicationPeriod: { start: null, end: null, note: '예산 소진 시까지 접수합니다.' }, + observedApplicationStatus: 'OPEN', + observedAt, + reviewedAt: observedAt, + nextReviewAt: null, + officialUrl: OLS_URL, + sourceDocumentName: '정책자금 한눈에 보기', + sourceDocumentRetrieved: false, + sourceChecksum: null, + publicLimit: '70000000', + interestCondition: '정책자금 기준금리 + 0.6%p', + interestRateConfirmed: false, + interestRatePercent: null, + repaymentCondition: '5년(거치 2년 포함)', + repaymentMethod: 'UNKNOWN', + repaymentTermMonths: null, + repaymentGraceMonths: null, + unsupportedCalculationReasons: ['금리가 기준금리 연동이라 확정 숫자가 아닙니다.'], + additionalChecks: [], + evidence: [ + evidence('identity', 'IDENTITY', observedAt), + evidence('period', 'APPLICATION_PERIOD', observedAt), + evidence('stage', 'BUSINESS_STAGE', observedAt), + evidence('region', 'REGION', observedAt), + evidence('purpose', 'PURPOSE', observedAt), + evidence('financial', 'FINANCIAL_CONDITION', observedAt), + ], + sourceRef: { source: 'SEMAS_OLS', externalId }, + ...overrides, + } as unknown as CollectedFundingProduct; +} + +function adapter(result: () => FundingSourceCollection): FundingSourceAdapter { + return { source: 'SEMAS_OLS', collect: async () => result() }; +} + +function collection(products: CollectedFundingProduct[], fetchedCount = 10): FundingSourceCollection { + return { products, fetchedCount, responseChecksum: 'a'.repeat(64) }; +} + +// 사람이 관리하는 카탈로그. 자동 상품과 겹치지 않는 상품 하나를 둔다. +function manualCatalogFile(products: Record[] = [manualProduct()]): string { + const path = join(tempDir(), 'catalog.json'); + writeFileSync( + path, + JSON.stringify({ + catalogKey: 'test-sync-catalog', + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + catalogVersion: '2026-09-20.1', + basisDate: '2026-09-20', + reviewer: 'tester', + notes: [], + automation: { policyLoanPromotion: true, repaymentPromotion: false, blockedSources: [], sourceSyncs: [] }, + products, + }), + 'utf8', + ); + return path; +} + +function manualProduct(overrides: Record = {}): Record { + const product = collected('manual', '2026-09-20') as unknown as Record; + delete product.sourceRef; + return { + ...product, + productKey: 'test-sync-manual', + version: '1.0.0', + name: '사람이 검수한 테스트 상품', + reviewer: 'tester', + nextReviewAt: '2099-01-01', + ...overrides, + }; +} + +afterAll(() => { + for (const dir of CREATED_DIRS) rmSync(dir, { recursive: true, force: true }); +}); + +describe('상품 버전 재사용 규칙', () => { + it('날짜와 근거 checksum만 다른 상품은 같은 내용으로 본다', () => { + const first = collected('fund-a', '2026-09-20'); + const later = collected('fund-a', '2026-09-21', { + evidence: [ + { ...evidence('identity', 'IDENTITY', '2026-09-21'), checksum: 'b'.repeat(64) }, + evidence('period', 'APPLICATION_PERIOD', '2026-09-21'), + evidence('stage', 'BUSINESS_STAGE', '2026-09-21'), + evidence('region', 'REGION', '2026-09-21'), + evidence('purpose', 'PURPOSE', '2026-09-21'), + evidence('financial', 'FINANCIAL_CONDITION', '2026-09-21'), + ], + }); + expect(productContentFingerprint({ ...first, reviewer: AUTO_REVIEWER })).toBe( + productContentFingerprint({ ...later, reviewer: AUTO_REVIEWER }), + ); + const changed = collected('fund-a', '2026-09-20', { publicLimit: '50000000' }); + expect(productContentFingerprint({ ...changed, reviewer: AUTO_REVIEWER })).not.toBe( + productContentFingerprint({ ...first, reviewer: AUTO_REVIEWER }), + ); + }); + + it('가장 높은 버전의 PATCH를 올린다', () => { + expect(nextPatchVersion([])).toBe('1.0.0'); + expect(nextPatchVersion(['1.0.0', '1.0.2', '1.0.1'])).toBe('1.0.3'); + expect(nextPatchVersion(['1.0.9', '1.1.0'])).toBe('1.1.1'); + }); +}); + +describe.skipIf(!process.env.TEST_DATABASE_URL)('funding:sync와 PostgreSQL', () => { + let client: PrismaClient | null = null; + let startedAt = new Date(0); + let previousActiveId: string | null = null; + + const db = (): PrismaClient => { + if (!client) throw new Error('PrismaClient가 초기화되지 않았습니다.'); + return client; + }; + + const sync = (options: { + asOfDate: string; + result: () => FundingSourceCollection; + catalogPath?: string; + dryRun?: boolean; + }) => + runFundingSync({ + prisma: db(), + catalogPath: options.catalogPath ?? manualCatalogFile(), + adapters: [adapter(options.result)], + asOfDate: options.asOfDate, + outputDir: tempDir(), + dryRun: options.dryRun, + now: () => new Date(`${options.asOfDate}T01:00:00.000Z`), + }); + + beforeAll(async () => { + client = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.TEST_DATABASE_URL }) }); + const stale = await db().fundingCatalogRelease.findMany({ + where: { catalogKey: { startsWith: 'test-' } }, + select: { id: true }, + }); + if (stale.length > 0) { + const staleIds = stale.map((release) => release.id); + await db().fundingCatalogProduct.deleteMany({ where: { catalogReleaseId: { in: staleIds } } }); + await db().fundingCatalogRelease.deleteMany({ where: { id: { in: staleIds } } }); + } + await db().fundingProductVersion.deleteMany({ where: { productKey: { startsWith: 'test-' } } }); + const baseline = await db().fundingCatalogRelease.findFirst({ + where: { catalogKey: { not: { startsWith: 'test-' } }, status: { in: ['ACTIVE', 'SUPERSEDED'] } }, + orderBy: { activatedAt: 'desc' }, + }); + if (baseline) { + await db().fundingCatalogRelease.updateMany({ where: { status: 'ACTIVE' }, data: { status: 'SUPERSEDED' } }); + await db().fundingCatalogRelease.update({ where: { id: baseline.id }, data: { status: 'ACTIVE' } }); + } + startedAt = new Date(); + previousActiveId = (await db().fundingCatalogRelease.findFirst({ where: { status: 'ACTIVE' } }))?.id ?? null; + }); + + afterAll(async () => { + 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-' } } }); + if (previousActiveId) { + const previous = await db().fundingCatalogRelease.findUnique({ where: { id: previousActiveId } }); + if (previous && previous.status !== 'ACTIVE') { + await db().fundingCatalogRelease.updateMany({ where: { status: 'ACTIVE' }, data: { status: 'SUPERSEDED' } }); + await db().fundingCatalogRelease.update({ where: { id: previousActiveId }, data: { status: 'ACTIVE' } }); + } + } + await db().$disconnect(); + }); + + it('출처 상품을 사람이 관리하는 상품과 합쳐 ACTIVE로 적재한다', async () => { + const report = await sync({ + asOfDate: '2026-09-20', + result: () => collection([collected('fund-a', '2026-09-20'), collected('fund-b', '2026-09-20')]), + }); + expect(report.load?.outcome).toBe('ACTIVATED'); + // 사람이 관리하는 카탈로그의 버전(2026-09-20.1)과 겹치지 않게 번호를 올린다. + expect(report.catalogVersion).toBe('2026-09-20.2'); + expect(report.sources).toMatchObject([ + { source: 'SEMAS_OLS', status: 'SUCCEEDED', lastSucceededOn: '2026-09-20', fetchedCount: 10, productCount: 2 }, + ]); + + const active = await findActiveFundingCatalog(db()); + expect(active?.catalogKey).toBe('test-sync-catalog'); + expect(active?.schemaVersion).toBe(FUNDING_CATALOG_SCHEMA_VERSION); + expect(active?.catalog.products.map((product) => `${product.productKey}@${product.version}`)).toEqual([ + 'test-sync-manual@1.0.0', + 'test-sync-fund-a@1.0.0', + 'test-sync-fund-b@1.0.0', + ]); + expect(active?.catalog.products[1].reviewer).toBe(AUTO_REVIEWER); + expect(active?.automation.policyLoanPromotion).toBe(true); + expect(active?.automation.sourceSyncs).toMatchObject([ + { source: 'SEMAS_OLS', status: 'SUCCEEDED', lastSucceededOn: '2026-09-20', failureReason: null }, + ]); + }); + + it('내용이 같은 상품은 버전을 재사용하고, 바뀐 상품은 새 버전을 만든다', async () => { + const report = await sync({ + asOfDate: '2026-09-21', + result: () => + collection([collected('fund-a', '2026-09-21'), collected('fund-b', '2026-09-21', { publicLimit: '50000000' })]), + }); + expect(report.catalogVersion).toBe('2026-09-21.1'); + 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'); + expect(byKey.get('test-sync-fund-a')?.observedAt).toBe('2026-09-20'); + expect(byKey.get('test-sync-fund-b')?.version).toBe('1.0.1'); + expect(byKey.get('test-sync-fund-b')?.publicLimit).toBe('50000000'); + expect(active?.automation.sourceSyncs[0].lastSucceededOn).toBe('2026-09-21'); + }); + + it('출처가 실패하면 직전 상품과 마지막 성공일을 그대로 두고 실패 사유를 남긴다', async () => { + const report = await sync({ + asOfDate: '2026-09-22', + result: () => { + throw new Error('출처 응답 403'); + }, + }); + expect(report.load?.outcome).toBe('ACTIVATED'); + expect(report.sources[0]).toMatchObject({ + status: 'FAILED', + lastSucceededOn: '2026-09-21', + fetchedCount: 10, + carriedOver: true, + productCount: 2, + }); + expect(report.sources[0].failureReason).toContain('출처 응답 403'); + const active = await findActiveFundingCatalog(db()); + expect(active?.catalog.products.map((product) => `${product.productKey}@${product.version}`)).toEqual([ + 'test-sync-manual@1.0.0', + 'test-sync-fund-a@1.0.0', + 'test-sync-fund-b@1.0.1', + ]); + expect(active?.automation.sourceSyncs[0]).toMatchObject({ status: 'FAILED', lastSucceededOn: '2026-09-21' }); + }); + + it('빈 응답과 직전 성공 대비 절반 미만의 공고 수는 실패로 기록한다', async () => { + const empty = await sync({ asOfDate: '2026-09-23', result: () => collection([], 0) }); + expect(empty.sources[0].status).toBe('FAILED'); + expect(empty.sources[0].failureReason).toContain('공고 없음'); + + const dropped = await sync({ + asOfDate: '2026-09-23', + result: () => collection([collected('fund-a', '2026-09-23')], 4), + }); + expect(dropped.catalogVersion).toBe('2026-09-23.2'); + expect(dropped.sources[0].status).toBe('FAILED'); + expect(dropped.sources[0].failureReason).toContain('절반 미만'); + expect(dropped.sources[0].productCount).toBe(2); + }); + + it('검증을 통과하지 못한 상품이 있으면 그 출처만 실패로 기록한다', async () => { + const report = await sync({ + asOfDate: '2026-09-24', + result: () => + collection([ + collected('fund-a', '2026-09-24'), + collected('fund-b', '2026-09-24', { applicationPeriod: { start: null, end: null, note: null } }), + ]), + }); + expect(report.load?.outcome).toBe('ACTIVATED'); + expect(report.sources[0].status).toBe('FAILED'); + expect(report.sources[0].failureReason).toContain('APPLICATION_PERIOD_NOTE_MISSING'); + expect(report.manualProductCount).toBe(1); + expect(report.automatedProductCount).toBe(2); + }); + + it('사람이 기록한 상품이 같은 공고를 가리키면 자동 상품을 뺀다', async () => { + const catalogPath = manualCatalogFile([ + manualProduct(), + manualProduct({ + productKey: 'test-sync-manual-fund-a', + sourceRef: { source: 'SEMAS_OLS', externalId: 'fund-a' }, + }), + ]); + const report = await sync({ + asOfDate: '2026-09-25', + catalogPath, + result: () => collection([collected('fund-a', '2026-09-25'), collected('fund-b', '2026-09-25')]), + }); + expect(report.sources[0].status).toBe('SUCCEEDED'); + expect(report.overriddenByManual).toEqual(['SEMAS_OLS:fund-a']); + const active = await findActiveFundingCatalog(db()); + expect(active?.catalog.products.map((product) => product.productKey)).toEqual([ + 'test-sync-manual', + 'test-sync-manual-fund-a', + 'test-sync-fund-b', + ]); + }); + + it('dry run은 합친 카탈로그를 쓰고 검증만 하며 적재하지 않는다', async () => { + const before = await db().fundingCatalogRelease.count(); + const report = await sync({ + asOfDate: '2026-09-26', + dryRun: true, + result: () => collection([collected('fund-a', '2026-09-26'), collected('fund-b', '2026-09-26')]), + }); + expect(report.load).toBeNull(); + expect(existsSync(report.catalogFile)).toBe(true); + expect(await db().fundingCatalogRelease.count()).toBe(before); + }); + + it('사람이 관리하는 카탈로그에 자동 검수자 상품이 있으면 거부한다', async () => { + const catalogPath = manualCatalogFile([ + manualProduct(), + manualProduct({ + productKey: 'test-sync-manual-auto', + reviewer: AUTO_REVIEWER, + nextReviewAt: null, + sourceRef: { source: 'SEMAS_OLS', externalId: 'fund-z' }, + }), + ]); + await expect( + sync({ asOfDate: '2026-09-27', catalogPath, result: () => collection([collected('fund-a', '2026-09-27')]) }), + ).rejects.toBeInstanceOf(FundingCatalogError); + }); +}); diff --git a/app/src/features/funding/sync.ts b/app/src/features/funding/sync.ts new file mode 100644 index 0000000..2c14515 --- /dev/null +++ b/app/src/features/funding/sync.ts @@ -0,0 +1,339 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { PrismaClient } from '../../generated/prisma/client.ts'; +import { canonicalJson } from './canonical.ts'; +import { loadFundingCatalog, readFundingCatalogFile, type FundingLoadReport } from './loader.ts'; +import { findActiveFundingCatalog } from './read.ts'; +import { + fundingProductSchema, + sourceRefKey, + type FundingCatalog, + type FundingProduct, + type FundingSourceRef, + type FundingSourceSync, +} from './schema.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION, isAutoReviewer, type FundingSource } from './types.ts'; +import { FundingCatalogError, validateFundingCatalog } from './validation.ts'; + +// 출처별 수집기가 만든 상품. 상품 버전과 검수자는 동기화가 정한다(ADR 0006 2절). +export type CollectedFundingProduct = Omit & { + sourceRef: FundingSourceRef; +}; + +export type FundingSourceCollection = { + products: CollectedFundingProduct[]; + // 출처에서 받은 공고·상품 수. 직전 성공 대비 급감 판정에 쓴다. + fetchedCount: number; + // 이번 수집에서 받은 응답 전체의 SHA-256. + responseChecksum: string; +}; + +export type FundingSourceAdapter = { + source: FundingSource; + collect(context: { asOfDate: string; log: (message: string) => void }): Promise; +}; + +export type FundingSyncOptions = { + prisma: PrismaClient; + // 사람이 관리하는 카탈로그(app/catalog/funding/catalog.json). + catalogPath: string; + adapters: readonly FundingSourceAdapter[]; + // 판정 기준일(한국 시간). 운영 명령은 서버의 한국 시간 날짜를 넘긴다. + asOfDate: string; + // 합친 카탈로그 파일을 쓰는 폴더. 적재한 릴리스의 원본으로 남는다. + outputDir: string; + // true면 합친 카탈로그를 쓰고 검증만 하며 적재하지 않는다. + dryRun?: boolean; + now?: () => Date; + log?: (message: string) => void; +}; + +export type FundingSourceSyncReport = FundingSourceSync & { + // 이번 릴리스에 들어간 이 출처의 자동 상품 수. + productCount: number; + // 실패해서 직전 ACTIVE 릴리스의 상품을 그대로 가져왔는지. + carriedOver: boolean; +}; + +export type FundingSyncReport = { + catalogFile: string; + catalogKey: string; + catalogVersion: string; + sources: FundingSourceSyncReport[]; + manualProductCount: number; + automatedProductCount: number; + // 사람이 기록한 상품이 같은 공고를 가리켜 빠진 자동 상품의 출처 식별자. + overriddenByManual: string[]; + // 적재 결과. dryRun이면 null이다. + load: FundingLoadReport | null; +}; + +class SourceSyncFailure extends Error {} + +// 날짜처럼 동기화마다 바뀌는 값을 뺀 상품 내용. 이 값이 같으면 이미 적재된 불변 상품 버전을 재사용해 +// 상품 버전이 날마다 늘지 않게 한다. +export function productContentFingerprint(product: Omit | FundingProduct): string { + const content: Record = { ...product }; + delete content.version; + delete content.observedAt; + delete content.reviewedAt; + delete content.nextReviewAt; + content.evidence = product.evidence.map((entry) => { + const stable: Record = { ...entry }; + delete stable.observedAt; + delete stable.checksum; + return stable; + }); + return canonicalJson(content); +} + +// 가장 높은 MAJOR.MINOR.PATCH의 PATCH를 하나 올린다. 버전이 없으면 1.0.0이다. +export function nextPatchVersion(versions: readonly string[]): string { + const parsed = versions + .map((version) => version.split('.').map((part) => Number(part))) + .filter((parts) => parts.length === 3 && parts.every((part) => Number.isInteger(part) && part >= 0)); + if (parsed.length === 0) return '1.0.0'; + parsed.sort((left, right) => left[0] - right[0] || left[1] - right[1] || left[2] - right[2]); + const [major, minor, patch] = parsed[parsed.length - 1]; + return `${major}.${minor}.${patch + 1}`; +} + +async function assignVersion(prisma: PrismaClient, product: Omit): Promise { + const stored = await prisma.fundingProductVersion.findMany({ + where: { productKey: product.productKey }, + select: { version: true, productJson: true }, + }); + const fingerprint = productContentFingerprint(product); + for (const row of stored) { + const parsed = fundingProductSchema.safeParse(row.productJson); + if (parsed.success && productContentFingerprint(parsed.data) === fingerprint) return parsed.data; + } + return fundingProductSchema.parse({ ...product, version: nextPatchVersion(stored.map((row) => row.version)) }); +} + +// 같은 날 여러 번 동기화해도 catalogKey + catalogVersion이 겹치지 않게 날짜 뒤 번호를 올린다. +// 사람이 관리하는 카탈로그의 버전도 피해서, 그 파일을 funding:load로 적재할 때 버전이 충돌하지 않게 한다. +async function nextCatalogVersion(prisma: PrismaClient, manual: FundingCatalog, asOfDate: string): Promise { + const releases = await prisma.fundingCatalogRelease.findMany({ + where: { catalogKey: manual.catalogKey, catalogVersion: { startsWith: `${asOfDate}.` } }, + select: { catalogVersion: true }, + }); + const used = [...releases.map((release) => release.catalogVersion), manual.catalogVersion] + .filter((version) => version.startsWith(`${asOfDate}.`)) + .map((version) => Number(version.slice(asOfDate.length + 1))) + .filter((value) => Number.isInteger(value)); + return `${asOfDate}.${Math.max(0, ...used) + 1}`; +} + +function describeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.slice(0, 2000); +} + +async function collectSource( + adapter: FundingSourceAdapter, + context: { + prisma: PrismaClient; + manual: FundingCatalog; + previousSync: FundingSourceSync | null; + asOfDate: string; + log: (message: string) => void; + }, +): Promise<{ products: FundingProduct[]; fetchedCount: number; responseChecksum: string }> { + const collection = await adapter.collect({ asOfDate: context.asOfDate, log: context.log }); + if (collection.fetchedCount === 0 || collection.products.length === 0) { + throw new SourceSyncFailure( + '출처에서 받은 공고가 없습니다. 빈 응답은 "공고 없음"으로 해석하지 않고 실패로 기록합니다.', + ); + } + const previousCount = context.previousSync?.fetchedCount ?? null; + if (previousCount !== null && collection.fetchedCount * 2 < previousCount) { + throw new SourceSyncFailure( + `받은 공고 수 ${collection.fetchedCount}건이 직전 성공 ${previousCount}건의 절반 미만입니다. 급감은 실패로 기록합니다.`, + ); + } + const manualKeys = new Set(context.manual.products.map((product) => product.productKey)); + const refs = new Set(); + for (const product of collection.products) { + if (product.sourceRef.source !== adapter.source) { + throw new SourceSyncFailure( + `${adapter.source} 수집기가 다른 출처(${product.sourceRef.source})의 상품을 만들었습니다.`, + ); + } + if (manualKeys.has(product.productKey)) { + throw new SourceSyncFailure(`자동 상품 키 ${product.productKey}가 사람이 기록한 상품 키와 겹칩니다.`); + } + const refKey = sourceRefKey(product.sourceRef); + if (refs.has(refKey)) throw new SourceSyncFailure(`같은 공고(${refKey})가 두 번 수집되었습니다.`); + refs.add(refKey); + } + + const products: FundingProduct[] = []; + for (const product of collection.products) { + products.push(await assignVersion(context.prisma, { ...product, reviewer: AUTO_REVIEWER })); + } + + // 수집한 상품만으로 카탈로그 검증을 먼저 돌린다. 파서가 필수 항목을 못 찾은 상품이 있으면 + // 그 출처만 실패로 기록하고, 사람이 관리하는 상품과 다른 출처는 그대로 적재한다. + const validation = validateFundingCatalog( + { + catalogKey: context.manual.catalogKey, + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + catalogVersion: `${context.asOfDate}.1`, + basisDate: context.asOfDate, + reviewer: context.manual.reviewer, + notes: [], + automation: context.manual.automation, + products, + }, + { asOfDate: context.asOfDate }, + ); + if (!validation.ok) { + const issues = validation.issues + .slice(0, 5) + .map((issue) => `[${issue.code}] ${issue.source}: ${issue.detail}`) + .join(' / '); + throw new SourceSyncFailure(`수집한 상품이 카탈로그 검증을 통과하지 못했습니다: ${issues}`); + } + return { products, fetchedCount: collection.fetchedCount, responseChecksum: collection.responseChecksum }; +} + +// 출처별로 공고를 모아 사람이 관리하는 카탈로그와 합치고, 기존 적재기로 새 릴리스를 적재한다(ADR 0006). +// 한 출처가 실패하면 그 출처의 상품은 직전 ACTIVE 릴리스의 것을 그대로 가져오고 마지막 성공일도 +// 그대로 둔다. 그 상품은 마지막 성공일에서 SOURCE_FRESHNESS_DAYS가 지나면 판정에서 현재 후보가 아니다. +export async function runFundingSync(options: FundingSyncOptions): Promise { + const log = options.log ?? (() => {}); + const now = options.now ?? (() => new Date()); + const { prisma, asOfDate } = options; + + const manual = readFundingCatalogFile(options.catalogPath, asOfDate).catalog; + const automatedInManual = manual.products.filter((product) => isAutoReviewer(product.reviewer)); + if (automatedInManual.length > 0) { + throw new FundingCatalogError( + 'MANUAL_CATALOG_HAS_AUTOMATED_PRODUCT', + `사람이 관리하는 카탈로그에 자동 검수자 상품이 있습니다: ${automatedInManual.map((product) => product.productKey).join(', ')}. 자동 상품은 funding:sync만 만듭니다.`, + ); + } + const seenSources = new Set(); + for (const adapter of options.adapters) { + if (seenSources.has(adapter.source)) { + throw new FundingCatalogError( + 'DUPLICATE_SOURCE_ADAPTER', + `출처 ${adapter.source}의 수집기가 두 번 등록되었습니다.`, + ); + } + seenSources.add(adapter.source); + } + + const previous = await findActiveFundingCatalog(prisma); + const sources: FundingSourceSyncReport[] = []; + const automatedProducts: FundingProduct[] = []; + for (const adapter of options.adapters) { + const previousSync = previous?.automation.sourceSyncs.find((entry) => entry.source === adapter.source) ?? null; + const attemptedAt = now().toISOString(); + try { + const collected = await collectSource(adapter, { prisma, manual, previousSync, asOfDate, log }); + automatedProducts.push(...collected.products); + sources.push({ + source: adapter.source, + status: 'SUCCEEDED', + attemptedAt, + lastSucceededOn: asOfDate, + fetchedCount: collected.fetchedCount, + responseChecksum: collected.responseChecksum, + failureReason: null, + productCount: collected.products.length, + carriedOver: false, + }); + 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); + sources.push({ + source: adapter.source, + status: 'FAILED', + attemptedAt, + lastSucceededOn: previousSync?.lastSucceededOn ?? null, + fetchedCount: previousSync?.fetchedCount ?? null, + responseChecksum: previousSync?.responseChecksum ?? null, + failureReason: describeError(error), + productCount: carried.length, + carriedOver: true, + }); + log( + `출처 ${adapter.source} 동기화 실패: ${describeError(error)} 직전 ACTIVE 릴리스의 상품 ${carried.length}건을 그대로 둡니다.`, + ); + } + } + + // 사람이 기록한 상품이 같은 공고를 가리키면 사람 기록이 우선한다. + const manualRefs = new Set( + manual.products.flatMap((product) => (product.sourceRef === undefined ? [] : [sourceRefKey(product.sourceRef)])), + ); + const overriddenByManual: string[] = []; + const keptAutomated = automatedProducts + .filter((product) => { + const refKey = product.sourceRef === undefined ? null : sourceRefKey(product.sourceRef); + if (refKey !== null && manualRefs.has(refKey)) { + overriddenByManual.push(refKey); + return false; + } + return true; + }) + .sort((left, right) => (left.productKey < right.productKey ? -1 : left.productKey > right.productKey ? 1 : 0)); + + const catalogVersion = await nextCatalogVersion(prisma, manual, asOfDate); + const composed: FundingCatalog = { + catalogKey: manual.catalogKey, + schemaVersion: FUNDING_CATALOG_SCHEMA_VERSION, + catalogVersion, + basisDate: asOfDate, + reviewer: manual.reviewer, + notes: [ + ...manual.notes, + `funding:sync가 ${asOfDate}에 사람이 관리하는 카탈로그 ${manual.catalogVersion}과 자동 상품 ${keptAutomated.length}건을 합쳤습니다.`, + ], + automation: { + ...manual.automation, + sourceSyncs: sources.map((entry) => ({ + source: entry.source, + status: entry.status, + attemptedAt: entry.attemptedAt, + lastSucceededOn: entry.lastSucceededOn, + fetchedCount: entry.fetchedCount, + responseChecksum: entry.responseChecksum, + failureReason: entry.failureReason, + })), + }, + products: [...manual.products, ...keptAutomated], + }; + + mkdirSync(options.outputDir, { recursive: true }); + const catalogFile = join(options.outputDir, `${composed.catalogKey}@${catalogVersion}.json`); + writeFileSync(catalogFile, `${JSON.stringify(composed, null, 2)}\n`, 'utf8'); + log(`합친 카탈로그를 썼습니다: ${catalogFile}`); + + let load: FundingLoadReport | null = null; + if (options.dryRun) { + // 적재와 같은 검사만 돌린다. 통과하지 못하면 FundingCatalogError를 던진다. + readFundingCatalogFile(catalogFile, asOfDate); + log('dry run: 검증만 하고 적재하지 않았습니다.'); + } else { + load = await loadFundingCatalog({ catalogPath: catalogFile, prisma, asOfDate, log }); + } + + return { + catalogFile, + catalogKey: composed.catalogKey, + catalogVersion, + sources, + manualProductCount: manual.products.length, + automatedProductCount: keptAutomated.length, + overriddenByManual, + load, + }; +} diff --git a/app/src/features/funding/types.ts b/app/src/features/funding/types.ts index ee1783a..5af37ed 100644 --- a/app/src/features/funding/types.ts +++ b/app/src/features/funding/types.ts @@ -2,13 +2,41 @@ // stored as-is in the JSON catalog and mirrored by the Prisma enums, so a name // change here is a catalog schema change too. -export const FUNDING_CATALOG_SCHEMA_VERSION = 'funding-catalog-v1.0.0'; +export const FUNDING_CATALOG_SCHEMA_VERSION = 'funding-catalog-v1.1.0'; + +// 이미 적재된 릴리스를 읽을 수 있는 스키마 버전. v1.1.0은 v1.0.0에 출처 식별자와 +// 자동화 설정을 더했을 뿐이라 v1.0.0 릴리스도 그대로 읽는다. +export const READABLE_FUNDING_CATALOG_SCHEMA_VERSIONS: readonly string[] = [ + 'funding-catalog-v1.0.0', + FUNDING_CATALOG_SCHEMA_VERSION, +]; // A product whose reviewer is still unassigned is kept in the catalog but the // operator commands warn: a real person must own the next review before the // catalog drives anything user-facing. export const REVIEWER_UNASSIGNED = 'UNASSIGNED'; +// funding:sync가 공식 출처에서 모은 상품의 검수자. 사람 대신 자동 규칙 버전을 적고, +// 규칙을 바꾸면 버전을 올린다(ADR 0006). +export const AUTO_REVIEWER_PREFIX = 'AUTO:'; +export const FUNDING_AUTOMATION_RULES_VERSION = 'funding-auto-v1'; +export const AUTO_REVIEWER = `${AUTO_REVIEWER_PREFIX}${FUNDING_AUTOMATION_RULES_VERSION}`; + +export function isAutoReviewer(reviewer: string): boolean { + return reviewer.startsWith(AUTO_REVIEWER_PREFIX); +} + +// funding:sync가 상품을 모으는 공식 출처. +export const FUNDING_SOURCES = ['SEMAS_OLS'] as const; +export type FundingSource = (typeof FUNDING_SOURCES)[number]; + +export const FUNDING_SOURCE_LABELS: Record = { + SEMAS_OLS: '소상공인시장진흥공단 정책자금 사이트', +}; + +// 출처의 마지막 성공 동기화가 이 일수를 넘기면 그 출처의 자동 상품은 현재 후보에서 빠진다. +export const SOURCE_FRESHNESS_DAYS = 3; + export const SUPPORT_TYPES = ['GRANT', 'GUARANTEE', 'LOAN', 'SPACE', 'PROGRAM'] as const; export type SupportType = (typeof SUPPORT_TYPES)[number]; @@ -16,6 +44,10 @@ export type SupportType = (typeof SUPPORT_TYPES)[number]; // repay the same way, so only LOAN products can ever reach repayment math. export const REPAYABLE_SUPPORT_TYPES: readonly SupportType[] = ['LOAN']; +// 정책자금 갈래(대출·보증). ADR 0006에 따라 자동 승격은 이 갈래에만 쓴다. 지원금·공간· +// 프로그램(지원사업)은 자동으로 모아도 사람이 확인해야 현재 후보가 된다. +export const POLICY_LOAN_SUPPORT_TYPES: readonly SupportType[] = ['LOAN', 'GUARANTEE']; + export const BUSINESS_STAGES = ['PRE_REGISTRATION', 'POST_REGISTRATION'] as const; export type BusinessStage = (typeof BUSINESS_STAGES)[number]; @@ -154,14 +186,44 @@ export function compareIsoDates(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } +export function addDaysToIsoDate(isoDate: string, days: number): string { + const [year, month, day] = isoDate.split('-').map((part) => Number(part)); + return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10); +} + export type ReviewState = 'CURRENT' | 'UNREVIEWED' | 'REVIEW_OVERDUE'; +// 출처별 마지막 성공 동기화일. 자동 상품의 최신성은 사람이 정한 다음 검토일 대신 이 날짜로 판단한다. +export type SourceFreshness = { source: FundingSource; lastSucceededOn: string | null }; + +// 자동 상품은 출처의 마지막 성공 동기화가 SOURCE_FRESHNESS_DAYS 안이어야 최신이다. +// 동기화 기록이 없거나 성공한 적이 없으면 최신이 아니다. +export function isSourceFresh( + source: FundingSource, + sourceSyncs: readonly SourceFreshness[], + asOfDate: string, +): boolean { + const sync = sourceSyncs.find((entry) => entry.source === source); + if (!sync || sync.lastSucceededOn === null) return false; + return compareIsoDates(addDaysToIsoDate(sync.lastSucceededOn, SOURCE_FRESHNESS_DAYS), asOfDate) >= 0; +} + // A product with no review date at all is treated as overdue: it can never be -// presented as currently applicable. +// presented as currently applicable. 자동 상품은 다음 검토일 대신 출처 최신성으로 판단한다. export function reviewState( - product: { reviewer: string; reviewedAt: string | null; nextReviewAt: string | null }, + product: { + reviewer: string; + reviewedAt: string | null; + nextReviewAt: string | null; + sourceRef?: { source: FundingSource } | undefined; + }, asOfDate: string, + sourceSyncs: readonly SourceFreshness[] = [], ): ReviewState { + if (isAutoReviewer(product.reviewer)) { + if (product.reviewedAt === null || product.sourceRef === undefined) return 'REVIEW_OVERDUE'; + return isSourceFresh(product.sourceRef.source, sourceSyncs, asOfDate) ? 'CURRENT' : 'REVIEW_OVERDUE'; + } if (product.reviewedAt === null || product.nextReviewAt === null) return 'REVIEW_OVERDUE'; if (compareIsoDates(product.nextReviewAt, asOfDate) < 0) return 'REVIEW_OVERDUE'; return product.reviewer === REVIEWER_UNASSIGNED ? 'UNREVIEWED' : 'CURRENT'; diff --git a/app/src/features/funding/validation.ts b/app/src/features/funding/validation.ts index 58a0de3..2e3afe4 100644 --- a/app/src/features/funding/validation.ts +++ b/app/src/features/funding/validation.ts @@ -3,13 +3,22 @@ import { REPAYABLE_SUPPORT_TYPES, REVIEWER_UNASSIGNED, REQUIRED_EVIDENCE_SUBJECTS, + SOURCE_FRESHNESS_DAYS, compareIsoDates, + isAutoReviewer, isSupportedRepaymentMethod, isPrimaryRetrievalMethod, reviewState, type EvidenceSubject, + type SourceFreshness, } from './types.ts'; -import { fundingCatalogSchema, productVersionKey, type FundingCatalog, type FundingProduct } from './schema.ts'; +import { + fundingCatalogSchema, + productVersionKey, + sourceRefKey, + type FundingCatalog, + type FundingProduct, +} from './schema.ts'; export type FundingIssueCode = | 'SCHEMA_INVALID' @@ -34,6 +43,9 @@ export type FundingIssueCode = | 'REVIEW_PERIOD_CONTRADICTION' | 'REVIEW_OVERDUE' | 'REVIEWER_UNASSIGNED' + | 'AUTO_REVIEWER_WITHOUT_SOURCE' + | 'DUPLICATE_SOURCE_REF' + | 'DUPLICATE_SOURCE_SYNC' | 'REGION_DISTRICT_CONTRADICTION' | 'REGION_DISTRICTS_MISSING' | 'INDUSTRY_SCOPE_CONTRADICTION' @@ -100,6 +112,9 @@ const CATEGORY_BY_CODE: Record = { REVIEW_PERIOD_CONTRADICTION: 'review', REVIEW_OVERDUE: 'review', REVIEWER_UNASSIGNED: 'review', + AUTO_REVIEWER_WITHOUT_SOURCE: 'review', + DUPLICATE_SOURCE_REF: 'duplicates', + DUPLICATE_SOURCE_SYNC: 'duplicates', REGION_DISTRICT_CONTRADICTION: 'support', REGION_DISTRICTS_MISSING: 'support', INDUSTRY_SCOPE_CONTRADICTION: 'support', @@ -166,8 +181,14 @@ function overlap(left: readonly string[], right: readonly string[]): string[] { return left.filter((value) => right.includes(value)); } -function checkProduct(product: FundingProduct, basisDate: string, collector: Collector): void { +function checkProduct( + product: FundingProduct, + basisDate: string, + sourceSyncs: readonly SourceFreshness[], + collector: Collector, +): void { const source = productVersionKey(product); + const automated = isAutoReviewer(product.reviewer); const { applicationPeriod, evidence } = product; // 근거: 근거 id 중복, 필수 근거 주제 누락, 근거 관측일. @@ -320,13 +341,18 @@ function checkProduct(product: FundingProduct, basisDate: string, collector: Col ); } } - if (product.nextReviewAt === null) { + // 자동 상품은 다음 검토일 대신 출처의 마지막 성공 동기화로 최신성을 판단한다. + if (product.nextReviewAt === null && !automated) { collector.add( 'NEXT_REVIEW_AT_MISSING', source, 'nextReviewAt이 없습니다. 다음 검토일 없는 상품은 현재 후보로 취급하지 않습니다.', ); - } else if (product.reviewedAt !== null && compareIsoDates(product.nextReviewAt, product.reviewedAt) < 0) { + } else if ( + product.nextReviewAt !== null && + product.reviewedAt !== null && + compareIsoDates(product.nextReviewAt, product.reviewedAt) < 0 + ) { collector.add( 'REVIEW_PERIOD_CONTRADICTION', source, @@ -488,11 +514,20 @@ function checkProduct(product: FundingProduct, basisDate: string, collector: Col '검수자가 지정되지 않았습니다(UNASSIGNED). 운영 공개 전에 실제 검수자를 지정해야 합니다.', ); } - if (reviewState(product, basisDate) === 'REVIEW_OVERDUE') { + if (automated && product.sourceRef === undefined) { + collector.add( + 'AUTO_REVIEWER_WITHOUT_SOURCE', + source, + `자동 검수자(${product.reviewer})는 funding:sync가 모은 상품에만 씁니다. 출처 식별자(sourceRef)가 없습니다.`, + ); + } + if (reviewState(product, basisDate, sourceSyncs) === 'REVIEW_OVERDUE') { collector.warn( 'REVIEW_OVERDUE', source, - `다음 검토일 ${product.nextReviewAt ?? '없음'}이(가) 기준일 ${basisDate} 이전입니다. 현재 후보에서 제외하고 재확인 대상으로만 남깁니다.`, + automated + ? `출처의 마지막 성공 동기화가 기준일 ${basisDate}보다 ${SOURCE_FRESHNESS_DAYS}일 넘게 지났거나 없습니다. 현재 후보에서 제외하고 재확인 대상으로만 남깁니다.` + : `다음 검토일 ${product.nextReviewAt ?? '없음'}이(가) 기준일 ${basisDate} 이전입니다. 현재 후보에서 제외하고 재확인 대상으로만 남깁니다.`, ); } } @@ -525,8 +560,17 @@ export function validateFundingCatalog(raw: unknown, options: { asOfDate: string } const catalog = parsed.data; + const { sourceSyncs } = catalog.automation; const seen = new Set(); + const seenSourceRefs = new Set(); const reviewOverdue: string[] = []; + const syncedSources = new Set(); + for (const sync of sourceSyncs) { + if (syncedSources.has(sync.source)) { + collector.add('DUPLICATE_SOURCE_SYNC', 'catalog', `출처 ${sync.source}의 동기화 결과가 두 번 있습니다.`); + } + syncedSources.add(sync.source); + } for (const product of catalog.products) { const key = productVersionKey(product); if (seen.has(key)) { @@ -538,8 +582,19 @@ export function validateFundingCatalog(raw: unknown, options: { asOfDate: string continue; } seen.add(key); - checkProduct(product, catalog.basisDate, collector); - if (reviewState(product, options.asOfDate) === 'REVIEW_OVERDUE') reviewOverdue.push(key); + if (product.sourceRef !== undefined) { + const refKey = sourceRefKey(product.sourceRef); + if (seenSourceRefs.has(refKey)) { + collector.add( + 'DUPLICATE_SOURCE_REF', + key, + `같은 출처 공고(${refKey})를 가리키는 상품이 두 개 있습니다. 한 공고는 한 상품으로만 기록합니다.`, + ); + } + seenSourceRefs.add(refKey); + } + checkProduct(product, catalog.basisDate, sourceSyncs, collector); + if (reviewState(product, options.asOfDate, sourceSyncs) === 'REVIEW_OVERDUE') reviewOverdue.push(key); } if (catalog.reviewer === REVIEWER_UNASSIGNED) { collector.warn('REVIEWER_UNASSIGNED', 'catalog', '카탈로그 검수자가 지정되지 않았습니다(UNASSIGNED).'); diff --git a/app/src/features/plans/funding-matches.test.ts b/app/src/features/plans/funding-matches.test.ts index d4bb33b..5f4829c 100644 --- a/app/src/features/plans/funding-matches.test.ts +++ b/app/src/features/plans/funding-matches.test.ts @@ -120,6 +120,7 @@ function releaseRow(products: Membership[]) { reviewedAt: new Date(TEST_DATE + 'T00:00:00.000Z'), activatedAt: new Date('2026-09-20T03:00:00.000Z'), reviewer: 'test-reviewer', + automation: null, products, }; } diff --git a/app/src/features/plans/loan-assumption.test.ts b/app/src/features/plans/loan-assumption.test.ts index 8a60297..89f6f0c 100644 --- a/app/src/features/plans/loan-assumption.test.ts +++ b/app/src/features/plans/loan-assumption.test.ts @@ -21,7 +21,8 @@ import type { FinanceInput } from '@/features/finance/types'; import { assessProductRepayment } from '@/features/funding/eligibility.ts'; import { loadFundingCatalog } from '@/features/funding/loader.ts'; import { fundingProductSchema } from '@/features/funding/schema.ts'; -import { FUNDING_CATALOG_SCHEMA_VERSION } from '@/features/funding/types.ts'; +import { todayInKst } from '@/features/funding/dates.ts'; +import { AUTO_REVIEWER, FUNDING_CATALOG_SCHEMA_VERSION } from '@/features/funding/types.ts'; import { loanAssumptionFromProduct, planLoanAssumptionSchema, @@ -156,7 +157,7 @@ function membership(product: Record, position: number): Members return { position, productVersion: { productJson: product } }; } -function releaseRow(products: Membership[]) { +function releaseRow(products: Membership[], automation: unknown = null) { return { id: 'release-1', catalogKey: 'test-catalog', @@ -167,6 +168,7 @@ function releaseRow(products: Membership[]) { activatedAt: new Date('2026-09-20T03:00:00.000Z'), reviewer: 'test-reviewer', productCount: products.length, + automation, products, }; } @@ -346,13 +348,16 @@ describe('POST /api/plans/{planId}/loan-assumption 경계', () => { updateCount?: number; conflictRevision?: number; updated?: Record; + automation?: unknown; }) { const findPlan = vi.fn(); findPlan.mockResolvedValueOnce(options.plan ?? null); if (options.conflictRevision !== undefined) findPlan.mockResolvedValueOnce({ revision: options.conflictRevision }); const updateMany = vi.fn().mockResolvedValue({ count: options.updateCount ?? 1 }); const findUniqueOrThrow = vi.fn().mockResolvedValue(options.updated ?? { id: 'plan-a', revision: 2 }); - const findRelease = vi.fn().mockResolvedValue(options.products ? releaseRow(options.products) : null); + const findRelease = vi + .fn() + .mockResolvedValue(options.products ? releaseRow(options.products, options.automation ?? null) : null); return { prisma: { plan: { findFirst: findPlan, updateMany, findUniqueOrThrow }, @@ -442,6 +447,49 @@ describe('POST /api/plans/{planId}/loan-assumption 경계', () => { expect(grant.updateMany).not.toHaveBeenCalled(); }); + it('자동으로 모은 대출은 상환 계산 자동 승격이 켜져 있을 때만 적용한다', async () => { + const automated = [ + membership( + rawProduct({ + ...CONFIRMED_TERMS, + reviewer: AUTO_REVIEWER, + sourceRef: { source: 'SEMAS_OLS', externalId: 'test-fund' }, + nextReviewAt: null, + }), + 0, + ), + ]; + const automation = (repaymentPromotion: boolean) => ({ + policyLoanPromotion: true, + repaymentPromotion, + blockedSources: [], + sourceSyncs: [ + { + source: 'SEMAS_OLS', + status: 'SUCCEEDED', + attemptedAt: new Date().toISOString(), + lastSucceededOn: todayInKst(), + fetchedCount: 1, + responseChecksum: null, + failureReason: null, + }, + ], + }); + + const off = stubApply({ plan: planRow, products: automated, automation: automation(false) }); + mocks.getPrisma.mockReturnValue(off.prisma); + const rejected = await applyLoanAssumption(applyRequest(body), context); + expect(rejected.status).toBe(400); + expect((await rejected.json()).error.message).toContain('상환 계산 자동 승격'); + expect(off.updateMany).not.toHaveBeenCalled(); + + const on = stubApply({ plan: planRow, products: automated, automation: automation(true) }); + mocks.getPrisma.mockReturnValue(on.prisma); + const applied = await applyLoanAssumption(applyRequest(body), context); + expect(applied.status).toBe(200); + expect(on.updateMany).toHaveBeenCalledTimes(1); + }); + it('상환 조건이 확정돼도 현재 계획의 CURRENT_CANDIDATE가 아니면 적용을 거부한다', async () => { const closed = stubApply({ plan: planRow, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 19b73a9..577f8b1 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.test.ts`, `candidates.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` | [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 e6256a2..abc4325 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -43,6 +43,8 @@ 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#동기화)에 있다. + 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 55e5d3f..2babbe8 100644 --- a/docs/domains/funding.md +++ b/docs/domains/funding.md @@ -7,6 +7,7 @@ - `app/src/features/funding`: 판정 함수는 React·DB·외부 API에 의존하지 않는다. - 카탈로그 Zod 스키마(`schema.ts`), 규칙 검사(`validation.ts`), 고정 조건 판정(`eligibility.ts`) - 릴리스·상품 버전 적재, 활성 카탈로그 읽기(`read.ts`), 요청 스키마·응답 조립(`candidates.ts`) + - 출처별 수집 결과를 사람이 관리하는 카탈로그와 합쳐 적재하는 동기화(`sync.ts`) - 페이지 단위 후보 화면(`FundingMatcher.tsx`), 두 화면이 함께 쓰는 결과 표시(`FundingCandidatesPanel.tsx`) - `app/catalog/funding`: 운영자가 공식 공고를 정리한 JSON 카탈로그와 갱신 절차 문서. @@ -26,6 +27,10 @@ - 다음은 오류가 아니라 주의로 기록한다: 검수 기한 경과, 검수자 미지정(UNASSIGNED), 공식 원문이 아닌 요약 근거, 신청기간 종료일 전의 CLOSED 기록(조기 마감이면 근거를 추가한다). - 검수 기능이 생기기 전까지 `funding:load`는 검수자가 UNASSIGNED여도 적재한다. 그런 상품은 판정에서 현재 후보가 되지 않고 추가 확인으로 분류된다. - 검증을 우회하는 명령행 옵션은 없다. 조건이 불명확한 상품을 강제로 활성화하는 경로를 만들지 않는다. +- 스키마 `funding-catalog-v1.1.0`은 v1.0.0에 다음을 더했다. v1.0.0으로 적재된 릴리스도 읽으며, 그때 자동화 설정은 모두 꺼진 기본값이다. + - 상품의 `sourceRef`(출처와 공고 식별자). funding:sync가 모은 상품에 붙고, 사람이 기록한 상품도 같은 공고를 가리키면 적는다. + - 카탈로그의 `automation`: 정책자금 자동 승격(`policyLoanPromotion`)·상환 계산 자동 승격(`repaymentPromotion`) 스위치, 차단 목록(`blockedSources`), 출처별 동기화 결과(`sourceSyncs`). 스위치는 기본으로 꺼져 있다. +- 자동 상품은 검수자에 사람 대신 자동 규칙 버전(`AUTO:funding-auto-v1`)을 적는다. 규칙 검사는 다음을 오류로 본다: 출처 식별자 없는 자동 검수자, 같은 공고를 가리키는 상품 두 개, 같은 출처의 동기화 결과 두 개. 자동 상품에는 다음 검토일이 없어도 된다. ### 릴리스와 상품 버전 @@ -36,6 +41,30 @@ - 릴리스와 상품 버전은 `funding_catalog_products` 연결 테이블로 묶어, 같은 불변 버전을 여러 릴리스가 재사용할 수 있다. - 적재는 PENDING으로 시작해 적재 후 검증까지 통과한 뒤 한 트랜잭션으로 ACTIVE가 된다. 활성화는 상태 전환만 하며 과거 릴리스와 상품 버전을 지우지 않는다. - 실패하면 이번 적재가 만든 상품 버전만 삭제하고, 이전에 이미 저장된 상품 버전은 그대로 둔다. 릴리스는 `FAILED`와 실패 사유(`오류 코드 + 메시지`)로 남는다. +- 릴리스의 `automation`(JSONB)에 자동화 설정과 출처별 동기화 결과를 저장한다. v1.0.0 릴리스는 NULL이다. + +### 동기화 + +[ADR 0006](../decisions/0006-funding-discovery-automation.md)의 동기화다. 실행 명령은 [DEVELOPMENT.md](../DEVELOPMENT.md#로컬-데이터-준비)에 있다. + +- 흐름: 출처별 수집기(`FundingSourceAdapter`)로 공고를 받아 상품을 만들고, 사람이 관리하는 `catalog.json`의 상품과 합친 뒤, 위 적재기와 같은 검사로 `PENDING → ACTIVE` 적재한다. +- 합친 카탈로그: + - catalogKey와 검수자·스위치·차단 목록은 `catalog.json`을 따른다. 기준일은 동기화한 날(한국 시간)이다. + - catalogVersion은 `동기화한 날.번호`다. 같은 날 적재된 릴리스와 `catalog.json`의 버전을 피해 번호를 올린다. + - 적재한 카탈로그 파일은 `data/funding-sync/`(Git 제외)에 남는다. +- 상품 버전: 날짜와 근거 checksum을 뺀 내용이 이미 적재된 버전과 같으면 그 버전을 재사용한다. 다르면 가장 높은 버전의 PATCH를 올린다. +- 출처가 실패하면 그 출처의 상품은 직전 ACTIVE 릴리스의 것을 그대로 두고, 마지막 성공일(`lastSucceededOn`)과 공고 수도 그대로 둔다. 다음은 실패다. + - 수집기 오류(장애·인증 오류·차단 등) + - 빈 응답. "공고 없음"으로 해석하지 않는다 + - 받은 공고 수가 직전 성공의 절반 미만 + - 수집한 상품이 카탈로그 규칙 검사를 통과하지 못함, 다른 출처의 상품, 같은 공고 두 번, 사람이 기록한 상품 키와 겹침 +- 사람이 기록한 상품이 같은 공고(`sourceRef`)를 가리키면 자동 상품을 빼고 사람 기록을 쓴다. +- `catalog.json`에 자동 검수자 상품이 있으면 거부한다. 자동 상품은 funding:sync만 만든다. +- 수집 출처와 허용 확인 기록. 출처는 robots.txt와 약관을 확인한 뒤에만 수집기를 등록한다(ADR 0006 3절). + +| 출처 | 대상 | 확인일 | 확인 결과 | 수집기 | +|---|---|---|---|---| +| `SEMAS_OLS` | 소진공 정책자금 사이트 `ols.semas.or.kr` | 2026-09-28 | robots.txt 없음(404). 약관에 자동 수집 금지 조항 없음. 담당자가 출처 표시 조건으로 수집 허용 | 아직 등록하지 않음 | ### 판정 @@ -49,6 +78,10 @@ - 신청 종료일이 판정 기준일보다 앞서면 관측 접수 상태가 OPEN·UNKNOWN이어도 접수 종료(CLOSED)로 판정한다. 종료일 당일까지는 접수 중으로 보고, 종료일이 없으면(예: 예산 소진 시까지) 관측 접수 상태를 따른다. - 검수 기한이 지난 상품, 검수일 또는 검수자가 없는 상품, 검색 결과 요약으로만 확인한 상품은 현재 후보로 확정하지 않는다. - 사업자등록 이후에만 검토할 수 있는 상품은 POST_REGISTRATION 상태로 현재 예비 창업자 후보와 분리한다. +- 자동 상품(검수자 `AUTO:…`)의 추가 규칙(ADR 0006). 사람이 기록한 상품은 `sourceRef`가 있어도 지금 규칙만 따른다. + - 다음 검토일 대신 출처의 마지막 성공 동기화일로 최신성을 본다. 그날에서 3일이 지났거나 성공 기록이 없으면 검수 기한 경과(`REVIEW_OVERDUE`)다. + - 차단 목록의 공고, 지원사업(지원금·공간·프로그램) 자동 상품, 정책자금 자동 승격이 꺼진 상태의 자동 상품은 현재 후보가 아니라 추가 확인이며 사유를 표시한다. + - 상환 계산 자동 승격이 꺼져 있으면 자동 상품은 확정 조건이 있어도 상환 계산 대상이 아니다. - 지원 유형 표시: GRANT 지원금, GUARANTEE 보증, LOAN 대출, SPACE 공간·보육, PROGRAM 프로그램. - 지원금·보증·공간·프로그램 지원은 대출 원금이나 상환 일정으로 변환하지 않는다. - 대출의 상환 계산 대상 여부: @@ -86,7 +119,7 @@ ## 상품 확정 조건 → 대출 가정 적용 - 상품 확정 조건을 계획의 신규 대출 가정으로 옮기는 일은 사용자가 계획 화면에서 명시적으로 실행할 때만 일어난다. 후보 조회·판정·계획 불러오기·초안 저장 시점에는 신규 대출 입력을 자동으로 채우지 않는다. 화면은 상품 확정 조건과 사용자가 입력한 가정을 구분해 표시한다. -- 적용 대상은 서버가 저장된 계획 조건으로 다시 판정한 `CURRENT_CANDIDATE`이면서 `assessProductRepayment(...).supported === true`인 대출 상품뿐이다. 그 밖의 상품은 "상환 계산 대상 아님 + 사유"를 유지하며 0원 상환으로 대체하지 않는다. +- 적용 대상은 서버가 저장된 계획 조건과 활성 릴리스의 자동화 설정으로 다시 판정한 `CURRENT_CANDIDATE`이면서, 그 판정의 상환 계산 가능(`repayment.supported`)이 true인 대출 상품뿐이다. 그 밖의 상품은 "상환 계산 대상 아님 + 사유"를 유지하며 0원 상환으로 대체하지 않는다. - 실행: `POST /api/plans/{planId}/loan-assumption`(본문 `{ productKey, version, revision }`). - 서버가 활성 카탈로그에서 상품 버전과 현재 후보 여부를 다시 확인한다. - 사용자가 초안에 저장한 `newLoan.principal`에 상품의 확정 금리·기간·거치·상환방식만 결합한다. @@ -101,4 +134,6 @@ ## 테스트 - DB 없이: 카탈로그 스키마·규칙 위반, 실제 카탈로그 5건의 규칙 통과와 모두 상환 계산 대상이 아님(`supported=false`), 조건 판정, 상환 계산 가능 여부, 상품 조건과 사용자 원금의 결합. 후보 조회·계획 조건 판정·상품 조건 적용 API의 경계(상태 코드)는 Prisma를 mock해 DB 없이 검증한다. +- DB 없이: 자동 상품의 최신성·승격 보류·상환 계산 스위치, v1.0.0 릴리스 읽기, 자동 상품 규칙 검사, 동기화의 상품 버전 재사용 규칙. - `TEST_DATABASE_URL` 전용 DB: 카탈로그 적재, 실제 DB에서의 후보 조회·계획 조건 판정·상품 조건 적용, 출처 저장과 과거 결과 불변성. 합성 카탈로그만 만들고 끝나면 지우며 실행 전 ACTIVE 상태를 복원한다. +- `TEST_DATABASE_URL` 전용 DB의 동기화(`sync.test.ts`): 합성 수집기로 적재, 버전 재사용과 새 버전, 출처 실패 시 직전 상품 유지, 빈 응답·급감, 검증 실패, 사람 기록 우선, dry run.