会計年度(第何期か)の計算

決算月と設立年月から、ある日が第何期にあたるかと、その期の開始日・決算日・次の期の開始日を出す。

日付とタイムゾーン

言語ごとの結果

正解表 17件のうち、正解と一致した件数です。

PHPPHP 8.4.26

よくある書き方15 / 17

直した書き方17 / 17

PythonPython 3.14.7

よくある書き方12 / 17

直した書き方17 / 17

GoGo 1.27.2

よくある書き方15 / 17

直した書き方17 / 17

JavaScriptNode 24.15.0

よくある書き方1 / 17

直した書き方17 / 17

なぜ割れたのか

よくある書き方が落ちたケースを、原因ごとにまとめた解説です(3本)。書いてある「落ちた言語」が今回の検証結果と一致することを確かめてから載せています。

日付とタイムゾーン

JavaScript だけ、日本で動かすとすべての日付が1日前にずれた

対象のケースc001c004c005c006c007c008c009c010c011c012c017 落ちた言語JavaScript

3月決算・2020年4月設立の会社で、2025年6月15日は第6期(2025-04-01 〜 2026-03-31)です。JavaScript は期の数こそ正しいものの、日付を 2025-03-31 〜 2026-03-30 と、すべて1日前にずらして返しました。

なぜずれたのか

日付を文字列にするのに、よく使われる toISOString().slice(0, 10) を使いました。

const fmt = (d) => d.toISOString().slice(0, 10);

new Date(2025, 3, 1) は実行環境のタイムゾーンでの 4月1日 0:00 を表します。一方 toISOString() は、それを UTC(世界標準時) に直してから文字列にします。日本時間の 4月1日 0:00 は UTC では 3月31日 15:00 なので、日付の部分だけ取ると 3月31日になります。

同じコードを別のタイムゾーンで回すと、結果が変わりました。

実行環境のタイムゾーン正解と一致したケース
日本(Asia/Tokyo・UTC+9)17件中1件
UTC17件中15件
ロサンゼルス(America/Los_Angeles・UTC−8)17件中15件

ずれるのは UTC より東のタイムゾーンだけです。ロサンゼルスの 0:00 は UTC では同じ日の朝 8:00 なので、日付が変わりません。海外のサーバーやテスト環境では通るのに、日本の利用者の手元でだけずれる、という形で表に出ます。

どう直したか

日付を Date に入れず、年・月・日の数のまま計算して、文字列も自分で組み立てました。月末日だけは、タイムゾーンの影響を受けない Date.UTC で求めています。

const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate();

直したあとは、日本・UTC・ロサンゼルス・UTC+14 のどのタイムゾーンでも17件中17件が一致しました。

日付とタイムゾーン

12月決算で「13月」ができ、Python だけが止まった

対象のケースc002c003c016 落ちた言語PythonJavaScript

12月決算の会社の期首は、決算月の翌月である 1月1日 です。「決算月の翌月1日」をそのまま 決算月 + 1 で書くと、12月決算では 13月1日 を作ることになります。

date(d.year - 1, closing_month + 1, 1)   # closing_month = 12 なら 13月

言語でどう割れたか

言語13月1日を作ると
Python(date)エラー(month must be in 1..12)
PHP(setDate)・Go(time.Date)・JavaScript(new Date)黙って翌年の1月1日に繰り越す

PHP・Go・JavaScript は繰り越しのおかげで、結果的に正しい1月1日になりました。Python だけが、12月決算のケース(c002・c003・c016)で止まりました。JavaScript はこの3件も落ちていますが、原因は別の解説(日付が1日前にずれる問題)です。

「繰り越してくれる言語なら安全」とは言えません。同じ繰り越しが、存在しない日付(2月30日)を黙って3月2日にしてしまうからです(別の解説を参照)。

どう直したか

日付を作らずに、月の通し番号(年×12+月−1)で数えました。12月の次は通し番号を1つ進めるだけなので、13月は現れません。4言語とも同じ考え方で書いています。

日付とタイムゾーン

存在しない日付(2月30日)を、黙って3月2日として計算した

