정산 산출물 템플릿#
settlement-invoice 스킬이 산출물을 만들 때 쓰는 세 가지 템플릿입니다 — 아티팩트 HTML,
구글 시트 워크북 생성 스크립트, 로컬 설정 파일. 전부 {{PLACEHOLDER}} 토큰을 실제 값으로
치환해서 씁니다. 이 문서 자체에는 실제 회사 정보·연락처를 넣지 않습니다 — 이 저장소는
공개 저장소이고, 실제 값은 항상 대상 프로젝트의 로컬 설정에서만 옵니다.
1. 로컬 설정 템플릿#
<대상 저장소>/.claude/settlement/<slug>.config.md 에 이 형태로 만듭니다. 예시 값은 전부
가상의 자리표시자입니다 — 실제 등록번호·연락처로 바꿔 쓰세요.
---
project: " {{프로젝트 이름 (예: 예시서비스)}} "
slug: " {{URL/파일명에 쓸 짧은 식별자 (예: example-app)}} "
---
## 공급자 (용역 수행사)
- 회사명: {{공급자 회사명}}
- 사업자등록번호: {{등록번호}}
- 대표자: {{대표자명}}
- 사업장 주소: {{주소}}
- 담당자: {{담당자명}} ({{직책}})
- 담당자 연락처: {{이메일 / 전화 — 개인정보라 판단되면 별도 gitignore 파일로 분리}}
## 발주처 (클라이언트)
- 회사명: {{발주처 회사명}}
- 담당자: {{담당자명}}
- 담당자 연락처: {{이메일 / 전화}}
## 요율
- 단가: {{예: 200000}} 원 / M·D
- 표준 공수 적용률: {{예: 0.2}}
- VAT: {{별도 | 포함}}
## 분류 체계 (기능 영역)
- {{영역 1 (예: 회원·인증·계정)}}
- {{영역 2 (예: 결제·정산)}}
- ...(프로젝트 실제 모듈 구성에 맞춰 정의)
## 이전 계약 범위로 이미 합의된 항목
- {{계약서 조항 인용 1}}
- {{계약서 조항 인용 2}}
## CI/CD 인프라 사용료 별도 청구 여부
- {{예 (자체 러너 운영) | 아니오 (클라우드 CI만 사용)}}
## 구글 시트
- 기존 추적 시트 fileId: {{URL의 /d/ 뒤 문자열}}
- (get_file_metadata 로 조회한) 상위 폴더 parentId: {{조회 결과 캐시 — 최초 1회 확인 후 기록}}
2. 아티팩트 HTML 템플릿#
CSS는 그대로 재사용합니다(테마 대응·반응형이 이미 처리돼 있습니다). {{...}} 부분과
`` 로 표시된 반복 구간만 실제 데이터로 채웁니다. 발행 전 artifact-design
스킬을 먼저 로드하세요 — 이 템플릿은 그 스킬이 요구하는 시스템 규약(테마 토큰 완전성·
반응형·자기완결 CSS)을 이미 만족하도록 만들어졌습니다.
< title > {{PROJECT_NAME}} {{PERIOD_LABEL}} 정산 내역 < /title >
< style >
:root {
--paper: #FCFDFC;
--card: #FFFFFF;
--panel: #F1F5F3;
--ink: #121917;
--ink-2: #4B5754;
--ink-3: #7E8B87;
--rule: #DEE6E2;
--rule-2: #BCC8C3;
--accent: #16594B;
--accent-2: #0E3F35;
--accent-soft:#E3EEEA;
--free: #6F7C78;
--free-soft: #EDF0EE;
--warn: #8A5E1C;
--warn-soft: #F7F0E4;
--on-accent: #FFFFFF;
--sans: " Pretendard Variable " , Pretendard, -apple-system, BlinkMacSystemFont,
" Apple SD Gothic Neo " , " Noto Sans KR " , " Malgun Gothic " , system-ui, sans-serif;
--mono: ui-monospace, " SF Mono " , SFMono-Regular, Menlo, " Cascadia Mono " , Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme= " light " ]) {
--paper:#0E1413; --card:#151D1B; --panel:#1A2422; --ink:#E8EEEB; --ink-2:#A7B4B0;
--ink-3:#78857F; --rule:#26312E; --rule-2:#3A4844;
--accent:#6FB3A0; --accent-2:#A6D4C5; --accent-soft:#16302A;
--free:#78857F; --free-soft:#18211F; --warn:#C99A54; --warn-soft:#2A2216;
--on-accent:#0E1413;
}
}
:root[data-theme= " dark " ] {
--paper:#0E1413; --card:#151D1B; --panel:#1A2422; --ink:#E8EEEB; --ink-2:#A7B4B0;
--ink-3:#78857F; --rule:#26312E; --rule-2:#3A4844;
--accent:#6FB3A0; --accent-2:#A6D4C5; --accent-soft:#16302A;
--free:#78857F; --free-soft:#18211F; --warn:#C99A54; --warn-soft:#2A2216;
--on-accent:#0E1413;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--paper); color: var(--ink);
font-family: var(--sans); font-size: 16px; line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 940px; margin: 0 auto; padding: 0 24px 110px; }
h1,h2,h3,h4 { margin:0; letter-spacing:-0.022em; font-weight:700; text-wrap:balance; }
h1 { font-size:40px; line-height:1.2; }
h2 { font-size:27px; line-height:1.3; }
h3 { font-size:19px; line-height:1.4; }
p { margin:0; }
b, strong { font-weight: 700; }
.eyebrow { font-size:12px; font-weight:700; letter-spacing:.16em; text-transform:uppercase;
color:var(--accent); font-family:var(--mono); }
.lead { color:var(--ink-2); font-size:17px; max-width:64ch; text-wrap:pretty; }
.note { color:var(--ink-3); font-size:14px; text-wrap:pretty; }
.num { font-family:var(--mono); font-variant-numeric:tabular-nums; }
section { padding-top:72px; }
.head { display:flex; flex-direction:column; gap:12px; margin-bottom:28px; }
.stack { display:flex; flex-direction:column; gap:18px; }
.stack-s { display:flex; flex-direction:column; gap:10px; }
.cover { padding:64px 0 0; display:flex; flex-direction:column; gap:22px; }
.cover .meta { display:flex; flex-wrap:wrap; gap:8px 24px; font-family:var(--mono);
font-size:13px; color:var(--ink-2); font-variant-numeric:tabular-nums; }
.cover .meta b { color:var(--ink); font-weight:600; }
.total {
background:var(--accent); color:var(--on-accent); border-radius:6px; padding:30px 32px;
display:flex; flex-wrap:wrap; gap:20px; align-items:flex-end; justify-content:space-between;
}
.total .k { font-size:13px; letter-spacing:.1em; text-transform:uppercase;
font-family:var(--mono); opacity:.82; display:block; margin-bottom:6px; }
.total .v { font-family:var(--mono); font-variant-numeric:tabular-nums;
font-size:44px; font-weight:700; line-height:1; letter-spacing:-0.03em; }
.total .sub { font-size:14px; opacity:.85; font-family:var(--mono); }
.sum { border:1px solid var(--rule); border-radius:6px; background:var(--card); overflow:hidden; }
.sum .r { display:grid; grid-template-columns:1fr auto auto; gap:18px; align-items:baseline;
padding:16px 22px; border-bottom:1px solid var(--rule); }
.sum .r:last-child { border-bottom:0; }
.sum .t { font-size:15px; font-weight:600; }
.sum .d { display:block; color:var(--ink-3); font-size:13px; font-weight:400; }
.sum .q { color:var(--ink-2); font-size:13.5px; text-align:right; min-width:120px; }
.sum .a { font-size:16px; font-weight:700; text-align:right; min-width:140px; }
.sum .r.minus .t, .sum .r.minus .a { color:var(--free); }
.sum .r.last { background:var(--accent-soft); border-top:2px solid var(--accent); padding:20px 22px; }
.sum .r.last .t { color:var(--accent-2); font-size:16px; }
.sum .r.last .a { color:var(--accent-2); font-size:22px; }
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:14px; }
.card { border:1px solid var(--rule); border-radius:6px; background:var(--card);
padding:20px; display:flex; flex-direction:column; gap:8px; }
.card .cn { font-family:var(--mono); font-size:12px; font-weight:700; color:var(--accent);
letter-spacing:.08em; }
.card .cp { font-family:var(--mono); font-size:12.5px; color:var(--ink-3);
font-variant-numeric:tabular-nums; }
.card .ca { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:24px;
font-weight:700; letter-spacing:-0.02em; }
.card .cs { font-size:13px; color:var(--ink-2); }
.items { border:1px solid var(--rule); border-radius:6px; background:var(--card); overflow:hidden; }
.item { display:grid; grid-template-columns:1fr 108px 132px; gap:20px;
padding:20px 22px; border-bottom:1px solid var(--rule); align-items:start; }
.item:last-child { border-bottom:0; }
.item .it { font-weight:600; font-size:15.5px; display:block; margin-bottom:6px; text-wrap:pretty; }
.item .idesc { color:var(--ink-2); font-size:14px; line-height:1.65; text-wrap:pretty; }
.item .iscope { display:inline-block; margin-top:9px; font-size:12px; color:var(--ink-3);
font-family:var(--mono); }
.item .imd { text-align:right; font-family:var(--mono); font-variant-numeric:tabular-nums;
font-size:14px; color:var(--ink-2); white-space:nowrap; }
.item .imd small { display:block; font-size:11px; color:var(--ink-3); letter-spacing:.06em; }
.item .iamt { text-align:right; font-family:var(--mono); font-variant-numeric:tabular-nums;
font-size:16px; font-weight:700; white-space:nowrap; }
.group { padding:12px 22px; background:var(--panel); border-top:1px solid var(--rule);
border-bottom:1px solid var(--rule); font-size:13.5px; font-weight:700; color:var(--ink-2);
display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.group span:last-child { font-family:var(--mono); font-weight:600; color:var(--ink-3);
font-variant-numeric:tabular-nums; }
.week { display:flex; flex-direction:column; gap:20px; margin-top:14px; }
.wbar { display:flex; flex-wrap:wrap; gap:18px; align-items:baseline; justify-content:space-between;
padding-bottom:14px; border-bottom:2px solid var(--ink); }
.wbar .wl { display:flex; flex-direction:column; gap:3px; }
.wbar .wt { font-size:21px; font-weight:700; }
.wbar .wp { font-family:var(--mono); font-size:13px; color:var(--ink-3);
font-variant-numeric:tabular-nums; }
.wbar .wa { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:24px;
font-weight:700; color:var(--accent); }
.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; }
.tile { border:1px solid var(--rule); border-radius:5px; background:var(--card);
padding:14px 16px; display:flex; align-items:baseline; justify-content:space-between; gap:10px; }
.tile .tn { font-size:14px; color:var(--ink-2); }
.tile .tc { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:17px;
font-weight:700; }
.freelist { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:0 26px; }
.freelist .fl { padding:9px 0 9px 14px; border-bottom:1px solid var(--rule); font-size:13.5px;
color:var(--ink-2); position:relative; line-height:1.55; }
.freelist .fl::before { content: " · " ; position:absolute; left:2px; color:var(--ink-3); }
.box { border:1px solid var(--rule); border-left:4px solid var(--accent); border-radius:5px;
background:var(--accent-soft); padding:18px 22px; font-size:15px; color:var(--ink-2); }
.box b { color:var(--ink); }
.box.g { border-left-color:var(--rule-2); background:var(--panel); }
.box.w { border-left-color:var(--warn); background:var(--warn-soft); }
.tw { overflow-x:auto; border:1px solid var(--rule); border-radius:6px; background:var(--card); }
table { width:100%; border-collapse:collapse; font-size:14.5px; table-layout:fixed; min-width:640px; }
thead th { text-align:left; font-size:11.5px; font-weight:700; letter-spacing:.09em;
text-transform:uppercase; color:var(--ink-3); padding:12px 16px; background:var(--panel);
border-bottom:1px solid var(--rule-2); white-space:nowrap; }
thead th.r { text-align:right; }
tbody td { padding:14px 16px; border-bottom:1px solid var(--rule); vertical-align:top;
overflow-wrap:break-word; }
tbody tr:last-child td { border-bottom:0; }
td.r { text-align:right; white-space:nowrap; font-family:var(--mono);
font-variant-numeric:tabular-nums; }
td .sm { color:var(--ink-2); font-size:13.5px; display:block; margin-top:4px; line-height:1.6; }
footer { margin-top:88px; padding-top:26px; border-top:2px solid var(--ink);
display:flex; flex-wrap:wrap; gap:10px 24px; justify-content:space-between;
font-family:var(--mono); font-size:13px; color:var(--ink-2); }
footer b { color:var(--ink); }
@media (max-width:760px) {
body { font-size:15px; }
h1 { font-size:30px; } h2 { font-size:22px; }
.wrap { padding:0 16px 70px; }
.total .v { font-size:34px; }
.item { grid-template-columns:1fr; gap:10px; }
.item .imd, .item .iamt { text-align:left; }
.item .iamt { font-size:18px; color:var(--accent); }
.sum .r { grid-template-columns:1fr auto; }
.sum .q { display:none; }
}
@media print { body { background:#fff; } section { break-inside:avoid; } }
< /style >
< div class= " wrap " >
< header class= " cover " >
< span class= " eyebrow " > 추가 개발 견적서 < /span >
< h1 > {{PROJECT_NAME}} {{PERIOD_LABEL}} < br > 추가 개발 정산 내역 < /h1 >
< p class= " lead " > {{기간 설명 문장 — 예: " N월 N일부터 N월 N일까지 반영한 작업의 전체
내역입니다. 계약 범위에 포함된 작업과 기존 기능의 하자 보수는 같은 기준으로 산출한 뒤
청구에서 전액 제외했습니다. " }} < /p >
< div class= " meta " >
< span > 대상 기간 < b > {{START_DATE}} — {{END_DATE}} < /b > < /span >
< span > 영업일 < b > {{BUSINESS_DAYS}}일 < /b > < /span >
< span > 단가 < b > {{UNIT_RATE}}원 / M·D < /b > < /span >
< span > 표준 공수 < b > {{APPLIED_RATE_PCT}}% 적용 < /b > < /span >
< span > < b > {{VAT_LABEL}} < /b > < /span >
< /div >
< div class= " total " >
< div > < span class= " k " > 최종 청구액 < /span > < span class= " v " > {{TOTAL_AMOUNT}}원 < /span > < /div >
< div class= " sub " > 개발 {{BILLABLE_COUNT}}건 {{BILLABLE_MD}} M/D{{ · CI 인프라 사용료 있으면 " + 인프라 사용료 " }} · VAT {{VAT_INCLUDED_OR_NOT}} < /div >
< /div >
< /header >
< section > < div class= " head " > < span class= " eyebrow " > Summary < /span > < h2 > 한눈에 보기 < /h2 >
< p class= " lead " > 이 기간에 수행한 작업 전체를 같은 기준으로 산출한 뒤, 청구 대상만 남기고 덜어낸 결과입니다. < /p > < /div >
< div class= " stack " >
< div class= " sum " >
< div class= " r " > < div class= " t " > 개발 공수 청구액 < span class= " d " > 발주처 요청 또는 계약 범위 밖으로 확인된 작업 < /span > < /div >
< div class= " q num " > {{BILLABLE_COUNT}}건 · {{BILLABLE_MD}} M/D < /div > < div class= " a num " > {{BILLABLE_AMOUNT}}원 < /div > < /div >
< !-- 자체 CI 러너를 쓰는 프로젝트만: -- >
< div class= " r " > < div class= " t " > CI/CD 인프라 사용료 < span class= " d " > 자체 빌드 서버에서 처리한 빌드·검증·배포 사용량 < /span > < /div >
< div class= " q num " > {{CI_RUNS}}회 · {{CI_HOURS}}시간 < /div > < div class= " a num " > {{CI_AMOUNT}}원 < /div > < /div >
< !-- /선택 모듈 -- >
< div class= " r minus " > < div class= " t " > 이전 계약 범위 · 내부 작업 < span class= " d " > 계약에 포함된 항목과 화면 동작이 달라지지 않는 내부 작업 < /span > < /div >
< div class= " q num " > {{EXCLUDED_COUNT}}건{{ · MD 있으면 표기 }} < /div > < div class= " a num " > 0원 < /div > < /div >
< div class= " r minus " > < div class= " t " > 하자 보수 · 내부 작업 (무상) < span class= " d " > 기존 기능이 설계대로 동작하지 않은 건 < /span > < /div >
< div class= " q num " > {{FREE_COUNT}}건 < /div > < div class= " a num " > 0원 < /div > < /div >
< div class= " r last " > < div class= " t " > 최종 청구액 < span class= " d " > {{VAT_LABEL}} < /span > < /div >
< div class= " q num " > {{BILLABLE_COUNT}}건 · {{BILLABLE_MD}} M/D{{ + 사용료 }} < /div > < div class= " a num " > {{TOTAL_AMOUNT}}원 < /div > < /div >
< /div >
< !-- LOOP: 이월 구간(있으면) + 주차별 카드 -- >
< div class= " cards " >
< div class= " card " > < span class= " cn " > {{WEEK_LABEL}} < /span >
< span class= " cp " > {{WEEK_PERIOD}} < /span >
< span class= " ca " > {{WEEK_AMOUNT}}원 < /span >
< span class= " cs " > {{WEEK_COUNT}}건 · {{WEEK_MD}} M/D < /span > < /div >
< !-- /LOOP -- >
< /div >
< div class= " box " > < b > 이 기간에 반영한 작업은 모두 {{TOTAL_COUNT}}건입니다. < /b >
그 가운데 청구 대상 < b > {{BILLABLE_COUNT}}건 < /b > 만 청구하고, 계약범위 제외 {{EXCLUDED_COUNT}}건,
하자 보수 {{FREE_COUNT}}건은 < b > 청구하지 않았습니다 < /b > . 청구하지 않은 항목도 무엇을 했는지
확인하실 수 있도록 아래에 전부 기재했습니다. < /div >
< /div > < /section >
< section > < div class= " head " > < span class= " eyebrow " > Work < /span > < h2 > 반영한 작업 {{BILLABLE_COUNT}}건 < /h2 >
< p class= " lead " > 청구 대상 작업입니다. 각 항목의 M/D 는 표준 공수에 {{APPLIED_RATE_PCT}}%를 적용한 값입니다. < /p > < /div >
< !-- LOOP: 주차 (이월분 먼저, 그 다음 1주차 2주차 ...) -- >
< div class= " week " > < div class= " wbar " >
< div class= " wl " > < span class= " wt " > {{WEEK_LABEL}} < /span > < span class= " wp " > {{WEEK_PERIOD}} · {{WEEK_COUNT}}건 < /span > < /div >
< span class= " wa " > {{WEEK_AMOUNT}}원 < /span > < /div >
< p class= " lead " style= " max-width:70ch " > {{그 주의 한 줄 요약}} < /p >
< div class= " items " >
< !-- LOOP: 기능 영역 그룹 -- >
< div class= " group " > < span > {{AREA_NAME}} < /span > < span > {{AREA_COUNT}}건 · {{AREA_AMOUNT}}원 < /span > < /div >
< !-- LOOP: 항목 -- >
< div class= " item " >
< div > < span class= " it " > {{ITEM_TITLE}} < /span >
< span class= " idesc " > {{ITEM_DESCRIPTION — 무엇을 어떻게 바꿨는지 3~5문장, 클라이언트가 읽고 이해할 수 있는 서술}} < /span >
< span class= " iscope " > {{IMPACTED_LAYERS — 예: 앱 · 서버}} < /span > < /div >
< div class= " imd " > {{ITEM_MD}} < small > M/D < /small > < /div >
< div class= " iamt " > {{ITEM_AMOUNT}} < /div >
< /div >
< !-- /LOOP 항목 -- >
< !-- /LOOP 그룹 -- >
< /div >
< /div >
< !-- /LOOP 주차 -- >
< /section >
< !-- 선택 섹션: CI/CD 인프라 사용료를 별도 청구하는 프로젝트만 포함 -- >
< section > < div class= " head " > < span class= " eyebrow " > Compute < /span >
< h2 > CI/CD 인프라 사용료 < /h2 >
< p class= " lead " > 코드를 고칠 때마다 자동으로 도는 빌드·검증·배포에 자체 빌드 서버가 사용된 대가입니다.
대상 기간에 < b > {{CI_RUNS}}회 < /b > 가 실행되어 < b > 약 {{CI_HOURS}}시간 < /b > 이 쓰였습니다. < /p > < /div >
< div class= " stack " >
< div class= " tw " > < table >
< thead > < tr > < th > 구분 < /th > < th class= " r " > 실행 < /th > < th class= " r " > 평균 < /th > < th class= " r " > 컴퓨팅 시간 < /th > < /tr > < /thead >
< tbody >
< !-- LOOP: 잡 종류 -- >
< tr > < td > < b > {{JOB_KIND}} < /b > < span class= " sm " > {{JOB_KIND_DESC}} < /span > < /td > < td class= " r " > {{JOB_RUNS}} < /td > < td class= " r " > {{JOB_AVG}} < /td > < td class= " r " > {{JOB_HOURS}}시간 < /td > < /tr >
< !-- /LOOP -- >
< tr > < td > < b > 합계 < /b > < /td > < td class= " r " > < b > {{CI_RUNS}} < /b > < /td > < td class= " r " > — < /td > < td class= " r " > < b > {{CI_HOURS}}시간 < /b > < /td > < /tr >
< /tbody > < /table > < /div >
< div class= " stack-s " > < h3 > 산정 내역 < /h3 >
< div class= " tw " > < table >
< thead > < tr > < th > 구분 < /th > < th class= " r " > 사용량 < /th > < th class= " r " > 단가 < /th > < th class= " r " > 금액 < /th > < /tr > < /thead >
< tbody >
< tr > < td > < b > 시장가 < /b > < span class= " sm " > 클라우드 macOS 표준 러너 공개 요금 기준 < /span > < /td >
< td class= " r " > {{CI_HOURS}}시간 < /td > < td class= " r " > {{CLOUD_RATE}} < /td > < td class= " r " > {{MARKET_AMOUNT}}원 < /td > < /tr >
< tr > < td > < b > 적용률 {{APPLIED_RATE_PCT}}% < /b > < span class= " sm " > 개발 공수와 같은 비율을 적용 < /span > < /td >
< td class= " r " > — < /td > < td class= " r " > ×{{APPLIED_RATE}} < /td > < td class= " r " > < b > {{CI_APPLIED_AMOUNT}}원 < /b > < /td > < /tr >
< tr > < td > < b > 합계 < /b > < /td > < td class= " r " > {{CI_HOURS}}시간 < /td > < td class= " r " > — < /td > < td class= " r " > < b > {{CI_AMOUNT}}원 < /b > < /td > < /tr >
< /tbody > < /table > < /div > < /div >
< p class= " note " > 환율·산정 기준이 다르면 그에 맞춰 조정합니다. < /p >
< /div > < /section >
< !-- /선택 섹션 -- >
< section > < div class= " head " > < span class= " eyebrow " > No charge < /span > < h2 > 무상으로 처리한 작업 {{FREE_COUNT}}건 < /h2 >
< p class= " lead " > 기존 기능이 설계대로 동작하지 않은 건과 내부 작업입니다. 청구 금액에 포함되지 않습니다. < /p > < /div >
< div class= " stack " >
< div class= " tiles " >
< !-- LOOP: 영역별 카운트 -- >
< div class= " tile " > < span class= " tn " > {{AREA_NAME}} < /span > < span class= " tc " > {{AREA_COUNT}} < /span > < /div >
< !-- /LOOP -- >
< /div >
< !-- LOOP: 영역별 상세 -- >
< h4 style= " margin-top:22px;color:var(--ink-2) " > {{AREA_NAME}} < span class= " note " style= " font-weight:400 " > {{AREA_COUNT}}건 < /span > < /h4 >
< div class= " freelist " >
< !-- LOOP: 항목 -- >
< div class= " fl " > {{ITEM_ONE_LINE}} < /div >
< !-- /LOOP -- >
< /div >
< !-- /LOOP -- >
< /div > < /section >
< section > < div class= " head " > < span class= " eyebrow " > Excluded < /span > < h2 > 청구하지 않은 작업 {{EXCLUDED_COUNT}}건 < /h2 >
< p class= " lead " > 이전 계약에 포함되어 있거나, 화면 동작이 달라지지 않는 내부 작업입니다. 청구하지 않지만 무엇을 했는지 확인하실 수 있도록 전체를 기재합니다. < /p > < /div >
< div class= " stack " >
< div class= " tiles " > < !-- LOOP: 영역별 카운트 (No-charge 섹션과 동일 패턴) -- > < /div >
< div class= " stack-s " >
< h3 > 어떤 작업인가 < /h3 >
< ul class= " bul " >
< li > < b > 이전 계약 범위 < /b > — {{계약서 조항 인용}} < /li >
< li > < b > 화면 동작이 달라지지 않는 내부 작업 < /b > — {{설명}} < /li >
< li > < b > 사내 개발·배포 환경 < /b > — {{설명}} < /li >
< li > < b > 자체 점검에서 발견한 조치 < /b > — {{설명}} < /li >
< /ul > < /div >
< !-- LOOP: 영역별 상세 (No-charge 섹션과 동일 패턴) -- >
< /div > < /section >
< /div >
ul.bul { margin:0; padding-left:20px; color:var(--ink-2); font-size:15px; display:flex; flex-direction:column; gap:10px; } ul.bul b { color:var(--ink); }
를 CSS 에 추가하는 것을 잊지 마세요(위 <ul class="bul"> 가 참조합니다).
3. 워크북 생성 스크립트#
정산 실행 시 아래 스크립트를 scratchpad(세션 임시 디렉터리)에 build_settlement_workbook.py
로 써서 실행합니다. 입력은 분류·산정이 끝난 JSON(§4 스키마)이고, 출력은 .xlsx 파일 경로입니다.
#!/usr/bin/env python3
" " " 정산 데이터(JSON) → 견적서/상세내역 2탭 워크북(.xlsx).
입력 JSON 스키마는 TEMPLATES.md §4 참조. openpyxl 이 없으면 자동 설치를 시도하고,
그래도 안 되면 CSV 2개(요약/상세)로 폴백한다.
" " "
import json
import subprocess
import sys
def ensure_openpyxl():
try:
import openpyxl # noqa: F401
return True
except ImportError:
pass
try:
subprocess.run(
[sys.executable, " -m " , " pip " , " install " , " --quiet " , " openpyxl " ],
check=True, timeout=60,
)
import openpyxl # noqa: F401
return True
except Exception:
return False
def build_xlsx(data: dict, out_path: str) - > str:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment
from openpyxl.utils import get_column_letter
wb = Workbook()
# --- 탭 1: 견적서 (요약) ---
ws1 = wb.active
ws1.title = " 견적서 "
bold = Font(bold=True)
ws1.append([ " NO " , data[ " invoice_no " ], " 회 사 명 " , data[ " supplier " ][ " name " ]])
ws1.append([data[ " client " ][ " name " ], " 등록번호 " , data[ " supplier " ][ " reg_no " ]])
ws1.append([ " 대 표 " , data[ " supplier " ][ " ceo " ]])
ws1.append([ " 담당자 " , data[ " client " ][ " contact_name " ], " 사업장주소 " , data[ " supplier " ][ " address " ]])
ws1.append([ " 전 화 " , data[ " client " ].get( " contact_phone " , " " ), " 담 당 자 " , data[ " supplier " ][ " contact_name " ]])
ws1.append([ " 이메일 " , data[ " client " ].get( " contact_email " , " " ), " 전화번호 " , data[ " supplier " ].get( " contact_phone " , " " )])
ws1.append([ " " , " " , " 이 메 일 " , data[ " supplier " ].get( " contact_email " , " " )])
ws1.append([ " 견 적 일 " , data[ " invoice_date " ]])
ws1.append([ " 공 급 자 " , data[ " supplier " ][ " name " ]])
ws1.append([ " 견적내용 " , f " {data[ ' project ' ]} {data[ ' period_label ' ]} 추가 개발 정산 " ])
ws1.append([ " 총 비 용 " , f " {data[ ' total_amount ' ]:,}원 ({data[ ' vat_label ' ]}) " ])
ws1.append([ " 개발 소요 기간 " , f " {data[ ' start_date ' ]} ~ {data[ ' end_date ' ]} " ])
ws1.append([])
ws1.append([ " < 견적내용 > " ])
ws1.append([ " (단위 : 원) " ])
ws1.append([ " 구분 " , " 설 명 " , " M/D " , " 금액 " ])
for c in range(1, 5):
ws1.cell(row=ws1.max_row, column=c).font = bold
for group in data[ " billable_groups " ]:
ws1.append([group[ " label " ], " " , " " , " " ])
for area in group[ " areas " ]:
for item in area[ " items " ]:
ws1.append([ " " , f " {area[ ' name ' ]} — {item[ ' title ' ]} " , item[ " md " ], item[ " amount " ]])
ws1.append([f " {group[ ' label ' ]} 소계 ({group[ ' count ' ]}건 · {group[ ' md ' ]} M/D) " ,
" " , " " , group[ " amount " ]])
ws1.cell(row=ws1.max_row, column=1).font = bold
ws1.append([f " 전체 수행 작업 산출액 ({data[ ' total_count ' ]}건 · {data[ ' total_md ' ]} M/D) " ,
" " , " " , data[ " gross_amount " ]])
ws1.append([f " 이전 계약 범위 내 작업 전액 제외 ({data[ ' excluded_count ' ]}건) " ,
" " , " " , -data.get( " excluded_would_be_amount " , 0)])
ws1.append([f " 하자 보수·내부 작업 무상 처리 ({data[ ' free_count ' ]}건) " , " " , " " , 0])
ws1.append([f " 최종 청구액 ({data[ ' billable_count ' ]}건 · {data[ ' billable_md ' ]} M/D) " ,
" " , " " , data[ " total_amount " ]])
ws1.cell(row=ws1.max_row, column=1).font = bold
ws1.append([ " 공급가액 " , " (부가세 별도) " if data[ " vat_separate " ] else " (부가세 포함) " , " " , data[ " total_amount " ]])
if data[ " vat_separate " ]:
vat = round(data[ " total_amount " ] * 0.1)
ws1.append([ " 부 가 세 " , " (10%) " , " " , vat])
ws1.append([ " 합 계 " , " (부가세 포함) " , " " , data[ " total_amount " ] + vat])
for col, width in zip( " ABCD " , (34, 44, 10, 14)):
ws1.column_dimensions[col].width = width
# --- 탭 2: 상세내역 ---
ws2 = wb.create_sheet( " 상세내역 " )
ws2.append([f " {data[ ' project ' ]} {data[ ' period_label ' ]} 추가 개발 정산 — 전체 작업 상세내역 " ])
ws2.append([ " (단위 : 원) " ])
header = [ " NO " , " 구분 " , " 분류 " , " 기능 / 작업 " , " 적용 범위 " , " 요청 출처 / 판정 " , " 규모 " , " M/D " , " 금액 " ]
ws2.append(header)
for c in range(1, len(header) + 1):
ws2.cell(row=ws2.max_row, column=c).font = bold
for idx, row in enumerate(data[ " detail_rows " ], start=1):
ws2.append([
idx, row[ " category " ], row[ " area " ], row[ " title " ], row[ " layers " ],
row[ " source " ], row[ " size " ], row[ " md " ], row[ " amount " ],
])
widths = (6, 20, 16, 44, 18, 26, 8, 8, 12)
for i, w in enumerate(widths, start=1):
ws2.column_dimensions[get_column_letter(i)].width = w
ws2.freeze_panes = " A4 "
wb.save(out_path)
return out_path
def build_csv_fallback(data: dict, out_dir: str) - > list:
import csv
import os
summary_path = os.path.join(out_dir, " settlement_summary.csv " )
detail_path = os.path.join(out_dir, " settlement_detail.csv " )
with open(summary_path, " w " , newline= " " , encoding= " utf-8-sig " ) as f:
w = csv.writer(f)
w.writerow([ " 구분 " , " 설명 " , " M/D " , " 금액 " ])
for group in data[ " billable_groups " ]:
for area in group[ " areas " ]:
for item in area[ " items " ]:
w.writerow([group[ " label " ], f " {area[ ' name ' ]} — {item[ ' title ' ]} " , item[ " md " ], item[ " amount " ]])
w.writerow([ " 최종 청구액 " , " " , data[ " billable_md " ], data[ " total_amount " ]])
with open(detail_path, " w " , newline= " " , encoding= " utf-8-sig " ) as f:
w = csv.writer(f)
w.writerow([ " NO " , " 구분 " , " 분류 " , " 기능 / 작업 " , " 적용 범위 " , " 요청 출처 / 판정 " , " 규모 " , " M/D " , " 금액 " ])
for idx, row in enumerate(data[ " detail_rows " ], start=1):
w.writerow([idx, row[ " category " ], row[ " area " ], row[ " title " ], row[ " layers " ],
row[ " source " ], row[ " size " ], row[ " md " ], row[ " amount " ]])
return [summary_path, detail_path]
if __name__ == " __main__ " :
data_path, out_path = sys.argv[1], sys.argv[2]
with open(data_path, encoding= " utf-8 " ) as f:
data = json.load(f)
if ensure_openpyxl():
result = build_xlsx(data, out_path)
print(f " XLSX:{result} " )
else:
import os
results = build_csv_fallback(data, os.path.dirname(out_path) or " . " )
print( " CSV_FALLBACK: " + " , " .join(results))
실행:
python3 build_settlement_workbook.py settlement_data.json settlement_{{SLUG}}_{{YYYYMMDD}}.xlsx
출력이 XLSX:... 면 그 파일을 base64 인코딩해 Google_Drive.create_file 의 base64Content
로 업로드합니다(contentMimeType: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet).
CSV_FALLBACK:... 이면 두 CSV 를 각각 textContent 로 업로드하고(자동으로 Google Sheets
파일로 변환됩니다 — 탭은 나뉘지 않고 파일이 2개가 됩니다), 최종 보고에 이 경로로 대체됐음을
명시합니다.
4. 입력 JSON 스키마 (settlement_data.json)#
{
" project " : " {{프로젝트명}} " ,
" slug " : " {{slug}} " ,
" period_label " : " {{예: 2026년 8월}} " ,
" start_date " : " YYYY-MM-DD " ,
" end_date " : " YYYY-MM-DD " ,
" invoice_no " : " {{YYYYMMDD-순번}} " ,
" invoice_date " : " YYYY년 M월 D일 " ,
" vat_separate " : true,
" vat_label " : " VAT 별도 " ,
" supplier " : { " name " : " " , " reg_no " : " " , " ceo " : " " , " address " : " " , " contact_name " : " " , " contact_email " : " " , " contact_phone " : " " },
" client " : { " name " : " " , " contact_name " : " " , " contact_email " : " " , " contact_phone " : " " },
" total_count " : 0, " total_md " : 0, " gross_amount " : 0,
" billable_count " : 0, " billable_md " : 0,
" free_count " : 0, " excluded_count " : 0, " excluded_would_be_amount " : 0,
" total_amount " : 0,
" billable_groups " : [
{ " label " : " ① 발주처 요청 신규 기능 " , " count " : 0, " md " : 0, " amount " : 0,
" areas " : [{ " name " : " " , " items " : [{ " title " : " " , " md " : 0, " amount " : 0}]}]}
],
" detail_rows " : [
{ " category " : " 청구 " , " area " : " " , " title " : " " , " layers " : " " , " source " : " UB-000 / GitHub #000 " ,
" size " : " M " , " md " : 0, " amount " : 0}
]
}
detail_rows 는 청구·무상·제외를 전부 담습니다(REFERENCE.md §4 참조) — 아티팩트의 각 섹션은
이 배열을 category 로 필터링해 채웁니다.