勤続年数(◯年◯か月◯日)

入社日と基準日から、勤続年数を「◯年◯か月◯日」で出す。通算の月数と日数も出す。

「1か月」などの定義日付とタイムゾーン

言語ごとの結果

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

PHPPHP 8.4.26

よくある書き方7 / 17

直した書き方17 / 17

PythonPython 3.14.7

よくある書き方7 / 17

直した書き方17 / 17

GoGo 1.27.2

よくある書き方7 / 17

直した書き方17 / 17

JavaScriptNode 24.15.0

よくある書き方7 / 17

直した書き方17 / 17

なぜ割れたのか

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

「1か月」などの定義

月末に入社した人の「◯か月」が、どの書き方でもずれた

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

1月31日に入社した人は、2月28日(平年)で何か月勤めたことになるでしょうか。この仕様では、元のツールと同じく「入社日に1か月を足して、その月に同じ日がなければ月末にする」と決めています。1月31日の1か月後は2月28日なので、答えは 1か月0日 です。

ところが、4言語とも 0か月28日 を返しました。

4つの書き方と、その答え

入社日 → 基準日正解前の月の日数を借りる書き方(Python・Go・JS)PHP DateTime::diff
1/31 → 2/29(うるう年)1か月0日0か月29日0か月29日
2/29 → 翌年2/281年0か月0日0年11か月30日0年11か月30日
2/29 → 翌年3/11年0か月1日1年0か月0日1年0か月0日
3/31 → 4/301か月0日0か月30日0か月30日
8/31 → 翌年2/296か月0日5か月29日5か月29日

どの書き方も「基準日の日付が入社日の日付に届いたか」で月を数えるので、月末で応当日(同じ日付)がない月は、1か月と数えません。言語の不具合ではなく、「1か月」の決め方が書き方ごとに違うことが原因です。勤続年数や有給休暇の付与日のように、月末入社の扱いが結果を左右する計算では、どの決め方を使っているかを先に確かめる必要があります。

どう直したか

「入社日に k か月を足した日(同じ日がなければ月末)が基準日を超えない、最大の k」を数えるように書き直しました。日付ライブラリの「◯か月足す」も使っていません。Go の AddDate や PHP の modify('+1 month') は、2025年1月31日 + 1か月を 3月3日 に繰り越してしまうからです(どちらも実際に確かめました)。

「1か月」などの定義

「1か月 −2日」——日数がマイナスになった

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

1月31日入社で3月1日(平年)の勤続は 1か月1日 です。前の月の日数を借りる書き方(Python・Go・JavaScript)は、ここで 1か月 −2日 を返しました。1月30日入社(c007)では −1日です。

なぜマイナスになったのか

この書き方は、年・月・日をそれぞれ引き算し、日がマイナスなら前の月の日数を足して「借りて」きます。

3月1日 − 1月31日 → 月 +2、日 −30
日がマイナスなので、月を1つ減らして前の月(2月)の28日を足す → 月 +1、日 −2

2月は28日しかないので、30日ぶんを借りきれず、日数がマイナスのまま残ります。借りる月を「前の月」に決め打ちしていることが原因です。

PHP の DateTime::diff はマイナスにはならず 0か月29日 を返しました。答えの形は自然ですが、この仕様の正解(1か月1日)とは違います。

夏時間のある地域では、通算日数も1日ずれる

JavaScript の書き方は、通算日数を Math.floor((基準日 − 入社日) / 1日のミリ秒) で求めています。日本では問題ありませんが、ロサンゼルス時間で回すと、1985年4月1日入社・2026年10月10日(c015)の通算日数が 15,166日(正解は15,167日)になりました。1985年4月1日は標準時、2026年10月10日は夏時間なので、差が1時間足りず、切り捨てで1日減ります。

どう直したか

日数をマイナスにしない数え方(入社日に k か月を足した日から基準日までを数える)に変えました。JavaScript では日数を Date.UTC で数え、夏時間の影響を受けないようにしています。

日付とタイムゾーン

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

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

入社日「2025-02-30」は存在しないので、正解は invalid_date です。

PHP(new DateTimeImmutable)と JavaScript(new Date(2025, 1, 30))は、黙って 3月2日 として計算し、4月1日まで「0か月30日」と答えました。Python(date.fromisoformat)と Go(time.Parse)はエラーで止まりました。会計年度の仕様(fiscal-year)と同じ割れ方です。

