kyle 3 hari lalu
induk
melakukan
36041e12dd

+ 42 - 41
auto-generate-export-contracts/SKILL.md

@@ -1,4 +1,4 @@
----
+---
 name: auto-generate-export-contracts
 description: "根据订单合同信息自动生成车辆出口销售合同(Word)和Proforma Invoice(Excel)。用于处理海外车辆出口业务。触发词:生成合同、生成PI、生成Proforma Invoice、做合同、做PI、合同信息、订单信息、出口合同、销售合同、车辆出口、帮我做合同、做出口合同、生成销售合同、FCA合同、FOB合同、买方信息、目的港、意向车型、合同签约公司、车辆明细。当用户提供包含以下任意字段的订单信息时触发:姓名/联系人、意向车型/车辆型号(LZW开头)、合同签约公司、买方公司、出发港口、目的港、结算方式(FCA/FOB/EXW)。支持格式:编号列表(1.姓名 2.任职 3.意向车型...)或自然语言描述。"
 ---
@@ -24,8 +24,8 @@ pip install --user -r requirements.txt
 | 包名 | 版本要求 | 用途 |
 |------|---------|------|
 | `python-docx` | >=1.1.0 | 生成/修改 Word 销售合同 |
- | `openpyxl` | >=3.1.0 | 生成/修改 Excel Proforma Invoice |
- | `requests` | >=2.31.0 | 调用海外价格系统 API |
+| `openpyxl` | >=3.1.0 | 生成/修改 Excel Proforma Invoice / 解析报价表 |
+| `requests` | >=2.31.0 | 调用海外价格系统 API |
 | `num2words` | >=0.5.13 | 金额英文大写转换 |
 
 ## 功能
@@ -52,44 +52,12 @@ pip install --user -r requirements.txt
 - 出发港口/起运港 + 目的港/目的地国家
 - 结算方式(FCA/FOB/EXW/CIF)
 
-**典型输入格式示例:**
-
-格式1 - 编号列表(最常见):
-```
-1. 姓名:张三;
-2. 任职:经理;
-3. 意向车型:五菱星光560 燃油版- 型号:LZW6470KHUA6 代码:VS03 5台蓝色
-4. 合同签约公司中文名称:xxx有限公司;
-5. 合同签约公司英文名称:xxx Co.,Ltd.
-6. 合同签约公司中文地址:xxx
-7. 合同签约公司英文地址:xxx
-8. 合同联系电话:138xxxx
-9. 国内出发港口:广州南沙新港;
-10. 目的地国家:xxx
-11. 目的地港口:xxx
-12. 结算方式:FCA
-```
-
-格式2 - 自然语言描述:
-```
-帮我做一份出口合同,买方是xxx公司,要5台五菱星光560,型号LZW6470KHUA6,
-发到贝宁科托努港,FCA条款,广州南沙出货
-```
-
 ## 工作流程
 
 ### Step 1: 接收订单信息
 
 支持两种输入格式:
-- **结构化编号列表**(标准格式):
-  ```
-  1. 姓名:xxx;
-  2.任职:xxx;
-  3.意向车型:五菱星光560 燃油版- 型号:LZW6470KHUA6 代码:VS03 5台蓝色
-  4.合同签约公司中文名称:xxx;
-  5.合同签约公司英文名称:xxx
-  ...
-  ```
+- **结构化编号列表**(标准格式)
 - **自然语言描述**:用户自由描述订单内容
 
 解析提取核心字段:
@@ -99,7 +67,7 @@ pip install --user -r requirements.txt
 - 贸易条款(FCA/FOB/EXW)
 - 出发港口、目的地国家
 
-### Step 2: 车型价格查询(海外价格系统 API)
+### Step 2a(默认路径): 车型价格查询(海外价格系统 API)
 
 通过 `scripts/overseas_price_client.py` 连接海外价格报价系统,按车型型号查询实时价格与配置:
 
@@ -114,7 +82,23 @@ pip install --user -r requirements.txt
 
 **用户提供的价格优先于 API 返回的价格。**
 
