跳至正文

【民航航季日历】使用 Postgresql 和 AI 制作航空日期维度表

XILEJUN
喜乐君 Tableau Visionary ✦ 5
📊 业务数据分析「专家」· 敏捷 BI 布道师
📚 《数据可视化分析》《业务可视化分析》多本书作者
🎓 中国地质大学(武汉)经管学院 MBA 校外导师
🤝 以 Tableau 会友,致力于构建业务分析通识框架

📚 本文配套课程 · SQL 系列

🎬 B 站课程:DAX「别裁新解」  —  https://www.bilibili.com/cheese/play/ss8780


近期在航空分析中,遇到一个看似简单,实则不麻烦的问题:如何对比不同年度、同一节假日的航空收益和航班计划量。

由于中国的节假日每年变化,在 Tableau 中写 IF else 显然不显示,最好的方式当然是制作一个日期表,预先记录每个日期对应的“属性”,比如年度、月份、假节日。

后来发现,在对比“元旦”假期时,由于会存在跨年度的情况,我使用航班日期而来的年度,无法获得正确的结果。比如203年的“元旦”可能对应2022年12月31日。

于是,更稳妥的方式就是在日期表中增加“航季”。

航季相当于某些公司的“会计年度”,属于美国多时区国家的特色之一,国际航协显然也受了“美国特色”的影响,甚至蔓延到全世界的航空管理。

如何在 Postgresql 中创建一个日期表,并能自动更新每天对应的航季呢?

1、创建日期表

我参考了 Github 上的一个英文日历表(https://gist.github.com/duffn/38449526e00abb47f4ec292f0491313d#file-creating_a_date_dimension_table_in_postgresql-sql)。

在此基础上,我注释掉了不用的一些部分(不常用的字段,我更喜欢直接在 Tableau 中计算而来),同时增加了几个自定义航空字段。

DROP TABLE if exists d_date;

CREATE TABLE d_date
(
  id						INT NOT NULL,
  "date"					DATE NOT NULL,
  --formatted_date			VARCHAR(10) NOT NULL,
  --full_date					varchar(20) not null,
 -- day_name					VARCHAR(12) NOT NULL, -- 之前是7位,改为12位,
 -- day_name_short			VARCHAR(3) NOT NULL,
  --day_of_week				INT NOT NULL,
  is_weekend				BOOLEAN NOT NULL,  -- 修改位置到前面
  --day_of_month				INT NOT NULL,
  --day_of_quarter			INT NOT NULL,
  --day_of_year				INT NOT NULL,
  --week_of_month				INT NOT NULL,
  week_of_year				INT NOT NULL,
  week_of_year_iso			CHAR(8) NOT NULL,
 -- "month"					INT NOT NULL,
  month_name_cn				VARCHAR(9) NOT NULL,
  month_name_en				CHAR(5) ,
 -- "quarter"					INT NOT NULL,
 -- quarter_name				CHAR(2) NOT NULL,
 -- "year"					INT NOT NULL,
 -- year_month				VARCHAR(12) not null,
 -- year_quarter				VARCHAR(12) not null,
  --first_day_of_week			DATE NOT NULL,
  --last_day_of_week			DATE NOT NULL,
  --first_day_of_month		DATE NOT NULL,
  --last_day_of_month			DATE NOT NULL,
  --first_day_of_quarter		DATE NOT NULL,
  --last_day_of_quarter		DATE NOT NULL,
  --first_day_of_year			DATE NOT NULL,
  --last_day_of_year			DATE NOT NULL,
    flight_season_year	VARCHAR(4) ,
 	flight_season			VARCHAR(4) ,
	is_Chinese_holiday  BOOLEAN, 	 -- 新增,是否中国节假日
	Chinese_holiday  	 CHAR(5) ,-- 新增,是否中国节假日
	is_Chinese_workday BOOLEAN, 	 -- 新增,是否工作日,t 是,包含调休
	factor_workday	INT   -- 加班因子,工资两倍或者三倍
);

ALTER TABLE public.d_date ADD CONSTRAINT pk_d_date PRIMARY KEY (id);

----CREATE INDEX pk_d_date
 -- ON d_date("date");
 
COMMIT; 

2、手动方式更新航季属性

难点在于,如何更新航季?我尝试问了一下 GPT,它虽然答对了航季的区间,但是没有给出合理的计算逻辑。

准确地计算夏季和冬季,我们需要判断“三月最后一个周日”和“十月最后一个周六”。

假设我这里已经知道了各年度的“航季分割点”,可以使用如下的 update 更新数据表中的相关字段值。

UPDATE d_date
SET 
    flight_season_year = 
    	CASE
		WHEN "date" BETWEEN '2022-10-30' AND '2023-03-25' THEN '2022'
        WHEN "date" BETWEEN '2023-03-26' AND '2023-10-28' THEN '2023'
        WHEN "date" BETWEEN '2023-10-29' AND '2024-03-30' THEN '2023'
        WHEN "date" BETWEEN '2024-03-31' AND '2024-10-26' THEN '2024'
        WHEN "date" BETWEEN '2024-10-27' AND '2025-03-29' THEN '2024'
        --ELSE NULL
    END ,
	flight_season = 
		CASE
		WHEN "date" BETWEEN '2022-10-30' AND '2023-03-25' THEN '冬春'
        WHEN "date" BETWEEN '2023-03-26' AND '2023-10-28' THEN '夏秋'
        WHEN "date" BETWEEN '2023-10-29' AND '2024-03-30' THEN '冬春'
        WHEN "date" BETWEEN '2024-03-31' AND '2024-10-26' THEN '夏秋'
        WHEN "date" BETWEEN '2024-10-27' AND '2025-03-29' THEN '冬春'
        --ELSE NULL
    END ;
COMMIT;

不过,这样很明显过于繁琐,能否直接用判断找到分割点清单呢?

3、找到冬夏航季区间的分割点“航季分割点”

我首先想确定的是,每年的三月份,有几个周日呢?如果都有四个就简单了,不过直觉中大概率是有五个周日的情况的——毕竟每年不是48个周,而是52个周。为此,可以在 Postgsql 的代码中验证一下:

-- 1\ how many sundays in each march each year ?
select  -- distinct 
EXTRACT(YEAR FROM "date")  ,
EXTRACT(MONTH FROM "date") ,
count(*)
from d_date dd 
where  EXTRACT(ISODOW FROM "date") =7 and EXTRACT(MONTH FROM "date") in (3,10)
group by 1,2

从结果可以看出,有不少月份都有5个 Sunday,因此,接下来难点就是“找到最后一个周日”,作为区间的起点。

year month count
2020 3 5 ———表示2020年3月有5个周日
2020 10 4
2021 3 4
2021 10 5
2022 3 4
2022 10 5
2023 3 4
2023 10 5
2024 3 5

1)找到三月和十月的分割点日期

