به محتوای اصلی

تاریخ شمسی در دیتابیس و API؛ روش درست ذخیره و نمایش

تاریخ را شمسی ذخیره نکنید — ولی نه به دلیلی که همه می‌گویند. چهار جایی که واقعاً می‌شکند، ذخیره‌ی ISO، نمایش جلالی بدون کتابخانه و تله‌ی ساعت ایران.

عاطفه امیری۶ دقیقه مطالعه
در این مطلب

تاریخ را در دیتابیس شمسی ذخیره نکنید. شکل درست این است: مقدار به‌صورت ISO 8601 و در منطقه‌ی زمانی UTC ذخیره شود و تبدیل به تقویم جلالی فقط در لحظه‌ی نمایش انجام شود — کاری که مرورگر و موبایل با یک خط کد و بدون هیچ کتابخانه‌ای انجام می‌دهند. تا این‌جا همان چیزی است که در نتایج فارسی هم می‌خوانید؛ ولی دلیلی که آن‌جا می‌آورند («مرتب‌سازی به هم می‌ریزد») برای رشته‌ی صفرپرشده غلط است، و همین باعث می‌شود توصیه قانع‌کننده نباشد. این مقاله چهار دلیل واقعی را می‌گوید، بعد کد ذخیره، نمایش، فیلتر و گزارش را نشان می‌دهد.

چرا «مرتب‌سازی» دلیل خوبی نیست؟

اول این را از سر راه برداریم، چون تقریباً همه‌ی جواب‌های فارسی روی آن ایستاده‌اند. 1405/06/23 و 1405/07/01 هر دو صفرپرشده‌اند و طولشان یکی است، پس مقایسه‌ی متنیشان دقیقاً همان ترتیب زمانی را می‌دهد. تاریخ شمسیِ مرتب‌نوشته‌شده درست مرتب می‌شود.

این نکته را برای دقت نمی‌گویم؛ برای این می‌گویم که اگر تنها استدلالتان این باشد، اولین کسی که امتحان می‌کند می‌بیند کار می‌کند و بقیه‌ی توصیه را هم کنار می‌گذارد. دلیل‌های واقعی جای دیگری‌اند.

ذخیره‌ی شمسی دقیقاً کجا می‌شکند؟

۱. مقایسه با «حالا» و حساب بازه. «سفارش‌های هفت روز اخیر» یعنی مقایسه‌ی ستون با زمان حال منهای هفت روز. دیتابیس زمان حال را میلادی می‌دهد و رشته‌ی 1405/06/23 را نمی‌شناسد. برای هر گزارش ساده باید تبدیل را داخل کوئری بیاورید، که هم کند است و هم ایندکس را از کار می‌اندازد.

۲. گزارش و تجمیع. «درآمد ماه‌به‌ماه» با date_trunc گرفته می‌شود و date_trunc یک timestamp واقعی می‌خواهد. رشته‌ی شمسی در این تبدیل خطا می‌دهد، پس دقیقاً همان صفحه‌ای که مدیر محصول می‌خواهد از کار می‌افتد.

۳. ناهمگونی فرمت، که اجتناب‌ناپذیر است. همان استدلال بالا فقط تا وقتی درست است که همه یکسان بنویسند. ولی یک کلاینت 1405/6/3 می‌فرستد، یکی 1405-06-03، و یکی ارقام فارسی ۱۴۰۵/۰۶/۰۳ که بایت‌هایش اصلاً عدد نیستند. هیچ اعتبارسنجی‌ای هم جلویشان را نمی‌گیرد، چون از نظر دیتابیس همه فقط «متن»اند. با ISO این اتفاق نمی‌افتد: یک فرمت بیشتر وجود ندارد و ناساز بودنش بلافاصله پیداست.

۴. هر سرویس دیگری ISO حرف می‌زند. درگاه پرداخت، سرویس پیامک، وبهوک طرف مقابل و هر SDK موبایلی تاریخ را ISO می‌دهند و ISO می‌خواهند. اگر داخل سیستم شمسی نگه دارید، در هر مرز باید تبدیل کنید — و تبدیل در مرز، جایی است که باگ‌های تاریخ متولد می‌شوند.

هیچ‌کدام از این چهار مورد روز اول خودشان را نشان نمی‌دهند. اپ با ده رکورد درست کار می‌کند؛ مشکل روزی پیدا می‌شود که اولین گزارش خواسته شود یا اولین کلاینت دوم به API وصل شود — یعنی دقیقاً وقتی مهاجرت گران شده است.

