営業日ベースの違反タイマーは、契約がカレンダー、起算日、タイムゾーンを明記していなければ、早すぎる側にも遅すぎる側にも誤ります。Narwhal が提供するのは国と年ごとの祝日で、それ以外のルールはデスク自身のコードと契約で決めます。
契約で指定できるカレンダーからタイマーを始める
祝日にもタイマーが動き続けると、契約上は許されていない違反が表示されます。契約が除外していない日にタイマーが止まると、本物の違反が隠れます。解決策は、双方が指し示せるカレンダーです。このリクエストはシンガポールの 2026年の祝日を読み、Deepavali 前後の2件だけを残します。
curl -s -H "Authorization: Bearer $NARWHAL_API_KEY" \
"https://api.narwhalapi.com/v1/calendars/holidays/SGP/2026"{
"country": "SGP",
"coverage": [
{
"scope": "national_public",
"subdivision_codes": []
}
],
"holidays": [
{
"date": "2026-11-08",
"name": "Deepavali",
"holiday_key": "deepavali",
"scope": "national_public",
"subdivision_codes": []
},
{
"date": "2026-11-09",
"name": "Deepavali (Observed)",
"holiday_key": "deepavali",
"scope": "national_public",
"subdivision_codes": [],
"observed": true
}
]
}各エントリには date、name、scope、subdivision_codes があります。Deepavali の日付は 11月8日(日)です。observed: true が付いたエントリは 11月9日(月)の振替休日で、平日にあたります。coverage リストはカレンダーが持つ scope を示し、シンガポールでは national_public です。API が提供するのは祝日だけなので、タイマーのそれ以外の部分は契約で決めます。
コードを実行する前に契約で決めておくこと
コードは表の各行に答えを必要とします。契約が何も定めていなければ、デスクと顧客は最初に争いになったチケットでそれに気づきます。
| 項目 | 契約への質問 | 誰が決めるか |
|---|---|---|
| カレンダー | どの国の、どの祝日 scope か。 | 契約、次に API の scope フィルター |
| 数える日 | 土曜と日曜は除外するか。 | 契約 |
| 初日 | 受付日は 0 日目か 1 日目か。 | 契約 |
| 期限 | 期限はその日の終わりか。 | 契約 |
| タイムゾーン | どの地域の現地日付でカウントを始めるか。 | 契約 |
| 一時停止の時間 | 顧客の返信待ちの間はタイマーを止めるか。 | 契約とチケット管理システム |
| 1日の時間 | 営業日とは営業時間を意味するか。 | 契約とサポートのスケジュール |
各行に書面の答えがあれば、タイマーは判断の余地がない関数になります。API が決めるのはカレンダーの行だけで、ほかは一切決めません。
期限日を計算して違反を判定する
スクリプトは祝日の日付を集合にまとめ、集合に入っていない平日を営業日とします。受付日は 0 日目なので、カウントは翌日から始まります。最初のブロックはチケットのタイムスタンプを契約のタイムゾーンの日付に変換し、2つ目のブロックは解決済みのチケットを判定します。
import json, os, urllib.request
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
def get(path):
req = urllib.request.Request("https://api.narwhalapi.com" + path,
headers={"Authorization": "Bearer " + os.environ["NARWHAL_API_KEY"]})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def holiday_dates(country, years):
return {date.fromisoformat(h["date"]) for y in years for h in get(f"/v1/calendars/holidays/{country}/{y}")["holidays"]}
def is_working_day(d, off):
return d.weekday() < 5 and d not in off
def due_date(opened, working_days, off):
d, left = opened, working_days
while left:
d += timedelta(1)
left -= is_working_day(d, off)
return d
def elapsed_working_days(opened, resolved, off):
return sum(is_working_day(opened + timedelta(n), off) for n in range(1, (resolved - opened).days + 1))
off = holiday_dates("SGP", (2026,))
sg = ZoneInfo("Asia/Singapore")
# Clock start: the ticket timestamp becomes a date in the contract's time zone.
created = datetime(2026, 11, 5, 20, 0, tzinfo=ZoneInfo("America/New_York"))
opened = created.astimezone(sg).date()
print("created", created.date(), "in New York is", opened, "in Singapore")
print("3 working days from the Singapore date:", due_date(opened, 3, off))
print("3 working days from the New York date: ", due_date(created.date(), 3, off))
# Breach test: compare the resolution date with the due date.
opened, resolved = date(2026, 2, 13), date(2026, 2, 27)
due = due_date(opened, 5, off)
late = elapsed_working_days(opened, resolved, off) - 5
print("5 working days from", opened, "is due", due, "; resolved", resolved, "so", late, "working days late")
created 2026-11-05 in New York is 2026-11-06 in Singapore
3 working days from the Singapore date: 2026-11-12
3 working days from the New York date: 2026-11-11
5 working days from 2026-02-13 is due 2026-02-24 ; resolved 2026-02-27 so 3 working days lateニューヨークのチケットは 11月5日(木)の 20:00 に作られ、シンガポールではすでに 11月6日(金)です。シンガポールの日付から営業日3日を数えると期限は 11月12日(木)です。11月9日(月)が Deepavali の振替休日だからです。ニューヨークの日付から数えると同じチケットの期限は 11月11日(水)で1日早くなるため、タイムゾーンの行がデスクの違反を左右します。2枚目のチケットは 2月13日(金)の受付で、旧正月が 2月17日(火)と 18日(水)にあたるため期限は 2月24日(火)です。2月27日(金)に解決したので、営業日3日の遅れです。
週末にあたる祝日はどう扱われますか。
平日の判定が先にその日を除外するので、二重に引かれることはありません。シンガポールのレスポンスでは Hari Raya Puasa が 2026年3月21日(土)、Deepavali が 11月8日(日)です。どちらもカウントは変わりません。振替休日は observed: true の別エントリなので、追加コードなしでタイマーが除外します。
SLA にはどの scope を使うべきですか。
顧客の事業が従うカレンダーに合う scope を使います。シンガポールは national_public だけを返します。インドネシアは national_public と collective_leave を返し、オーストラリアは subdivision_codes を持つ地域のエントリに加えて bank scope を返します。年のエンドポイントには任意の scope フィルターがあります。SLA が特定の種類の日を除外するときに指定し、カウントを再現できるよう、すべてのリクエストで同じ scope を使ってください。
API は営業時間を数えられますか。
いいえ。カレンダーのエンドポイントが返すのは祝日の日付です。営業時間、昼休み、顧客の返信待ちの一時停止は、サポートのスケジュールとチケット管理システムから得ます。時間単位で書かれた SLA では、このコードで営業日を求め、最初の日と最後の日にスケジュールを当てはめてください。
未解決のチケットが多いとき、リクエスト数を抑えるには。
国と年ごとに1回だけ取得し、日付の集合をメモリか自分のテーブルに保持します。その国・年のすべてのチケットが同じ集合を使い回します。まだ読み込んでいない年にかかるチケットがあるときだけ、年のエンドポイントを再度呼びます。2つの日付の間の営業日を数えるガイドに、スプレッドシートでの方法も含めて同じ計算が載っています。
公開。