🔧 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:
thug0bin 2026-03-02 02:10:21 +09:00
parent 65c69f5666
commit d4a7b9de07
2 changed files with 801 additions and 198 deletions

View File

@ -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,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<string, unknown>;
const direction = a.direction as string;
const period = a.period as string;
const endpointMap: Record<string, string> = {
"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_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<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",
"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<string, unknown> = {
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<string, unknown>);
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<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> = 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<string, unknown> = {
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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
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) => {

534
src/validator.ts Normal file
View 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");
}