“最后一个周日”是典型的表计算场景,可以使用 row_number 计算“每个月份中周日的序号”,如下所示:

-- find the last sunday in March and October ?
select  -- distinct 
"date",
EXTRACT(YEAR FROM "date")  as "year",
EXTRACT(MONTH FROM "date") as "month",
TO_CHAR( "date",'Day' )  as "workday",
--EXTRACT(ISODOW FROM "date"),
row_number()  over  (partition by EXTRACT(YEAR FROM "date") ,EXTRACT(MONTH FROM "date") order by "date" ) as "N_in_month"
from d_date dd 
where  EXTRACT(ISODOW FROM "date") =7 and EXTRACT(MONTH FROM "date") in (3,10)

这样就会返回每个三月、十月,每个周日及其月内的次序。

Date Year Month Workday N_in_month
2022-03-13 2022 3 Sunday 2
2022-03-20 2022 3 Sunday 3
2022-03-27 2022 3 Sunday 4
2022-10-02 2022 10 Sunday 1
2022-10-09 2022 10 Sunday 2
2022-10-16 2022 10 Sunday 3
2022-10-23 2022 10 Sunday 4
2022-10-30 2022 10 Sunday 5
2023-03-05 2023 3 Sunday 1
2023-03-12 2023 3 Sunday 2
2023-03-19 2023 3 Sunday 3
2023-03-26 2023 3 Sunday 4
2023-10-01 2023 10 Sunday 1

新的难点来了,如何保留上面标红的部分?总不能在表计算基础上再来一个 MAX 表计算吧?

换个方式,这里的“最后一个周日”,可以变换为“有几个周日”的简单聚合问题。这样原本复杂的窗口函数嵌套,就变成了简单的直接聚合。

一个月有几个周日,这个在前面一步已经完成了。

2)使用 SQL获得航季区间的分割点

为了简化查询,这里是用 Common Table Expression 构建一个子查询,而后将其与明细表 join 获得每个日期对应的三月、十月的“Sunday”数量。

--第一次嵌套,在三月、十月的每个周日后,增加月内的次序和当月 Sunday 数量
with Sunday_count as (
	select  -- distinct 
	d."year",	
	d."month",
	count(*) as sunday_count
	from d_date d 
	where d.day_of_week =7 and d."month" in (3,10)
	group by 1,2
	)
select  dd."date",dd."year",
		dd."month",dd.day_of_week,
	row_number() over (partition by dd."year",dd."month", dd.day_of_week) as "N_in_month",
	Sunday_count.sunday_count as  "sunday_count"
from d_date dd
join Sunday_count on dd."year" = Sunday_count."year" and dd."month" = Sunday_count."month" 
where dd.day_of_week=7 
order by dd."date"

