PHP DateTime :: modifica aggiungendo e sottraendo mesi


101

Ho lavorato molto con il DateTime classe recentemente mi sono imbattuto in quello che pensavo fosse un bug quando ho aggiunto mesi. Dopo un po 'di ricerca, sembra che non si trattasse di un bug, ma invece funzionasse come previsto. Secondo la documentazione trovata qui :

Esempio # 2 Fai attenzione quando aggiungi o sottrai mesi

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

Qualcuno può giustificare il motivo per cui questo non è considerato un bug?

Inoltre qualcuno ha soluzioni eleganti per correggere il problema e fare in modo che +1 mese funzioni come previsto invece che come previsto?


Cosa ti aspetti da "2001-01-31" più 1 mese? ... "2001-02-28"? "2001/03/01"?
Artefacto

57
Personalmente mi aspetto che sia il 2001-02-28.
tplaner


2
Sì, è piuttosto una stranezza fastidiosa. Hai letto le scritte in piccolo per capire che P1M è di 31 giorni. Non capisco davvero perché le persone continuano a difenderlo come un comportamento "giusto".
Indivision Dev

Sembra che l'opinione popolare sia che la logica dovrebbe arrotondare per difetto (a 2/28), sebbene PHP arrotondi per eccesso (a 3/1) ... anche se preferisco il modo di PHP, ma Microsoft Excel arrotonda per difetto, contrapponendo gli sviluppatori web agli utenti di fogli di calcolo ...
Dave Heq

Risposte:


106

Perché non è un bug:

Il comportamento attuale è corretto. Quanto segue avviene internamente:

  1. +1 monthaumenta il numero del mese (originariamente 1) di uno. Questo fa la data 2010-02-31.

  2. Il secondo mese (febbraio) ha solo 28 giorni nel 2010, quindi PHP lo corregge automaticamente continuando a contare i giorni dal 1 ° febbraio. Quindi finisci al 3 marzo.

Come ottenere quello che vuoi:

Per ottenere quello che vuoi è: controllare manualmente il mese successivo. Quindi aggiungi il numero di giorni del prossimo mese.

Spero che tu possa codificarlo da solo. Sto solo dando cosa fare.

Modalità PHP 5.3:

Per ottenere il comportamento corretto, è possibile utilizzare una delle nuove funzionalità di PHP 5.3 che introduce la stanza relativa al tempo first day of. Questa stanza può essere utilizzato in combinazione con next month, fifth montho +8 monthsper andare al primo giorno del mese specificato. Invece di +1 monthquello che stai facendo, puoi usare questo codice per ottenere il primo giorno del mese successivo in questo modo:

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

Questo script verrà prodotto correttamente February. Le seguenti cose accadono quando PHP elabora questa first day of next monthstanza:

  1. next monthaumenta il numero del mese (originariamente 1) di uno. Questo fa la data 2010-02-31.

  2. first day ofimposta il numero del giorno su 1, risultando nella data 2010-02-01.


1
Quindi quello che stai dicendo è che aggiunge letteralmente 1 mese, ignorando completamente i giorni? Quindi presumo che potresti riscontrare un problema simile con +1 anno se lo aggiungi durante un anno bisestile?
tplaner

@evolve, Sì, letterario aggiunge 1 mese.
shamittomar

13
E se sottrai 1 mese dopo averlo aggiunto, finisci con una data completamente diversa, presumo. Sembra molto poco intuitivo.
Dan Breen

2
Fantastico esempio sull'utilizzo delle nuove stanze in PHP 5.3 in cui è possibile utilizzare il primo giorno, l'ultimo giorno, questo mese, il mese successivo e il mese precedente.
Kim Stacks

6
imho questo è un bug. un bug serio. se voglio aggiungere 31 giorni, aggiungo 31 giorni. Voglio aggiungere un mese, un mese dovrebbe essere aggiunto, non 31 giorni.
low_rents

12

Ecco un'altra soluzione compatta che utilizza interamente i metodi DateTime, modificando l'oggetto sul posto senza creare cloni.

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

Emette:

2012-01-31
2012-02-29

1
Grazie. La migliore soluzione fornita qui finora. Puoi anche abbreviare il codice in $dt->modify()->modify(). Funziona altrettanto bene.
Alph.Dev

10

Questo può essere 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

2
Non è una soluzione generale, poiché funziona solo per determinati input, come il 1 ° del mese. Ad esempio, farlo per il 30 gennaio porta alla sofferenza.
Jens Roland

Oppure potresti fare$dateTime->modify('first day of next month')->modify('-1day')
Anthony

6

La mia soluzione al problema:

$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
Il comando "clone" era la soluzione ai miei problemi di assegnazione delle variabili. Grazie per questo.
Steph Rose

4

Sono d'accordo con il parere dell'OP che questo è controintuitivo e frustrante, ma lo è anche determinare cosa +1 monthsignifica negli scenari in cui ciò si verifica. Considera questi esempi:

Inizi con 2015-01-31 e desideri aggiungere un mese 6 volte per ottenere un ciclo di pianificazione per l'invio di una newsletter via email. Tenendo a mente le aspettative iniziali del PO, questo ritornerebbe:

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

Notare subito che ci aspettiamo +1 monthdi significare last day of montho, in alternativa, di aggiungere 1 mese per iterazione ma sempre in riferimento al punto di partenza. Invece di interpretarlo come "ultimo giorno del mese", potremmo leggerlo come "31esimo giorno del mese successivo o ultimo disponibile entro quel mese". Ciò significa che passiamo dal 30 aprile al 31 maggio invece che al 30 maggio. Tieni presente che questo non è perché è "l'ultimo giorno del mese" ma perché vogliamo "la disponibilità più vicina alla data del mese di inizio".

Supponiamo quindi che uno dei nostri utenti si iscriva a un'altra newsletter per iniziare il 30/01/2015. A cosa serve la data intuitiva +1 month? Un'interpretazione potrebbe essere "30 ° giorno del mese successivo o più vicino disponibile" che restituirebbe:

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

Questo andrebbe bene tranne quando il nostro utente riceve entrambe le newsletter lo stesso giorno. Supponiamo che questo sia un problema dal lato dell'offerta anziché dal lato della domanda Non siamo preoccupati che l'utente sarà infastidito dal ricevere 2 newsletter nello stesso giorno, ma invece che i nostri server di posta non possono permettersi la larghezza di banda per inviare molte newsletter. Con questo in mente, torniamo all'altra interpretazione di "+1 mese" come "invio dal penultimo giorno di ogni mese" che restituirebbe:

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

Ora abbiamo evitato qualsiasi sovrapposizione con il primo set, ma ci ritroviamo anche con aprile e 29 giugno, che sicuramente corrispondono alle nostre intuizioni originali che +1 monthsemplicemente dovrebbero tornare m/$d/Yo attraenti e semplici m/30/Yper tutti i mesi possibili. Quindi ora consideriamo una terza interpretazione +1 monthdell'uso di entrambe le date:

31 gennaio

  • 2015/01/31
  • 2015/03/03
  • 2015/03/31
  • 2015/05/01
  • 2015/05/31
  • 2015/07/01

30 gennaio

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

Quanto sopra ha alcuni problemi. Febbraio viene saltato, il che potrebbe essere un problema sia alla fine dell'offerta (diciamo se c'è un'allocazione mensile della larghezza di banda e febbraio va sprecato e marzo viene raddoppiato) che alla fine della domanda (gli utenti si sentono ingannati da febbraio e percepiscono il marzo in più come tentativo di correggere l'errore). D'altra parte, nota che i due set di date:

  • non si sovrappongono mai
  • sono sempre nella stessa data in cui quel mese ha la data (quindi il set del 30 gennaio sembra abbastanza pulito)
  • sono tutti entro 3 giorni (1 giorno nella maggior parte dei casi) da quella che potrebbe essere considerata la data "corretta".
  • sono tutti ad almeno 28 giorni (un mese lunare) dal loro successore e predecessore, quindi distribuiti in modo molto uniforme.

Date le ultime due serie, non sarebbe difficile ripristinare semplicemente una delle date se cade al di fuori del mese successivo effettivo (quindi tornare al 28 febbraio e al 30 aprile nella prima serie) e non perdere il sonno durante il sovrapposizione occasionale e divergenza dal modello "ultimo giorno del mese" rispetto al modello "penultimo giorno del mese". Ma aspettarsi che la biblioteca scelga tra "molto carino / naturale", "interpretazione matematica del 31/02 e altri mesi in eccesso" e "relativo al primo o all'ultimo mese" finirà sempre con le aspettative di qualcuno che non vengono soddisfatte e un programma che necessita di aggiustare la data "sbagliata" per evitare il problema del mondo reale che l'interpretazione "sbagliata" introduce.

Quindi di nuovo, anche se mi aspetterei +1 monthdi restituire una data che in realtà è nel mese successivo, non è così semplice come intuizione e date le scelte, andare con la matematica oltre le aspettative degli sviluppatori web è probabilmente la scelta sicura.

Ecco una soluzione alternativa che è ancora goffa come qualsiasi altra, ma penso che abbia buoni risultati:

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

