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"?
Артефакто

57
Особисто я б очікував, що це буде 2001-02-28.
tplaner


2
Так, це досить дратівлива примха. Ви прочитали тонкий шрифт, щоб зрозуміти, що P1M - 31 день. Не зрозумійте, чому люди продовжують захищати це як "правильну" поведінку.
Indivision Dev

Здається, що поширена думка полягає в тому, що логіка повинна закруглюватися (до 2/28), хоча PHP округляється (до 3/1) ... хоча я віддаю перевагу шляху PHP, але Microsoft Excel закріплює вниз, підкоряючи веб-розробників проти користувачів електронних таблиць. ...
Дейв Хек

Відповіді:


107

Чому це не помилка:

Поточна поведінка правильна. Внутрішнє відбувається:

  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.


1
Отже, що ви говорите, це буквально додає 1 місяць, повністю ігноруючи дні? Тож я припускаю, що ви можете зіткнутися з подібною проблемою з +1 роком, якщо додати її протягом високосного року?
tplaner

@evolve, Так, це літературний додає 1 місяць.
shamittomar

13
І якщо ви віднімаєте через 1 місяць після додавання, я припускаю цілком іншу дату, я припускаю. Це здається дуже неінтуїтивним.
Ден Брін

2
Чудовий приклад використання нових строф в PHP 5.3, де ви можете використовувати перший день, останній день, цей місяць, наступний місяць та попередній місяць.
Кім Стек

6
Імхо, це помилка. серйозна помилка. якщо я хочу додати 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
Команда "клон" була вирішенням моїх проблем із призначенням змінних. Дякую за це.
Стеф Роуз

4

Я погоджуюся з думкою ОП про те, що це контрінтуїтивно та розчаровує, але так визначається, що +1 monthозначає у сценаріях, де це відбувається. Розглянемо наступні приклади:

Ви починаєте з 31.01.2015 і хочете додати місяць 6 разів, щоб отримати цикл планування для надсилання електронних листів. Зважаючи на початкові очікування ОП, це повернеться:

  • 2015-01-31
  • 28.02.2015
  • 2015-03-31
  • 2015-04-30
  • 2015-05-31
  • 2015-06-30

Відразу зауважте, що ми очікуємо +1 monthозначати last day of monthабо, альтернативно, додавати 1 місяць за ітерацію, але завжди посилаючись на початкову точку. Замість того, щоб інтерпретувати це як "останній день місяця", ми могли б читати це як "31 день наступного місяця або останній доступний протягом цього місяця". Це означає, що ми стрибаємо з 30 квітня по 31 травня замість 30 травня. Зауважте, що це не тому, що це "останній день місяця", а тому, що ми хочемо "найближчий доступний до дати початку місяця".

Тож припустимо, що один з наших користувачів підписується на інший інформаційний бюлетень, який розпочнеться 30.01.2015. Для чого інтуїтивно зрозуміла дата +1 month? Одне тлумачення було б "30-го числа наступного місяця або найближчого доступного", яке поверне:

  • 30.01.2015
  • 28.02.2015
  • 2015-03-30
  • 2015-04-30
  • 2015-05-30
  • 2015-06-30

Це було б добре, за винятком випадків, коли наш користувач отримує обидва інформаційні бюлетені в один і той же день. Припустимо, що це питання щодо пропозиції, а не з боку попиту. Ми не переживаємо, що користувач буде роздратований тим, що отримає 2 бюлетені в той же день, але натомість наші поштові сервери не можуть дозволити собі пропускну здатність для надсилання вдвічі багато інформаційних бюлетенів. Зважаючи на це, ми повертаємось до іншої інтерпретації "+1 місяць" як "надіслати другий до останнього дня кожного місяця", яка б повернула:

  • 30.01.2015
  • 27.02.2015
  • 2015-03-30
  • 2015-04-29
  • 2015-05-30
  • 29.06.2015

Зараз ми уникнули будь-якого перекриття з першим набором, але також закінчуємо квітнем та 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
  • 01.07.2015

30 січня

  • 30.01.2015
  • 02.03.2015
  • 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
  • 28.02.2015
  • 2015-03-31
  • 2015-04-28
  • 2015-05-31
  • 28.06.2015

30 січня

  • 30.01.2015
  • 27.02.2015
  • 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;
}

Дуже дякую!!
гекони

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

Мені потрібно було побачити дату "цього минулого місяця", і це стає неприємно досить швидко, коли цей місяць є лютим високосним. Однак я вважаю, що це працює ...: - / Схоже, хитрість полягає в тому, щоб змінити свої зміни в перший день місяця.

$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

Дякую! Це найновіша відповідь поки. Якщо ви переключите останні два рядки, то це завжди дає правильний місяць. Кудо!
Денні Шоман

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()));

якщо ви хочете скористатися класом дати, це теж добре, але це так само просто. Більше деталей тут


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.