0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

2桁年・元号を「黙って正しそうな年」に寄せると期ずれ|会計CSVの沈黙誤変換

0
Posted at

はじめに

銀行 CSV を会計データに取り込むツールを書いていると、必ずこの壁にぶつかります。

  • 26/05/1526 は 2026 年か、1926 年か、はたまた昭和 26 年(1951 年)か。
  • R1.3.15 は令和元年 3 月……いや、令和は 2019 年 5 月 1 日からなので、この日付は本当は平成 31 年です。
  • S64.1.8 は昭和 64 年 1 月 8 日ですが、昭和は 1 月 7 日で終わっているので実在しません。

こういう「曖昧な年」を、ライブラリが気を利かせて「たぶんこの年でしょう」と黙って埋めてくれることがあります。日付ピッカーやログ表示なら、それでいい。けれど会計の文脈では、この「気の利いた沈黙変換」が事故の起点になります。

この記事では、銀行 CSV を StandardTransaction 行へ正規化する変換エンジン(PHP 実装)を題材に、「曖昧な年を黙って寄せない」という設計判断を、実際のコードと ADR(設計判断の記録)から掘り下げます。ロジック自体は言語非依存なので、日付を扱うすべての実務者に読んでほしい話です。

この記事で分かること:

  • 会計の文脈で「曖昧な年を黙って寄せる」ことが、なぜ期ずれ=重要な虚偽表示に直結するのか
  • 2桁年を pivot(設定値)で、元号を静的な変換表で「監査可能なルール」に落とし込む実装
  • 曖昧・無効な入力を推測で埋めず、次アクション付きエラー(use Heisei 31 など)で人間に返す設計

沈黙誤変換の何が問題か —— 「期ずれ」はコンプラ違反になりうる

会計データで年を 1 つ間違えると、その取引は別の会計期間に計上されます。日本の実務でいう「期ずれ」です。売上や経費が本来と違う期に乗れば、その期の P&L(損益)も BS(貸借)も狂う。税務・監査の観点では、これは「見た目のバグ」ではなく**重要な虚偽表示(material misstatement)**に直結します。

このツールの設計判断は ADR 0003「Transform Fidelity Rules」にまとまっていて、危険な変換を 3 つ名指ししています(docs/adr/0003-transform-fidelity.md §Context)。

  1. 金額の符号 —— 入金・出金を反転させると P&L / BS が丸ごと逆になる
  2. 年の曖昧さ —— 2 桁年・元号年が誤った西暦に解決されると期ずれを起こす
  3. 文字コード —— 摘要や相手先の文字化けが行の同一性(line_hash)を壊す

そして ADR はこう釘を刺しています(§Context、原文を訳出)。

これらのケースはいずれも複数の解釈が成り立ちうる。そのうちの誤ったものをオペレーターに通知せず黙って選ぶことは、意図がどうであれコンプライアンス違反である。

ポイントは「意図がどうであれ」です。悪意はなくても、ライブラリが親切心で 262026 に寄せた結果、1951 年の入金が 2026 年の売上に化ける —— これが監査で問われたとき、「ライブラリのデフォルトがそうだった」は言い訳になりません。

だからこのエンジンの基本姿勢は一貫しています。曖昧さは、黙って埋めるのではなく「明示ルールで解決」するか「エラーにして人間に返す」か、そのどちらかしか許さない。 推測した日付は絶対に出力に混ぜない。

解決策 その1 —— pivot を「明示ルール」にする(2 桁年)

2 桁年は、それ単体では西暦を確定できません。このエンジンはpivot(境界年)を設定値として明示することで、この曖昧さを「暗黙の推測」から「監査可能なルール」に変えます(ADR 0003 §2)。

判断はコンテキストに載って各行へ渡ります(src/Transformer/TransformContext.php)。

final readonly class TransformContext
{
    public function __construct(
        public int $yearPivot = 50,
        public int $rawRowNumber = 0,
    ) {
    }
}

実際の解決ロジックはこれだけです(src/Transformer/GregorianDateParser.php)。

public static function toIso(string $year, string $month, string $day, int $yearPivot): ?string
{
    if (!ctype_digit($year) || !ctype_digit($month) || !ctype_digit($day)) {
        return null;
    }

    $y = (int) $year;
    $m = (int) $month;
    $d = (int) $day;

    // 2 桁年 → pivot。pivot=50 なら 00–49 → 2000 年代、50–99 → 1900 年代。
    if (strlen($year) <= 2) {
        $y = $y < $yearPivot ? 2000 + $y : 1900 + $y;
    }

    if (!checkdate($m, $d, $y)) {
        return null;
    }

    return sprintf('%04d-%02d-%02d', $y, $m, $d);
}

