LogoSkills

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_filebase64Content 로 업로드합니다(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 로 필터링해 채웁니다.