讨论

共享费用账单分摊与结算规划器

来自 Wikiprompt,自由的提示词百科全书

Fatih Kadir Akın

2026年10月31日

共享费用账单分摊与结算规划器 一个完整的AI技能/提示,用于从CSV账本中分摊共享费用,支持多种分摊方式、货币以及找出最少的结算转账次数。包括详细说明、模板、示例和完整的Python实现。

提示词内容收藏

🌐
--- name: shared-expense-bill-splitter-and-settle-up-planner description: 将旅行、公寓或活动的共享费用CSV账本进行分摊,支持均摊、份额、精确、百分比和含税费/小费按比例分摊的明细账单,'除某人外所有人'排除规则,多个付款人,退款,还款以及按约定汇率的多币种计算(精确到分),然后找出可证明的最少结算转账(精确零和子群搜索)。返回余额、每人审计和聊天消息。包含一个带有--self-test的stdlib 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行:按夜数份额,一个被排除的人,一份明细晚餐,一笔美元预订,高级座位,两个付款人,一笔退款,英镑零食和一笔已完成的还款 - `examples/lisbon-settle-output.md` - 规划器在该账本上的真实输出 - `examples/flat-october.csv` - 6个室友,其中最小值(4次转账)优于最大债务付给最大债权(5次) ## 工作流程 ### 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 <name>`。 - 如果有人更愿意付给其他人(或无法直接付给某人),在添加约定的转账作为`payment`行后重新运行;计划会重新计算其余部分。 ### 4. 交付 填写`templates/settle-up-summary.md`:总计,余额表,编号转账,假设(汇率,谁在哪里被排除,税费/小费如何分摊),以及简短的群聊消息。建议通过将每笔转账作为`payment`行添加到账本中来标记完成,这样第二次运行会显示每个人为0.00。 ## 示例结果 在`examples/lisbon-trip.csv`上(5人,花费1,583.07欧元),Ana支付了公寓和法多音乐会门票,拿回512.69欧元;计划是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美元 = 0.92欧元) ``` 如果没有`# people:`,文件中任何地方提到的每个人都是成员,`all`表示所有人;姓名匹配不区分大小写。 ## 选择分摊方法 | 费用 | 分摊方法 | 示例 `participants` / `details` | |---|---|---| | 杂货,出租车,燃料,共享门票 | `equal` | `all`,`Ana;Ben`,`all-Eli` | | 住宿(人们住了不同夜数,房间大小不同) | `shares` | `Ana:3;Ben:3;Dara:2`(夜数或房间权重) | | 价格不同的门票,已知的每人金额 | `exact` | `Ana:30;Ben:45`(participants中的其他人平分剩余部分) | | 约定的百分比(按收入分摊租金,喝酒的人分摊葡萄酒) | `percent` | `Ana:25;Ben:25;Dara:50`(必须总计100) | | 人们单独点餐的餐厅账单 | `itemized` | `Ana:31;Ben:28.50;shared:42` | | 某人已经还了某人钱 | `payment` | participants = 唯一的收款人 | ## 明细账单 - `details`列出每个人在税费、服务费和小费之前的项目;`shared`是开胃菜、瓶装酒或任何大家共享的东西的总和(在`participants`中均分)。 - `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` = 收到钱的人,与原始费用相同的participants。 - 房东退还的押金:负`amount`,与押金相同的分摊方式。 - 成员之间转交的钱:`payment`(从`paid_by`到`participants`中的唯一一个人)。以相同方式记录结算转账;重新运行后显示谁仍未结清。 ## 规划器捕获的常见错误 - 精确金额或百分比加起来不对,付款人金额与账单不符,项目超过账单。 - 没有汇率的币种,未知的分摊词,设置`# people:`时姓名拼写错误。 - 两行完全相同(相同日期,描述,金额和付款人)- 报告为警告,不删除。 - 零金额 - 跳过并警告。 FILE:references/settle-up-and-currency.md # 结算与货币 ## 净余额 对每个人:`net = paid + payments sent - share - payments received`。正数 = 拿回钱,负数 = 欠钱。净额总是恰好加起来为0.00,因为每一行都以整数分分摊,总和等于行总额。 ## 为什么“最少转账”可以少于人数 - 1 - 任何有k个有余额的人的小组最多可以用k - 1次转账结算(每次转账至少清零一个人)。 - 如果小组分成若干子群,每个子群的余额加起来为零,每个子群自行结算:有g个这样的子群时,最小值是k - g。示例:Bo +300和Finn -300抵消,所以6个室友需要4次转账,而不是5次。 - 将最大债务付给最大债权(通常的应用程序方法)不会寻找这些子群,可能需要更多转账。 - 找到最佳零和子群划分通常是一个难题;规划器对最多18个有非零余额的人精确求解(子集动态规划,不到一秒),超过则回退到贪心计划并说明。 - 在每个子群内,计划将最大债务付给最大债权,恰好使用(子群大小 - 1)次转账。 - 多个计划可以具有相同的最小次数;任何一个都是公平的,因为每个人的总进出由其净额固定。 ## 舍入 - 每一行转换为基础货币分一次(`amount x rate`,四舍五入到最接近的半分),然后用最大余数法分摊:每个人得到向下取整的金额,剩余的分给最大余数,平局时给`# people:`中第一个列出的人。 - 在整个行程中,这每人最多移动几分钱;如果有人问为什么10.00 / 3对一个人是3.34,请说明。 ## 汇率 - 分摊前为每种货币约定一个汇率:大多数费用支付当天的汇率,行程平均值,或付款人信用卡账单上的汇率(包括银行加价)。 - 如果付款人的卡收取了外币交易费,要么将其包含在金额中(团队分摊),要么排除(付款人吸收)- 为整个行程决定一次。 - 以实际支付的币种输入金额,让规划器转换;不要手动预先转换某些行。 - 以基础货币结算。如果有人以另一种货币支付,按相同的约定汇率转换转账,并以该币种记录为`payment`行。 ## 支付礼仪 - 发送带有转账和账本链接的群聊消息;给人们一两天时间在支付前对某行提出异议。 - 小额转账(约2欧元/美元以下)可以在双方同意的情况下免除或滚入下一次共享费用 - 将其记录为`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,公寓3晚(Dara和Eli 2晚),Ana,840.00,EUR,shares,all,Ana:3;Ben:3;Cem:3;Dara:2;Eli:2 2026-10-02,机场出租车(Eli坐火车来),Ben,48.00,EUR,equal,all-Eli, 2026-10-02,公寓杂货,Eli,63.75,EUR,equal,all, 2026-10-03,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,冲浪课(在美国网站预订),Dara,180.00,USD,equal,Ana;Dara;Eli, 2026-10-03,法多音乐会门票(Dara和Eli高级座位),Ana,150.00,EUR,exact,all-Cem,Ana:30;Ben:30;Dara:45;Eli:45 2026-10-04,去辛特拉的火车,Ben:40;Cem:20,60.00,EUR,equal,all, 2026-10-04,公寓葡萄酒,Dara,36.00,EUR,percent,,Ana:25;Ben:25;Dara:50 2026-10-04,宫殿门票退款(下雨),Ben,-24.00,EUR,equal,Ben;Cem, 2026-10-05,伦敦中转零食,Cem,27.00,GBP,equal,all, 2026-10-05,Eli还给Ana公寓钱,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,Bo和Finn的音乐节门票,Bo,600,EUR,exact,Bo;Finn,Bo:300;Finn:300 2026-10-31,十月杂货(Bo和Finn巡演中),Asha,800,EUR,equal,all-Bo-Finn, 2026-10-31,九月至十月电费(Bo和Finn巡演中),Chen,600,EUR,equal,all-Bo-Finn, 2026-10-20,二手沙发(Dev和Ema的部分),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" 项目均分;金额 - 项目 = 税费/服务费/小费,按每人所点内容比例分摊) 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 # 有非零余额的人数,用于精确最小搜索(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的字典)。 最大余数法;平局时给`order`中更早的名字。总和精确。""" names = [n for n in order if n in weights] wsum = sum(weights[n] for n in names) if wsum <= 0: raise LedgerError("权重加起来为零") 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) # 第一遍:当没有给出人员列表时,学习每个名字 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(不在人员列表中)" % 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 -> 已付/应摊(基础货币分) 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:一笔payment需要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:details中的%s不在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 -(最大不相交零和组数)。返回(转账,组,精确)。""" 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 = "给 " + 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("份额权重", lambda: L(",Room,Ana,100,,shares,,Ana:3;Ben:1\n").lines[0]["share"] == {"Ana": 7500, "Ben": 2500}) case("未列出的参与者份额默认1", lambda: L(",Room,Ana,90,,shares,Ana;Ben;Cem,Ana:2;Ben:0\n").lines[0]["share"] == {"Ana": 6000, "Cem": 3000}) case("精确金额", lambda: L(",Tickets,Ana,75,,exact,,Ana:30;Ben:45\n").lines[0]["share"] == {"Ana": 3000, "Ben": 4500}) case("精确且其余均分", lambda: L(",T,Ana,100,,exact,Ana;Ben;Cem,Ana:40\n").lines[0]["share"] == {"Ana": 4000, "Ben": 3000, "Cem": 3000}) case("精确不匹配被拒绝", lambda: raises(lambda: L(",T,Ana,80,,exact,,Ana:30;Ben:45\n"))) case("百分比", lambda: L(",Wine,Ana,36,,percent,,Ana:25;Ben:25;Cem:50\n").lines[0]["share"] == {"Ana": 900, "Ben": 900, "Cem": 1800}) case("百分比不是100被拒绝", lambda: raises(lambda: L(",W,Ana,36,,percent,,Ana:25;Ben:25\n"))) case("含共享和税费/小费的明细", lambda: L(",Dinner,Ana,66,,itemized,Ana;Ben,Ana:20;Ben:30;shared:10\n").lines[0]["share"] == {"Ana": 2750, "Ben": 3850}) case("明细超过账单被拒绝", lambda: raises(lambda: L(",D,Ana,40,,itemized,Ana;Ben,Ana:20;Ben:30\n"))) case("还款行", lambda: nets(L(",Food,Ana,20,,equal,Ana;Ben,\n,Paid back,Ben,10,,payment,Ana,\n")) == {"Ana": 0, "Ben": 0}) case("还款需要一个收款人", 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("严格人员列表拒绝拼写错误", 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(("通过 " if ok else "失败 ") + 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())

登录以查看完整提示词

继续使用:

登录即表示你同意我们的 使用条款 和 隐私政策

用法

此提示词专为 productivity 设计。复制上方内容并粘贴到你常用的 AI 工具中。

为获得最佳效果,可将占位符(方括号或大写字母标示)替换为你的具体需求。

参考资料

分类:productivity| prompts.chat| expense-splitter| bill-splitter

讨论