AOE 瀚忠空运价格提取 Skill
提取 AOE 瀚忠空运多航司运价表 → 24列标准输出 → 上传 WPS。
⚠️ 铁律:仅提取用户最新提交文件,禁止复制历史数据
- 禁止查看/引用/复制任何历史脚本中的 price/data 数组
- 禁止从历史输出文件直接读取数据
- 禁止硬编码历史文件路径作为源文件
- 每次提取必须从用户当前提交文件全新读取
- 有就是有,没有就是没有:不得用历史数据填补缺失值
一、核心参数
| 参数 | 值 |
|------|-----|
| 源文件 | 用户提交(命名 AOE XXMAY GARY.xlsx) |
| 格式 | Excel .xlsx,多 Sheet(12个价格Sheet) |
| 起始港 | 多港口:PVG/HGH/HAK/NKG/CGO/KMG |
| 价格编号 | aoe_{sheet}_{carrier}_001 |
| 来源 | AOE |
| 货型 | 1:1(默认)+ 子行货型(1:300/1:500等) |
| 引擎 | pandas + openpyxl(GF需要 openpyxl 检测填充色) |
二、Sheet 清单与特征
| Sheet | 航司 | 格式特征 | 特殊处理 | |-------|------|---------|---------| | EY PVG | EY | 简单, +250KG列 | +250→300KG槽位, PVG起运 | | EY SZX | EY | 同上 | SZX起运 | | EY EHU | EY | 同上 | EHU起运 | | 印度专线 | CX/AI/NH/GI/JD/O3/RH | 主行+子行, 多货型 | 合并单元格, 全角冒号, 货型子行 | | ~~AI~~ | ~~AI~~ | ~~副本版已移除~~ | - | | 6E印度散货 | 6E | 简单 | 生效日期=05-29 | | CX CGK | CX | 主行+子行 | 合并单元格 | | 南非CXEYVN | CX/VN | 主行+子行 | 卡车行跳过 | | D0中东 欧洲 | D0 | 双段式:中东直价+Zone表 | 🚨 新增,col1=目的港,同行多比率共价,Zone表跳过 | | VN | VN | 多目的地 | 生效日期=01-Jun | | HU | HU | 副本版格式:区域+航线+卡车Zone | 🚨 列映射变更,col0=区域,col1=航线,col4=目的站 | | ~~GF~~ | ~~GF~~ | ~~副本版已移除~~ | - | | ~~UA~~ | ~~UA~~ | ~~副本版已移除~~ | 0608恢复 | | ~~QF~~ | ~~QF~~ | ~~副本版已移除~~ | 0608恢复 | | ~~KTM~~ | ~~KTM~~ | ~~副本版已移除~~ | 0608恢复 | | GF | GF | 多目的地,黑底跳过 | 0608恢复,openpyxl检测 | | ~~C6~~ | ~~C6~~ | ~~副本版已移除~~ | 0608新增OSR HGH | | ~~LH~~ | ~~LH~~ | ~~副本版已移除~~ | 0608新增FRA PVG | | ~~CX RUH 停飞中~~ | ~~CX~~ | ~~副本版已移除~~ | 0608恢复,停飞仍提取 | | ~~D0美国~~ | - | ~~图片无法OCR~~ | 0608跳过 |
三、关键规则
3.1 合并单元格价格传播(⚠️ 高频坑)
印度专线、CX CGK、南非 等Sheet中,子行的300/500/1000KG常为合并单元格,只有col8(300KG)有值。
# 子行价格读取后统一处理
if p300 and not p500: p500 = p300
if p500 and not p1000: p1000 = p500
3.2 全角冒号检测
印度专线 Sheet 中货型列存在全角冒号:1:260、1::500。
def is_ratio_str(val):
s = str(val).strip() if pd.notna(val) else ''
return bool(re.match(r'^1[::]+(\d+)', s))
所有 startswith('1:') 判断必须替换为 is_ratio_str()。
3.3 货型子行处理
印度专线中,主行下可能有「散货」「带电托盘」「1M以下矮托盘」等货型子行——这些行的col4有货型描述,col5为/或Q100起...(非1:XXX)。
处理逻辑:
- col4有货型描述 + col6-10有价格 → 提取为1:1行
- 更新
current_cargo供后续子行继承 - st_type 从 col4 解析
3.4 散托类型(多表述)
def st_type_from_cargo(s):
has_tuo = '托' in s
has_san = '散' in s
if has_tuo and has_san: return '散货/托盘'
if has_tuo: return '托盘' # 含 矮托盘/超高/带电托盘
if has_san: return '散货' # 含 带电散货
return '散货/托盘'
常见货型表述:
托/散/托盘/散货→ 散货/托盘托盘/托/超高→ 托盘散货/1:300散货→ 散货带电托盘→ 托盘1M高度以下矮托盘→ 托盘
3.5 黑底单元格过滤(GF专有)
GF Sheet 使用 openpyxl 读取,价格区域 col C-G 有 patternType='solid' 的行整行跳过。
c45_cell = ws.cell(row=row, column=3)
fill = c45_cell.fill
if fill and fill.patternType == 'solid':
continue # 黑底,不提取
3.6 HU 卡车/飞机Zone + 托盘列(⚠️ 托盘增量不提取)
HU Sheet 中 托盘 列(col12=col M)为合并单元格(如 M8:M14 包含 BRU主行+6卡车区)。但 HU 的托盘增量不提取,散托增量统一留空。
中转区有两种格式:
卡车N区:公路转运,col4 列出目的站飞机N区:空运转运,col4 列出目的站(结构完全相同,必须同时提取)
# 必须同时处理卡车和飞机区
is_truck = routing.startswith('卡车') or routing.startswith('飞机')
# HU 所有行:散托增量留空
'散托增量': ''
3.7 目的地继承
印度专线中,同一目的地下多个航司行(col0为空)→ 继承 current_dest:
if dest:
current_dest = dest
# 后续无dest的航司行使用 current_dest
3.8 QF 双区块
QF Sheet 有两个独立格式块(SYD + JFK/ORD/DFW),通过 'AOE报价' / 'valid to' 行切换生效日期。
3.9 EY +250KG 特殊列
EY Sheet 价格列为 +100/+250/+500/+1000(无+300)。+250KG 值填入标准 300KG 列。
全局规则
- 频次为空时默认填"Daily":频次列如果为空则填入 Daily(0727全局规则)
- 分泡列不提取:分泡列始终留空,不提取任何分泡数据(0720全局规则)
四、提取流程
1. 确认源文件路径(命名含 AOE + 日期)
2. pandas 读取各 Sheet 结构
3. 逐 Sheet 调用对应提取函数
4. GF 使用 openpyxl 检测填充色
5. 应用合并单元格传播 + 货型判断
6. 生成 24 列 DataFrame
7. validate_columns() 验证
8. 保存 → 自动上传 WPS → 删除临时文件
五、脚本模板
当前脚本:/Users/hantao/WorkBuddy/oone/extract_aoe_0525.py
源文件:0525/AOE 25MAY GARY.xlsx
输出:aoe_0525_output.xlsx(临时,上传后删除)
新批次时需更新:
file_path指向新源文件- 各Sheet 生效日期
- 新增/停飞 Sheet 的处理
六、常见坑
坑1:合并单元格价格(印度/CGK/南非)
子行 300/500/1000KG 合并为单值,只有 col8 有数据。
→ 修复:if p300 and not p500: p500 = p300; if p500 and not p1000: p1000 = p500
坑2:全角冒号(印度专线)
1:260 / 1::500 导致 startswith('1:') 失败。
→ 修复:用 is_ratio_str() 正则 ^1[::]+(\d+)
坑3:货型子行跳过(印度专线)
「散货」「带电托盘」等行 col5 为 / 不是 1:XXX,被跳过。
→ 修复:检测 col4 有货型描述 + col6-10 有价格 → 提取为 1:1 行
坑4:黑底单元格误提取(GF)
GF 部分价格单元格黑色填充表示废弃。
→ 修复:用 openpyxl 读取,patternType=='solid' → 跳过整行
坑5:HU 托盘增量不提取
HU Sheet 的托盘列有 +2/+1 合并单元格,但不提取散托增量,统一留空。
坑6:目的地跨行继承(印度专线)
DEL块下 NH/AI 行的 col0 为空,dest 丢失。
→ 修复:if dest: current_dest = dest + 记录用 current_dest
坑7:子行 100KG 错位
子行记录中 '100KG': p45 应为 p100。
→ 修复:改为 '100KG': p100
坑8:GF openpyxl 警告
DrawingML support is incomplete 不影响数据读取,可忽略。
坑9:D0中东欧洲 多比率同行共用价格(0603新增)
D0 Sheet 的 RATIO col 可能含多级比率(如 1:100;1:300),同行所有比率共用同一价格。
❌ 错误:尝试从下一行读价格 → BAH 1:300 取了 R7 的 38
✅ 正确:同行多比率全部使用该行价格 → BAH 1:100 和 1:300 都是 37
坑10:D0 Zone参考表误提取(0603新增)
D0 Sheet R23+ 是 Zone 参考表(Region/PVG/DEST/add-on),col0='Europe'/'Africa'。
✅ 在循环中检测到 col0 in ('Europe','Africa','Region','ME','General Cargo') → break 终止
坑11:D0 目的港解析(0603新增)
目的港在 col1(非 col0),多目的港用 / 或 , 分隔(如 BAH/AUH/DWC/SHJ)。
排除 PVG(3字母但为起运港)和含 to/+ 的 add-on 字符串。
坑15:EY PVG/SZX/EHU 起始行错误(0603修正)
EY 三个 Sheet 数据起始行均为 R6(df.iloc[5]),不是 R10(df.iloc[9])。
❌ 错误:range(9, len(df)) → 跳过前3~4行有效数据(AMM/AUH/BAH 等)
✅ 正确:range(5, len(df)) → 从 R6 开始
坑16:EY 三个 Sheet 生效日期不同(0603修正)
- EY PVG:R3=
AOE报价6.1 报价6.1-6.15→2026-06-01 00:00:00 - EY SZX:R3=
AOE报价 6.8-6.14→2026-06-08 00:00:00(与PVG/EHU不同!) - EY EHU:R3=
AOE报价 6.1→2026-06-01 00:00:00每次提取必须先读 R3 内容确认生效日期,不能用datetime.now()代替。
坑12:HU 列映射差异(0603新增)
副本版 HU Sheet 格式与 0525 不同:
- col0=区域描述(加拿大区域/美东区域...)
- col1=航线(PEK-YYZ),起运港从航线首段提取
- col4=目的站 / 卡车区列表(用逗号分隔多个3字码)
- col6-10=价格(+45KG/+100KG/+300KG/+500KG/+1000KG)
- 直飞行
routing='直飞',卡车子行routing='卡车N区'
坑13:D0 散托增量(0603新增)
D0 备注 托盘+N → 解析为散货+增量N。
def parse_d0_increment(remark_str):
m = re.search(r'托盘\+(\d+(?:\.\d+)?)', remark_str)
if m: return m.group(1)
m = re.search(r'\+(\d+(?:\.\d+)?)', remark_str)
if m and float(m.group(1)) <= 20: return m.group(1)
return ''
坑17:EY 三个 Sheet 频次处理(0603新增)
- EY PVG/SZX:表格无明确频次标注 → 全部默认
Daily,不能留空 - EY EHU:频次记录在第一个价格行(R6)的备注列,如
*ETD/EHU: D1467(货机)- 解析规则:从第一行备注中
re.search(r'D(\d+)', first_remark)提取,整个Sheet统一使用 - 当前值:
D1467(后续批次可能变化,每次需读 R6 备注确认) - 如未匹配到则回退为
Daily
- 解析规则:从第一行备注中
first_row = df.iloc[5] # R6是第一个数据行
first_remark = safe_str(first_row[6]) # col6 = 备注列
m = re.search(r'D(\d+)', first_remark)
sheet_freq = 'D' + m.group(1) if m else 'Daily'
坑14:印度专线「航司」空值跳过(0603修正)
印度专线中 JIA/CCU 行 MIN 值特殊(MIN 1.7/KG OR 3900/CNY),col2='CX' 有航司但 col0 有其他目的港码。
✅ 修正:if 'MIN' in min_val.upper() or 'KG' in min_val.upper(): i += 1; continue 在主行提取中已覆盖
坑18:印度专线频次清洗 + 生效日期 + ratio双列 + cargo传播(0603修正)
18a. 生效日期读取:
- 印度专线 R7
'AOE报价6.2调整'→ 正则提取 →2026-06-02 - 6E印度散货 R1
'AOE报价05.29 valid from 01.Jun'→ 正则提取 →2026-06-01 - 禁止使用
datetime.now()
18b. 频次清洗函数 clean_freq():
- 删除
via及之后内容(re.sub(r'\bvia\b.*$', '', s)) - 删除所有中文字符
- 删除非频率残留词(转卡车/直达/直飞/服务/窄体等)
- 搜索
\bdaily\b(全字符串)→'Daily' - 🆕 纯中文输入默认 Daily:清理后为空但原始含中文(如"窄体续程")→
'Daily'- 触发例:6E BOM~GOX 共15行 col6='窄体续程',col7无频次信息
- 搜索所有
\bD([\d\-\/]+)模式合并去重 →D{unique_digits}D4-5 D5-6→ 合并 →D456D3/5→D35D3/4/5/7 VIA HKG→D3457
parse_freq(val, remark='')双参数:先试 val,失败则从 remark fallback- 6E DEL: col6='窄体续程'→空,remark='PVG-CCU DAILY 直达'→
Daily
- 6E DEL: col6='窄体续程'→空,remark='PVG-CCU DAILY 直达'→
18c. col4 ratio 检测(印度专线子行格式不一致):
- 标准子行:col4(货型)空,col5(MIN)是 ratio → 原逻辑
- 变异:col4 是 ratio(如
1:300),col5 空 → 需额外is_ratio_str(col4_val)分支- 触发例:GI CGO-BLR R71
['', '', '', '', '1:300', '', '', '21.5', ...]
- 触发例:GI CGO-BLR R71
18d. cargo+ratio 合并更新(O3散货/托盘分类错误):
- col4='散货'(cargo变更)+ col5='1:167'(ratio)→ 需先
current_cargo=col4_val再处理 ratio- 触发例:O3 CGO-BLR R78
['', '', '', '', '散货', '1:167', '', '17.5', ...] - 之前:用旧的
current_cargo(限1米以下矮托盘)→ 散托类型=托盘 ✗ - 修正:更新后 → 散托类型=散货 ✓
- 触发例:O3 CGO-BLR R78
# 子行4路分支处理顺序:
# A) col4是ratio, col5非ratio → ratio在货型列
# B) col4是cargo, col5是ratio → 先更新cargo再ratio
# C) col5是ratio, col4空 → 标准ratio子行
# D) col4是cargo, col5非ratio → 纯cargo变更
坑19:CX CGK 生效日期 + 频次默认Daily(0603修正)
19a. 生效日期:
- R7
'AOE报价6.1'→ 正则(\d{1,2})\.(\d{1,2})→2026-06-01 - 禁止使用
datetime.now()
19b. 频次 VIA HKG → Daily:
- R10 routing='VIA HKG' → clean_freq 删除 via 后剩 'HKG' → 非 Daily 非 D-pattern → 空
parse_freq()最终 fallback:无识别频率 → 默认'Daily'
19c. parse_freq() 末尾通用规则(全局生效):
def parse_freq(val, remark=''):
freq = clean_freq(val)
if freq: return freq
if remark:
freq = clean_freq(remark)
if freq: return freq
return 'Daily' # 🆕 无识别频率 → 默认 Daily
- 影响范围:所有 Sheet 中频次列为空/纯中文/VIA 等情况均回退为 Daily
- 例外:EY EHU 单独提取 D1467(不在 routing 列,在 remark),不受影响
坑20:D0 中东 欧洲 生效日期 + ratio减价跳过 + 欧洲段目的地继承(0603修正)
20a. 生效日期:
- R4 col6
'Valid to 04.Jun'→2026-06-04(统一整个Sheet) - 禁止使用
datetime.now()
20b. ratio 含 -1/kg → 不展开:
- R6 ratio_text =
'1:100 -1/kg 55分;1:300 -1/kg。'→ 这些是减价备注,不是真正的比重段 - 检测
ratio_text and '-1/kg' not in ratio_text跳过,不生成 1:100/1:300 额外行 - BAH/AUH/DWC/SHJ 等中东目的地只有 1:1 主行(各1条)
20c. 欧洲段 R12-R16 目的地解析:
- R12 col1 =
'to LEJ+1/kg ,WAW+3/kg+add on'→ 用re.findall(r'\b([A-Z]{3})\b', col1)提取 →['LEJ', 'WAW'] - R13-R16 col1 为空 → 继承
prev_dests(上一行目的地) - 每个比重(1:80/120/167/250/500)LEJ 和 WAW 各生成一条 → 共 10 条
- 散托类型: col7 含"托盘"→散货,否则→散货/托盘
- 1:80/120/167 col7='托盘+2' → inc=2, 散托=散货
- 1:250/500 col7='' → inc=空, 散托=散货/托盘
20d. 频次从路线头提取:
- R4 col1 =
'PVG-BAH-MES 带电不接 D1234'→re.search(r'D(\d+)', ...)→D1234 - 所有目的港统一使用
20e. 目的地排除列表:
# 只排除纯转运点(MES, BRU),BAH 是目的地不能排除
if len(part) == 3 and part.isalpha() and part not in ('PVG', 'MES', 'BRU'):
坑21:HU 「飞机N区」被跳过(0603修正)
HU Sheet 中转区有两种:卡车N区(公路转运)和 飞机N区(空运转运)。两者结构完全相同,都是 col4 列出目的站列表。
原代码只匹配 is_truck = routing.startswith('卡车'),飞机N区 不匹配任何分支,直接被跳过。
- 影响行:R74-R76(飞机1/2/3区,拉美)、R204-R207(飞机1/2/3/4区,中东)、R213-R215、R219-R220、R225-R226、R255、R265、R287、R303-R304、R306-R307
- 以 PEK-HKT 为例:R287
飞机1区含 RUH/SHJ/BAH 等 21 个中东目的地,全部丢失
✅ 修复:
# fuben 版(副本AOE 2JUN EVA.xlsx,col3=routing, col4=dest)
is_truck = routing.startswith('卡车') or routing.startswith('飞机')
# 0601 版(AOE 2JUN EVA.xlsx,col3=routing, col4=cargo)
if (routing.startswith('卡车') or routing.startswith('飞机')) and cargo and cargo != 'NA':
坑22:extract_dest *号/括号/非字符污染(0608修正)
通用 extract_dest() 函数缺陷导致 GF/UA 等 Sheet 产出伪目的地:
| 问题 | 输入示例 | 错误产出 | 原因 |
|------|---------|---------|------|
| 末尾星号 | DEN* / ORD* | 跳过(len=4≠3) | 星号计入长度 |
| 括号备注 | BAH (must go +5/kg) | KG) 伪码 | / 号分割伪造 |
| 非字母码 | Zone 5-6 → 5-6 | 非目的地码 | 无 alpha 检查 |
✅ 修复(三步清洗):
def extract_dest(val):
if not val: return []
val = val.strip().upper()
val = re.sub(r'\*+$', '', val) # ① 去末尾星号
val = re.sub(r'\s*\([^)]*\)', '', val) # ② 去括号备注
# ③ 所有分隔路径统一加 .isalpha()
if ',' in val:
return [d.strip() for d in val.split(',') if len(d.strip())==3 and d.strip().isalpha()]
if '/' in val:
return [d.strip() for d in val.split('/') if len(d.strip())==3 and d.strip().isalpha()]
parts = val.split()
if len(parts) > 1:
return [p.strip() for p in parts if len(p.strip())==3 and p.strip().isalpha()]
return [val] if len(val)==3 and val.isalpha() else []
影响范围:0525/0608 脚本的 GF、UA 提取器(均已修复)。
七、🛡️ 防坑经验总结(0603 批次积累)
以下经验适用于所有价格提取数据源,不限于 AOE。
经验1:频次处理三阶段流水线 ✅
无论哪个 Sheet,频次处理统一走三阶段:
raw_value → clean_freq() → parse_freq(val, remark='') → 最终值
| 阶段 | 函数 | 职责 |
|------|------|------|
| 清理 | clean_freq(val) | 删除 via/VIA + 地名、中文、多余空格;提取 Daily 或 D-pattern |
| 解析 | parse_freq(val, remark='') | 先试 val → 为空则从 remark fallback → 为空则返回 'Daily' |
| 默认 | 最末 fallback | 所有 Sheet 统一:无识别频率 → 'Daily'(不再输出空) |
关键细节:
- D 模式合并去重:
D4-5 D5-6→D456,D3/5→D35 - 纯中文("窄体续程")→
clean_freq返回空 →parse_freq返回'Daily' - VIA 路由("VIA HKG")→
clean_freq过滤后空 →'Daily' - EY EHU 例外:频次从 remark 单独提取(
D1467),不走通用流水线
经验2:生效日期严禁 datetime.now() ✅
所有 Sheet 的生效日期必须从源文件提取,严禁用 datetime.now()。
提取优先级:
- 表格中明确标注(
Valid to 04.Jun→2026-06-04) - R3/R4/R7 等表头行含日期字符串(
AOE报价6.1→2026-06-01) - 都不行 → 从文件名或用户提供的信息推断,绝不用 datetime.now()
原因:0603 当天是 6 月 3 日,但 D0 报价 valid to 6 月 4 日、印度专线 6 月 2 日调整 —— 系统时间与报价生效日无关。
经验3:子行处理「谁来都一样」分支模式 ✅
子行(无航线名的数据行)格式变化多端,必须按优先级多路检测:
# 子行4路分支(按检测顺序):
# A) col4 是 ratio, col5 不是 → ratio 在货型列(如 GI BLR 1:300)
# B) col4 是 cargo, col5 是 ratio → 先更新 cargo 再处理 ratio(如 O3 BLR 散货 1:167)
# C) col5 是 ratio, col4 空 → 标准 ratio 子行
# D) col4 是 cargo, col5 不是 → 纯 cargo 变更
核心教训:不能假设子行某列一定是什么。即使是「比重列」,也可能被填在货型列;即使是「货型列」,也可能同时含 cargo + 下一列 ratio。
经验4:ratio 含减价/加价标记不展开 ✅
检测 ratio 文本是否含 -1/kg、+1/kg(加减价备注):
if '-1/kg' in ratio_text:
pass # 不展开为比重行
这些只是对主行价格的修正说明,不是真正的比重段。
经验5:中转区/路由区「同类全覆盖」✅
同一 Sheet 中功能相同的路由类型必须全部匹配:
- HU:
卡车N区+飞机N区→is_truck = routing.startswith('卡车') or routing.startswith('飞机') - 教训:写
startswith('卡车')时必须问自己「有没有其他等价类型?」
经验6:多目的地「空继承」模式 ✅
连续多行的 col1/路由列为空时,继承上一行的目的地:
if not col1_val:
col1_val = prev_dest # 继承上一行
else:
prev_dest = col1_val # 更新继承源
D0 欧洲段 R13-R16 均为空,依赖此机制正确生成 LEJ/WAW 各比重行。
经验7:排除列表 = 纯转运点,不排除目的地 ✅
路线头 PVG-BAH-MES 解析目的地时:
# ❌ 错误:把 BAH 也排除了(BAH 是目的地)
exclude = {'PVG', 'MES', 'BRU', 'BAH'}
# ✅ 正确:只排除纯转运点
exclude = {'PVG', 'MES', 'BRU'}
规则:三字码在路线头中间且不作为独立目的站出现 → 转运点。
经验8:合并单元格增量「跨行传播」✅
合并单元格中的托盘+N 备注要传播到所有合并子行:
- 读取合并单元格 master value
- 建立 merge_map
- 子行无备注时,用 merge_map 查找主行备注
- 从备注提取增量(卡板/托盘+N 优先)
经验9:验证闭环 ✅
每次修正后必须:
- 跑完整提取脚本
- 对比修正前后条数
- 抽查修正行的实际输出
- 上传 WPS 原地更新
- 不做假设性验证
八、24 列输出标准
from columns_24 import COL_24, validate_columns
所有提取器统一使用 COL_24 列名,输出前调用 validate_columns()。
微信扫一扫