فهرست مقاله
منوی روز کاری باید از روی یک زمان مشخص تصمیم بگیرد، نه با حدسزدن ساعت رایانهٔ اجراکننده. در این درس، یک تابع آفلاین میسازیم که زمان آگاه به منطقه را میگیرد، آن را به تهران تبدیل میکند و برای برنامهٔ هفتگیِ فرضی، برچسب منوی مناسب برمیگرداند. تماس، پخش پیام یا انتقال واقعی نداریم؛ ساعتها و تعطیلی نمونه نیز متعلق به هیچ سازمان واقعی نیستند.
۱. یک لحظه را به زمان محلی تهران تبدیل کنیم

ورودی datetime باید آگاه باشد: tzinfo داشته باشد و utcoffset آن None نباشد. اگر زمان بدون منطقه وارد شود، خطا میدهیم؛ ساعت محلی سیستم را بهجای دادهٔ گمشده فرض نمیکنیم. این تصمیم، رفتار تابع را روی دو رایانه با تنظیمات متفاوت روشنتر میکند. خودِ لحظه را فراخواننده میدهد و کد datetime.now را صدا نمیزند.
مستندات astimezone تبدیل یک لحظه به نمایش محلیِ منطقهٔ دیگر را توضیح میدهد. این کار با چسباندن tzinfo از راه replace تفاوت دارد. مثلاً ۰۵:۳۰ UTC در نمونه به ۰۹:۰۰ تهران میرسد؛ صرف عوضکردن برچسب منطقه روی ۰۵:۳۰، همان لحظه را حفظ نمیکند.
برای تکرارپذیری، دادهٔ Asia/Tehran را صریحاً از بستهٔ از قبل نصبشدهٔ tzdata میخوانیم و با ZoneInfo.from_file میسازیم. نسخهٔ بسته 2026.2، دادهٔ IANA برابر 2026b و hash بایتی فایل در خروجی ثبت است. برنامه چیزی نصب نمیکند و مسیر جهانی منطقههای زمانی را تغییر نمیدهد. دامنهٔ تمرین فقط سال ۲۰۲۶ در تاریخِ تبدیلشدهٔ تهران است.
۲. بازهٔ دقیقهای را نیمهباز تعریف کنیم

در برنامهٔ ساختگی، شنبه تا چهارشنبه از ۹ تا ۱۲ و از ۱۳ تا ۱۷ باز است. پنجشنبه فقط ۹ تا ۱۳ و جمعه بسته است. این جدول، ساعات کاری واقعی شرکت یا توصیهٔ عمومی نیست. در شمارهگذاری weekday پایتون، دوشنبه صفر و یکشنبه شش است؛ پس شنبه کلید پنج دارد. قرارداد باید این تفاوت را آشکار کند.
هر بازه آغاز را شامل و پایان را خارج میگیرد. شنبه ساعت ۰۹:۰۰ باز است؛ ساعت ۱۲:۰۰ وارد وقفه میشود؛ ۱۳:۰۰ دوباره باز و ۱۷:۰۰ بسته است. آخرین میکروثانیهٔ پیش از دوازده هنوز داخل بازه است. چون تمام مرزها دقیقهٔ کاملاند، استفاده از شمارهٔ دقیقه برای سنجش عضویت، ثانیهها را اشتباه به بازهٔ بعدی نمیبرد.
هر روز حداکثر دو بازهٔ مرتب و بدون همپوشانی میپذیرد. پایان یک بازه میتواند آغاز بعدی باشد، اما آغاز کوچکتر از پایان قبلی خطاست. انتهای کوچکتر از ابتدا، بازهٔ شبانه تلقی نمیشود؛ رد میشود. حمل خودکارِ فعالیت به روز بعد در این نسخه نداریم و برای شیفت شب باید قرارداد جداگانه نوشت.
۳. تعطیلی اعلامشده را بر جدول هفتگی مقدم کنیم

