362 lines
16 KiB
PHP
362 lines
16 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 三个生日:身份证生日 / 真实阳历生日 / 农历生日。
|
|||
|
|
*
|
|||
|
|
* 为什么要有三个(而不是把生日当一个字段):
|
|||
|
|
* · 身份证上的那个是**户籍事实**,和证件完全一致,任何人都不该在系统里改它 ——
|
|||
|
|
* 它和真实生日**经常不一样**(当年上户口随手写的、只报农历的、报错的)。
|
|||
|
|
* · 真实阳历生日是"实际过的那天",是给本人过的。
|
|||
|
|
* · 农历生日是长辈那边记的那天,很多人只认这个。
|
|||
|
|
* 三个人可能是同一天,也可能差好几天甚至几个月 —— 所以提醒要分开出。
|
|||
|
|
*
|
|||
|
|
* 存法:
|
|||
|
|
* 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
|
|||
|
|
{
|
|||
|
|
/** 三种生日的键 => 人话名字 */
|
|||
|
|
public const KINDS = [
|
|||
|
|
'id' => '身份证生日',
|
|||
|
|
'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']);
|
|||
|
|
|
|||
|
|
if ($idBd !== '') {
|
|||
|
|
$d = self::nextSolarDate($idBd, $today);
|
|||
|
|
$buckets[$d]['kinds'][] = 'id';
|
|||
|
|
}
|
|||
|
|
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'] ?? '');
|
|||
|
|
|
|||
|
|
if ($idBd !== '') {
|
|||
|
|
$rows[] = ['kind' => 'id', 'text' => $idBd, 'note' => '由身份证推导,不可修改', 'date' => $idBd];
|
|||
|
|
}
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|