@nhnpayco-dev/mcp-devcenter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +197 -0
  3. package/dist/data/api-specs.json +8135 -0
  4. package/dist/data/error-codes.json +2212 -0
  5. package/dist/data/guides/calculate.md +164 -0
  6. package/dist/data/guides/calculate.meta.json +22 -0
  7. package/dist/data/guides/membership-api.md +967 -0
  8. package/dist/data/guides/membership-api.meta.json +27 -0
  9. package/dist/data/guides/membership-check.md +30 -0
  10. package/dist/data/guides/membership-check.meta.json +24 -0
  11. package/dist/data/guides/membership-code.md +59 -0
  12. package/dist/data/guides/membership-code.meta.json +22 -0
  13. package/dist/data/guides/membership-sample.md +23 -0
  14. package/dist/data/guides/membership-sample.meta.json +22 -0
  15. package/dist/data/guides/membership-start.md +74 -0
  16. package/dist/data/guides/membership-start.meta.json +26 -0
  17. package/dist/data/guides/membership-vmem.md +512 -0
  18. package/dist/data/guides/membership-vmem.meta.json +24 -0
  19. package/dist/data/guides/offline-api.md +743 -0
  20. package/dist/data/guides/offline-api.meta.json +50 -0
  21. package/dist/data/guides/offline-authapi.md +1170 -0
  22. package/dist/data/guides/offline-authapi.meta.json +81 -0
  23. package/dist/data/guides/offline-check.md +33 -0
  24. package/dist/data/guides/offline-check.meta.json +24 -0
  25. package/dist/data/guides/offline-code.md +138 -0
  26. package/dist/data/guides/offline-code.meta.json +22 -0
  27. package/dist/data/guides/offline-sample.md +34 -0
  28. package/dist/data/guides/offline-sample.meta.json +22 -0
  29. package/dist/data/guides/offline-start.md +99 -0
  30. package/dist/data/guides/offline-start.meta.json +25 -0
  31. package/dist/data/guides/offline-vcat.md +370 -0
  32. package/dist/data/guides/offline-vcat.meta.json +35 -0
  33. package/dist/data/guides/online-autopay.md +495 -0
  34. package/dist/data/guides/online-autopay.meta.json +50 -0
  35. package/dist/data/guides/online-check.md +57 -0
  36. package/dist/data/guides/online-check.meta.json +24 -0
  37. package/dist/data/guides/online-code.md +367 -0
  38. package/dist/data/guides/online-code.meta.json +22 -0
  39. package/dist/data/guides/online-easypay.md +530 -0
  40. package/dist/data/guides/online-easypay.meta.json +50 -0
  41. package/dist/data/guides/online-etc.md +167 -0
  42. package/dist/data/guides/online-etc.meta.json +36 -0
  43. package/dist/data/guides/online-extra.md +190 -0
  44. package/dist/data/guides/online-extra.meta.json +35 -0
  45. package/dist/data/guides/online-extradata.md +103 -0
  46. package/dist/data/guides/online-extradata.meta.json +54 -0
  47. package/dist/data/guides/online-sample.md +23 -0
  48. package/dist/data/guides/online-sample.meta.json +22 -0
  49. package/dist/data/guides/online-start.md +122 -0
  50. package/dist/data/guides/online-start.meta.json +25 -0
  51. package/dist/data/guides/order-check.md +35 -0
  52. package/dist/data/guides/order-check.meta.json +24 -0
  53. package/dist/data/guides/order-code.md +143 -0
  54. package/dist/data/guides/order-code.meta.json +22 -0
  55. package/dist/data/guides/order-sample.md +31 -0
  56. package/dist/data/guides/order-sample.meta.json +22 -0
  57. package/dist/data/guides/order-start.md +94 -0
  58. package/dist/data/guides/order-start.meta.json +25 -0
  59. package/dist/data/guides/order-vorder.md +326 -0
  60. package/dist/data/guides/order-vorder.meta.json +24 -0
  61. package/dist/data/index.json +782 -0
  62. package/dist/data/sitemap.json +302 -0
  63. package/dist/data.js +42 -0
  64. package/dist/excel.js +320 -0
  65. package/dist/index.js +25 -0
  66. package/dist/tools.js +929 -0
  67. package/dist/validation-buffer.js +29 -0
  68. package/package.json +58 -0
