SOL 价格提取 Skill
读取 SOL 多Sheet Excel 价格表 → 24列标准输出 → 上传 WPS。
SOL 是 AOZHOU 图片价格的 Excel 版本,覆盖更多航线(8个Sheet),数据更完整。
一、核心参数
| 参数 | 值 |
|---|---|
| 起始港 | PVG |
| 价格编号 | sol_001 |
| 来源 | SOL |
| 输入格式 | .xlsx(8 个 Sheet) |
| Python | python3 |
| 24列标准 | columns_24.COL_24 + validate_columns() |
二、源文件 Sheet 结构
| Sheet序号 | Sheet名称 | 格式概述 | 列映射 |
|---|---|---|---|
| 1 | MU&JL&HO澳洲 | 块状定价表 | C3-C7 = 45/100/300/500/1000 |
| 2 | 东南亚及港台 | 多块混合 | C3-C7 或 C4-C8 或自定义 |
| 3 | MU-JL欧美加拿大线 | 两种列头类型 | Type A: C3-C7; Type B: C3-C6 |
| 4 | HO欧洲 | 块状+航班表 | Block: C4-C8; Flight: C9-C13 |
| 5 | AI-MU-UL印巴线 | 航班表 | C9-C13 |
| 6 | TG澳洲东南亚中东 | 航班表 | C9-C13 |
| 7 | 上海UL | 简化表 | C5-C7 = 100/300/500 |
| 8 | JL&5J&HO东南亚和中东中转 | 简化表 | C5-C8 = 45/100/300/500 |
二·五、生效日期提取(★必须做,日期全埋在块头文本里)
SOL 源表大部分块没有独立日期列,生效日写在块头合并单元格文本里,格式五花八门:
| 位置 | 原文样例 | 解析结果 |
|---|---|---|
| Sheet1 块头 | 直飞奥克兰MU航班 AKL 不分泡 10/2号起运价 |
2026-10-02 |
| Sheet1 块头 | MU PVG直飞BNE 不分泡 9/25号起运价 |
2026-09-25 |
| Sheet1 块头 | MU/HO统配MEL 不分泡 9/28起指定航司+2 |
2026-09-28 |
| Sheet3 块头 | MU直飞 DUB 9.25日起 |
2026-09-25 |
| Sheet3 真列 | G列 生效日期 / 2026/9/27号 |
2026-09-27 |
| Sheet4/5 块头 | HO-ATH(不能锂电池) 5/5分泡 10.2号起的节中特价 |
2026-10-02 |
| Sheet7(印巴) | L2 生效日期:9月28号 |
2026-09-28 |
解析规则(每个块继承块头日期,写入该块所有价格行的「开始生效日期」):
- 支持格式:
M/D号起、M/D起、M.D日起、M.D号起、M月D号、YYYY/M/D号。 - 年份一律按源文件名批次年(
SOL最新价格销售表 2026.9.28.xlsx→ 2026),禁止拿 datetime.now() 猜、禁止把过期批次改成今天。 - 某价格行所属块头解析不到日期(如 Sheet6 上海UL、Sheet8 东南亚中东中转无日期文本)→ 填文件名批次日期(2026-09-28),这是供应表"本期价格"的默认生效日,不算伪造。
- 解析后逐行
keep_effective(开始生效日期, job_cutoff())过滤:早于对照日的批次(如7/19号起、4/23号起旧块)整块丢掉,禁止给过期块借用邻近块的日期把它救活(0928 生产 182 行草稿:TBS 块头「生效日期9.18」、CPH「9.27」、YVR「9/21起」全过期,却被标成 09-30/10-01 混进成果——日期只认本块块头原文);9/28、10/2、10/5等必须保住。 - 禁止"找不到 EFF 表头就整表 continue"——日期在块头文本里,不在表头行。
二·六、目的港/航司继承(★状态机,禁止逐行找三字码)
SOL 的 Sheet 1/2/3/4/5/8 是块状结构:目的港和航司写在块头合并单元格里,块下面的价格行 A 列是空的。逐行在数据行里找 [A-Z]{3} 会漏掉整块——这是 0928 只抽出 3 行事故的根因。
正确做法(每表一个状态机):
current_dest = '' # 三字码,如 AKL/MEL/BNE
current_airline = '' # MU/HO/JL/TG/5J/AI/UL…
current_date = '' # 见二·五,从块头解析
for r in range(len(df)):
line = ' '.join(str(v) for v in df.iloc[r].tolist() if v is not None)
# 块头行:含中文城市名/航班词/「起运价」「分泡」「统配」等
if 是块头(line):
current_dest = 城市名或三字码(line) # 奥克兰→AKL
current_airline = 航司码(line) # MU/HO 统配 → 两个都要出行或按主航司
current_date = 解析日期(line) or current_date
continue # 块头本身不是价格行
# 价格行:五档列有数字 → 用 current_dest/current_airline/current_date 出行
if 有五档价格(row):
add_row(current_dest, current_airline, ..., eff_date=current_date)
| 中文/写法 | 目的港 | 备注 |
|---|---|---|
| 奥克兰 | AKL | 块头「直飞奥克兰MU航班 AKL」通常两者都有 |
| 墨尔本 | MEL | 常与 SYD 合并「SYD MEL 中转统配」→ 拆两行或按列区分 |
| 悉尼 | SYD | |
| 布里斯班 | BNE | |
| 雅加达 | CGK | |
| 马尼拉 | MNL | |
| 第比利斯 | TBS;阿拉木图 ALA;塔什干 TAS;伊斯坦布尔 IST | |
| 台北 | TPE;东京 NRT/HND;大阪 KIX |
判定"块头行":A 列文本含中文城市名或「直飞/统配/中转/航班/起运价/分泡」且五档价格列无数字;纯三字码开头(如 MU-DEL)也是块头。价格行则五档至少一档是数字。
★禁止对整行文本取 re.findall(r'\b[A-Z]{3}\b')[0] 当目的港——航班信息行
MU779 DAILY ETD 00:10 ETA 11:35 抓到的第一段是 ETD,MIN 1008 抓到 MIN,
表头 ORG|DEST 抓到 DES,STN DEP 抓到 STD。这些词形状合法(3 位大写字母)
但不是港口,2.12.012 起校验器直接拒。目的港只认两个来源:
① 块头里的显式三字码(AKL)或城市名映射(奥克兰→AKL);② 目的港列单元格值(取横线右侧段,PVG-BKK-CGK → CGK)。
「PVG→PVG」(目的港抄成起始港)同样被校验拒。
- Sheet 6(上海UL)例外:目的港逐行写在 A/B 列(DXB/RUH/CMB…),不需要状态机。
- Sheet 8:A 列逐行有
KCH(PEN卡转)等值,取行内三字码即可;匹配不到才回退块头继承。 - 块头解析不出 dest 的行,禁止输出空目的港(校验会拒),跳过并打印
skip r=… line=…留痕。 - 每表处理完打印:
print(sheet, 'rows=', n)。SOL 全文件预期 60+ 行价格;任何一张含价数字的表输出 0 行 = 漏块,必须回查块头结构。
三、列映射字典(核心)
每个 Sheet 的列头结构不同,必须使用对应的列映射字典:
# 列映射常量
CM_5 = {'p45': 3, 'p100': 4, 'p300': 5, 'p500': 6, 'p1000': 7} # C3-C7 (5列)
CM_4 = {'p100': 3, 'p300': 4, 'p500': 5, 'p1000': 6} # C3-C6 (4列, 无p45)
CM_S2 = {'p45': 4, 'p100': 5, 'p300': 6, 'p500': 7, 'p1000': 8} # C4-C8 (Sheet2部分)
CM_S5 = {'p45': 9, 'p100': 10, 'p300': 11, 'p500': 12, 'p1000': 13} # C9-C13 (Sheet5/6)
CM_S7 = {'p100': 5, 'p300': 6, 'p500': 7} # C5-C7 (Sheet7, 3列)
CM_S8 = {'p45': 5, 'p100': 6, 'p300': 7, 'p500': 8} # C5-C8 (Sheet8, 4列)
CM_S4B = {'p45': 4, 'p100': 5, 'p300': 6, 'p500': 7, 'p1000': 8} # C4-C8 (Sheet4 block)
CM_S4F = {'p45': 9, 'p100': 10, 'p300': 11, 'p500': 12, 'p1000': 13} # C9-C13 (Sheet4 flight)
Sheet 3 两种列头类型(关键)
| 类型 | 适用目的港 | 列结构 |
|---|---|---|
| Type A (5列) | DUB, VCE, ZRH, TBS, ARN, CPH | C3-C7 = 45/100/300/500/1000 |
| Type B (4列) | BCN, HEL, CDG, LHR, SFO, LAX, YVR, YYZ, ORD | C3-C6 = 100/300/500/1000 (无p45) |
Sheet 2 自定义列头
部分块使用非标准列头,需逐块检查:
- MNL/CAI/CMN/TPE/MLE/CMB: C3-C7 (CM_5)
- CGK/IST: C4-C8 (CM_S2)
- ALA/MNL-5J: C3=45, C4=100, C5=500, C6=1000 (CM_ALA, 无p300)
- RUH-5J/UL: 同 CM_ALA
四、散/托双行价格自动检测
源 Excel 单元格内可能包含双行价格,格式为 散 18\n托盘 20。add_row() 函数自动检测并拆分:
def parse_dual(val):
"""Parse '散 18\n托盘 20' -> (18.0, 20.0)"""
s = str(val).strip()
m = re.match(r'散\s*([\d.]+)\s*\n\s*托[盘盘]?\s*([\d.]+)', s)
if m:
return float(m.group(1)), float(m.group(2))
# 非双行格式,返回同一值
return float(s), float(s)
def add_row(ws, r, col_map, ...):
# 检测任一价格列是否含 '散' 和 '\n'
is_dual = False
for key in ['p45', 'p100', 'p300', 'p500', 'p1000']:
if key in col_map:
val = rv(ws, r, col_map[key])
if val and isinstance(val, str) and '散' in val and '\n' in val:
is_dual = True
break
if is_dual:
# 生成散货 + 托盘两行
...
else:
# 单行直接读取
...
五、价格值处理
def vp(val):
"""转换价格为 float 或空字符串"""
if val is None or val == '/' or val == '' or val == 'MIN':
return ''
if isinstance(val, (int, float)):
return float(val)
try:
return float(str(val).strip())
except:
return ''
/→ 留空MIN→ 留空'032'→ 32.0(前导零自动处理)
六、跳过规则
| 规则 | 说明 |
|---|---|
| 空价格行 | 5个价格列全为空或'/' → 跳过 |
| AI 航司停飞 | Sheet 5 中 AI 航司行跳过 |
| TG 零价格 | Sheet 6 中 p45 为 None 或 0 → 跳过 |
| 卡车转运表 | Sheet 1 中 BNE 中转卡车段不提取(或按需提取) |
| Sheet 5 R83-R101 | 与 Sheet 7 重复 → 跳过 |
空价格行跳过实现
def add(dest, airline, ...):
# Skip rows where all prices are empty
if not any([p45, p100, p300, p500, p1000]):
return
rows.append([...])
七、ZRH 特殊处理
ZRH(苏黎世)散货和托盘的分泡规则不同:
| 货型 | 分泡 | 处理方式 |
|---|---|---|
| 散货 | 5/5 分泡 | fenbao='5/5' |
| 托盘 | 不分泡 | fenbao='' |
需要分别调用 add_row() 两次:一次为托盘行(不分泡),一次为散货行(5/5分泡)。
# 托盘行 (不分泡)
add_row(ws3, 40, CM_5, 'ZRH','MU','1:1',0,9999,'托盘','D247', '不可锂电池; 托盘不分泡')
# 散货行 (5/5分泡)
add_row(ws3, 41, CM_5, 'ZRH','MU','1:1',0,9999,'散货','D247', '不可锂电池; 散货分泡5/5', fenbao='5/5')
八、Sheet 6 TG 中转目的地提取
Sheet 6 的 TG 中转目的地通过循环提取,每个目的港一行:
transit_info = [
(9, 'MNL', 'Daily'), (10, 'DEL', 'Daily'), (11, 'AMD', 'Daily'),
(12, 'BLR', 'Daily'), (13, 'BOM', 'Daily'), (14, 'BOM', 'D12457'),
(15, 'CCU', 'Daily'), (16, 'LHE', 'D12457'), (17, 'MAA', 'Daily'),
(18, 'HYD', 'Daily'), (19, 'ISB', 'D1356'), (20, 'KHI', 'D2457'),
(22, 'CMB', 'Daily'), (23, 'DAC', 'Daily'), (25, 'DPS', 'Daily'),
(27, 'CGK', 'Daily'), (29, 'HAN', 'Daily'), (31, 'KUL', 'Daily'),
(33, 'KTI', 'Daily'), (35, 'RGN', 'Daily'), (37, 'SGN', 'Daily'),
(40, 'SIN', 'Daily'), (44, 'VTE', 'Daily'), (46, 'TPE', 'Daily'),
]
for r, dest, freq in transit_info:
p45_val = rv(ws6, r, 9)
if p45_val is None or p45_val == 0:
continue # 零价格跳过
add_row(ws6, r, CM_S5, dest, 'TG', '1:1', 0, 9999, '散货/托盘', freq,
f'PVG-BKK-{dest}', transit='BKK')
九、Sheet 7 上海UL 提取
Sheet 7 只有 3 列价格(100/300/500),使用 CM_S7。两组路线:
- PVG-BKK-UL: 17 个目的港(DXB/RUH/CMB/MLE/MAA/BLR/COK/HYD/BOM/DEL/KTM/DAC/KHI/LHE/KWI/DOH/DMM)
- NKG-KUL-UL: 6 个目的港(DXB/RUH/KTM/DAC/KHI/LHE)
for i, dest in enumerate(ul_dests_1):
r = 3 + i
p100_val = rv(ws7, r, 5)
if p100_val is None or p100_val == '/':
continue
add_row(ws7, r, CM_S7, dest, 'UL', '1:1', 0, 9999, '散货/托盘', 'Daily',
f'PVG-BKK-{dest}; 不分泡; 窄体', transit='BKK')
十、Sheet 8 HO 东南亚和中东中转提取
Sheet 8 有 4 列价格(45/100/300/500),使用 CM_S8。通过目的港名称在 A/B 列匹配行号:
ho_dests = [
('HKG', 'Daily', '不接锂电池; 单件限重120KG'),
('TPE', 'D12457', '不接锂电池; 单件限重120KG'),
('KHH', 'D236', '不接锂电池; 单件限重120KG'),
('PEN', 'D2467', '不接锂电池; 单件限重120KG'),
# ... 共 11 个目的港
]
for dest, freq, note in ho_dests:
for r in range(7, 25):
if rv(ws8, r, 1) == dest or rv(ws8, r, 2) == dest:
add_row(ws8, r, CM_S8, dest, 'HO', '1:1', 0, 9999, '散货/托盘', freq, note)
break
十一、散托类型判断
| 条件 | 散托类型 |
|---|---|
| 源数据标「托盘」 | 托盘 |
| 源数据标「散货」 | 散货 |
| 源数据标「散/托」或双行拆分 | 散货/托盘 |
| 比重行(1:300, 1:500等)合并档 | 散货/托盘 |
十二、货型与比重映射
| 源数据货型 | 输出货型 | 开始比重 | 结束比重 |
|---|---|---|---|
| 平货/平托/平散/散/托同价 | 1:1 | 0 | 9999 |
| <120/120以内/120以下 | <120 | 0 | 120 |
| <100/100以内/100以下 | <100 | 0 | 100 |
| <80/80以内 | <80 | 0 | 80 |
| 1:200 | 1:200 | 200 | 下一档start |
| 1:300 | 1:300 | 300 | 下一档start |
| 末档 | 1:XXX | XXX | 9999 |
十三、常见坑
坑1:Sheet 3 列头类型混淆(最严重)
问题:Sheet 3 有两种列头结构,Type A(C3-C7)和 Type B(C3-C6)。如果不区分,Type B 行用 CM_5 会读错列。
修复:Type A 用 CM_5,Type B 用 CM_4。
坑2:Sheet 4 块状表与航班表列头不同
问题:Sheet 4 块状定价区用 C4-C8,航班表用 C9-C13。
修复:Block 用 CM_S4B,Flight 用 CM_S4F。
坑3:散/托双行价格未拆分
问题:单元格内 散 18\n托盘 20 被当作字符串处理,价格变为空。
修复:add_row() 自动检测双行格式并拆分为两行。
坑4:空价格行未跳过
问题:源数据中所有价格为 '/' 的行仍被输出,产生空价格行。
修复:add() 函数检查所有5个价格列,全空时跳过。
坑5:ZRH 散/托分泡差异
问题:ZRH 散货 5/5 分泡,托盘不分泡。如果统一处理会丢失差异。
修复:分别调用 add_row() 两次,散货行 fenbao='5/5',托盘行 fenbao=''。
坑6:Sheet 2 自定义列头
问题:Sheet 2 部分块使用非标准列头(如 ALA 的 C3=45, C4=100, C5=500, C6=1000,无 p300)。
修复:逐块检查列头,为非标准块创建自定义列映射(如 CM_ALA)。
坑7:目的港埋在块头,逐行找三字码只抽出 3 行(0928 事故)
问题:脚本用「本行 A 列有没有 [A-Z]{3}」判断目的港,块下面价格行 A 列为空 → 整块 continue。8 张表 722 行只出 3 行还过了 24 列校验。
修复:按「二·六、目的港/航司继承」写状态机;中文城市名映射三字码;每表打印行数,含价数字的表输出 0 行就是漏块。
坑8:整行抓 [A-Z]{3} 第一段,把 ETD/MIN/DES 当目的港(0928 160 行草稿事故)
问题:修坑7时改成"块头文本抓第一个三字码继承",结果 MU779 DAILY ETD 00:10 ETA 11:35 抽出 ETD(39 行)、MIN 1008 抽出 MIN、ORG/DEST 表头抽出 DES、PVG-BKK-XXX 串取错第一段 → PVG→PVG 79 行。都是形状合法的假目的港,旧校验器全放行。
修复:目的港只认块头显式三字码/城市映射、目的港列单元格(横线右侧段);ETD/ETA/STD/MIN/DES/ACFT 等术语残留 2.12.012 校验器直接拒。
坑9:距离表/中转目的地列当价格 + 过期块日期改写(0928 182 行草稿事故)
问题:生产 182 行草稿混入 8 个本地正确跳过、它却当价格抽出的脏港,三种来源:
1. 距离/天数表行:Sheet3 r92-93「CDG 6391 3 MXP 2556 2.8」是 距离km/天数(r113「GOT 232 1」、r114「HEL 1162 2」、r109-112 卡车点距离表同理),抽出来就是「45KG=6391、100KG=3」的怪价——欧洲线块之间穿插的三字码+两个数字的行一律不是价格。
2. 右侧中转目的地列:Sheet3 r209-221 MU-YYZ 块右侧「M | +100KG | 货物目的地 / 502 6.4 BOS」是二程转运附表,BOS 不是本块目的港,那几列也不进价格位。
3. 过期块改日期混入:TBS 块头「生效日期9.18」、CPH「9.27」、YVR「9/21起」全部早于对照日 → 整块过期应跳过;生产稿却标成 09-30/10-01 混了进来。生效日只认块头原文「X.X生效/起运价」,禁止就近取别的块的日期或自行推日期;ABZ 是 JL-LHR 行备注列里的卡车点清单(ABZ BFS BHX…),备注列三字码不是目的港。
修复:块头解析出生效日 → 早于对照日整块跳过(不许用邻近块日期顶替);块间「三字码+两数字」孤立行、块右侧多出的「M/+100KG/货物目的地」附表列、备注列城市清单,一律不产出价格行。
坑10:本地 223 行基准(0928 定稿口径)
对照日 2026-09-28 时,本文件正确提取 = 223 行 / 48 目的港 / MU108·HO61·UL23·MU-HO13·JL10·AI5·FM2·5J-UL1,空价行 0、无 GOT/BUD/BOS/CPH/TBS/YVR/ABZ/PRG(坑9 脏港)。每表行数:澳洲32、东南亚及港台39、欧美加95、HO欧洲29、HO节中18、上海UL23、印巴18、JL5JHO中转14。跳过量大的段:Sheet1 中转统配卡车点段、Sheet2 R82-101 EK/SV/G9 换单表、Sheet3 r108+ 卡车点距离表、Sheet4 R44+ 卡车点表、2024-2025 与 3-7 月过期块。
十四、校验清单
提取完成后必须检查:
- 24列校验:
validate_columns()PASS - 空价格行:5个价格列全空的行数 = 0
- 散托类型合法:仅 托盘/散货/散货/托盘 三种
- 分泡分布:5/5 分泡行有对应备注
- 抽样对比:DUB/BCN/ZRH/BRU/LHR/YYZ 等之前有 bug 的行与源数据对比
- 航司分布:MU/TG/HO/UL/JL/5J-HO/5J-UL/MU-HO/JL-HO-TG-PR
- 目的港数量:约 60+ 个
十五、脚本模板
参考脚本:workspace/0810/extract_sol_0810_v2.py
新批次时需更新:
1. 源文件路径(SRC_FILE)
2. 逐 Sheet 检查列头是否与上次一致(可能新增/删除列)
3. 检查是否有新增目的港或航司
4. 检查跳过规则是否需要调整(停飞航司等)
5. 运行后校验空价格行、散托类型、分泡字段
6. 上传 WPS
十六、WPS 上传
python3 \
workspace/skills/wps-upload/scripts/upload.py \
<output_file> <WPS_DRIVE_ID> <WPS_PARENT_ID> sol_<MMDD>.xlsx