Files
FamilyBook/app/Birthday.php
T

371 lines
17 KiB
PHP
Raw Normal View History

<?php
/**
* 生日这一块:证件上的那天(只读、不显示)/ 真实阳历生日 / 农历生日。
*
* ⚠️ v24(用户 2026-10-02 第 16 条):**身份证生日默认不显示**。
* 它照常存库(户籍事实,永不变),但不出现在任何页面与任何提醒里,
* 对外只保留"阳历生日"与"农历生日"两种真实生日。
*
* 为什么要有这些(而不是把生日当一个字段):
* · 身份证上的那个是**户籍事实**,和证件完全一致,任何人都不该在系统里改它 ——
* 它和真实生日**经常不一样**(当年上户口随手写的、只报农历的、报错的)。
* · 真实阳历生日是"实际过的那天",是给本人过的。
* · 农历生日是长辈那边记的那天,很多人只认这个。
* 三个人可能是同一天,也可能差好几天甚至几个月 —— 所以提醒要分开出。
*
* 存法:
* birthday DATETIME —— 真实阳历生日(精确到时分秒)。
* ⚠️ 列名就叫 birthday(历史原因:它原本是个 DATE 字段),
* 而表单里叫 birthday_solar。读取一律走 Birthday::solarOf()。
* birthday_id_card DATE —— 由身份证第 7-14 位推导,只读、永不变
* birthday_lunar DATETIME —— **日期部分是农历年月日**,时间部分与阳历一致
* birthday_lunar_leap TINYINT —— 该农历月是否闰月
*
* ⚠️ `birthday_lunar` 的日期部分不是公历!比如 '1954-11-01' 表示
* 「农历一九五四年冬月初一」。这样存是为了让"农历生日"这个数据本身
* 可读、可编辑、可回溯,而不是只存一个算出来的公历日期
* (那样第二年就没法用它反推农历了)。
*/
declare(strict_types=1);
namespace App;
final class Birthday
{
/**
* 会**往外显示**的生日种类 => 人话名字。
*
* ⚠️ v24(用户 2026-10-02 第 16 条):这里原本有三档,含身份证生日。
* 现在只剩阳历与农历两种 —— 身份证生日仍然照常存库
* (`birthday_id_card`,只读、永不变),但**任何地方都不再显示它**:
* 它常常和真实生日不是同一天,两个日期并列摆着反而让人分不清"到底哪天过生日"。
*/
public const KINDS = [
'solar' => '阳历生日',
'lunar' => '农历生日',
];
/**
* 从身份证号里截出生日。
* 18 位:第 7-14 位是 YYYYMMDD;15 位(老证):第 7-12 位是 YYMMDD,年份补 19。
*/
public static function fromIdCard(?string $idCard): ?string
{
$s = strtoupper(trim((string)$idCard));
if ($s === '') {
return null;
}
if (preg_match('/^\d{17}[\dX]$/', $s)) {
$y = (int)substr($s, 6, 4);
$m = (int)substr($s, 10, 2);
$d = (int)substr($s, 12, 2);
} elseif (preg_match('/^\d{15}$/', $s)) {
$y = 1900 + (int)substr($s, 6, 2);
$m = (int)substr($s, 8, 2);
$d = (int)substr($s, 10, 2);
} else {
return null;
}
if (!checkdate($m, $d, $y) || $y < Lunar::MIN_YEAR || $y > Lunar::MAX_YEAR) {
return null;
}
return sprintf('%04d-%02d-%02d', $y, $m, $d);
}
/** 'Y-m-d H:i:s' 的日期部分(空/非法输入返回 '') */
public static function dateOnly(?string $dt): string
{
return substr(trim((string)$dt), 0, 10);
}
/**
* 取一「行数据」里的阳历生日原值。
*
* ⚠️ 为什么要有这个函数:**同一个东西有两个键名** ——
* · 数据库列叫 `birthday`(历史原因:它原本就是一个 DATE 字段,升级时只改了类型)。
* · 表单字段叫 `birthday_solar`(要和 `birthday_lunar` 对称,否则读表单的人
* 会以为 `birthday` 是"总生日")。
* 两边混用就会出"线上永远看不到阳历生日"这类静默 bug:
* 直接读 `$row['birthday_solar']` 时,来自 `SELECT * FROM persons` 的行里
* **根本没有这个键**,取到 null,代码不报错、只是那一条生日消失了。
* 所以凡是"读一行人"的地方都走这里,两个键都认。
*/
public static function solarOf(array $person): string
{
foreach (['birthday', 'birthday_solar'] as $k) {
$v = trim((string)($person[$k] ?? ''));
if ($v !== '') {
return $v;
}
}
return '';
}
/** 把 'Y-m-d' 或 'Y-m-d H:i:s' 统一成 'Y-m-d H:i:s' */
public static function toDateTime(?string $v, string $fallbackTime = '00:00:00'): ?string
{
$v = trim((string)$v);
if ($v === '') {
return null;
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $v, $m)) {
return checkdate((int)$m[2], (int)$m[3], (int)$m[1])
? sprintf('%04d-%02d-%02d %s', $m[1], $m[2], $m[3], $fallbackTime)
: null;
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/', $v, $m)) {
if (!checkdate((int)$m[2], (int)$m[3], (int)$m[1])) {
return null;
}
$h = min(23, max(0, (int)$m[4]));
$i = min(59, max(0, (int)$m[5]));
$s = isset($m[6]) ? min(59, max(0, (int)$m[6])) : 0;
return sprintf('%04d-%02d-%02d %02d:%02d:%02d', $m[1], $m[2], $m[3], $h, $i, $s);
}
return null;
}
/**
* 把表单送来的三个生日规范化成可落库的一行。
*
* $in 里认这些键:
* id_card 身份证号(用来推导身份证生日)
* birthday_id_card 库里已有的身份证生日(**只进不出**:一旦有了就不再改、更不会清掉)
* birthday_solar 阳历生日 'Y-m-d H:i:s'
* birthday_lunar 农历生日 'Y-m-d H:i:s'(日期部分是农历)
* birthday_lunar_leap 该农历月是否闰月
* birthday_source 'solar' | 'lunar' —— 用户这次填的是哪一个,以它为准
*
* @return array{id_birthday:?string,solar:?string,lunar:?string,lunar_leap:int,warning:string}
*/
public static function normalize(array $in): array
{
$warning = '';
// ① 身份证生日:由证件推导。证件没填/不合法就沿用已有的 ——
// 「永远不要发生改变」的落点就在这里:它只可能被证件本身改写,不会被人手清掉。
$idBirthday = self::fromIdCard($in['id_card'] ?? null) ?? self::dateOnly($in['birthday_id_card'] ?? null);
if ($idBirthday === '') {
$idBirthday = null;
}
$solar = self::toDateTime($in['birthday_solar'] ?? null);
$lunarRaw = trim((string)($in['birthday_lunar'] ?? ''));
$lunarDate = self::toDateTime($lunarRaw);
$lunarLeap = !empty($in['birthday_lunar_leap']) ? 1 : 0;
$source = (string)($in['birthday_source'] ?? 'solar');
// 「几点生的」只有一个,所以时:分:秒要跟着**用户这次填的那一边**走。
// ⚠️ 早先这里只从阳历取时间,于是"只填农历、时辰填了 06:15:30"会被静默
// 存成 00:00:00 —— 农历那边填的时辰像没填一样。两边都可能带时刻,得挑对不对。
$timedSrc = $source === 'lunar' ? ($lunarDate ?? $solar) : ($solar ?? $lunarDate);
$time = $timedSrc !== null ? substr($timedSrc, 11) : null;
if ($source === 'lunar' && $lunarDate !== null) {
// 以农历为准 → 反推阳历
[$ly, $lm, $ld] = array_map('intval', explode('-', self::dateOnly($lunarDate)));
$sol = Lunar::lunarToSolar($ly, $lm, $ld, $lunarLeap === 1);
if ($sol === null) {
// 该年没有这个闰月 / 该月没有这一天(如腊月三十遇到 29 天的腊月)
$warning = '农历' . Lunar::monthName($lm, $lunarLeap === 1) . Lunar::dayName($ld)
. '在' . Lunar::yearLabel($ly) . '年不存在,已按「非闰月」重算,请确认。';
$sol = Lunar::lunarToSolar($ly, $lm, $ld, false);
$lunarLeap = 0;
}
if ($sol !== null) {
$solar = sprintf('%04d-%02d-%02d %s', $sol['year'], $sol['month'], $sol['day'],
$time ?? '00:00:00');
}
}
if ($solar === null && $idBirthday !== null) {
// 阳历生日默认就是身份证上的那天
$solar = $idBirthday . ' 00:00:00';
}
// 以阳历为准 → 算农历(用户没手填农历时,或者刚才走的就是阳历分支)
if ($solar !== null && ($source !== 'lunar' || $lunarDate === null)) {
[$y, $m, $d] = array_map('intval', explode('-', self::dateOnly($solar)));
$lu = Lunar::solarToLunar($y, $m, $d);
if ($lu !== null) {
// 时间部分跟着阳历走:同一个人"几点生的"只有一个
$lunarDate = sprintf('%04d-%02d-%02d %s', $lu['year'], $lu['month'], $lu['day'], substr($solar, 11));
$lunarLeap = $lu['is_leap'] ? 1 : 0;
}
}
// 阳历生日倒过来对齐身份证:不强制 —— 真实生日本来就允许与证件不同。
return [
'id_birthday' => $idBirthday,
'solar' => $solar,
'lunar' => $lunarDate,
'lunar_leap' => $lunarLeap,
'warning' => $warning,
];
}
/** 把生日字符串渲染成「1990-05-12 14:30:00」里好看的一段 */
public static function pretty(?string $dt): string
{
$dt = trim((string)$dt);
if ($dt === '') {
return '';
}
$date = self::dateOnly($dt);
$time = substr($dt, 11, 8);
if ($time === '' || $time === '00:00:00') {
return $date;
}
return $date . ' ' . $time;
}
/**
* 下一次过这个"阳历类"生日(身份证生日 / 阳历生日都走这里)。
* 2 月 29 日在平年退到 2 月 28 日 —— 总得有个说法,比"今年不提醒"合理。
*/
private static function nextSolarDate(string $dateYmd, string $today): string
{
$md = substr($dateYmd, 5, 5);
$year = (int)substr($today, 0, 4);
for ($i = 0; $i <= 2; $i++) {
$y = $year + $i;
$cand = sprintf('%04d-%s', $y, $md);
if (!checkdate((int)substr($md, 0, 2), (int)substr($md, 3, 2), $y)) {
$cand = sprintf('%04d-02-28', $y); // 2/29 落到平年
}
if ($cand >= $today) {
return $cand;
}
}
return sprintf('%04d-%s', $year + 1, $md);
}
private static function daysFrom(string $today, string $date): int
{
$utc = new \DateTimeZone('UTC');
$a = \DateTimeImmutable::createFromFormat('!Y-m-d', $today, $utc);
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', $date, $utc);
return (int)$a->diff($b)->days;
}
/**
* 这个人接下来要过哪几个生日。
*
* 三个生日落在同一天时**只出一条**(合并 kinds)—— 用户不需要看三遍
* 「今天生日快乐」;不在同一天就分开出,这正是"要分别提醒"的意思。
*
* @return array<int, array{date:string,days:int,kinds:string[],lunar_label:string,age:?int}>
*/
public static function nextOccurrences(array $person, ?string $today = null, int $withinDays = 60): array
{
$today = $today ?: date('Y-m-d');
$buckets = []; // date => ['kinds'=>[], 'lunar_label'=>'']
$solarBd = self::dateOnly(self::solarOf($person));
$idBd = self::dateOnly($person['birthday_id_card'] ?? '');
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
$lunarLeap = !empty($person['birthday_lunar_leap']);
// v24 第 16 条:身份证生日不再参与"接下来要过哪个生日"(理由见 describe())。
// 它仍然会照常存库,只是不再产出提醒与日历条目。
if ($solarBd !== '') {
$d = self::nextSolarDate($solarBd, $today);
$buckets[$d]['kinds'][] = 'solar';
}
if ($lunarBd !== '') {
[$ly, $lm, $ld] = array_map('intval', explode('-', $lunarBd));
$baseYear = (int)substr($today, 0, 4);
// 农历新年在公历 1~2 月,所以要看前后各一年才不漏
for ($ly2 = $baseYear - 1; $ly2 <= $baseYear + 2; $ly2++) {
if (!Lunar::inRange($ly2)) {
continue;
}
$leap = $lunarLeap && Lunar::leapMonth($ly2) === $lm;
$ok = $leap ? ($ld <= Lunar::leapDays($ly2)) : ($ld <= Lunar::monthDays($ly2, $lm));
if (!$ok) {
// 该年这个月没有这一天(如「腊月三十」遇到 29 天的腊月)→ 退到该月最后一天
$ldUse = min($ld, $leap ? Lunar::leapDays($ly2) : Lunar::monthDays($ly2, $lm));
$sol = Lunar::lunarToSolar($ly2, $lm, $ldUse, $leap);
if ($sol === null) {
continue;
}
$ld = $ldUse;
} else {
$sol = Lunar::lunarToSolar($ly2, $lm, $ld, $leap);
}
if ($sol === null) {
continue;
}
$cand = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']);
if ($cand >= $today) {
$buckets[$cand]['kinds'][] = 'lunar';
$buckets[$cand]['lunar_label'] = Lunar::monthName($lm, $leap) . Lunar::dayName($ld);
break;
}
}
}
$out = [];
foreach ($buckets as $date => $info) {
$days = self::daysFrom($today, $date);
if ($days > $withinDays) {
continue;
}
$out[] = [
'date' => $date,
'days' => $days,
'kinds' => array_values(array_unique($info['kinds'])),
'lunar_label' => (string)($info['lunar_label'] ?? ''),
'age' => $solarBd !== '' ? self::ageOn($solarBd, $date) : null,
];
}
usort($out, fn($a, $b) => $a['days'] <=> $b['days']);
return $out;
}
/** 到某个公历日期时的周岁 */
public static function ageOn(string $birthYmd, string $onDate): int
{
$utc = new \DateTimeZone('UTC');
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', self::dateOnly($birthYmd), $utc);
$o = \DateTimeImmutable::createFromFormat('!Y-m-d', self::dateOnly($onDate), $utc);
if ($b === false || $o === false) {
return 0;
}
return (int)$b->diff($o)->y;
}
/** 三种生日的展示用清单(详情页/个人信息页都吃这个) */
public static function describe(array $person): array
{
$rows = [];
$idBd = self::dateOnly($person['birthday_id_card'] ?? '');
$solarRaw = self::solarOf($person);
$solarBd = self::dateOnly($solarRaw);
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
// ⚠️ v24(用户 2026-10-02 第 16 条):**身份证生日默认不显示**。
// 它只是"证件上的那一天",常常和真实生日不是同一天,两个日期并列摆在详情页
// 反而让人分不清"到底哪天过生日"。现在只显示阳历与农历两种真实生日。
// 身份证生日仍然照常在库里(birthday_id_card 只读、永不变),只是不往外显示。
if ($solarBd !== '') {
$rows[] = ['kind' => 'solar', 'text' => self::pretty($solarRaw), 'note' => '', 'date' => $solarBd];
}
if ($lunarBd !== '') {
[$ly, $lm, $ld] = array_map('intval', explode('-', $lunarBd));
$leap = !empty($person['birthday_lunar_leap']);
$rows[] = [
'kind' => 'lunar',
'text' => Lunar::label($ly, $lm, $ld, $leap) . (substr((string)$person['birthday_lunar'], 11, 8) !== '00:00:00'
? ' ' . substr((string)$person['birthday_lunar'], 11, 8) : ''),
'note' => '',
'date' => $lunarBd,
];
}
return $rows;
}
}