見出し画像

構造化CSVから売掛・買掛事象をどう判定し、取引先別残高を集計したか

Ledger Explorer改定の開発者向け解説

前回の記事では、Ledger Explorerに取引先別の売掛金・買掛金を確認する画面を追加し、残高の集計から関連仕訳、その月のその他の取引へたどれるようにした経過を紹介しました。

今回は、その内部で構造化CSVをどのように読み取り、売掛・買掛事象を判定し、取引先別の発生額、回収・支払額、月末残高へ集計しているかを、開発者向けに解説します。

ここで重要なのは、元のCSVに「これは売掛金の発生」「これは買掛金の支払」と書かれた専用列があるわけではないことです。勘定科目、借方・貸方、補助科目、相手科目、日付、金額という会計上の関係から、処理対象となる事象を判定します。

1 一般的なRDBの処理と何が違うのか

一般的な業務システムでは、売掛金・買掛金を処理する前に、用途に合わせたRDBのテーブルを設計します。

例えば、仕訳ヘッダー、仕訳明細、勘定科目、補助科目、取引先、売掛残高、買掛残高などのテーブルを設け、それぞれを主キーと外部キーで関連付けます。売掛金残高を表示するときは、必要なテーブルをJOINし、WHERE句で対象科目と取引先を選択し、GROUP BYで集計します。

処理のイメージは、次のようになります。

SELECT partner_code,
       SUM(debit_amount) - SUM(credit_amount) AS balance
FROM journal_lines
WHERE account_code = '10A100090'
GROUP BY partner_code;

この方式では、先に「どのテーブルの、どの列へ格納するか」を決めます。売掛金集計用のテーブルやビューを作れば、集計処理は簡潔になります。その一方で、元データを異なる構造の会計ソフトへ移したり、新しい分析目的に再利用したりするときには、データベーススキーマ、取込処理、テーブル間の関係及びSQLを見直す必要があります。

今回のLedger Explorerでは、売掛金専用又は買掛金専用の取引テーブルを正本として作っていません。伝票、仕訳明細、勘定科目、補助科目及び取引先の意味と階層を保持した構造化CSVを正本とし、そこから次の関係をたどって売掛・買掛事象を導出します。

構造化CSVの階層行
→ 仕訳明細
→ 借方側・貸方側の元帳行
→ 対象科目と通常残高側の判定
→ 相手科目による事象分類
→ 取引先別残高

RDBでは、あらかじめ定義したテーブル間の関係をJOINして答えを得ます。今回の方式では、構造化CSVに記録された会計上の意味と階層から、用途に応じたデータ構造をその都度生成します。

図1 一般的なRDB中心の処理と構造化CSVによる処理の違い

この図の左側では、用途別に設計したテーブルへ値を格納し、そのスキーマに対してSQLを実行しています。右側では、意味と階層を保持した構造化CSVを正本とし、会計上の関係を復元して、必要な帳簿や残高を導出しています。

したがって、違いは「データベースを使うか、CSVを使うか」だけではありません。

RDBの一般的な処理

・用途別のテーブル構造を先に決める
・取込時に各値を所定の列へ格納する
・主キーと外部キーでテーブルを結合する
・SQL又はビューに集計条件を記述する
・処理結果は、そのデータベーススキーマに依存する

今回の構造化CSVによる処理

・元データの意味と階層関係を先に保持する
・識別子、コード及び階層から仕訳の構造を復元する
・会計上の通常残高側と相手科目から事象を判定する
・同じ正本から元帳、試算表、売掛・買掛管理などを導出する
・判定規則を結合表へ外出しすれば、入力構文が変わっても処理を再利用できる

RDBは、稼働中のシステムで高速な検索、同時更新及びトランザクション管理を行う内部実装としては有効です。しかし、会計データの意味や証拠性まで、特定システムのRDBスキーマに閉じ込めるべきではありません。長期保存、システム移行、監査及びAI検証の正本は、元のデータベース製品、テーブル定義及びアプリケーションがなくても、意味を読み取り、再計算し、検証できる形式で保持する必要があります。

データ更新も、一度の走査によるO(n)の再計算でよい