-**前置配置:** 在 `.env` 文件中填写 API 连接信息(URL、用户名、密码)。
+### Step 2b(报价表路径): 从报价表 xlsx 提取价格
+
+在用户提供五菱海外报价表 xlsx 的情况下,系统自动从报价表提取价格,
+完全替代 API 查询。报价表的价格已包含运费和加装费,确保合同总价与客户实际报价一致。
+
+```bash
+python scripts/generate_contracts.py order.txt -o ./output -q "报价表.xlsx"
+```
+
+1. 加载报价表 xlsx,自动识别中英文两个 Sheet(中文报价表 / English Quotation)
+2. 按车型型号(D列 型号/Model)精确匹配订单中的每一款车
+3. 提取 N列(FCA价格USD)作为单价(已包含运费和加装费)
+4. 提取 I列配置信息、F列发动机描述,用于生成车型描述
+5. 提取英文 Sheet 的配置描述优先用于合同和PI
+6. 如果报价表中找不到某车型 → 提示用户手动补充价格(同现有流程)
+
+**适用场景:** 报价系统(API)返回的价格不含运费加装费时,直接使用报价表的数据。
 
 ### Step 3: 计算汇总
 
@@ -149,19 +133,35 @@ pip install --user -r requirements.txt
 ## 脚本使用
 
 ```bash
+# 默认路径(API查询)
 python scripts/generate_contracts.py <订单信息文件.txt> -o <输出目录> -p '{"LZW1028SPY": 6176}'
+
+# 报价表路径(替代 API,优先使用)
+python scripts/generate_contracts.py order.txt -o ./output -q 报价表.xlsx
+
+# 混合:报价表 + 手动补充缺失车型
+python scripts/generate_contracts.py order.txt -q 报价表.xlsx -p '{"LZW5030": 8000}'
 ```
 
 参数:
 - `order_file`: 订单信息文本文件路径
 - `-o, --output`: 输出目录(默认当前目录)
 - `-p, --prices`: JSON字符串,手动提供缺失的车型价格
