🔧 McpServer 마이그레이션 + dry_run 모드 추가
- Server(deprecated) → McpServer로 마이그레이션 (TypeScript 경고 제거) - mcp.server.setRequestHandler() 패턴으로 핸들러 등록 - mcp.connect(transport)로 서버 시작 - dry_run: boolean 파라미터 전 API 툴에 추가 - SBKEY 없이 파라미터 유효성 검증 가능 - ERP DB 필드 매핑 테스트용 - validator.ts 추가: 청구서/회원/휴폐업/홈택스 검증 로직 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
65c69f5666
commit
d4a7b9de07
417
src/index.ts
417
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<string, unknown>;
|
||||
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<string, [string, (a: Record<string, unknown>) => Record<string, unknown>]>;
|
||||
|
||||
const endpoints: EndpointMap = {
|
||||
// 인증서 관리
|
||||
type EndpointEntry = [string, (a: Record<string, unknown>) => Record<string, unknown>];
|
||||
const endpoints: Record<string, EndpointEntry> = {
|
||||
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,79 +636,133 @@ 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<string, unknown>;
|
||||
// 홈택스 조회 (방향+주기 조합 라우팅)
|
||||
if (name === "sendbill_hometax_query_tax_invoice" || name === "sendbill_hometax_query_cash_receipt") {
|
||||
return await handleHometaxQuery(name, a, client);
|
||||
}
|
||||
|
||||
const entry = endpoints[name];
|
||||
if (!entry) {
|
||||
return { content: [{ type: "text", text: `알 수 없는 툴: ${name}` }], isError: true };
|
||||
}
|
||||
const [endpoint, buildBody] = entry;
|
||||
try {
|
||||
const result = await client.post(endpoint, buildBody(a));
|
||||
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 };
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// dry_run 핸들러
|
||||
// ─────────────────────────────────────────────
|
||||
function handleDryRun(name: string, a: Record<string, unknown>) {
|
||||
const body = stripMeta(a);
|
||||
|
||||
// 엔드포인트 맵
|
||||
const endpointMap: Record<string, string> = {
|
||||
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<string, unknown>[] | 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<string, unknown>,
|
||||
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<string, string> = {
|
||||
const endpointMap: Record<string, string> = 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",
|
||||
};
|
||||
|
||||
const endpoint = endpointMap[`${direction}-${period}`];
|
||||
if (!endpoint) {
|
||||
return { content: [{ type: "text", text: "잘못된 direction 또는 period 값입니다." }], isError: true };
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
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_cash_receipt") {
|
||||
const a = args as Record<string, unknown>;
|
||||
const direction = a.direction as string;
|
||||
const period = a.period as string;
|
||||
|
||||
const endpointMap: Record<string, string> = {
|
||||
: {
|
||||
"sales-daily": "/api/agent/hometax/listDailyCashBillSales",
|
||||
"sales-monthly": "/api/agent/hometax/listMonthlyCashBillSales",
|
||||
"purchase-daily": "/api/agent/hometax/listDailyCashBillPurchase",
|
||||
@ -690,69 +778,51 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
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);
|
||||
const text = formatResult(result);
|
||||
return { content: [{ type: "text", text }] };
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 엔드포인트 처리
|
||||
const entry = endpoints[name];
|
||||
if (!entry) {
|
||||
return {
|
||||
content: [{ type: "text", text: `알 수 없는 툴: ${name}` }],
|
||||
isError: true,
|
||||
};
|
||||
// ─────────────────────────────────────────────
|
||||
// 유틸
|
||||
// ─────────────────────────────────────────────
|
||||
function stripMeta(a: Record<string, unknown>): Record<string, unknown> {
|
||||
const out = { ...a };
|
||||
delete out["dry_run"];
|
||||
delete out["_period"];
|
||||
delete out["_direction"];
|
||||
return out;
|
||||
}
|
||||
|
||||
const [endpoint, buildBody] = entry;
|
||||
const body = buildBody(args as Record<string, unknown>);
|
||||
|
||||
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 };
|
||||
}
|
||||
});
|
||||
|
||||
function formatResult(result: unknown): string {
|
||||
if (typeof result !== "object" || result === null) {
|
||||
return String(result);
|
||||
}
|
||||
if (typeof result !== "object" || result === null) return String(result);
|
||||
const obj = result as Record<string, unknown>;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Result 코드 해석
|
||||
if ("Result" in obj || "result" in obj) {
|
||||
const code = (obj.Result ?? obj.result) as number;
|
||||
const code = (obj.Result ?? obj.result ?? obj.code) as number | undefined;
|
||||
const msg = (obj.Message ?? obj.message) as string | undefined;
|
||||
if (code < 0) {
|
||||
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) => {
|
||||
|
||||
534
src/validator.ts
Normal file
534
src/validator.ts
Normal file
@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>): 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<string, unknown>;
|
||||
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<string, unknown>[]).reduce((s, it) => s + Number(it.sup ?? 0), 0);
|
||||
const taxTotal = (items as Record<string, unknown>[]).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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>,
|
||||
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: <SENDBILL_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");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user