特定の一件を探して上書きする処理だけを比べれば、構造化CSVがRDBより高速なわけではありません。構造化CSVから対象行を探してファイルを書き直す処理は、データ件数をnとすると、一般にO(n)です。索引を備えたRDBでは、対象行の検索をおおむねO(log n)で行えるため、頻繁な部分更新にはRDBが適しています。

しかし、確定した会計データは、過去の仕訳を直接上書きするよりも、訂正仕訳又は新しい取引を追記し、その正本から帳簿や残高を再生成する方が適切です。追記自体は原則としてO(1)であり、再集計は全データを先頭から一度読み取るO(n)の処理で実行できます。

この一度の走査の中で、次の処理を同時に行えます。

・仕訳日記帳の生成
・勘定科目別の総勘定元帳への展開
・残高試算表の集計
・取引先別の売掛金・買掛金残高の計算
・開始残高、当月増減及び月末残高の照合

RDB中心の設計では、仕訳テーブルを更新した後、売掛残高、買掛残高、月次残高その他の用途別テーブル又はキャッシュも更新し、それらの整合性を維持する処理が必要になる場合があります。途中の更新に失敗すれば、同じ取引を基礎にしているにもかかわらず、仕訳と残高が一致しない状態を残すおそれがあります。

構造化CSVでは、用途別の派生データを正本として更新しません。意味と階層を保持した一つの正本へ取引を追記し、必要な結果をO(n)で再計算します。そのため、複数の派生テーブルを個別に同期更新する必要がなく、同じ入力と同じ判定規則から同じ結果を何度でも再現できます。

ここで重要なのは、O(n)だからRDBより常に高速だということではありません。会計処理では、更新のたびに複雑な状態を各所へ書き込むよりも、正本を保ち、単純な線形処理で派生結果を作り直せることに価値があります。データ量に比例する予測可能な計算であり、処理経路を追跡しやすく、監査やAIによる独立検証にも適しています。

つまり、構造化CSVによる設計は、RDBの用途別テーブルを会計データの正本とする考え方を採りません。データをシステム固有のスキーマへ従属させるのではなく、システム側がデータに保持された意味を読み取り、必要な帳簿と管理情報をその都度生成します。

2 処理の全体像

処理は、大きく次の五段階です。

  1. UADCで元の仕訳日記帳を構造化CSVへ変換する

  2. 構造化CSVの階層行から、金額を持つ仕訳明細を抽出する

  3. 仕訳明細を勘定科目ごとの総勘定元帳行へ展開する

  4. 売掛金又は買掛金の元帳行を、発生、回収・支払、手形、値引などに分類する

  5. 取引先別開始残高に月次増減を加え、選択月の残高を表示する

図2 構造化CSVから取引先別残高を導出する処理

売掛金・買掛金専用の列やテーブルを読み取っているのではありません。構造化CSVから仕訳の階層を復元し、元帳行へ展開した後、勘定科目、借方・貸方、取引先及び相手科目の関係から売掛・買掛事象を判定しています。

構造化CSVは、意味と階層を保持する保存用の元データです。一方、画面での集計には、同じデータから生成した月次の総勘定元帳CSVを使用します。RDBに置き換えて考えるなら、月次の総勘定元帳CSVは、正本そのものではなく、検索と表示を容易にするマテリアライズドビューに近い役割です。

これは構造化CSVを使っていないという意味ではありません。階層構造を保持する構造化CSVを正本とし、用途に応じて仕訳日記帳、総勘定元帳、試算表などの表示用データへ展開しています。

3 構造化CSVの階層を読む

今回使用した構造化CSVでは、伝票、仕訳明細、部門などが一つの表の中で階層的に表現されています。

例えば、伝票日付や伝票番号を持つ行の下に、借方科目、貸方科目、借方金額、貸方金額を持つ明細行が続きます。さらに、その明細に関係する部門などが子行として記録されます。

そのため、すべての行を単純に合計してはいけません。最初に、階層上の役割を判定し、金額を持つ仕訳明細行だけを抽出します。同じ伝票に属する行には、上位行の伝票日付、伝票番号、摘要などを引き継ぎます。

処理の考え方を簡略化すると、次のようになります。