直した書き方では、PHP は checkdate()、JavaScript はその月の日数と比べて確かめ、Python と Go は読み込みのエラーを捕まえて、どれも invalid_date を返します。

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

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

ケース入力正解 PHPPythonGoJavaScript
c001基本・6年に1日足りない
join
2020-04-01
base
2026-03-31
years
5
months
11
days
30
total_months
71
total_days
2190
✓✓✓✓
c002ちょうど6年
join
2020-04-01
base
2026-04-01
years
6
months
0
days
0
total_months
72
total_days
2191
✓✓✓✓
c003入社日当日 境界値。すべて 0
join
2025-04-01
base
2025-04-01
years
0
months
0
days
0
total_months
0
total_days
0
✓✓✓✓
c0041月31日入社・うるう年の2月29日 1月31日 + 1か月 = 2月29日(その月の末日)なので「1か月0日」。前の月の日数を借りる書き方だと「0か月29日」 なぜ? 解説を読む
join
2024-01-31
base
2024-02-29
years
0
months
1
days
0
total_months
1
total_days
29
months0days29total_months0months0days29total_months0months0days29total_months0months0days29total_months0
c0051月31日入社・平年の2月28日 「1か月0日」 なぜ? 解説を読む
join
2025-01-31
base
2025-02-28
years
0
months
1
days
0
total_months
1
total_days
28
months0days28total_months0months0days28total_months0months0days28total_months0months0days28total_months0
c0061月31日入社・3月1日 「1か月1日」。2月の28日を借りる書き方だと日数がマイナス(−2日)になる なぜ? 解説を読む
join
2025-01-31
base
2025-03-01
years
0
months
1
days
1
total_months
1
total_days
29
months0days29total_months0days-2days-2days-2
c0071月30日入社・3月1日 「1か月1日」 なぜ? 解説を読む
join
2025-01-30
base
2025-03-01
years
0
months
1
days
1
total_months
1
total_days
30
months0days30total_months0days-1days-1days-1
c0082月29日入社・翌年2月28日 2月29日 + 12か月 = 翌年2月28日なので「1年0か月0日」 なぜ? 解説を読む
join
2024-02-29
base
2025-02-28
years
1
months
0
days
0
total_months
12
total_days
365
years0months11days30total_months11years0months11days30total_months11years0months11days30total_months11years0months11days30total_months11
c0092月29日入社・翌年3月1日 「1年0か月1日」 なぜ? 解説を読む
join
2024-02-29
base
2025-03-01
years
1
months
0
days
1
total_months
12
total_days
366
days0days0days0days0
c0102月29日入社・4年後の2月29日 ちょうど4年
join
2024-02-29
base
2028-02-29
years
4
months
0
days
0
total_months
48
total_days
1461
✓✓✓✓
c0113月31日入社・4月30日 「1か月0日」 なぜ? 解説を読む
join
2023-03-31
base
2023-04-30
years
0
months
1
days
0
total_months
1
total_days
30
months0days30total_months0months0days30total_months0months0days30total_months0months0days30total_months0
c0123月31日入社・5月1日 「1か月1日」(3月31日 + 1か月 = 4月30日、そこから1日) なぜ? 解説を読む
join
2023-03-31
base
2023-05-01
years
0
months
1
days
1
total_months
1
total_days
31
days0days0days0days0
c0138月31日入社・翌年2月29日 「6か月0日」 なぜ? 解説を読む
join
2023-08-31
base
2024-02-29
years
0
months
6
days
0
total_months
6
total_days
182
months5days29total_months5months5days29total_months5months5days29total_months5months5days29total_months5
c014年またぎ・12月31日入社
join
2025-12-31
base
2026-01-31
years
0
months
1
days
0
total_months
1
total_days
31
✓✓✓✓
c015長期
join
1985-04-01
base
2026-10-10
years
41
months
6
days
9
total_months
498
total_days
15167
✓✓✓✓
c016基準日が入社日より前
join
2025-04-01
base
2025-03-31
error
base_before_join
✓✓✓✓
c017存在しない日付 3月2日に繰り越して計算してはいけない なぜ? 解説を読む
join
2025-02-30
base
2025-04-01
error
invalid_date
errornullエラーエラーerrornull

コード

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

Service.php+36 −7よくある書き方(59456f6)→ 直した書き方

