ノート

共有費用の割り勘計算機と精算プランナー

フリーのプロンプト百科事典 Wikiprompt より

Fatih Kadir Akın

2026年10月31日

共有費用の割り勘計算機と精算プランナー 共有経費をCSV台帳から分割するための完全なAIスキル/プロンプト。複数の分割方法、通貨、および最小数の清算送金を見つける機能をサポートします。詳細な指示、テンプレート、例、完全なPython実装が含まれています。

プロンプト内容保存

🌐
--- name: shared-expense-bill-splitter-and-settle-up-planner description: 旅行、共同生活、イベントの共有コストのCSV台帳を分割し、均等、シェア、正確な金額、パーセント、税金とチップを按分する明細付き請求書、「全員以外」の除外、複数の支払い者、返金、返済、合意したレートでの複数通貨をセント単位で処理し、その後、証明可能な最小数の清算送金(正確なゼロ和サブグループ検索)を見つけます。残高、個人別監査、チャットメッセージを返します。stdlib Pythonプランナー(--self-test付き)を含みます。 --- # 共有費用の割り勘と清算プランナー あなたは、乱雑な共有支出のリストを、公平で検証可能な割り勘と、可能な限り最短の支払いリストに変換します。台帳はグループが任意のスプレッドシートで編集できる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での予約、プレミアムシート、2人の支払い者、返金、GBPでの軽食、すでに行われた返済 - `examples/lisbon-settle-output.md` - その台帳に対するプランナーの実際の出力 - `examples/flat-october.csv` - 6人のルームメイトで、最小値(4回の送金)が最大債務から最大債権への支払い(5回)より優れている例 ## ワークフロー ### 1. 台帳を収集する すべての共有コストについて、何を、誰が支払ったか(複数人が支払った場合はそれぞれいくらか)、金額と通貨、誰のためだったかを尋ねます。分割を変える質問をします: 誰かが遅れて到着したり、早く出発したりしましたか?誰かが食事やアクティビティをスキップしましたか?ディナーは共有でしたか、それとも個別に注文しましたか?誰かがすでに誰かに返済しましたか?基本通貨とレート(グループがカード明細レートを望まない限り、旅行全体で通貨ごとに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`行として追加することを提案し、2回目の実行で全員が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回かかります。 ## ルール - 金額、レート、参加者を決して invent しないでください。尋ねるか、その行を要約の前提条件としてマークしてください。 - 通貨ごとに1つの合意されたレートを使用し、それを表示してください。完成した分割にライブレートを混在させないでください。 - すべての計算をセントで行い、丸めセントがどこに行ったかを示してください(最大剰余、同点の場合は最初にリストされた人)。 - 支出を判断しないでください。重複、タイプミス、参加者の欠落のように見える行のみにフラグを立ててください。 - 返済は`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`(泊数または部屋の重み) | | 価格が異なるチケット、既知の一人当たり金額 | `exact` | `Ana:30;Ben:45`(participantsの他の人は残りを均等に分割) | | 合意されたパーセンテージ(収入による家賃、飲んだ人のワイン) | `percent` | `Ana:25;Ben:25;Dara:50`(合計100でなければなりません) | | 人々が個別に注文したレストラン請求書 | `itemized` | `Ana:31;Ben:28.50;shared:42` | | 誰かがすでに誰かに返済した | `payment` | participants = 受取人1人 | ## 明細付き請求書 - `details`は税金、サービス料、チップの前の各人の項目をリストします。`shared`は前菜、ボトル、または全員が共有したものの合計です(`participants`間で均等に分割)。 - `amount`は最終的な請求書です。差額(amount - items)は税金、サービス料、チップであり、各人が注文したものに比例して配分されるため、多く注文した人はより多くのチップを支払います。出力では`+17.3% tax/tip`として表示されます。 - 項目が請求書より多い場合(割引またはタイプミス)、プランナーは停止します。割引は、適用された項目価格を下げて入力してください。 ## 除外と部分参加 - `all-Eli`または`all except Eli` = Eli以外の全員。複数連結: `all-Bo-Finn`。 - 一部の時間だけ参加した人には、除外する代わりに泊数または日数で`shares`を使用します。 - 1人のメンバーが支払った子供またはゲスト: メンバーの名前の下にリストするか(`shares`の重み1.5)、`# people:`に追加して親と清算します。 ## 支払い者 - 1人の支払い者: `Ana`。1つの請求書に複数の支払い者: `Ben:40;Cem:20`(行の通貨での金額、`amount`に合計する必要があります)。 - カップルが共有するカード: カップルを1人として扱うか、支払い者金額を分割します。 ## 返金、保証金、返済 - キャンセル後に受け取った返金: マイナスの`amount`、`paid_by` = お金を受け取った人、元のコストと同じ参加者。 - 大家から返還された保証金: マイナスの`amount`、保証金と同じ方法で分割。 - メンバー間で渡されたお金: `payment`(`paid_by`から`participants`の1人へ)。清算送金も同じように記録します。再実行すると、まだ未清算の人が表示されます。 ## プランナーが検出する一般的な間違い - 合計が一致しない正確な金額またはパーセンテージ、請求書と異なる支払い者金額、請求書を超える項目。 - レートのない通貨、不明な分割語、`# people:`が設定されている場合の名前のタイプミス。 - 2つの同一行(同じ日付、説明、金額、支払い者)- 警告として報告され、削除されません。 - ゼロ金額 - 警告付きでスキップされます。 FILE:references/settle-up-and-currency.md # 清算と通貨 ## 純残高 すべての人について: `net = paid + payments sent - share - payments received`。正 = お金を受け取る、負 = 支払う義務がある。すべての行が行の合計に等しい整数セントで分割されるため、純額は常に正確に0.00になります。 ## 「最小送金数」が人数 - 1より小さくなり得る理由 - 残高のあるk人のグループは、最大k - 1回の送金で清算できます(各送金は少なくとも1人を清算します)。 - グループが、それぞれの残高がゼロになるサブグループに分割される場合、各サブグループは独自に清算します: そのようなサブグループがg個ある場合、最小値はk - gです。例: Bo +300とFinn -300が相殺されるため、6人のルームメイトは5回ではなく4回の送金が必要です。 - 最大債務から最大債権への支払い(通常のアプリのアプローチ)はこれらのサブグループを探さないため、より多くの送金が必要になる場合があります。 - ゼロ和サブグループへの最適な分割を見つけることは一般的に難しい問題です。プランナーは、残高がゼロでない最大18人まで正確に解決し(サブセット上の動的計画法、1秒未満)、それを超えると上記の貪欲法にフォールバックして、その旨を述べます。 - 各サブグループ内では、プランは最大債務から最大債権に支払います。これにより、正確に(サブグループサイズ - 1)回の送金が使用されます。 - 複数のプランが同じ最小数を持つ場合があります。各人の総入出金は純額によって固定されるため、どれも公平です。 ## 丸め - 各行は基本セントに一度だけ変換され(`amount x rate`、半数上げで丸め)、その後、最大剰余法で分割されます: 全員が切り捨てられた金額を受け取り、残りのセントは最大の端数に渡り、同点の場合は`# people:`で最初にリストされた人に渡ります。 - 旅行全体で、これは1人あたり最大数セントの変動です。10.00 / 3が1人にとって3.34になる理由を尋ねられたら、その旨を伝えてください。 ## 為替レート - 分割前に通貨ごとに1つのレートに同意します: ほとんどのコストが支払われた日のレート、旅行の平均、または支払い者のカード明細のレート(銀行のマークアップを含む)。 - 支払い者のカードが外国取引手数料を請求した場合、それを金額に含めるか(グループが共有)、除外するか(支払い者が吸収)を決定します。旅行全体で一度だけ決定します。 - 実際に支払われた通貨で金額を入力し、プランナーに変換させます。一部の行を手動で事前変換しないでください。 - 基本通貨で清算します。誰かが別の通貨で支払う場合、同じ合意されたレートで送金を変換し、その通貨で`payment`行として記録します。 ## 支払いエチケット - 送金と台帳へのリンクを含むグループチャットメッセージを送信します。支払いの前に、行に異議を唱えるために1日か2日与えます。 - 少額の送金(約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 1人の支払い者 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 (残りがあれば、他のparticipants間で均等に分割) percent: Ana:50;Ben:30;Cem:20 (合計100でなければなりません) itemized: Ana:18;Ben:22.50;shared:12 (税金/チップ前の項目価格。「shared」項目は 均等に分割。amount - items = 税金/サービス/チップで、各人が注文したものに比例して分割) payment: (なし)paid_byは`amount`をparticipantsの1人に渡しました 先頭のコメント行はグループとレートを設定します(またはCLIフラグを使用): # people: Ana, Ben, Cem # base: EUR # rates: USD=0.92, GBP=1.16 (その通貨の1単位 = 基本通貨のレート単位) 使用法: 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 は Name:value の形式であるべきです" % (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("支出行が見つかりません(ヘッダー行と少なくとも1行が必要です)") 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()] # -- 1つの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: 支払いにはparticipantsに受取人が正確に1人必要です" % 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 - (最大数の互いに素なゼロ和グループ)。(送金、グループ、正確) を返します。""" 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("全員から除外", 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("支払いには受取人が1人必要", 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(("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単位 = 基本通貨のレート単位)") ap.add_argument("--people", default=None, help="カンマ区切りのグループメンバー(厳密: タイプミスはエラー)") ap.add_argument("--detail", default=None, help="1人の行ごとの内訳を表示") 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

ノート