문서 목차
이 페이지에서목차

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 로 필터링해 채웁니다.

검색어를 입력하면 문서를 찾습니다.

↑ ↓ 이동 · Enter 열기Esc 닫기