From d4a7b9de070a3dbea5c2ecb9dfe4ae2e64de5190 Mon Sep 17 00:00:00 2001 From: thug0bin Date: Mon, 2 Mar 2026 02:10:21 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20McpServer=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20+=20dry=5Frun=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server(deprecated) → McpServer로 마이그레이션 (TypeScript 경고 제거) - mcp.server.setRequestHandler() 패턴으로 핸들러 등록 - mcp.connect(transport)로 서버 시작 - dry_run: boolean 파라미터 전 API 툴에 추가 - SBKEY 없이 파라미터 유효성 검증 가능 - ERP DB 필드 매핑 테스트용 - validator.ts 추가: 청구서/회원/휴폐업/홈택스 검증 로직 Co-Authored-By: Claude --- src/index.ts | 465 +++++++++++++++++++++++------------------ src/validator.ts | 534 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 801 insertions(+), 198 deletions(-) create mode 100644 src/validator.ts diff --git a/src/index.ts b/src/index.ts index ea57530..1cc71ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,9 +14,10 @@ * - 홈택스 연동 (세금계산서/현금영수증 조회) * - 휴폐업 조회 * - 추가메일 전송 + * - dry_run 모드: SBKEY 없이 파라미터 유효성 검증 및 페이로드 미리보기 */ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, @@ -24,16 +25,33 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { createClient } from "./client.js"; import { getErrorMessage, CODE_TABLES } from "./error-codes.js"; +import { + validateInvoice, + validateMember, + validateCompanyCloseSearch, + validateHometaxQuery, + formatDryRunResult, +} from "./validator.js"; -const server = new Server( - { name: "sendbill-mcp", version: "1.0.0" }, +const mcp = new McpServer( + { name: "sendbill-mcp", version: "1.1.0" }, { capabilities: { tools: {} } } ); +// dry_run 공통 property (API 호출 툴에 공통 추가) +const DRY_RUN_PROP = { + dry_run: { + type: "boolean", + description: + "true로 설정하면 실제 API를 호출하지 않고 유효성 검증 결과와 전송될 JSON payload를 미리 확인합니다. " + + "SBKEY 없이도 사용 가능. ERP 필드 매핑 테스트에 활용하세요.", + }, +}; + // ───────────────────────────────────────────── // 툴 목록 정의 // ───────────────────────────────────────────── -server.setRequestHandler(ListToolsRequestSchema, async () => ({ +mcp.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ // ── 인증서 관리 ────────────────────────── { @@ -48,6 +66,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ type: "string", description: "센드빌 회원사 사업자번호 (하이픈 제외 10자리)", }, + ...DRY_RUN_PROP, }, required: ["venderno"], }, @@ -65,6 +84,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ password: { type: "string", description: "인증서 비밀번호" }, certderfile: { type: "string", description: "signCert.der 파일 Base64 인코딩값" }, certkeyfile: { type: "string", description: "signPri.key 파일 Base64 인코딩값" }, + ...DRY_RUN_PROP, }, required: ["jwt", "password", "certderfile", "certkeyfile"], }, @@ -78,6 +98,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ type: "object", properties: { jwt: { type: "string", description: "sendbill_get_token으로 발급받은 토큰" }, + ...DRY_RUN_PROP, }, required: ["jwt"], }, @@ -92,6 +113,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ properties: { jwt: { type: "string", description: "sendbill_get_token으로 발급받은 토큰" }, password: { type: "string", description: "등록된 인증서 비밀번호" }, + ...DRY_RUN_PROP, }, required: ["jwt", "password"], }, @@ -105,6 +127,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ type: "object", properties: { venderno: { type: "string", description: "사업자번호 (하이픈 제외 10자리)" }, + ...DRY_RUN_PROP, }, required: ["venderno"], }, @@ -116,12 +139,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ description: "전자세금계산서(또는 계산서)를 단건 발행합니다.\n" + "POST /api/agent/document/registDirect\n" + - "billtype 코드: 10=세금계산서, 20=(면세)계산서, 30=거래명세서, 40=위수탁세금계산서 등\n" + - "gubun: 1=영수, 2=청구\n" + - "taxrate: 0=과세, 1=영세, 2=면세", + "💡 dry_run: true → SBKEY 없이 필드 유효성 검증 + 전송 JSON 미리보기 (ERP 매핑 테스트용)\n" + + "billtype: 10=세금계산서, 20=(면세)계산서, 30=거래명세서, 40=위수탁세금계산서 등\n" + + "gubun: 1=영수, 2=청구 | taxrate: 0=과세, 1=영세, 2=면세", inputSchema: { type: "object", properties: { + ...DRY_RUN_PROP, billseq: { type: "string", description: "세금계산서 고유번호 (최대 25자, 중복불가)" }, svenderno: { type: "string", description: "공급자 사업자번호 (하이픈 제외 10자리)" }, rvenderno: { type: "string", description: "공급받는자 사업자번호 또는 주민등록번호 (13자리 이내)" }, @@ -150,7 +174,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ report_except_yn: { type: "string", description: "신고 제외 여부 (Y/N, 기본값 N)" }, reverseyn: { type: "string", description: "역발행 여부 (Y/N, 기본값 N)" }, transyn: { type: "string", description: "전송 여부 (Y/N, 기본값 N)" }, - test_yn: { type: "string", description: "테스트 여부 (Y/N, 기본값 N)" }, + test_yn: { type: "string", description: "테스트 여부 (Y/N, 기본값 N) - N이라도 실제 국세청 신고는 별도 처리" }, bigo: { type: "string", description: "비고 (최대 150자)" }, cash: { type: "string", description: "현금 지급액" }, checks: { type: "string", description: "수표 지급액" }, @@ -158,14 +182,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ credit: { type: "string", description: "외상미수금" }, sendid: { type: "string", description: "공급자 센드빌 ID (etc01 코드에 따라 결정)" }, recvid: { type: "string", description: "공급받는자 센드빌 ID (etc01 코드에 따라 결정)" }, - etc01: { type: "string", description: "ID 구분 (X=거래처ID, I=공급자ID+거래처ID, S=양쪽 센드빌ID, 기본값 X)" }, + etc01: { type: "string", description: "ID 구분 (X=거래처ID/기본, I=공급자ID+거래처ID, S=양쪽 센드빌ID)" }, etc03: { type: "string", description: "비회원 자동가입 여부 (Y/N, 기본값 N)" }, file_url: { type: "string", description: "첨부파일 URL (최대 250자)" }, upbillseq: { type: "string", description: "수정세금계산서의 원본 고유번호" }, - report_amend_cd: { type: "string", description: "수정사유 코드 (1=착오정정, 2=공급가액변동, 3=환입, 4=계약해제, 5=내국신용장, 6=이중발행)" }, + report_amend_cd: { type: "string", description: "수정사유 코드 (1~6)" }, + saccount_no: { type: "string", description: "매출자계정코드 (최대 50자)" }, + raccount_no: { type: "string", description: "매입자계정코드 (최대 50자)" }, item: { type: "array", - description: "품목 목록 (필수)", + description: "품목 목록 (필수, 최소 1개). 품목별 tax+sup 합계가 총액과 일치해야 합니다.", items: { type: "object", properties: { @@ -208,8 +234,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ description: "수정세금계산서를 최대 2건 동시 발행합니다. (묶음 발행)\n" + "POST /api/agent/document/registMultiDirect\n" + - "주로 기재사항 착오/정정, 내국신용장 사후 개설 시 원본+수정본을 한 번에 발행할 때 사용.\n" + - "1건이라도 오류 시 전체 취소됩니다.", + "💡 dry_run: true → 각 계산서 필드 유효성 검증 + 페이로드 미리보기\n" + + "주로 기재사항 착오/정정, 내국신용장 사후 개설 시 원본+수정본을 한 번에 발행할 때 사용.", inputSchema: { type: "object", properties: { @@ -219,6 +245,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ items: { type: "object" }, maxItems: 2, }, + ...DRY_RUN_PROP, }, required: ["documents"], }, @@ -228,16 +255,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ description: "전자세금계산서를 최대 100건 대량 발행합니다.\n" + "POST /api/agent/document/registBulkDirect\n" + - "일부 실패 시에도 성공건은 발행됩니다. 응답에 성공/실패 건수와 오류 목록이 포함됩니다.", + "💡 dry_run: true → 전체 건수 유효성 검증 + 첫 3건 페이로드 미리보기\n" + + "일부 실패 시에도 성공건은 발행됩니다.", inputSchema: { type: "object", properties: { documents: { type: "array", - description: "발행할 계산서 목록 (최대 100건). 각 항목은 sendbill_register_invoice와 동일한 형식.", + description: "발행할 계산서 목록 (최대 100건).", items: { type: "object" }, maxItems: 100, }, + ...DRY_RUN_PROP, }, required: ["documents"], }, @@ -249,12 +278,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ description: "세금계산서의 상태를 단건 조회합니다.\n" + "POST /api/agent/status/detail\n" + - "billstat 코드: 0=미개봉, 1=승인, 2=반려, 3=개봉, 4=승인취소, 5=미전송, 6=삭제, 9=종이문서저장\n" + + "billstat: 0=미개봉, 1=승인, 2=반려, 3=개봉, 4=승인취소, 5=미전송, 6=삭제, 9=종이문서저장\n" + "transyn: Y=전송성공, N=전송대기, E=에러", inputSchema: { type: "object", properties: { billseq: { type: "string", description: "세금계산서 고유번호" }, + ...DRY_RUN_PROP, }, required: ["billseq"], }, @@ -273,6 +303,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ items: { type: "string" }, maxItems: 1000, }, + ...DRY_RUN_PROP, }, required: ["billseqs"], }, @@ -290,11 +321,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ billseq: { type: "string", description: "세금계산서 고유번호" }, cmd_div: { type: "string", description: "요청코드 (6=삭제, E=이메일전송)" }, remark: { type: "string", description: "이메일 전송 시 수신자 이메일 주소 (최대 500자)" }, - etc1: { type: "string", description: "기타1 (최대 200자)" }, - etc2: { type: "string", description: "기타2 (최대 200자)" }, - etc3: { type: "string", description: "기타3 (최대 200자)" }, - etc4: { type: "string", description: "기타4 (최대 200자)" }, - etc5: { type: "string", description: "기타5 (최대 200자)" }, + etc1: { type: "string" }, + etc2: { type: "string" }, + etc3: { type: "string" }, + etc4: { type: "string" }, + etc5: { type: "string" }, + ...DRY_RUN_PROP, }, required: ["billseq", "cmd_div"], }, @@ -308,6 +340,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ type: "object", properties: { billseq: { type: "string", description: "세금계산서 고유번호" }, + ...DRY_RUN_PROP, }, required: ["billseq"], }, @@ -315,12 +348,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: "sendbill_get_manage_error_log", description: - "계산서 관리 요청(삭제/이메일 전송 등) 시 발생한 에러 로그를 조회합니다.\n" + + "계산서 관리 요청 시 발생한 에러 로그를 조회합니다.\n" + "POST /api/agent/log/manager", inputSchema: { type: "object", properties: { billseq: { type: "string", description: "세금계산서 고유번호" }, + ...DRY_RUN_PROP, }, required: ["billseq"], }, @@ -332,6 +366,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ description: "센드빌에 거래처(비회원)를 임시 회원으로 등록합니다.\n" + "POST /api/agent/member/registDirect\n" + + "💡 dry_run: true → 필드 유효성 검증 + 페이로드 미리보기 (DB 거래처 데이터 매핑 테스트용)\n" + "비회원 세금계산서를 사용하지 않는 경우, 반드시 거래처가 센드빌에 등록되어 있어야 합니다.", inputSchema: { type: "object", @@ -357,6 +392,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ resno: { type: "string", description: "주민등록번호 (13자리)" }, smsno: { type: "string", description: "핸드폰번호" }, sign_yn: { type: "string", description: "담당자 동의 여부 (Y/N)" }, + ...DRY_RUN_PROP, }, required: ["entcode", "id", "company", "venderno", "ceoname", "uptae", "upjong", "address", "mgmt_yn", "email_yn", "sms_yn"], }, @@ -371,6 +407,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ properties: { entcode: { type: "string", description: "제휴업체 코드 (최대 6자리)" }, userid: { type: "string", description: "회원 아이디(임시가입) (최대 30자)" }, + ...DRY_RUN_PROP, }, required: ["entcode", "userid"], }, @@ -382,22 +419,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ description: "홈택스에서 세금계산서 매출/매입 내역을 조회합니다.\n" + "direction: sales=매출, purchase=매입\n" + - "period: daily=일별(basedate), monthly=월별(basemonth)\n" + - "taxtype: '01'=과세+영세, '03'=면세\n" + - "datetype: '01'=작성일자, '02'=발행일자\n" + - "orderdirection: '01'=내림차순, '02'=오름차순\n" + - "⚠️ 홈택스 접근제어로 30건당 5초 딜레이가 있습니다.", + "period: daily=일별(basedate 필요), monthly=월별(basemonth 필요)\n" + + "taxtype: '01'=과세+영세, '03'=면세 | datetype: '01'=작성일, '02'=발행일\n" + + "⚠️ 홈택스 접근제어로 30건당 5초 딜레이", inputSchema: { type: "object", properties: { - direction: { type: "string", enum: ["sales", "purchase"], description: "조회 방향 (sales=매출, purchase=매입)" }, - period: { type: "string", enum: ["daily", "monthly"], description: "조회 주기 (daily=일별, monthly=월별)" }, + direction: { type: "string", enum: ["sales", "purchase"] }, + period: { type: "string", enum: ["daily", "monthly"] }, venderno: { type: "string", description: "사업자번호 (10자리)" }, - taxtype: { type: "string", description: "과세구분 ('01'=과세+영세, '03'=면세)" }, - datetype: { type: "string", description: "기준일자 유형 ('01'=작성일자, '02'=발행일자)" }, - basedate: { type: "string", description: "기준일자 (YYYYMMDD, period=daily일 때)" }, - basemonth: { type: "string", description: "기준월 (YYYYMM, period=monthly일 때)" }, - orderdirection: { type: "string", description: "정렬방향 ('01'=내림차순, '02'=오름차순)" }, + taxtype: { type: "string", description: "'01'=과세+영세, '03'=면세" }, + datetype: { type: "string", description: "'01'=작성일자, '02'=발행일자" }, + basedate: { type: "string", description: "기준일자 YYYYMMDD (period=daily)" }, + basemonth: { type: "string", description: "기준월 YYYYMM (period=monthly)" }, + orderdirection: { type: "string", description: "'01'=내림차순, '02'=오름차순" }, + ...DRY_RUN_PROP, }, required: ["direction", "period", "venderno", "taxtype", "datetype", "orderdirection"], }, @@ -408,17 +444,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ "홈택스에서 현금영수증 매출/매입 내역을 조회합니다.\n" + "direction: sales=매출, purchase=매입\n" + "period: daily=일별(basedate), monthly=월별(basemonth)\n" + - "orderdirection: '01'=내림차순, '02'=오름차순\n" + - "⚠️ 홈택스 접근제어로 30건당 5초 딜레이가 있습니다.", + "⚠️ 홈택스 접근제어로 30건당 5초 딜레이", inputSchema: { type: "object", properties: { - direction: { type: "string", enum: ["sales", "purchase"], description: "조회 방향 (sales=매출, purchase=매입)" }, - period: { type: "string", enum: ["daily", "monthly"], description: "조회 주기 (daily=일별, monthly=월별)" }, + direction: { type: "string", enum: ["sales", "purchase"] }, + period: { type: "string", enum: ["daily", "monthly"] }, venderno: { type: "string", description: "사업자번호 (10자리)" }, - basedate: { type: "string", description: "기준일자 (YYYYMMDD, period=daily일 때)" }, - basemonth: { type: "string", description: "기준월 (YYYYMM, period=monthly일 때)" }, - orderdirection: { type: "string", description: "정렬방향 ('01'=내림차순, '02'=오름차순)" }, + basedate: { type: "string", description: "기준일자 YYYYMMDD (period=daily)" }, + basemonth: { type: "string", description: "기준월 YYYYMM (period=monthly)" }, + orderdirection: { type: "string", description: "'01'=내림차순, '02'=오름차순" }, + ...DRY_RUN_PROP, }, required: ["direction", "period", "venderno", "orderdirection"], }, @@ -427,16 +463,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ name: "sendbill_hometax_register_account", description: "홈택스 연동을 위한 홈택스 계정을 센드빌에 등록합니다.\n" + - "POST /api/agent/hometax/account/regist\n" + - "세금계산서용과 현금영수증용 계정을 별도로 사용합니다.", + "POST /api/agent/hometax/account/regist", inputSchema: { type: "object", properties: { - jwt: { type: "string", description: "sendbill_get_token으로 발급받은 토큰" }, + jwt: { type: "string" }, billhtid: { type: "string", description: "세금계산서용 홈택스 아이디" }, billhtpw: { type: "string", description: "세금계산서용 홈택스 패스워드" }, cashhtid: { type: "string", description: "현금영수증용 홈택스 아이디" }, cashhtpw: { type: "string", description: "현금영수증용 홈택스 패스워드" }, + ...DRY_RUN_PROP, }, required: ["jwt", "billhtid", "billhtpw", "cashhtid", "cashhtpw"], }, @@ -445,12 +481,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ name: "sendbill_hometax_delete_account", description: "홈택스 연동계정을 센드빌에서 삭제합니다.\n" + - "POST /api/agent/hometax/account/delete\n" + - "계정 존재 유무에 대한 결과는 반환되지 않습니다.", + "POST /api/agent/hometax/account/delete", inputSchema: { type: "object", properties: { - jwt: { type: "string", description: "sendbill_get_token으로 발급받은 토큰" }, + jwt: { type: "string" }, + ...DRY_RUN_PROP, }, required: ["jwt"], }, @@ -464,6 +500,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ type: "object", properties: { venderno: { type: "string", description: "사업자번호 (하이픈 제외 10자리)" }, + ...DRY_RUN_PROP, }, required: ["venderno"], }, @@ -475,7 +512,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ description: "사업자의 휴업/폐업 상태를 최대 100건 조회합니다.\n" + "POST /api/agent/companyclosesearch\n" + - "국세청 데이터를 실시간으로 조회합니다.", + "💡 dry_run: true → 사업자번호 형식 검증 + 페이로드 미리보기", inputSchema: { type: "object", properties: { @@ -485,6 +522,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ items: { type: "string" }, maxItems: 100, }, + ...DRY_RUN_PROP, }, required: ["venderno"], }, @@ -499,9 +537,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ inputSchema: { type: "object", properties: { - billseq: { type: "string", description: "발행 문서 고유번호 (최대 19자)" }, - sendType: { type: "string", description: "전송타입 코드" }, + billseq: { type: "string" }, + sendType: { type: "string" }, newMailAddress: { type: "string", description: "신규 전송 메일 주소 (최대 30자)" }, + ...DRY_RUN_PROP, }, required: ["billseq", "sendType", "newMailAddress"], }, @@ -514,17 +553,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ inputSchema: { type: "object", properties: { - billseq: { type: "string", description: "API 발행 문서 고유번호 (최대 19자)" }, + billseq: { type: "string" }, + ...DRY_RUN_PROP, }, required: ["billseq"], }, }, - // ── 참조 도구 ───────────────────────────── + // ── 참조 도구 (SBKEY 불필요) ────────────── { name: "sendbill_get_error_message", description: - "센드빌 API 에러코드에 해당하는 한국어 메시지를 조회합니다.\n" + + "센드빌 API 에러코드에 해당하는 한국어 메시지를 조회합니다. (SBKEY 불필요)\n" + "API 응답에서 음수 Result 코드를 받았을 때 사용하세요.", inputSchema: { type: "object", @@ -537,15 +577,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: "sendbill_get_code_tables", description: - "센드빌 API에서 사용하는 코드표 전체를 반환합니다.\n" + - "billtype, cmd_div, taxrate, billstat, gubun, etc01, report_stat, report_amend_cd 코드를 확인할 수 있습니다.", + "센드빌 API에서 사용하는 코드표를 반환합니다. (SBKEY 불필요)\n" + + "billtype, cmd_div, taxrate, billstat, gubun, etc01, report_stat, report_amend_cd 코드 확인 가능.", inputSchema: { type: "object", properties: { table: { type: "string", enum: ["billtype", "cmd_div", "taxrate", "billstat", "gubun", "etc01", "report_stat", "report_amend_cd", "all"], - description: "조회할 코드표 이름 (all=전체 조회)", + description: "조회할 코드표 이름 (all=전체)", }, }, required: ["table"], @@ -557,44 +597,38 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ // ───────────────────────────────────────────── // 툴 실행 핸들러 // ───────────────────────────────────────────── -server.setRequestHandler(CallToolRequestSchema, async (request) => { +mcp.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; + const a = (args ?? {}) as Record; + const isDryRun = a.dry_run === true; - // 참조 도구는 API 호출 없음 + // ── 참조 도구 (항상 로컬, API 불필요) ────── if (name === "sendbill_get_error_message") { - const code = (args as { code: number }).code; - const message = getErrorMessage(code); - return { - content: [{ type: "text", text: `에러코드 ${code}: ${message}` }], - }; + const code = a.code as number; + return { content: [{ type: "text", text: `에러코드 ${code}: ${getErrorMessage(code)}` }] }; } - if (name === "sendbill_get_code_tables") { - const table = (args as { table: string }).table; + const table = a.table as string; if (table === "all") { - return { - content: [{ type: "text", text: JSON.stringify(CODE_TABLES, null, 2) }], - }; + return { content: [{ type: "text", text: JSON.stringify(CODE_TABLES, null, 2) }] }; } const tableData = CODE_TABLES[table as keyof typeof CODE_TABLES]; if (!tableData) { - return { - content: [{ type: "text", text: `코드표 '${table}'를 찾을 수 없습니다.` }], - isError: true, - }; + return { content: [{ type: "text", text: `코드표 '${table}'를 찾을 수 없습니다.` }], isError: true }; } - return { - content: [{ type: "text", text: JSON.stringify({ [table]: tableData }, null, 2) }], - }; + return { content: [{ type: "text", text: JSON.stringify({ [table]: tableData }, null, 2) }] }; } - // API 호출 도구 + // ── dry_run 핸들러 ─────────────────────────── + if (isDryRun) { + return handleDryRun(name, a); + } + + // ── 실제 API 호출 ──────────────────────────── const client = createClient(); - type EndpointMap = Record) => Record]>; - - const endpoints: EndpointMap = { - // 인증서 관리 + type EndpointEntry = [string, (a: Record) => Record]; + const endpoints: Record = { sendbill_get_token: ["/api/agent/certificate/gettoken", (a) => ({ venderno: a.venderno })], sendbill_upload_cert: ["/api/agent/certificate/uploadcert", (a) => ({ jwt: a.jwt, password: a.password, certderfile: a.certderfile, certkeyfile: a.certkeyfile @@ -602,157 +636,193 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { sendbill_inquire_cert: ["/api/agent/certificate/inquirecert", (a) => ({ jwt: a.jwt })], sendbill_delete_cert: ["/api/agent/certificate/deletecert", (a) => ({ jwt: a.jwt, password: a.password })], sendbill_get_cert_upload_view: ["/api/agent/certificate/getuploadview", (a) => ({ venderno: a.venderno })], - - // 계산서 발행 - sendbill_register_invoice: ["/api/agent/document/registDirect", (a) => a], - sendbill_register_invoice_multi: ["/api/agent/document/registMultiDirect", (a) => a], - sendbill_register_invoice_bulk: ["/api/agent/document/registBulkDirect", (a) => a], - - // 계산서 조회/관리 + sendbill_register_invoice: ["/api/agent/document/registDirect", stripMeta], + sendbill_register_invoice_multi: ["/api/agent/document/registMultiDirect", stripMeta], + sendbill_register_invoice_bulk: ["/api/agent/document/registBulkDirect", stripMeta], sendbill_get_invoice_status: ["/api/agent/status/detail", (a) => ({ billseq: a.billseq })], sendbill_get_invoice_status_list: ["/api/agent/status/list", (a) => ({ billseqs: a.billseqs })], - sendbill_manage_invoice: ["/api/agent/manager/registDirect", (a) => a], + sendbill_manage_invoice: ["/api/agent/manager/registDirect", stripMeta], sendbill_get_invoice_error_log: ["/api/agent/log/bill", (a) => ({ billseq: a.billseq })], sendbill_get_manage_error_log: ["/api/agent/log/manager", (a) => ({ billseq: a.billseq })], - - // 회원 관리 - sendbill_register_member: ["/api/agent/member/registDirect", (a) => a], + sendbill_register_member: ["/api/agent/member/registDirect", stripMeta], sendbill_get_member_error_log: ["/api/agent/log/member", (a) => ({ entcode: a.entcode, userid: a.userid })], - - // 홈택스 연동 - sendbill_hometax_register_account: ["/api/agent/hometax/account/regist", (a) => a], + sendbill_hometax_register_account: ["/api/agent/hometax/account/regist", stripMeta], sendbill_hometax_delete_account: ["/api/agent/hometax/account/delete", (a) => ({ jwt: a.jwt })], sendbill_hometax_get_register_view: ["/api/agent/hometax/account/getRegistView", (a) => ({ venderno: a.venderno })], - - // 휴폐업 조회 sendbill_company_close_search: ["/api/agent/companyclosesearch", (a) => ({ venderno: a.venderno })], - - // 추가메일 - sendbill_send_additional_mail: ["/api/agent/additionalMail/sendAdditionalMail", (a) => a], + sendbill_send_additional_mail: ["/api/agent/additionalMail/sendAdditionalMail", stripMeta], sendbill_get_mail_send_list: ["/api/agent/additionalMail/sendMailList", (a) => ({ billseq: a.billseq })], }; - // 홈택스 조회는 방향+주기에 따라 엔드포인트가 다름 - if (name === "sendbill_hometax_query_tax_invoice") { - const a = args as Record; - const direction = a.direction as string; - const period = a.period as string; - - const endpointMap: Record = { - "sales-daily": "/api/agent/hometax/listDailyTaxInvoiceSales", - "sales-monthly": "/api/agent/hometax/listMonthlyTaxInvoiceSales", - "purchase-daily": "/api/agent/hometax/listDailyTaxInvoicePurchase", - "purchase-monthly": "/api/agent/hometax/listMonthlyTaxInvoicePurchase", - }; - - const endpoint = endpointMap[`${direction}-${period}`]; - if (!endpoint) { - return { content: [{ type: "text", text: "잘못된 direction 또는 period 값입니다." }], isError: true }; - } - - const body: Record = { - venderno: a.venderno, - taxtype: a.taxtype, - datetype: a.datetype, - orderdirection: a.orderdirection, - }; - if (period === "daily") body.basedate = a.basedate; - else body.basemonth = a.basemonth; - - try { - const result = await client.post(endpoint, body); - const text = formatResult(result); - return { content: [{ type: "text", text }] }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text", text: `API 오류: ${message}` }], isError: true }; - } + // 홈택스 조회 (방향+주기 조합 라우팅) + if (name === "sendbill_hometax_query_tax_invoice" || name === "sendbill_hometax_query_cash_receipt") { + return await handleHometaxQuery(name, a, client); } - if (name === "sendbill_hometax_query_cash_receipt") { - const a = args as Record; - const direction = a.direction as string; - const period = a.period as string; - - const endpointMap: Record = { - "sales-daily": "/api/agent/hometax/listDailyCashBillSales", - "sales-monthly": "/api/agent/hometax/listMonthlyCashBillSales", - "purchase-daily": "/api/agent/hometax/listDailyCashBillPurchase", - "purchase-monthly": "/api/agent/hometax/listMonthlyCashBillPurchase", - }; - - const endpoint = endpointMap[`${direction}-${period}`]; - if (!endpoint) { - return { content: [{ type: "text", text: "잘못된 direction 또는 period 값입니다." }], isError: true }; - } - - const body: Record = { - venderno: a.venderno, - orderdirection: a.orderdirection, - }; - if (period === "daily") body.basedate = a.basedate; - else body.basemonth = a.basemonth; - - try { - const result = await client.post(endpoint, body); - const text = formatResult(result); - return { content: [{ type: "text", text }] }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text", text: `API 오류: ${message}` }], isError: true }; - } - } - - // 일반 엔드포인트 처리 const entry = endpoints[name]; if (!entry) { - return { - content: [{ type: "text", text: `알 수 없는 툴: ${name}` }], - isError: true, - }; + return { content: [{ type: "text", text: `알 수 없는 툴: ${name}` }], isError: true }; } - const [endpoint, buildBody] = entry; - const body = buildBody(args as Record); - try { - const result = await client.post(endpoint, body); - const text = formatResult(result); - return { content: [{ type: "text", text }] }; + const result = await client.post(endpoint, buildBody(a)); + return { content: [{ type: "text", text: formatResult(result) }] }; } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text", text: `API 오류: ${message}` }], isError: true }; + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `API 오류: ${msg}` }], isError: true }; } }); -function formatResult(result: unknown): string { - if (typeof result !== "object" || result === null) { - return String(result); +// ───────────────────────────────────────────── +// dry_run 핸들러 +// ───────────────────────────────────────────── +function handleDryRun(name: string, a: Record) { + const body = stripMeta(a); + + // 엔드포인트 맵 + const endpointMap: Record = { + sendbill_get_token: "/api/agent/certificate/gettoken", + sendbill_upload_cert: "/api/agent/certificate/uploadcert", + sendbill_inquire_cert: "/api/agent/certificate/inquirecert", + sendbill_delete_cert: "/api/agent/certificate/deletecert", + sendbill_get_cert_upload_view: "/api/agent/certificate/getuploadview", + sendbill_register_invoice: "/api/agent/document/registDirect", + sendbill_register_invoice_multi: "/api/agent/document/registMultiDirect", + sendbill_register_invoice_bulk: "/api/agent/document/registBulkDirect", + sendbill_get_invoice_status: "/api/agent/status/detail", + sendbill_get_invoice_status_list: "/api/agent/status/list", + sendbill_manage_invoice: "/api/agent/manager/registDirect", + sendbill_get_invoice_error_log: "/api/agent/log/bill", + sendbill_get_manage_error_log: "/api/agent/log/manager", + sendbill_register_member: "/api/agent/member/registDirect", + sendbill_get_member_error_log: "/api/agent/log/member", + sendbill_hometax_register_account: "/api/agent/hometax/account/regist", + sendbill_hometax_delete_account: "/api/agent/hometax/account/delete", + sendbill_hometax_get_register_view: "/api/agent/hometax/account/getRegistView", + sendbill_hometax_query_tax_invoice: "/api/agent/hometax/listDailyTaxInvoice[Sales|Purchase]", + sendbill_hometax_query_cash_receipt: "/api/agent/hometax/listDailyCashBill[Sales|Purchase]", + sendbill_company_close_search: "/api/agent/companyclosesearch", + sendbill_send_additional_mail: "/api/agent/additionalMail/sendAdditionalMail", + sendbill_get_mail_send_list: "/api/agent/additionalMail/sendMailList", + }; + + const endpoint = endpointMap[name] ?? "/api/agent/unknown"; + + // 툴별 검증 규칙 적용 + let validation = { valid: true, errors: [] as import("./validator.js").ValidationError[], warnings: [] as string[] }; + if (name === "sendbill_register_invoice") { + validation = validateInvoice(body); + } else if (name === "sendbill_register_invoice_multi" || name === "sendbill_register_invoice_bulk") { + const docs = (body.documents as Record[] | undefined) ?? []; + const allErrors: import("./validator.js").ValidationError[] = []; + const allWarnings: string[] = []; + if (docs.length === 0) { + allErrors.push({ field: "documents", value: docs, rule: "required", message: "발행할 계산서가 없습니다." }); + } + docs.forEach((doc, i) => { + const r = validateInvoice(doc); + r.errors.forEach(e => allErrors.push({ ...e, field: `documents[${i}].${e.field}` })); + allWarnings.push(...r.warnings.map(w => `[${i}번째] ${w}`)); + }); + if (name === "sendbill_register_invoice_multi" && docs.length > 2) { + allErrors.push({ field: "documents", value: docs.length, rule: "maxItems", message: "묶음 발행은 최대 2건까지 가능합니다." }); + } + validation = { valid: allErrors.length === 0, errors: allErrors, warnings: allWarnings }; + } else if (name === "sendbill_register_member") { + validation = validateMember(body); + } else if (name === "sendbill_company_close_search") { + validation = validateCompanyCloseSearch(body); + } else if (name === "sendbill_hometax_query_tax_invoice" || name === "sendbill_hometax_query_cash_receipt") { + const period = a.period as string; + const direction = a.direction as string; + const bodyWithMeta = { ...body, _period: period, _direction: direction }; + validation = validateHometaxQuery(bodyWithMeta); } + + const text = formatDryRunResult(name, endpoint, body, validation); + return { content: [{ type: "text", text }] }; +} + +// ───────────────────────────────────────────── +// 홈택스 실제 API 호출 +// ───────────────────────────────────────────── +async function handleHometaxQuery( + name: string, + a: Record, + client: import("./client.js").SendbillClient +) { + const direction = a.direction as string; + const period = a.period as string; + const isTax = name === "sendbill_hometax_query_tax_invoice"; + + const endpointMap: Record = isTax + ? { + "sales-daily": "/api/agent/hometax/listDailyTaxInvoiceSales", + "sales-monthly": "/api/agent/hometax/listMonthlyTaxInvoiceSales", + "purchase-daily": "/api/agent/hometax/listDailyTaxInvoicePurchase", + "purchase-monthly": "/api/agent/hometax/listMonthlyTaxInvoicePurchase", + } + : { + "sales-daily": "/api/agent/hometax/listDailyCashBillSales", + "sales-monthly": "/api/agent/hometax/listMonthlyCashBillSales", + "purchase-daily": "/api/agent/hometax/listDailyCashBillPurchase", + "purchase-monthly": "/api/agent/hometax/listMonthlyCashBillPurchase", + }; + + const endpoint = endpointMap[`${direction}-${period}`]; + if (!endpoint) { + return { content: [{ type: "text", text: "잘못된 direction 또는 period 값입니다." }], isError: true }; + } + + const body: Record = { + venderno: a.venderno, + orderdirection: a.orderdirection, + }; + if (isTax) { body.taxtype = a.taxtype; body.datetype = a.datetype; } + if (period === "daily") body.basedate = a.basedate; + else body.basemonth = a.basemonth; + + try { + const result = await client.post(endpoint, body); + return { content: [{ type: "text", text: formatResult(result) }] }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `API 오류: ${msg}` }], isError: true }; + } +} + +// ───────────────────────────────────────────── +// 유틸 +// ───────────────────────────────────────────── +function stripMeta(a: Record): Record { + const out = { ...a }; + delete out["dry_run"]; + delete out["_period"]; + delete out["_direction"]; + return out; +} + +function formatResult(result: unknown): string { + if (typeof result !== "object" || result === null) return String(result); const obj = result as Record; const lines: string[] = []; - - // Result 코드 해석 - if ("Result" in obj || "result" in obj) { - const code = (obj.Result ?? obj.result) as number; - const msg = (obj.Message ?? obj.message) as string | undefined; - if (code < 0) { + const code = (obj.Result ?? obj.result ?? obj.code) as number | undefined; + const msg = (obj.Message ?? obj.message) as string | undefined; + if (code !== undefined) { + if (Number(code) < 0) { lines.push(`❌ 실패 (Result: ${code})`); - lines.push(` 오류: ${msg ?? getErrorMessage(code)}`); + lines.push(` 오류: ${msg ?? getErrorMessage(Number(code))}`); } else { lines.push(`✅ 성공 (Result: ${code})`); if (msg) lines.push(` 메시지: ${msg}`); } } - - // 나머지 데이터 - const skip = new Set(["Result", "result", "Message", "message"]); + const skip = new Set(["Result", "result", "Message", "message", "code"]); const rest = Object.fromEntries(Object.entries(obj).filter(([k]) => !skip.has(k))); if (Object.keys(rest).length > 0) { lines.push("\n데이터:"); lines.push(JSON.stringify(rest, null, 2)); } - return lines.join("\n") || JSON.stringify(result, null, 2); } @@ -761,9 +831,8 @@ function formatResult(result: unknown): string { // ───────────────────────────────────────────── async function main() { const transport = new StdioServerTransport(); - await server.connect(transport); - // stderr에만 출력 (stdout은 MCP 프로토콜 전용) - process.stderr.write("센드빌 MCP 서버 시작됨\n"); + await mcp.connect(transport); + process.stderr.write("센드빌 MCP 서버 v1.1.0 시작됨 (dry_run 모드 지원)\n"); } main().catch((err) => { diff --git a/src/validator.ts b/src/validator.ts new file mode 100644 index 0000000..6829e88 --- /dev/null +++ b/src/validator.ts @@ -0,0 +1,534 @@ +/** + * 센드빌 API 파라미터 유효성 검증기 + * ERP DB 필드 → 센드빌 API 필드 매핑 테스트용 + */ + +import { CODE_TABLES } from "./error-codes.js"; + +export interface ValidationError { + field: string; + value: unknown; + rule: string; + message: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; + warnings: string[]; +} + +// ──────────────────────────────────────────────────── +// 기본 검증 헬퍼 +// ──────────────────────────────────────────────────── + +function checkRequired( + errors: ValidationError[], + body: Record, + field: string, + label?: string +) { + const val = body[field]; + if (val === undefined || val === null || String(val).trim() === "") { + errors.push({ + field, + value: val, + rule: "required", + message: `[${label ?? field}] 필수 항목입니다.`, + }); + } +} + +function checkMaxLength( + errors: ValidationError[], + body: Record, + field: string, + maxLen: number, + label?: string +) { + const val = body[field]; + if (val !== undefined && val !== null) { + // 한국어 포함 byte 체크 (EUC-KR 기준: 한글 2byte) + const byteLen = calcBytes(String(val)); + if (byteLen > maxLen) { + errors.push({ + field, + value: val, + rule: "maxLength", + message: `[${label ?? field}] 최대 길이는 ${maxLen} bytes입니다. (현재 약 ${byteLen} bytes)`, + }); + } + } +} + +function checkOnlyDigits( + errors: ValidationError[], + body: Record, + field: string, + label?: string +) { + const val = body[field]; + if (val !== undefined && val !== null && String(val).trim() !== "") { + if (!/^\d+$/.test(String(val).replace(/-/g, ""))) { + errors.push({ + field, + value: val, + rule: "digitsOnly", + message: `[${label ?? field}] 숫자만 입력 가능합니다.`, + }); + } + } +} + +function checkOneOf( + errors: ValidationError[], + body: Record, + field: string, + allowed: string[], + label?: string +) { + const val = body[field]; + if (val !== undefined && val !== null && String(val).trim() !== "") { + if (!allowed.includes(String(val))) { + errors.push({ + field, + value: val, + rule: "oneOf", + message: `[${label ?? field}] 올바른 코드 값을 입력해주세요. [ ${allowed.join(", ")} ]`, + }); + } + } +} + +function checkEmail( + errors: ValidationError[], + body: Record, + field: string, + label?: string +) { + const val = body[field]; + if (val !== undefined && val !== null && String(val).trim() !== "") { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(val))) { + errors.push({ + field, + value: val, + rule: "email", + message: `[${label ?? field}] 올바른 이메일 형식이 아닙니다.`, + }); + } + } +} + +function checkDateFormat( + errors: ValidationError[], + body: Record, + field: string, + format: "YYYY-MM-DD" | "YYYYMMDD" | "YYYYMM", + label?: string +) { + const val = body[field]; + if (val !== undefined && val !== null && String(val).trim() !== "") { + const s = String(val); + let ok = false; + if (format === "YYYY-MM-DD") ok = /^\d{4}-\d{2}-\d{2}$/.test(s); + if (format === "YYYYMMDD") ok = /^\d{8}$/.test(s); + if (format === "YYYYMM") ok = /^\d{6}$/.test(s); + if (!ok) { + errors.push({ + field, + value: val, + rule: "dateFormat", + message: `[${label ?? field}] 날짜 형식이 올바르지 않습니다. (${format} 형식 필요)`, + }); + } else if (format === "YYYY-MM-DD") { + const d = new Date(s); + if (d > new Date()) { + errors.push({ + field, + value: val, + rule: "futureDate", + message: `[${label ?? field}] 미래 날짜는 입력할 수 없습니다.`, + }); + } + } + } +} + +function checkBusinessNo( + errors: ValidationError[], + body: Record, + field: string, + label?: string +) { + const val = body[field]; + if (val !== undefined && val !== null && String(val).trim() !== "") { + const s = String(val).replace(/-/g, ""); + if (!/^\d{10}$/.test(s)) { + errors.push({ + field, + value: val, + rule: "businessNo", + message: `[${label ?? field}] 올바른 형태의 사업자번호가 아닙니다. (하이픈 제외 10자리 숫자)`, + }); + } + } +} + +function checkPhoneFormat( + _errors: ValidationError[], + body: Record, + field: string, + warnings: string[] +) { + const val = body[field]; + if (val !== undefined && val !== null && String(val).trim() !== "") { + if (!/^\d{2,4}-\d{3,4}-\d{4}$/.test(String(val))) { + warnings.push(`[${field}] 전화번호 형식 권장: 000-0000-0000 (현재값: ${val})`); + } + } +} + +function checkMoneyField( + errors: ValidationError[], + body: Record, + field: string, + maxLen: number, + label?: string +) { + const val = body[field]; + if (val !== undefined && val !== null && String(val).trim() !== "") { + if (!/^-?\d+$/.test(String(val))) { + errors.push({ + field, + value: val, + rule: "moneyFormat", + message: `[${label ?? field}] 음의 부호(-) 또는 숫자만 입력 가능합니다.`, + }); + } + if (String(val).length > maxLen) { + errors.push({ + field, + value: val, + rule: "maxLength", + message: `[${label ?? field}] 최대 ${maxLen}자리를 초과합니다.`, + }); + } + } +} + +// 한글 포함 byte 길이 계산 (EUC-KR 기준) +function calcBytes(s: string): number { + let b = 0; + for (const c of s) { + b += c.charCodeAt(0) > 127 ? 2 : 1; + } + return b; +} + +// ──────────────────────────────────────────────────── +// 엔드포인트별 검증 규칙 +// ──────────────────────────────────────────────────── + +export function validateInvoice(body: Record): ValidationResult { + const errors: ValidationError[] = []; + const warnings: string[] = []; + + // billseq + checkRequired(errors, body, "billseq", "세금계산서고유번호"); + checkMaxLength(errors, body, "billseq", 25, "세금계산서고유번호"); + + // 공급자 + checkRequired(errors, body, "svenderno", "공급자사업자번호"); + checkBusinessNo(errors, body, "svenderno", "공급자사업자번호"); + + // 공급받는자 + checkRequired(errors, body, "rvenderno", "공급받는자사업자번호"); + checkMaxLength(errors, body, "rvenderno", 13, "공급받는자사업자번호"); + checkOnlyDigits(errors, body, "rvenderno", "공급받는자사업자번호"); + + // 작성일 + checkRequired(errors, body, "dt", "작성일"); + checkDateFormat(errors, body, "dt", "YYYY-MM-DD", "작성일"); + + // 금액 + checkRequired(errors, body, "supmoney", "공급가액"); + checkMoneyField(errors, body, "supmoney", 13, "공급가액"); + checkRequired(errors, body, "taxmoney", "세액"); + checkMoneyField(errors, body, "taxmoney", 13, "세액"); + checkMoneyField(errors, body, "cash", 13, "현금"); + checkMoneyField(errors, body, "checks", 13, "수표"); + checkMoneyField(errors, body, "note", 13, "어음"); + checkMoneyField(errors, body, "credit", 13, "외상미수금"); + + // 코드 + checkRequired(errors, body, "billtype", "계산서종류"); + checkOneOf(errors, body, "billtype", Object.keys(CODE_TABLES.billtype), "계산서종류"); + checkRequired(errors, body, "gubun", "청구구분"); + checkOneOf(errors, body, "gubun", Object.keys(CODE_TABLES.gubun), "청구구분"); + if (body.taxrate !== undefined) { + checkOneOf(errors, body, "taxrate", Object.keys(CODE_TABLES.taxrate), "과세구분"); + } + if (body.etc01 !== undefined) { + checkOneOf(errors, body, "etc01", Object.keys(CODE_TABLES.etc01), "ID구분"); + } + checkRequired(errors, body, "report_except_yn", "신고제외여부"); + checkOneOf(errors, body, "report_except_yn", ["Y", "N"], "신고제외여부"); + checkRequired(errors, body, "reverseyn", "역발행여부"); + checkOneOf(errors, body, "reverseyn", ["Y", "N"], "역발행여부"); + if (body.transyn !== undefined) { + checkOneOf(errors, body, "transyn", ["Y", "N"], "전송여부"); + } + if (body.test_yn !== undefined) { + checkOneOf(errors, body, "test_yn", ["Y", "N"], "테스트여부"); + } + if (body.etc03 !== undefined) { + checkOneOf(errors, body, "etc03", ["Y", "N"], "비회원자동가입"); + } + + // 공급자 정보 + checkRequired(errors, body, "scompany", "공급자업체명"); + checkMaxLength(errors, body, "scompany", 200, "공급자업체명"); + checkRequired(errors, body, "sceoname", "공급자대표자명"); + checkMaxLength(errors, body, "sceoname", 100, "공급자대표자명"); + checkMaxLength(errors, body, "suptae", 100, "공급자업태"); + checkMaxLength(errors, body, "supjong", 100, "공급자업종"); + checkMaxLength(errors, body, "saddress", 150, "공급자주소"); + checkMaxLength(errors, body, "suser", 100, "공급자담당자"); + checkMaxLength(errors, body, "stelno", 20, "공급자전화번호"); + checkEmail(errors, body, "semail", "공급자이메일"); + checkMaxLength(errors, body, "semail", 100, "공급자이메일"); + if (body.stelno) checkPhoneFormat(errors as ValidationError[], body, "stelno", warnings); + + // 공급받는자 정보 + checkRequired(errors, body, "rcompany", "공급받는자업체명"); + checkMaxLength(errors, body, "rcompany", 200, "공급받는자업체명"); + checkRequired(errors, body, "rceoname", "공급받는자대표자명"); + checkMaxLength(errors, body, "rceoname", 100, "공급받는자대표자명"); + checkMaxLength(errors, body, "ruptae", 100, "공급받는자업태"); + checkMaxLength(errors, body, "rupjong", 100, "공급받는자업종"); + checkMaxLength(errors, body, "raddress", 150, "공급받는자주소"); + checkMaxLength(errors, body, "ruser", 100, "공급받는자담당자"); + checkMaxLength(errors, body, "rtelno", 20, "공급받는자전화번호"); + checkEmail(errors, body, "remail", "공급받는자이메일"); + checkMaxLength(errors, body, "remail", 100, "공급받는자이메일"); + if (body.rtelno) checkPhoneFormat(errors as ValidationError[], body, "rtelno", warnings); + + // 비고 + checkMaxLength(errors, body, "bigo", 150, "비고"); + + // 품목 검증 + const items = body.item as unknown[]; + if (!items || !Array.isArray(items) || items.length === 0) { + errors.push({ field: "item", value: items, rule: "required", message: "[품목] 최소 1개 이상의 품목이 필요합니다." }); + } else { + items.forEach((item, i) => { + const it = item as Record; + if (!it.tax && it.tax !== 0) { + errors.push({ field: `item[${i}].tax`, value: it.tax, rule: "required", message: `[품목${i+1}] 세액(tax)은 필수입니다.` }); + } + if (!it.sup && it.sup !== 0) { + errors.push({ field: `item[${i}].sup`, value: it.sup, rule: "required", message: `[품목${i+1}] 공급가액(sup)은 필수입니다.` }); + } + if (!it.dt) { + errors.push({ field: `item[${i}].dt`, value: it.dt, rule: "required", message: `[품목${i+1}] 거래일자(dt)는 필수입니다.` }); + } else { + checkDateFormat(errors, it, "dt", "YYYY-MM-DD", `품목${i+1}거래일자`); + } + }); + + // 품목 합계 vs 총액 검증 + const supTotal = (items as Record[]).reduce((s, it) => s + Number(it.sup ?? 0), 0); + const taxTotal = (items as Record[]).reduce((s, it) => s + Number(it.tax ?? 0), 0); + const bodySupTotal = Number(body.supmoney ?? 0); + const bodyTaxTotal = Number(body.taxmoney ?? 0); + if (supTotal !== bodySupTotal) { + errors.push({ + field: "supmoney", + value: body.supmoney, + rule: "sumMismatch", + message: `품목별 공급가액 합계(${supTotal})와 총 공급가액(${bodySupTotal})이 일치하지 않습니다. (에러코드 -12651)`, + }); + } + if (taxTotal !== bodyTaxTotal) { + errors.push({ + field: "taxmoney", + value: body.taxmoney, + rule: "sumMismatch", + message: `품목별 세액 합계(${taxTotal})와 총 세액(${bodyTaxTotal})이 일치하지 않습니다. (에러코드 -12651)`, + }); + } + } + + // 수탁자 포함 billtype 확인 + const billtypeStr = String(body.billtype ?? ""); + const brokerBilltypes = ["40", "41", "50", "51"]; + if (body.broker && !brokerBilltypes.includes(billtypeStr)) { + warnings.push(`수탁자(broker)는 billtype이 40/41/50/51일 때만 유효합니다. (현재 billtype: ${body.billtype})`); + } + if (brokerBilltypes.includes(billtypeStr) && !body.broker) { + warnings.push(`billtype ${body.billtype}는 수탁자(broker) 정보가 필요할 수 있습니다.`); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +export function validateMember(body: Record): ValidationResult { + const errors: ValidationError[] = []; + const warnings: string[] = []; + + checkRequired(errors, body, "entcode", "제휴업체코드"); + checkMaxLength(errors, body, "entcode", 4, "제휴업체코드"); + checkRequired(errors, body, "id", "회원아이디"); + checkMaxLength(errors, body, "id", 20, "회원아이디"); + checkRequired(errors, body, "company", "업체명"); + checkMaxLength(errors, body, "company", 200, "업체명"); + checkRequired(errors, body, "venderno", "사업자번호"); + checkBusinessNo(errors, body, "venderno", "사업자번호"); + checkRequired(errors, body, "uptae", "업태"); + checkMaxLength(errors, body, "uptae", 100, "업태"); + checkRequired(errors, body, "upjong", "업종"); + checkMaxLength(errors, body, "upjong", 100, "업종"); + checkRequired(errors, body, "address", "주소"); + checkMaxLength(errors, body, "address", 150, "주소"); + checkRequired(errors, body, "mgmt_yn", "관리자구분"); + checkOneOf(errors, body, "mgmt_yn", ["Y", "N"], "관리자구분"); + checkRequired(errors, body, "email_yn", "이메일수신여부"); + checkOneOf(errors, body, "email_yn", ["Y", "N"], "이메일수신여부"); + checkRequired(errors, body, "sms_yn", "SMS수신여부"); + checkOneOf(errors, body, "sms_yn", ["Y", "N"], "SMS수신여부"); + checkMaxLength(errors, body, "zipcode", 6, "우편번호"); + checkEmail(errors, body, "email", "이메일"); + checkMaxLength(errors, body, "fax", 20, "팩스"); + checkMaxLength(errors, body, "tel", 20, "전화번호"); + checkMaxLength(errors, body, "smsno", 20, "핸드폰번호"); + if (body.sign_yn !== undefined) { + checkOneOf(errors, body, "sign_yn", ["Y", "N"], "담당자동의여부"); + } + if (body.resno) { + if (!/^\d{13}$/.test(String(body.resno).replace(/-/g, ""))) { + errors.push({ field: "resno", value: body.resno, rule: "format", message: "[주민등록번호] 올바른 형태의 주민등록번호가 아닙니다." }); + } + } + + return { valid: errors.length === 0, errors, warnings }; +} + +export function validateCompanyCloseSearch(body: Record): ValidationResult { + const errors: ValidationError[] = []; + const warnings: string[] = []; + + const vendernos = body.venderno as string[]; + if (!vendernos || !Array.isArray(vendernos) || vendernos.length === 0) { + errors.push({ field: "venderno", value: vendernos, rule: "required", message: "사업자번호 목록이 필요합니다." }); + } else { + if (vendernos.length > 100) { + errors.push({ field: "venderno", value: vendernos.length, rule: "maxItems", message: "최대 100건까지 조회 가능합니다." }); + } + vendernos.forEach((vn, i) => { + if (!/^\d{10}$/.test(String(vn).replace(/-/g, ""))) { + errors.push({ field: `venderno[${i}]`, value: vn, rule: "businessNo", message: `사업자번호[${i}] 형식 오류: ${vn} (10자리 숫자)` }); + } + }); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +export function validateHometaxQuery(body: Record): ValidationResult { + const errors: ValidationError[] = []; + const warnings: string[] = []; + const period = body._period as string; + + checkRequired(errors, body, "venderno", "사업자번호"); + checkBusinessNo(errors, body, "venderno", "사업자번호"); + + if (body.taxtype !== undefined) { + checkOneOf(errors, body, "taxtype", ["01", "03"], "과세구분"); + } + if (body.datetype !== undefined) { + checkOneOf(errors, body, "datetype", ["01", "02"], "기준일자유형"); + } + checkRequired(errors, body, "orderdirection", "정렬방향"); + checkOneOf(errors, body, "orderdirection", ["01", "02"], "정렬방향"); + + if (period === "daily") { + checkRequired(errors, body, "basedate", "기준일자"); + checkDateFormat(errors, body, "basedate", "YYYYMMDD", "기준일자"); + } else { + checkRequired(errors, body, "basemonth", "기준월"); + checkDateFormat(errors, body, "basemonth", "YYYYMM", "기준월"); + warnings.push("홈택스 접근제어로 30건당 5초 딜레이가 발생할 수 있습니다."); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +// ──────────────────────────────────────────────────── +// dry-run 결과 포맷터 +// ──────────────────────────────────────────────────── + +export function formatDryRunResult( + toolName: string, + endpoint: string, + body: Record, + validation: ValidationResult +): string { + const lines: string[] = []; + lines.push("═══════════════════════════════════════════"); + lines.push(`🧪 DRY-RUN 결과: ${toolName}`); + lines.push("═══════════════════════════════════════════"); + lines.push(""); + + // 검증 결과 + if (validation.valid) { + lines.push("✅ 유효성 검증: 통과"); + } else { + lines.push(`❌ 유효성 검증: 실패 (${validation.errors.length}개 오류)`); + } + lines.push(""); + + // 오류 상세 + if (validation.errors.length > 0) { + lines.push("─── 오류 목록 ───────────────────────────"); + validation.errors.forEach((e, i) => { + lines.push(` ${i + 1}. [${e.field}] ${e.message}`); + if (e.value !== undefined && e.value !== null && e.value !== "") { + lines.push(` 입력값: ${JSON.stringify(e.value)}`); + } + }); + lines.push(""); + } + + // 경고 + if (validation.warnings.length > 0) { + lines.push("─── 주의사항 ────────────────────────────"); + validation.warnings.forEach((w) => lines.push(` ⚠️ ${w}`)); + lines.push(""); + } + + // 전송될 엔드포인트 + lines.push("─── 호출 정보 ───────────────────────────"); + lines.push(` 메서드 : POST`); + lines.push(` URL : https://api.sendbill.co.kr${endpoint}`); + lines.push(` 헤더 : SBKEY: `); + lines.push(` Content-Type: application/json; charset=UTF-8`); + lines.push(""); + + // 실제 전송될 JSON payload + const payloadBody = { ...body }; + delete payloadBody["dry_run"]; + delete payloadBody["_period"]; + delete payloadBody["_direction"]; + + lines.push("─── 전송될 JSON payload ─────────────────"); + lines.push(JSON.stringify(payloadBody, null, 2)); + lines.push(""); + + if (validation.valid) { + lines.push("💡 dry_run: false 로 변경하면 실제 API를 호출합니다."); + } else { + lines.push("💡 위 오류를 수정한 후 다시 시도하세요."); + } + + return lines.join("\n"); +}