PHP DateTime :: изменить сложение и вычитание месяцев - PullRequest
89 голосов
/ 30 августа 2010

Я много работал с 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 месяц работал как положено, а не как предполагалось?

Ответы [ 19 ]

98 голосов
/ 30 августа 2010

Почему это не ошибка:

Текущее поведение правильное. Внутри происходит следующее:

  1. +1 month увеличивает номер месяца (изначально 1) на единицу. Это делает дату 2010-02-31.

  2. Во втором месяце (феврале) в 2010 году только 28 дней, поэтому PHP автоматически исправляет это, просто продолжая считать дни с 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 раздел:

  1. next month увеличивает номер месяца (изначально 1) на единицу. Это делает дату 2010-02-31.

  2. first day of устанавливает номер дня равным 1, в результате чего получается дата 2010-02-01.

11 голосов
/ 30 марта 2013

Это может быть полезно:

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
6 голосов
/ 24 апреля 2014

Мое решение проблемы:

$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' );
    }
}
5 голосов
/ 20 января 2016

Вот еще одно компактное решение, полностью использующее методы 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
4 голосов
/ 18 февраля 2014

Я сделал функцию, которая возвращает 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');
    }
}
3 голосов
/ 13 июля 2013

Я согласен с мнением ОП, что это нелогично и неприятно, но так же определяет, что означает +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

Это было бы хорошо, за исключением случаев, когда наш пользователь получает обе рассылки в один и тот же день. Давайте предположим, что это проблема стороны предложения, а не стороны спроса. Мы не беспокоимся о том, что пользователь будет раздражен получением двух информационных бюллетеней в один и тот же день, но вместо этого наши почтовые серверы не могут позволить пропускную способность для отправки вдвое много новостных рассылок. Имея это в виду, мы возвращаемся к другой интерпретации «+1 месяц» как «отправка со второго по последний день каждого месяца», которая будет возвращать:

  • 2015-01-30
  • 2015-02-27
  • 2015-03-30
  • 2015-05-30
  • 2015-06-29

Теперь мы избежали какого-либо совпадения с первым сетом, но мы также заканчиваем с апрелем и 29 июня, что, безусловно, соответствует нашим первоначальным представлениям о том, что +1 month просто должно вернуть m/$d/Y или привлекательный и простой m/30/Y на все возможные месяцы. Итак, теперь давайте рассмотрим третью интерпретацию +1 month с использованием обеих дат:

Январь * 1063 тридцать первого * 2015-01-31 2015-03-03 2015-03-31 2015-05-01 2015-05-31 2015-07-01 Январь Тридцатые

  • 2015-01-30
  • 2015-03-02
  • 2015-03-30
  • 2015-04-30
  • 2015-05-30
  • 2015-06-30

Выше есть некоторые проблемы. Февраль пропущен, что может быть проблемой как в конце предложения (скажем, если есть ежемесячное распределение пропускной способности, а февраль тратится впустую, а март удваивается), так и в конце спроса (пользователи чувствуют себя обманутыми в феврале и чувствуют дополнительный март как попытка исправить ошибку). С другой стороны, обратите внимание, что два набора дат:

  • никогда не перекрываются
  • всегда в одну и ту же дату, когда в этом месяце есть дата (поэтому набор 30 января выглядит довольно чистым)
  • все в течение 3 дней (в большинстве случаев - 1 день) от того, что можно считать «правильной» датой.
  • все по крайней мере 28 дней (лунный месяц) от их преемника и предшественника, поэтому очень равномерно распределены.

Учитывая последние два сета, не составит труда просто откатить одну из дат, если она выходит за пределы фактического следующего месяца (поэтому откат к 28 февраля и 30 апреля в первом сете) и не потерять ни одного сон из-за случайного совпадения и расхождения от паттерна «последний день месяца» против «второго до последнего дня месяца». Но ожидание выбора библиотек между «наиболее красивым / естественным», «математическим толкованием 31/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("Y-m-d") . PHP_EOL);
}

Это не оптимально, но основная логика такова: если добавление 1 месяца приводит к дате, отличной от ожидаемой в следующем месяце, отбросьте эту дату и добавьте вместо нее 4 недели. Вот результаты с двумя датами испытаний:

Январь * 1117 тридцать первого * 2015-01-31 2015-02-28 2015-03-31 2015-04-28 2015-05-31 2015-06-28 Январь Тридцатые

  • 2015-02-27
  • 2015-03-30
  • 2015-04-30
  • 2015-05-30
  • 2015-06-30

(Мой код - беспорядок и не будет работать в многолетнем сценарии. Я приветствую всех, кто переписывает решение более элегантным кодом, если основная предпосылка сохраняется, то есть, если +1 месяц вернет фанк вместо даты используйте +4 недели.)

3 голосов
/ 15 января 2016

В сочетании с ответом 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;
}
2 голосов
/ 19 сентября 2014

Я нашел более короткий путь, используя следующий код:

                   $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');
1 голос
/ 01 апреля 2015

Вот реализация улучшенной версии Ответ Джуханы в связанном вопросе:

<?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
1 голос
/ 24 февраля 2017

Это улучшенная версия ответа Касихаси в связанном вопросе.Это правильно добавит или вычтет произвольное количество месяцев к дате.

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'
...