结果如下所示:

在此基础上,只保留最后两列相等的行,就能获得所有年度中,航季区间的分割点。

看来还需要一次嵌套:


-- 第二次嵌套,保留三月和十月 最后一个周日 
with Sunday_count as (
	select  -- distinct 
	d."year",	
	d."month",
	count(*) as sunday_count
	from d_date d 
	where d.day_of_week =7 and d."month" in (3,10)
	group by 1,2
	)
select  r."date" 
from ( 
	select  dd."date",dd."year",
			dd."month",dd.day_of_week,
		row_number() over (partition by dd."year",dd."month", dd.day_of_week) as "N_in_month",
		Sunday_count.sunday_count as  "sunday_count"
	from d_date dd
	join Sunday_count on dd."year" = Sunday_count."year" and dd."month" = Sunday_count."month" 
	where dd.day_of_week=7 
	order by dd."date"
	) as r
where r."N_in_month" = r.sunday_count

如此一来,就获得了一个清单了。 从2010年到2029年,航季的分割点如下所示:

2010-03-28
2010-10-31
2011-03-27
2011-10-30
2012-03-25
2012-10-28
2013-03-31
2013-10-27
2014-03-30
2014-10-26
2015-03-29
2015-10-25
2016-03-27
2016-10-30
2017-03-26
2017-10-29
2018-03-25
2018-10-28
2019-03-31
2019-10-27
2020-03-29
2020-10-25
2021-03-28
2021-10-31
2022-03-27
2022-10-30
2023-03-26
2023-10-29
2024-03-31
2024-10-27
2025-03-30
2025-10-26
2026-03-29
2026-10-25
2027-03-28
2027-10-31
2028-03-26
2028-10-29
2029-03-25
2029-10-28

有了这个日期,就可以使用 Update 更新数据表中的值了。

4、AI 参与:自动更新“航季分割点”标记

特别注意,后面补充了一个更简单的方法,不需要使用窗口函数!

回到 d_date 数据表,使用 update 更新每个日期对应的值,为了便于后期计算,可以先创建一个字段列:航季分割点,is_flight_season_inteval

1)航季分割点,标记为数据库标记

我承认,这个超过我目前的 SQL 水平了,我还没有系统学习 Update 的使用,不过我现在有了 GPT,我问了它:

我这里有一个 update 命令 ,用来把符合条件的日期标记为 true,表示为 航季的分割点。

UPDATE d_date
SET 
is_flight_season_interval = true 
where
"date"  in ('2022-10-30' , '2023-03-25' ) ;
COMMIT; 

我现在通过如下的 sql 找到了数据库中每一年的分割点,如何将上面的 update 命令更新一下。

-- 第二次嵌套,保留三月和十月 最后一个周日
-- 第二次嵌套,保留三月和十月 最后一个周日 
with Sunday_count as (
	select  -- distinct 
	d."year",	
	d."month",
	count(*) as sunday_count
	from d_date d 
	where d.day_of_week =7 and d."month" in (3,10)
	group by 1,2
	)
select  r."date" 
from ( 
	select  dd."date",dd."year",
			dd."month",dd.day_of_week,
		row_number() over (partition by dd."year",dd."month", dd.day_of_week) as "N_in_month",
		Sunday_count.sunday_count as  "sunday_count"
	from d_date dd
	join Sunday_count on dd."year" = Sunday_count."year" and dd."month" = Sunday_count."month" 
	where dd.day_of_week=7 
	order by dd."date"
	) as r
where r."N_in_month" = r.sunday_count

GPT 告诉了我答案,不仅正确,而且帮我优化了代码!

我在上面使用了一次 CTE 和两次嵌套,虽然符合个人的写字习惯,但是并非容易后人理解,你看 GTP 的优化。

 - 找到每年三月和十月的最后一个周日
WITH Sunday_count AS (
    SELECT  
        d."year",	
        d."month",
        COUNT(*) AS sunday_count
    FROM d_date d 
    WHERE d.day_of_week = 7 AND d."month" IN (3, 10)
    GROUP BY 1, 2
),
Last_Sundays AS (
    SELECT 
        dd."date",
        dd."year",
        dd."month",
        dd.day_of_week,
        ROW_NUMBER() OVER (PARTITION BY dd."year", dd."month" ORDER BY dd."date" DESC) AS rn
    FROM d_date dd
    JOIN Sunday_count sc 
    ON dd."year" = sc."year" AND dd."month" = sc."month"
    WHERE dd.day_of_week = 7
)
-- 更新操作,标记航季分割点
UPDATE d_date
SET 
    is_flight_season_interval = TRUE
WHERE 
    "date" IN (SELECT "date" FROM Last_Sundays WHERE rn = 1);