for row in structured_csv:
    if row.is_voucher_header:
        current_voucher = read_voucher_values(row)

    if row.is_journal_line:
        line = inherit(current_voucher, row)
        journal_lines.append(line)

実際の実装では、列の意味を日本語の表示名だけでなく、構造化CSVの識別子に対応させています。このため、表示言語を日本語から英語へ変えても、識別子とコード値を基準に同じ処理ができます。

4 仕訳を総勘定元帳行へ展開する

仕訳日記帳の一行には、借方と貸方が同時に記録されています。しかし、勘定科目別の増減を計算するには、借方科目側と貸方科目側を、それぞれ独立した元帳行として扱う方が明快です。

そこで、一つの仕訳明細から次の二種類の元帳行を生成します。

借方側の元帳行

・借方科目を「元帳科目」とする
・貸方科目を「相手科目」とする
・借方金額を計上する
・貸方金額は0とする

貸方側の元帳行

・貸方科目を「元帳科目」とする
・借方科目を「相手科目」とする
・貸方金額を計上する
・借方金額は0とする

生成後の主な列は、次のとおりです。

Transaction_Date:取引日
Description:摘要
Ledger_Account_Number:元帳科目コード
Subaccount_Code:元帳科目側の補助科目コード
Subaccount_Name:取引先名
Debit_Amount:借方金額
Credit_Amount:貸方金額
Counterpart_Account_Number:相手科目コード

この変換により、売掛金又は買掛金の元帳行だけを選択し、その行の借方・貸方と相手科目から事象を判定できます。

5 売掛金と買掛金をコードで識別する

今回のサンプルでは、次の勘定科目コードを使用しています。

売掛金:10A100090
買掛金:10B100040

取引先の区分には、別の取引先マスターを使用します。

得意先:partner_type = C
仕入先:partner_type = S

売掛金画面では得意先だけを、買掛金画面では仕入先だけを対象にします。名称ではなく、勘定科目コード、取引先区分、補助科目コードをキーにすることで、表記揺れによる誤集計を避けています。

6 通常残高側から発生と消込を判定する

売掛金は資産であり、通常は借方で増加します。買掛金は負債であり、通常は貸方で増加します。

この通常残高側の違いを設定として保持します。

const reports = {
  receivables: {
    partnerType: "C",
    accountNumber: "10A100090",
    normalSide: "debit"
  },
  payables: {
    partnerType: "S",
    accountNumber: "10B100040",
    normalSide: "credit"
  }
};

売掛金では、借方計上額を発生、貸方計上額を回収・消込として扱います。

売掛金発生額 = 売掛金元帳行の借方金額
売掛金消込額 = 売掛金元帳行の貸方金額

買掛金では、その関係が逆になります。

買掛金発生額 = 買掛金元帳行の貸方金額
買掛金消込額 = 買掛金元帳行の借方金額

処理を共通化すると、次のようになります。

const occurrence = normalSide === "debit"
  ? debitAmount
  : creditAmount;

const settlement = normalSide === "debit"
  ? creditAmount
  : debitAmount;

この判定は、摘要の「売上」「仕入」「入金」「支払」といった文字列には依存しません。勘定科目と借方・貸方の関係を使うため、摘要の書き方が変わっても基本的な判定は維持できます。

図3 通常残高側による事象判定と残高の背景確認

図の上半分が計算ロジック、下半分が画面での確認経路です。通常残高側によって発生と消込を判定し、相手科目で内訳を分類します。画面では、その集計結果から直接関係する仕訳、さらに当月の周辺取引へとたどれます。

7 相手科目から回収・支払方法を分類する

消込額は、相手科目コードによって、現金・預金、手形、値引、振込料、相殺・その他へ分類します。

今回の実装例では、主に次のような規則を使用しています。

現金及び預金:10A100020
受取手形:10A100060
支払手形:10B100030
売上値引・返品等:10D100110、10D100111、10D100112
仕入値引・返品等:10E100120、10E100121、10E100122
売掛金回収時の振込料:10E200690
上記以外:相殺・その他

簡略化した判定は次のとおりです。

