공동 비용 청구서 분할 및 정산 플래너
Wikiprompt, 무료 프롬프트 백과사전에서
공동 비용 청구서 분할 및 정산 플래너 공유 비용을 CSV 원장에서 분할하는 완전한 AI 스킬/프롬프트로, 여러 분할 방법, 통화, 그리고 최소 수의 정산 이체를 찾는 기능을 지원합니다. 상세 지침, 템플릿, 예시, 그리고 완전한 Python 구현이 포함되어 있습니다.
프롬프트 내용저장
🌐
---
name: shared-expense-bill-splitter-and-settle-up-planner
description: CSV 원장을 사용하여 여행, 공동 거주, 이벤트의 공동 비용을 분배합니다 - 균등, 지분, 정확, 백분율 및 세금/팁을 비례 배분한 상세 청구서, '모두 제외' 제외, 여러 지불자, 환불, 상환 및 합의된 환율에 따른 여러 통화를 센트 단위로 처리한 후, 증명 가능한 최소 정산 이체(정확한 제로섬 하위 그룹 검색)를 찾습니다. 잔액, 개인별 감사 내역 및 채팅 메시지를 반환합니다. --self-test가 포함된 표준 라이브러리 Python 플래너를 포함합니다.
---
# 공동 경비 청구서 분배기 및 정산 플래너
지저분한 공동 지출 목록을 공정하고 검증 가능한 분배와 가장 짧은 지불 목록으로 변환합니다. 원장을 그룹이 스프레드시트에서 편집할 수 있는 CSV로 유지하고, 각 항목에 실제로 필요한 분배 규칙(누가 있었는지, 누가 무엇을 주문했는지, 누가 더 적은 밤을 묵었는지)을 적용하며, 그룹이 합의한 환율로 통화를 변환하고, 최소한의 이체로 모든 사람을 정산합니다. 모든 센트가 계산되며, 누구나 자신의 숫자 라인을 추적할 수 있습니다.
## 이 스킬의 파일
- `scripts/settle_up.py` - CSV 원장을 읽고, 각 항목을 정수 센트(최대 나머지 반올림)로 분배하며, 개인별 지불액/지분/순액을 계산하고, 모든 제로섬 하위 그룹을 검색하여 최소 이체 수를 찾습니다 (잔액이 있는 18명까지 정확, 그 이상은 탐욕적) (Python 3.8+ 표준 라이브러리만 사용)
- `references/split-methods.md` - CSV 형식, 비용 유형별 분배 방법, 세금/팁 포함 상세 청구서, 제외, 지불자, 환불 및 상환
- `references/settle-up-and-currency.md` - 최소값이 "사람 수 - 1"보다 작을 수 있는 이유, 검색 작동 방식, 반올림, 환율 및 카드 수수료, 지불 에티켓
- `templates/settle-up-summary.md` - 그룹 채팅에 전달하고 붙여넣을 요약
- `examples/lisbon-trip.csv` - 친구 5명, 11개 항목: 밤 수 기준 지분, 제외된 사람, 상세 저녁 식사, USD 예약, 프리미엄 좌석, 두 명의 지불자, 환불, GBP 간식 및 이미 이루어진 상환
- `examples/lisbon-settle-output.md` - 해당 원장에 대한 플래너의 실제 출력
- `examples/flat-october.csv` - 최소값(4회 이체)이 가장 큰 부채를 가장 큰 채권에 지불하는 방식(5회)보다 나은 6명의 룸메이트
## 워크플로우
### 1. 원장 수집
모든 공동 비용에 대해 다음을 요청하세요: 무엇을, 누가 지불했는지(여러 명이 지불한 경우 각자 얼마인지), 금액과 통화, 그리고 누구를 위한 것이었는지. 분배를 바꾸는 질문을 하세요: 늦게 도착하거나 일찍 떠난 사람이 있나요? 식사나 활동을 건너뛴 사람이 있나요? 저녁 식사를 공유했나요, 아니면 개별적으로 주문했나요? 누군가 이미 다른 사람에게 상환했나요? 기본 통화와 환율(그룹이 카드 명세서 환율을 원하지 않는 한 여행 전체에 대해 통화당 하나의 환율)에 동의하세요. `references/split-methods.md`의 CSV로 작성하고, 이름 오타를 잡기 위해 `# people:`을 포함하세요.
### 2. 플래너 실행
```
python3 scripts/settle_up.py examples/lisbon-trip.csv
python3 scripts/settle_up.py flat.csv --base GBP --rates EUR=0.86 --detail Ben
python3 scripts/settle_up.py ledger.csv --json
python3 scripts/settle_up.py --self-test
```
종료 코드 0 = 계획 출력됨(경고 섹션 읽기), 2 = 원장에 오류가 있음(합계 불일치, 환율 누락, 알 수 없는 분배 또는 이름) - 지목된 항목을 수정하세요. 코드를 실행할 수 없는 경우, 동일한 단계를 수동으로 따르세요: 각 항목을 기본 통화 센트로 변환하고, 분배하고, 개인별 지불액에서 지분을 뺀 값을 계산한 다음, 채무자를 채권자에게 연결하되, 잔액이 정확히 상쇄되는 개인이나 소규모 그룹을 먼저 찾으세요.
### 3. 그룹과 결과 확인
- 검사 라인은 `nets add up to 0.00`으로 읽혀야 합니다; 지불액과 지분은 같아야 합니다.
- 잔액을 평이한 말로 읽으세요 ("Ana는 990.00을 지불했고, 그녀의 지분은 377.31이며 Eli는 이미 그녀에게 100.00을 주었으므로, 그녀는 512.69를 돌려받습니다").
- 결과를 이끌고 분쟁이 쉬운 항목(밤 수 기준 지분, 상세 식사, 환불, 상환)을 지적하고, 자신의 내역을 원하는 사람을 위해 `--detail <이름>`을 제공하세요.
- 누군가 다른 사람에게 지불하는 것을 선호하거나(또는 특정인에게 직접 지불할 수 없는 경우), 합의된 이체를 `payment` 항목으로 추가한 후 다시 실행하세요; 계획이 나머지를 다시 계산합니다.
### 4. 전달
`templates/settle-up-summary.md`를 채우세요: 합계, 잔액 표, 번호가 매겨진 이체, 가정(환율, 어디서 누가 제외되었는지, 세금/팁이 어떻게 분산되었는지), 그리고 짧은 그룹 채팅 메시지. 각 이체를 완료로 표시하려면 원장에 `payment` 항목으로 추가하여 두 번째 실행에서 모든 사람이 0.00으로 표시되도록 제안하세요.
## 예시 결과
`examples/lisbon-trip.csv`(5명, 1,583.07 EUR 지출)에서 Ana는 아파트와 파두 티켓을 지불했고 512.69 EUR를 돌려받습니다; 계획은 4회 이체입니다: Ben이 Ana에게 243.17, Dara가 Ana에게 140.94, Eli가 Ana에게 128.58, Eli가 Cem에게 0.85(Eli의 이전 100.00 상환은 이미 계산됨). `examples/flat-october.csv`에서 Bo와 Finn은 서로 상쇄되므로 6명의 룸메이트가 4회 이체로 정산되는 반면, 가장 큰 부채를 가장 큰 채권에 지불하는 방식은 5회가 필요합니다.
## 규칙
- 금액, 환율 또는 참가자를 절대 임의로 만들지 마세요; 물어보거나 요약에서 해당 항목을 가정으로 표시하세요.
- 통화당 하나의 합의된 환율을 사용하고 표시하세요; 완성된 분배에 실시간 환율을 혼합하지 마세요.
- 모든 계산을 센트 단위로 유지하고 반올림된 센트가 어디로 갔는지 보여주세요(최대 나머지, 동률일 경우 먼저 나열된 사람에게).
- 지출을 판단하지 마세요; 중복, 오타 또는 참가자 누락으로 보이는 항목만 표시하세요.
- 상환은 `payment` 항목이지, 절대 음수 비용이 아닙니다; 환불은 원래 비용과 동일한 사람들이 공유하는 음수 비용입니다.
- 계획을 제안으로 제시하세요; 각 개인의 순액이 변경되지 않는 한 그룹은 누가 누구에게 지불할지 바꿀 수 있습니다.
FILE:references/split-methods.md
# 분배 방법 및 CSV 형식
## 열
`date,description,paid_by,amount,currency,split,participants,details` - 헤더 행 필수; 모든 스프레드시트에서 내보낼 수 있습니다. 헤더 앞에 선택적 주석 줄:
```
# people: Ana, Ben, Cem, Dara, Eli (엄격한 목록: 이름 철자 오류는 오류)
# base: EUR
# rates: USD=0.92, GBP=1.16 (1 USD = 0.92 EUR)
```
`# people:`이 없으면 파일 어디에나 언급된 모든 사람이 구성원이고 `all`은 그들 모두를 의미합니다; 이름은 대소문자를 구분하지 않고 일치합니다.
## 분배 선택
| 비용 | 분배 | 예시 `participants` / `details` |
|---|---|---|
| 식료품, 택시, 연료, 공유 티켓 | `equal` | `all`, `Ana;Ben`, `all-Eli` |
| 사람들이 다른 밤을 묵거나, 다른 크기의 방을 쓴 숙박 | `shares` | `Ana:3;Ben:3;Dara:2` (밤 수 또는 방 가중치) |
| 가격이 다른 티켓, 알려진 1인당 금액 | `exact` | `Ana:30;Ben:45` (참가자의 나머지는 나머지를 균등 분배) |
| 합의된 백분율(소득별 임대료, 술 마신 사람들의 와인) | `percent` | `Ana:25;Ben:25;Dara:50` (합계 100이어야 함) |
| 사람들이 개별적으로 주문한 레스토랑 청구서 | `itemized` | `Ana:31;Ben:28.50;shared:42` |
| 누군가 이미 다른 사람에게 상환함 | `payment` | participants = 단일 수령자 |
## 상세 청구서
- `details`에는 세금, 서비스 및 팁 전 각 개인의 항목이 나열됩니다; `shared`는 모두가 공유한 에피타이저, 병 또는 기타 항목의 합계입니다(참가자 간 균등 분배).
- `amount`는 최종 청구서 금액입니다. 차액(금액 - 항목)은 세금, 서비스 및 팁이며, 각 개인이 가진 금액에 비례하여 분산되므로 더 많이 주문한 사람이 더 많은 팁을 냅니다. 출력에는 `+17.3% tax/tip`으로 표시됩니다.
- 항목 합계가 청구서보다 많으면(할인 또는 오타) 플래너가 중지됩니다; 할인을 적용한 항목 가격을 낮추어 입력하세요.
## 제외 및 부분 참여
- `all-Eli` 또는 `all except Eli` = Eli를 제외한 모든 사람; 여러 개 연결: `all-Bo-Finn`.
- 일부 기간만 참석한 사람은 제외하는 대신 `shares`에 밤 수나 일 수를 사용하세요.
- 한 구성원이 비용을 지불한 어린이 또는 손님: 해당 구성원의 이름 아래에 나열(`shares` 가중치 1.5)하거나 `# people:`에 추가하고 부모와 정산하세요.
## 지불자
- 단일 지불자: `Ana`. 한 청구서에 여러 지불자: `Ben:40;Cem:20` (행 통화 금액, `amount`와 합계가 일치해야 함).
- 커플이 공유하는 카드: 커플을 한 사람으로 취급하거나 지불자 금액을 분할하세요.
## 환불, 보증금, 상환
- 취소 후 받은 환불: 음수 `amount`, `paid_by` = 돈을 받은 사람, 원래 비용과 동일한 참가자.
- 집주인이 반환한 보증금: 음수 `amount`를 보증금과 동일한 방식으로 분배.
- 구성원 간에 전달된 돈: `payment` (`paid_by`에서 `participants`의 한 사람에게). 정산 이체도 같은 방식으로 기록하세요; 다시 실행하면 아직 정산되지 않은 사람이 표시됩니다.
## 플래너가 잡는 일반적인 실수
- 합계가 맞지 않는 정확한 금액이나 백분율, 청구서와 다른 지불자 금액, 청구서를 초과하는 항목.
- 환율이 없는 통화, 알 수 없는 분배 단어, `# people:`이 설정된 경우 철자가 틀린 이름.
- 두 개의 동일한 항목(같은 날짜, 설명, 금액 및 지불자) - 경고로 보고, 제거하지 않음.
- 0 금액 - 경고와 함께 건너뜀.
FILE:references/settle-up-and-currency.md
# 정산 및 통화
## 순 잔액
모든 사람에 대해: `net = paid + payments sent - share - payments received`. 양수 = 돈을 돌려받음, 음수 = 빚짐. 모든 항목이 합계와 동일한 정수 센트로 분배되므로 순액은 항상 정확히 0.00이 됩니다.
## "최소 이체"가 사람 수 - 1보다 작을 수 있는 이유
- 잔액이 있는 k명의 그룹은 최대 k - 1회 이체로 정산할 수 있습니다 (각 이체는 최소 한 사람을 정리함).
- 그룹이 각 잔액 합계가 0이 되는 하위 그룹으로 나뉘면 각 하위 그룹은 자체적으로 정산됩니다: 이러한 하위 그룹이 g개 있으면 최소값은 k - g입니다. 예: Bo +300과 Finn -300이 상쇄되므로 6명의 룸메이트는 5회가 아닌 4회의 이체가 필요합니다.
- 가장 큰 부채를 가장 큰 채권에 지불하는 것(일반적인 앱 접근 방식)은 이러한 하위 그룹을 찾지 않으므로 더 많은 이체가 필요할 수 있습니다.
- 제로섬 하위 그룹으로의 최적 분할을 찾는 것은 일반적으로 어려운 문제입니다; 플래너는 잔액이 0이 아닌 최대 18명에 대해 정확하게 풀고(부분집합에 대한 동적 프로그래밍, 1초 미만), 그 이상은 위의 탐욕적 계획으로 대체하며 이를 명시합니다.
- 각 하위 그룹 내에서 계획은 가장 큰 부채를 가장 큰 채권에 지불하며, 정확히 (하위 그룹 크기 - 1)회의 이체를 사용합니다.
- 여러 계획이 동일한 최소 횟수를 가질 수 있습니다; 각 개인의 총 지불액 또는 수령액이 순액에 의해 고정되므로 그 중 어느 것이든 공정합니다.
## 반올림
- 각 항목은 기본 통화 센트로 한 번 변환된 후(`amount x rate`, 반올림), 최대 나머지 방법으로 분배됩니다: 모든 사람은 내림된 금액을 받고, 남은 센트는 가장 큰 분수에 돌아가며, 동률일 경우 `# people:`에 먼저 나열된 사람에게 돌아갑니다.
- 여행 전체에 걸쳐 이는 개인당 몇 센트만 이동합니다; 누군가 10.00 / 3이 한 사람에게 3.34인 이유를 묻는다면 그렇게 말하세요.
## 환율
- 분배 전에 통화당 하나의 환율에 동의하세요: 대부분의 비용이 지불된 날의 환율, 여행 평균, 또는 지불자 카드 명세서의 환율(은행 인상 포함).
- 지불자의 카드에 해외 거래 수수료가 부과된 경우, 금액에 포함하거나(그룹이 공유) 제외하거나(지불자가 부담) - 여행 전체에 대해 한 번 결정하세요.
- 실제 지불된 통화로 금액을 입력하고 플래너가 변환하도록 하세요; 일부 항목을 수동으로 미리 변환하지 마세요.
- 기본 통화로 정산하세요. 누군가 다른 통화로 지불하는 경우, 동일한 합의된 환율로 이체를 변환하고 해당 통화로 `payment` 항목으로 기록하세요.
## 지불 에티켓
- 이체와 원장 링크가 포함된 그룹 채팅 메시지를 보내고, 지불 전에 사람들에게 항목에 이의를 제기할 수 있는 하루나 이틀을 주세요.
- 소액 이체(약 2 EUR/USD 미만)는 양측이 동의하면 면제되거나 다음 공동 비용에 포함될 수 있습니다 - 원장을 정확하게 유지하려면 이를 `payment` 항목으로 기록하세요.
- 모든 사람이 지불했으면 각 이체를 `payment` 항목으로 추가하고 다시 실행하세요: 모든 순액이 0.00이어야 합니다.
FILE:templates/settle-up-summary.md
# 정산: <여행 / 공동 거주 / 이벤트>, <날짜>
**그룹:** <Ana, Ben, Cem, Dara, Eli> **기준 통화:** <EUR> **총 지출:** <1,583.07 EUR> / <11>개 항목
**합의된 환율:** <1 USD = 0.92 EUR, 1 GBP = 1.16 EUR (여행 평균)>
## 잔액
| 사람 | 지불함 | 공정한 지분 | 이미 보냄 / 받음 | 순액 |
|---|---|---|---|---|
| <Ana> | <990.00> | <377.31> | <100.00 받음> | <+512.69 돌려받음> |
| <Ben> | <64.00> | <307.17> | <-> | <-243.17 빚짐> |
## 이체 (<n>, 가능한 최소)
1. <Ben이 Ana에게 243.17 EUR 지불>
2. <Dara가 Ana에게 140.94 EUR 지불>
3. <...>
## 항목 분배 방법
- <아파트: 밤 수 기준, Dara와 Eli는 3박 중 2박>
- <공항 택시: Eli 제외 모든 사람 (기차로 옴)>
- <저녁 식사: 상세 내역, 17.3% 세금 및 팁은 각자 주문한 금액에 따라 분산; 에피타이저 42.00 공유>
- <궁전 티켓 환불: Ben과 Cem에게, 그들이 지불했으므로>
- <Eli가 Ana에게 보낸 100.00은 이미 계산됨>
## 가정 및 미해결 사항
- <위 환율; 카드 수수료 미포함>
- <5번 항목 확인 (서핑 레슨): Ben이 있었나?>
## 그룹 채팅 메시지
```
정산 (<EUR>, <11>개 항목, 환율: <USD 0.92, GBP 1.16>):
- <Ben -> Ana: 243.17 EUR>
- <Dara -> Ana: 140.94 EUR>
전체 표: <시트 링크>. 항목이 잘못된 경우 2일 이내에 알려주세요.
```
**지불 완료 시:** 각 이체를 `payment` 항목으로 추가하고 다시 실행하세요 - 모든 순액이 0.00으로 표시되어야 합니다.
FILE:examples/lisbon-trip.csv
# people: Ana, Ben, Cem, Dara, Eli
# base: EUR
# rates: USD=0.92, GBP=1.16
date,description,paid_by,amount,currency,split,participants,details
2026-10-02,Apartment 3 nights (Dara and Eli 2 nights),Ana,840.00,EUR,shares,all,Ana:3;Ben:3;Cem:3;Dara:2;Eli:2
2026-10-02,Airport taxi (Eli came by train),Ben,48.00,EUR,equal,all-Eli,
2026-10-02,Groceries for the flat,Eli,63.75,EUR,equal,all,
2026-10-03,Dinner at Taberna do Mar,Cem,212.40,EUR,itemized,all,Ana:31;Ben:28.50;Cem:24;Dara:36;Eli:19.50;shared:42
2026-10-03,Surf lesson (booked on a US site),Dara,180.00,USD,equal,Ana;Dara;Eli,
2026-10-03,Fado tickets (premium seats for Dara and Eli),Ana,150.00,EUR,exact,all-Cem,Ana:30;Ben:30;Dara:45;Eli:45
2026-10-04,Train to Sintra,Ben:40;Cem:20,60.00,EUR,equal,all,
2026-10-04,Wine for the flat,Dara,36.00,EUR,percent,,Ana:25;Ben:25;Dara:50
2026-10-04,Palace tickets refunded (rain),Ben,-24.00,EUR,equal,Ben;Cem,
2026-10-05,Snacks at the London layover,Cem,27.00,GBP,equal,all,
2026-10-05,Eli paid Ana back for the apartment,Eli,100.00,EUR,payment,Ana,
FILE:examples/lisbon-settle-output.md
# 정산 계획
기준 통화: EUR 사람: 5 항목: 11 총 지출: 1,583.07 EUR
사용된 환율: 1 GBP = 1.16 EUR, 1 USD = 0.92 EUR
## 지출
| # | 날짜 | 무엇 | 지불자 | 금액 | EUR 환산 | 분배 | 대상 |
|---|---|---|---|---|---|---|---|
| 1 | 2026-10-02 | 아파트 3박 (Dara와 Eli 2박) | Ana | 840.00 EUR | 840.00 | shares | all |
| 2 | 2026-10-02 | 공항 택시 (Eli는 기차로 옴) | Ben | 48.00 EUR | 48.00 | equal | Ana, Ben, Cem, Dara |
| 3 | 2026-10-02 | 아파트 식료품 | Eli | 63.75 EUR | 63.75 | equal | all |
| 4 | 2026-10-03 | Taberna do Mar 저녁 식사 | Cem | 212.40 EUR | 212.40 | itemized (+17.3% tax/tip) | all |
| 5 | 2026-10-03 | 서핑 레슨 (미국 사이트에서 예약) | Dara | 180.00 USD | 165.60 | equal | Ana, Dara, Eli |
| 6 | 2026-10-03 | 파두 티켓 (Dara와 Eli 프리미엄 좌석) | Ana | 150.00 EUR | 150.00 | exact | Ana, Ben, Dara, Eli |
| 7 | 2026-10-04 | 신트라행 기차 | Ben 40.00, Cem 20.00 | 60.00 EUR | 60.00 | equal | all |
| 8 | 2026-10-04 | 아파트 와인 | Dara | 36.00 EUR | 36.00 | percent | Ana, Ben, Dara |
| 9 | 2026-10-04 | 궁전 티켓 환불 (비) | Ben | -24.00 EUR | -24.00 | equal | Ben, Cem |
| 10 | 2026-10-05 | 런던 경유지 간식 | Cem | 27.00 GBP | 31.32 | equal | all |
| 11 | 2026-10-05 | Eli가 Ana에게 아파트 비용 상환 | Eli | 100.00 EUR | 100.00 | payment | Ana에게 |
## 잔액
| 사람 | 지불함 | 지분 | 보냄 | 받음 | 순액 |
|---|---|---|---|---|---|
| Ana | 990.00 | 377.31 | 0.00 | 100.00 | 512.69 (돌려받음) |
| Ben | 64.00 | 307.17 | 0.00 | 0.00 | -243.17 (빚짐) |
| Cem | 263.72 | 262.87 | 0.00 | 0.00 | 0.85 (돌려받음) |
| Dara | 201.60 | 342.54 | 0.00 | 0.00 | -140.94 (빚짐) |
| Eli | 63.75 | 293.18 | 100.00 | 0.00 | -129.43 (빚짐) |
확인: 순액 합계 0.00 (지불 1,583.07 = 지분 1,583.07).
## 이체 (4)
1. Ben이 Ana에게 **243.17 EUR** 지불
2. Dara가 Ana에게 **140.94 EUR** 지불
3. Eli가 Ana에게 **128.58 EUR** 지불
4. Eli가 Cem에게 **0.85 EUR** 지불
가능한 최소: 잔액이 있는 5명에 대해 4회 이체 (더 작은 하위 그룹이 자체적으로 상쇄되지 않음).
## 그룹 채팅용 메시지
```
정산 (EUR, 11개 항목, 환율: GBP 1.16, USD 0.92):
- Ben -> Ana: 243.17 EUR
- Dara -> Ana: 140.94 EUR
- Eli -> Ana: 128.58 EUR
- Eli -> Cem: 0.85 EUR
전체 표는 공유 시트에 있습니다. 항목이 잘못된 경우 2일 이내에 알려주세요.
```
FILE:examples/flat-october.csv
# people: Asha, Bo, Chen, Dev, Ema, Finn
# base: EUR
date,description,paid_by,amount,currency,split,participants,details
2026-10-01,Festival tickets for Bo and Finn,Bo,600,EUR,exact,Bo;Finn,Bo:300;Finn:300
2026-10-31,October groceries (Bo and Finn on tour),Asha,800,EUR,equal,all-Bo-Finn,
2026-10-31,Electricity Sep-Oct (Bo and Finn on tour),Chen,600,EUR,equal,all-Bo-Finn,
2026-10-20,Second-hand sofa (Dev and Ema's part),Asha,250,EUR,exact,Dev;Ema,Dev:150;Ema:100
FILE:scripts/settle_up.py
#!/usr/bin/env python3
"""공동 경비 청구서 분배기 및 정산 플래너 (Python 3.8+ 표준 라이브러리만 사용).
공동 비용(여행, 공동 거주, 이벤트)의 CSV 원장을 읽고, 모든 항목을 정수 센트로 분배하며,
그룹을 정산하는 가장 작은 가능한 이체 수를 찾습니다 (제로섬 하위 그룹에 대한 정확한 검색,
단지 "최대 사람 수 - 1"이 아님).
CSV 열 (헤더 행 필수, 추가 열은 무시):
date,description,paid_by,amount,currency,split,participants,details
paid_by Ana 단일 지불자
Ana:40;Ben:20 여러 지불자 (행 통화 금액, 합계 일치해야 함)
amount 행 통화의 총액; 음수 = paid_by가 받은 환불
currency 공백 = 기준 통화
split equal | shares | exact | percent | itemized | payment
participants all | Ana;Ben;Cem | all-Eli (Eli 제외 모두) | 공백 = details의 이름
details shares: Ana:3;Ben:2 (participants에 있지만 나열되지 않은 사람은 1)
exact: Ana:30;Ben:45 (나머지가 있으면 다른 참가자에게 균등 분배)
percent: Ana:50;Ben:30;Cem:20 (합계 100이어야 함)
itemized: Ana:18;Ben:22.50;shared:12 (세금/팁 전 항목 가격; "shared"
항목은 균등 분배; amount - items = 세금/서비스/팁, 각 개인이
주문한 금액에 비례하여 분배)
payment: (없음) paid_by가 `amount`를 participants의 한 사람에게 제공
상단의 주석 줄은 그룹과 환율을 설정합니다 (또는 CLI 플래그 사용):
# people: Ana, Ben, Cem
# base: EUR
# rates: USD=0.92, GBP=1.16 (해당 통화 1단위 = 기준 통화 rate 단위)
사용법:
python3 settle_up.py ledger.csv [--base EUR] [--rates USD=0.92,GBP=1.16] [--people Ana,Ben]
[--detail Ana] [--json]
python3 settle_up.py --self-test
종료 코드: 0 = 정산 계획 출력, 2 = 잘못된 입력 (알 수 없는 분배, 합계 불일치,
환율 누락, 엄격한 목록의 알 수 없는 사람).
"""
import argparse
import csv
import io
import json
import re
import sys
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
MAX_EXACT = 18 # 정확한 최소 검색을 위한 잔액이 0이 아닌 사람 수 (2^18 상태)
SPLITS = ("equal", "shares", "exact", "percent", "itemized", "payment")
class LedgerError(ValueError):
pass
def dec(s, what):
try:
return Decimal(str(s).replace(",", "").strip())
except (InvalidOperation, AttributeError):
raise LedgerError("%s: %r은(는) 숫자가 아닙니다" % (what, s))
def to_cents(amount):
return int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def fmt(cents, cur=""):
sign = "-" if cents < 0 else ""
c = abs(cents)
s = "%s%d.%02d" % (sign, c // 100, c % 100)
s = re.sub(r"(\d)(?=(\d{3})+\.)", r"\1,", s)
return (s + " " + cur).strip()
def allocate(total, weights, order):
"""정수 `total` 센트를 `weights`(이름 -> Decimal >= 0 dict)에 비례하여 분배.
최대 나머지 방법; 동률은 `order`에서 더 먼저 나오는 이름. 합계는 정확."""
names = [n for n in order if n in weights]
wsum = sum(weights[n] for n in names)
if wsum <= 0:
raise LedgerError("가중치 합계가 0입니다")
sign = -1 if total < 0 else 1
t = abs(total)
raw = {n: Decimal(t) * weights[n] / wsum for n in names}
base = {n: int(raw[n]) for n in names}
left = t - sum(base.values())
rank = sorted(names, key=lambda n: (-(raw[n] - base[n]), order.index(n)))
for n in rank[:left]:
base[n] += 1
return {n: sign * v for n, v in base.items() if v}
def parse_pairs(text, what):
out = {}
for part in [p for p in re.split(r"[;|]", text or "") if p.strip()]:
if ":" not in part:
raise LedgerError("%s: %r은(는) 이름:값 형식이어야 합니다" % (what, part.strip()))
k, v = part.split(":", 1)
k = k.strip()
out[k] = out.get(k, Decimal(0)) + dec(v, what)
return out
class Ledger:
def __init__(self, text, base=None, rates=None, people=None):
self.warnings = []
self.base = base
self.rates = {}
self.people = []
self._canon = {}
header_people = None
body = []
for line in text.splitlines():
s = line.strip()
if s.startswith("#"):
m = re.match(r"#\s*(people|base|rates?)\s*:\s*(.*)$", s, re.I)
if m:
key, val = m.group(1).lower(), m.group(2)
if key == "people":
header_people = [p.strip() for p in re.split(r"[,;]", val) if p.strip()]
elif key == "base" and not self.base:
self.base = val.strip().upper()
elif key.startswith("rate"):
self._add_rates(val)
continue
body.append(line)
self.base = (self.base or "EUR").upper()
if rates:
self._add_rates(rates)
self.rates[self.base] = Decimal(1)
for p in (people or header_people or []):
self._person(p, declare=True)
self.strict = bool(people or header_people)
rows = list(csv.DictReader(io.StringIO("\n".join(body))))
if not rows:
raise LedgerError("비용 행이 없습니다 (헤더 행과 최소 한 줄 필요)")
need = {"description", "paid_by", "amount", "split"}
cols = {c.strip().lower() for c in rows[0].keys() if c}
if not need <= cols:
raise LedgerError("CSV 열 누락: %s" % ", ".join(sorted(need - cols)))
self.rows = []
for r in rows:
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
if not any(r.values()):
continue
self.rows.append(r)
# 첫 번째 패스: people 목록이 없으면 모든 이름 학습
if not self.strict:
for r in self.rows:
for p in self._names_in(r):
self._person(p, declare=True)
self.lines = [self._line(n, r) for n, r in enumerate(self.rows, 1)]
self._check_duplicates()
# -- 사람과 환율
def _add_rates(self, text):
for part in re.split(r"[,;]", text):
if not part.strip():
continue
if "=" not in part:
raise LedgerError("환율 %r은(는) USD=0.92 형식이어야 합니다" % part.strip())
k, v = part.split("=", 1)
r = dec(v, "환율 " + k.strip())
if r <= 0:
raise LedgerError("%s의 환율은 양수여야 합니다" % k.strip())
self.rates[k.strip().upper()] = r
def _person(self, name, declare=False):
key = name.strip().lower()
if not key:
raise LedgerError("빈 사람 이름")
if key in self._canon:
canon = self._canon[key]
if canon != name.strip() and declare:
self.warnings.append("'%s'와(과) '%s'을(를) 같은 사람으로 취급합니다" % (canon, name.strip()))
return canon
if not declare:
raise LedgerError("알 수 없는 사람 %r (people 목록에 없음)" % name.strip())
self._canon[key] = name.strip()
self.people.append(name.strip())
return name.strip()
def _names_in(self, r):
names = []
pb = r.get("paid_by", "")
names += list(parse_pairs(pb, "paid_by").keys()) if ":" in pb else [pb]
part = r.get("participants", "")
if part and not part.lower().startswith("all"):
names += [p for p in re.split(r"[;|]", part) if p.strip()]
if r.get("split", "").lower() != "payment":
names += [k for k in parse_pairs(r.get("details", ""), "details") if k.lower() != "shared"]
return [n for n in names if n.strip()]
def _participants(self, r, ln):
part = r.get("participants", "").strip()
if not part:
return None
if part.lower().startswith("all"):
rest = part[3:].strip()
excl = []
if rest:
rest = re.sub(r"^(except|but|without)\s+", "", rest.lstrip("-").strip(), flags=re.I)
excl = [self._person(x) for x in re.split(r"[-;|,]", rest) if x.strip()]
return [p for p in self.people if p not in excl]
return [self._person(p) for p in re.split(r"[;|]", part) if p.strip()]
# -- CSV 한 줄 -> 기본 통화 센트의 paid / share
def _line(self, ln, r):
desc = r.get("description") or "줄 %d" % ln
where = "줄 %d (%s)" % (ln, desc)
split = (r.get("split") or "equal").lower()
if split not in SPLITS:
raise LedgerError("%s: 알 수 없는 분배 %r (사용: %s)" % (where, split, ", ".join(SPLITS)))
cur = (r.get("currency") or self.base).upper()
if cur not in self.rates:
raise LedgerError("%s: %s에 대한 환율 없음 ('# rates: %s=...' 또는 --rates 추가)" % (where, cur, cur))
amount = dec(r.get("amount"), where + " 금액")
if amount == 0:
self.warnings.append("%s의 금액이 0이며 건너뜁니다" % where)
total = to_cents(amount * self.rates[cur])
# 지불자
pb = r.get("paid_by", "")
if ":" in pb:
pays = {self._person(k): v for k, v in parse_pairs(pb, where + " paid_by").items()}
if sum(pays.values()) != amount:
raise LedgerError("%s: 지불자 합계가 %s이지만 금액은 %s입니다" % (where, sum(pays.values()), amount))
paid = allocate(total, {k: abs(v) for k, v in pays.items()}, self.people) if total else {}
if total < 0:
paid = {k: -abs(v) for k, v in paid.items()}
else:
paid = {self._person(pb): total} if total else {}
people = self._participants(r, ln)
details = parse_pairs(r.get("details", ""), where + " details") if split != "payment" else {}
share = {}
if split == "payment":
if not people or len(people) != 1:
raise LedgerError("%s: 지불에는 participants에 정확히 한 명의 수령인이 필요합니다" % where)
share = {people[0]: total}
elif split == "equal":
people = people or self.people
share = allocate(total, {p: Decimal(1) for p in people}, self.people)
elif split == "shares":
d = {self._person(k): v for k, v in details.items()}
people = people or list(d)
w = {p: d.get(p, Decimal(1)) for p in people}
extra = [p for p in d if p not in people]
if extra:
raise LedgerError("%s: %s이(가) details에 있지만 participants에 없습니다" % (where, ", ".join(extra)))
if any(v < 0 for v in w.values()):
raise LedgerError("%s: 지분은 음수일 수 없습니다" % where)
share = allocate(total, w, self.people)
elif split == "percent":
d = {self._person(k): v for k, v in details.items()}
if sum(d.values()) != 100:
raise LedgerError("%s: 백분율 합계가 %s, 100이 아님" % (where, sum(d.values())))
people = list(d)
share = allocate(total, d, self.people)
elif split == "exact":
d = {self._person(k): v for k, v in details.items()}
people = people or list(d)
fixed = sum(d.values())
rest_people = [p for p in people if p not in d]
if fixed > abs(amount) or (fixed != abs(amount) and not rest_people):
raise LedgerError("%s: 정확한 금액 합계가 %s이지만 청구서는 %s입니다" % (where, fixed, amount))
w = dict(d)
if rest_people:
each = (abs(amount) - fixed) / len(rest_people)
for p in rest_people:
w[p] = each
share = allocate(total, w, self.people)
elif split == "itemized":
people = people or self.people
shared = details.pop("shared", details.pop("Shared", Decimal(0)))
d = {self._person(k): v for k, v in details.items()}
items = sum(d.values()) + shared
if items > abs(amount):
raise LedgerError("%s: 항목 합계가 %s, 청구서 %s보다 큼" % (where, items, amount))
per = {p: d.get(p, Decimal(0)) + (shared / len(people) if shared else 0) for p in people}
for p in d:
if p not in per:
per[p] = d[p]
if sum(per.values()) <= 0:
raise LedgerError("%s: 분배할 항목 없음" % where)
# 세금/팁은 각 개인의 항목에 비례 == 항목 가중치로 총액 분배
share = allocate(total, per, self.people)
rec = {"n": ln, "date": r.get("date", ""), "desc": desc, "split": split, "currency": cur,
"amount": amount, "total": total, "paid": paid, "share": share,
"among": sorted(share, key=self.people.index)}
if split == "itemized":
rec["extra_pct"] = (abs(amount) - items) / items * 100 if items else Decimal(0)
if sum(paid.values()) != sum(share.values()):
raise LedgerError("%s: 내부 반올림 오류" % where) # 예상되지 않음
return rec
def _check_duplicates(self):
seen = {}
for l in self.lines:
key = (l["date"], l["desc"].lower(), l["total"], tuple(sorted(l["paid"])))
if key in seen:
self.warnings.append("%d행은 %d행의 중복으로 보입니다 (%s, %s)" % (l["n"], seen[key], l["desc"], fmt(l["total"], self.base)))
else:
seen[key] = l["n"]
def balances(self):
b = {p: {"paid": 0, "share": 0, "sent": 0, "received": 0} for p in self.people}
for l in self.lines:
if l["split"] == "payment":
for p, c in l["paid"].items():
b[p]["sent"] += c
for p, c in l["share"].items():
b[p]["received"] += c
else:
for p, c in l["paid"].items():
b[p]["paid"] += c
for p, c in l["share"].items():
b[p]["share"] += c
for p in b:
b[p]["net"] = b[p]["paid"] + b[p]["sent"] - b[p]["share"] - b[p]["received"]
return b
# ----------------------------------------------------------------------------- 정산
def greedy(net, order):
"""가장 큰 채무자가 가장 큰 채권자에게 모두가 같아질 때까지 지불 (최대 k-1회 이체)."""
cred = {p: v for p, v in net.items() if v > 0}
debt = {p: -v for p, v in net.items() if v < 0}
out = []
while cred and debt:
c = max(cred, key=lambda p: (cred[p], -order.index(p)))
d = max(debt, key=lambda p: (debt[p], -order.index(p)))
x = min(cred[c], debt[d])
out.append((d, c, x))
cred[c] -= x
debt[d] -= x
if not cred[c]:
del cred[c]
if not debt[d]:
del debt[d]
return out
def minimal(net, order):
"""최소 이체: n - (최대 분리 제로섬 그룹 수). (transfers, groups, exact) 반환."""
people = [p for p in order if net.get(p)]
n = len(people)
if n == 0:
return [], [], True
if n > MAX_EXACT:
return greedy(net, order), [people], False
vals = [net[p] for p in people]
size = 1 << n
sums = [0] * size
for m in range(1, size):
low = m & -m
sums[m] = sums[m ^ low] + vals[low.bit_length() - 1]
dp = [0] * size
for m in range(1, size):
best = 0
mm = m
while mm:
low = mm & -mm
v = dp[m ^ low]
if v > best:
best = v
mm ^= low
dp[m] = best + (1 if sums[m] == 0 else 0)
# 역추적, 각 제로섬 체크포인트에서 그룹 절단
groups, m, last = [], size - 1, size - 1
while m:
mm, pick = m, None
target = dp[m] - (1 if sums[m] == 0 else 0)
while mm:
low = mm & -mm
if dp[m ^ low] == target:
pick = low
break
mm ^= low
m ^= pick
if sums[m] == 0:
groups.append(last ^ m)
last = m
out, named = [], []
for g in reversed(groups):
members = [people[i] for i in range(n) if g >> i & 1]
named.append(members)
out += greedy({p: net[p] for p in members}, order)
return out, named, True
# ----------------------------------------------------------------------------- 출력
def render(led, detail=None):
b = led.balances()
net = {p: b[p]["net"] for p in led.people}
tr, groups, exact = minimal(net, led.people)
gr = greedy(net, led.people)
B = led.base
o = ["# 정산 계획", "",
"기준 통화: %s 사람: %d 항목: %d 총 지출: %s" % (
B, len(led.people), len(led.lines),
fmt(sum(l["total"] for l in led.lines if l["split"] != "payment"), B))]
others = sorted(c for c in led.rates if c != B)
if others:
o.append("사용된 환율: " + ", ".join("1 %s = %s %s" % (c, led.rates[c].normalize(), B) for c in others))
o += ["", "## 지출", "", "| # | 날짜 | 무엇 | 지불자 | 금액 | %s 환산 | 분배 | 대상 |" % B, "|---|---|---|---|---|---|---|---|"]
for l in led.lines:
payer = ", ".join("%s %s" % (p, fmt(c)) if len(l["paid"]) > 1 else p for p, c in l["paid"].items())
among = "all" if len(l["among"]) == len(led.people) and l["split"] != "payment" else ", ".join(l["among"])
split = l["split"] + (" (+%.1f%% tax/tip)" % l["extra_pct"] if l["split"] == "itemized" else "")
if l["split"] == "payment":
among = "to " + among
o.append("| %d | %s | %s | %s | %s %s | %s | %s | %s |" % (l["n"], l["date"], l["desc"].replace("|", "/"), payer,
fmt(to_cents(l["amount"])), l["currency"], fmt(l["total"]), split, among))
o += ["", "## 잔액", "", "| 사람 | 지불함 | 지분 | 보냄 | 받음 | 순액 |", "|---|---|---|---|---|---|"]
for p in led.people:
x = b[p]
state = "돌려받음" if x["net"] > 0 else ("빚짐" if x["net"] < 0 else "같음")
o.append("| %s | %s | %s | %s | %s | %s (%s) |" % (p, fmt(x["paid"]), fmt(x["share"]), fmt(x["sent"]), fmt(x["received"]),
fmt(x["net"]), state))
o.append("")
o.append("확인: 순액 합계 %s (지불 %s = 지분 %s)." % (fmt(sum(net.values())), fmt(sum(x["paid"] for x in b.values())),
fmt(sum(x["share"] for x in b.values()))))
nz = sum(1 for v in net.values() if v)
o += ["", "## 이체 (%d)" % len(tr), ""]
if not tr:
o.append("모두 같습니다. 지불할 것이 없습니다.")
else:
for i, (d, c, x) in enumerate(tr, 1):
o.append("%d. %s이(가) %s에게 **%s** 지불" % (i, d, c, fmt(x, B)))
o.append("")
if exact:
if len(groups) > 1:
o.append("가능한 최소: %d회 이체. 그룹은 자체적으로 정산하는 %d개의 집합으로 나뉩니다 (%s), 따라서 %d명은 %d회 이체만 필요합니다; 가장 큰 부채를 가장 큰 채권에 지불하면 %d회가 필요합니다." % (
len(tr), len(groups), "; ".join(" + ".join(g) for g in groups), nz, len(tr), len(gr)))
else:
o.append("가능한 최소: 잔액이 있는 %d명에 대해 %d회 이체 (더 작은 하위 그룹이 자체적으로 상쇄되지 않음)." % (len(tr), nz))
else:
o.append("%d명에게 잔액이 있습니다; 정확한 최소 검색 건너뜀 (한도 %d), 탐욕적 계획은 최대 %d회 이체를 사용합니다." % (nz, MAX_EXACT, nz - 1))
if detail:
p = led._person(detail)
o += ["", "## 세부 내역: %s" % p, "", "| # | 무엇 | 지불함 | 지분 | 누적 순액 |", "|---|---|---|---|---|"]
run = 0
for l in led.lines:
pd, sh = l["paid"].get(p, 0), l["share"].get(p, 0)
if not pd and not sh:
continue
run += pd - sh
o.append("| %d | %s | %s | %s | %s |" % (l["n"], l["desc"], fmt(pd), fmt(sh), fmt(run)))
if led.warnings:
o += ["", "## 경고", ""] + ["- " + w for w in led.warnings]
o += ["", "## 그룹 채팅용 메시지", "", "```"]
msg = ["정산 (%s, %d개 항목, 환율: %s):" % (B, len(led.lines), ", ".join("%s %s" % (c, led.rates[c].normalize()) for c in others) or "없음")]
msg += ["- %s -> %s: %s" % (d, c, fmt(x, B)) for d, c, x in tr] or ["- 모두 같음"]
msg.append("전체 표는 공유 시트에 있습니다. 항목이 잘못된 경우 2일 이내에 알려주세요.")
o += msg + ["```"]
return "\n".join(o) + "\n"
def as_json(led):
b = led.balances()
net = {p: b[p]["net"] for p in led.people}
tr, groups, exact = minimal(net, led.people)
return {"base": led.base, "people": led.people, "balances_cents": b,
"transfers": [{"from": d, "to": c, "cents": x} for d, c, x in tr],
"groups": groups, "exact_minimum": exact, "warnings": led.warnings}
# ----------------------------------------------------------------------------- 자체 테스트
def self_test():
import random
cases = []
def case(name, fn):
try:
ok, err = bool(fn()), ""
except Exception as e: # noqa
ok, err = False, repr(e)
cases.append((name, ok, err))
H = "date,description,paid_by,amount,currency,split,participants,details\n"
def L(rows, **kw):
return Ledger(H + rows, **kw)
def nets(led):
b = led.balances()
return {p: b[p]["net"] for p in led.people}
def raises(fn):
try:
fn()
except LedgerError:
return True
return False
case("균등 분배 센트까지", lambda: nets(L(",Taxi,Ana,10.00,,equal,Ana;Ben;Cem,\n")) == {"Ana": 666, "Ben": -333, "Cem": -333})
case("나머지 센트는 순서상 먼저에게", lambda: L(",X,Ana,0.10,,equal,Ana;Ben;Cem,\n").lines[0]["share"] == {"Ana": 4, "Ben": 3, "Cem": 3})
case("all 빼기 제외", lambda: Ledger("# people: Ana, Ben, Cem\n" + H + ",X,Ana,30,,equal,all-Cem,\n").lines[0]["among"] == ["Ana", "Ben"])
case("all except 단어", lambda: Ledger("# people: Ana, Ben, Cem\n" + H + ",X,Ana,30,,equal,all except Ben,\n").lines[0]["among"] == ["Ana", "Cem"])
case("shares 가중치", lambda: L(",Room,Ana,100,,shares,,Ana:3;Ben:1\n").lines[0]["share"] == {"Ana": 7500, "Ben": 2500})
case("shares 목록에 없는 참가자는 기본 1", lambda: L(",Room,Ana,90,,shares,Ana;Ben;Cem,Ana:2;Ben:0\n").lines[0]["share"] == {"Ana": 6000, "Cem": 3000})
case("exact 금액", lambda: L(",Tickets,Ana,75,,exact,,Ana:30;Ben:45\n").lines[0]["share"] == {"Ana": 3000, "Ben": 4500})
case("exact 나머지 균등 분배", lambda: L(",T,Ana,100,,exact,Ana;Ben;Cem,Ana:40\n").lines[0]["share"] == {"Ana": 4000, "Ben": 3000, "Cem": 3000})
case("exact 불일치 거부", lambda: raises(lambda: L(",T,Ana,80,,exact,,Ana:30;Ben:45\n")))
case("percent", lambda: L(",Wine,Ana,36,,percent,,Ana:25;Ben:25;Cem:50\n").lines[0]["share"] == {"Ana": 900, "Ben": 900, "Cem": 1800})
case("percent 100 아님 거부", lambda: raises(lambda: L(",W,Ana,36,,percent,,Ana:25;Ben:25\n")))
case("itemized shared 및 tax/tip 포함", lambda: L(",Dinner,Ana,66,,itemized,Ana;Ben,Ana:20;Ben:30;shared:10\n").lines[0]["share"] == {"Ana": 2750, "Ben": 3850})
case("itemized 청구서 초과 거부", lambda: raises(lambda: L(",D,Ana,40,,itemized,Ana;Ben,Ana:20;Ben:30\n")))
case("payment 행", lambda: nets(L(",Food,Ana,20,,equal,Ana;Ben,\n,Paid back,Ben,10,,payment,Ana,\n")) == {"Ana": 0, "Ben": 0})
case("payment 수령인 한 명 필요", lambda: raises(lambda: L(",P,Ben,10,,payment,Ana;Cem,\n")))
case("여러 지불자", lambda: L(",Train,Ana:40;Ben:20,60,,equal,Ana;Ben;Cem,\n").lines[0]["paid"] == {"Ana": 4000, "Ben": 2000})
case("여러 지불자 합계 일치 필요", lambda: raises(lambda: L(",Train,Ana:40;Ben:10,60,,equal,Ana;Ben,\n")))
case("환불 (음수 금액)", lambda: nets(L(",Museum refund,Ben,-24,,equal,Ben;Cem,\n")) == {"Ben": -1200, "Cem": 1200})
case("통화 변환", lambda: L(",Surf,Ana,100,USD,equal,Ana;Ben,\n", rates="USD=0.92").lines[0]["total"] == 9200)
case("주석 줄의 환율", lambda: Ledger("# base: EUR\n# rates: USD=0.92, GBP=1.16\n" + H + ",S,Ana,10,GBP,equal,Ana;Ben,\n").lines[0]["total"] == 1160)
case("환율 누락 거부", lambda: raises(lambda: L(",S,Ana,10,CHF,equal,Ana;Ben,\n")))
case("알 수 없는 분배 거부", lambda: raises(lambda: L(",S,Ana,10,,halves,Ana;Ben,\n")))
case("엄격한 people 목록 오타 거부", lambda: raises(lambda: Ledger("# people: Ana, Ben\n" + H + ",S,Anna,10,,equal,all,\n")))
case("대소문자 무시 이름 병합", lambda: L(",A,Ana,10,,equal,ana;Ben,\n").people == ["Ana", "Ben"])
case("중복 행 경고", lambda: any("중복" in w for w in L("1,Taxi,Ana,10,,equal,Ana;Ben,\n1,Taxi,Ana,10,,equal,Ana;Ben,\n").warnings))
case("열 누락 거부", lambda: raises(lambda: Ledger("what,who\nx,y\n")))
case("잘못된 숫자 거부", lambda: raises(lambda: L(",S,Ana,ten,,equal,Ana;Ben,\n")))
# 최소 이체
case("제로섬 쌍 발견 (4명 -> 2회 이체)", lambda: len(minimal({"A": 500, "B": -500, "C": 300, "D": -300}, list("ABCD"))[0]) == 2)
case("제로섬 하위 그룹 없음 -> 사람 수 - 1회 이체", lambda: len(greedy({"A": 600, "B": 400, "C": -500, "D": -500}, list("ABCD"))) == 3 and len(minimal({"A": 600, "B": 400, "C": -500, "D": -500}, list("ABCD"))[0]) == 3)
case("6명에서 탐욕적보다 최소가 우세", lambda: (lambda n: len(minimal(n, list("ABCDEF"))[0]) == 4 and len(greedy(n, list("ABCDEF"))) == 5)({"A": 700, "B": 300, "C": 250, "D": -500, "E": -450, "F": -300}))
case("모두 같음 -> 이체 없음", lambda: minimal({"A": 0, "B": 0}, ["A", "B"])[0] == [])
def brute_min(net):
ps = [p for p in net if net[p]]
best = len(ps) - 1 if ps else 0
for parts in _partitions(ps):
if all(sum(net[p] for p in g) == 0 for g in parts):
best = min(best, len(ps) - len(parts))
return best
def check_random():
rnd = random.Random(7)
for _ in range(60):
k = rnd.randint(2, 7)
vals = [rnd.choice([-1, 1]) * rnd.choice([100, 200, 300, 500, 250, 50]) for _ in range(k - 1)]
vals.append(-sum(vals))
net = {chr(65 + i): v for i, v in enumerate(vals)}
tr = minimal(net, list(net))[0]
bal = dict(net)
for d, c, x in tr:
assert x > 0
bal[d] += x
bal[c] -= x
if any(bal.values()) or len(tr) != brute_min(net):
return False
return True
case("60개 무작위 그룹에서 최소 == 무차별 대입 및 모든 사람 정산", check_random)
case("이체가 예제와 유사한 원장 정산", lambda: _settles(L(",Flat,Ana,840,,shares,,Ana:3;Ben:3;Cem:2\n,Food,Cem,63.75,,equal,all,\n,Wine,Ben,36,,percent,,Ana:25;Ben:25;Cem:50\n")))
case("렌더링에 이체 및 채팅 메시지 포함", lambda: "그룹 채팅용 메시지" in render(L(",Taxi,Ana,10,,equal,Ana;Ben,\n")))
case("세부 보기", lambda: "세부 내역: Ben" in render(L(",Taxi,Ana,10,,equal,Ana;Ben,\n"), "ben"))
case("json 출력", lambda: as_json(L(",Taxi,Ana,10,,equal,Ana;Ben,\n"))["transfers"] == [{"from": "Ben", "to": "Ana", "cents": 500}])
case("천 단위 서식", lambda: fmt(123456789, "EUR") == "1,234,567.89 EUR" and fmt(-5) == "-0.05")
passed = sum(1 for _, ok, _ in cases if ok)
for name, ok, err in cases:
print(("PASS " if ok else "FAIL ") + name + ((" " + err) if err else ""))
print("%d/%d 통과" % (passed, len(cases)))
return 0 if passed == len(cases) else 1
def _partitions(items):
if not items:
yield []
return
first, rest = items[0], items[1:]
for p in _partitions(rest):
for i in range(len(p)):
yield p[:i] + [[first] + p[i]] + p[i + 1:]
yield [[first]] + p
def _settles(led):
b = led.balances()
net = {p: b[p]["net"] for p in led.people}
for d, c, x in minimal(net, led.people)[0]:
net[d] += x
net[c] -= x
return not any(net.values())
def main(argv=None):
ap = argparse.ArgumentParser(description="공동 경비를 분배하고 최소 정산 이체를 계획합니다.")
ap.add_argument("file", nargs="?", help="CSV 원장 ('-' = 표준 입력)")
ap.add_argument("--base", default=None, help="기준 통화 (기본: '# base:' 줄 또는 EUR)")
ap.add_argument("--rates", default=None, help="USD=0.92,GBP=1.16 (1단위 = 기준 통화 rate 단위)")
ap.add_argument("--people", default=None, help="쉼표로 구분된 그룹 구성원 (엄격: 오타는 오류)")
ap.add_argument("--detail", default=None, help="한 사람에 대한 항목별 내역 표시")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("CSV 원장 또는 --self-test를 제공하세요")
try:
text = sys.stdin.read() if a.file == "-" else open(a.file, encoding="utf-8-sig").read()
people = [p.strip() for p in a.people.split(",")] if a.people else None
led = Ledger(text, a.base, a.rates, people)
out = json.dumps(as_json(led), indent=2) + "\n" if a.json else render(led, a.detail)
except (OSError, LedgerError) as e:
print("오류: %s" % e, file=sys.stderr)
return 2
sys.stdout.write(out)
return 0
if __name__ == "__main__":
sys.exit(main())
전체 프롬프트를 보려면 로그인하세요
Continue with:
By logging in, you agree to our Terms of Use and Privacy Policy
사용법
이 프롬프트는 productivity와 함께 사용하도록 설계되었습니다. 위의 프롬프트 내용을 복사하여 원하는 AI 도구에 붙여넣으세요.
최상의 결과를 얻으려면 자리 표시자(대괄호 또는 대문자로 표시)를 특정 요구 사항으로 사용자 지정할 수 있습니다.
토론
댓글 0개