+- `-q, --quotation`: 报价表 xlsx 文件路径(可选,提供后替代 API 查询)
 
 Python API:
 ```python
 from scripts.generate_contracts import generate_contracts
 
+# 默认路径(API查询)
 result = generate_contracts(order_text, output_dir='.', user_prices={"LZW1028SPY": 6176})
+
+# 报价表路径
+result = generate_contracts(order_text, output_dir='.', quotation_path='报价表.xlsx')
+
+# 混合使用
+result = generate_contracts(order_text, output_dir='.', quotation_path='报价表.xlsx', user_prices={"LZW5030": 8000})
+
 # result['success'] = True/False
 # result['pi_path'] = PI文件路径
 # result['contract_path'] = 销售合同文件路径
@@ -178,9 +178,10 @@ result = generate_contracts(order_text, output_dir='.', user_prices={"LZW1028SPY
 
 - `assets/proforma-invoice-template.xlsx`: Proforma Invoice模板
 - `assets/vehicle-sales-contract-template.docx`: 销售合同模板
- - `scripts/overseas_price_client.py`: 海外价格系统 API 客户端
- - `.env`: API 连接配置(URL、用户名、密码)
+- `scripts/overseas_price_client.py`: 海外价格系统 API 客户端
+- `scripts/parse_quotation.py`: 报价表 xlsx 解析引擎
+- `.env`: API 连接配置(URL、用户名、密码)
 
 ## 字段映射详情
 
-详见 [references/field-mapping.md](references/field-mapping.md)
+详见 [references/field-mapping.md](references/field-mapping.md)

+ 171 - 17
auto-generate-export-contracts/scripts/generate_contracts.py

@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 """
 主生成脚本:根据订单信息生成销售合同和Proforma Invoice
@@ -16,6 +16,7 @@ sys.path.insert(0, script_dir)
 
 from parse_order_info import parse_order_info
 from match_vehicle import match_vehicle, build_vehicle_description
+from parse_quotation import load_quotation, match_vehicle_from_quotation
 from number_to_words import format_say_us_only, calculate_payment_split
 
 # 模板正文默认字号:12pt
@@ -43,6 +44,31 @@ def get_asset_path(filename: str) -> str:
     return os.path.join(script_dir, '..', 'assets', filename)
 
 
+def _get_clean_template():
+    """Return path to a cleaned PI template with no embedded stamp."""
+    orig = get_asset_path("proforma-invoice-template.xlsx")
+    cache = os.path.join(os.environ.get("TEMP", os.getcwd()), "_pi_clean_template.xlsx")
+    if not os.path.exists(cache) or os.path.getmtime(orig) > os.path.getmtime(cache):
+        import zipfile
+        import re as _re
+        with zipfile.ZipFile(orig, "r") as zin:
+            with zipfile.ZipFile(cache, "w") as zout:
+                for item in zin.namelist():
+                    if "drawing" in item.lower() or "media" in item.lower():
+                        continue
+                    data = zin.read(item)
+                    if item == "xl/worksheets/sheet1.xml":
+                        data = data.decode("utf-8")
+                        data = _re.sub(r"<drawing[^>]*/>", "", data)
+                        data = data.encode("utf-8")
+                    if item == "xl/worksheets/_rels/sheet1.xml.rels":
+                        data = data.decode("utf-8")
+                        data = _re.sub(r"<Relationship[^>]*drawing[^>]*/>", "", data)
+                        data = data.encode("utf-8")
+                    zout.writestr(item, data)
+    return cache
+
+
 def generate_contract_no() -> str:
     """生成合同编号: HLXYWPA{年月日}"""
     return f"HLXYWPA{datetime.now().strftime('%Y%m%d')}"
@@ -84,6 +110,48 @@ def get_port_names(port_input: str) -> tuple:
     # 默认返回输入值
     return port_input, port_input
 
+# 国家名称中文→英文映射(用于Export Destination字段)
+COUNTRY_NAME_MAP = {
+    '加纳': 'Ghana',
+    '巴拿马': 'Panama',
+    '安哥拉': 'Angola',
+    '阿尔及利亚': 'Algeria',
+    '阿根廷': 'Argentina',
+    '阿联酋': 'UAE',
+    '埃及': 'Egypt',
+    '埃塞俄比亚': 'Ethiopia',
+    '澳大利亚': 'Australia',
+    '巴基斯坦': 'Pakistan',
+    '巴西': 'Brazil',
+    '刚果': 'Congo',
+    '哥伦比亚': 'Colombia',
+    '哈萨克斯坦': 'Kazakhstan',
+    '韩国': 'South Korea',
+    '荷兰': 'Netherlands',
+    '加拿大': 'Canada',
+    '肯尼亚': 'Kenya',
+    '马来西亚': 'Malaysia',
+    '美国': 'USA',
+    '孟加拉': 'Bangladesh',
+    '秘鲁': 'Peru',
+    '墨西哥': 'Mexico',
+    '南非': 'South Africa',
+    '尼日利亚': 'Nigeria',
+    '日本': 'Japan',
+    '泰国': 'Thailand',
+    '坦桑尼亚': 'Tanzania',
+    '土耳其': 'Turkey',
+    '乌兹别克斯坦': 'Uzbekistan',
+    '西班牙': 'Spain',
+    '意大利': 'Italy',
+    '印度': 'India',
+    '印度尼西亚': 'Indonesia',
+    '英国': 'UK',
+    '越南': 'Vietnam',
+    '智利': 'Chile',
+    '中国': 'China',
+}
+
 
 def number_to_chinese(num: int) -> str:
     """将整数金额转换为中文大写(壹贰叁肆伍陆柒捌玖拾佰仟万)"""
@@ -305,6 +373,8 @@ def build_full_description(vehicle: dict, match_result: dict = None) -> str:
     name_en = vehicle.get("name_en", "")
     color_en, color_cn = COLOR_MAP.get(color, (color, color))
     color_display = color if color else ""
+    qty = vehicle.get("quantity", 0)
+    qty_str = f" {qty}" if qty else ""
     lines = []
     if match_result:
         series_en = match_result.get("series_en", "")
@@ -337,7 +407,7 @@ def build_full_description(vehicle: dict, match_result: dict = None) -> str:
             lines.append(config_en)
         elif config_cn:
             lines.append(config_cn)
-        lines.append("Color: " + color_en if color_display else "Color: ")
+        lines.append("Color: " + color_en + qty_str if color_display else "Color: ")
         cn_line = chr(22411) + chr(21495) + chr(65306) + model_code + ", " + eng_first
         if desc_cn:
             cn_line += " " + desc_cn
@@ -351,7 +421,7 @@ def build_full_description(vehicle: dict, match_result: dict = None) -> str:
             lines.append(config_cn)
         elif config_en:
             lines.append(config_en)
-        lines.append(chr(39068) + chr(33394) + chr(65306) + color_cn if color_display else chr(39068) + chr(33394) + chr(65306))
+        lines.append(chr(39068) + chr(33394) + chr(65306) + color_cn + qty_str if color_display else chr(39068) + chr(33394) + chr(65306))
     else:
         eng_lines = [l.strip() for l in engine_code.split(chr(10)) if l.strip()]
         eng_first = eng_lines[0] if eng_lines else engine_code
@@ -365,7 +435,7 @@ def build_full_description(vehicle: dict, match_result: dict = None) -> str:
         lines.append(eng_line)
         for extra in eng_extra:
             lines.append(extra)
-        lines.append("Color: " + color_en if color_display else "Color: ")
+        lines.append("Color: " + color_en + qty_str if color_display else "Color: ")
         cn_line = chr(22411) + chr(21495) + chr(65306) + model_code + ", " + eng_first
         if name_cn:
             cn_line += " " + name_cn
@@ -374,9 +444,9 @@ def build_full_description(vehicle: dict, match_result: dict = None) -> str:
         lines.append(cn_line)
         for extra in eng_extra:
             lines.append(extra)
-        lines.append(chr(39068) + chr(33394) + chr(65306) + color_cn if color_display else chr(39068) + chr(33394) + chr(65306))
+        lines.append(chr(39068) + chr(33394) + chr(65306) + color_cn + qty_str if color_display else chr(39068) + chr(33394) + chr(65306))
     return chr(10).join(lines)
-def process_vehicles(vehicles: list, trade_term: str) -> list:
+def process_vehicles(vehicles: list, trade_term: str, quotation: dict = None) -> list:
     """
     处理车型列表,匹配价格表或提示用户提供价格
 
@@ -395,7 +465,10 @@ def process_vehicles(vehicles: list, trade_term: str) -> list:
             continue
 
         # 从价格表匹配
-        match_result = match_vehicle(model_code, trade_term)
+        if quotation:
+            match_result = match_vehicle_from_quotation(model_code, quotation)
+        else:
+            match_result = match_vehicle(model_code, trade_term)
         if match_result and match_result.get('unit_price_usd'):
             v['unit_price_usd'] = match_result['unit_price_usd']
             v['config_desc'] = build_vehicle_description(v, match_result)
@@ -434,9 +507,10 @@ def calculate_summary(vehicles: list) -> dict:
 
 
 def _add_stamp_to_sheet(ws, template_path, output_path):
-    """Add stamp image. Clean old drawing files first."""
+    """Add stamp image to E30. Remove existing stamp from template first to avoid overlap."""
     import zipfile, os, tempfile
     from openpyxl.drawing.image import Image
+    from openpyxl.drawing.spreadsheet_drawing import SpreadsheetDrawing
     from io import BytesIO
     try:
         img_data = None
@@ -445,12 +519,23 @@ def _add_stamp_to_sheet(ws, template_path, output_path):
                 img_data = tz.read("xl/media/image1.png")
         if not img_data:
             return
+
+        # \u6e05\u7406\u6a21\u677f\u4e2d\u5df2\u6709\u7684\u5370\u7ae0\u56fe\u7247\uff0c\u907f\u514d\u91cd\u53e0
+        ws._images.clear()
+        ws._drawing = None
+
         tmp = os.path.join(tempfile.gettempdir(), "_sgmw_stamp.png")
         with open(tmp, "wb") as f:
             f.write(img_data)
         img = Image(tmp)
         img.anchor = "E30"
         ws.add_image(img)
+
+        # \u660e\u786e\u6784\u5efa SpreadsheetDrawing\uff0c\u786e\u4fdd save \u65f6\u53ea\u6709\u4e00\u4e2a\u5370\u7ae0
+        drawing = SpreadsheetDrawing()
+        drawing.charts = ws._charts
+        drawing.images = ws._images
+        ws._drawing = drawing
     except Exception as e:
         print("Stamp warning:", e)
 def generate_proforma_invoice(order: dict, vehicles: list, summary: dict,
@@ -462,10 +547,11 @@ def generate_proforma_invoice(order: dict, vehicles: list, summary: dict,
     from io import BytesIO
     import zipfile
 
-    template_path = get_asset_path("proforma-invoice-template.xlsx")
+    original_template = get_asset_path("proforma-invoice-template.xlsx")
+    clean_template = _get_clean_template()
     output_filename = contract_no + "-Proforma Invoice.xlsx"
     output_path = os.path.join(output_dir, output_filename)
-    shutil.copy(template_path, output_path)
+    shutil.copy(clean_template, output_path)
     wb = openpyxl.load_workbook(output_path)
     ws = wb[chr(73) + chr(78) + chr(86) + chr(79) + chr(73) + chr(67) + chr(69)]
 
@@ -506,7 +592,7 @@ def generate_proforma_invoice(order: dict, vehicles: list, summary: dict,
         "2. 70% of the Total Amount (USD " + str(summary["balance_70"]) + ") shall be paid to Seller within 10 working days before delivery.     ")
     ws["B29"] = payment_text
 
-    _add_stamp_to_sheet(ws, template_path, output_path)
+    _add_stamp_to_sheet(ws, original_template, output_path)
 
     wb.save(output_path)
     wb.close()
@@ -592,7 +678,13 @@ def generate_sales_contract(order: dict, vehicles: list, summary: dict,
             dest_raw = order.get('destination_country', '')
             if dest_raw and para.runs:
                 # 从混合字符串中提取英文部分(如 "巴拿马Panama" → "Panama")
-                dest_en = re.sub(r'[\u4e00-\u9fff]+', '', dest_raw).strip()
+                dest_en = re.sub(r'[\u4e00-\u9fff\u200b-\u200d\ufeff]+', '', dest_raw).strip()
+                if not dest_en:
+                    # 如果纯中文,尝试通过中→英映射表查找英文名
+                    for cn, en in COUNTRY_NAME_MAP.items():
+                        if cn in dest_raw.replace('\u200b', ''):
+                            dest_en = en
+                            break
                 if not dest_en:
                     dest_en = dest_raw
                 # 找到包含国家名的 run(非 bold、非括号说明部分),替换为新国家名
@@ -606,7 +698,7 @@ def generate_sales_contract(order: dict, vehicles: list, summary: dict,
             dest_raw = order.get('destination_country_cn', '') or order.get('destination_country', '')
             if dest_raw and para.runs:
                 # 从混合字符串中提取中文部分(如 "巴拿马Panama" → "巴拿马")
-                dest_cn = re.sub(r'[A-Za-z0-9\s]+', '', dest_raw).strip()
+                dest_cn = re.sub(r'[A-Za-z0-9\s\u200b-\u200d\ufeff]+', '', dest_raw).strip()
                 if not dest_cn:
                     dest_cn = dest_raw
                 # 找到":"run 之后的第一个内容 run(非括号说明),替换国家名
@@ -714,6 +806,45 @@ def generate_sales_contract(order: dict, vehicles: list, summary: dict,
     return output_path
 
 
+
+from copy import deepcopy
+def _ensure_data_rows(table, needed_rows, summary_row_idx):
+    """Add extra data rows before the summary row if needed."""
+    from lxml import etree
+    ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+    # Count existing data rows
+    data_rows = []
+    for idx, row in enumerate(table.rows):
+        if idx == 0 or idx == summary_row_idx:
+            continue
+        data_rows.append((idx, row))
+    
+    existing = len(data_rows)
+    if existing >= needed_rows:
+        return existing
+    
+    # Need to add (needed_rows - existing) rows
+    cells_count = len(table.rows[1].cells)
+    ref_row = table.rows[1]
+    ref_tr = ref_row._tr
+    tbl = table._tbl
+    
+    # Find the summary row in the XML
+    summary_tr = table.rows[summary_row_idx]._tr
+    
+    for _ in range(needed_rows - existing):
+        new_tr = deepcopy(ref_tr)
+        # Clear text content in cells
+        for tc in new_tr.findall(f"{{{ns}}}tc"):
+            for p in tc.findall(f"{{{ns}}}p"):
+                for r in p.findall(f"{{{ns}}}r"):
+                    for t in r.findall(f"{{{ns}}}t"):
+                        t.text = ""
+        # Insert before summary row
+        tbl.insert(list(tbl).index(summary_tr), new_tr)
+    
+    return needed_rows
+
 def _fill_vehicle_table(table, vehicles, summary, trade_term, port_cn, port_en):
     """填充销售合同中的车辆明细表(保留格式 + 删除多余行)"""
     data_start_row = 1  # 表头后第1行开始数据
@@ -735,7 +866,23 @@ def _fill_vehicle_table(table, vehicles, summary, trade_term, port_cn, port_en):
             break
         available_data_rows.append(idx)
 
-    # 1. 填充车辆数据
+    # Ensure enough data rows for all vehicles
+    summary_row_idx = _ensure_data_rows(table, len(vehicles), summary_row_idx)
+    # Re-find summary row and recompute available rows after row inserts
+    for idx, row in enumerate(table.rows):
+        if idx == 0:
+            continue
+        row_text = ' '.join(cell.text for cell in row.cells)
+        if 'Total' in row_text or '总数量' in row_text or 'SAY USD' in row_text:
+            summary_row_idx = idx
+            break
+    available_data_rows = []
+    for idx in range(data_start_row, len(table.rows)):
+        if idx == summary_row_idx:
+            break
+        available_data_rows.append(idx)
+
+    # 1. Fill vehicle data
     for i, v in enumerate(vehicles):
         if i >= len(available_data_rows):
             break
@@ -780,7 +927,8 @@ def _fill_vehicle_table(table, vehicles, summary, trade_term, port_cn, port_en):
 
 
 def generate_contracts(order_text: str, output_dir: str = '.',
-                        user_prices: dict = None) -> dict:
+                        user_prices: dict = None,
+                        quotation_path: str = None) -> dict:
     """
     主函数:根据订单文本生成合同
 
@@ -788,6 +936,7 @@ def generate_contracts(order_text: str, output_dir: str = '.',
         order_text: 订单信息文本
         output_dir: 输出目录
         user_prices: 用户手动提供的价格 {model_code: price}
+        quotation_path: 报价表xlsx文件路径(可选)
 
     Returns:
         {
@@ -814,7 +963,11 @@ def generate_contracts(order_text: str, output_dir: str = '.',
             if v['model_code'] in user_prices:
                 v['unit_price_usd'] = user_prices[v['model_code']]
 
-    processed, missing = process_vehicles(vehicles, trade_term)
+    quotation_data = None
+    if quotation_path:
+        quotation_data = load_quotation(quotation_path)
+
+    processed, missing = process_vehicles(vehicles, trade_term, quotation_data)
 
     # 如果有缺失价格,返回提示信息
     if missing:
@@ -850,6 +1003,7 @@ if __name__ == '__main__':
     parser.add_argument('order_file', help='Path to order info text file')
     parser.add_argument('-o', '--output', default='.', help='Output directory')
     parser.add_argument('-p', '--prices', help='JSON string of manual prices {"LZW1028SPY": 6176}')
+    parser.add_argument('-q', '--quotation', help='Path to quotation xlsx file')
 
     args = parser.parse_args()
 
@@ -861,7 +1015,7 @@ if __name__ == '__main__':
         import json
         user_prices = json.loads(args.prices)
 
-    result = generate_contracts(order_text, args.output, user_prices)
+    result = generate_contracts(order_text, args.output, user_prices, args.quotation)
 
     import json as json_mod
     sys.stdout.reconfigure(encoding='utf-8')

+ 229 - 0
auto-generate-export-contracts/scripts/parse_quotation.py

@@ -0,0 +1,229 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Quotation xlsx parser for Wuling overseas price sheets.
+
+Extract vehicle prices and config from Wuling overseas quotation xlsx,
+replacing the API-based price lookup path.
+
+Supports dual-sheet format:
+  - Chinese: 中文报价表 (Chinese headers + descriptions)
+  - English: English Quotation (English headers + descriptions, optional)
+"""
+
+import re
+import logging
+from typing import Optional
+
+import openpyxl
+
+logger = logging.getLogger(__name__)
+
+# Chinese header -> internal field mapping
+CN_COL_MAP = {
+    '\u5e8f\u53f7': 'seq_no',
+    '\u63a8\u5e7f\u547d\u540d': 'promotion_name',
+    '\u7248\u672c': 'version',
+    '\u578b\u53f7': 'model_code',
+    '\u8f66\u578b\u4ee3\u7801': 'model_code2',
+    '\u53d1\u52a8\u673a': 'engine',
+    '\u6392\u653e\u6807\u51c6': 'emission_std',
+    '\u8f66\u8f86\u7c7b\u578b': 'vehicle_type',
+    '\u914d\u7f6e\u4fe1\u606f': 'config',
+    '\u56fe\u7247': 'picture',
+    '\u91c7\u8d2d\u6570\u91cf': 'quantity',
+    '\u52a0\u88c5\u4fe1\u606f': 'add_on_info',
+    'FCA\u4ef7\u683c(RMB)': 'fca_price_rmb',
+    'FCA\u4ef7\u683c(USD)': 'fca_price_usd',
+    '\u8fd0\u8d39(RMB)': 'freight_rmb',
+    '\u8fd0\u8d39(USD)': 'freight_usd',
+    '\u52a0\u88c5(RMB)': 'installation_rmb',
+    '\u52a0\u88c5(USD)': 'installation_usd',
+    'FCA\u5c0f\u8ba1(RMB)': 'fca_subtotal_rmb',
+    'FCA\u5c0f\u8ba1(USD)': 'fca_subtotal_usd',
+}
+
+# English header -> internal field mapping
+EN_COL_MAP = {
+    'No.': 'seq_no',
+    'Promotion Name': 'promotion_name',
+    'Version': 'version',
+    'Model': 'model_code',
+    'Model Code': 'model_code2',
+    'Engine': 'engine',
+    'Emission Std': 'emission_std',
+    'Vehicle Type': 'vehicle_type',
+    'Configuration': 'config',
+    'Picture': 'picture',
+    'Quantity': 'quantity',
+    'Add-on': 'add_on_info',
+    'FCA Price(RMB)': 'fca_price_rmb',
+    'FCA Price(USD)': 'fca_price_usd',
+    'Freight(RMB)': 'freight_rmb',
+    'Freight(USD)': 'freight_usd',
+    'Add-on(RMB)': 'installation_rmb',
+    'Add-on(USD)': 'installation_usd',
+    'FCA Subtotal(RMB)': 'fca_subtotal_rmb',
+    'FCA Subtotal(USD)': 'fca_subtotal_usd',
+}
+
+
+def _parse_meta_row(value: str) -> dict:
+    """Parse Row 3 export settings line."""
+    meta = {}
+    if not value:
+        return meta
+    value = re.sub(r'^(\u5bfc\u51fa\u8bbe\u7f6e:|Export Settings:)\s*', '', value).strip()
+    for pair in value.split('|'):
+        pair = pair.strip()
+        if '=' not in pair:
+            continue
+        k, _, v = pair.partition('=')
+        k, v = k.strip(), v.strip()
+        key_map = {
+            '\u57fa\u5730': 'base', 'Base': 'base',
+            '\u6e2f\u53e3': 'port', 'Port': 'port',
+            '\u4ef7\u683c\u7c7b\u578b': 'price_type', 'Price Type': 'price_type',
+            '\u5bfc\u51fa\u4eba': 'exporter', 'Exporter': 'exporter',
+            '\u5ba2\u6237': 'customer', 'Customer': 'customer',
+            '\u552e\u5356\u56fd\u5bb6': 'country', 'Sales Country': 'country',
+            '\u6c47\u7387': 'exchange_rate', 'Exchange Rate': 'exchange_rate',
+        }
+        field = key_map.get(k, k)
+        if field == 'exchange_rate':
+            try:
+                v = float(v)
+            except ValueError:
+                pass
+        meta[field] = v
+    return meta
+
+
+def _detect_col_map(headers: list) -> Optional[dict]:
+    """Detect Chinese or English column map from header row."""
+    for h in headers:
+        hs = (h or '').strip()
+        if hs in CN_COL_MAP:
+            return CN_COL_MAP
+        if hs in EN_COL_MAP:
+            return EN_COL_MAP
+    return None
+
+
+def _build_col_index(headers: list, col_map: dict) -> dict:
+    """Build {field_name: col_index} from header row + map."""
+    idx = {}
+    for ci, h in enumerate(headers):
+        hs = (h or '').strip()
+        field = col_map.get(hs)
+        if field:
+            idx[field] = ci
+    return idx
+
+
+def _parse_sheet(ws) -> tuple:
+    """Parse one sheet. Returns (data_rows, meta, sheet_type)."""
+    max_row = ws.max_row
+    if max_row < 2:
+        return [], {}, 'cn'
+    headers = [cell.value for cell in ws[1]]
+    col_map = _detect_col_map(headers)
+    if col_map is None:
+        return [], {}, 'cn'
+    sheet_type = 'cn' if col_map is CN_COL_MAP else 'en'
+    col_idx = _build_col_index(headers, col_map)
+    meta = {}
+    if max_row >= 3:
+        r3 = ws.cell(row=3, column=1).value
+        if r3 and isinstance(r3, str):
+            meta = _parse_meta_row(r3)
+    rows = []
+    for ri in range(2, max_row + 1):
+        rv = [cell.value for cell in ws[ri]]
+        if not any(v for v in rv):
+            continue
+        # skip rows that are export settings (Row 3 style)
+        first_val = str(rv[0] or '')
+        if '\u5bfc\u51fa\u8bbe\u7f6e' in first_val or 'Export Settings' in first_val:
+            continue
+        entry = {}
+        for field, ci in col_idx.items():
+            if ci < len(rv):
+                entry[field] = rv[ci]
+        if not entry:
+            continue
+        if entry.get('model_code'):
+            entry['model_code'] = str(entry['model_code']).strip().upper()
+        rows.append(entry)
+    return rows, meta, sheet_type
+
+
+def load_quotation(excel_path: str) -> dict:
+    """Load quotation xlsx. Returns {'meta': {...}, 'rows': [...]}.
+
+    Merges data from both Chinese and English sheets by model_code.
+    """
+    wb = openpyxl.load_workbook(excel_path, data_only=True)
+    cn_rows, meta, _ = _parse_sheet(wb['\u4e2d\u6587\u62a5\u4ef7\u8868'])
+    en_rows, _, _ = _parse_sheet(wb['English Quotation'])
+    en_by_model = {r['model_code']: r for r in en_rows if r.get('model_code')}
+    merged = []
+    for cr in cn_rows:
+        mc = cr.get('model_code')
+        er = en_by_model.get(mc, {})
+        merged.append({
+            'model_code': mc,
+            'name_cn': cr.get('promotion_name', ''),
+            'name_en': er.get('promotion_name', ''),
+            'version': cr.get('version', ''),
+            'unit_price_usd': cr.get('fca_price_usd'),
+            'unit_price_rmb': cr.get('fca_price_rmb'),
+            'freight_usd': cr.get('freight_usd'),
+            'installation_usd': cr.get('installation_usd', 0),
+            'config_desc_cn': cr.get('config', ''),
+            'config_desc_en': er.get('config', ''),
+            'engine_code': cr.get('engine', ''),
+            'quantity': cr.get('quantity', 1),
+        })
+    wb.close()
+    return {'meta': meta, 'rows': merged}
+
+
+def match_vehicle_from_quotation(model_code: str, quotation: dict) -> Optional[dict]:
+    """Match vehicle in quotation data. Returns dict compatible with match_vehicle().
+
+    Returns None if not found.
+    """
+    if not model_code or not quotation:
+        return None
+    mc = model_code.strip().upper()
+    for r in quotation.get('rows', []):
+        if r.get('model_code') == mc:
+            config = r.get('config_desc_en') or r.get('config_desc_cn', '')
+            return {
+                'unit_price_usd': r.get('unit_price_usd'),
+                'model_code': mc,
+                'engine_code': r.get('engine_code', ''),
+                'description_cn': r.get('name_cn', ''),
+                'description_en': r.get('name_en', ''),
+                'series_cn': '',
+                'series_en': '',
+                'version': r.get('version', ''),
+                'config_desc': config,
+                'sheet_name': 'Quotation',
+            }
+    return None
+
+
+if __name__ == '__main__':
+    import sys, json
+    sys.stdout.reconfigure(encoding='utf-8')
+    if len(sys.argv) < 2:
+        print('Usage: python parse_quotation.py <quotation.xlsx>')
+        sys.exit(1)
+    q = load_quotation(sys.argv[1])
+    print(json.dumps(q, ensure_ascii=False, indent=2, default=str))
+    if q['rows']:
+        m = match_vehicle_from_quotation(q['rows'][0]['model_code'], q)
+        print()
+        print('--- Match Result ---')
+        print(json.dumps(m, ensure_ascii=False, indent=2, default=str))