diff --git a/src/Form/Extension/DateTimeModelTimezoneExtension.php b/src/Form/Extension/DateTimeModelTimezoneExtension.php new file mode 100644 index 00000000..3c4818ea --- /dev/null +++ b/src/Form/Extension/DateTimeModelTimezoneExtension.php @@ -0,0 +1,79 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\Extension; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; + +/** + * Catches timezone mismatches between a DateTimeInterface model value and the effective + * model_timezone configured on the field. + * + * Doctrine's UTCDateTimeImmutableType always returns UTC DateTimeImmutable objects, so any + * date/datetime field that omits `model_timezone: 'UTC'` will silently corrupt stored values + * (the transformer treats the UTC instant as if it were in the user's local timezone). + * This extension throws a \LogicException early so the mistake is caught at development time. + */ +class DateTimeModelTimezoneExtension extends AbstractTypeExtension +{ + public static function getExtendedTypes(): iterable + { + return [DateTimeType::class, DateType::class]; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event) use ($options): void { + $data = $event->getData(); + + if (!$data instanceof \DateTimeInterface) { + return; + } + + // Resolve the effective model timezone: explicit option or the PHP default set at build time. + // This mirrors what BaseDateTimeTransformer does in its constructor. + $modelTimezone = $options['model_timezone'] ?? date_default_timezone_get(); + + $dataOffset = $data->getTimezone()->getOffset($data); + $modelOffset = (new \DateTimeZone($modelTimezone))->getOffset($data); + + if ($dataOffset !== $modelOffset) { + throw new \LogicException(sprintf( + 'Form field "%s" received a %s with timezone "%s" (UTC offset %+d s), ' + . 'but the effective model_timezone is "%s" (UTC offset %+d s). ' + . 'Set the "model_timezone" option to match the timezone of your data source.', + $event->getForm()->getName(), + get_debug_type($data), + $data->getTimezone()->getName(), + $dataOffset, + $modelTimezone, + $modelOffset + )); + } + }); + } +} diff --git a/src/Form/Part/PartLotType.php b/src/Form/Part/PartLotType.php index fc330bb1..ef49c57e 100644 --- a/src/Form/Part/PartLotType.php +++ b/src/Form/Part/PartLotType.php @@ -115,8 +115,10 @@ class PartLotType extends AbstractType $builder->add('last_stocktake_at', DateTimeType::class, [ 'label' => 'part_lot.edit.last_stocktake_at', 'widget' => 'single_text', + 'model_timezone' => 'UTC', // The database stores the datetime in UTC, so we need to set the model timezone to UTC 'disabled' => !$this->security->isGranted('@parts_stock.stocktake'), 'required' => false, + 'with_seconds' => true, ]); }