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

tplaner
источник
Что, по вашему мнению, будет "2001-01-31" плюс 1 месяц? ... "2001-02-28"? «2001-03-01»?
Artefacto
57
Лично я ожидал, что это будет 28 февраля 2001 г.
tplaner
Та же история с strtotime() stackoverflow.com/questions/7119777/…
Валентин Деспа 09
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' );
    }
}
Bernland
источник
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');
    }
}
AR
источник
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;
}
Patrickzzz
источник
Огромное спасибо!!
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');
Rommel Paras
источник
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'
Hải Phong
источник
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"));
user1590391
источник
0

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

Primoz Rome
источник
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);
Just Plain High
источник
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, это тоже хорошо, но это так же просто. подробнее здесь

PHP-наркоман
источник
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

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

Вадим Самохин
источник
-2
     $date = date('Y-m-d', strtotime("+1 month"));
     echo $date;
Мохаммед Ф. Газо
источник