COMMIT;

这样,我就在数据库中,把“每年三月和十月最后一个周日”标记为了分割点。

2)获得各个年度的航季开始、结束日期,关系型结构

接下来,我想要标记航季了。 这当然还需要 GPT 帮我。

GPT:
要标记每年的航季区间,将三月最后一个周日(包含)到十月最后一个周日(不含)区间对应的 flight_season 字段标记为“夏季”,其他时间标记为“冬季”,可以使用以下步骤:

1. 确定每年的三月和十月的最后一个周日。

2. 更新这些日期之间的记录,将 flight_season 字段标记为“夏季”。

3. 将其他日期的 flight_season 字段标记为“冬季”。

上面的逻辑只标记了航季的夏季、冬季航季,没有区分年度。为此,还需增加一点逻辑。GPT 给出了优化结果。

-- 假设已经有 d_date 表,并包含 flight_season 和 flight_season_year 字段
ALTER TABLE d_date ADD COLUMN flight_season VARCHAR(10);
ALTER TABLE d_date ADD COLUMN flight_season_year INT;

-- 找到每年三月和十月的最后一个周日
WITH Sunday_count AS (
    SELECT  
        d.year,	
        d.month,
        COUNT(*) AS sunday_count
    FROM d_date d 
    WHERE d.day_of_week = 7 AND d.month IN (3, 10)
    GROUP BY 1, 2
),
Last_Sundays AS (
    SELECT 
        dd.datum,
        dd.year,
        dd.month,
        ROW_NUMBER() OVER (PARTITION BY dd.year, dd.month ORDER BY dd.datum DESC) AS rn
    FROM d_date dd
    JOIN Sunday_count sc 
    ON dd.year = sc.year AND dd.month = sc.month
    WHERE dd.day_of_week = 7
),
Season_Boundaries AS (
    SELECT 
        year,
        MAX(CASE WHEN month = 3 AND rn = 1 THEN datum END) AS start_summer,
        MAX(CASE WHEN month = 10 AND rn = 1 THEN datum END) AS end_summer,
        LAG(MAX(CASE WHEN month = 3 AND rn = 1 THEN datum END)) OVER (ORDER BY year) AS prev_start_summer
    FROM Last_Sundays
    GROUP BY year
)
-- 更新操作,标记航季区间和年度
UPDATE d_date d
SET 
    flight_season = CASE 
                        WHEN d.datum >= sb.start_summer AND d.datum < sb.end_summer THEN '夏季'
                        ELSE '冬季'
                    END,
    flight_season_year = CASE
                            WHEN d.datum >= sb.start_summer THEN sb.year
                            ELSE sb.year - 1
                         END
FROM 
    Season_Boundaries sb
WHERE 
    d.year = sb.year OR d.year = sb.year - 1
    AND (d.datum >= sb.prev_start_summer OR sb.prev_start_summer IS NULL);

-- 提交更新
COMMIT;

相当不错!依次就更新了数据库中所有日期的航季标签了。

5、后补:找到分割点的新方法

客户的一位 SQL 高手提示,可以通过4月1日和11月1日所在周的周一去识别 开始日期。这个是一个非常巧妙的办法。

这里使用 SQL 查询,可以一次性获得每年4月1日和10月1日的属性,并根据它在本周的天数位置,倒推上周日的日期。

    SELECT
        "date" AS start_date,
        "year",
        day_of_week,   --周几
        --TO_CHAR( "date", '"W"IW'),
         EXTRACT(DOW FROM "date")  as dow,  -- 本周的第几天? 倒推可以获得上周日的日期
         "date"- EXTRACT(DOW FROM "date")::int as start_d  
       
    FROM d_date
    WHERE ("month" = 4 or "month" = 11 ) AND EXTRACT(DAY FROM "date") = 1

如下所示,

于此,就可以这样更新了。

-- 找到航季日期的新方法:
-- 找到每年三月和11月的最后一个周日
WITH Week_Starts AS (
    -- 找到每年4月1日和11月1日的日期
    SELECT
        "date" AS start_date,
        "year",
        day_of_week,   --周几,相当于下面的 dow 值
        --TO_CHAR( "date", '"W"IW'),
         EXTRACT(DOW FROM "date")  as dow,  -- 本周的第几天? 倒推可以获得上周日的日期
         "date"- EXTRACT(DOW FROM "date")::int as Last_sunday 
    FROM d_date
    WHERE ("month" = 4 or "month" = 11 ) AND EXTRACT(DAY FROM "date") = 1
)
-- 更新操作,标记航季分割点
UPDATE d_date
SET 
    is_flight_season_interval = TRUE
WHERE 
    "date" IN (SELECT Last_sunday FROM Week_Starts);

COMMIT;

2024-08-06 by 喜乐君