PHP DateTime :: изменить добавление и вычитание месяцев


102

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


Что, по вашему мнению, будет "2001-01-31" плюс 1 месяц? ... "2001-02-28"? «2001-03-01»?
— Artefacto

57
Лично я ожидал, что это будет 28 февраля 2001 г.
— tplaner


2
Ага, это довольно неприятная причуда. Вы прочитали мелкий шрифт, чтобы понять, что P1M составляет 31 день. Не совсем понимаю, почему люди продолжают защищать это как «правильное» поведение.
— Indivision Dev

Похоже, популярное мнение заключается в том, что логика должна округляться (до 2/28), хотя PHP округляется (до 3/1) ... хотя я предпочитаю путь PHP, но Microsoft Excel округляет вниз, настраивая веб-разработчиков против пользователей электронных таблиц. ...
— Дэйв Хек 07

Ответы:


107

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

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

  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) на единицу. Это делает дату 31.02.2010.

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


1
То есть вы говорите, что он буквально добавляет 1 месяц, полностью игнорируя дни? Итак, я предполагаю, что вы можете столкнуться с аналогичной проблемой с +1 годом, если добавите его в високосный год?
— tplaner

@evolve, да, литературно прибавляет 1 месяц.
— shamittomar

13
И если вы вычтите 1 месяц после добавления, я полагаю, вы получите совсем другую дату. Это кажется очень не интуитивным.
— Дэн Брин,

2
Замечательный пример использования новых строф в PHP 5.3, где вы можете использовать первый день, последний день, этот месяц, следующий месяц и предыдущий месяц.
— Ким Стэкс

7
имхо это ошибка. серьезная ошибка. если я хочу добавить 31 день, я добавляю 31 день. Я хочу добавить месяц, нужно добавить месяц, а не 31 день.
— low_rents

12

Вот еще одно компактное решение, полностью использующее методы 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

1
Спасибо. Лучшее решение, представленное здесь на данный момент. Вы также можете сократить код до $dt->modify()->modify(). Так же хорошо работает.
— Alph.Dev

10

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

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

2
Это не общее решение, так как это работает только для определенных входов, например 1-го числа месяца. Например, если это сделать 30 января, это приведет к страданиям.
— Йенс Роланд

Или вы могли бы сделать$dateTime->modify('first day of next month')->modify('-1day')
— Энтони

6

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

$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' );
    }
}

3
Команда "clone" была решением моих проблем с присвоением переменных. Спасибо тебе за это.
— Стеф Роуз,

4

Я согласен с мнением OP о том, что это противоречит интуиции и разочаровывает, но также определяет, что +1 month означает в сценариях, где это происходит. Рассмотрим эти примеры:

Вы начинаете с 2015-01-31 и хотите добавить месяц 6 раз, чтобы получить цикл планирования для отправки информационного бюллетеня по электронной почте. Учитывая первоначальные ожидания OP, это вернется:

  • 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 мая. Обратите внимание, что это не потому, что это «последний день месяца», а потому, что мы хотим «ближайший доступный к дате начала месяца».

Итак, предположим, что один из наших пользователей подписывается на другой информационный бюллетень, который начнется 30 января 2015 года. Для чего нужна интуитивная дата +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 июня, что, безусловно, соответствует нашей первоначальной интуиции, которая +1 monthпросто должна вернуться m/$d/Yили привлекательной и простой m/30/Yдля всех возможных месяцев. Итак, теперь давайте рассмотрим третью интерпретацию +1 monthиспользования обеих дат:

31 января

  • 2015-01-31
  • 2015-03-03
  • 2015-03-31
  • 2015-05-01
  • 2015-05-31
  • 2015-07-01

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

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

31 января

  • 2015-01-31
  • 2015-02-28
  • 2015-03-31
  • 2015-04-28
  • 2015-05-31
  • 2015-06-28

30 января

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

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


4

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

4

В сочетании с ответом Шамиттомара это могло быть для добавления месяцев «безопасно»:

/**
 * 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;
}

Огромное спасибо!!
— geckos

2

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

                   $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

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

<?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

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

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'

0

Если вы просто хотите избежать пропуска месяца, вы можете выполнить что-то вроде этого, чтобы получить дату и запустить цикл в следующем месяце, уменьшив дату на единицу и перепроверив ее до правильной даты, где $ start_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"));


0

При использовании strtotime()просто используйте$date = strtotime('first day of +1 month');


0

Мне нужно было получить дату «в этом месяце в прошлом году», и довольно быстро становится неприятно, когда в этом месяце февраль в високосном году. Тем не менее, я считаю, что это работает ...: - / Уловка, похоже, состоит в том, чтобы вносить изменения в 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);

0
$ds = new DateTime();
$ds->modify('+1 month');
$ds->modify('first day of this month');

1
Вам нужно объяснить свой ответ. Ответы только на коде считаются низкокачественными
— Machavity

Спасибо! Это лучший ответ. Если вы переключите последние 2 строки, то всегда будет указан правильный месяц. Престижность!
— Дэнни Шеманн

0
$month = 1; $year = 2017;
echo date('n', mktime(0, 0, 0, $month + 2, -1, $year));

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


0
$current_date = new DateTime('now');
$after_3_months = $current_date->add(\DateInterval::createFromDateString('+3 months'));

Несколько дней:

$after_3_days = $current_date->add(\DateInterval::createFromDateString('+3 days'));

Важный:

Метод add()класса DateTime изменяет значение объекта, поэтому после вызова add()объекта DateTime он возвращает новый объект даты, а также сам изменяет объект.


0

вы можете сделать это только с помощью date () и strtotime (). Например, чтобы добавить 1 месяц к сегодняшней дате:

date("Y-m-d",strtotime("+1 month",time()));

если вы хотите использовать класс datetime, это тоже хорошо, но это так же просто. подробнее здесь


0

Принятый ответ уже объясняет, почему это не но, а некоторые другие ответы представляют собой изящное решение с выражениями php, такими как first day of the +2 months . Проблема с этими выражениями в том, что они не заполняются автоматически.

Однако решение довольно простое. Во-первых, вы должны найти полезные абстракции, отражающие вашу проблемную область. В данном случае это файл ISO8601DateTime. Во-вторых, должно быть несколько реализаций, которые могут дать желаемое текстовое представление. Например, Today, Tomorrow, The first day of this month, Future- все они представляют собой реализацию специфичной ISO8601DateTimeконцепции.

Итак, в вашем случае вам нужна реализация TheFirstDayOfNMonthsLater. Его легко найти, просто взглянув на список подклассов в любой среде IDE. Вот код:

$start = new DateTimeParsedFromISO8601String('2000-12-31');
$firstDayOfOneMonthLater = new TheFirstDayOfNMonthsLater($start, 1);
$firstDayOfTwoMonthsLater = new TheFirstDayOfNMonthsLater($start, 2);
var_dump($start->value()); // 2000-12-31T00:00:00+00:00
var_dump($firstDayOfOneMonthLater->value()); // 2001-01-01T00:00:00+00:00
var_dump($firstDayOfTwoMonthsLater->value()); // 2001-02-01T00:00:00+00:00

То же самое и с последними числами месяца. Для получения дополнительных примеров этого подхода прочтите это .


Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.