対象のケースc013c014 落ちた言語PHPPythonGoJavaScript

「2025-02-30」は存在しない日付なので、正解は invalid_date(日付が正しくない)というエラーです。2100年2月29日も、2100年はうるう年ではないので存在しません。

言語でどう割れたか

言語「2025-02-30」を読むと結果
PHP(new DateTimeImmutable)黙って 2025-03-02 にする2月決算なら次の期(第6期)として答えてしまう
JavaScript(new Date(2025, 1, 30))黙って 2025-03-02 にする同上
Python(date.fromisoformat)エラーで止まる答えは返さないが、止まり方は仕様どおりではない
Go(time.Parse)エラーで止まる同上

PHP は、書式を指定して読む DateTime::createFromFormat でも、同じように3月2日に繰り越しました。この検証では4言語とも「落ちた」扱いですが、中身は2通りです。PHP と JavaScript は間違った答えを返し、Python と Go は答えを返さずに止まりました。利用者から見て危ないのは前者です。

どう直したか

日付を読む前に、その月に本当にある日かを確かめ、なければ invalid_date を返すようにしました。

  • PHP: checkdate() で確かめる
  • JavaScript: その月の日数(Date.UTC で求める)と比べる
  • Python・Go: 読み込みのエラーを捕まえて invalid_date にする

仕様の元になったシルギアのツールも、日が31以下なら存在しない日付をそのまま受け付けています(計算には月しか使わないため、答えは2月として出ます)。

正解表と、よくある書き方の答え

正解は実装より先に決めて固定しています。言語の列の ✓ は正解と一致、赤い値はその言語が実際に返した間違った答えです。