大事なのは 2 点です。

1. pivot はハードコードではなく preset の設定値。 給与関連の preset なら 1999 年の日付を見ることもあるし、稼働中の事業口座なら見ない。「どの銀行にも効く唯一のルール」は存在しないので、pivot を preset ごとに持たせ、管理 UI に露出させる(ADR 0003 §2 Rationale)。境界がコードに埋まっていないので、「この取り込みではどの境界を使ったか」があとから追える

テストも境界の可変性を担保しています(tests/Transformer/DateTransformersTest.php)。

public function test_slash_pivot_boundary_is_configurable(): void
{
    // pivot 30: 28 → 2028、35 → 1935
    $t = new DateYmdSlashTransformer();
    $this->assertSame('2028-01-01', $t->transform('28/1/1', new TransformContext(yearPivot: 30))->value);
    $this->assertSame('1935-01-01', $t->transform('35/1/1', new TransformContext(yearPivot: 30))->value);
}

2. 曖昧でない失敗は黙って埋めない。 toIso は不正な入力に対して推測を返さず null を返し、呼び出し側がそれを明示的な行エラーへ変換します(src/Transformer/DateYmdSlashTransformer.php)。

$iso = GregorianDateParser::toIso($m[1], $m[2], $m[3], $context->yearPivot);

return $iso !== null
    ? TransformOutcome::ok($iso)
    : TransformOutcome::error("invalid calendar date '{$source}'");

2026/13/452026/02/30 のような「年を広げたあとに構造的に無効な日付」は、それらしい日に丸めずエラーにして人間に返す(ADR 0003 §2 の Error cases)。

解決策 その2 —— 元号は「静的な法律」として変換し、境界外は拒否する

元号年は 2 桁年よりもっと厄介です。改元日は月の途中に来るので、同じ「令和 1 年」でも 1〜4 月と 5 月以降で西暦が変わる。ここを取り違えると、やはり期ずれになります。

このエンジンは元号を外部 API に問い合わせず、静的な変換表として持ちます(ADR 0003 §3。理由は後述の却下案を参照)。実装は src/Transformer/JapaneseEraDateParser.php。まず 4 元号の offset と最大年をテーブルで持ちます。

private const ERAS = [
    'reiwa'  => ['offset' => 2018, 'maxYear' => 99, 'name' => 'Reiwa'],
    'heisei' => ['offset' => 1988, 'maxYear' => 31, 'name' => 'Heisei'],
    'showa'  => ['offset' => 1925, 'maxYear' => 64, 'name' => 'Showa'],
    'taisho' => ['offset' => 1911, 'maxYear' => 15, 'name' => 'Taisho'],
];

そして改元の「月の途中」境界を、正しい側だけ通し、間違った側は明示的にエラーにします。

// 令和は 2019-05-01 から。R1 の 1〜4 月は平成 31 年に属する。
if ($eraKey === 'reiwa' && $y === 1 && $m >= 1 && $m <= 4) {
    return ['error', 'Reiwa 1 January–April is not a valid date; use Heisei 31'];
}

// 平成は 2019-04-30 で終了。H31 の 5 月以降は存在しない。
if ($eraKey === 'heisei' && $y === 31 && $m >= 5) {
    return ['error', 'Heisei 31 May and later are not valid Heisei dates; Heisei ended on H31.4.30'];
}

// 昭和は 1989-01-07 で終了。S64 の 1/7 より後は存在しない。
if ($eraKey === 'showa' && $y === 64 && ($m > 1 || ($m === 1 && $d > 7))) {
    return ['error', 'Showa 64 ended on S64.1.7; dates after January 7 are not valid Showa dates'];
}

戻り値は ['ok', 'YYYY-MM-DD']['error', message] のタプルで、推測した日付は決して返さない(クラス docblock に明記)。ここが肝で、「令和元年 3 月」のような実在しない日付をそれっぽい西暦に寄せず、use Heisei 31(平成 31 年を使え)という次のアクションまで含んだエラーを返します。オペレーターは何を直せばいいかがすぐ分かる。