function classifySettlement(counterpart, reportType) {
  if (counterpart === "10A100020") return "cash";

  if (reportType === "receivables") {
    if (counterpart === "10A100060") return "note";
    if (counterpart === "10E200690") return "fee";
    if (["10D100110", "10D100111", "10D100112"].includes(counterpart)) {
      return "discount";
    }
  } else {
    if (counterpart === "10B100030") return "note";
    if (["10E100120", "10E100121", "10E100122"].includes(counterpart)) {
      return "discount";
    }
  }

  return "other";
}

この分類表はプログラムへ固定したままにせず、将来は勘定科目マスター又はセマンティック結合表へ外出しする方がよいと考えています。会計ソフトや事業者によって科目コードが異なっても、分類定義を差し替えて同じ集計ロジックを再利用できるからです。

8 取引先別開始残高と月次増減を結合する

年間データの途中月を正しく表示するには、当月取引だけを合計するのでは不十分です。年度開始時点の取引先別残高を読み込み、4月から選択月まで順番に残高を繰り越します。

取引先別開始残高は、次の三列を持つCSVとして分離しました。

partner_type:C又はS
partner_code:取引先コード
opening_balance:年度開始残高

各月の計算式は共通です。

月末残高 = 月初残高 + 当月発生額 - 当月消込額

翌月の月初残高には、前月の月末残高をそのまま引き継ぎます。

for (const month of monthsUpToSelectedMonth) {
  for (const partner of partners) {
    partner.opening = partner.balance;
    resetMonthlyAmounts(partner);
  }

  aggregateLedgerRows(month, partners);

  for (const partner of partners) {
    partner.balance =
      partner.opening + partner.occurrence - partner.applied;
  }
}

開始残高ファイルがない場合、ゼロとみなして処理を続けることはしていません。不足、不正な金額、取引先コードの重複、マスターに存在しないコードを検出した場合は、集計を停止します。

ゼロ補完すると画面は表示できますが、誤った残高を正常な結果のように見せる危険があるためです。

9 売掛・買掛事象とその他の取引を分ける

集計表で取引先を選択すると、選択月の仕訳日記帳を読み込み、二種類に分けて表示します。

一つ目は、売掛金又は買掛金に直接関係する仕訳です。

借方又は貸方の勘定科目コードが対象科目と一致し、同じ側の補助科目コードが選択した取引先コードと一致するものを抽出します。

const directMatch =
  (debitAccount === targetAccount && debitSubCode === partnerCode) ||
  (creditAccount === targetAccount && creditSubCode === partnerCode);

二つ目は、その取引先に関係するものの、売掛金又は買掛金へ直接計上されていない当月取引です。

現在の実装では、借方・貸方の補助科目名又は摘要に取引先名が含まれる仕訳を候補とし、直接関係する仕訳と分けて表示します。法人種別を除いた短縮名も照合に使用しています。

この名称照合は、背景確認を助ける検索機能です。残高計算そのものには使用していません。残高集計は、あくまで勘定科目コードと取引先コードで行います。

10 検証で確認すべき不変条件

売掛金・買掛金の集計では、画面が表示されるだけでは十分ではありません。少なくとも次の条件を機械的に検証します。

  1. 仕訳伝票ごとに借方合計と貸方合計が一致すること

  2. 取引先別開始残高の合計が勘定科目の開始残高と一致すること

  3. 取引先別月末残高の合計が総勘定元帳の科目残高と一致すること

  4. 前月末残高と翌月初残高が一致すること

  5. 日本語版と英語版でコード、行構造、金額が一致すること

  6. マスター、開始残高、月次元帳の欠落を検出できること

今回のテストデータでは、売掛金26社、買掛金15社について12か月を検証し、取引先別残高合計と科目残高との差額が全月で0円になることを確認しました。

11 LLMによる検証とプログラムによる集計を分ける

今回、ChatGPTとCodexは、当事務所が提供するLedger Explorerの構造化CSVを対象に、合計、按分、月次比較、残高推移を独自に再計算しました。既存プログラムの表示結果を追認するのではなく、構造化された項目間の関係を読み取り、金額規模の妥当性も評価したことで、開始残高の不整合を発見し、テストデータの改善につなげました。

一方、公開中のLedger Explorerが画面を表示するたびに、LLMへ会計判断を依頼しているわけではありません。

