From 8c03933debe9b6d3d77dff55a058badd3b371a99 Mon Sep 17 00:00:00 2001 From: Yuze Fu Date: Sat, 29 Aug 2026 11:14:04 +0900 Subject: [PATCH 1/2] feat: add ATC position management API --- .../20260829000000_add_atc_positions.sql | 704 ++++++++++++++++++ src/app.rs | 2 + src/error.rs | 13 + src/modules/atc_position/dto.rs | 163 ++++ src/modules/atc_position/mod.rs | 5 + src/modules/atc_position/models.rs | 52 ++ src/modules/atc_position/repository.rs | 158 ++++ src/modules/atc_position/routes.rs | 118 +++ src/modules/atc_position/service.rs | 110 +++ src/modules/audit_log/dto.rs | 8 + src/modules/audit_log/models.rs | 3 +- src/modules/audit_log/repository.rs | 163 ++-- src/modules/audit_log/routes.rs | 64 ++ src/modules/audit_log/service.rs | 17 + src/modules/mod.rs | 1 + src/openapi.rs | 43 ++ src/services.rs | 8 + 17 files changed, 1585 insertions(+), 47 deletions(-) create mode 100644 migrations/20260829000000_add_atc_positions.sql create mode 100644 src/modules/atc_position/dto.rs create mode 100644 src/modules/atc_position/mod.rs create mode 100644 src/modules/atc_position/models.rs create mode 100644 src/modules/atc_position/repository.rs create mode 100644 src/modules/atc_position/routes.rs create mode 100644 src/modules/atc_position/service.rs diff --git a/migrations/20260829000000_add_atc_positions.sql b/migrations/20260829000000_add_atc_positions.sql new file mode 100644 index 0000000..172b148 --- /dev/null +++ b/migrations/20260829000000_add_atc_positions.sql @@ -0,0 +1,704 @@ +CREATE TABLE public.atc_position ( + category text NOT NULL, + callsign text PRIMARY KEY, + is_tier_2 boolean NOT NULL DEFAULT false, + callsign_zh text, + callsign_en text, + frequency_khz integer NOT NULL, + cpdlc_code text, + remarks text, + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT ck_atc_position_callsign CHECK (callsign ~ '^[A-Z0-9_*]{1,32}$'), + CONSTRAINT ck_atc_position_category CHECK ( + category IN ('standard', 'chengdu-low-area', 'military', 'atis') + ), + CONSTRAINT ck_atc_position_frequency CHECK ( + frequency_khz BETWEEN 118000 AND 136975 AND frequency_khz % 5 = 0 + ) +); + +CREATE INDEX ix_atc_position_category_callsign + ON public.atc_position (category, callsign); + +ALTER TABLE public.audit_log + ALTER COLUMN entity_id DROP NOT NULL, + ADD COLUMN entity_key text, + ADD CONSTRAINT ck_audit_log_entity_identifier CHECK ( + (entity_id IS NULL) <> (entity_key IS NULL) + ); + +CREATE INDEX ix_audit_log_entity_key + ON public.audit_log (entity_kind, entity_key); + +-- Seeded from https://www.vatprc.net/zh-cn/airspace/station on 2026-08-29. + +INSERT INTO public.atc_position ( + category, callsign, is_tier_2, callsign_zh, callsign_en, + frequency_khz, cpdlc_code, remarks +) VALUES + ('standard', 'ZBAA_W_TWR', false, '北京塔台', 'Beijing Tower', 124300, NULL, NULL), + ('standard', 'ZBAA_E_TWR', false, '北京塔台', 'Beijing Tower', 118600, NULL, NULL), + ('standard', 'ZBAA_AW_GND', false, '北京机坪', 'Beijing Apron', 122675, NULL, NULL), + ('standard', 'ZBAA_APP', false, '北京进近', 'Beijing Approach', 120600, NULL, NULL), + ('standard', 'ZBAA_AA_APP', false, '首都进近', 'Capital Approach', 119000, NULL, NULL), + ('standard', 'ZBAA_AD_APP', false, '大兴进近', 'Daxing Approach', 126500, NULL, NULL), + ('standard', 'ZBAA_W_APP', false, '北京进近', 'Beijing Approach', 121100, NULL, NULL), + ('standard', 'ZBAA_S_APP', false, '北京进近', 'Beijing Approach', 119850, NULL, NULL), + ('standard', 'ZBAA_A_GND', false, '北京机坪', 'Beijing Apron', 122225, NULL, NULL), + ('standard', 'ZBAA_RMP', false, '北京机坪', 'Beijing Apron', 122225, NULL, NULL), + ('standard', 'ZBAA_W_RMP', false, '北京机坪', 'Beijing Apron', 122675, NULL, NULL), + ('standard', 'ZBAA_CTR', false, '北京区域', 'Beijing Control', 128300, 'ZBAB', NULL), + ('standard', 'ZBAA_SW_CTR', false, '北京区域', 'Beijing Control', 128100, 'ZBSW', NULL), + ('standard', 'ZBAA_E_CTR', false, '北京区域', 'Beijing Control', 120350, 'ZBCE', NULL), + ('standard', 'ZBAA_DEL', false, '北京放行', 'Beijing Delivery', 121600, NULL, NULL), + ('standard', 'ZBAA_E_DEL', false, '北京放行', 'Beijing Delivery', 121650, NULL, NULL), + ('standard', 'ZBAA_GND', false, '北京地面', 'Beijing Ground', 121800, NULL, NULL), + ('standard', 'ZBAA_N_CTR', false, '北京区域', 'Beijing Control', 133025, 'ZBCN', NULL), + ('standard', 'ZBAA_S_CTR', false, '北京区域', 'Beijing Control', 127350, 'ZBCS', NULL), + ('standard', 'ZBAA_TWR', false, '北京塔台', 'Beijing Tower', 118500, NULL, NULL), + ('standard', 'ZBAA_W_CTR', false, '北京区域', 'Beijing Control', 128100, 'ZBCW', NULL), + ('standard', 'ZBAD_A_GND', false, '大兴机坪', 'Daxing Apron', 122700, NULL, NULL), + ('standard', 'ZBAD_RMP', false, '大兴机坪', 'Daxing Apron', 122700, NULL, NULL), + ('standard', 'ZBAD_DEL', false, '大兴放行', 'Daxing Delivery', 121875, NULL, NULL), + ('standard', 'ZBAD_GND', false, '大兴地面', 'Daxing Ground', 121625, NULL, NULL), + ('standard', 'ZBAD_TWR', false, '大兴塔台', 'Daxing Tower', 118375, NULL, NULL), + ('standard', 'ZBAL_TWR', true, '阿拉善左旗塔台', 'Alxa Left Banner Tower', 123550, NULL, NULL), + ('standard', 'ZBAR_TWR', true, '阿拉善右旗塔台', 'Alxa Right Banner Tower', 130350, NULL, NULL), + ('standard', 'ZBCD_TWR', true, '承德塔台', 'Chengde Tower', 118025, NULL, NULL), + ('standard', 'ZBCF_TWR', true, '赤峰塔台', 'Chifeng Tower', 118100, NULL, NULL), + ('standard', 'ZBCZ_TWR', true, '长治塔台', 'Changzhi Tower', 130000, NULL, NULL), + ('standard', 'ZBDH_TWR', true, '北戴河塔台', 'Beidaihe Tower', 118150, NULL, NULL), + ('standard', 'ZBDS_TWR', true, '鄂尔多斯塔台', 'Ordos Tower', 118150, NULL, NULL), + ('standard', 'ZBDT_TWR', true, '大同塔台', 'Datong Tower', 118650, NULL, NULL), + ('standard', 'ZBEN_TWR', true, '额济纳塔台', 'Ejina Tower', 130550, NULL, NULL), + ('standard', 'ZBER_TWR', true, '二连浩特塔台', 'Erenhot Tower', 118250, NULL, NULL), + ('standard', 'ZBES_TWR', true, '阿尔山塔台', 'Aershan Tower', 118650, NULL, NULL), + ('standard', 'ZBHD_TWR', true, '邯郸塔台', 'Handan Tower', 118600, NULL, NULL), + ('standard', 'ZBHH_APP', false, '呼和进近', 'Hohhot Approach', 124850, NULL, NULL), + ('standard', 'ZBHH_A_GND', false, '呼和机坪', 'Hohhot Apron', 121650, NULL, NULL), + ('standard', 'ZBHH_RMP', false, '呼和机坪', 'Hohhot Apron', 121650, NULL, NULL), + ('standard', 'ZBHH_CTR', false, '呼和区域', 'Hohhot Control', 133700, NULL, NULL), + ('standard', 'ZBHH_GND', false, '呼和地面', 'Hohhot Ground', 121900, NULL, NULL), + ('standard', 'ZBHH_TWR', false, '呼和塔台', 'Hohhot Tower', 118100, NULL, NULL), + ('standard', 'ZBHZ_TWR', true, '霍林河塔台', 'Huolinhe Tower', 118075, NULL, NULL), + ('standard', 'ZBLA_APP', false, '呼伦贝尔进近', 'Hulunbuir Approach', 119025, NULL, NULL), + ('standard', 'ZBLA_GND', false, '海拉尔地面', 'Hailar Ground', 121650, NULL, NULL), + ('standard', 'ZBLA_TWR', false, '海拉尔塔台', 'Hailar Tower', 118500, NULL, NULL), + ('standard', 'ZBLF_TWR', true, '临汾塔台', 'Linfen Tower', 118125, NULL, NULL), + ('standard', 'ZBLL_TWR', true, '吕梁塔台', 'Lvliang Tower', 130550, NULL, NULL), + ('standard', 'ZBMZ_TWR', true, '满洲里塔台', 'Manzhouli Tower', 118100, NULL, NULL), + ('standard', 'ZBOW_TWR', true, '包头塔台', 'Baotou Tower', 118200, NULL, NULL), + ('standard', 'ZBSG_TWR', true, '朔州塔台', 'Shuozhou Tower', 118475, NULL, NULL), + ('standard', 'ZBSJ_APP', false, '石家庄进近', 'Shijiazhuang Approach', 120450, NULL, NULL), + ('standard', 'ZBSJ_DEL', false, '石家庄放行', 'Shijiazhuang Delivery', 121725, NULL, NULL), + ('standard', 'ZBSJ_GND', false, '石家庄地面', 'Shijiazhuang Ground', 121600, NULL, NULL), + ('standard', 'ZBSJ_TWR', false, '石家庄塔台', 'Shijiazhuang Tower', 118350, NULL, NULL), + ('standard', 'ZBSN_TWR', true, '唐山塔台', 'Tangshan Tower', 130350, NULL, NULL), + ('standard', 'ZBTJ_APP', false, '天津进近', 'Tianjin Approach', 119275, NULL, NULL), + ('standard', 'ZBTJ_A_GND', false, '天津机坪', 'Tianjin Apron', 121725, NULL, NULL), + ('standard', 'ZBTJ_RMP', false, '天津机坪', 'Tianjin Apron', 121725, NULL, NULL), + ('standard', 'ZBTJ_DEL', false, '天津放行', 'Tianjin Delivery', 121800, NULL, NULL), + ('standard', 'ZBTJ_GND', false, '天津地面', 'Tianjin Ground', 121950, NULL, NULL), + ('standard', 'ZBTJ_TWR', false, '天津塔台', 'Tianjin Tower', 118200, NULL, NULL), + ('standard', 'ZBTL_TWR', true, '通辽塔台', 'Tongliao Tower', 118225, NULL, NULL), + ('standard', 'ZBUC_TWR', true, '乌兰察布塔台', 'Ulanqab Tower', 118075, NULL, NULL), + ('standard', 'ZBUH_TWR', true, '乌海塔台', 'Wuhai Tower', 118050, NULL, NULL), + ('standard', 'ZBUL_TWR', true, '乌兰浩特塔台', 'Ulanhot Tower', 118475, NULL, NULL), + ('standard', 'ZBXH_TWR', true, '锡林塔台', 'Xilin Tower', 124300, NULL, NULL), + ('standard', 'ZBXT_TWR', true, '邢台塔台', 'Xingtai Tower', 118175, NULL, NULL), + ('standard', 'ZBXZ_TWR', true, '五台山塔台', 'Wutaishan Tower', 118025, NULL, NULL), + ('standard', 'ZBYC_TWR', true, '运城塔台', 'Yuncheng Tower', 118700, NULL, NULL), + ('standard', 'ZBYN_APP', false, '太原进近', 'Taiyuan Approach', 119200, NULL, NULL), + ('standard', 'ZBYN_A_GND', false, '太原机坪', 'Taiyuan Apron', 121675, NULL, NULL), + ('standard', 'ZBYN_RMP', false, '太原机坪', 'Taiyuan Apron', 121675, NULL, NULL), + ('standard', 'ZBYN_DEL', false, '太原放行', 'Taiyuan Delivery', 121925, NULL, NULL), + ('standard', 'ZBYN_GND', false, '太原地面', 'Taiyuan Ground', 121800, NULL, NULL), + ('standard', 'ZBYN_TWR', false, '太原塔台', 'Taiyuan Tower', 118250, NULL, NULL), + ('standard', 'ZBYZ_TWR', true, '巴彦淖尔塔台', 'Bayannaoer Tower', 118400, NULL, NULL), + ('standard', 'ZBZJ_TWR', true, '张家口塔台', 'Zhangjiakou Tower', 118125, NULL, NULL), + ('standard', 'ZBZL_TWR', true, '扎兰屯塔台', 'Zhalantun Tower', 118025, NULL, NULL), + ('standard', 'ZGBH_TWR', true, '北海塔台', 'Beihai Tower', 118050, NULL, NULL), + ('standard', 'ZGBS_TWR', true, '百色塔台', 'Baise Tower', 118250, NULL, NULL), + ('standard', 'ZGCD_TWR', true, '常德塔台', 'Changde Tower', 118850, NULL, NULL), + ('standard', 'ZGCJ_TWR', true, '芷江塔台', 'Zhijiang Tower', 118400, NULL, NULL), + ('standard', 'ZGCZ_TWR', true, '郴州塔台', 'Chenzhou Tower', 118050, NULL, NULL), + ('standard', 'ZGDY_TWR', true, '张家界塔台', 'Zhangjiajie Tower', 118450, NULL, NULL), + ('standard', 'ZGGG_AO_GND', false, '机坪放行', 'Apron Delivery', 121975, NULL, '暂不使用'), + ('standard', 'ZGGG_O_RMP', false, '机坪放行', 'Apron Delivery', 121975, NULL, '暂不使用'), + ('standard', 'ZGGG_APP', false, '广州进近', 'Guangzhou Approach', 126550, NULL, NULL), + ('standard', 'ZGGG_AE_GND', false, '白云机坪', 'Baiyun Apron', 121625, NULL, NULL), + ('standard', 'ZGGG_E_RMP', false, '白云机坪', 'Baiyun Apron', 121625, NULL, NULL), + ('standard', 'ZGGG_A_GND', false, '白云机坪', 'Baiyun Apron', 121825, NULL, NULL), + ('standard', 'ZGGG_RMP', false, '白云机坪', 'Baiyun Apron', 121825, NULL, NULL), + ('standard', 'ZGGG_CTR', false, '广州区域', 'Guangzhou Control', 118950, 'ZGGC', NULL), + ('standard', 'ZGGG_DEL', false, '白云放行', 'Baiyun Delivery', 121950, NULL, NULL), + ('standard', 'ZGGG_E_DEL', false, '白云放行', 'Baiyun Delivery', 122175, NULL, NULL), + ('standard', 'ZGGG_E_TWR', false, '白云塔台', 'Baiyun Tower', 118100, NULL, NULL), + ('standard', 'ZGGG_GND', false, '白云地面', 'Baiyun Ground', 121750, NULL, NULL), + ('standard', 'ZGGG_TWR', false, '白云塔台', 'Baiyun Tower', 123550, NULL, NULL), + ('standard', 'ZGGG_E_GND', false, '白云地面', 'Baiyun Ground', 121550, NULL, NULL), + ('standard', 'ZGGG_W_TWR', false, '白云塔台', 'Baiyun Tower', 118325, NULL, NULL), + ('standard', 'ZGGG_W_GND', false, '白云地面', 'Baiyun Ground', 121850, NULL, NULL), + ('standard', 'ZGHA_APP', false, '长沙进近', 'Changsha Approach', 119650, NULL, NULL), + ('standard', 'ZGHA_A_GND', false, '黄花机坪', 'Changsha Apron', 121850, NULL, NULL), + ('standard', 'ZGHA_RMP', false, '黄花机坪', 'Changsha Apron', 121850, NULL, NULL), + ('standard', 'ZGHA_CTR', false, '长沙区域', 'Changsha Control', 132550, NULL, NULL), + ('standard', 'ZGHA_DEL', false, '黄花放行', 'Huanghua Delivery', 121950, NULL, NULL), + ('standard', 'ZGHA_GND', false, '黄花地面', 'Huanghua Ground', 121750, NULL, NULL), + ('standard', 'ZGHA_TWR', false, '黄花塔台', 'Huanghua Tower', 118550, NULL, NULL), + ('standard', 'ZGHC_TWR', true, '河池塔台', 'Hechi Tower', 118875, NULL, NULL), + ('standard', 'ZGHY_TWR', true, '衡阳塔台', 'Hengyang Tower', 130475, NULL, NULL), + ('standard', 'ZGHZ_TWR', true, '惠州塔台', 'Huizhou Tower', 118500, NULL, NULL), + ('standard', 'ZGJD_APP', false, '珠海进近', 'Zhuhai Approach', 120350, NULL, NULL), + ('standard', 'ZGKL_APP', false, '桂林进近', 'Guilin Approach', 120850, NULL, NULL), + ('standard', 'ZGKL_GND', false, '两江地面', 'Liangjiang Ground', 121650, NULL, NULL), + ('standard', 'ZGKL_TWR', false, '两江塔台', 'Liangjiang Tower', 118000, NULL, NULL), + ('standard', 'ZGLD_TWR', true, '罗定塔台', 'Luoding Tower', 124300, NULL, NULL), + ('standard', 'ZGLG_TWR', true, '零陵塔台', 'Lingling Tower', 130000, NULL, NULL), + ('standard', 'ZGMX_TWR', true, '梅县塔台', 'Meixian Tower', 118150, NULL, NULL), + ('standard', 'ZGNN_APP', false, '南宁进近', 'Nanning Approach', 121250, NULL, NULL), + ('standard', 'ZGNN_A_GND', false, '南宁机坪', 'Nanning Apron', 121600, NULL, NULL), + ('standard', 'ZGNN_RMP', false, '南宁机坪', 'Nanning Apron', 121600, NULL, NULL), + ('standard', 'ZGNN_CTR', false, '南宁区域', 'Nanning Control', 132700, 'ZGNC', NULL), + ('standard', 'ZGNN_GND', false, '南宁地面', 'Nanning Ground', 121750, NULL, NULL), + ('standard', 'ZGNN_TWR', false, '南宁塔台', 'Nanning Tower', 130350, NULL, NULL), + ('standard', 'ZGOW_APP', false, '汕头进近', 'Shantou Approach', 120650, NULL, NULL), + ('standard', 'ZGOW_GND', false, '潮汕地面', 'Chaoshan Ground', 130850, NULL, NULL), + ('standard', 'ZGOW_TWR', false, '潮汕塔台', 'Chaoshan Tower', 118350, NULL, NULL), + ('standard', 'ZGSD_A_GND', false, '金湾机坪', 'Jinwan Apron', 121675, NULL, NULL), + ('standard', 'ZGSD_RMP', false, '金湾机坪', 'Jinwan Apron', 121675, NULL, NULL), + ('standard', 'ZGSD_GND', false, '金湾地面', 'Jinwan Ground', 121750, NULL, NULL), + ('standard', 'ZGSD_TWR', false, '金湾塔台', 'Jinwan Tower', 118650, NULL, NULL), + ('standard', 'ZGSG_TWR', true, '韶关塔台', 'Shaoguan Tower', 118575, NULL, NULL), + ('standard', 'ZGSY_TWR', true, '武冈塔台', 'Wugang Tower', 118025, NULL, NULL), + ('standard', 'ZGSZ_A_GND', false, '宝安机坪', 'Bao''an Apron', 122700, NULL, NULL), + ('standard', 'ZGSZ_RMP', false, '宝安机坪', 'Bao''an Apron', 122700, NULL, NULL), + ('standard', 'ZGSZ_DEL', false, '宝安放行', 'Bao''an Delivery', 121950, NULL, NULL), + ('standard', 'ZGSZ_GND', false, '宝安地面', 'Bao''an Ground', 121650, NULL, NULL), + ('standard', 'ZGSZ_TWR', false, '宝安塔台', 'Bao''an Tower', 118450, NULL, NULL), + ('standard', 'ZGUH_TWR', true, '九洲塔台', 'Jiuzhou Tower', 124300, NULL, NULL), + ('standard', 'ZGWZ_TWR', true, '梧州塔台', 'Wuzhou Tower', 118850, NULL, NULL), + ('standard', 'ZGXX_TWR', true, '边城塔台', 'Biancheng Tower', 118075, NULL, NULL), + ('standard', 'ZGYJ_TWR', true, '阳江塔台', 'Yangjiang Tower', 134150, NULL, NULL), + ('standard', 'ZGYL_TWR', true, '玉林塔台', 'Yulin Tower', 118125, NULL, NULL), + ('standard', 'ZGYY_TWR', true, '岳阳塔台', 'Yueyang Tower', 118225, NULL, NULL), + ('standard', 'ZGZH_TWR', true, '柳州塔台', 'Liuzhou Tower', 118600, NULL, NULL), + ('standard', 'ZGZJ_APP', false, '湛江进近', 'Zhanjiang Approach', 120875, NULL, NULL), + ('standard', 'ZGZJ_TWR', false, '湛江塔台', 'Zhanjiang Tower', 118750, NULL, NULL), + ('standard', 'ZHCC_APP', false, '郑州进近', 'Zhengzhou Approach', 120275, NULL, NULL), + ('standard', 'ZHCC_A_GND', false, '郑州机坪', 'Zhengzhou Apron', 121700, NULL, NULL), + ('standard', 'ZHCC_RMP', false, '郑州机坪', 'Zhengzhou Apron', 121700, NULL, NULL), + ('standard', 'ZHCC_CTR', false, '郑州区域', 'Zhengzhou Control', 119350, NULL, NULL), + ('standard', 'ZHCC_DEL', false, '郑州放行', 'Zhengzhou Delivery', 121800, NULL, NULL), + ('standard', 'ZHCC_GND', false, '郑州地面', 'Zhengzhou Ground', 121600, NULL, NULL), + ('standard', 'ZHCC_TWR', false, '郑州塔台', 'Zhengzhou Tower', 118075, NULL, NULL), + ('standard', 'ZHEC_DEL', false, '花湖放行', 'Huahu Delivery', 121825, NULL, NULL), + ('standard', 'ZHEC_GND', false, '花湖地面', 'Huahu Ground', 121625, NULL, NULL), + ('standard', 'ZHEC_TWR', false, '花湖塔台', 'Huahu Tower', 118525, NULL, NULL), + ('standard', 'ZHES_TWR', true, '恩施塔台', 'Enshi Tower', 118750, NULL, NULL), + ('standard', 'ZHHH_APP', false, '武汉进近', 'Wuhan Approach', 121200, NULL, NULL), + ('standard', 'ZHHH_A_GND', false, '武汉机坪', 'Wuhan Apron', 121600, NULL, NULL), + ('standard', 'ZHHH_RMP', false, '武汉机坪', 'Wuhan Apron', 121600, NULL, NULL), + ('standard', 'ZHHH_CTR', false, '武汉区域', 'Wuhan Control', 119700, NULL, NULL), + ('standard', 'ZHHH_DEL', false, '武汉放行', 'Wuhan Delivery', 121800, NULL, NULL), + ('standard', 'ZHHH_GND', false, '武汉地面', 'Wuhan Ground', 121650, NULL, NULL), + ('standard', 'ZHHH_TWR', false, '武汉地面', 'Wuhan Tower', 124350, NULL, NULL), + ('standard', 'ZHJZ_TWR', true, '荆州塔台', 'Jingzhou Tower', 118150, NULL, NULL), + ('standard', 'ZHLY_GND', false, '洛阳地面', 'Luoyang Ground', 121700, NULL, NULL), + ('standard', 'ZHLY_TWR', true, '洛阳塔台', 'Luoyang Tower', 118000, NULL, NULL), + ('standard', 'ZHNY_GND', false, '南阳地面', 'Nanyang Ground', 128875, NULL, NULL), + ('standard', 'ZHNY_TWR', true, '南阳塔台', 'Nanyang Tower', 118500, NULL, NULL), + ('standard', 'ZHQQ_TWR', true, '安阳塔台', 'Anyang Tower', 122450, NULL, NULL), + ('standard', 'ZHSN_TWR', true, '神农架塔台', 'Shennongjia Tower', 118575, NULL, NULL), + ('standard', 'ZHSY_TWR', true, '十堰塔台', 'Shiyan Tower', 118025, NULL, NULL), + ('standard', 'ZHXF_TWR', true, '襄阳塔台', 'Xiangyang Tower', 118200, NULL, NULL), + ('standard', 'ZHXY_TWR', true, '信阳塔台', 'Xinyang Tower', 118050, NULL, NULL), + ('standard', 'ZHYC_TWR', true, '宜昌塔台', 'Yichang Tower', 118350, NULL, NULL), + ('standard', 'ZJHK_APP', false, '海口进近', 'Haikou Approach', 119150, NULL, NULL), + ('standard', 'ZJHK_A_GND', false, '美兰机坪', 'Meilan Apron', 121800, NULL, NULL), + ('standard', 'ZJHK_RMP', false, '美兰机坪', 'Meilan Apron', 121800, NULL, NULL), + ('standard', 'ZJHK_DEL', false, '海口放行', 'Haikou Delivery', 121900, NULL, NULL), + ('standard', 'ZJHK_GND', false, '海口地面', 'Haikou Ground', 121650, NULL, NULL), + ('standard', 'ZJHK_TWR', false, '海口塔台', 'Haikou Tower', 118550, NULL, NULL), + ('standard', 'ZJQH_TWR', true, '博鳌塔台', 'Boao Tower', 118025, NULL, NULL), + ('standard', 'ZJSY_APP', false, '三亚进近', 'Sanya Approach', 125550, NULL, NULL), + ('standard', 'ZJSY_A_GND', false, '凤凰机坪', 'Fenghuang Apron', 121600, NULL, NULL), + ('standard', 'ZJSY_RMP', false, '凤凰机坪', 'Fenghuang Apron', 121600, NULL, NULL), + ('standard', 'ZJSY_GND', false, '凤凰地面', 'Fenghuang Ground', 121700, NULL, NULL), + ('standard', 'ZJSY_DEL', false, '凤凰放行', 'Fenghuang Delivery', 121825, NULL, NULL), + ('standard', 'ZJSY_CTR', false, '三亚区域', 'Sanya Control', 120500, NULL, '暂不使用'), + ('standard', 'ZJSY_L_CTR', false, '三亚区域', 'Sanya Control', 133200, 'ZJSL', NULL), + ('standard', 'ZJSY_O_CTR', false, 'N/A', 'Sanya Control', 130200, 'ZJSO', NULL), + ('standard', 'ZJSY_TWR', false, '凤凰塔台', 'Fenghuang Tower', 118150, NULL, NULL), + ('standard', 'ZJYX_TWR', true, '永兴塔台', 'Yongxing Tower', 118100, NULL, NULL), + ('standard', 'ZLAK_TWR', true, '安康塔台', 'Ankang Tower', 118825, NULL, NULL), + ('standard', 'ZLDH_TWR', true, '敦煌塔台', 'Dunhuang Tower', 118600, NULL, NULL), + ('standard', 'ZLDL_TWR', true, '德令哈塔台', 'Delingha Tower', 118150, NULL, NULL), + ('standard', 'ZLGL_TWR', true, '玛沁塔台', 'Maqin Tower', 118025, NULL, NULL), + ('standard', 'ZLGM_TWR', true, '格尔木塔台', 'Ge''ermu Tower', 130000, NULL, NULL), + ('standard', 'ZLGY_TWR', true, '固原塔台', 'Guyuan Tower', 118300, NULL, NULL), + ('standard', 'ZLHB_TWR', true, '祁连塔台', 'Qilian Tower', 118075, NULL, NULL), + ('standard', 'ZLHX_TWR', true, '芒崖塔台', 'Mangya Tower', 118050, NULL, NULL), + ('standard', 'ZLHZ_TWR', true, '汉中塔台', 'Hanzhong Tower', 130525, NULL, NULL), + ('standard', 'ZLIC_APP', false, '银川进近', 'Yinchuan Approach', 124050, NULL, NULL), + ('standard', 'ZLIC_A_GND', false, '银川机坪', 'Yinchuan Apron', 121950, NULL, NULL), + ('standard', 'ZLIC_RMP', false, '银川机坪', 'Yinchuan Apron', 121950, NULL, NULL), + ('standard', 'ZLIC_GND', false, '银川地面', 'Yinchuan Ground', 121800, NULL, NULL), + ('standard', 'ZLIC_TWR', false, '银川塔台', 'Yinchuan Tower', 118350, NULL, NULL), + ('standard', 'ZLJC_TWR', true, '金昌塔台', 'Jinchang Tower', 118050, NULL, NULL), + ('standard', 'ZLJQ_TWR', true, '嘉峪关塔台', 'Jiayuguan Tower', 118200, NULL, NULL), + ('standard', 'ZLLL_APP', false, '兰州进近', 'Lanzhou Approach', 120250, NULL, NULL), + ('standard', 'ZLLL_A_GND', false, '兰州机坪', 'Lanzhou Apron', 121800, NULL, NULL), + ('standard', 'ZLLL_RMP', false, '兰州机坪', 'Lanzhou Apron', 121800, NULL, NULL), + ('standard', 'ZLLL_CTR', false, '兰州区域', 'Lanzhou Control', 134700, 'ZLLC', NULL), + ('standard', 'ZLLL_DEL', false, '兰州放行', 'Lanzhou Delivery', 121700, NULL, NULL), + ('standard', 'ZLLL_GND', false, '兰州地面', 'Lanzhou Ground', 122200, NULL, NULL), + ('standard', 'ZLLL_TWR', false, '兰州塔台', 'Lanzhou Tower', 118400, NULL, NULL), + ('standard', 'ZLLN_TWR', true, '陇南塔台', 'Longnan Tower', 118025, NULL, NULL), + ('standard', 'ZLQY_TWR', true, '庆阳塔台', 'Qingyang Tower', 118250, NULL, NULL), + ('standard', 'ZLTS_TWR', true, '天水塔台', 'Tianshui Tower', 118450, NULL, NULL), + ('standard', 'ZLXH_TWR', true, '夏河塔台', 'Xiahe Tower', 118600, NULL, NULL), + ('standard', 'ZLXN_APP', false, '西宁进近', 'Xining Approach', 119875, NULL, NULL), + ('standard', 'ZLXN_GND', false, '西宁地面', 'Xining Ground', 121600, NULL, NULL), + ('standard', 'ZLXN_TWR', false, '西宁塔台', 'Xining Tower', 118500, NULL, NULL), + ('standard', 'ZLXY_APP', false, '西安进近', 'Xi''an Approach', 125100, NULL, NULL), + ('standard', 'ZLXY_A_GND', false, '咸阳机坪', 'Xianyang Apron', 121925, NULL, NULL), + ('standard', 'ZLXY_RMP', false, '咸阳机坪', 'Xianyang Apron', 121925, NULL, NULL), + ('standard', 'ZLXY_CTR', false, '西安区域', 'Xi''an Control', 125300, 'ZLXC', NULL), + ('standard', 'ZLXY_DEL', false, '咸阳放行', 'Xianyang Delivery', 121600, NULL, NULL), + ('standard', 'ZLXY_GND', false, '咸阳地面', 'Xianyang Ground', 121650, NULL, NULL), + ('standard', 'ZLXY_TWR', false, '咸阳塔台', 'Xianyang Tower', 130450, NULL, NULL), + ('standard', 'ZLYA_TWR', true, '延安塔台', 'Yan''an Tower', 118175, NULL, NULL), + ('standard', 'ZLYL_TWR', true, '榆林塔台', 'Yulin Tower', 118550, NULL, NULL), + ('standard', 'ZLYS_TWR', true, '玉树塔台', 'Yushu Tower', 118450, NULL, NULL), + ('standard', 'ZLZW_TWR', true, '中卫塔台', 'Zhongwei Tower', 118800, NULL, NULL), + ('standard', 'ZLZY_TWR', true, '张掖塔台', 'Zhangye Tower', 118350, NULL, NULL), + ('standard', 'ZPBS_TWR', true, '保山塔台', 'Baoshan Tower', 118750, NULL, NULL), + ('standard', 'ZPCW_TWR', true, '沧源塔台', 'Cangyuan Tower', 118025, NULL, NULL), + ('standard', 'ZPDL_TWR', true, '大理塔台', 'Dali Tower', 118600, NULL, NULL), + ('standard', 'ZPDQ_TWR', true, '迪庆塔台', 'Diqing Tower', 118150, NULL, NULL), + ('standard', 'ZPJH_APP', false, '版纳进近', 'Banna Approach', 119100, NULL, NULL), + ('standard', 'ZPJH_GND', false, '版纳地面', 'Banna Ground', 121950, NULL, NULL), + ('standard', 'ZPJH_TWR', false, '版纳塔台', 'Banna Tower', 118600, NULL, NULL), + ('standard', 'ZPJM_TWR', true, '澜沧塔台', 'Lancang Tower', 118075, NULL, NULL), + ('standard', 'ZPLC_TWR', true, '临沧塔台', 'Lincang Tower', 118250, NULL, NULL), + ('standard', 'ZPLJ_APP', false, '丽江进近', 'Lijiang Approach', 119050, NULL, NULL), + ('standard', 'ZPLJ_GND', false, '丽江地面', 'Lijiang Ground', 121750, NULL, NULL), + ('standard', 'ZPLJ_TWR', false, '丽江塔台', 'Lijiang Tower', 118450, NULL, NULL), + ('standard', 'ZPMS_TWR', true, '芒市塔台', 'Mangshi Tower', 118200, NULL, NULL), + ('standard', 'ZPNL_TWR', true, '泸沽湖塔台', 'Luguhu Tower', 123625, NULL, NULL), + ('standard', 'ZPPP_APP', false, '昆明进近', 'Kunming Approach', 119000, NULL, NULL), + ('standard', 'ZPPP_A_GND', false, '长水机坪', 'Changshui Apron', 121600, NULL, NULL), + ('standard', 'ZPPP_RMP', false, '长水机坪', 'Changshui Apron', 121600, NULL, NULL), + ('standard', 'ZPPP_CTR', false, '昆明区域', 'Kunming Control', 125750, 'ZPPC', NULL), + ('standard', 'ZPPP_DEL', false, '昆明放行', 'Kunming Delivery', 121700, NULL, NULL), + ('standard', 'ZPPP_GND', false, '昆明地面', 'Kunming Ground', 121650, NULL, NULL), + ('standard', 'ZPPP_TWR', false, '昆明塔台', 'Kunming Tower', 118100, NULL, NULL), + ('standard', 'ZPSM_TWR', true, '普洱塔台', 'Pu''er Tower', 118450, NULL, NULL), + ('standard', 'ZPTC_TWR', true, '腾冲塔台', 'Tengchong Tower', 124350, NULL, NULL), + ('standard', 'ZPWS_TWR', true, '文山塔台', 'Wenshan Tower', 118000, NULL, NULL), + ('standard', 'ZPZT_TWR', true, '昭通塔台', 'Zhaotong Tower', 118750, NULL, NULL), + ('standard', 'ZSAM_APP', false, '厦门进近', 'Xiamen Approach', 121350, NULL, NULL), + ('standard', 'ZSAM_A_GND', false, '厦门机坪', 'Xiamen Apron', 121600, NULL, NULL), + ('standard', 'ZSAM_RMP', false, '厦门机坪', 'Xiamen Apron', 121600, NULL, NULL), + ('standard', 'ZSAM_CTR', false, '厦门区域', 'Xiamen Control', 125700, NULL, NULL), + ('standard', 'ZSAM_DEL', false, '厦门放行', 'Xiamen Delivery', 121950, NULL, NULL), + ('standard', 'ZSAM_GND', false, '厦门地面', 'Xiamen Ground', 121700, NULL, NULL), + ('standard', 'ZSAM_TWR', false, '厦门塔台', 'Xiamen Tower', 118250, NULL, NULL), + ('standard', 'ZSAQ_TWR', true, '安庆塔台', 'Anqing Tower', 118050, NULL, NULL), + ('standard', 'ZSBA_TWR', true, '蚌埠塔台', 'Bengbu Tower', 118325, NULL, NULL), + ('standard', 'ZSBO_TWR', true, '亳州塔台', 'Bozhou Tower', 118625, NULL, NULL), + ('standard', 'ZSCG_APP', false, '常州进近', 'Changzhou Approach', 120600, NULL, NULL), + ('standard', 'ZSCG_GND', false, '常州地面', 'Changzhou Ground', 121650, NULL, NULL), + ('standard', 'ZSCG_TWR', false, '常州塔台', 'Changzhou Tower', 118450, NULL, NULL), + ('standard', 'ZSCN_APP', false, '南昌进近', 'Nanchang Approach', 119950, NULL, NULL), + ('standard', 'ZSCN_A_GND', false, '南昌机坪', 'Nanchang Apron', 121850, NULL, NULL), + ('standard', 'ZSCN_RMP', false, '南昌机坪', 'Nanchang Apron', 121850, NULL, NULL), + ('standard', 'ZSCN_CTR', false, '南昌区域', 'Nanchang Control', 124150, NULL, NULL), + ('standard', 'ZSCN_GND', false, '南昌地面', 'Nanchang Ground', 121700, NULL, NULL), + ('standard', 'ZSCN_TWR', false, '南昌塔台', 'Nanchang Tower', 118650, NULL, NULL), + ('standard', 'ZSDY_TWR', true, '东营塔台', 'Dongying Tower', 118400, NULL, NULL), + ('standard', 'ZSFY_TWR', true, '阜阳塔台', 'Fuyang Tower', 118400, NULL, NULL), + ('standard', 'ZSFZ_APP', false, '福州进近', 'Fuzhou Approach', 124850, NULL, NULL), + ('standard', 'ZSFZ_A_GND', false, '福州机坪', 'Fuzhou Apron', 121725, NULL, NULL), + ('standard', 'ZSFZ_DEL', false, '福州放行', 'Fuzhou Delivery', 122125, NULL, NULL), + ('standard', 'ZSFZ_RMP', false, '福州机坪', 'Fuzhou Apron', 121725, NULL, NULL), + ('standard', 'ZSFZ_GND', false, '福州地面', 'Fuzhou Ground', 121600, NULL, NULL), + ('standard', 'ZSFZ_TWR', false, '福州塔台', 'Fuzhou Tower', 118450, NULL, NULL), + ('standard', 'ZSGS_TWR', true, '井冈山塔台', 'Jinggangshan Tower', 118500, NULL, NULL), + ('standard', 'ZSGZ_TWR', true, '赣州塔台', 'Ganzhou Tower', 118750, NULL, NULL), + ('standard', 'ZSHC_APP', false, '杭州进近', 'Hangzhou Approach', 125550, NULL, NULL), + ('standard', 'ZSHC_A_GND', false, '杭州机坪', 'Hangzhou Ramp', 121725, NULL, NULL), + ('standard', 'ZSHC_RMP', false, '杭州机坪', 'Hangzhou Ramp', 121725, NULL, NULL), + ('standard', 'ZSHC_DEL', false, '杭州放行', 'Hangzhou Delivery', 121950, NULL, NULL), + ('standard', 'ZSHC_GND', false, '杭州地面', 'Hangzhou Ground', 121650, NULL, NULL), + ('standard', 'ZSHC_TWR', false, '杭州塔台', 'Hangzhou Tower', 118300, NULL, NULL), + ('standard', 'ZSHZ_TWR', true, '菏泽塔台', 'Heze Tower', 118675, NULL, NULL), + ('standard', 'ZSJD_TWR', true, '景德镇塔台', 'Jingdezhen Tower', 118350, NULL, NULL), + ('standard', 'ZSJG_GND', false, '济宁地面', 'Jining Ground', 121650, NULL, NULL), + ('standard', 'ZSJG_TWR', true, '济宁塔台', 'Jining Tower', 118775, NULL, NULL), + ('standard', 'ZSJH_TWR', true, '九华山塔台', 'Jiuhuashan Tower', 123050, NULL, NULL), + ('standard', 'ZSJJ_TWR', true, '九江塔台', 'Jiujiang Tower', 122450, NULL, NULL), + ('standard', 'ZSJN_APP', false, '济南进近', 'Ji''nan Approach', 119050, NULL, NULL), + ('standard', 'ZSJN_A_GND', false, '济南机坪', 'Ji''nan Apron', 121725, NULL, NULL), + ('standard', 'ZSJN_RMP', false, '济南机坪', 'Ji''nan Apron', 121725, NULL, NULL), + ('standard', 'ZSJN_CTR', false, '济南区域', 'Ji''nan Control', 122900, NULL, NULL), + ('standard', 'ZSJN_DEL', false, '济南放行', 'Ji''nan Delivery', 121650, NULL, NULL), + ('standard', 'ZSJN_GND', false, '济南地面', 'Ji''nan Ground', 121850, NULL, NULL), + ('standard', 'ZSJN_TWR', false, '济南塔台', 'Ji''nan Tower', 118550, NULL, NULL), + ('standard', 'ZSJU_TWR', true, '衢州塔台', 'Quzhou Tower', 118400, NULL, NULL), + ('standard', 'ZSJX_TWR', true, '嘉兴塔台', 'Jiaxing Tower', 130325, NULL, NULL), + ('standard', 'ZSLI_TWR', true, '丽水塔台', 'Lishui Tower', 118625, NULL, NULL), + ('standard', 'ZSLG_TWR', true, '连云港塔台', 'Lianyungang Tower', 118375, NULL, NULL), + ('standard', 'ZSLO_TWR', true, '连城塔台', 'Liancheng Tower', 118850, NULL, NULL), + ('standard', 'ZSLQ_TWR', true, '黄岩塔台', 'Huangyan Tower', 130000, NULL, NULL), + ('standard', 'ZSLY_TWR', true, '临沂塔台', 'Linyi Tower', 118150, NULL, NULL), + ('standard', 'ZSNB_APP', false, '宁波进近', 'Ningbo Approach', 125450, NULL, NULL), + ('standard', 'ZSNB_A_GND', false, '宁波机坪', 'Ningbo Apron', 121600, NULL, NULL), + ('standard', 'ZSNB_RMP', false, '宁波机坪', 'Ningbo Apron', 121600, NULL, NULL), + ('standard', 'ZSNB_DEL', false, '宁波放行', 'Ningbo Delivery', 121950, NULL, NULL), + ('standard', 'ZSNB_TWR', false, '宁波塔台', 'Ningbo Tower', 118350, NULL, NULL), + ('standard', 'ZSNJ_APP', false, '南京进近', 'Nanjing Approach', 119250, NULL, NULL), + ('standard', 'ZSNJ_A_GND', false, '南京机坪', 'Nanjing Apron', 121975, NULL, NULL), + ('standard', 'ZSNJ_RMP', false, '南京机坪', 'Nanjing Apron', 121975, NULL, NULL), + ('standard', 'ZSNJ_DEL', false, '南京放行', 'Nanjing Delivery', 121900, NULL, NULL), + ('standard', 'ZSNJ_GND', false, '南京地面', 'Nanjing Ground', 121700, NULL, NULL), + ('standard', 'ZSNJ_TWR', false, '南京塔台', 'Nanjing Tower', 118850, NULL, NULL), + ('standard', 'ZSNT_TWR', true, '南通塔台', 'Nantong Tower', 118200, NULL, NULL), + ('standard', 'ZSOF_APP', false, '合肥进近', 'Hefei Approach', 119850, NULL, NULL), + ('standard', 'ZSOF_A_GND', false, '合肥机坪', 'Hefei Apron', 121725, NULL, NULL), + ('standard', 'ZSOF_RMP', false, '合肥机坪', 'Hefei Apron', 121725, NULL, NULL), + ('standard', 'ZSOF_CTR', false, '合肥区域', 'Hefei Control', 125775, NULL, NULL), + ('standard', 'ZSOF_GND', false, '合肥地面', 'Hefei Ground', 121625, NULL, NULL), + ('standard', 'ZSOF_TWR', false, '合肥塔台', 'Hefei Tower', 118750, NULL, NULL), + ('standard', 'ZSPD_E_TWR', false, '浦东塔台', 'Pudong Tower', 118400, NULL, NULL), + ('standard', 'ZSPD_AS_GND', false, '浦东机坪', 'Pudong Apron', 122600, NULL, NULL), + ('standard', 'ZSPD_S_RMP', false, '浦东机坪', 'Pudong Apron', 122600, NULL, NULL), + ('standard', 'ZSPD_A_GND', false, '浦东机坪', 'Pudong Apron', 121650, NULL, NULL), + ('standard', 'ZSPD_RMP', false, '浦东机坪', 'Pudong Apron', 121650, NULL, NULL), + ('standard', 'ZSPD_DEL', false, '浦东放行', 'Pudong Delivery', 121950, NULL, NULL), + ('standard', 'ZSPD_GND', false, '浦东地面', 'Pudong Ground', 121700, NULL, NULL), + ('standard', 'ZSPD_TWR', false, '浦东塔台', 'Pudong Tower', 118800, NULL, NULL), + ('standard', 'ZSQD_APP', false, '青岛进近', 'Qingdao Approach', 119400, NULL, NULL), + ('standard', 'ZSQD_A_GND', false, '青岛机坪', 'Qingdao Apron', 121600, NULL, NULL), + ('standard', 'ZSQD_RMP', false, '青岛机坪', 'Qingdao Apron', 121600, NULL, NULL), + ('standard', 'ZSQD_CTR', false, '青岛区域', 'Qingdao Control', 128550, 'ZSQC', NULL), + ('standard', 'ZSQD_DEL', false, '青岛放行', 'Qingdao Delivery', 121950, NULL, NULL), + ('standard', 'ZSQD_GND', false, '青岛地面', 'Qingdao Ground', 121650, NULL, NULL), + ('standard', 'ZSQD_TWR', false, '青岛塔台', 'Qingdao Tower', 118700, NULL, NULL), + ('standard', 'ZSQZ_GND', false, '晋江地面', 'Jinjiang Ground', 121625, NULL, NULL), + ('standard', 'ZSQZ_TWR', false, '晋江塔台', 'Jinjiang Tower', 118050, NULL, NULL), + ('standard', 'ZSRJ_TWR', true, '瑞金塔台', 'Ruijin Tower', 123575, NULL, NULL), + ('standard', 'ZSRZ_TWR', true, '日照塔台', 'Rizhao Tower', 118525, NULL, NULL), + ('standard', 'ZSSH_TWR', true, '淮安塔台', 'Huaian Tower', 130350, NULL, NULL), + ('standard', 'ZSSM_TWR', true, '三明塔台', 'Sanming Tower', 118025, NULL, NULL), + ('standard', 'ZSSR_TWR', true, '上饶塔台', 'Shangrao Tower', 118075, NULL, NULL), + ('standard', 'ZSSS_APP', false, '上海进近', 'Shanghai Approach', 120300, NULL, NULL), + ('standard', 'ZSSS_A_GND', false, '虹桥机坪', 'Hongqiao Apron', 121675, NULL, NULL), + ('standard', 'ZSSS_RMP', false, '虹桥机坪', 'Hongqiao Apron', 121675, NULL, NULL), + ('standard', 'ZSSS_CTR', false, '上海区域', 'Shanghai Control', 120950, 'ZSSC', NULL), + ('standard', 'ZSSS_C_CTR', false, '上海区域', 'Shanghai Control', 132625, 'ZSCC', NULL), + ('standard', 'ZSSS_DEL', false, '虹桥放行', 'Hongqiao Delivery', 121750, NULL, NULL), + ('standard', 'ZSSS_E_APP', false, '上海进近', 'Shanghai Approach', 127750, NULL, NULL), + ('standard', 'ZSSS_F_APP', false, '上海进近', 'Shanghai Approach', 125400, NULL, NULL), + ('standard', 'ZSSS_GND', false, '虹桥地面', 'Hongqiao Ground', 121600, NULL, NULL), + ('standard', 'ZSSS_N_CTR', false, '上海区域', 'Shanghai Control', 135000, 'ZSSN', NULL), + ('standard', 'ZSSS_PF_APP', false, '上海进近', 'Shanghai Approach', 121100, NULL, NULL), + ('standard', 'ZSSS_S_APP', false, '上海进近', 'Shanghai Approach', 119975, NULL, NULL), + ('standard', 'ZSSS_S_CTR', false, '上海区域', 'Shanghai Control', 128350, 'ZSCS', NULL), + ('standard', 'ZSSS_TWR', false, '虹桥塔台', 'Hongqiao Tower', 118100, NULL, NULL), + ('standard', 'ZSSS_W_APP', false, '上海进近', 'Shanghai Approach', 119075, NULL, NULL), + ('standard', 'ZSSS_W_CTR', false, '上海区域', 'Shanghai Control', 132400, 'ZSSW', NULL), + ('standard', 'ZSTX_TWR', true, '黄山塔台', 'Huangshan Tower', 124300, NULL, NULL), + ('standard', 'ZSWA_TWR', true, '芜湖塔台', 'Wuhu Tower', 118025, NULL, NULL), + ('standard', 'ZSWF_TWR', true, '潍坊塔台', 'Weifang Tower', 130000, NULL, NULL), + ('standard', 'ZSWH_TWR', true, '威海塔台', 'Weihai Tower', 130000, NULL, NULL), + ('standard', 'ZSWX_APP', false, '无锡进近', 'Wuxi Approach', 119450, NULL, NULL), + ('standard', 'ZSWX_GND', false, '无锡地面', 'Wuxi Ground', 121625, NULL, NULL), + ('standard', 'ZSWX_TWR', false, '无锡塔台', 'Wuxi Tower', 118000, NULL, NULL), + ('standard', 'ZSWY_TWR', true, '武夷山塔台', 'Wuyishan Tower', 118150, NULL, NULL), + ('standard', 'ZSWZ_APP', false, '温州进近', 'Wenzhou Approach', 119625, NULL, NULL), + ('standard', 'ZSWZ_GND', false, '温州地面', 'Wenzhou Ground', 121850, NULL, NULL), + ('standard', 'ZSWZ_TWR', false, '温州塔台', 'Wenzhou Tower', 118875, NULL, NULL), + ('standard', 'ZSXZ_TWR', true, '徐州塔台', 'Xuzhou Tower', 118250, NULL, NULL), + ('standard', 'ZSYA_TWR', true, '扬州塔台', 'Yangzhou Tower', 130450, NULL, NULL), + ('standard', 'ZSYC_TWR', true, '宜春塔台', 'Yichun Tower', 130600, NULL, NULL), + ('standard', 'ZSYN_TWR', true, '盐城塔台', 'Yancheng Tower', 123150, NULL, NULL), + ('standard', 'ZSYT_APP', false, '烟台进近', 'Yantai Approach', 119150, NULL, NULL), + ('standard', 'ZSYT_GND', false, '烟台地面', 'Yantai Ground', 121600, NULL, NULL), + ('standard', 'ZSYT_TWR', false, '烟台塔台', 'Yantai Tower', 118450, NULL, NULL), + ('standard', 'ZSYW_TWR', true, '义乌塔台', 'Yiwu Tower', 118500, NULL, NULL), + ('standard', 'ZSZS_TWR', true, '舟山塔台', 'Zhoushan Tower', 118050, NULL, NULL), + ('standard', 'ZUAL_TWR', true, '阿里塔台', 'Ali Tower', 118100, NULL, NULL), + ('standard', 'ZUAS_TWR', true, '安顺塔台', 'Anshun Tower', 118700, NULL, NULL), + ('standard', 'ZUBD_TWR', true, '昌都塔台', 'Changdu Tower', 118200, NULL, NULL), + ('standard', 'ZUBJ_TWR', true, '毕节塔台', 'Bijie Tower', 130550, NULL, NULL), + ('standard', 'ZUBZ_TWR', true, '巴中塔台', 'Bazhong Tower', 118875, NULL, NULL), + ('standard', 'ZUCK_APP', false, '重庆进近', 'Chongqing Approach', 125200, NULL, NULL), + ('standard', 'ZUCK_A_GND', false, '江北机坪', 'Jiangbei Apron', 121600, NULL, NULL), + ('standard', 'ZUCK_RMP', false, '江北机坪', 'Jiangbei Apron', 121600, NULL, NULL), + ('standard', 'ZUCK_DEL', false, '重庆放行', 'Chongqing Delivery', 121950, NULL, NULL), + ('standard', 'ZUCK_GND', false, '重庆地面', 'Chongqing Ground', 121750, NULL, NULL), + ('standard', 'ZUCK_TWR', false, '重庆塔台', 'Chongqing Tower', 118200, NULL, NULL), + ('standard', 'ZUDA_TWR', true, '达州塔台', 'Dazhou Tower', 130325, NULL, NULL), + ('standard', 'ZUDC_TWR', true, '稻城塔台', 'Daocheng Tower', 118500, NULL, NULL), + ('standard', 'ZUDJ_TWR', true, '德江塔台', 'Dejiang Tower', 123600, NULL, NULL), + ('standard', 'ZUDR_TWR', true, '定日塔台', 'Dingri Tower', 118550, NULL, NULL), + ('standard', 'ZUGU_TWR', true, '广源塔台', 'Guangyuan Tower', 118400, NULL, NULL), + ('standard', 'ZUGY_APP', false, '贵阳进近', 'Guiyang Approach', 126050, NULL, NULL), + ('standard', 'ZUGY_A_GND', false, '贵阳机坪', 'Guiyang Apron', 121700, NULL, NULL), + ('standard', 'ZUGY_RMP', false, '贵阳机坪', 'Guiyang Apron', 121700, NULL, NULL), + ('standard', 'ZUGY_CTR', false, '贵阳区域', 'Guiyang Control', 132850, NULL, NULL), + ('standard', 'ZUGY_DEL', false, '贵阳放行', 'Guiyang Delivery', 121800, NULL, NULL), + ('standard', 'ZUGY_GND', false, '贵阳地面', 'Guiyang Ground', 121600, NULL, NULL), + ('standard', 'ZUGY_TWR', false, '贵阳塔台', 'Guiyang Tower', 118300, NULL, NULL), + ('standard', 'ZUGZ_TWR', true, '甘孜塔台', 'Ganzi Tower', 118075, NULL, NULL), + ('standard', 'ZUHY_TWR', true, '红原塔台', 'Hongyuan Tower', 130400, NULL, NULL), + ('standard', 'ZUJZ_TWR', true, '九寨塔台', 'Jiuzhai Tower', 118450, NULL, NULL), + ('standard', 'ZUKD_TWR', true, '康定塔台', 'Kangding Tower', 118050, NULL, NULL), + ('standard', 'ZUKJ_TWR', true, '黄平塔台', 'Huangping Tower', 130400, NULL, NULL), + ('standard', 'ZULA_TWR', true, '阆中塔台', 'Langzhong Tower', 130575, NULL, NULL), + ('standard', 'ZULB_TWR', true, '荔波塔台', 'Libo Tower', 118800, NULL, NULL), + ('standard', 'ZULS_APP', true, '拉萨进近', 'Lhasa Approach', 119000, NULL, NULL), + ('standard', 'ZULS_CTR', true, '拉萨区域', 'Lhasa Control', 132350, 'ZULC', NULL), + ('standard', 'ZULS_GND', false, '拉萨地面', 'Lhasa Ground', 121650, NULL, NULL), + ('standard', 'ZULS_TWR', false, '拉萨塔台', 'Lhasa Tower', 118250, NULL, NULL), + ('standard', 'ZULZ_APP', false, '川南进近', 'Chuannan Approach', 119200, NULL, NULL), + ('standard', 'ZULZ_GND', false, '泸州地面', 'Luzhou Ground', 121600, NULL, NULL), + ('standard', 'ZULZ_TWR', false, '泸州塔台', 'Luzhou Tower', 118025, NULL, NULL), + ('standard', 'ZUMT_TWR', true, '茅台塔台', 'Maotai Tower', 118125, NULL, NULL), + ('standard', 'ZUMY_TWR', true, '绵阳塔台', 'Mianyang Tower', 118500, NULL, NULL), + ('standard', 'ZUNC_TWR', true, '南充塔台', 'Nanchong Tower', 118600, NULL, NULL), + ('standard', 'ZUNP_TWR', true, '黎平塔台', 'Liping Tower', 118650, NULL, NULL), + ('standard', 'ZUNZ_TWR', true, '林芝塔台', 'Linzhi Tower', 118100, NULL, NULL), + ('standard', 'ZUPL_TWR', true, '普兰塔台', 'Pulan Tower', 124350, NULL, NULL), + ('standard', 'ZUPS_TWR', true, '六盘水塔台', 'Liupanshui Tower', 123575, NULL, NULL), + ('standard', 'ZUQJ_TWR', true, '黔江塔台', 'Qianjiang Tower', 118100, NULL, NULL), + ('standard', 'ZURK_TWR', true, '日喀则塔台', 'Rikaze Tower', 118400, NULL, NULL), + ('standard', 'ZUSH_TWR', true, '隆子塔台', 'Longzi Tower', 118300, NULL, NULL), + ('standard', 'ZUTF_A_GND', false, '天府机坪', 'Tianfu Apron', 122700, NULL, NULL), + ('standard', 'ZUTF_RMP', false, '天府机坪', 'Tianfu Apron', 122700, NULL, NULL), + ('standard', 'ZUTF_DEL', false, '天府放行', 'Tianfu Delivery', 121825, NULL, NULL), + ('standard', 'ZUTF_GND', false, '天府地面', 'Tianfu Ground', 121925, NULL, NULL), + ('standard', 'ZUTF_TWR', false, '天府塔台', 'Tianfu Tower', 118800, NULL, NULL), + ('standard', 'ZUTR_TWR', true, '铜仁塔台', 'Tongren Tower', 118500, NULL, NULL), + ('standard', 'ZUUU_APP', false, '成都进近', 'Chengdu Approach', 124850, NULL, NULL), + ('standard', 'ZUUU_A_GND', false, '双流机坪', 'Shuangliu Apron', 121800, NULL, NULL), + ('standard', 'ZUUU_RMP', false, '双流机坪', 'Shuangliu Apron', 121800, NULL, NULL), + ('standard', 'ZUUU_CTR', false, '成都区域', 'Chengdu Control', 120900, 'ZUUC', NULL), + ('standard', 'ZUUU_DEL', false, '双流放行', 'Shuangliu Delivery', 121600, NULL, NULL), + ('standard', 'ZUUU_GND', false, '双流地面', 'Shuangliu Ground', 121850, NULL, NULL), + ('standard', 'ZUUU_TWR', false, '双流塔台', 'Shuangliu Tower', 123000, NULL, NULL), + ('standard', 'ZUWL_TWR', false, '仙女山塔台', 'Xiannvshan Tower', 118725, NULL, NULL), + ('standard', 'ZUWS_TWR', true, '巫山塔台', 'Wushan Tower', 118250, NULL, NULL), + ('standard', 'ZUWX_TWR', true, '万州塔台', 'Wanzhou Tower', 130550, NULL, NULL), + ('standard', 'ZUXC_GND', false, '西昌地面', 'Xichang Ground', 121600, NULL, NULL), + ('standard', 'ZUXC_TWR', true, '西昌塔台', 'Xichang Tower', 118200, NULL, NULL), + ('standard', 'ZUYB_TWR', false, '宜宾塔台', 'Yibin Tower', 123550, NULL, NULL), + ('standard', 'ZUYI_TWR', true, '兴义塔台', 'Xingyi Tower', 118150, NULL, NULL), + ('standard', 'ZUZH_TWR', true, '攀枝花塔台', 'Panzhihua Tower', 118350, NULL, NULL), + ('standard', 'ZUZY_TWR', true, '遵义塔台', 'Zunyi Tower', 123650, NULL, NULL), + ('standard', 'ZWAK_APP', true, '阿克苏进近', 'Aksu Approach', 119025, NULL, NULL), + ('standard', 'ZWAK_TWR', false, '红旗坡塔台', 'Hongqipo Tower', 118100, NULL, NULL), + ('standard', 'ZWAL_TWR', true, '阿拉尔塔台', 'Alaer Tower', 118175, NULL, NULL), + ('standard', 'ZWAT_TWR', true, '阿勒泰塔台', 'Altay Tower', 118500, NULL, NULL), + ('standard', 'ZWBL_TWR', true, '博乐塔台', 'Bola Tower', 118200, NULL, NULL), + ('standard', 'ZWCM_TWR', true, '且末塔台', 'Qiemo Tower', 118850, NULL, NULL), + ('standard', 'ZWFY_TWR', true, '富蕴塔台', 'Fuyun Tower', 118150, NULL, NULL), + ('standard', 'ZWHJ_TWR', true, '巴音布鲁克塔台', 'Bayinbuluke Tower', 118300, NULL, NULL), + ('standard', 'ZWHM_TWR', true, '哈密塔台', 'Hami Tower', 118350, NULL, NULL), + ('standard', 'ZWHZ_TWR', true, '石河子塔台', 'Shihezi Tower', 118025, NULL, NULL), + ('standard', 'ZWKC_TWR', true, '库车塔台', 'Kuqa Tower', 118350, NULL, NULL), + ('standard', 'ZWKL_TWR', true, '库尔勒塔台', 'Korla Tower', 118650, NULL, NULL), + ('standard', 'ZWKM_TWR', true, '克拉玛依塔台', 'Kelamayi Tower', 118850, NULL, NULL), + ('standard', 'ZWKN_TWR', true, '喀纳斯塔台', 'Kanasi Tower', 118000, NULL, NULL), + ('standard', 'ZWLK_TWR', true, '巴里坤塔台', 'Balikun Tower', 118025, NULL, NULL), + ('standard', 'ZWLT_TWR', true, '轮台塔台', 'Luntai Tower', 118400, NULL, NULL), + ('standard', 'ZWNL_TWR', true, '那拉提塔台', 'Nalati Tower', 118050, NULL, NULL), + ('standard', 'ZWQT_TWR', true, '江布拉克塔台', 'Jiangbulake Tower', 118300, NULL, NULL), + ('standard', 'ZWRQ_TWR', true, '若羌塔台', 'Ruoqiang Tower', 118800, NULL, NULL), + ('standard', 'ZWSC_TWR', true, '莎车塔台', 'Shache Tower', 118450, NULL, NULL), + ('standard', 'ZWSH_TWR', true, '喀什塔台', 'Kashi Tower', 118500, NULL, NULL), + ('standard', 'ZWTC_TWR', true, '塔城塔台', 'Tacheng Tower', 118650, NULL, NULL), + ('standard', 'ZWTK_TWR', true, '塔什库尔干塔台', 'Taxkorgan Tower', 118100, NULL, NULL), + ('standard', 'ZWTL_TWR', true, '吐鲁番塔台', 'Turpan Tower', 118250, NULL, NULL), + ('standard', 'ZWTN_TWR', true, '和田塔台', 'Hotan Tower', 118650, NULL, NULL), + ('standard', 'ZWTS_TWR', true, '图木舒克塔台', 'Tumushuke Tower', 118050, NULL, NULL), + ('standard', 'ZWWW_APP', false, '乌鲁木齐进近', 'Urumqi Approach', 120250, NULL, NULL), + ('standard', 'ZWWW_A_GND', false, '天山机坪', 'Tianshan Apron', 121800, NULL, NULL), + ('standard', 'ZWWW_RMP', false, '天山机坪', 'Tianshan Apron', 121800, NULL, NULL), + ('standard', 'ZWWW_DEL', false, '天山放行', 'Tianshan Delivery', 121900, NULL, NULL), + ('standard', 'ZWWW_GND', false, '天山地面', 'Tianshan Ground', 121650, NULL, NULL), + ('standard', 'ZWWW_CTR', false, '乌鲁木齐区域', 'Urumqi Control', 118900, NULL, '暂不使用'), + ('standard', 'ZWWW_N_CTR', false, '乌鲁木齐区域', 'Urumqi Control', 128150, 'ZWUN', NULL), + ('standard', 'ZWWW_S_CTR', false, '乌鲁木齐区域', 'Urumqi Control', 118975, 'ZWUS', NULL), + ('standard', 'ZWWW_TWR', false, '天山塔台', 'Tianshan Tower', 118100, NULL, NULL), + ('standard', 'ZWYN_TWR', true, '伊宁塔台', 'Yining Tower', 118700, NULL, NULL), + ('standard', 'ZWYT_TWR', true, '于田塔台', 'Yutian Tower', 118150, NULL, NULL), + ('standard', 'ZWZS_TWR', true, '昭苏塔台', 'Zhaosu Tower', 118125, NULL, NULL), + ('standard', 'ZYAS_TWR', true, '鞍山塔台', 'Anshan Tower', 118550, NULL, NULL), + ('standard', 'ZYBA_TWR', true, '白城塔台', 'Baicheng Tower', 118050, NULL, NULL), + ('standard', 'ZYBS_TWR', true, '长白山塔台', 'Changbaishan Tower', 118200, NULL, NULL), + ('standard', 'ZYCC_APP', false, '长春进近', 'Changchun Approach', 119450, NULL, NULL), + ('standard', 'ZYCC_A_GND', false, '长春机坪', 'Changchun Ramp', 121600, NULL, NULL), + ('standard', 'ZYCC_RMP', false, '长春机坪', 'Changchun Ramp', 121600, NULL, NULL), + ('standard', 'ZYCC_DEL', false, '龙嘉放行', 'Longjia Delivery', 121750, NULL, NULL), + ('standard', 'ZYCC_GND', false, '长春地面', 'Changchun Ground', 121950, NULL, NULL), + ('standard', 'ZYCC_TWR', false, '龙嘉塔台', 'Longjia Tower', 118850, NULL, NULL), + ('standard', 'ZYCH_TWR', true, '长海塔台', 'Changhai Tower', 118050, NULL, NULL), + ('standard', 'ZYCY_TWR', true, '朝阳塔台', 'Chaoyang Tower', 118350, NULL, NULL), + ('standard', 'ZYDD_TWR', true, '丹东塔台', 'Dandong Tower', 118750, NULL, NULL), + ('standard', 'ZYDQ_TWR', true, '萨尔图塔台', 'Saertu Tower', 118550, NULL, NULL), + ('standard', 'ZYDU_TWR', true, '五大连池塔台', 'Wudalianchi Tower', 118050, NULL, NULL), + ('standard', 'ZYFY_TWR', true, '抚远塔台', 'Fuyuan Tower', 118050, NULL, NULL), + ('standard', 'ZYHB_APP', false, '哈尔滨进近', 'Harbin Approach', 119650, NULL, NULL), + ('standard', 'ZYHB_A_GND', false, '哈尔滨机坪', 'Harbin Apron', 121625, NULL, NULL), + ('standard', 'ZYHB_RMP', false, '哈尔滨机坪', 'Harbin Apron', 121625, NULL, NULL), + ('standard', 'ZYHB_CTR', false, '哈尔滨区域', 'Harbin Control', 120550, 'ZYHC', NULL), + ('standard', 'ZYHB_DEL', false, '哈尔滨放行', 'Harbin Delivery', 121725, NULL, NULL), + ('standard', 'ZYHB_GND', false, '哈尔滨地面', 'Harbin Ground', 121850, NULL, NULL), + ('standard', 'ZYHB_TWR', false, '哈尔滨塔台', 'Harbin Tower', 118700, NULL, NULL), + ('standard', 'ZYHE_TWR', true, '黑河塔台', 'Heihe Tower', 130000, NULL, NULL), + ('standard', 'ZYJD_TWR', true, '加格达奇塔台', 'Jiagedaqi Tower', 118500, NULL, NULL), + ('standard', 'ZYJM_TWR', true, '佳木斯塔台', 'Jiamusi Tower', 130000, NULL, NULL), + ('standard', 'ZYJS_TWR', true, '建三江塔台', 'Jiansanjiang Tower', 118025, NULL, NULL), + ('standard', 'ZYJX_TWR', true, '兴凯湖塔台', 'Xingkaihu Tower', 123150, NULL, NULL), + ('standard', 'ZYJZ_TWR', true, '锦州塔台', 'Jinzhou Tower', 118625, NULL, NULL), + ('standard', 'ZYLD_TWR', true, '林都塔台', 'Lindu Tower', 118250, NULL, NULL), + ('standard', 'ZYMD_TWR', true, '牡丹江塔台', 'Mudanjiang Tower', 130000, NULL, NULL), + ('standard', 'ZYMH_TWR', true, '漠河塔台', 'Mohe Tower', 118050, NULL, NULL), + ('standard', 'ZYQQ_TWR', true, '齐齐哈尔塔台', 'Qiqihaer Tower', 130000, NULL, NULL), + ('standard', 'ZYSD_TWR', true, '绥芬河塔台', 'Suifenhe Tower', 118050, NULL, NULL), + ('standard', 'ZYSQ_TWR', true, '松原塔台', 'Songyuan Tower', 118275, NULL, NULL), + ('standard', 'ZYTL_APP', false, '大连进近', 'Dalian Approach', 123850, NULL, NULL), + ('standard', 'ZYTL_CTR', false, '大连区域', 'Dalian Control', 132950, 'ZYTC', NULL), + ('standard', 'ZYTL_DEL', false, '大连放行', 'Dalian Delivery', 121850, NULL, NULL), + ('standard', 'ZYTL_GND', false, '大连地面', 'Dalian Ground', 121650, NULL, NULL), + ('standard', 'ZYTL_TWR', false, '大连塔台', 'Dalian Tower', 118250, NULL, NULL), + ('standard', 'ZYTN_TWR', true, '通化塔台', 'Tonghua Tower', 118150, NULL, NULL), + ('standard', 'ZYTX_APP', false, '沈阳进近', 'Shenyang Approach', 125550, NULL, NULL), + ('standard', 'ZYTX_A_GND', false, '桃仙机坪', 'Taoxian Apron', 121950, NULL, NULL), + ('standard', 'ZYTX_RMP', false, '桃仙机坪', 'Taoxian Apron', 121950, NULL, NULL), + ('standard', 'ZYTX_CTR', false, '沈阳区域', 'Shenyang Control', 118900, 'ZYSC', NULL), + ('standard', 'ZYTX_DEL', false, '桃仙放行', 'Taoxian Delivery', 121675, NULL, NULL), + ('standard', 'ZYTX_GND', false, '桃仙地面', 'Taoxian Ground', 121900, NULL, NULL), + ('standard', 'ZYTX_TWR', false, '桃仙塔台', 'Taoxian Tower', 118100, NULL, NULL), + ('standard', 'ZYYJ_TWR', true, '延吉塔台', 'Yanji Tower', 118750, NULL, NULL), + ('standard', 'ZYYK_TWR', true, '营口塔台', 'Yingkou Tower', 118175, NULL, NULL), + ('chengdu-low-area', 'ZUUU_V_APP', true, '低空中心', 'Di Kong Zhong Xin', 129750, NULL, NULL), + ('chengdu-low-area', 'ZUGH_TWR', true, '广汉塔台', 'Guanghan Tower', 129450, NULL, NULL), + ('chengdu-low-area', 'ZUQC_TWR', true, '安龙', 'An Long', 125800, NULL, NULL), + ('chengdu-low-area', 'ZUTE_TWR', true, '街子', 'Jie Zi', 135625, NULL, NULL), + ('chengdu-low-area', 'ZUHA_TWR', true, '豪芸', 'Hao Yun', 135575, NULL, NULL), + ('chengdu-low-area', 'ZULD_TWR', true, '洛带', 'Luo Dai', 125650, NULL, NULL), + ('chengdu-low-area', 'ZUHZ_TWR', true, '金堂', 'Jin Tang', 122100, NULL, NULL), + ('chengdu-low-area', 'ZUTK_TWR', true, '江口', 'Jiang Kou', 122200, NULL, NULL), + ('chengdu-low-area', 'ZUDG_TWR', true, '大国', 'Da Guo', 131010, NULL, NULL), + ('chengdu-low-area', 'ZUBC_TWR', true, '北川', 'Bei Chuan', 130700, NULL, NULL), + ('chengdu-low-area', 'ZUFM_TWR', true, '凤鸣', 'Feng Ming', 122050, NULL, NULL), + ('military', '*_MIL_TWR', true, NULL, NULL, 130000, NULL, '军民协调'), + ('military', '*_MIL_CTR', true, NULL, NULL, 122050, NULL, '军民协调'), + ('atis', 'ZBAA_ATIS', false, NULL, NULL, 128650, NULL, NULL), + ('atis', 'ZBAD_ATIS', false, NULL, NULL, 128400, NULL, NULL), + ('atis', 'ZBCZ_ATIS', false, NULL, NULL, 126200, NULL, NULL), + ('atis', 'ZBDS_ATIS', false, NULL, NULL, 126800, NULL, NULL), + ('atis', 'ZBHH_ATIS', false, NULL, NULL, 126250, NULL, NULL), + ('atis', 'ZBLA_ATIS', false, NULL, NULL, 126200, NULL, NULL), + ('atis', 'ZBSJ_ATIS', false, NULL, NULL, 127850, NULL, NULL), + ('atis', 'ZBTJ_ATIS', false, NULL, NULL, 126400, NULL, NULL), + ('atis', 'ZBTL_ATIS', false, NULL, NULL, 126600, NULL, NULL), + ('atis', 'ZBYC_ATIS', false, NULL, NULL, 126825, NULL, NULL), + ('atis', 'ZBYN_ATIS', false, NULL, NULL, 126450, NULL, NULL), + ('atis', 'ZGBH_ATIS', false, NULL, NULL, 126600, NULL, NULL), + ('atis', 'ZGCD_ATIS', false, NULL, NULL, 126250, NULL, NULL), + ('atis', 'ZGDY_ATIS', false, NULL, NULL, 126875, NULL, NULL), + ('atis', 'ZGGG_ATIS', false, NULL, NULL, 128600, NULL, NULL), + ('atis', 'ZGHA_A_ATIS', false, NULL, NULL, 127600, NULL, NULL), + ('atis', 'ZGHA_D_ATIS', false, NULL, NULL, 127075, NULL, NULL), + ('atis', 'ZGHC_ATIS', false, NULL, NULL, 126200, NULL, NULL), + ('atis', 'ZGHY_ATIS', false, NULL, NULL, 126800, NULL, NULL), + ('atis', 'ZGKL_ATIS', false, NULL, NULL, 126450, NULL, NULL), + ('atis', 'ZGNN_ATIS', false, NULL, NULL, 126250, NULL, NULL), + ('atis', 'ZGOW_ATIS', false, NULL, NULL, 126650, NULL, NULL), + ('atis', 'ZGSD_ATIS', false, NULL, NULL, 127825, NULL, NULL), + ('atis', 'ZGSZ_ATIS', false, NULL, NULL, 127450, NULL, NULL), + ('atis', 'ZGZJ_ATIS', false, NULL, NULL, 126800, NULL, NULL), + ('atis', 'ZHCC_ATIS', false, NULL, NULL, 128450, NULL, NULL), + ('atis', 'ZHEC_A_ATIS', false, NULL, NULL, 126850, NULL, NULL), + ('atis', 'ZHEC_D_ATIS', false, NULL, NULL, 127425, NULL, NULL), + ('atis', 'ZHHH_A_ATIS', false, NULL, NULL, 126600, NULL, NULL), + ('atis', 'ZHHH_D_ATIS', false, NULL, NULL, 126200, NULL, NULL), + ('atis', 'ZHLY_ATIS', false, NULL, NULL, 127400, NULL, NULL), + ('atis', 'ZHNY_ATIS', false, NULL, NULL, 127050, NULL, NULL), + ('atis', 'ZHSN_ATIS', false, NULL, NULL, 126225, NULL, NULL), + ('atis', 'ZHSY_ATIS', false, NULL, NULL, 126275, NULL, NULL), + ('atis', 'ZHXF_ATIS', false, NULL, NULL, 126650, NULL, NULL), + ('atis', 'ZHXY_ATIS', false, NULL, NULL, 126425, NULL, NULL), + ('atis', 'ZHYC_ATIS', false, NULL, NULL, 126425, NULL, NULL), + ('atis', 'ZJHK_A_ATIS', false, NULL, NULL, 127650, NULL, NULL), + ('atis', 'ZJHK_D_ATIS', false, NULL, NULL, 126625, NULL, NULL), + ('atis', 'ZJQH_ATIS', false, NULL, NULL, 126825, NULL, NULL), + ('atis', 'ZJSY_ATIS', false, NULL, NULL, 126450, NULL, NULL), + ('atis', 'ZLDH_ATIS', false, NULL, NULL, 126400, NULL, NULL), + ('atis', 'ZLIC_ATIS', false, NULL, NULL, 126650, NULL, NULL), + ('atis', 'ZLLL_ATIS', false, NULL, NULL, 128450, NULL, NULL), + ('atis', 'ZLXN_ATIS', false, NULL, NULL, 126850, NULL, NULL), + ('atis', 'ZLXY_ATIS', false, NULL, NULL, 128650, NULL, NULL), + ('atis', 'ZPDL_ATIS', false, NULL, NULL, 127025, NULL, NULL), + ('atis', 'ZPJH_ATIS', false, NULL, NULL, 126225, NULL, NULL), + ('atis', 'ZPLJ_ATIS', false, NULL, NULL, 126675, NULL, NULL), + ('atis', 'ZPMS_ATIS', false, NULL, NULL, 126200, NULL, NULL), + ('atis', 'ZPPP_A_ATIS', false, NULL, NULL, 128450, NULL, NULL), + ('atis', 'ZPPP_D_ATIS', false, NULL, NULL, 126275, NULL, NULL), + ('atis', 'ZSAM_ATIS', false, NULL, NULL, 126250, NULL, NULL), + ('atis', 'ZSCG_ATIS', false, NULL, NULL, 127025, NULL, NULL), + ('atis', 'ZSCN_ATIS', false, NULL, NULL, 128400, NULL, NULL), + ('atis', 'ZSFZ_ATIS', false, NULL, NULL, 126400, NULL, NULL), + ('atis', 'ZSHC_ATIS', false, NULL, NULL, 127250, NULL, NULL), + ('atis', 'ZSJN_ATIS', false, NULL, NULL, 127050, NULL, NULL), + ('atis', 'ZSJX_ATIS', false, NULL, NULL, 126600, NULL, NULL), + ('atis', 'ZSLG_ATIS', false, NULL, NULL, 118425, NULL, NULL), + ('atis', 'ZSLQ_ATIS', false, NULL, NULL, 126200, NULL, NULL), + ('atis', 'ZSLY_ATIS', false, NULL, NULL, 128250, NULL, NULL), + ('atis', 'ZSNB_ATIS', false, NULL, NULL, 126450, NULL, NULL), + ('atis', 'ZSNJ_ATIS', false, NULL, NULL, 126250, NULL, NULL), + ('atis', 'ZSNT_ATIS', false, NULL, NULL, 126875, NULL, NULL), + ('atis', 'ZSOF_ATIS', false, NULL, NULL, 128850, NULL, NULL), + ('atis', 'ZSPD_ATIS', false, NULL, NULL, 127850, NULL, NULL), + ('atis', 'ZSQD_ATIS', false, NULL, NULL, 127200, NULL, NULL), + ('atis', 'ZSQZ_ATIS', false, NULL, NULL, 126825, NULL, NULL), + ('atis', 'ZSRZ_ATIS', false, NULL, NULL, 127800, NULL, NULL), + ('atis', 'ZSSH_ATIS', false, NULL, NULL, 126425, NULL, NULL), + ('atis', 'ZSSS_ATIS', false, NULL, NULL, 132250, NULL, NULL), + ('atis', 'ZSWH_ATIS', false, NULL, NULL, 126250, NULL, NULL), + ('atis', 'ZSWX_ATIS', false, NULL, NULL, 127650, NULL, NULL), + ('atis', 'ZSWZ_ATIS', false, NULL, NULL, 127050, NULL, NULL), + ('atis', 'ZSXZ_ATIS', false, NULL, NULL, 126225, NULL, NULL), + ('atis', 'ZSYA_ATIS', false, NULL, NULL, 127450, NULL, NULL), + ('atis', 'ZSYN_ATIS', false, NULL, NULL, 126825, NULL, NULL), + ('atis', 'ZSYT_ATIS', false, NULL, NULL, 126450, NULL, NULL), + ('atis', 'ZUCK_A_ATIS', false, NULL, NULL, 126400, NULL, NULL), + ('atis', 'ZUCK_D_ATIS', false, NULL, NULL, 126650, NULL, NULL), + ('atis', 'ZUDJ_ATIS', false, NULL, NULL, 126225, NULL, NULL), + ('atis', 'ZUGH_ATIS', false, NULL, NULL, 126200, NULL, NULL), + ('atis', 'ZUGU_ATIS', false, NULL, NULL, 126600, NULL, NULL), + ('atis', 'ZUGY_ATIS', false, NULL, NULL, 126825, NULL, NULL), + ('atis', 'ZULS_ATIS', false, NULL, NULL, 131450, NULL, NULL), + ('atis', 'ZUMY_ATIS', false, NULL, NULL, 128850, NULL, NULL), + ('atis', 'ZUNC_ATIS', false, NULL, NULL, 127400, NULL, NULL), + ('atis', 'ZUTF_ATIS', false, NULL, NULL, 127075, NULL, NULL), + ('atis', 'ZUTR_ATIS', false, NULL, NULL, 127225, NULL, NULL), + ('atis', 'ZUUU_ATIS', false, NULL, NULL, 128600, NULL, NULL), + ('atis', 'ZUWX_ATIS', false, NULL, NULL, 127025, NULL, NULL), + ('atis', 'ZUXC_ATIS', false, NULL, NULL, 127400, NULL, NULL), + ('atis', 'ZUZY_ATIS', false, NULL, NULL, 127425, NULL, NULL), + ('atis', 'ZWAK_ATIS', false, NULL, NULL, 126250, NULL, NULL), + ('atis', 'ZWKL_ATIS', false, NULL, NULL, 126200, NULL, NULL), + ('atis', 'ZWSH_ATIS', false, NULL, NULL, 126200, NULL, NULL), + ('atis', 'ZWWW_A_ATIS', false, NULL, NULL, 126800, NULL, NULL), + ('atis', 'ZWWW_D_ATIS', false, NULL, NULL, 126700, NULL, NULL), + ('atis', 'ZYCC_ATIS', false, NULL, NULL, 126250, NULL, NULL), + ('atis', 'ZYHB_ATIS', false, NULL, NULL, 127400, NULL, NULL), + ('atis', 'ZYTL_ATIS', false, NULL, NULL, 126650, NULL, NULL), + ('atis', 'ZYTX_ATIS', false, NULL, NULL, 127450, NULL, NULL); diff --git a/src/app.rs b/src/app.rs index 2bb6c97..b027326 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,6 +15,7 @@ use utoipa_scalar::{Scalar, Servable}; use crate::error::ApiError; use crate::modules::atc_application::routes::build_atc_application_routes; use crate::modules::atc_booking::routes::build_atc_booking_routes; +use crate::modules::atc_position::routes::build_atc_position_routes; use crate::modules::audit_log::routes::build_audit_log_routes; use crate::modules::controller::routes::controllers::build_atc_routes; use crate::modules::controller::routes::sectors::build_sector_routes; @@ -58,6 +59,7 @@ pub fn router(services: Services) -> Router { .nest("/api/atc/controllers", build_atc_routes()) .nest("/api/atc/applications", build_atc_application_routes()) .nest("/api/atc/bookings", build_atc_booking_routes()) + .nest("/api/atc/positions", build_atc_position_routes()) .nest("/api/compat", build_compat_routes()) .nest( "/api/navdata/preferred-routes", diff --git a/src/error.rs b/src/error.rs index 8511e31..b26eb72 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,6 +12,7 @@ use crate::adapter::smms::SmmsError; use crate::modules::atc_application::models::InvalidAtcApplicationStatus; use crate::modules::atc_application::service::AtcApplicationServiceError; use crate::modules::atc_booking::service::AtcBookingServiceError; +use crate::modules::atc_position::service::AtcPositionServiceError; use crate::modules::audit_log::service::AuditLogServiceError; use crate::modules::controller::service::ControllerServiceError; use crate::modules::event::service::EventServiceError; @@ -239,6 +240,18 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(error: AtcPositionServiceError) -> Self { + match error { + AtcPositionServiceError::NotFound(callsign) => { + ApiError::not_found("ATC position", callsign) + } + AtcPositionServiceError::Database(source) => ApiError::Database { source }, + AtcPositionServiceError::AuditLog(source) => ApiError::AuditLog { source }, + } + } +} + impl From for ApiError { fn from(error: TrainingServiceError) -> Self { match error { diff --git a/src/modules/atc_position/dto.rs b/src/modules/atc_position/dto.rs new file mode 100644 index 0000000..cccb971 --- /dev/null +++ b/src/modules/atc_position/dto.rs @@ -0,0 +1,163 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; + +use super::models::{AtcPosition, AtcPositionCategory, AtcPositionSave}; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct AtcPositionSaveRequest { + pub category: AtcPositionCategory, + pub callsign: String, + pub is_tier_2: bool, + pub callsign_zh: Option, + pub callsign_en: Option, + /// Radio frequency in MHz, for example `118.500`. + pub frequency: f64, + pub cpdlc_code: Option, + pub remarks: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct AtcPositionDto { + pub category: AtcPositionCategory, + pub callsign: String, + pub is_tier_2: bool, + pub callsign_zh: Option, + pub callsign_en: Option, + /// Radio frequency in MHz. + pub frequency: f64, + /// Exact radio frequency in kHz for machine consumers. + pub frequency_khz: i32, + pub cpdlc_code: Option, + pub remarks: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl TryFrom for AtcPositionSave { + type Error = ApiError; + + fn try_from(request: AtcPositionSaveRequest) -> Result { + let callsign = normalize_callsign(request.callsign)?; + let frequency_khz = frequency_to_khz(request.frequency)?; + + Ok(Self { + category: request.category, + callsign, + is_tier_2: request.is_tier_2, + callsign_zh: optional_trimmed(request.callsign_zh), + callsign_en: optional_trimmed(request.callsign_en), + frequency_khz, + cpdlc_code: optional_trimmed(request.cpdlc_code), + remarks: optional_trimmed(request.remarks), + }) + } +} + +impl TryFrom for AtcPositionDto { + type Error = ApiError; + + fn try_from(position: AtcPosition) -> Result { + let category = position.category.parse().map_err(|_| { + ApiError::invalid_database_value("atc_position.category", &position.category) + })?; + + Ok(Self { + category, + callsign: position.callsign, + is_tier_2: position.is_tier_2, + callsign_zh: position.callsign_zh, + callsign_en: position.callsign_en, + frequency: f64::from(position.frequency_khz) / 1000.0, + frequency_khz: position.frequency_khz, + cpdlc_code: position.cpdlc_code, + remarks: position.remarks, + created_at: position.created_at, + updated_at: position.updated_at, + }) + } +} + +fn frequency_to_khz(frequency: f64) -> Result { + if !frequency.is_finite() { + return Err(ApiError::bad_request( + "frequency", + "must be a finite number", + )); + } + + let frequency_khz = (frequency * 1000.0).round(); + if !(118_000.0..=136_975.0).contains(&frequency_khz) + || (frequency * 1000.0 - frequency_khz).abs() > 0.000_1 + || frequency_khz as i32 % 5 != 0 + { + return Err(ApiError::bad_request( + "frequency", + "must be a valid 5 kHz VHF channel between 118.000 and 136.975 MHz", + )); + } + + Ok(frequency_khz as i32) +} + +pub(crate) fn normalize_callsign(callsign: String) -> Result { + let callsign = required_trimmed("callsign", callsign)?.to_ascii_uppercase(); + if callsign.len() > 32 + || !callsign + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '*')) + { + return Err(ApiError::bad_request( + "callsign", + "must contain only ASCII letters, digits, underscores, or wildcards and be at most 32 characters", + )); + } + Ok(callsign) +} + +fn required_trimmed(field: &str, value: String) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(ApiError::bad_request(field, "must not be empty")); + } + Ok(value.to_owned()) +} + +fn optional_trimmed(value: Option) -> Option { + value + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::{frequency_to_khz, normalize_callsign}; + + #[test] + fn converts_valid_frequency_to_exact_khz() { + assert_eq!(frequency_to_khz(121.825).unwrap(), 121_825); + assert_eq!(frequency_to_khz(131.010).unwrap(), 131_010); + } + + #[test] + fn rejects_invalid_frequency() { + assert!(frequency_to_khz(117.995).is_err()); + assert!(frequency_to_khz(121.823).is_err()); + assert!(frequency_to_khz(f64::NAN).is_err()); + } + + #[test] + fn normalizes_and_validates_callsign() { + assert_eq!( + normalize_callsign(" zbaa_twr ".to_owned()).unwrap(), + "ZBAA_TWR" + ); + assert_eq!( + normalize_callsign("*_mil_twr".to_owned()).unwrap(), + "*_MIL_TWR" + ); + assert!(normalize_callsign("ZBAA TWR".to_owned()).is_err()); + assert!(normalize_callsign("ZBAA_TWR(停用)".to_owned()).is_err()); + } +} diff --git a/src/modules/atc_position/mod.rs b/src/modules/atc_position/mod.rs new file mode 100644 index 0000000..bc4f489 --- /dev/null +++ b/src/modules/atc_position/mod.rs @@ -0,0 +1,5 @@ +pub mod dto; +pub mod models; +pub mod repository; +pub mod routes; +pub mod service; diff --git a/src/modules/atc_position/models.rs b/src/modules/atc_position/models.rs new file mode 100644 index 0000000..f64f047 --- /dev/null +++ b/src/modules/atc_position/models.rs @@ -0,0 +1,52 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize, de::IntoDeserializer}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum AtcPositionCategory { + Standard, + ChengduLowArea, + Military, + Atis, +} + +impl std::fmt::Display for AtcPositionCategory { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.serialize(formatter) + } +} + +impl std::str::FromStr for AtcPositionCategory { + type Err = serde::de::value::Error; + + fn from_str(value: &str) -> Result { + Self::deserialize(value.into_deserializer()) + } +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AtcPosition { + pub category: String, + pub callsign: String, + pub is_tier_2: bool, + pub callsign_zh: Option, + pub callsign_en: Option, + pub frequency_khz: i32, + pub cpdlc_code: Option, + pub remarks: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct AtcPositionSave { + pub category: AtcPositionCategory, + pub callsign: String, + pub is_tier_2: bool, + pub callsign_zh: Option, + pub callsign_en: Option, + pub frequency_khz: i32, + pub cpdlc_code: Option, + pub remarks: Option, +} diff --git a/src/modules/atc_position/repository.rs b/src/modules/atc_position/repository.rs new file mode 100644 index 0000000..14a562a --- /dev/null +++ b/src/modules/atc_position/repository.rs @@ -0,0 +1,158 @@ +#![allow(async_fn_in_trait)] + +use chrono::Utc; + +use super::models::{AtcPosition, AtcPositionSave}; + +const SELECT: &str = r#" + SELECT category, callsign, is_tier_2, callsign_zh, callsign_en, + frequency_khz, cpdlc_code, remarks, created_at, updated_at + FROM public.atc_position +"#; + +pub(crate) trait AtcPositionRepository<'executor> { + async fn list_atc_positions(self) -> Result, sqlx::Error>; + async fn find_atc_position(self, callsign: &str) -> Result, sqlx::Error>; + async fn find_atc_position_for_update( + self, + callsign: &str, + ) -> Result, sqlx::Error>; + async fn create_atc_position( + self, + position: AtcPositionSave, + ) -> Result; + async fn update_atc_position( + self, + callsign: &str, + position: AtcPositionSave, + ) -> Result, sqlx::Error>; + async fn delete_atc_position(self, callsign: &str) -> Result; +} + +impl<'executor, E> AtcPositionRepository<'executor> for E +where + E: sqlx::Executor<'executor, Database = sqlx::Postgres>, +{ + async fn list_atc_positions(self) -> Result, sqlx::Error> { + sqlx::query_as::<_, AtcPosition>(&format!( + r#"{SELECT} + ORDER BY CASE + WHEN callsign ~ '_CTR$' THEN 0 + WHEN callsign ~ '_APP$' THEN 1 + WHEN callsign ~ '_TWR$' THEN 2 + WHEN callsign ~ '_GND$' THEN 3 + WHEN callsign ~ '_RMP$' THEN 4 + WHEN callsign ~ '_DEL$' THEN 5 + ELSE 6 + END, + callsign"# + )) + .fetch_all(self) + .await + } + + async fn find_atc_position(self, callsign: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, AtcPosition>(&format!("{SELECT} WHERE callsign = $1")) + .bind(callsign) + .fetch_optional(self) + .await + } + + async fn find_atc_position_for_update( + self, + callsign: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, AtcPosition>(&format!("{SELECT} WHERE callsign = $1 FOR UPDATE")) + .bind(callsign) + .fetch_optional(self) + .await + } + + async fn create_atc_position( + self, + position: AtcPositionSave, + ) -> Result { + tracing::info!( + operation = "create", + repository = "atc_position", + "modifying data" + ); + let now = Utc::now(); + sqlx::query_as::<_, AtcPosition>( + r#" + INSERT INTO public.atc_position ( + category, callsign, is_tier_2, callsign_zh, callsign_en, + frequency_khz, cpdlc_code, remarks, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) + RETURNING category, callsign, is_tier_2, callsign_zh, callsign_en, + frequency_khz, cpdlc_code, remarks, created_at, updated_at + "#, + ) + .bind(position.category.to_string()) + .bind(position.callsign) + .bind(position.is_tier_2) + .bind(position.callsign_zh) + .bind(position.callsign_en) + .bind(position.frequency_khz) + .bind(position.cpdlc_code) + .bind(position.remarks) + .bind(now) + .fetch_one(self) + .await + } + + async fn update_atc_position( + self, + callsign: &str, + position: AtcPositionSave, + ) -> Result, sqlx::Error> { + tracing::info!( + operation = "update", + repository = "atc_position", + "modifying data" + ); + sqlx::query_as::<_, AtcPosition>( + r#" + UPDATE public.atc_position + SET category = $2, + is_tier_2 = $3, + callsign_zh = $4, + callsign_en = $5, + frequency_khz = $6, + cpdlc_code = $7, + remarks = $8, + updated_at = now() + WHERE callsign = $1 + RETURNING category, callsign, is_tier_2, callsign_zh, callsign_en, + frequency_khz, cpdlc_code, remarks, created_at, updated_at + "#, + ) + .bind(callsign) + .bind(position.category.to_string()) + .bind(position.is_tier_2) + .bind(position.callsign_zh) + .bind(position.callsign_en) + .bind(position.frequency_khz) + .bind(position.cpdlc_code) + .bind(position.remarks) + .fetch_optional(self) + .await + } + + async fn delete_atc_position(self, callsign: &str) -> Result { + tracing::info!( + operation = "delete", + repository = "atc_position", + "modifying data" + ); + Ok( + sqlx::query("DELETE FROM public.atc_position WHERE callsign = $1") + .bind(callsign) + .execute(self) + .await? + .rows_affected() + > 0, + ) + } +} diff --git a/src/modules/atc_position/routes.rs b/src/modules/atc_position/routes.rs new file mode 100644 index 0000000..38d014a --- /dev/null +++ b/src/modules/atc_position/routes.rs @@ -0,0 +1,118 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::get; +use axum::{Json, Router}; + +use crate::error::ApiError; +use crate::modules::user::middleware::CurrentUser; +use crate::modules::user::models::UserRole; +use crate::services::Services; + +use super::dto::{AtcPositionDto, AtcPositionSaveRequest, normalize_callsign}; +use super::models::AtcPositionSave; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + list_atc_positions, + get_atc_position, + create_atc_position, + update_atc_position, + delete_atc_position +))] +pub(crate) struct ApiDoc; + +pub fn build_atc_position_routes() -> Router { + Router::new() + .route("/", get(list_atc_positions).post(create_atc_position)) + .route( + "/{callsign}", + get(get_atc_position) + .put(update_atc_position) + .delete(delete_atc_position), + ) +} + +#[utoipa::path(get, path = "api/atc/positions", tag = "ATC Positions", responses((status = 200, description = "All published ATC positions and frequencies", body = Vec)))] +async fn list_atc_positions( + State(services): State, +) -> Result>, ApiError> { + services + .atc_position() + .list() + .await? + .into_iter() + .map(TryInto::try_into) + .collect::, _>>() + .map(Json) +} + +#[utoipa::path(get, path = "api/atc/positions/{callsign}", tag = "ATC Positions", params(("callsign" = String, Path, description = "ATC callsign")), responses((status = 200, description = "ATC position", body = AtcPositionDto)))] +async fn get_atc_position( + State(services): State, + Path(callsign): Path, +) -> Result, ApiError> { + let callsign = normalize_callsign(callsign)?; + services + .atc_position() + .find(&callsign) + .await? + .try_into() + .map(Json) +} + +#[utoipa::path(post, path = "api/atc/positions", tag = "ATC Positions", security(("oauth2" = [])), request_body = AtcPositionSaveRequest, responses((status = 200, description = "ATC position created", body = AtcPositionDto)))] +async fn create_atc_position( + State(services): State, + current_user: CurrentUser, + Json(request): Json, +) -> Result, ApiError> { + current_user.require_role(UserRole::TechAfvFacilityEngineer)?; + let operated_by = current_user.user_id.ok_or(ApiError::Unauthorized)?; + services + .atc_position() + .create(request.try_into()?, operated_by) + .await? + .try_into() + .map(Json) +} + +#[utoipa::path(put, path = "api/atc/positions/{callsign}", tag = "ATC Positions", security(("oauth2" = [])), params(("callsign" = String, Path, description = "ATC callsign")), request_body = AtcPositionSaveRequest, responses((status = 200, description = "ATC position updated", body = AtcPositionDto)))] +async fn update_atc_position( + State(services): State, + current_user: CurrentUser, + Path(callsign): Path, + Json(request): Json, +) -> Result, ApiError> { + current_user.require_role(UserRole::TechAfvFacilityEngineer)?; + let operated_by = current_user.user_id.ok_or(ApiError::Unauthorized)?; + let callsign = normalize_callsign(callsign)?; + let position: AtcPositionSave = request.try_into()?; + if position.callsign != callsign { + return Err(ApiError::bad_request( + "callsign", + "must match the callsign in the request path", + )); + } + services + .atc_position() + .update(&callsign, position, operated_by) + .await? + .try_into() + .map(Json) +} + +#[utoipa::path(delete, path = "api/atc/positions/{callsign}", tag = "ATC Positions", security(("oauth2" = [])), params(("callsign" = String, Path, description = "ATC callsign")), responses((status = 204, description = "ATC position deleted")))] +async fn delete_atc_position( + State(services): State, + current_user: CurrentUser, + Path(callsign): Path, +) -> Result { + current_user.require_role(UserRole::TechAfvFacilityEngineer)?; + let operated_by = current_user.user_id.ok_or(ApiError::Unauthorized)?; + let callsign = normalize_callsign(callsign)?; + services + .atc_position() + .delete(&callsign, operated_by) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/modules/atc_position/service.rs b/src/modules/atc_position/service.rs new file mode 100644 index 0000000..48f4d26 --- /dev/null +++ b/src/modules/atc_position/service.rs @@ -0,0 +1,110 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::modules::audit_log::models::AuditLogEntity; +use crate::modules::audit_log::service::{AuditLogService, AuditLogServiceError}; + +use super::models::{AtcPosition, AtcPositionSave}; +use super::repository::AtcPositionRepository; + +#[derive(Debug, Clone)] +pub struct AtcPositionService { + db: PgPool, + audit_log: AuditLogService, +} + +impl AtcPositionService { + pub fn new(db: PgPool, audit_log: AuditLogService) -> Self { + Self { db, audit_log } + } + + pub async fn list(&self) -> Result, AtcPositionServiceError> { + Ok(self.db.list_atc_positions().await?) + } + + pub async fn find(&self, callsign: &str) -> Result { + self.db + .find_atc_position(callsign) + .await? + .ok_or_else(|| AtcPositionServiceError::NotFound(callsign.to_owned())) + } + + pub async fn create( + &self, + position: AtcPositionSave, + operated_by: Uuid, + ) -> Result { + let position = self.db.create_atc_position(position).await?; + self.audit_log + .record( + AuditLogEntity::AtcPosition(position.callsign.clone()), + operated_by, + None, + Some(&position), + ) + .await?; + Ok(position) + } + + pub async fn update( + &self, + callsign: &str, + position: AtcPositionSave, + operated_by: Uuid, + ) -> Result { + let mut transaction = self.db.begin().await?; + let before = (&mut *transaction) + .find_atc_position_for_update(callsign) + .await? + .ok_or_else(|| AtcPositionServiceError::NotFound(callsign.to_owned()))?; + let position = (&mut *transaction) + .update_atc_position(callsign, position) + .await? + .ok_or_else(|| AtcPositionServiceError::NotFound(callsign.to_owned()))?; + transaction.commit().await?; + self.audit_log + .record( + AuditLogEntity::AtcPosition(callsign.to_owned()), + operated_by, + Some(&before), + Some(&position), + ) + .await?; + Ok(position) + } + + pub async fn delete( + &self, + callsign: &str, + operated_by: Uuid, + ) -> Result<(), AtcPositionServiceError> { + let mut transaction = self.db.begin().await?; + let before = (&mut *transaction) + .find_atc_position_for_update(callsign) + .await? + .ok_or_else(|| AtcPositionServiceError::NotFound(callsign.to_owned()))?; + if !(&mut *transaction).delete_atc_position(callsign).await? { + return Err(AtcPositionServiceError::NotFound(callsign.to_owned())); + } + transaction.commit().await?; + self.audit_log + .record( + AuditLogEntity::AtcPosition(callsign.to_owned()), + operated_by, + Some(&before), + None, + ) + .await?; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AtcPositionServiceError { + #[error("ATC position {0} not found")] + NotFound(String), + #[error("database error: {0}")] + Database(#[from] sqlx::Error), + #[error("audit log error: {0}")] + AuditLog(#[from] AuditLogServiceError), +} diff --git a/src/modules/audit_log/dto.rs b/src/modules/audit_log/dto.rs index 1914635..23834a3 100644 --- a/src/modules/audit_log/dto.rs +++ b/src/modules/audit_log/dto.rs @@ -11,6 +11,7 @@ use super::models::{AuditLog, AuditLogEntity}; pub enum AuditLogEntityKindDto { Event, AtcApplication, + AtcPosition, User, UserRole, UserAtcPermission, @@ -44,6 +45,13 @@ impl From for (AuditLogEntityDto, Option) { }, None, ), + AuditLogEntity::AtcPosition(id) => ( + AuditLogEntityDto { + kind: AuditLogEntityKindDto::AtcPosition, + id, + }, + None, + ), AuditLogEntity::Event(id) => ( AuditLogEntityDto { kind: AuditLogEntityKindDto::Event, diff --git a/src/modules/audit_log/models.rs b/src/modules/audit_log/models.rs index a8e3f84..c48a47b 100644 --- a/src/modules/audit_log/models.rs +++ b/src/modules/audit_log/models.rs @@ -2,9 +2,10 @@ use chrono::{DateTime, Utc}; use serde_json::Value; use uuid::Uuid; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum AuditLogEntity { AtcApplication(Uuid), + AtcPosition(String), Event(Uuid), EventAtcPosition(Uuid, Uuid), EventSlot(Uuid, Uuid), diff --git a/src/modules/audit_log/repository.rs b/src/modules/audit_log/repository.rs index f0ce726..b6e8a58 100644 --- a/src/modules/audit_log/repository.rs +++ b/src/modules/audit_log/repository.rs @@ -14,6 +14,7 @@ use super::models::{AuditLog, AuditLogEntity}; pub enum AuditLogEntityKind { Event, AtcApplication, + AtcPosition, User, UserRole, UserAtcPermission, @@ -48,6 +49,12 @@ pub trait AuditLogRepository<'executor> { entity_kind: AuditLogEntityKind, entity_id: Uuid, ) -> Result, sqlx::Error>; + + async fn list_audit_log_by_entity_kind_and_key( + self, + entity_kind: AuditLogEntityKind, + entity_key: &str, + ) -> Result, sqlx::Error>; } impl<'executor, E> AuditLogRepository<'executor> for E @@ -60,16 +67,17 @@ where sqlx::query_as::<_, AuditLogRecord>( r#" INSERT INTO public.audit_log ( - entity_kind, entity_id, child_entity_kind, child_entity_id, + entity_kind, entity_id, entity_key, child_entity_kind, child_entity_id, before, after, operated_by, created_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING entity_kind, entity_id, child_entity_kind, child_entity_id, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING entity_kind, entity_id, entity_key, child_entity_kind, child_entity_id, before, after, operated_by, created_at "#, ) .bind(record.entity_kind) .bind(record.entity_id) + .bind(record.entity_key) .bind(record.child_entity_kind) .bind(record.child_entity_id) .bind(record.before) @@ -86,7 +94,7 @@ where list_records( sqlx::query_as::<_, AuditLogRecord>( r#" - SELECT entity_kind, entity_id, child_entity_kind, child_entity_id, + SELECT entity_kind, entity_id, entity_key, child_entity_kind, child_entity_id, before, after, operated_by, created_at FROM public.audit_log WHERE entity_kind = $1 @@ -107,7 +115,7 @@ where list_records( sqlx::query_as::<_, AuditLogRecord>( r#" - SELECT entity_kind, entity_id, child_entity_kind, child_entity_id, + SELECT entity_kind, entity_id, entity_key, child_entity_kind, child_entity_id, before, after, operated_by, created_at FROM public.audit_log WHERE entity_kind = $1 AND entity_id = $2 @@ -121,12 +129,36 @@ where .await?, ) } + + async fn list_audit_log_by_entity_kind_and_key( + self, + entity_kind: AuditLogEntityKind, + entity_key: &str, + ) -> Result, sqlx::Error> { + list_records( + sqlx::query_as::<_, AuditLogRecord>( + r#" + SELECT entity_kind, entity_id, entity_key, child_entity_kind, child_entity_id, + before, after, operated_by, created_at + FROM public.audit_log + WHERE entity_kind = $1 AND entity_key = $2 + ORDER BY created_at DESC + LIMIT 100 + "#, + ) + .bind(entity_kind.to_string()) + .bind(entity_key) + .fetch_all(self) + .await?, + ) + } } #[derive(Debug, Clone, PartialEq, FromRow)] pub struct AuditLogRecord { pub entity_kind: String, - pub entity_id: Uuid, + pub entity_id: Option, + pub entity_key: Option, pub child_entity_kind: Option, pub child_entity_id: Option, pub before: Value, @@ -141,41 +173,56 @@ pub struct InvalidAuditLogEntityKind(pub String); impl From for AuditLogRecord { fn from(audit_log: AuditLog) -> Self { - let (entity_kind, entity_id, child_entity_kind, child_entity_id) = match audit_log.entity { - AuditLogEntity::AtcApplication(id) => { - (AuditLogEntityKind::AtcApplication, id, None, None) - } - AuditLogEntity::Event(id) => (AuditLogEntityKind::Event, id, None, None), - AuditLogEntity::EventAtcPosition(pid, id) => ( - AuditLogEntityKind::Event, - pid, - Some(AuditLogEntityKind::EventAtcPosition), - Some(id), - ), - AuditLogEntity::EventSlot(pid, id) => ( - AuditLogEntityKind::Event, - pid, - Some(AuditLogEntityKind::EventSlot), - Some(id), - ), - AuditLogEntity::User(id) => (AuditLogEntityKind::User, id, None, None), - AuditLogEntity::UserAtcPermission(pid, id) => ( - AuditLogEntityKind::User, - pid, - Some(AuditLogEntityKind::UserAtcPermission), - Some(id), - ), - AuditLogEntity::UserRole(pid, id) => ( - AuditLogEntityKind::User, - pid, - Some(AuditLogEntityKind::UserRole), - Some(id), - ), - }; + let (entity_kind, entity_id, entity_key, child_entity_kind, child_entity_id) = + match audit_log.entity { + AuditLogEntity::AtcApplication(id) => ( + AuditLogEntityKind::AtcApplication, + Some(id), + None, + None, + None, + ), + AuditLogEntity::AtcPosition(key) => { + (AuditLogEntityKind::AtcPosition, None, Some(key), None, None) + } + AuditLogEntity::Event(id) => { + (AuditLogEntityKind::Event, Some(id), None, None, None) + } + AuditLogEntity::EventAtcPosition(pid, id) => ( + AuditLogEntityKind::Event, + Some(pid), + None, + Some(AuditLogEntityKind::EventAtcPosition), + Some(id), + ), + AuditLogEntity::EventSlot(pid, id) => ( + AuditLogEntityKind::Event, + Some(pid), + None, + Some(AuditLogEntityKind::EventSlot), + Some(id), + ), + AuditLogEntity::User(id) => (AuditLogEntityKind::User, Some(id), None, None, None), + AuditLogEntity::UserAtcPermission(pid, id) => ( + AuditLogEntityKind::User, + Some(pid), + None, + Some(AuditLogEntityKind::UserAtcPermission), + Some(id), + ), + AuditLogEntity::UserRole(pid, id) => ( + AuditLogEntityKind::User, + Some(pid), + None, + Some(AuditLogEntityKind::UserRole), + Some(id), + ), + }; Self { entity_kind: entity_kind.to_string(), entity_id, + entity_key, child_entity_kind: child_entity_kind.as_ref().map(ToString::to_string), child_entity_id, before: audit_log.before, @@ -199,24 +246,30 @@ impl TryFrom for AuditLog { let incomplete_error = || InvalidAuditLogEntityKind(format!("incomplete {entity_kind}")); + let entity_id = |id: Option| id.ok_or_else(|| incomplete_error()); let entity = match entity_kind { - AuditLogEntityKind::Event => AuditLogEntity::Event(record.entity_id), - AuditLogEntityKind::AtcApplication => AuditLogEntity::AtcApplication(record.entity_id), - AuditLogEntityKind::User => AuditLogEntity::User(record.entity_id), + AuditLogEntityKind::Event => AuditLogEntity::Event(entity_id(record.entity_id)?), + AuditLogEntityKind::AtcApplication => { + AuditLogEntity::AtcApplication(entity_id(record.entity_id)?) + } + AuditLogEntityKind::AtcPosition => { + AuditLogEntity::AtcPosition(record.entity_key.clone().ok_or_else(incomplete_error)?) + } + AuditLogEntityKind::User => AuditLogEntity::User(entity_id(record.entity_id)?), AuditLogEntityKind::UserRole => AuditLogEntity::UserRole( - record.entity_id, + entity_id(record.entity_id)?, record.child_entity_id.ok_or_else(incomplete_error)?, ), AuditLogEntityKind::UserAtcPermission => AuditLogEntity::UserAtcPermission( - record.entity_id, + entity_id(record.entity_id)?, record.child_entity_id.ok_or_else(incomplete_error)?, ), AuditLogEntityKind::EventAtcPosition => AuditLogEntity::EventAtcPosition( - record.entity_id, + entity_id(record.entity_id)?, record.child_entity_id.ok_or_else(incomplete_error)?, ), AuditLogEntityKind::EventSlot => AuditLogEntity::EventSlot( - record.entity_id, + entity_id(record.entity_id)?, record.child_entity_id.ok_or_else(incomplete_error)?, ), }; @@ -249,7 +302,8 @@ mod tests { fn record(entity_kind: &str, entity_id: Uuid) -> AuditLogRecord { AuditLogRecord { entity_kind: entity_kind.to_owned(), - entity_id, + entity_id: Some(entity_id), + entity_key: None, child_entity_kind: None, child_entity_id: None, before: Value::Null, @@ -274,7 +328,7 @@ mod tests { let record = AuditLogRecord::from(audit_log); assert_eq!(record.entity_kind, "event"); - assert_eq!(record.entity_id, event_id); + assert_eq!(record.entity_id, Some(event_id)); assert_eq!( record.child_entity_kind.as_deref(), Some("event-atc-position") @@ -301,6 +355,23 @@ mod tests { ); } + #[test] + fn converts_atc_position_entity_in_both_directions() { + let audit_log = AuditLog { + entity: AuditLogEntity::AtcPosition("ZBAA_TWR".to_owned()), + before: Value::Null, + after: Value::Null, + operated_by: Uuid::nil(), + created_at: Utc.with_ymd_and_hms(2026, 8, 29, 0, 0, 0).unwrap(), + }; + + let record = AuditLogRecord::from(audit_log.clone()); + assert_eq!(record.entity_kind, "atc-position"); + assert_eq!(record.entity_id, None); + assert_eq!(record.entity_key.as_deref(), Some("ZBAA_TWR")); + assert_eq!(AuditLog::try_from(record).unwrap(), audit_log); + } + #[test] fn rejects_unknown_database_entity_kind() { assert_eq!( diff --git a/src/modules/audit_log/routes.rs b/src/modules/audit_log/routes.rs index 4d151ad..5a1c2ea 100644 --- a/src/modules/audit_log/routes.rs +++ b/src/modules/audit_log/routes.rs @@ -7,6 +7,7 @@ use ulid::Ulid; use uuid::Uuid; use crate::error::ApiError; +use crate::modules::atc_position::dto::normalize_callsign; use crate::modules::audit_log::dto::AuditLogDto; use crate::modules::audit_log::models::AuditLog; use crate::modules::user::middleware::CurrentUser; @@ -19,6 +20,8 @@ use crate::services::Services; list_event_audit_logs_by_event, list_atc_application_audit_logs, list_atc_application_audit_logs_by_application, + list_atc_position_audit_logs, + list_atc_position_audit_logs_by_position, list_user_audit_logs, list_user_audit_logs_by_user, list_user_atc_status_audit_logs @@ -37,6 +40,11 @@ pub fn build_audit_log_routes() -> Router { "/atc/applications/{id}/audit", get(list_atc_application_audit_logs_by_application), ) + .route("/atc/positions/audit", get(list_atc_position_audit_logs)) + .route( + "/atc/positions/{callsign}/audit", + get(list_atc_position_audit_logs_by_position), + ) .route("/users/audit", get(list_user_audit_logs)) .route("/users/{id}/audit", get(list_user_audit_logs_by_user)) .route( @@ -151,6 +159,62 @@ async fn list_atc_application_audit_logs_by_application( )) } +#[utoipa::path( + get, + path = "api/atc/positions/audit", + tag = "Audit Logs", + security(("oauth2" = [])), + responses( + (status = 200, description = "ATC-position audit logs", body = Vec) + ) +)] +async fn list_atc_position_audit_logs( + State(services): State, + current_user: CurrentUser, +) -> Result>, ApiError> { + current_user.require_role(UserRole::TechAfvFacilityEngineer)?; + let audit_logs = services.audit_log().list_atc_positions().await?; + + Ok(Json( + join_audit_log_with_user(&services, audit_logs) + .await? + .into_iter() + .map(Into::into) + .collect(), + )) +} + +#[utoipa::path( + get, + path = "api/atc/positions/{callsign}/audit", + tag = "Audit Logs", + security(("oauth2" = [])), + params(("callsign" = String, Path, description = "ATC callsign")), + responses( + (status = 200, description = "Audit logs for an ATC position", body = Vec) + ) +)] +async fn list_atc_position_audit_logs_by_position( + State(services): State, + current_user: CurrentUser, + Path(callsign): Path, +) -> Result>, ApiError> { + current_user.require_role(UserRole::TechAfvFacilityEngineer)?; + let callsign = normalize_callsign(callsign)?; + let audit_logs = services + .audit_log() + .list_atc_positions_by_callsign(&callsign) + .await?; + + Ok(Json( + join_audit_log_with_user(&services, audit_logs) + .await? + .into_iter() + .map(Into::into) + .collect(), + )) +} + #[utoipa::path( get, path = "api/users/audit", diff --git a/src/modules/audit_log/service.rs b/src/modules/audit_log/service.rs index 622604e..5dd6750 100644 --- a/src/modules/audit_log/service.rs +++ b/src/modules/audit_log/service.rs @@ -60,6 +60,23 @@ impl AuditLogService { .await?) } + pub async fn list_atc_positions(&self) -> Result, AuditLogServiceError> { + Ok(self + .db + .list_audit_log_by_entity_kind(AuditLogEntityKind::AtcPosition) + .await?) + } + + pub async fn list_atc_positions_by_callsign( + &self, + callsign: &str, + ) -> Result, AuditLogServiceError> { + Ok(self + .db + .list_audit_log_by_entity_kind_and_key(AuditLogEntityKind::AtcPosition, callsign) + .await?) + } + pub async fn list_atc_applications_by_id( &self, id: Uuid, diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 4e72e1a..8cb3e35 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,5 +1,6 @@ pub mod atc_application; pub mod atc_booking; +pub mod atc_position; pub mod audit_log; pub mod controller; pub mod event; diff --git a/src/openapi.rs b/src/openapi.rs index 7a9a43e..4338597 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -15,6 +15,7 @@ use utoipa::{OpenApi, PartialSchema, ToSchema}; (path = "/", api = crate::modules::controller::routes::user_atc_permissions::ApiDoc), (path = "/", api = crate::modules::atc_application::routes::ApiDoc), (path = "/", api = crate::modules::atc_booking::routes::ApiDoc), + (path = "/", api = crate::modules::atc_position::routes::ApiDoc), (path = "/", api = crate::modules::training::routes::trainings::ApiDoc), (path = "/", api = crate::modules::training::routes::applications::ApiDoc), (path = "/", api = crate::modules::event::routes::events::ApiDoc), @@ -34,6 +35,7 @@ use utoipa::{OpenApi, PartialSchema, ToSchema}; (name = "Auth", description = "OAuth and session endpoints"), (name = "Audit Logs", description = "Audit log history"), (name = "ATC", description = "ATC status and controllers"), + (name = "ATC Positions", description = "Published ATC positions and frequencies"), (name = "ATC Application", description = "ATC applications"), (name = "Compat", description = "Compatibility endpoints"), (name = "Events", description = "Events, slots, airspaces, and event ATC positions"), @@ -245,6 +247,47 @@ mod tests { ); } + #[test] + fn atc_position_operations_are_documented() { + let openapi = serde_json::to_value(openapi()).expect("OpenAPI should serialize"); + + assert!( + openapi + .pointer("/paths/~1api~1atc~1positions/get") + .is_some() + ); + assert!( + openapi + .pointer("/paths/~1api~1atc~1positions/post") + .is_some() + ); + assert!( + openapi + .pointer("/paths/~1api~1atc~1positions~1{callsign}/get") + .is_some() + ); + assert!( + openapi + .pointer("/paths/~1api~1atc~1positions~1{callsign}/put") + .is_some() + ); + assert!( + openapi + .pointer("/paths/~1api~1atc~1positions~1{callsign}/delete") + .is_some() + ); + assert!( + openapi + .pointer("/paths/~1api~1atc~1positions~1audit/get") + .is_some() + ); + assert!( + openapi + .pointer("/paths/~1api~1atc~1positions~1{callsign}~1audit/get") + .is_some() + ); + } + fn operations(path_item: &PathItem) -> impl Iterator { [ &path_item.get, diff --git a/src/services.rs b/src/services.rs index 34e146e..22c69a5 100644 --- a/src/services.rs +++ b/src/services.rs @@ -8,6 +8,7 @@ use crate::adapter::moodle::MoodleClient; use crate::adapter::smms::SmmsClient; use crate::modules::atc_application::service::AtcApplicationService; use crate::modules::atc_booking::service::AtcBookingService; +use crate::modules::atc_position::service::AtcPositionService; use crate::modules::controller::service::ControllerService; use crate::modules::event::service::EventService; use crate::modules::flight::service::FlightService; @@ -38,6 +39,7 @@ pub struct Services { audit_log: AuditLogService, atc_application: AtcApplicationService, atc_booking: AtcBookingService, + atc_position: AtcPositionService, event: EventService, flight: FlightService, sheet: SheetService, @@ -73,6 +75,7 @@ impl Services { let atc_application = AtcApplicationService::new(db.clone(), audit_log.clone(), user.clone(), sheet.clone()); let atc_booking = AtcBookingService::new(db.clone(), user.clone()); + let atc_position = AtcPositionService::new(db.clone(), audit_log.clone()); let event = EventService::new(db.clone(), audit_log.clone(), user.clone()); let access_token = AccessTokenService::new(&settings.authentication.jwt); let refresh_token = @@ -96,6 +99,7 @@ impl Services { audit_log, atc_application, atc_booking, + atc_position, event, flight, sheet, @@ -169,6 +173,10 @@ impl Services { &self.atc_booking } + pub fn atc_position(&self) -> &AtcPositionService { + &self.atc_position + } + pub fn event(&self) -> &EventService { &self.event } From f5b98a729ecedf454036580046bc34ec007f47bf Mon Sep 17 00:00:00 2001 From: Yuze Fu Date: Sat, 29 Aug 2026 11:29:53 +0900 Subject: [PATCH 2/2] test: add E2E coverage for ATC positions --- e2e/lib/api/schema.ts | 278 ++++++++++++++++++++- e2e/src/atc/positions.test.ts | 150 +++++++++++ src/modules/flight/flight_plan/lexer_v2.rs | 41 +++ src/modules/flight/flight_plan/mod.rs | 1 + 4 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 e2e/src/atc/positions.test.ts create mode 100644 src/modules/flight/flight_plan/lexer_v2.rs diff --git a/e2e/lib/api/schema.ts b/e2e/lib/api/schema.ts index eb228d5..0fad5f7 100644 --- a/e2e/lib/api/schema.ts +++ b/e2e/lib/api/schema.ts @@ -3,7 +3,7 @@ * Do not make direct changes to the file. */ -export interface paths { +export type paths = { "/api/atc/applications": { parameters: { query?: never; @@ -196,6 +196,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/atc/positions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_atc_positions"]; + put?: never; + post: operations["create_atc_position"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/atc/positions/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_atc_position_audit_logs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/atc/positions/{callsign}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_atc_position"]; + put: operations["update_atc_position"]; + post?: never; + delete: operations["delete_atc_position"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/atc/positions/{callsign}/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_atc_position_audit_logs_by_position"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/atc/trainings": { parameters: { query?: never; @@ -1092,9 +1156,9 @@ export interface paths { patch?: never; trace?: never; }; -} +}; export type webhooks = Record; -export interface components { +export type components = { schemas: { AccessTokenRequest: { client_id?: string; @@ -1170,6 +1234,45 @@ export interface components { solo_expires_at?: string | null; state: components["schemas"]["UserControllerState"]; }; + /** @enum {string} */ + AtcPositionCategory: "standard" | "chengdu-low-area" | "military" | "atis"; + AtcPositionDto: { + callsign: string; + callsign_en?: string | null; + callsign_zh?: string | null; + category: components["schemas"]["AtcPositionCategory"]; + cpdlc_code?: string | null; + /** Format: date-time */ + created_at: string; + /** + * Format: double + * @description Radio frequency in MHz. + */ + frequency: number; + /** + * Format: int32 + * @description Exact radio frequency in kHz for machine consumers. + */ + frequency_khz: number; + is_tier_2: boolean; + remarks?: string | null; + /** Format: date-time */ + updated_at: string; + }; + AtcPositionSaveRequest: { + callsign: string; + callsign_en?: string | null; + callsign_zh?: string | null; + category: components["schemas"]["AtcPositionCategory"]; + cpdlc_code?: string | null; + /** + * Format: double + * @description Radio frequency in MHz, for example `118.500`. + */ + frequency: number; + is_tier_2: boolean; + remarks?: string | null; + }; AtcStatusDto: { is_absent: boolean; is_visiting: boolean; @@ -1198,7 +1301,7 @@ export interface components { kind: components["schemas"]["AuditLogEntityKindDto"]; }; /** @enum {string} */ - AuditLogEntityKindDto: "event" | "atc-application" | "user" | "user-role" | "user-atc-permission" | "event-atc-position" | "event-slot"; + AuditLogEntityKindDto: "event" | "atc-application" | "atc-position" | "user" | "user-role" | "user-atc-permission" | "event-atc-position" | "event-slot"; AuthApiErrorBody: { error: components["schemas"]["OAuthErrorCode"]; error_description: string; @@ -1655,7 +1758,7 @@ export interface components { requestBodies: never; headers: never; pathItems: never; -} +}; export type $defs = Record; export interface operations { list_applications: { @@ -2011,6 +2114,171 @@ export interface operations { 500: components["responses"]["InternalServerError"]; }; }; + list_atc_positions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description All published ATC positions and frequencies */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AtcPositionDto"][]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + create_atc_position: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AtcPositionSaveRequest"]; + }; + }; + responses: { + /** @description ATC position created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AtcPositionDto"]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + list_atc_position_audit_logs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description ATC-position audit logs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuditLogDto"][]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + get_atc_position: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ATC callsign */ + callsign: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description ATC position */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AtcPositionDto"]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + update_atc_position: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ATC callsign */ + callsign: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AtcPositionSaveRequest"]; + }; + }; + responses: { + /** @description ATC position updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AtcPositionDto"]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + delete_atc_position: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ATC callsign */ + callsign: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description ATC position deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + list_atc_position_audit_logs_by_position: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ATC callsign */ + callsign: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Audit logs for an ATC position */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuditLogDto"][]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; create_training: { parameters: { query?: never; diff --git a/e2e/src/atc/positions.test.ts b/e2e/src/atc/positions.test.ts new file mode 100644 index 0000000..33f7024 --- /dev/null +++ b/e2e/src/atc/positions.test.ts @@ -0,0 +1,150 @@ +import { expect, test as baseTest } from "vitest"; +import { getClient } from "../../lib/backend.js"; +import type { components } from "../../lib/api/schema.js"; + +const test = baseTest + .extend("facilityEngineer", async ({}) => { + return await getClient(["tech-afv-facility-engineer"]); + }) + .extend("user", async ({}) => { + return await getClient([]); + }); + +test("GET /api/atc/positions publicly lists positions in operational order", async () => { + const client = await getClient(); + const { data, error, response } = await client.GET("/api/atc/positions"); + + expect(error).toBeFalsy(); + expect(response.status).toBe(200); + expect(data.length).toBeGreaterThanOrEqual(665); + + const callsigns = data.map((position) => position.callsign); + const orderedExamples = [ + "ZBAA_CTR", + "ZBAA_APP", + "ZBAA_TWR", + "ZBAA_GND", + "ZBAA_RMP", + "ZBAA_DEL", + "ZBAA_ATIS", + ]; + const indexes = orderedExamples.map((callsign) => callsigns.indexOf(callsign)); + + expect(indexes.every((index) => index >= 0)).toBe(true); + expect(indexes).toEqual([...indexes].sort((left, right) => left - right)); + expect(callsigns.indexOf("ZBAA_TWR")).toBeLessThan( + callsigns.indexOf("ZBAA_W_TWR"), + ); +}); + +test("facility engineers can manage an ATC position by callsign with audit history", async ({ + facilityEngineer, + user, +}) => { + const callsign = `ZE2E${Date.now().toString(36).toUpperCase()}_TWR`; + const createBody: components["schemas"]["AtcPositionSaveRequest"] = { + category: "standard", + callsign: ` ${callsign.toLowerCase()} `, + is_tier_2: true, + callsign_zh: " 测试塔台 ", + callsign_en: " E2E Tower ", + frequency: 123.45, + cpdlc_code: " E2ET ", + remarks: " Created by the E2E test. ", + }; + + const forbidden = await user.POST("/api/atc/positions", { + body: createBody, + }); + expect(forbidden.response.status).toBe(403); + + const created = await facilityEngineer.POST("/api/atc/positions", { + body: createBody, + }); + expect(created.error).toBeFalsy(); + expect(created.response.status).toBe(200); + expect(created.data).toEqual( + expect.objectContaining({ + category: "standard", + callsign, + is_tier_2: true, + callsign_zh: "测试塔台", + callsign_en: "E2E Tower", + frequency: 123.45, + frequency_khz: 123450, + cpdlc_code: "E2ET", + remarks: "Created by the E2E test.", + }), + ); + + const found = await facilityEngineer.GET( + "/api/atc/positions/{callsign}", + { + params: { path: { callsign: callsign.toLowerCase() } }, + }, + ); + expect(found.error).toBeFalsy(); + expect(found.data?.callsign).toBe(callsign); + + const updateBody: components["schemas"]["AtcPositionSaveRequest"] = { + ...createBody, + callsign, + category: "chengdu-low-area", + frequency: 124.35, + remarks: " Updated by the E2E test. ", + }; + const updated = await facilityEngineer.PUT( + "/api/atc/positions/{callsign}", + { + params: { path: { callsign } }, + body: updateBody, + }, + ); + expect(updated.error).toBeFalsy(); + expect(updated.data).toEqual( + expect.objectContaining({ + callsign, + category: "chengdu-low-area", + frequency: 124.35, + frequency_khz: 124350, + remarks: "Updated by the E2E test.", + }), + ); + + const audit = await facilityEngineer.GET( + "/api/atc/positions/{callsign}/audit", + { params: { path: { callsign } } }, + ); + expect(audit.error).toBeFalsy(); + expect(audit.data).toHaveLength(2); + expect(audit.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entity: { kind: "atc-position", id: callsign }, + before: null, + after: expect.objectContaining({ callsign }), + }), + expect.objectContaining({ + entity: { kind: "atc-position", id: callsign }, + before: expect.objectContaining({ category: "standard", callsign }), + after: expect.objectContaining({ + category: "chengdu-low-area", + callsign, + }), + }), + ]), + ); + + const deleted = await facilityEngineer.DELETE( + "/api/atc/positions/{callsign}", + { params: { path: { callsign } } }, + ); + expect(deleted.error).toBeFalsy(); + expect(deleted.response.status).toBe(204); + + const missing = await facilityEngineer.GET( + "/api/atc/positions/{callsign}", + { params: { path: { callsign } } }, + ); + expect(missing.response.status).toBe(404); +}); diff --git a/src/modules/flight/flight_plan/lexer_v2.rs b/src/modules/flight/flight_plan/lexer_v2.rs new file mode 100644 index 0000000..9e04975 --- /dev/null +++ b/src/modules/flight/flight_plan/lexer_v2.rs @@ -0,0 +1,41 @@ +pub struct Lexer<'a> { + route: &'a str, + state: LexerState, +} + +pub enum LexerState { + Fix, + Leg, +} + +pub struct LexerToken<'a> { + str: &'a str, + value: LexerTokenValue, +} + +pub enum LexerTokenValue { + Direct, +} + +impl<'a> Lexer<'a> { + pub fn new(route: &'a str) -> Self { + Self { + route, + state: LexerState::Fix, + } + } + + pub fn parse_all(&self) -> impl Iterator> { + self.route + .split([' ', '\t', '\n', '\r']) + .filter(|seg| !seg.is_empty()) + .map(|seg| LexerToken { + str: seg, + value: LexerTokenValue::Direct, + }) + } +} + +trait TokenHandler { + fn handle_segment<'a>(segment: &'a str, lexer: &'a Lexer<'a>) -> (LexerToken<'a>, LexerState); +} diff --git a/src/modules/flight/flight_plan/mod.rs b/src/modules/flight/flight_plan/mod.rs index c96c64c..080dc46 100644 --- a/src/modules/flight/flight_plan/mod.rs +++ b/src/modules/flight/flight_plan/mod.rs @@ -1,6 +1,7 @@ use crate::modules::navdata::models::AnyFix; pub mod lexer; +pub mod lexer_v2; pub mod parser; pub mod validator;