@@ -0,0 +1,302 @@
1
+ [
2
+ {
3
+ "text": "결제/정산 대사",
4
+ "href": "/guide/calculate?id=220401040",
5
+ "path": "/guide/calculate",
6
+ "id": "220401040"
7
+ },
8
+ {
9
+ "text": "② POS, KIOSK",
10
+ "href": "/guide/membership/api/pos?id=220401036",
11
+ "path": "/guide/membership/api/pos",
12
+ "id": "220401036"
13
+ },
14
+ {
15
+ "text": "API 연동하기",
16
+ "href": "/guide/membership/api/web?id=220401035",
17
+ "path": "/guide/membership/api/web",
18
+ "id": "220401035"
19
+ },
20
+ {
21
+ "text": "검수하기",
22
+ "href": "/guide/membership/check?id=220401038",
23
+ "path": "/guide/membership/check",
24
+ "id": "220401038"
25
+ },
26
+ {
27
+ "text": "코드 정리",
28
+ "href": "/guide/membership/code/start?id=220401037",
29
+ "path": "/guide/membership/code/start",
30
+ "id": "220401037"
31
+ },
32
+ {
33
+ "text": "소스코드 샘플",
34
+ "href": "/guide/membership/sample?id=220401039",
35
+ "path": "/guide/membership/sample",
36
+ "id": "220401039"
37
+ },
38
+ {
39
+ "text": "멤버십 클라우드",
40
+ "href": "/guide/membership/start?id=220401033",
41
+ "path": "/guide/membership/start",
42
+ "id": "220401033"
43
+ },
44
+ {
45
+ "text": "VMEM 연동하기",
46
+ "href": "/guide/membership/vmem/start?id=220401034",
47
+ "path": "/guide/membership/vmem/start",
48
+ "id": "220401034"
49
+ },
50
+ {
51
+ "text": "③ 결제 승인",
52
+ "href": "/guide/offline/api/approval?id=2204010244",
53
+ "path": "/guide/offline/api/approval",
54
+ "id": "2204010244"
55
+ },
56
+ {
57
+ "text": "④ 결제 취소",
58
+ "href": "/guide/offline/api/cancel?id=2204010245",
59
+ "path": "/guide/offline/api/cancel",
60
+ "id": "2204010245"
61
+ },
62
+ {
63
+ "text": "① API 공통 규약",
64
+ "href": "/guide/offline/api/common?id=2204010242",
65
+ "path": "/guide/offline/api/common",
66
+ "id": "2204010242"
67
+ },
68
+ {
69
+ "text": "API 연동하기",
70
+ "href": "/guide/offline/api/flow?id=2204010241",
71
+ "path": "/guide/offline/api/flow",
72
+ "id": "2204010241"
73
+ },
74
+ {
75
+ "text": "⑤ 결제 망취소",
76
+ "href": "/guide/offline/api/netCancel?id=2204010246",
77
+ "path": "/guide/offline/api/netCancel",
78
+ "id": "2204010246"
79
+ },
80
+ {
81
+ "text": "② POS 등록",
82
+ "href": "/guide/offline/api/pos?id=2204010243",
83
+ "path": "/guide/offline/api/pos",
84
+ "id": "2204010243"
85
+ },
86
+ {
87
+ "text": "검수하기",
88
+ "href": "/guide/offline/check?id=220401026",
89
+ "path": "/guide/offline/check",
90
+ "id": "220401026"
91
+ },
92
+ {
93
+ "text": "코드정리",
94
+ "href": "/guide/offline/code/start?id=220401025",
95
+ "path": "/guide/offline/code/start",
96
+ "id": "220401025"
97
+ },
98
+ {
99
+ "text": "소스코드 샘플",
100
+ "href": "/guide/offline/sample?id=220401027",
101
+ "path": "/guide/offline/sample",
102
+ "id": "220401027"
103
+ },
104
+ {
105
+ "text": "오프라인 결제",
106
+ "href": "/guide/offline/start?id=220401022",
107
+ "path": "/guide/offline/start",
108
+ "id": "220401022"
109
+ },
110
+ {
111
+ "text": "② DLL 사용하기",
112
+ "href": "/guide/offline/vcat/dll?id=2204010233",
113
+ "path": "/guide/offline/vcat/dll",
114
+ "id": "2204010233"
115
+ },
116
+ {
117
+ "text": "VCAT 연동하기",
118
+ "href": "/guide/offline/vcat/flow?id=2204010231",
119
+ "path": "/guide/offline/vcat/flow",
120
+ "id": "2204010231"
121
+ },
122
+ {
123
+ "text": "① VCAT 알아보기",
124
+ "href": "/guide/offline/vcat/vcat?id=2204010232",
125
+ "path": "/guide/offline/vcat/vcat",
126
+ "id": "2204010232"
127
+ },
128
+ {
129
+ "text": "⑤ 결제 취소/삭제",
130
+ "href": "/guide/online/autopay/cancel?id=220401012",
131
+ "path": "/guide/online/autopay/cancel",
132
+ "id": "220401012"
133
+ },
134
+ {
135
+ "text": "자동결제 연동하기",
136
+ "href": "/guide/online/autopay/flow?id=220401007",
137
+ "path": "/guide/online/autopay/flow",
138
+ "id": "220401007"
139
+ },
140
+ {
141
+ "text": "③ 결제 정보조회",
142
+ "href": "/guide/online/autopay/info?id=220401010",
143
+ "path": "/guide/online/autopay/info",
144
+ "id": "220401010"
145
+ },
146
+ {
147
+ "text": "④ 결제 승인",
148
+ "href": "/guide/online/autopay/payment?id=220401011",
149
+ "path": "/guide/online/autopay/payment",
150
+ "id": "220401011"
151
+ },
152
+ {
153
+ "text": "① 결제창 호출",
154
+ "href": "/guide/online/autopay/reserve?id=220401008",
155
+ "path": "/guide/online/autopay/reserve",
156
+ "id": "220401008"
157
+ },
158
+ {
159
+ "text": "② 결제 인증",
160
+ "href": "/guide/online/autopay/return?id=220401009",
161
+ "path": "/guide/online/autopay/return",
162
+ "id": "220401009"
163
+ },
164
+ {
165
+ "text": "검수하기",
166
+ "href": "/guide/online/check?id=220401020",
167
+ "path": "/guide/online/check",
168
+ "id": "220401020"
169
+ },
170
+ {
171
+ "text": "코드정리",
172
+ "href": "/guide/online/code/start?id=220401019",
173
+ "path": "/guide/online/code/start",
174
+ "id": "220401019"
175
+ },
176
+ {
177
+ "text": "③ 결제 승인",
178
+ "href": "/guide/online/easypay/approval?id=220401005",
179
+ "path": "/guide/online/easypay/approval",
180
+ "id": "220401005"
181
+ },
182
+ {
183
+ "text": "④ 결제 취소",
184
+ "href": "/guide/online/easypay/cancel?id=220401006",
185
+ "path": "/guide/online/easypay/cancel",
186
+ "id": "220401006"
187
+ },
188
+ {
189
+ "text": "결제 연동하기",
190
+ "href": "/guide/online/easypay/flow?id=220401002",
191
+ "path": "/guide/online/easypay/flow",
192
+ "id": "220401002"
193
+ },
194
+ {
195
+ "text": "결제 연동하기",
196
+ "href": "/guide/online/easypay/reserve?id=220401002",
197
+ "path": "/guide/online/easypay/reserve",
198
+ "id": "220401002"
199
+ },
200
+ {
201
+ "text": "① 결제창 호출",
202
+ "href": "/guide/online/easypay/reserve?id=220401003",
203
+ "path": "/guide/online/easypay/reserve",
204
+ "id": "220401003"
205
+ },
206
+ {
207
+ "text": "② 결제 인증",
208
+ "href": "/guide/online/easypay/return?id=220401004",
209
+ "path": "/guide/online/easypay/return",
210
+ "id": "220401004"
211
+ },
212
+ {
213
+ "text": "바로구매",
214
+ "href": "/guide/online/etc/checkout?id=220401014",
215
+ "path": "/guide/online/etc/checkout",
216
+ "id": "220401014"
217
+ },
218
+ {
219
+ "text": "기타결제 연동하기",
220
+ "href": "/guide/online/etc/nonmember?id=220401013",
221
+ "path": "/guide/online/etc/nonmember",
222
+ "id": "220401013"
223
+ },
224
+ {
225
+ "text": "주문서 결제",
226
+ "href": "/guide/online/etc/ordersheet?id=220401015",
227
+ "path": "/guide/online/etc/ordersheet",
228
+ "id": "220401015"
229
+ },
230
+ {
231
+ "text": "결제 상세조회",
232
+ "href": "/guide/online/extra/detail?id=220401017",
233
+ "path": "/guide/online/extra/detail",
234
+ "id": "220401017"
235
+ },
236
+ {
237
+ "text": "영수증",
238
+ "href": "/guide/online/extra/receipt?id=220401018",
239
+ "path": "/guide/online/extra/receipt",
240
+ "id": "220401018"
241
+ },
242
+ {
243
+ "text": "부가기능",
244
+ "href": "/guide/online/extra/start?id=220401016",
245
+ "path": "/guide/online/extra/start",
246
+ "id": "220401016"
247
+ },
248
+ {
249
+ "text": "소스코드 샘플",
250
+ "href": "/guide/online/sample?id=220401021",
251
+ "path": "/guide/online/sample",
252
+ "id": "220401021"
253
+ },
254
+ {
255
+ "text": "연동하기",
256
+ "href": "/guide/online/start?id=220401001",
257
+ "path": "/guide/online/start",
258
+ "id": "220401001"
259
+ },
260
+ {
261
+ "text": "검수하기",
262
+ "href": "/guide/order/check?id=220401031",
263
+ "path": "/guide/order/check",
264
+ "id": "220401031"
265
+ },
266
+ {
267
+ "text": "코드 정리",
268
+ "href": "/guide/order/code/start?id=220401030",
269
+ "path": "/guide/order/code/start",
270
+ "id": "220401030"
271
+ },
272
+ {
273
+ "text": "소스코드 샘플",
274
+ "href": "/guide/order/sample?id=220401032",
275
+ "path": "/guide/order/sample",
276
+ "id": "220401032"
277
+ },
278
+ {
279
+ "text": "오더 클라우드",
280
+ "href": "/guide/order/start?id=220401028",
281
+ "path": "/guide/order/start",
282
+ "id": "220401028"
283
+ },
284
+ {
285
+ "text": "VORDER 연동하기",
286
+ "href": "/guide/order/vorder/start?id=220401029",
287
+ "path": "/guide/order/vorder/start",
288
+ "id": "220401029"
289
+ },
290
+ {
291
+ "text": "보안사항 확인하기",
292
+ "href": "/guide/secure?id=220401050",
293
+ "path": "/guide/secure",
294
+ "id": "220401050"
295
+ },
296
+ {
297
+ "text": "USERTrust 루트 인증서 설치 가이드",
298
+ "href": "/guide/secure/detail?id=220401051",
299
+ "path": "/guide/secure/detail",
300
+ "id": "220401051"
301
+ }
302
+ ]
package/dist/data.js ADDED
@@ -0,0 +1,42 @@
1
+ // 데이터 로더 — data/index.json, data/error-codes.json, data/guides/*.md 를 메모리에 적재.
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+ function findDataDir() {
9
+ // 1) 환경변수 명시 (운영 배포 시)
10
+ if (process.env.PAYCO_MCP_DATA_DIR)
11
+ return process.env.PAYCO_MCP_DATA_DIR;
12
+ // 2) 패키지 내 dist/data (npm 배포본)
13
+ const packaged = resolve(__dirname, "data");
14
+ if (existsSync(packaged))
15
+ return packaged;
16
+ // 3) 모노리포 개발 모드 — mcp-server/dist/data.js 기준 ../../data
17
+ const dev = resolve(__dirname, "..", "..", "data");
18
+ return dev;
19
+ }
20
+ export async function loadDataStore() {
21
+ const dataDir = findDataDir();
22
+ const indexRaw = await readFile(resolve(dataDir, "index.json"), "utf8");
23
+ const errorRaw = await readFile(resolve(dataDir, "error-codes.json"), "utf8");
24
+ const apiSpecsRaw = await readFile(resolve(dataDir, "api-specs.json"), "utf8");
25
+ const guidesDir = resolve(dataDir, "guides");
26
+ const files = await readdir(guidesDir);
27
+ const guides = new Map();
28
+ for (const f of files) {
29
+ if (!f.endsWith(".md"))
30
+ continue;
31
+ const slug = f.replace(/\.md$/, "");
32
+ const text = await readFile(resolve(guidesDir, f), "utf8");
33
+ guides.set(slug, text);
34
+ }
35
+ return {
36
+ index: JSON.parse(indexRaw),
37
+ errorCodes: JSON.parse(errorRaw),
38
+ apiSpecs: JSON.parse(apiSpecsRaw),
39
+ guides,
40
+ dataDir,
41
+ };
42
+ }
package/dist/excel.js ADDED
@@ -0,0 +1,320 @@
1
+ // 검수 결과 → xlsx 변환.
2
+ // 3 시트: 검수요약 / 항목별 상세 / 공통검수체크리스트
3
+ // 양식 참조: data/samples/payco_nonmember_checklist.xlsx (Arial 10pt, 진남색 헤더)
4
+ import { mkdir } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join, resolve, isAbsolute } from "node:path";
7
+ import ExcelJS from "exceljs";
8
+ import { CHECKLISTS, SERVICE_LABELS } from "./tools.js";
9
+ // ── 디자인 토큰 ──────────────────────────────────────────────
10
+ const FONT_NAME = "Arial";
11
+ const FONT_SIZE = 10;
12
+ const COLOR_TITLE = "FF1F3864"; // 진남색 - 시트 제목
13
+ const COLOR_SUBTITLE = "FF595959"; // 회색 - 서브타이틀
14
+ const COLOR_HEADER_BG = "FF1F3864";
15
+ const COLOR_HEADER_TEXT = "FFFFFFFF";
16
+ const COLOR_PASS_TEXT = "FF006100";
17
+ const COLOR_PASS_BG = "FFC6EFCE";
18
+ const COLOR_FAIL_TEXT = "FF9C0006";
19
+ const COLOR_FAIL_BG = "FFFFC7CE";
20
+ const COLOR_NEUTRAL = "FF7F7F7F"; // 회색 (스킵/수동)
21
+ const SPEC_KIND_LABEL = {
22
+ missing_required: "API 명세 - 필수 누락",
23
+ unknown_param: "API 명세 - 알 수 없는 파라미터",
24
+ type_mismatch: "API 명세 - 타입 불일치",
25
+ };
26
+ const STATUS_LABEL = {
27
+ pass: "✓ 통과",
28
+ fail: "✗ 실패",
29
+ skip: "⊘ 스킵",
30
+ manual: "⬜ 수동확인",
31
+ };
32
+ // ── 경로/파일명 ──────────────────────────────────────────────
33
+ export function defaultOutputPath() {
34
+ const now = new Date();
35
+ const pad = (n) => String(n).padStart(2, "0");
36
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
37
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
38
+ return join(homedir(), "Downloads", `payco-validation-${stamp}.xlsx`);
39
+ }
40
+ async function ensureOutputPath(userPath) {
41
+ let p = userPath?.trim() || defaultOutputPath();
42
+ if (!isAbsolute(p))
43
+ p = resolve(p);
44
+ if (!p.toLowerCase().endsWith(".xlsx"))
45
+ p = `${p}.xlsx`;
46
+ await mkdir(dirname(p), { recursive: true });
47
+ return p;
48
+ }
49
+ // ── 공통 헬퍼 ────────────────────────────────────────────────
50
+ function fillSolid(argb) {
51
+ return { type: "pattern", pattern: "solid", fgColor: { argb } };
52
+ }
53
+ /** 제목 행: 시트 상단 큰 글자 (Row 1, C1 한 셀, 나머지는 비움) */
54
+ function addTitleRow(ws, text, colCount) {
55
+ const row = ws.addRow([text]);
56
+ // 첫 셀만 스타일 적용, 나머지 빈 셀에는 적용하지 않음
57
+ const cell = row.getCell(1);
58
+ cell.font = { name: FONT_NAME, size: 14, bold: true, color: { argb: COLOR_TITLE } };
59
+ cell.alignment = { vertical: "middle" };
60
+ // 시각적으로 합쳐서 보이게: 1~colCount 머지
61
+ ws.mergeCells(row.number, 1, row.number, colCount);
62
+ row.height = 24;
63
+ }
64
+ /** 서브타이틀 행: 검수일/대상 등 보조 정보 (Row 2) */
65
+ function addSubtitleRow(ws, text, colCount) {
66
+ const row = ws.addRow([text]);
67
+ const cell = row.getCell(1);
68
+ cell.font = { name: FONT_NAME, size: 9, color: { argb: COLOR_SUBTITLE } };
69
+ cell.alignment = { vertical: "middle" };
70
+ ws.mergeCells(row.number, 1, row.number, colCount);
71
+ row.height = 16;
72
+ }
73
+ /** 빈 행 추가 (구분용) */
74
+ function addSpacerRow(ws) {
75
+ ws.addRow([]);
76
+ }
77
+ /** 테이블 헤더 행: 진남색 배경 + 흰글자, 실제 컬럼 범위(1..colCount)에만 적용 */
78
+ function addHeaderRow(ws, headers) {
79
+ const row = ws.addRow(headers);
80
+ for (let i = 1; i <= headers.length; i++) {
81
+ const cell = row.getCell(i);
82
+ cell.font = {
83
+ name: FONT_NAME, size: FONT_SIZE, bold: true,
84
+ color: { argb: COLOR_HEADER_TEXT },
85
+ };
86
+ cell.fill = fillSolid(COLOR_HEADER_BG);
87
+ cell.alignment = { vertical: "middle", horizontal: "center", wrapText: true };
88
+ }
89
+ row.height = 26;
90
+ return row;
91
+ }
92
+ /** 데이터 셀 기본 스타일 (size 10, Arial, top/wrap) */
93
+ function styleDataCell(cell, opts = {}) {
94
+ const existing = (cell.font ?? {});
95
+ cell.font = { ...existing, name: FONT_NAME, size: FONT_SIZE };
96
+ cell.alignment = {
97
+ vertical: opts.center ? "middle" : "top",
98
+ horizontal: opts.center ? "center" : "left",
99
+ wrapText: true,
100
+ };
101
+ }
102
+ /** 검수결과 셀에 PASS/FAIL/SKIP/MANUAL 별 색상 적용 */
103
+ function paintResultCell(cell, status) {
104
+ cell.alignment = { vertical: "middle", horizontal: "center", wrapText: true };
105
+ if (status === "pass" || status === "summary-pass") {
106
+ cell.font = { name: FONT_NAME, size: FONT_SIZE, bold: true, color: { argb: COLOR_PASS_TEXT } };
107
+ cell.fill = fillSolid(COLOR_PASS_BG);
108
+ }
109
+ else if (status === "fail" || status === "summary-fail") {
110
+ cell.font = { name: FONT_NAME, size: FONT_SIZE, bold: true, color: { argb: COLOR_FAIL_TEXT } };
111
+ cell.fill = fillSolid(COLOR_FAIL_BG);
112
+ }
113
+ else {
114
+ // skip / manual
115
+ cell.font = { name: FONT_NAME, size: FONT_SIZE, color: { argb: COLOR_NEUTRAL } };
116
+ }
117
+ }
118
+ function formatStamp() {
119
+ const d = new Date();
120
+ const pad = (n) => String(n).padStart(2, "0");
121
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
122
+ }
123
+ // ── Sheet 1: 검수요약 ────────────────────────────────────────
124
+ function buildSummarySheet(wb, records) {
125
+ const ws = wb.addWorksheet("검수요약");
126
+ const widths = [5, 24, 24, 28, 36, 16, 60];
127
+ ws.columns = widths.map((w) => ({ width: w }));
128
+ const COLS = 7;
129
+ addTitleRow(ws, "PAYCO 검수 결과 요약", COLS);
130
+ addSubtitleRow(ws, `검수일: ${formatStamp()} | 검수 건수: ${records.length}건`, COLS);
131
+ addSpacerRow(ws);
132
+ addHeaderRow(ws, [
133
+ "No", "가맹점주문번호", "예약주문번호", "API", "Endpoint", "검수결과", "조치 필요 사항",
134
+ ]);
135
+ // 헤더 행 번호를 frozen 기준점으로 사용
136
+ const headerRowNum = ws.rowCount;
137
+ ws.views = [{ state: "frozen", ySplit: headerRowNum }];
138
+ records.forEach((rec, i) => {
139
+ const fails = rec.specIssues.length + rec.checklistRuns.filter((c) => c.status === "fail").length;
140
+ const resultText = rec.pass ? "✓ PASS" : `✗ FAIL (${fails})`;
141
+ const actionLines = [];
142
+ for (const s of rec.specIssues) {
143
+ actionLines.push(`[${SPEC_KIND_LABEL[s.kind] ?? s.kind}] ${s.message}`);
144
+ }
145
+ for (const c of rec.checklistRuns) {
146
+ if (c.status === "fail") {
147
+ actionLines.push(`[체크리스트-${c.category}] ${c.title}${c.message ? ` — ${c.message}` : ""}`);
148
+ }
149
+ }
150
+ const row = ws.addRow([
151
+ i + 1,
152
+ rec.sellerOrderRefKey ?? "",
153
+ rec.reserveOrderNo ?? "",
154
+ rec.api,
155
+ rec.endpoint,
156
+ resultText,
157
+ actionLines.join("\n"),
158
+ ]);
159
+ // 모든 셀 기본 스타일
160
+ for (let c = 1; c <= COLS; c++)
161
+ styleDataCell(row.getCell(c));
162
+ // No 컬럼 가운데 정렬
163
+ row.getCell(1).alignment = { vertical: "middle", horizontal: "center", wrapText: true };
164
+ // 검수결과 컬럼 색 처리
165
+ paintResultCell(row.getCell(6), rec.pass ? "summary-pass" : "summary-fail");
166
+ row.height = Math.min(96, Math.max(22, actionLines.length * 14 + 8));
167
+ });
168
+ }
169
+ // ── Sheet 2: 항목별 상세 ─────────────────────────────────────
170
+ function buildDetailSheet(wb, records) {
171
+ const ws = wb.addWorksheet("항목별 상세");
172
+ const widths = [5, 22, 22, 24, 30, 36, 16, 50];
173
+ ws.columns = widths.map((w) => ({ width: w }));
174
+ const COLS = 8;
175
+ addTitleRow(ws, "API별 검증 항목 상세", COLS);
176
+ addSubtitleRow(ws, `검수일: ${formatStamp()} | 검수 건수: ${records.length}건`, COLS);
177
+ addSpacerRow(ws);
178
+ addHeaderRow(ws, [
179
+ "No", "가맹점주문번호", "예약주문번호", "API", "Endpoint", "검증항목", "검수결과", "조치 필요 사항",
180
+ ]);
181
+ const headerRowNum = ws.rowCount;
182
+ ws.views = [{ state: "frozen", ySplit: headerRowNum }];
183
+ let rowNo = 0;
184
+ records.forEach((rec, recIdx) => {
185
+ // API 명세 이슈만 표시 (체크리스트는 공통검수체크리스트 시트에서 다룸)
186
+ for (const s of rec.specIssues) {
187
+ rowNo++;
188
+ const row = ws.addRow([
189
+ rowNo,
190
+ rec.sellerOrderRefKey ?? "",
191
+ rec.reserveOrderNo ?? "",
192
+ rec.api,
193
+ rec.endpoint,
194
+ SPEC_KIND_LABEL[s.kind] ?? s.kind,
195
+ "✗ 실패",
196
+ s.message,
197
+ ]);
198
+ for (let c = 1; c <= COLS; c++)
199
+ styleDataCell(row.getCell(c));
200
+ row.getCell(1).alignment = { vertical: "middle", horizontal: "center", wrapText: true };
201
+ paintResultCell(row.getCell(7), "fail");
202
+ row.height = 24;
203
+ }
204
+ if (recIdx < records.length - 1)
205
+ ws.addRow([]);
206
+ });
207
+ }
208
+ // ── Sheet 3: 공통검수체크리스트 ──────────────────────────────
209
+ // 서비스 단위 체크리스트.
210
+ // - 자동 항목: 해당 서비스의 모든 record 를 집계해 결과 표시 (하나라도 fail 이면 ✗)
211
+ // - 수동 항목: 자동 검증 불가 → 결과 빈값
212
+ // - 조치 필요 사항: 서비스 그룹의 첫 행에만 endpoint(record) 단위로 정리해서 표시
213
+ function buildChecklistSheet(wb, records) {
214
+ const ws = wb.addWorksheet("공통검수체크리스트");
215
+ const widths = [14, 18, 50, 50, 16, 60];
216
+ ws.columns = widths.map((w) => ({ width: w }));
217
+ const COLS = 6;
218
+ const presentServices = Array.from(new Set(records.map((r) => r.service)))
219
+ .filter((s) => s in SERVICE_LABELS);
220
+ const presentServiceLabels = presentServices.map((s) => SERVICE_LABELS[s]);
221
+ addTitleRow(ws, "공통 검수 체크리스트", COLS);
222
+ addSubtitleRow(ws, `검수일: ${formatStamp()} | 대상 서비스: ${presentServiceLabels.join(", ") || "(없음)"} | 검수 건수: ${records.length}건`, COLS);
223
+ addSpacerRow(ws);
224
+ addHeaderRow(ws, ["서비스", "카테고리", "검수 항목", "비고/설명", "검수결과", "조치 필요 사항"]);
225
+ const headerRowNum = ws.rowCount;
226
+ ws.views = [{ state: "frozen", ySplit: headerRowNum }];
227
+ const itemStatusByService = {};
228
+ for (const rec of records) {
229
+ const svcMap = itemStatusByService[rec.service] ??= {};
230
+ for (const c of rec.checklistRuns) {
231
+ if (c.status === "manual")
232
+ continue;
233
+ const cur = svcMap[c.title];
234
+ if (c.status === "fail")
235
+ svcMap[c.title] = "fail";
236
+ else if (c.status === "pass" && cur !== "fail")
237
+ svcMap[c.title] = "pass";
238
+ else if (c.status === "skip" && !cur)
239
+ svcMap[c.title] = "skip";
240
+ }
241
+ }
242
+ for (const svc of presentServices) {
243
+ const label = SERVICE_LABELS[svc];
244
+ const list = CHECKLISTS[svc] ?? [];
245
+ // 이 서비스의 모든 record 의 조치사항 통합 (endpoint 단위로 헤더 + 줄로 정리)
246
+ const svcRecords = records.filter((r) => r.service === svc);
247
+ const actionLines = [];
248
+ for (const rec of svcRecords) {
249
+ const lines = [];
250
+ for (const s of rec.specIssues) {
251
+ lines.push(` · [${SPEC_KIND_LABEL[s.kind] ?? s.kind}] ${s.message}`);
252
+ }
253
+ for (const c of rec.checklistRuns) {
254
+ if (c.status === "fail") {
255
+ lines.push(` · [체크리스트-${c.category}] ${c.title}${c.message ? ` — ${c.message}` : ""}`);
256
+ }
257
+ }
258
+ if (lines.length > 0) {
259
+ const orderKey = rec.sellerOrderRefKey ? ` / ${rec.sellerOrderRefKey}` : "";
260
+ actionLines.push(`■ ${rec.api}${orderKey}`);
261
+ actionLines.push(...lines);
262
+ }
263
+ }
264
+ const actionText = actionLines.length > 0
265
+ ? actionLines.join("\n")
266
+ : "(이 서비스 검수에서 자동 검증 실패·명세 위반 없음)";
267
+ list.forEach((item, idx) => {
268
+ const isFirst = idx === 0;
269
+ const isAuto = !!item.autoCheck;
270
+ let resultText = "";
271
+ let resultStyle = null;
272
+ if (isAuto) {
273
+ const status = itemStatusByService[svc]?.[item.title];
274
+ if (status === "pass") {
275
+ resultText = "✓ 통과";
276
+ resultStyle = "pass";
277
+ }
278
+ else if (status === "fail") {
279
+ resultText = "✗ 실패";
280
+ resultStyle = "fail";
281
+ }
282
+ else if (status === "skip") {
283
+ resultText = "⊘ 스킵";
284
+ resultStyle = "skip";
285
+ }
286
+ }
287
+ const row = ws.addRow([
288
+ label,
289
+ item.category,
290
+ item.title,
291
+ item.detail ?? "",
292
+ resultText,
293
+ isFirst ? actionText : "",
294
+ ]);
295
+ for (let c = 1; c <= COLS; c++)
296
+ styleDataCell(row.getCell(c));
297
+ if (resultStyle)
298
+ paintResultCell(row.getCell(5), resultStyle);
299
+ else
300
+ row.getCell(5).alignment = { vertical: "middle", horizontal: "center", wrapText: true };
301
+ row.height = 22;
302
+ });
303
+ // 서비스 사이 빈 행
304
+ if (svc !== presentServices[presentServices.length - 1])
305
+ ws.addRow([]);
306
+ }
307
+ }
308
+ // ── 메인 export 함수 ─────────────────────────────────────────
309
+ export async function exportValidationToExcel(records, userPath) {
310
+ const wb = new ExcelJS.Workbook();
311
+ wb.creator = "payco-mcp-devcenter";
312
+ wb.created = new Date();
313
+ buildSummarySheet(wb, records);
314
+ buildDetailSheet(wb, records);
315
+ buildChecklistSheet(wb, records);
316
+ const outPath = await ensureOutputPath(userPath);
317
+ await wb.xlsx.writeFile(outPath);
318
+ const detailRows = records.reduce((sum, r) => sum + r.specIssues.length + r.checklistRuns.length, 0);
319
+ return { path: outPath, recordCount: records.length, rowCount: detailRows };
320
+ }