web-dev-qa-db-fra.com

PHP DateTime :: modifier les mois d'addition et de soustraction

J'ai beaucoup travaillé avec le DateTime class et j'ai récemment rencontré ce que je pensais être un bogue lors de l'ajout de mois. Après quelques recherches, il apparaît que ce n’est pas un bug, mais fonctionne comme prévu. Selon la documentation trouvée ici :

Exemple n ° 2 Attention en ajoutant ou soustraction mois

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

Quelqu'un peut-il justifier pourquoi ceci n'est pas considéré comme un bug?

De plus, est-ce que quelqu'un a des solutions élégantes pour corriger le problème et le rendre ainsi +1 mois fonctionneront comme prévu plutôt que comme prévu?

86
tplaner

Pourquoi ce n'est pas un bug:

Le comportement actuel est correct. Ce qui suit se produit en interne:

  1. +1 month augmente le nombre de mois (1 à l'origine) de un. Cela rend la date 2010-02-31.

  2. Le deuxième mois (février) ne compte que 28 jours en 2010, donc PHP corrige automatiquement cela en continuant de compter les jours à partir du 1er février. Vous vous retrouvez ensuite le 3 mars.

Comment obtenir ce que vous voulez:

Pour obtenir ce que vous voulez, vous devez: vérifier manuellement le mois suivant. Ajoutez ensuite le nombre de jours du mois prochain.

J'espère que vous pouvez coder vous-même ceci. Je ne fais que donner quoi faire.

Manière PHP 5.3:

Pour obtenir le comportement correct, vous pouvez utiliser l'une des nouvelles fonctionnalités de PHP 5.3 introduisant la strophe d'heure relative first day of. Cette strophe peut être utilisée en combinaison avec next month, fifth month ou +8 months pour aller au premier jour du mois spécifié. Au lieu de +1 month de ce que vous faites, vous pouvez utiliser ce code pour obtenir le premier jour du mois prochain comme ceci:

<?php
$d = new DateTime( '2010-01-31' );
$d->modify( 'first day of next month' );
echo $d->format( 'F' ), "\n";
?>

Ce script produira correctement February. Les choses suivantes se produisent lorsque PHP traite cette strophe first day of next month:

  1. next month augmente le nombre de mois (1 à l'origine) de un. Cela rend la date 2010-02-31.

  2. first day of définit le numéro du jour sur 1, ce qui donne la date le 2010-02-01.

94
shamittomar

Cela peut être utile:

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

Ma solution au problème:

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

Voici une autre solution compacte utilisant entièrement les méthodes DateTime, modifiant l'objet en place sans créer de clonage.

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

Il produit:

2012-01-31
2012-02-29
4
Rudiger W.

En conjonction avec la réponse de shamittomar, il pourrait alors s'agir d'ajouter des mois "en toute sécurité"

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

J'ai créé une fonction qui renvoie un DateInterval pour vérifier que l'ajout d'un mois indique le mois suivant et supprime les jours suivants.

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

Je suis d'accord avec le sentiment de l'OP que cela est contre-intuitif et frustrant, mais il en va de même de déterminer ce que +1 month signifie dans les scénarios où cela se produit. Considérez ces exemples:

Vous commencez le 2015-01-31 et souhaitez ajouter un mois 6 fois pour obtenir un cycle de planification pour l'envoi d'un bulletin d'information par courrier électronique. Avec les attentes initiales du PO en tête, cela reviendrait:

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

Tout de suite, notez que nous nous attendons à ce que +1 month signifie last day of month ou, alternativement, nous ajoutions 1 mois par itération, mais toujours en référence au point de départ. Au lieu de l'interpréter comme "dernier jour du mois", nous pourrions le lire comme "31ème jour du mois suivant ou dernier jour disponible dans ce mois". Cela signifie que nous sautons du 30 avril au 31 mai au lieu du 30 mai. Notez que ce n'est pas parce que c'est le "dernier jour du mois" mais parce que nous voulons "la date la plus proche de la date du début du mois".

Supposons donc qu'un de nos utilisateurs s'abonne à une autre lettre d'information pour commencer le 30/01/2015. Quelle est la date intuitive pour +1 month? Une interprétation serait "30ème jour du mois prochain ou le plus proche disponible" qui renverrait:

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

Ce serait bien sauf si notre utilisateur reçoit les deux bulletins le même jour. Supposons qu'il s'agisse d'un problème d'offre plutôt que de demande. Nous ne craignons pas que l'utilisateur soit ennuyé de recevoir 2 newsletters le même jour, mais plutôt que nos serveurs de messagerie ne peuvent pas se permettre la bande passante nécessaire pour envoyer deux fois plus de messages. beaucoup de lettres d'information. Gardant cela à l'esprit, nous revenons à l'autre interprétation de "+1 mois" comme "envoyer l'avant-dernier jour de chaque mois" qui renverrait:

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

Nous avons maintenant évité tout chevauchement avec le premier ensemble, mais nous nous retrouvons également entre avril et le 29 juin, ce qui correspond certainement à nos intuitions originales selon lesquelles +1 month devrait simplement renvoyer m/$d/Y ou le m/30/Y simple et attrayant pour tous les mois possibles. Alors maintenant, considérons une troisième interprétation de +1 month utilisant les deux dates:

31 janvier

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

30 janvier

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

Ce qui précède a quelques problèmes. Février est ignoré, ce qui pourrait poser problème à la fin de l'offre (par exemple, s'il y a une allocation de bande passante mensuelle et que février est gaspillé et que l'on double le mois de mars) et à la fin de la demande (les utilisateurs se sentent trompés à partir de février et perçoivent le mois de mars supplémentaire comme tentative de corriger l'erreur). D'autre part, notez que les deux ensembles de dates:

  • ne se chevauchent jamais
  • sont toujours à la même date quand ce mois a la date (donc le set du 30 janvier a l'air bien net)
  • sont tous dans les 3 jours (1 jour dans la plupart des cas) de ce qui pourrait être considéré comme la date "correcte".
  • sont tous à au moins 28 jours (un mois lunaire) de leur successeur et de leur prédécesseur, répartis de manière très homogène.

Compte tenu des deux dernières séries, il ne serait pas difficile d'annuler simplement l'une des dates si elle tombe en dehors du mois suivant (il faut donc revenir au 28 février et au 30 avril de la première série) et ne pas perdre de sommeil au cours de la première. chevauchement et divergence occasionnels par rapport au "dernier jour du mois" par rapport à la configuration "avant-dernier jour du mois". Mais s’attendre à ce que la bibliothèque choisisse entre "plus jolie/naturelle", "une interprétation mathématique du 02/31 et des autres débordements mensuels", et "par rapport au premier du mois ou au mois dernier" finira toujours par le non-respect des attentes de quelqu'un et certains programmes doivent ajuster la "mauvaise" date pour éviter le problème du monde réel que la "mauvaise" interprétation introduit.

Donc, encore une fois, alors que je m'attendrais aussi à ce que +1 month renvoie une date qui sera en réalité le mois suivant, ce n’est pas aussi simple que l’intuition et étant donné les choix à faire, aller en mathématiques sur les attentes des développeurs Web est probablement le choix sûr.

Voici une solution alternative qui est toujours aussi maladroite que tout autre mais je pense avoir de bons résultats:

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

Ce n'est pas optimal, mais la logique sous-jacente est la suivante: si l'ajout d'un mois donne une date autre que celle attendue le mois prochain, supprimez cette date et ajoutez 4 semaines à la place. Voici les résultats avec les deux dates de test:

31 janvier

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

30 janvier

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

(Mon code est en désordre et ne fonctionnerait pas dans un scénario pluriannuel. J'invite tout le monde à réécrire la solution avec un code plus élégant tant que le principe sous-jacent reste intact, c'est-à-dire si +1 mois renvoie une date funky, utilisez +4 semaines à la place.)

2
Anthony

J'ai trouvé un moyen plus court de le contourner en utilisant le code suivant:

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

Voici une implémentation d'une version améliorée de la réponse de Juhana dans une question connexe:

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

Cette version utilise $createdDate sous la présomption que vous traitez avec une période mensuelle récurrente, telle qu'un abonnement, qui a débuté à une date spécifique, telle que la 31e. Il faut toujours $createdDate pour que les dates "récurrentes" tardives ne passent pas à des valeurs plus basses car elles sont avancées au cours de mois moins valorisés (par exemple, pour que les 29e, 30e ou 31e dates ne soient pas automatiquement bloquées le passant par une année non bissextile en février).

Voici un code de pilote pour tester l'algorithme:

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

Quelles sorties:

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
derekm

Ceci est une version améliorée de la réponse de Kasihasi dans une question connexe. Ceci ajoutera ou soustrayera correctement un nombre arbitraire de mois à une date.

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

Par exemple:

addMonths(-25, '2017-03-31')

affichera:

'2015-02-28'
1
Hải Phong

J'avais besoin d'une date pour 'ce mois de l'année dernière' et cela devient désagréable assez rapidement lorsque ce mois est en février, année bissextile. Cependant, je crois que cela fonctionne ...: -/Le truc semble être de baser votre monnaie le 1er jour du mois.

$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
Just Plain High
$current_date = new DateTime('now');
$after_3_months = $current_date->add(\DateInterval::createFromDateString('+3 months'));

Pendant des jours: 

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

Important:

La méthode add() de la classe DateTime modifie la valeur de l'objet. Ainsi, après avoir appelé add() sur un objet DateTime, il renvoie le nouvel objet de date et le modifie également.

0
MiharbKH

Si vous utilisez strtotime(), utilisez simplement $date = strtotime('first day of +1 month');

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

affichera 2 (février). travaillera pour les autres mois aussi.

0
galki

Extension pour la classe DateTime qui résout le problème de l'ajout ou de la soustraction de mois

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

0
66Ton99

Si vous voulez juste éviter de sauter un mois, vous pouvez utiliser quelque chose comme ceci pour obtenir la date et exécuter une boucle le mois suivant en réduisant la date d'un jour et en revérifiant jusqu'à une date valide où $ starting_calculated est une chaîne valide pour strtotime (c'est-à-dire mysql datetime ou "maintenant"). Ceci trouve la toute fin du mois à 1 minute à minuit au lieu de sauter le mois.

    $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
user1590391

vous pouvez également le faire avec simplement date () et strtotime (). Par exemple, pour ajouter 1 mois à la date du jour:

date ("Y-m-d", strtotime ("+ 1 mois", heure ()));

si vous voulez utiliser la classe datetime, c'est bien aussi, mais c'est tout aussi facile. plus de détails ici

0
PHP Addict