Я много работал с DateTime class
и недавно столкнулся с тем, что, по моему мнению, было ошибкой при добавлении месяцев. После небольшого исследования выяснилось, что это не ошибка, а работа по назначению. Согласно документации, приведенной здесь :
Пример №2 Остерегайтесь при добавлении или вычитании месяцев
<?php $date = new DateTime('2000-12-31'); $date->modify('+1 month'); echo $date->format('Ym-d') . "\n"; $date->modify('+1 month'); echo $date->format('Ym-d') . "\n"; ?>
The above example will output: 2001-01-31 2001-03-03
Может ли кто-нибудь обосновать, почему это не считается ошибкой?
Кроме того, есть ли у кого-нибудь какие-нибудь изящные решения, чтобы исправить эту проблему и сделать так, чтобы +1 месяц работал как ожидалось, а не как предполагалось?
Текущее поведение правильное. Внутри происходит следующее:
+1 month
увеличивает номер месяца (первоначально 1) на единицу. Это делает дату 2010-02-31
.
Второй месяц (февраль) имеет только 28 дней в 2010 году, поэтому PHP автоматически корректирует это, просто продолжая считать дни с 1 февраля. Затем вы закончите 3 марта.
Чтобы получить то, что вы хотите, выполните следующие действия: вручную проверьте следующий месяц. Затем добавьте количество дней в следующем месяце.
Надеюсь, вы сами можете это кодировать. Я просто даю что-то делать.
Чтобы получить правильное поведение, вы можете использовать одну из новых функциональных возможностей PHP 5.3, которая вводит относительный период времени в first day of
. Эта строфа может использоваться в сочетании со next month
, fifth month
или +8 months
чтобы перейти к первому дню указанного месяца. Вместо +1 month
от того, что вы делаете, вы можете использовать этот код, чтобы получить первый день следующего месяца следующим образом:
<?php $d = new DateTime( '2010-01-31' ); $d->modify( 'first day of next month' ); echo $d->format( 'F' ), "\n"; ?>
Этот скрипт будет правильно выводить February
. Следующие события происходят, когда PHP обрабатывает этот first day of next month
:
next month
число месяца (первоначально 1) увеличивается на единицу. Это делает дату 2010-02-31.
first day of
устанавливает день число до 1
, в результате чего дата 2010-02-01.
Это может быть полезно:
echo Date("Ymd", strtotime("2013-01-01 +1 Month -1 Day")); // 2013-01-31 echo Date("Ymd", strtotime("2013-02-01 +1 Month -1 Day")); // 2013-02-28 echo Date("Ymd", strtotime("2013-03-01 +1 Month -1 Day")); // 2013-03-31 echo Date("Ymd", strtotime("2013-04-01 +1 Month -1 Day")); // 2013-04-30 echo Date("Ymd", strtotime("2013-05-01 +1 Month -1 Day")); // 2013-05-31 echo Date("Ymd", strtotime("2013-06-01 +1 Month -1 Day")); // 2013-06-30 echo Date("Ymd", strtotime("2013-07-01 +1 Month -1 Day")); // 2013-07-31 echo Date("Ymd", strtotime("2013-08-01 +1 Month -1 Day")); // 2013-08-31 echo Date("Ymd", strtotime("2013-09-01 +1 Month -1 Day")); // 2013-09-30 echo Date("Ymd", strtotime("2013-10-01 +1 Month -1 Day")); // 2013-10-31 echo Date("Ymd", strtotime("2013-11-01 +1 Month -1 Day")); // 2013-11-30 echo Date("Ymd", strtotime("2013-12-01 +1 Month -1 Day")); // 2013-12-31
Мое решение проблемы:
$startDate = new \DateTime( '2015-08-30' ); $endDate = clone $startDate; $billing_count = '6'; $billing_unit = 'm'; $endDate->add( new \DateInterval( 'P' . $billing_count . strtoupper( $billing_unit ) ) ); if ( intval( $endDate->format( 'n' ) ) > ( intval( $startDate->format( 'n' ) ) + intval( $billing_count ) ) % 12 ) { if ( intval( $startDate->format( 'n' ) ) + intval( $billing_count ) != 12 ) { $endDate->modify( 'last day of -1 month' ); } }
Я создал функцию, которая возвращает DateInterval, чтобы убедиться, что добавление месяца показывает следующий месяц и удаляет дни после этого.
$time = new DateTime('2014-01-31'); echo $time->format('dmY H:i') . '<br/>'; $time->add( add_months(1, $time)); echo $time->format('dmY H:i') . '<br/>'; function add_months( $months, \DateTime $object ) { $next = new DateTime($object->format('dmY H:i:s')); $next->modify('last day of +'.$months.' month'); if( $object->format('d') > $next->format('d') ) { return $object->diff($next); } else { return new DateInterval('P'.$months.'M'); } }
Вот реализация улучшенной версии ответа Юханы по смежному вопросу:
<?php function sameDateNextMonth(DateTime $createdDate, DateTime $currentDate) { $addMon = clone $currentDate; $addMon->add(new DateInterval("P1M")); $nextMon = clone $currentDate; $nextMon->modify("last day of next month"); if ($addMon->format("n") == $nextMon->format("n")) { $recurDay = $createdDate->format("j"); $daysInMon = $addMon->format("t"); $currentDay = $currentDate->format("j"); if ($recurDay > $currentDay && $recurDay <= $daysInMon) { $addMon->setDate($addMon->format("Y"), $addMon->format("n"), $recurDay); } return $addMon; } else { return $nextMon; } }
Эта версия принимает $createdDate
соответствии с предположением о том, что вы имеете дело с повторяющимся месячным периодом, таким как подписка, которая начиналась в определенную дату, например 31-го. Это всегда занимает $createdDate
настолько поздно, что «повторяется». Даты не будут меняться на более низкие значения, поскольку они продвигаются вперед по сравнению с меньшими месяцами (например, поэтому все 29, 30 или 31 даты повторения не будут в конечном итоге застревать на 28-м после прохождения через февраль без високосного года).
Вот пример кода драйвера для проверки алгоритма:
$createdDate = new DateTime("2015-03-31"); echo "created date = " . $createdDate->format("Ymd") . PHP_EOL; $next = sameDateNextMonth($createdDate, $createdDate); echo " next date = " . $next->format("Ymd") . PHP_EOL; foreach(range(1, 12) as $i) { $next = sameDateNextMonth($createdDate, $next); echo " next date = " . $next->format("Ymd") . PHP_EOL; }
Какие результаты:
created date = 2015-03-31 next date = 2015-04-30 next date = 2015-05-31 next date = 2015-06-30 next date = 2015-07-31 next date = 2015-08-31 next date = 2015-09-30 next date = 2015-10-31 next date = 2015-11-30 next date = 2015-12-31 next date = 2016-01-31 next date = 2016-02-29 next date = 2016-03-31 next date = 2016-04-30
Я нашел более короткий путь вокруг него, используя следующий код:
$datetime = new DateTime("2014-01-31"); $month = $datetime->format('n'); //without zeroes $day = $datetime->format('j'); //without zeroes if($day == 31){ $datetime->modify('last day of next month'); }else if($day == 29 || $day == 30){ if($month == 1){ $datetime->modify('last day of next month'); }else{ $datetime->modify('+1 month'); } }else{ $datetime->modify('+1 month'); } echo $datetime->format('Ymd H:i:s');
В сочетании с ответом shamittomar, это могло бы быть это для добавления месяцев «безопасно»:
/** * Adds months without jumping over last days of months * * @param \DateTime $date * @param int $monthsToAdd * @return \DateTime */ public function addMonths($date, $monthsToAdd) { $tmpDate = clone $date; $tmpDate->modify('first day of +'.(int) $monthsToAdd.' month'); if($date->format('j') > $tmpDate->format('t')) { $daysToAdd = $tmpDate->format('t') - 1; }else{ $daysToAdd = $date->format('j') - 1; } $tmpDate->modify('+ '. $daysToAdd .' days'); return $tmpDate; }
Если вы просто хотите избежать пропусков в месяц, вы можете выполнить что-то подобное, чтобы получить дату и запустить цикл в следующем месяце, уменьшив дату на один и перепроверяя до допустимой даты, когда $ start_calculated является допустимой строкой для strtotime (т. Е. mysql datetime или "now"). Это находит конец конца месяца с 1 минутой до полуночи, а не пропускает месяц.
$start_dt = $starting_calculated; $next_month = date("m",strtotime("+1 month",strtotime($start_dt))); $next_month_year = date("Y",strtotime("+1 month",strtotime($start_dt))); $date_of_month = date("d",$starting_calculated); if($date_of_month>28){ $check_date = false; while(!$check_date){ $check_date = checkdate($next_month,$date_of_month,$next_month_year); $date_of_month--; } $date_of_month++; $next_d = $date_of_month; }else{ $next_d = "d"; } $end_dt = date("Ym-$next_d 23:59:59",strtotime("+1 month"));
в$start_dt = $starting_calculated; $next_month = date("m",strtotime("+1 month",strtotime($start_dt))); $next_month_year = date("Y",strtotime("+1 month",strtotime($start_dt))); $date_of_month = date("d",$starting_calculated); if($date_of_month>28){ $check_date = false; while(!$check_date){ $check_date = checkdate($next_month,$date_of_month,$next_month_year); $date_of_month--; } $date_of_month++; $next_d = $date_of_month; }else{ $next_d = "d"; } $end_dt = date("Ym-$next_d 23:59:59",strtotime("+1 month"));
Я согласен с настроем OP, что это противоречит интуиции и разочарованию, но так же определяет, что +1 month
означает в сценариях, где это происходит. Рассмотрим следующие примеры:
Вы начинаете с 2015-01-31 и хотите добавить месяц 6 раз, чтобы получить цикл планирования для отправки бюллетеня по электронной почте. Учитывая первоначальные ожидания ОП, это вернет:
Сразу отметим, что мы ожидаем, что +1 month
означает last day of month
или, альтернативно, добавить 1 месяц на итерацию, но всегда в отношении начальной точки. Вместо того, чтобы интерпретировать это как «последний день месяца», мы могли бы прочитать его как «31-й день следующего месяца или последний доступный в течение этого месяца». Это означает, что мы прыгаем с 30 апреля по 31 мая вместо 30 мая. Обратите внимание, что это не потому, что это «последний день месяца», а потому, что мы хотим «ближе всего к дате начала месяца».
Предположим, что один из наших пользователей подписывается на другой информационный бюллетень, который начнется в 2015-01-30 годах. Какова интуитивная дата на +1 month
? Одна интерпретация будет «30-й день следующего месяца или ближайший доступный», который будет возвращаться:
Это было бы хорошо, если бы наш пользователь не получал оба бюллетеня в тот же день. Предположим, что это проблема со стороны предложения, а не с точки зрения спроса. Мы не беспокоимся о том, что пользователь будет раздражен тем, что получает 2 информационных бюллетеня в тот же день, но вместо этого наши почтовые серверы не могут позволить себе пропускную способность для отправки дважды, как многие информационные бюллетени. Имея это в виду, мы возвращаемся к другой интерпретации «+1 месяц» как «отправляем второй и последний день каждого месяца», которые возвращаются:
Теперь мы избегали каких-либо совпадений с первым набором, но мы также закончили с апрелем и 29 июня, что, безусловно, соответствует нашим первоначальным интуициям, что +1 month
просто должен вернуть m/$d/Y
или привлекательный и простой m/30/Y
течение всех возможных месяцев. Итак, теперь давайте рассмотрим третью интерпретацию +1 month
используя обе даты:
Вышеизложенное имеет некоторые проблемы. Февраль пропущен, что может быть проблемой как с точки зрения предложения (скажем, если есть ежемесячное распределение полосы пропускания, а фев, то впустую, а март удваивается) и спрос (пользователи чувствуют себя обманутыми с февраля и воспринимают дополнительный марш как попытка исправить ошибку). С другой стороны, обратите внимание, что два набора дат:
Учитывая последние два набора, нетрудно просто отбросить одну из дат, если она выпадет за пределы фактического следующего месяца (поэтому откат до 28 февраля и 30 апреля в первом наборе) и не потерять сон над случайное совпадение и расхождение с шаблоном «последний день месяца» против «второго по последний день месяца». Но ожидая, что библиотека будет выбирать между «самой красивой / естественной», «математической интерпретацией 02/31 и другими переполнениями месяца» и «по сравнению с первым месяцем или прошлым месяцем» всегда будет заканчиваться тем, чьи ожидания не выполняются, и в некоторых случаях необходимо скорректировать «неправильную» дату, чтобы избежать реальной проблемы, которую вводит «неправильная» интерпретация.
Итак, еще раз, хотя я также ожидал бы, что +1 month
вернет дату, которая на самом деле находится в следующем месяце, это не так просто, как интуиция, и, учитывая выбор, переход к математике из-за ожиданий веб-разработчиков, вероятно, является безопасным выбором.
Вот альтернативное решение, которое по-прежнему столь же неуклюже, как и все, но я считаю приятными результаты:
foreach(range(0,5) as $count) { $new_date = clone $date; $new_date->modify("+$count month"); $expected_month = $count + 1; $actual_month = $new_date->format("m"); if($expected_month != $actual_month) { $new_date = clone $date; $new_date->modify("+". ($count - 1) . " month"); $new_date->modify("+4 weeks"); } echo "* " . nl2br($new_date->format("Ymd") . PHP_EOL); }
Это не оптимально, но основная логика заключается в следующем: если добавление 1 месяца приводит к дате, отличной от ожидаемого в следующем месяце, замените эту дату и добавьте 4 недели вместо этого. Ниже приведены результаты с двумя датами тестирования:
(Мой код беспорядок и не будет работать в многолетнем сценарии. Я приветствую любого, кто переписывает решение с более элегантным кодом, если базовое помещение будет сохранено, т. Е. Если +1 месяц возвращает фанк-дату, используйте +4 недель вместо этого.)
Расширение для класса DateTime, которое решает проблему добавления или вычитания месяцев
Если используется strtotime()
просто используйте $date = strtotime('first day of +1 month');
вы можете сделать это с помощью только даты () и strtotime (). Например, чтобы добавить 1 месяц к сегодняшней дате:
date («Ymd», strtotime («+ 1 месяц», время ()));
если вы хотите использовать класс datetime, это тоже хорошо, но это так же просто. подробнее здесь
Вот еще одно компактное решение, полностью использующее методы DateTime, изменяющее объект на месте без создания клонов.
$dt = new DateTime('2012-01-31'); echo $dt->format('Ym-d'), PHP_EOL; $day = $dt->format('j'); $dt->modify('first day of +1 month'); $dt->modify('+' . (min($day, $dt->format('t')) - 1) . ' days'); echo $dt->format('Ym-d'), PHP_EOL;
Он выводит:
2012-01-31 2012-02-29
Мне нужно было получить дату «в этом месяце в прошлом году», и это становится неприятным довольно быстро, когда в этом месяце – февраль в високосный год. Тем не менее, я считаю, что это работает …: – / Трюк, похоже, должен основывать ваши изменения на 1-й день месяца.
$this_month_last_year_end = new \DateTime(); $this_month_last_year_end->modify('first day of this month'); $this_month_last_year_end->modify('-1 year'); $this_month_last_year_end->modify('last day of this month'); $this_month_last_year_end->setTime(23, 59, 59);
$ds = new DateTime(); $ds->modify('+1 month'); $ds->modify('first day of this month');
Это улучшенная версия ответа Касихаси в соответствующем вопросе. Это позволит правильно добавить или вычесть произвольное количество месяцев до даты.
public static function addMonths($monthToAdd, $date) { $d1 = new DateTime($date); $year = $d1->format('Y'); $month = $d1->format('n'); $day = $d1->format('d'); if ($monthToAdd > 0) { $year += floor($monthToAdd/12); } else { $year += ceil($monthToAdd/12); } $monthToAdd = $monthToAdd%12; $month += $monthToAdd; if($month > 12) { $year ++; $month -= 12; } elseif ($month < 1 ) { $year --; $month += 12; } if(!checkdate($month, $day, $year)) { $d2 = DateTime::createFromFormat('Yn-j', $year.'-'.$month.'-1'); $d2->modify('last day of'); }else { $d2 = DateTime::createFromFormat('Yn-d', $year.'-'.$month.'-'.$day); } return $d2->format('Ym-d'); }
Например:
addMonths(-25, '2017-03-31')
выведет:
'2015-02-28'
$month = 1; $year = 2017; echo date('n', mktime(0, 0, 0, $month + 2, -1, $year));
выйдет 2
(февраль). будет работать и в другие месяцы.
$date = date('Ym-d', strtotime("+1 month")); echo $date;