@@ 変更前 3行目〜 / 変更後 3行目〜 @@
33
44 /**
55 * 勤続年数(◯年◯か月◯日)。
6− * v1: よくある書き方(DateTimeImmutable::diff に任せる)。
7− * @return array<string, int|string>
6+ * v2: 入社日に k か月を足し(行き先の月に同じ日がなければ末日にする)、基準日を超えない最大の k を数える。
7+ * DateTime::diff の「年・月・日」は月末の数え方が違うので使わず、日数(->days)だけを使う。
8+ * 存在しない日付は checkdate() で invalid_date にする(new DateTimeImmutable('2025-02-30') は 3月2日に繰り越すため)。
89 */
10+function parseYmd(string $s): ?array
11+{
12+ if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $s, $m) || !checkdate((int) $m[2], (int) $m[3], (int) $m[1])) {
13+ return null;
14+ }
15+ return [(int) $m[1], (int) $m[2], (int) $m[3]];
16+}
17+
18+function addMonths(array $ymd, int $k): DateTimeImmutable
19+{
20+ [$y, $m, $d] = $ymd;
21+ $t = $y * 12 + ($m - 1) + $k;
22+ $ty = intdiv($t, 12);
23+ $tm = $t % 12 + 1;
24+ $last = (int) (new DateTimeImmutable(sprintf('%04d-%02d-01', $ty, $tm)))->format('t');
25+ return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $ty, $tm, min($d, $last)));
26+}
27+
28+/** @return array<string, int|string> */
929 function calcService(string $join, string $base): array
1030 {
11− $j = new DateTimeImmutable($join);
12− $b = new DateTimeImmutable($base);
13− if ($b < $j) {
31+ $j = parseYmd($join);
32+ $b = parseYmd($base);
33+ if ($j === null || $b === null) {
34+ return ['error' => 'invalid_date'];
35+ }
36+ $jd = new DateTimeImmutable($join);
37+ $bd = new DateTimeImmutable($base);
38+ if ($bd < $jd) {
1439 return ['error' => 'base_before_join'];
1540 }
16− $d = $j->diff($b);
17− return ['years' => $d->y, 'months' => $d->m, 'days' => $d->d, 'total_months' => $d->y * 12 + $d->m, 'total_days' => $d->days];
41+ $k = ($b[0] - $j[0]) * 12 + ($b[1] - $j[1]);
42+ while ($k > 0 && addMonths($j, $k) > $bd) {
43+ $k--;
44+ }
45+ return ['years' => intdiv($k, 12), 'months' => $k % 12, 'days' => addMonths($j, $k)->diff($bd)->days,
46+ 'total_months' => $k, 'total_days' => $jd->diff($bd)->days];
1847 }

仕様

入力

項目型説明
join文字列 "YYYY-MM-DD"入社日
base文字列 "YYYY-MM-DD"基準日(今日・退職日など)

出力

成功したとき:

項目型定義
years整数満年数
months整数満年数を除いた満月数(0〜11)
days整数残りの日数(0 以上)
total_months整数通算の満月数(years × 12 + months)
total_days整数入社日から基準日までの日数(入社日当日は 0)

判定できないとき: {"error": "invalid_date"}(存在しない日付)または {"error": "base_before_join"}(基準日が入社日より前)。

計算(元ツールと同じ「暦どおり」の数え方)

  1. 入社日に k か月を足した日(下の「月を足す」)が基準日を超えない、最大の k を求める。years = k ÷ 12、months = k mod 12
  2. 入社日に k か月を足した日から基準日までの日数を days とする

月を足す

入社日に k か月を足して、行き先の月に同じ日がなければ、その月の末日にする。

  • 1月31日 + 1か月 = 2月28日(うるう年は2月29日)
  • 2月29日 + 12か月 = 翌年の2月28日
  • 3月31日 + 1か月 = 4月30日

したがって、1月31日入社で2月28日(平年)なら「1か月0日」、3月1日なら「1か月1日」になる。

日付の扱い

  • 日付はタイムゾーンを持たない暦の日付。実行環境のタイムゾーンで結果が変わってはいけない
  • 存在しない日付は invalid_date。翌月に繰り越して計算してはいけない
  • days が負になってはいけない

元ツールとの違い

元ツールにある「休職期間(控除月数)」の差し引きは、この spec では扱わない。

期待値の作り方

expect は oracle.py で、上の定義を Python の整数と calendar でそのまま計算して生成した(2026-10-10)。実装ではない。runner は使わない。