ケース入力正解 PHPPythonGoJavaScript
c001基本・3月決算 なぜ? 解説を読む
closing_month
3
start
2020-04
date
2025-06-15
period
6
start
2025-04-01
end
2026-03-31
next_start
2026-04-01
✓✓✓start"2025-03-31"end"2026-03-30"next_start"2026-03-31"
c00212月決算 期首は「決算月の翌月」= 13月 → 翌年1月。13月を作る書き方は言語で割れる なぜ? 解説を読む
closing_month
12
start
2020-01
date
2025-06-15
period
6
start
2025-01-01
end
2025-12-31
next_start
2026-01-01
✓エラー✓start"2024-12-31"end"2025-12-30"next_start"2025-12-31"
c00312月決算・決算日当日 なぜ? 解説を読む
closing_month
12
start
2020-01
date
2025-12-31
period
6
start
2025-01-01
end
2025-12-31
next_start
2026-01-01
✓エラー✓start"2024-12-31"end"2025-12-30"next_start"2025-12-31"
c0043月決算・決算日当日 境界値(期の最終日) なぜ? 解説を読む
closing_month
3
start
2020-04
date
2026-03-31
period
6
start
2025-04-01
end
2026-03-31
next_start
2026-04-01
✓✓✓start"2025-03-31"end"2026-03-30"next_start"2026-03-31"
c0053月決算・期首当日 境界値(期の初日) なぜ? 解説を読む
closing_month
3
start
2020-04
date
2025-04-01
period
6
start
2025-04-01
end
2026-03-31
next_start
2026-04-01
✓✓✓start"2025-03-31"end"2026-03-30"next_start"2026-03-31"
c0063月決算・期首の前日 前の期に入る なぜ? 解説を読む
closing_month
3
start
2020-04
date
2025-03-31
period
5
start
2024-04-01
end
2025-03-31
next_start
2025-04-01
✓✓✓start"2024-03-31"end"2025-03-30"next_start"2025-03-31"
c007第1期が12か月より短い 10月設立・3月決算 → 第1期は6か月 なぜ? 解説を読む
closing_month
3
start
2024-10
date
2024-12-01
period
1
start
2024-10-01
end
2025-03-31
next_start
2025-04-01
✓✓✓start"2024-09-30"end"2025-03-30"next_start"2025-03-31"
c008決算月に設立 第1期は1か月 なぜ? 解説を読む
closing_month
3
start
2025-03
date
2025-03-15
period
1
start
2025-03-01
end
2025-03-31
next_start
2025-04-01
✓✓✓start"2025-02-28"end"2025-03-30"next_start"2025-03-31"
c0092月決算・うるう年の決算日 2024年2月は29日まで なぜ? 解説を読む
closing_month
2
start
2020-03
date
2024-02-29
period
4
start
2023-03-01
end
2024-02-29
next_start
2024-03-01
✓✓✓start"2023-02-28"end"2024-02-28"next_start"2024-02-29"
c0102月決算・平年の決算日 なぜ? 解説を読む
closing_month
2
start
2020-03
date
2025-02-28
period
5
start
2024-03-01
end
2025-02-28
next_start
2025-03-01
✓✓✓start"2024-02-29"end"2025-02-27"next_start"2025-02-28"
c0112100年はうるう年ではない 100で割り切れる年はうるう年ではない → 決算日は2月28日 なぜ? 解説を読む
closing_month
2
start
2099-03
date
2100-01-15
period
1
start
2099-03-01
end
2100-02-28
next_start
2100-03-01
✓✓✓start"2099-02-28"end"2100-02-27"next_start"2100-02-28"
c0122000年はうるう年 400で割り切れる年はうるう年 → 決算日は2月29日 なぜ? 解説を読む
closing_month
2
start
1999-03
date
2000-02-10
period
1
start
1999-03-01
end
2000-02-29
next_start
2000-03-01
✓✓✓start"1999-02-28"end"2000-02-28"next_start"2000-02-29"
c013存在しない日付・2月30日 3月2日に繰り越して計算すると、次の期になってしまう なぜ? 解説を読む
closing_month
2
start
2020-03
date
2025-02-30
error
invalid_date
errornullエラーエラーerrornull
c014存在しない日付・2100年2月29日 なぜ? 解説を読む
closing_month
3
start
2020-04
date
2100-02-29
error
invalid_date
errornullエラーエラーerrornull
c015設立より前の日付
closing_month
3
start
2024-10
date
2024-09-30
error
before_start
✓✓✓✓
c0161970年より前(12月決算) UNIX 時間の始まり(1970-01-01)をまたぐ なぜ? 解説を読む
closing_month
12
start
1969-12
date
1969-12-31
period
1
start
1969-12-01
end
1969-12-31
next_start
1970-01-01
✓エラー✓start"1969-11-30"end"1969-12-30"next_start"1969-12-31"
c0171月決算 なぜ? 解説を読む
closing_month
1
start
2020-02
date
2025-01-31
period
5
start
2024-02-01
end
2025-01-31
next_start
2025-02-01
✓✓✓start"2024-01-31"end"2025-01-30"next_start"2025-01-31"

コード

「違い」では、よくある書き方から直した書き方へ、どの行をどう変えたかを示しています(赤が消した行、緑が足した行、濃い色が行の中で変わった部分)。計算の部分だけを載せ、入出力を受け渡す部分は除いています。

Fiscal.php+28 −18よくある書き方(472f2b6)→ 直した書き方

