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

21 ответ

Решение

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

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

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

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

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

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

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

Я согласен с мнением ФП, что это противоречит интуиции и разочаровывает, но это также определяет, что +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-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 апреля в первом сете) и не потерять сон в течение случайное наложение и отклонение от шаблона "последний день месяца" против "второй-последний день месяца". Но ожидание выбора библиотек между "наиболее красивым / естественным", "математическим толкованием 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 недели. Вот результаты с двумя датами испытаний:

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 недели вместо.)

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

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

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'
$ds = new DateTime();
$ds->modify('+1 month');
$ds->modify('first day of this month');

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

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

Принятый ответ уже объясняет, почему это не но, а некоторые другие ответы представляют собой изящное решение с выражениями 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

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

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

<?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, которое решает проблему сложения или вычитания месяцев

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

Немного поздно, но, возможно, это может помочь кому-то:

      public static function DateSameMonth($date_iso, $add_sub_months = 1, $operator = "+") {
    $mdate = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

    $date = new DateTime($date_iso);
    $d = (int)$date->format('d');
    $m = (int)$date->format('m');
    $y = (int)$date->format('Y');

    if ($m == 2) {
        $mdate[$m] = (($y % 4) === 0) ? (($d <= 29) ? $d : 29) : (($d <= 28) ? $d : 28);
    }

    //first day / last day 
    if($d == 1) {
        $mod = "first day of ";
    }
    elseif($d == $mdate[$m]) {
        $mod = "last day of ";
    }
    else {
        $mod = "";
    }

    $date->modify($mod . $operator . $add_sub_months . ' months');

  
    return $date->format("Y-m-d");
}

var_dump(DateSameMonth("2022-01-31", 3, "-")); //string(10) "2021-10-31"
var_dump(DateSameMonth("2022-11-30", 3, "+")); //string(10) "2023-02-28"
var_dump(DateSameMonth("2022-01-31", 25, "+")); //string(10) "2024-02-29" leap year
var_dump(DateSameMonth("2022-01-16", 25, "+")); //string(10) "2024-02-16"

Прямой эфир: https://3v4l.org/hmcm6

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

$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 он возвращает новый объект даты, а также изменяет сам объект.

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

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

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

Если вы просто хотите избежать пропуска месяца, вы можете выполнить что-то вроде этого, чтобы вывести дату и запустить цикл в следующем месяце, сократив дату на единицу и перепроверив до действительной даты, где $ начальная_калькуляция является допустимой строкой для 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 = date('Y-m-d', strtotime("+1 month"));
     echo $date;

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

date ("Ymd",strtotime("+1 month",time()));

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

Другие вопросы по тегам