تاریخ 2026-10-18 را فقط برای آزمایش تعطیل اعلام کردهایم. این تاریخ، ادعای تعطیلی رسمی نیست. آن یکشنبه طبق برنامهٔ هفتگی در ساعت نه باز میبود، اما فهرست تعطیلی، نتیجه را به declared_holiday تغییر میدهد. اگر همین تاریخ از ورودی تعطیلی حذف شود، همان لحظه برچسب منوی زمان کاری میگیرد.
تبدیل منطقه باید پیش از انتخاب روز انجام شود. چهارشنبه ساعت ۲۲ UTC در نمونه، پنجشنبهٔ تهران است؛ استفاده از weekday ورودی، جدول اشتباه را انتخاب میکند. دامنهٔ سال نیز پس از تبدیل سنجیده میشود: لحظهای در آخرِ سال ۲۰۲۵ UTC ممکن است در تهران وارد ۲۰۲۶ شده باشد؛ برعکس، آخر ۲۰۲۶ UTC میتواند محلیِ ۲۰۲۷ و خارج از دامنه باشد.
برای تعطیلی حداکثر شانزده شیء واقعی date از سال ۲۰۲۶ میپذیریم. datetime که زیرکلاس date است در این قرارداد پذیرفته نیست؛ نوع دقیق بررسی میشود. تمام هفت کلید روز هفته هم لازماند و bool را بهعنوان عددِ روز یا دقیقه قبول نمیکنیم. فهرست نامعتبر، بیصدا به «دفتر بسته است» تبدیل نمیشود؛ خطای تنظیمات باید آشکار بماند.
۴. کد کامل و پاسخ دستی مرزها را اجرا کنیم