Non è ottimale, ma la logica sottostante è: se l'aggiunta di 1 mese risulta in una data diversa dal mese successivo previsto, elimina quella data e aggiungi invece 4 settimane. Ecco i risultati con le due date dei test:

31 gennaio

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

30 gennaio

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

(Il mio codice è un disastro e non funzionerebbe in uno scenario pluriennale. Accolgo con favore chiunque riscriva la soluzione con un codice più elegante purché la premessa sottostante sia mantenuta intatta, cioè se +1 mese restituisce una data funky, usa +4 settimane invece.)


4

Ho creato una funzione che restituisce un DateInterval per assicurarmi che l'aggiunta di un mese mostri il mese successivo e rimuova i giorni in quello successivo.

$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

In concomitanza con la risposta di shamittomar, potrebbe essere questa per aggiungere mesi "in sicurezza":

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

Grazie mille!!
gechi il

2

Ho trovato un modo più breve per aggirarlo usando il seguente codice:

                   $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

Ecco un'implementazione di una versione migliorata della risposta di Juhana in una domanda correlata:

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

Questa versione $createdDatepresume che tu abbia a che fare con un periodo mensile ricorrente, come un abbonamento, che è iniziato in una data specifica, come il 31. Ci vuole sempre $createdDatecosì tardi le date "ricorre in" non si spostano a valori più bassi mentre vengono spinte in avanti attraverso mesi di minor valore (ad esempio, così tutte le date ricorrenti del 29 °, 30 ° o 31 ° alla fine non si bloccheranno il 28 dopo il passaggio durante un febbraio non bisestile).

Ecco un po 'di codice del driver per testare l'algoritmo:

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

Quali uscite:

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

Questa è una versione migliorata della risposta di Kasihasi in una domanda correlata. Ciò aggiungerà o sottrarrà correttamente un numero arbitrario di mesi a una data.

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

Per esempio:

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

produrrà:

'2015-02-28'

0

Se vuoi solo evitare di saltare un mese puoi eseguire qualcosa del genere per ottenere la data ed eseguire un ciclo il mese successivo riducendo la data di uno e ricontrollando fino a una data valida in cui $ starting_calculated è una stringa valida per strtotime (cioè mysql datetime o "now"). Questo trova la fine del mese a 1 minuto a mezzanotte invece di saltare il mese.

    $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

Se usi strtotime()solo usa$date = strtotime('first day of +1 month');


0

Avevo bisogno di una data per "questo mese dell'anno scorso" e diventa spiacevole abbastanza rapidamente quando questo mese è febbraio in un anno bisestile. Tuttavia, credo che funzioni ...: - / Il trucco sembra essere quello di basare la modifica sul primo giorno del mese.

$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
Devi spiegare la tua risposta. Le risposte solo in codice sono considerate di bassa qualità
Machavity

Grazie! Questa è ancora la risposta più chiara. Se si cambiano le ultime 2 righe, viene sempre visualizzato il mese corretto. Complimenti!
Danny Schoemann

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

uscirà 2(febbraio). funzionerà anche per altri mesi.


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

Per giorni:

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

Importante:

Il metodo add()della classe DateTime modifica il valore dell'oggetto, quindi dopo aver chiamato add()un oggetto DateTime restituisce il nuovo oggetto data e modifica anche l'oggetto stesso.


0

in realtà puoi farlo anche solo con date () e strtotime (). Ad esempio, per aggiungere 1 mese alla data odierna:

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

se vuoi usare la classe datetime va bene anche questo, ma è altrettanto facile. maggiori dettagli qui


0

La risposta accettata spiega già perché questo non è un ma, e alcune altre risposte rappresentano una soluzione accurata con espressioni php come first day of the +2 months. Il problema con queste espressioni è che non vengono completate automaticamente.

La soluzione è abbastanza semplice però. Innanzitutto, dovresti trovare astrazioni utili che riflettono lo spazio del tuo problema. In questo caso, è un file ISO8601DateTime. In secondo luogo, dovrebbero esserci più implementazioni che possono portare una rappresentazione testuale desiderata. Ad esempio, Today, Tomorrow, The first day of this month, Future- tutti rappresentano una specifica implementazione del ISO8601DateTimeconcetto.

Quindi, nel tuo caso, un'implementazione di cui hai bisogno è TheFirstDayOfNMonthsLater. È facile da trovare semplicemente guardando le sottoclassi llist in qualsiasi IDE. Ecco il codice:

$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

La stessa cosa con gli ultimi giorni di un mese. Per altri esempi di questo approccio, leggi questo .


Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.