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 82

18 ответов:

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

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

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

  2. второй месяц (февраль) имеет только 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 строфа:

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

  2. 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"));

расширение для класса DateTime, которое решает проблему добавления или вычитания месяцев

https://gist.github.com/66Ton99/60571ee49bf1906aaa1c

при использовании strtotime() просто использовать $date = strtotime('first day of +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);
$ds = new DateTime();
$ds->modify('+1 month');
$ds->modify('first day of this month');
$month = 1; $year = 2017;
echo date('n', mktime(0, 0, 0, $month + 2, -1, $year));

выводит 2 (февраль). будет работать и в другие месяцы тоже.

     $date = date('Y-m-d', strtotime("+1 month"));
     echo $date;