LLMによる検証で発見した問題と確認条件を、勘定科目コード、通常残高側、取引先コード、相手科目分類、残高計算式という明示的なプログラム規則へ落とし込みました。そのため、同じ入力には同じ結果が返り、検証用プログラムでも再計算できます。

LLMは、問題を探索し、別の観点から計算し、テスト条件を改善するために使います。Ledger Explorer本体は、確認済みの規則に従って決定的に集計します。この役割分担が重要です。

12 今回の実装から見えた構造化CSVの利点

通常の帳票CSVでは、表示に必要な列だけが出力され、伝票、仕訳、取引先、部門の関係が失われることがあります。

構造化CSVでは、行の階層と項目の意味を保持できるため、同じ元データから次の処理を行えます。

・仕訳日記帳を再構成する
・勘定科目別の総勘定元帳を生成する
・取引先別の売掛金・買掛金を集計する
・残高から関係仕訳へ戻る
・試算表、貸借対照表、損益計算書と照合する
・LLMが別経路で再計算し、金額の不自然さを検査する

つまり、構造化CSVは単なる画面表示用ファイルではありません。用途別テーブルへ格納した後のデータを正本とするのではなく、会計データの意味と関係を読める形で保持し、集計、追跡、照合、RDBへのロード及びAI検証へ再利用するための中間基盤です。

13 今後の改定

現在の実装では、売掛金・買掛金の科目コードと相手科目の分類規則の一部をプログラム内に定義しています。

次の段階では、これらをセマンティックモデル及び結合表へ移し、次のような定義として管理したいと考えています。

・対象科目が売掛債権か買掛債務か
・通常残高側が借方か貸方か
・補助科目が得意先か仕入先か
・相手科目が現金、預金、手形、値引、手数料、相殺のどれか
・発生、消込、振替、訂正のどの事象に該当するか

こうすれば、PCA会計のサンプルだけでなく、別の会計ソフト、XBRL GL、ISO 21378に基づく監査データにも、同じ判定・集計処理を適用しやすくなります。

付記

今回使用したテストデータは、PCA会計が提供する事業者の年間仕訳日記帳のサンプルデータを基に、当事務所がUADCを使用して構造化CSVへ変換したものです。

開始残高は、このサンプルデータとは別に設定しています。当初は勘定科目単位の合計額だけを設定していましたが、今回、売掛金・買掛金を取引先別に管理するため、取引先ごとの開始残高を設定しました。

勘定科目コードは、国税庁が公開している「勘定科目コード表」のコードを使用しています。
https://www.e-tax.nta.go.jp/hojin/gimuka/csv_jyoho4.htm

続報

本記事の原稿はChatGPT及びCodexが作成し、三分一技術士事務所が内容を確認しました。なお、Ledger ExplorerはMITライセンスで公開しています。
追記:開始残高を取引状況から再評価しました

本記事では、年間を通じて残高が負にならないことを条件として、開始残高を再設定しました。

その後、棚卸資産回転日数(DIO)、売上債権回転日数(DSO)、仕入債務回転日数(DPO)及びキャッシュ・コンバージョン・サイクル(CCC)を用いて再評価したところ、残高は負にならなくても、年間の取引規模や支払条件からは説明しにくい開始残高になっていることが分かりました。

さらに、買掛金を取引先別に検証した結果、仕訳日記帳から総勘定元帳を生成する際に、買掛金額と仕入先情報との対応が正しく保持されていない問題も発見しました。

新しい記事では、AIが最初に計算した結果を別の会計的視点から再評価し、元データとプログラムへさかのぼって誤解と不具合を訂正した経過を紹介しています。

続編

「数字が合っても、正しいとは限らない――CCCから開始残高と元帳生成処理を再検証する」

関連リンク

no+eマガジン「構造化CSVでつなぐ LedgerExplorer 改定記」
https://note.com/pont_soleil/m/m2e97f86c826f

Ledger Explorer
https://www.sambuichi.jp/ledger/?view=tidy&month=2021-04&lang=ja

GitHub
https://github.com/pontsoleil/LedgerExplorer

いいなと思ったら応援しよう!