境界の正誤はテストで固定されています(tests/Transformer/JapaneseEraDateParserTest.php)。

/** R1 May 1 is the first valid Reiwa date. */
public function test_reiwa_year1_may_is_valid(): void
{
    $this->assertOk('2019-05-01', JapaneseEraDateParser::toIso('R', '1', '5', '1'));
}

/** R1 Jan–Apr belong to Heisei 31 — must be an error. */
public function test_reiwa_year1_january_is_error(): void
{
    $this->assertError('Reiwa 1 January–April', JapaneseEraDateParser::toIso('R', '1', '1', '1'));
}

最大年超過(R100)・不明な元号記号(M6.5.15)・実在しない暦日(R6.2.30)も、すべて推測せずエラー。実在する昭和 64 年 1 月 7 日(S64.1.71989-01-07)は通し、1 月 8 日は弾く、という 1 日単位の境界まで実コードとテストで押さえています。

却下した案 —— なぜ「気を利かせない」を選んだか

ADR 0003 の価値は、採った案よりむしろ**「却下した案」を残していること**にあります。日付処理で誘惑にかられる選択肢が、なぜ危険なのかが明記されています。

2 桁年(§2 Alternatives considered)

却下案 却下理由
pivot を 68 に固定(POSIX/UNIX 慣習) 日本の銀行文脈では 1969–1999 年の日付も十分あり得る。境界はオペレーターが決めるべき
2 桁年をすべて拒否 実運用中の銀行 CSV フォーマットに対して厳しすぎる
黙って現在の世紀に寄せる レガシーデータに対して静かに誤る —— まさに本記事が問題にしている沈黙誤変換

元号(§3 Alternatives considered)

却下案 却下理由
国税庁 API から都度参照 外部依存とネットワーク障害リスクを持ち込む。改元表は動的データではなく「静的な法律」
令和・平成のみ対応 長期口座の履歴には昭和データが現れる

金額の符号(§1 Alternatives considered、姿勢が同じなので併記)

金額でも同じ思想が貫かれています。

Warn and emit best-guess(警告を出しつつベストゲスの符号を出す)—— 却下。金額に対する推測された符号はコンプライアンス違反である。

「警告は出したんだから」では免責されない。推測値を出力に混ぜた時点でアウト、という判断です。日付の「黙って寄せる」却下と完全に地続きです。

学び

会計・金融のデータ変換を書くときに、この設計から持ち帰れる原則は 3 つです。

  1. 曖昧さを「暗黙の推測」で潰さない。「明示ルール」にするか「エラーにして人間へ返す」か。 2 桁年は pivot という設定値に、元号境界は静的な変換表に落とし込む。どちらも「あとから、どのルールでその年になったか」を追える形にする。

  2. 推測値を出力に混ぜない。 曖昧・無効な入力に対しては、それらしい値ではなく、次のアクション(use Heisei 31 など)を含んだエラーを返す。「警告は出した」は免責にならない。

  3. 却下した案を記録する。 「pivot を 68 固定」「黙って現在の世紀へ」「API で都度参照」—— 一見便利なこれらをなぜ選ばなかったかを残すことが、半年後の自分や次の担当者が同じ罠を踏まないための最大の防御になる。

日付ピッカーの世界では「気を利かせる」が美徳です。けれど会計の世界では、気を利かせて黙ることが最大のリスクになる。曖昧さは、丸めるのではなく、明示するか突き返す。それだけの話です。


一次資料

  • nene-profile ADR 0003「Transform Fidelity Rules — Amount Sign, Date, and Encoding」(docs/adr/0003-transform-fidelity.md、status: accepted)
  • 実装: src/Transformer/JapaneseEraDateParser.php / GregorianDateParser.php / DateEraTransformer.php / TransformContext.php
  • テスト: tests/Transformer/JapaneseEraDateParserTest.php / DateTransformersTest.php
  • 関連 Issue/PR: Issue #61 / PR #65(date_era トランスフォーマー)、Issue #23 / PR #24(変換エンジン本体・ADR 0003 コンプライアンスコア)

関連記事

  • 日本ローカル×会計コンプライアンスの実装知見をまとめたシリーズの一本(独自性の高い領域)。関連する「金額符号を黙って反転させない」「文字化けは警告でなく行エラー」の記事は同じ ADR 0003 の §1 / §4 が題材。※これらの姉妹記事は現時点で未公開のため、公開後に相互リンクを追記する。

── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?