settlement-invoice Templates
`settlement-invoice` 스킬이 산출물을 만들 때 쓰는 세 가지 템플릿입니다 — 아티팩트 HTML,
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 로 필터링해 채웁니다.