فایل ivr_business_hours.py با Python 3.14.5 و دادهٔ منطقهٔ ثبتشده اجرا شده است. تابع فقط یک تصمیمِ دادهای میدهد. مقدار open، علت تصمیم، زمان محلی و یکی از دو رشتهٔ office_menu یا after_hours_menu در خروجیاند. هیچ ماژول تلفن یا شبکهای فراخوانی نمیشود.
"""Synthetic offline IVR hours; explicit instants and installed tzdata, no calls."""
import copy
import hashlib
import json
import platform
from datetime import date, datetime, timedelta, timezone, tzinfo
from importlib.resources import files
from io import BytesIO
from zoneinfo import ZoneInfo
import tzdata
TZ_BYTES = files('tzdata.zoneinfo').joinpath('Asia/Tehran').read_bytes()
TEHRAN = ZoneInfo.from_file(BytesIO(TZ_BYTES), key='Asia/Tehran')
WEEKLY = {day: [(540, 720), (780, 1020)] for day in (0, 1, 2, 5, 6)}
WEEKLY.update({3: [(540, 780)], 4: []})
HOLIDAYS = frozenset({date(2026, 10, 18)})
def validated_config(weekly, holidays):
if type(weekly) is not dict or set(weekly) != set(range(7)):
raise ValueError('weekdays')
if any(type(key) is not int for key in weekly):
raise ValueError('weekdays')
snapshot = {}
for day, intervals in weekly.items():
if type(intervals) is not list or len(intervals) > 2:
raise ValueError('intervals')
previous_end, accepted = -1, []
for interval in intervals:
if type(interval) is not tuple or len(interval) != 2:
raise ValueError('intervals')
start, end = interval
if type(start) is not int or type(end) is not int:
raise ValueError('intervals')
if not (0 <= start < end <= 1440) or start < previous_end:
raise ValueError('intervals')
accepted.append((start, end))
previous_end = end
snapshot[day] = tuple(accepted)
if type(holidays) not in (set, frozenset) or len(holidays) > 16:
raise ValueError('holidays')
if any(type(value) is not date or value.year != 2026 for value in holidays):
raise ValueError('holidays')
return snapshot, frozenset(holidays)
def business_hours(instant, weekly=WEEKLY, holidays=HOLIDAYS):
if type(instant) is not datetime or instant.tzinfo is None or instant.utcoffset() is None:
raise ValueError('aware_datetime_required')
local = instant.astimezone(TEHRAN)
if local.year != 2026:
raise ValueError('local_year_2026_required')
schedule, closures = validated_config(weekly, holidays)
intervals = schedule[local.weekday()]
minute = local.hour * 60 + local.minute
matched = next((pair for pair in intervals if pair[0] <= minute < pair[1]), None)
if local.date() in closures:
opened, reason = False, 'declared_holiday'
elif not intervals:
opened, reason = False, 'weekly_closed'
else:
opened, reason = matched is not None, 'within_hours' if matched else 'outside_hours'
return {'local_datetime': local.isoformat(), 'local_date': local.date().isoformat(),
'weekday_monday_zero': local.weekday(), 'minute_of_day': minute,
'open': opened, 'reason': reason,
'route_label': 'office_menu' if opened else 'after_hours_menu'}
def manual_utc_oracle(instant):
"""Independent fixed-offset fixture oracle; no ZoneInfo/main validation calls."""
wall = instant + timedelta(minutes=210)
if wall.date() == date(2026, 10, 18):
return False
minute = wall.hour * 60 + wall.minute
if wall.weekday() == 4:
allowed = set()
elif wall.weekday() == 3:
allowed = set(range(540, 780))
else:
allowed = set(range(540, 720)) | set(range(780, 1020))
return minute in allowed
def rejects(call, message):
try:
call()
except ValueError as error:
return str(error) == message
return False
class UnknownOffset(tzinfo):
def utcoffset(self, value):
return None
def run_example():
utc = timezone.utc
dt = lambda day, hour, minute=0, second=0, microsecond=0: datetime(2026, 10, day, hour, minute, second, microsecond, tzinfo=utc)
instants = [dt(16, 6), dt(17, 5, 29, 59), dt(17, 5, 30),
dt(17, 8, 29, 59, 999999), dt(17, 8, 30), dt(17, 9, 30),
dt(17, 13, 30), dt(18, 5, 30)]
snapshots = [business_hours(instant) for instant in instants]
expected_open = [False, False, True, True, False, True, False, False]
expected_reason = ['weekly_closed', 'outside_hours', 'within_hours', 'within_hours',
'outside_hours', 'within_hours', 'outside_hours', 'declared_holiday']
before = copy.deepcopy(WEEKLY)
input_time = dt(17, 5, 30)
fixed_offset = timezone(timedelta(minutes=330))
# The same instant can be supplied in a different aware offset.
alternative = input_time.astimezone(fixed_offset)
bad_weekday = {True: [], **{i: [] for i in (0, 2, 3, 4, 5, 6)}}
bad_intervals = [[(720, 540)], [(540, 720), (700, 780)], [(780, 900), (540, 720)],
[(True, 720)], [(0, 1441)], [(540, 540)], [(1, 2), (3, 4), (5, 6)]]
oracle_ok, cases = True, 0
for day in range(17, 24):
for minute in (0, 539, 540, 719, 720, 779, 780, 1019, 1020, 1439):
for microsecond in (0, 999999):
wall_as_utc = datetime(2026, 10, day, minute // 60, minute % 60, 0, microsecond, tzinfo=utc)
instant = wall_as_utc - timedelta(minutes=210)
oracle_ok &= business_hours(instant)['open'] == manual_utc_oracle(instant)
cases += 1
checks = {
'hand_eight_statuses_and_reasons': [s['open'] for s in snapshots] == expected_open and [s['reason'] for s in snapshots] == expected_reason,
'utc_instant_is_converted_to_tehran': snapshots[2]['local_datetime'] == '2026-10-17T09:00:00+03:30',
'equivalent_aware_fixed_offset_gives_same_decision': business_hours(alternative) == business_hours(input_time),
'interval_start_is_inclusive': business_hours(dt(17, 5, 30))['open'] is True,
'interval_ends_are_exclusive': business_hours(dt(17, 8, 30))['open'] is False and business_hours(dt(17, 13, 30))['open'] is False,
'last_microsecond_before_close_is_open': snapshots[3]['open'] is True and business_hours(dt(17, 13, 29, 59, 999999))['open'] is True,
'split_day_break_and_afternoon_restart': business_hours(dt(17, 9))['reason'] == 'outside_hours' and snapshots[5]['open'] is True,
'thursday_uses_its_own_short_interval': business_hours(dt(22, 9, 29, 59))['open'] is True and business_hours(dt(22, 9, 30))['open'] is False,
'friday_is_weekly_closed': snapshots[0]['reason'] == 'weekly_closed',
'declared_holiday_overrides_open_weekday': snapshots[7]['reason'] == 'declared_holiday' and business_hours(dt(18, 5, 30), holidays=frozenset())['open'] is True,
'date_and_weekday_change_after_timezone_conversion': business_hours(dt(21, 22))['local_date'] == '2026-10-22' and business_hours(dt(21, 22))['weekday_monday_zero'] == 3,
'naive_and_unknown_offset_datetime_reject': rejects(lambda: business_hours(datetime(2026, 10, 17, 9)), 'aware_datetime_required') and rejects(lambda: business_hours(datetime(2026, 10, 17, 9, tzinfo=UnknownOffset())), 'aware_datetime_required'),
'domain_is_converted_local_year_not_input_year': business_hours(datetime(2025, 12, 31, 21, tzinfo=utc))['local_date'] == '2026-01-01' and rejects(lambda: business_hours(datetime(2026, 12, 31, 21, tzinfo=utc)), 'local_year_2026_required'),
'all_seven_exact_integer_weekday_keys_required': rejects(lambda: business_hours(input_time, {0: []}), 'weekdays') and rejects(lambda: business_hours(input_time, bad_weekday), 'weekdays'),
'interval_order_overlap_types_and_no_overnight_validated': all(rejects(lambda slots=slots: business_hours(input_time, {**WEEKLY, 5: slots}), 'intervals') for slots in bad_intervals),
'holidays_are_bounded_actual_2026_dates': rejects(lambda: business_hours(input_time, holidays={datetime(2026, 10, 18)}), 'holidays') and rejects(lambda: business_hours(input_time, holidays={date(2025, 1, 1)}), 'holidays') and rejects(lambda: business_hours(input_time, holidays={date(2026, 1, d) for d in range(1, 18)}), 'holidays'),
'independent_fixed_offset_membership_oracle': cases == 140 and bool(oracle_ok),
'recorded_2026_zone_offsets_and_input_preservation': all(datetime(2026, month, 15, 12, tzinfo=TEHRAN).utcoffset() == timedelta(minutes=210) for month in range(1, 13)) and WEEKLY == before,
}
assert len(checks) == 18 and all(type(value) is bool and value for value in checks.values()), checks
return {'python': platform.python_version(),
'dependencies': 'Python standard library plus already installed first-party tzdata resource',
'timezone_data': {'tzdata_package_version': tzdata.__version__, 'iana_version': tzdata.IANA_VERSION,
'zone': 'Asia/Tehran', 'tzif_sha256': hashlib.sha256(TZ_BYTES).hexdigest(),
'loaded_explicitly_from_package': True},
'scope': 'Synthetic offline weekly routing labels, Tehran local year2026 only; no phone call or official holiday calendar',
'results': {'eight_hand_scenarios': snapshots, 'open_count': sum(s['open'] for s in snapshots),
'closed_count': sum(not s['open'] for s in snapshots),
'weekly_intervals_minutes': {str(day): [list(pair) for pair in intervals] for day, intervals in WEEKLY.items()},
'declared_synthetic_holidays': sorted(d.isoformat() for d in HOLIDAYS),
'independent_oracle_cases_inside_one_group': cases,
'limits': {'local_year': 2026, 'max_intervals_per_weekday': 2, 'max_declared_holidays': 16,
'intervals': 'half-open minute endpoints within one local day, no overnight carry', 'calls_made': 0}},
'checks': checks}
if __name__ == '__main__':
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
هشت سناریوی دستی، سه حالت باز و پنج حالت بسته دارند: جمعه ۹:۳۰ بسته؛ شنبه ۸:۵۹:۵۹ بسته، ۹ باز، ۱۱:۵۹:۵۹٫۹۹۹۹۹۹ باز، ۱۲ بسته، ۱۳ باز و ۱۷ بسته؛ یکشنبهٔ تعطیل نیز ساعت نه بسته است. دلیل بستهبودن جمعه weekly_closed، وقفهٔ شنبه outside_hours و تعطیلی اعلامشده declared_holiday است؛ یک bool همهٔ این علتها را پنهان نمیکند.
هجده گروه بررسی ثبت شده است. مرجع مستقل، برای ورودی UTC، ۲۱۰ دقیقه اضافه میکند و با مجموعهٔ صریح دقیقههای مجاز تصمیم میگیرد؛ ZoneInfo یا اعتبارسنج مسیر اصلی را صدا نمیزند. هفت روز، ده دقیقهٔ مرزی یا نماینده و دو مقدار میکروثانیه، ۱۴۰ حالت داخل یک گروهاند. این مرجع فقط فرضِ ثابتِ همین دادهٔ ۲۰۲۶ را میآزماید، نه قوانین همهٔ منطقهها و سالها را.
۵. برچسب مسیر و چهار پاسخ را درست تفسیر کنیم

دادهٔ نصبشده در دوازده نمونهٔ ماهانهٔ سال ۲۰۲۶ برای تهران offset برابر ۲۱۰ دقیقه نشان داد. این مشاهدهٔ محدود را تضمین قوانین آینده یا تمام تاریخچهٔ منطقه نمینامیم. مستندات zoneinfo نیز وابستگی نتیجه به منبع دادهٔ منطقه را توضیح میدهد؛ ثبت نسخه و hash برای همین اهمیت دارد.
{
"python": "3.14.5",
"dependencies": "Python standard library plus already installed first-party tzdata resource",
"timezone_data": {
"tzdata_package_version": "2026.2",
"iana_version": "2026b",
"zone": "Asia/Tehran",
"tzif_sha256": "65ac5ec01f3721d608195a49bdca7c7accb3de27df020c44cec8fb70cada4377",
"loaded_explicitly_from_package": true
},
"scope": "Synthetic offline weekly routing labels, Tehran local year2026 only; no phone call or official holiday calendar",
"results": {
"eight_hand_scenarios": [
{
"local_datetime": "2026-10-16T09:30:00+03:30",
"local_date": "2026-10-16",
"weekday_monday_zero": 4,
"minute_of_day": 570,
"open": false,
"reason": "weekly_closed",
"route_label": "after_hours_menu"
},
{
"local_datetime": "2026-10-17T08:59:59+03:30",
"local_date": "2026-10-17",
"weekday_monday_zero": 5,
"minute_of_day": 539,
"open": false,
"reason": "outside_hours",
"route_label": "after_hours_menu"
},
{
"local_datetime": "2026-10-17T09:00:00+03:30",
"local_date": "2026-10-17",
"weekday_monday_zero": 5,
"minute_of_day": 540,
"open": true,
"reason": "within_hours",
"route_label": "office_menu"
},
{
"local_datetime": "2026-10-17T11:59:59.999999+03:30",
"local_date": "2026-10-17",
"weekday_monday_zero": 5,
"minute_of_day": 719,
"open": true,
"reason": "within_hours",
"route_label": "office_menu"
},
{
"local_datetime": "2026-10-17T12:00:00+03:30",
"local_date": "2026-10-17",
"weekday_monday_zero": 5,
"minute_of_day": 720,
"open": false,
"reason": "outside_hours",
"route_label": "after_hours_menu"
},
{
"local_datetime": "2026-10-17T13:00:00+03:30",
"local_date": "2026-10-17",
"weekday_monday_zero": 5,
"minute_of_day": 780,
"open": true,
"reason": "within_hours",
"route_label": "office_menu"
},
{
"local_datetime": "2026-10-17T17:00:00+03:30",
"local_date": "2026-10-17",
"weekday_monday_zero": 5,
"minute_of_day": 1020,
"open": false,
"reason": "outside_hours",
"route_label": "after_hours_menu"
},
{
"local_datetime": "2026-10-18T09:00:00+03:30",
"local_date": "2026-10-18",
"weekday_monday_zero": 6,
"minute_of_day": 540,
"open": false,
"reason": "declared_holiday",
"route_label": "after_hours_menu"
}
],
"open_count": 3,
"closed_count": 5,
"weekly_intervals_minutes": {
"0": [
[
540,
720
],
[
780,
1020
]
],
"1": [
[
540,
720
],
[
780,
1020
]
],
"2": [
[
540,
720
],
[
780,
1020
]
],
"5": [
[
540,
720
],
[
780,
1020
]
],
"6": [
[
540,
720
],
[
780,
1020
]
],
"3": [
[
540,
780
]
],
"4": []
},
"declared_synthetic_holidays": [
"2026-10-18"
],
"independent_oracle_cases_inside_one_group": 140,
"limits": {
"local_year": 2026,
"max_intervals_per_weekday": 2,
"max_declared_holidays": 16,
"intervals": "half-open minute endpoints within one local day, no overnight carry",
"calls_made": 0
}
},
"checks": {
"hand_eight_statuses_and_reasons": true,
"utc_instant_is_converted_to_tehran": true,
"equivalent_aware_fixed_offset_gives_same_decision": true,
"interval_start_is_inclusive": true,
"interval_ends_are_exclusive": true,
"last_microsecond_before_close_is_open": true,
"split_day_break_and_afternoon_restart": true,
"thursday_uses_its_own_short_interval": true,
"friday_is_weekly_closed": true,
"declared_holiday_overrides_open_weekday": true,
"date_and_weekday_change_after_timezone_conversion": true,
"naive_and_unknown_offset_datetime_reject": true,
"domain_is_converted_local_year_not_input_year": true,
"all_seven_exact_integer_weekday_keys_required": true,
"interval_order_overlap_types_and_no_overnight_validated": true,
"holidays_are_bounded_actual_2026_dates": true,
"independent_fixed_offset_membership_oracle": true,
"recorded_2026_zone_offsets_and_input_preservation": true
}
}
طراحی مسیر تماس در IVR زمینهٔ کلی منو را توضیح میدهد؛ اینجا فقط انتخاب منو بر پایهٔ زمان را آزمودیم. رفتار ورودی نامعتبر و مسیر جایگزین مسئولیت جداگانه دارد. موفقیت دو مثال بهتنهایی اثبات اتصال تلفنیِ کامل یا پاسخدادن کارشناس نیست.
پرسش اول: در خود ساعت دوازدهٔ شنبه چه میشود؟ پاسخ: بازهٔ صبح پایان یافته و وقفه آغاز شده است؛ open برابر False و علت outside_hours است.
پرسش دوم: آیا replace با منطقهٔ تهران معادل تبدیل زمان است؟ پاسخ: خیر؛ برای حفظ یک لحظه، astimezone بهکار میرود. چسباندن برچسب منطقه، ممکن است معنای لحظه را عوض کند.
پرسش سوم: اگر یکشنبهٔ آزمایش از فهرست تعطیلی حذف شود چه میشود؟ پاسخ: ساعت نه طبق جدول هفتگی باز است. این تغییر فقط در دادهٔ ساختگی است و تقویم رسمی را تعیین نمیکند.
پرسش چهارم: آیا after_hours_menu یعنی پیام پخش شده است؟ پاسخ: خیر؛ فقط رشتهٔ تصمیم تولید شده است. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدند؛ نسخهٔ مستندات 3.14.8 با اجرای 3.14.5 و نسخهٔ دادهٔ منطقه جدا ثبت شدهاند. این درس آموزش پایهٔ تألیفی است و خبر تازه یا گزارش تماس واقعی نیست.
منابع و مطالعهٔ بیشتر
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.


