PHP DateTime:: изменить добавление и вычитание месяцев
Я много работал с DateTime class
и недавно столкнулся с тем, что я думал, была ошибка при добавлении месяцев. После небольшого исследования кажется, что это не ошибка, а вместо этого работает так, как предполагалось. Согласно найденной документации здесь:
Пример #2 Будьте осторожны при добавлении или вычитание месяцев
<?php
$date = new DateTime('2000-12-31');
$date->modify('+1 month');
echo $date->format('Y-m-d') . "n";
$date->modify('+1 month');
echo $date->format('Y-m-d') . "n";
?>
The above example will output: 2001-01-31 2001-03-03
кто-нибудь может обосновать, почему это не считается ошибка?
кроме того, есть ли у кого-нибудь элегантные решения для исправления проблемы и сделать это так +1 месяц будет работать, как ожидалось, а не как предполагалось?
18 ответов:
почему это не ошибка:
текущее поведение является правильным. Внутри происходит следующее:
+1 month
увеличивает число месяцев (первоначально 1) на единицу. Это делает дату2010-02-31
.второй месяц (февраль) имеет только 28 дней в 2010 году, так что РНР автоматически исправляет это, просто продолжая считать дни с 1-го февраля. В итоге 3 марта.
как получить то, что вы хочу:
чтобы получить то, что вы хотите, это: ручная проверка в следующем месяце. Затем добавьте количество дней в следующем месяце.
я надеюсь, что вы можете сами кодировать это. Я просто даю то, что нужно делать.
в PHP 5.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("Y-m-d", strtotime("2013-01-01 +1 Month -1 Day")); // 2013-01-31 echo Date("Y-m-d", strtotime("2013-02-01 +1 Month -1 Day")); // 2013-02-28 echo Date("Y-m-d", strtotime("2013-03-01 +1 Month -1 Day")); // 2013-03-31 echo Date("Y-m-d", strtotime("2013-04-01 +1 Month -1 Day")); // 2013-04-30 echo Date("Y-m-d", strtotime("2013-05-01 +1 Month -1 Day")); // 2013-05-31 echo Date("Y-m-d", strtotime("2013-06-01 +1 Month -1 Day")); // 2013-06-30 echo Date("Y-m-d", strtotime("2013-07-01 +1 Month -1 Day")); // 2013-07-31 echo Date("Y-m-d", strtotime("2013-08-01 +1 Month -1 Day")); // 2013-08-31 echo Date("Y-m-d", strtotime("2013-09-01 +1 Month -1 Day")); // 2013-09-30 echo Date("Y-m-d", strtotime("2013-10-01 +1 Month -1 Day")); // 2013-10-31 echo Date("Y-m-d", strtotime("2013-11-01 +1 Month -1 Day")); // 2013-11-30 echo Date("Y-m-d", 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' ); } }
вот еще одно компактное решение, полностью использующее методы DateTime, изменяя объект на месте без создания клонов.
$dt = new DateTime('2012-01-31'); echo $dt->format('Y-m-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('Y-m-d'), PHP_EOL;
выдает:
2012-01-31 2012-02-29
Я сделал функцию, которая возвращает DateInterval, чтобы убедиться, что добавление месяца показывает следующий месяц, и удаляет дни в после этого.
$time = new DateTime('2014-01-31'); echo $time->format('d-m-Y H:i') . '<br/>'; $time->add( add_months(1, $time)); echo $time->format('d-m-Y H:i') . '<br/>'; function add_months( $months, \DateTime $object ) { $next = new DateTime($object->format('d-m-Y 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'); } }
в сочетании с ответом шамиттомара, это может быть так для добавления месяцев "безопасно":
/** * 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; }
Я согласен с мнением ОП, что это противоречит интуиции и расстраивает, но так же определяет, что
+1 month
значит в сценариях, где это происходит. Рассмотрим следующие примеры:вы начинаете с 2015-01-31 и хотите добавить в месяц 6 раз, чтобы получить цикл планирования для отправки электронного письма. С учетом первоначальных ожиданий ОП это будет возвращение:
- 2015-01-31
- 2015-02-28
- 2015-03-31
- 2015-04-30
- 2015-05-31
- 2015-06-30
сразу же обратите внимание, что мы ожидаем
+1 month
означаетlast day of month
или, в качестве альтернативы, чтобы добавить 1 месяц за итерацию, но всегда в отношении начальной точки. Вместо того, чтобы интерпретировать это как "последний день месяца", мы могли бы прочитать его как " 31-й день следующего месяца или последний доступный в течение этого месяца". Это означает, что мы прыгаем с 30 апреля по 31 мая вместо 30 мая. Обратите внимание, что это не потому, что это "последний день месяца", а потому, что мы хотим "ближайший доступный к дате начала месяца."Итак, предположим, что один из наших пользователей подписывается на другой информационный бюллетень, чтобы начать 2015-01-30. Что такое интуитивная дата для
+1 month
? Одним из толкований будет "30-й день следующего месяца или ближайший доступный", который будет возвращение:
- 2015-01-30
- 2015-02-28
- 2015-03-30
- 2015-04-30
- 2015-05-30
- 2015-06-30
это было бы прекрасно, за исключением тех случаев, когда наш пользователь получает обе информационные бюллетени в тот же день. Предположим, что это проблема со стороны предложения, а не со стороны спроса мы не беспокоимся, что пользователь будет раздражен получением 2 бюллетеней в тот же день, но вместо этого наши почтовые серверы не могу позволить себе пропускную способность для отправки в два раза больше информационных бюллетеней. Имея это в виду, мы возвращаемся к другой интерпретации "+1 месяц " как "отправить на второй до последнего дня каждого месяца", который вернется:
- 2015-01-30
- 2015-02-27
- 2015-03-30
- 2015-04-29
- 2015-05-30
- 2015-06-29
теперь мы избежали любого перекрытия с первым набором, но мы также в конечном итоге с 29 апреля и 29 июня, что, безусловно, соответствует нашим первоначальным интуициям, что
+1 month
просто должен вернутьm/$d/Y
или привлекательный и простойm/30/Y
на все возможные месяцы. Итак, теперь давайте рассмотрим третью интерпретацию+1 month
используя обе даты:Jan. 31-й
- 2015-01-31
- 2015-03-03
- 2015-03-31
- 2015-05-01
- 2015-05-31
- 2015-07-01
Jan. 30-й
- 2015-01-30
- 2015-03-02
- 2015-03-30
- 2015-04-30
- 2015-05-30
- 2015-06-30
выше есть некоторые проблемы. Февраль пропускается, что может быть проблема как со стороны предложения (скажем, если есть ежемесячное распределение пропускной способности, а февраль идет впустую, а март удваивается), так и со стороны спроса (пользователи чувствуют себя обманутыми из Февраля и воспринимают дополнительный март как попытку исправить ошибку). С другой стороны, обратите внимание, что два набора дат:
- никогда не пересекаются
- всегда на ту же дату, когда этот месяц имеет дату (так что Ян. 30 комплект выглядит довольно чисто)
- все в течение 3 дней (1 день в большинстве случаев) того, что можно считать "правильной" датой.
- все по крайней мере 28 дней (лунный месяц) от их преемника и предшественника, поэтому очень равномерно распределены.
учитывая последние два набора, было бы нетрудно просто откатить одну из дат, если она выпадает за пределы фактического следующего месяца (так что откат до 28 февраля и 30 апреля в первом наборе) и не потерять сон из-за случайного перекрытия и расхождения с " последним днем месяц "vs" второй до последнего дня месяца " шаблон. Но ожидая, что библиотека будет выбирать между "самой красивой / естественной", "математической интерпретацией 02/31 и другими переполнениями месяца" и "относительно первого месяца или последнего месяца", всегда будет заканчиваться тем, что чьи-то ожидания не будут выполнены, а какой-то график должен скорректировать "неправильную" дату, чтобы избежать реальной проблемы, которую вводит "неправильная" интерпретация.
так что опять же, в то время как я также ожидал бы
+1 month
to вернуть дату, которая на самом деле находится в следующем месяце, это не так просто, как интуиция и учитывая выбор, идя с математикой над ожиданиями веб-разработчиков, вероятно, безопасный выбор.вот альтернативное решение, которое по-прежнему так же неуклюже, как и любой, но я думаю, имеет хорошие результаты:
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("Y-m-d") . PHP_EOL); }
это не оптимально, но основная логика : если добавление 1 месяца приводит к дате, отличной от ожидаемой в следующем месяце, откажитесь от этой даты и добавьте вместо этого 4 недели. Вот результаты с двумя датами тестирования:
Jan. 31-й
- 2015-01-31
- 2015-02-28
- 2015-03-31
- 2015-04-28
- 2015-05-31
- 2015-06-28
Jan. 30-й
- 2015-01-30
- 2015-02-27
- 2015-03-30
- 2015-04-30
- 2015-05-30
- 2015-06-30
(мой код-это беспорядок и не будет работать в многолетнем сценарии. Я приветствую всех, кто переписывает решение с более элегантным кодом, пока основная предпосылка остается неизменной, т. е. если +1 месяц возвращает фанк-дату, используйте +4 недели вместо этого.)
вот реализация улучшенной версии Джухона это в связи с этим вопрос:
<?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("Y-m-d") . PHP_EOL; $next = sameDateNextMonth($createdDate, $createdDate); echo " next date = " . $next->format("Y-m-d") . PHP_EOL; foreach(range(1, 12) as $i) { $next = sameDateNextMonth($createdDate, $next); echo " next date = " . $next->format("Y-m-d") . 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('Y-m-d H:i:s');
Это улучшенная версия Kasihasi это!--5--> в соответствующем вопросе. Это позволит правильно добавить или вычесть произвольное количество месяцев до даты.
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('Y-n-j', $year.'-'.$month.'-1'); $d2->modify('last day of'); }else { $d2 = DateTime::createFromFormat('Y-n-d', $year.'-'.$month.'-'.$day); } return $d2->format('Y-m-d'); }
например:
addMonths(-25, '2017-03-31')
вывод:
'2015-02-28'
Если вы просто хотите избежать пропуска месяца, вы можете выполнить что-то вроде этого, чтобы получить дату и запустить цикл на следующий месяц, уменьшив дату на один и перепроверяя до допустимой даты, где $starting_calculated является допустимой строкой для strtotime (т. е. mysql datetime или "сейчас"). Это находит самый конец месяца в 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("Y-m-$next_d 23:59:59",strtotime("+1 month"));
вы действительно можете сделать это с помощью только date() и strtotime (). Например, чтобы добавить 1 месяц к сегодняшней датой:
дата ("Y-m-d", strtotime ("+1 месяц", time ()));
Если вы хотите использовать класс datetime, это тоже хорошо, но это так же просто. подробнее здесь
Мне нужно было получить дату для "этого месяца в прошлом году", и это становится неприятным довольно быстро, когда в этом месяце февраль в високосном году. Тем не менее, я считаю, что это работает... :- /Трюк, похоже, заключается в том, чтобы основывать ваше изменение на 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);