@@ 変更前 3行目〜 / 変更後 3行目〜 @@
33
44 /**
55 * 会計年度(第何期か)の計算。
6− * v1: よくある書き方(DateTimeImmutable で日付を読み、期首を「決算月の翌月1日」として作る)。
6+ * v2: 月の通し番号(年×12+月−1)で数え、DateTime の繰り越しに頼らない。
7+ * 存在しない日付は checkdate() で invalid_date にする(new DateTime('2025-02-30') は 3月2日に繰り越すため)。
78 */
8−function fiscalStart(DateTimeImmutable $d, int $closingMonth): DateTimeImmutable
9+function monthIndex(int $y, int $m): int
910 {
10− $y = (int) $d->format('Y');
11− $year = (int) $d->format('n') > $closingMonth ? $y : $y - 1;
12− return (new DateTimeImmutable())->setDate($year, $closingMonth + 1, 1)->setTime(0, 0);
11+ return $y * 12 + ($m - 1);
12+}
13+
14+function firstClosingAtOrAfter(int $idx, int $closingMonth): int
15+{
16+ return $idx + ((($closingMonth - 1 - $idx % 12) % 12) + 12) % 12;
17+}
18+
19+function ymd(int $idx, bool $last = false): string
20+{
21+ $y = intdiv($idx, 12);
22+ $m = $idx % 12 + 1;
23+ $d = $last ? (int) (new DateTimeImmutable(sprintf('%04d-%02d-01', $y, $m)))->format('t') : 1;
24+ return sprintf('%04d-%02d-%02d', $y, $m, $d);
1325 }
1426
1527 /** @return array<string, int|string> */
1628 function calcFiscal(int $closingMonth, string $start, string $date): array
1729 {
18− $d = new DateTimeImmutable($date);
19− $s = new DateTimeImmutable($start . '-01');
20− if ($d < $s) {
30+ if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $mm) || !checkdate((int) $mm[2], (int) $mm[3], (int) $mm[1])) {
31+ return ['error' => 'invalid_date'];
32+ }
33+ [$sy, $sm] = array_map('intval', explode('-', $start));
34+ $s = monthIndex($sy, $sm);
35+ $t = monthIndex((int) $mm[1], (int) $mm[2]);
36+ if ($t < $s) {
2137 return ['error' => 'before_start'];
2238 }
23− $fy = fiscalStart($d, $closingMonth);
24− $first = fiscalStart($s, $closingMonth);
25− $n = (int) $fy->format('Y') - (int) $first->format('Y') + 1;
26− $next = $fy->modify('+1 year');
27− return [
28− 'period' => $n,
29− 'start' => ($n === 1 ? $s : $fy)->format('Y-m-d'),
30− 'end' => $next->modify('-1 day')->format('Y-m-d'),
31− 'next_start' => $next->format('Y-m-d'),
32− ];
39+ $firstClose = firstClosingAtOrAfter($s, $closingMonth);
40+ $close = firstClosingAtOrAfter($t, $closingMonth);
41+ $n = intdiv($close - $firstClose, 12) + 1;
42+ return ['period' => $n, 'start' => ymd($n === 1 ? $s : $close - 11), 'end' => ymd($close, true), 'next_start' => ymd($close + 1)];
3343 }

仕様

入力

項目型説明
closing_month整数決算月(1〜12)
start文字列 "YYYY-MM"設立年月(第1期の開始月)
date文字列 "YYYY-MM-DD"判定したい日

出力

成功したとき:

項目型定義
period整数第何期か
start文字列 "YYYY-MM-DD"その期の開始日
end文字列 "YYYY-MM-DD"その期の終了日(決算日)
next_start文字列 "YYYY-MM-DD"次の期の開始日

判定できないとき: {"error": "invalid_date"}(存在しない日付)または {"error": "before_start"}(第1期の開始より前)。

計算

  • 事業年度は「決算月の翌月1日」から「決算月の末日」までの12か月
  • 第1期は、設立年月の1日から、設立後はじめて来る決算月の末日まで(12か月より短いことがある。設立月が決算月なら1か月)
  • 第2期以降は、第1期の決算から12か月ごとに区切る
  • 判定日が属する期の決算月 = 判定日の月以降で、はじめて来る決算月
  • 月末日はグレゴリオ暦に従う(2月はうるう年で29日。2100年はうるう年ではなく、2000年はうるう年)

日付の扱い

  • 日付はタイムゾーンを持たない暦の日付として扱う。実行環境のタイムゾーンで結果が変わってはいけない
  • 存在しない日付(2月30日、2100年2月29日など)は invalid_date。翌月に繰り越して計算してはいけない

元ツールとの違い

元ツールは存在しない日付(日が31以下なら何でも)をそのまま受け付けて計算する。本 spec はエラーにした(2026-10-10)。

期待値の作り方

expect は oracle.py で、元ツールと同じ「月の通し番号(年×12+月−1)」の定義を整数でそのまま計算して生成した(2026-10-10)。実装ではない。runner は使わない。