پس ساختار درست چیست؟

دو نوع تاریخ داریم و یکی‌گرفتنشان شایع‌ترین باگ این حوزه است:

چه چیزینوع درستنمونه
تاریخ تولد، تاریخ سررسید، روز تعطیلdate‎1991-03-21‎
زمان ثبت سفارش، زمان ورود، زمان رویدادdatetime‎2026-09-14T08:30:00Z‎

تاریخ تولد یک روز تقویمی است و منطقه‌ی زمانی ندارد؛ زمان ثبت سفارش یک لحظه است و بدون منطقه‌ی زمانی بی‌معناست. در فیکارو همین دو حالت‌اند: فیلد از نوع date با فرمت date فقط YYYY-MM-DD می‌پذیرد و با فرمت datetime مقدار کامل RFC 3339. هر چیز دیگری — از جمله رشته‌ی شمسی — با خطای invalid_format و پیام «باید تاریخ معتبر (ISO 8601) باشد» رد می‌شود، نه اینکه بی‌صدا به‌عنوان متن ذخیره شود.

اگر تاریخ تولد را datetime تعریف کنید، خطای یک‌روزه می‌گیرید. نیمه‌شبِ تهران در UTC می‌شود ساعت ۲۰:۳۰ روز قبل؛ هر کلاینتی که بدون تعیین منطقه‌ی زمانی نمایش دهد، یک روز عقب‌تر نشان می‌دهد. روز تقویمی را date بگذارید تا اصلاً ساعتی وجود نداشته باشد که جابه‌جا شود.

نمایش شمسی، بدون هیچ کتابخانه‌ای

این بخش را معمولاً با نصب یک پکیج جواب می‌دهند، در حالی که لازم نیست:

const iso = "2026-09-14T08:30:00Z";
const fmt = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
  dateStyle: "medium",
  timeStyle: "short",
  timeZone: "Asia/Tehran",
});
fmt.format(new Date(iso));

سه نکته که این چند خط را از کار نمی‌اندازند ولی اغلب جا می‌مانند. u-ca-persian را صریح بنویسید: fa-IR به‌صورت پیش‌فرض هم تقویم جلالی می‌دهد، ولی در محیط‌هایی مثل Node با ICU کوچک تضمینی نیست. timeZone را هم صریح بدهید، وگرنه منطقه‌ی زمانی دستگاه استفاده می‌شود و برای کاربری که خارج از ایران است یا برای رندر سمت سرور، تاریخ اشتباه درمی‌آید. و اگر ارقام لاتین می‌خواهید — مثلاً در فاکتوری که قرار است کپی شود — لوکال را fa-IR-u-ca-persian-nu-latn بنویسید.

تله‌ای که از شهریور ۱۴۰۱ ساخته شد

ایران از ۳۰ شهریور ۱۴۰۱ (۲۱ سپتامبر ۲۰۲۲) ساعت تابستانی را حذف کرده و تمام سال روی +03:30 است. یعنی کدی که هنوز تفاوت تابستان و زمستان را حساب می‌کند، یا جایی +04:30 را دستی نوشته، شش ماه از سال یک ساعت خطا دارد.

راه امن، هرگز ننوشتن خودِ اختلاف است: همیشه نام منطقه — Asia/Tehran — را بدهید و بگذارید کتابخانه‌ی زمان تصمیم بگیرد. اگر روزی قانون دوباره عوض شود، به‌روزرسانی پایگاه‌داده‌ی مناطق زمانی کافی است و کد شما دست‌نخورده می‌ماند.

فیلتر، مرتب‌سازی و گزارش روی تاریخ

وقتی ISO ذخیره شده باشد، این‌ها همان‌طور کار می‌کنند که انتظار دارید:

curl -G "https://api.fikaro.ir/my-shop/v1/order" \
  --data-urlencode "filter[created_at][gte]=2026-09-01T00:00:00Z" \
  --data-urlencode "sort=-created_at" \
  -H "Authorization: Bearer apck_..."

در فیکارو هر رکورد دو مُهر زمانی سیستمی دارد — createdAt و updatedAt — که ستون واقعی timestamp هستند، نه متن داخل داده. برای مرتب‌سازی هر دو املا پذیرفته می‌شوند، هم createdAt و هم created_at، چون همان چیزی که در پاسخ می‌بینید باید در درخواست هم کار کند. گزارش ماهانه هم یک پارامتر است:

curl -G "https://api.fikaro.ir/my-shop/v1/order/aggregate" \
  --data-urlencode "group_by=created_at:month" \
  --data-urlencode "sum=total" \
  -H "Authorization: Bearer apck_..."

یک تفاوت تقویمی که باید بدانید: سطل‌های زمانی day و week و month و year را PostgreSQL می‌سازد، و هفته‌ی PostgreSQL دوشنبه تا یکشنبه است نه شنبه تا جمعه. برای گزارش روزانه و ماهانه فرقی نمی‌کند؛ فقط اگر گزارش هفتگی به تقویم کاری ایران می‌خواهید، روی day گروه‌بندی کنید و سطل‌های شنبه‌محور را سمت خودتان بسازید.

کاربر شمسی وارد می‌کند — تبدیل کجا انجام شود؟

در مرز ورودی، پیش از فرستادن درخواست. تاریخ‌گیر شمسی روی فرم بنشیند، مقدارش همان‌جا به ISO تبدیل شود، و از آن نقطه به بعد در کل سیستم فقط ISO جابه‌جا شود. قرینه‌ی همین کار در خروجی است: پاسخ ISO می‌آید و لایه‌ی نمایش شمسی‌اش می‌کند.

اهمیتش این است که تبدیل دو جای مشخص و قابل تست داشته باشد، نه اینکه در ده فایل پخش باشد. هر تبدیل اضافه‌ای در وسط زنجیره، یک فرصت دیگر برای خطای یک‌روزه است.

اگر با متن فارسی هم همین دردسر را دارید، جست‌وجوی فارسی در دیتابیس همین شکل مسئله را برای ی، ک و نیم‌فاصله باز می‌کند. اگر تاریخ برایتان هسته‌ی محصول است — رزرو و نوبت — سیستم نوبت‌دهی آنلاین مدل داده‌اش را می‌سازد. فهرست کامل پارامترهای فیلتر و گزارش هم در کار با API آمده است.

سوالات متداول

تاریخ شمسی را در دیتابیس ذخیره کنم یا میلادی؟
میلادی، به فرمت ISO 8601 و در منطقه‌ی زمانی UTC. تبدیل به تقویم جلالی باید در لحظه‌ی نمایش انجام شود. دلیلش مرتب‌سازی نیست — رشته‌ی شمسی صفرپرشده درست مرتب می‌شود — بلکه چهار چیز دیگر است: مقایسه با زمان حال، تجمیع و گزارش با date_trunc، ناهمگونی فرمتی که کلاینت‌های مختلف می‌سازند، و اینکه هر سرویس بیرونی ISO حرف می‌زند.
برای نمایش تاریخ شمسی حتماً باید کتابخانه نصب کنم؟
نه. Intl.DateTimeFormat با لوکال fa-IR-u-ca-persian در مرورگر، Node و موبایل تقویم جلالی می‌دهد و بخشی از خود زبان است. فقط timeZone را صریح روی Asia/Tehran بگذارید تا به منطقه‌ی زمانی دستگاه وابسته نشوید، و اگر ارقام لاتین لازم دارید nu-latn را به لوکال اضافه کنید.
اختلاف ساعت ایران را چطور حساب کنم؟
اختلاف را دستی ننویسید. ایران از ۳۰ شهریور ۱۴۰۱ ساعت تابستانی ندارد و تمام سال روی +۳:۳۰ است، پس هر کدی که هنوز تابستان و زمستان را جدا حساب می‌کند شش ماه از سال خطا دارد. همیشه نام منطقه یعنی Asia/Tehran را بدهید تا اگر قانون دوباره عوض شد، فقط به‌روزرسانی پایگاه‌داده‌ی مناطق زمانی لازم باشد.
چرا تاریخ تولد کاربر یک روز عقب‌تر نمایش داده می‌شود؟
چون آن را به‌جای یک روز تقویمی، یک لحظه ذخیره کرده‌اید. نیمه‌شب تهران در UTC می‌شود ساعت ۲۰:۳۰ روز قبل، و هر کلاینتی که بدون تعیین منطقه‌ی زمانی نمایش دهد یک روز عقب‌تر نشان می‌دهد. راه‌حل، استفاده از نوع تاریخِ بدون ساعت است تا اصلاً زمانی وجود نداشته باشد که جابه‌جا شود.

مطالب مرتبط