Ver código fonte

v0.9.1(报价表识别功能修复)

kyle 6 dias atrás
pai
commit
d181f2789f

+ 8 - 7
auto-generate-export-contracts/SKILL.md

@@ -85,20 +85,21 @@ pip install --user -r requirements.txt
 ### Step 2b(报价表路径): 从报价表 xlsx 提取价格
 
 在用户提供五菱海外报价表 xlsx 的情况下,系统自动从报价表提取价格,
-完全替代 API 查询。报价表的价格已包含运费和加装费,确保合同总价与客户实际报价一致。
+完全替代 API 查询。报价表中 **每台价格** 即为单价(已包含运费和其余加装费),
+**数量总和** 即为单价乘数量,确保合同总价与客户实际报价一致。
 
 ```bash
 python scripts/generate_contracts.py order.txt -o ./output -q "报价表.xlsx"
 ```
 
-1. 加载报价表 xlsx,自动识别中英文两个 Sheet(中文报价表 / English Quotation
-2. 按车型型号(D列 型号/Model)精确匹配订单中的每一款车
-3. 从报价表的 `导出设置` 行自动识别价格类型(FCA/FOB/EXW),提取对应列的价格
-4. 提取 I列配置信息、F列发动机描述,用于生成车型描述
+1. 加载报价表 xlsx,自动识别中英文两个 Sheet(表单式:`报价单-中文版-*` / `Quotation-EN-*`
+2. 按车型型号(`Model / 型号` 字段,如 `LZW6500BEVA9`)精确匹配订单中的每一款车
+3. 从报价表的 `导出设置` 行自动识别价格类型(支持 `FCA,FOB` 等多种),按订单的 `结算方式` 提取对应价格类型的 `Price per Unit / 每台价格`
+4. 提取 `Engine Code / 发动机`、`Version / 版本`、`Major Configurations Description / 主要配置描述`,用于生成车型描述
 5. 提取英文 Sheet 的配置描述优先用于合同和PI
 6. 如果报价表中找不到某车型 → 提示用户手动补充价格(同现有流程)
 
-**支持的价格类型:** FCA / FOB / EXW 三种报价表均可自动识别,解析器按 `导出设置` 中的 `价格类型` 字段自动从对应列(`FCA价格(USD)` / `FOB价格(USD)` / `EXW价格(USD)`)提取单价。
+**支持的价格类型:** FCA / FOB / EXW 均可自动识别,解析器支持 `导出设置` 中同时声明多种价格类型(如 `价格类型=FCA,FOB`),并按订单中的贸易条款自动选择对应单价。
 **适用场景:** 报价系统(API)返回的价格不含运费加装费时,直接使用报价表的数据。
 
 ### Step 3: 计算汇总
@@ -194,4 +195,4 @@ result = generate_contracts(order_text, output_dir='.', quotation_path='报价
 - **Windows 源更新 skill 时**:`scripts/parse_order_info.py` 的车型续行修复(无编号前缀续行匹配)在 Windows 源文件中不存在。更新后需重新在 `parse_structured_order()` 的 while 循环中添加续行匹配代码,参考之前的 patch。此修复添加在第二个车型匹配模式(`\d+[..]\s*意向车型`)后面,第三个 `if not m:` 分支中。
 - **`generate_contracts.py` 中的 `COUNTRY_NAME_MAP`**:如果需要新增国家映射,在字典中添加 `'国家中文名': 'EnglishName'` 条目。
 - **`_get_clean_template()`** 会在 skill 目录生成 `_pi_clean_template.xlsx` 缓存文件,可删除(会自动重建)。
-- **报价表解析器 `parse_quotation.py` 的列名支持**:`CN_COL_MAP` 和 `EN_COL_MAP` 中需同时维护 FCA / FOB / EXW 三种列名映射。`load_quotation()` 中的 `price_field` 选择逻辑默认用 FCA,检测到 `价格类型=FOB` 或 `价格类型=EXW` 时自动切换对应列。如果出现报价表解析不到价格,先用 openpyxl 检查实际列名是否已覆盖,再添加映射
+- **报价表解析器 `parse_quotation.py` 的格式支持**:解析器同时支持旧版表格格式(`中文报价表` / `English Quotation`)和新版表单格式(`报价单-中文版-*` / `Quotation-EN-*`)。新版表单中每台车为一个 Sheet,`Model / 型号` 用于匹配车型,`Price per Unit / 每台价格` 已包含运费和加装费,按订单贸易条款自动选择对应价格类型。如出现新报价表解析不到价格,先用 openpyxl 检查实际行列结构,再调整 `_parse_form_sheet()` 中的标签识别与取值逻辑

+ 1 - 1
auto-generate-export-contracts/scripts/generate_contracts.py

@@ -467,7 +467,7 @@ def process_vehicles(vehicles: list, trade_term: str, quotation: dict = None) ->
 
         # 从价格表匹配
         if quotation:
-            match_result = match_vehicle_from_quotation(model_code, quotation)
+            match_result = match_vehicle_from_quotation(model_code, quotation, trade_term)
         else:
             match_result = match_vehicle(model_code, trade_term)
         if match_result and match_result.get('unit_price_usd'):

+ 381 - 53
auto-generate-export-contracts/scripts/parse_quotation.py

@@ -5,9 +5,14 @@
 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)
+Supports two formats:
+  1. Table format (legacy):
+       - Chinese: 中文报价表 (Chinese headers + descriptions)
+       - English: English Quotation (English headers + descriptions, optional)
+  2. Form format (new):
+       - One vehicle per sheet, bilingual labels (e.g. 报价单-中文版-* / Quotation-EN-*)
+       - Price per Unit already includes freight and add-on fees
+       - Quantity Sum = Price per Unit × Quantity
 """
 
 import re
@@ -18,7 +23,7 @@ import openpyxl
 
 logger = logging.getLogger(__name__)
 
-# Chinese header -> internal field mapping
+# Chinese header -> internal field mapping (legacy table format)
 CN_COL_MAP = {
     '序号': 'seq_no',
     '推广命名': 'promotion_name',
@@ -50,7 +55,7 @@ CN_COL_MAP = {
     'EXW小计(USD)': 'exw_subtotal_usd',
 }
 
-# English header -> internal field mapping
+# English header -> internal field mapping (legacy table format)
 EN_COL_MAP = {
     'No.': 'seq_no',
     'Promotion Name': 'promotion_name',
@@ -82,13 +87,60 @@ EN_COL_MAP = {
     'EXW Subtotal(USD)': 'exw_subtotal_usd',
 }
 
+# Form-style label -> internal field mapping
+# Labels are bilingual: e.g. "Model / 型号"
+FORM_LABEL_MAP = {
+    'model': 'model_code',
+    '型号': 'model_code',
+    'model code': 'model_code2',
+    '车型代码': 'model_code2',
+    'version': 'version',
+    '版本': 'version',
+    'engine code': 'engine_code',
+    '发动机': 'engine_code',
+    'emission standard': 'emission_std',
+    '排放标准': 'emission_std',
+    'vehicle type': 'vehicle_type',
+    '车辆类型': 'vehicle_type',
+    'quantity': 'quantity',
+    '采购数量': 'quantity',
+    'major configurations description': 'config_desc',
+    '主要配置描述': 'config_desc',
+    'configuration': 'config_desc',
+    '配置信息': 'config_desc',
+}
+
+# Form-style price section markers
+PRICE_SECTION_MARKERS = {
+    'price per unit': 'unit_price',
+    '每台价格': 'unit_price',
+    'freight per unit': 'freight',
+    '每台运费': 'freight',
+    'add-on per unit': 'add_on',
+    '每台加装费': 'add_on',
+    'quantity sum': 'quantity_sum',
+    '数量总和': 'quantity_sum',
+}
+
+
+def _normalize_text(value) -> str:
+    """Normalize cell value to string."""
+    if value is None:
+        return ''
+    return str(value).strip()
+
+
+def _has_chinese(text: str) -> bool:
+    """Check if text contains Chinese characters."""
+    return bool(re.search(r'[\u4e00-\u9fff]', text))
+
 
 def _parse_meta_row(value: str) -> dict:
-    """Parse Row 3 export settings line."""
+    """Parse export settings line."""
     meta = {}
     if not value:
         return meta
-    value = re.sub(r'^(\u5bfc\u51fa\u8bbe\u7f6e:|Export Settings:)\s*', '', value).strip()
+    value = re.sub(r'^(导出设置:|Export Settings:)\s*', '', value, flags=re.IGNORECASE).strip()
     for pair in value.split('|'):
         pair = pair.strip()
         if '=' not in pair:
@@ -96,13 +148,14 @@ def _parse_meta_row(value: str) -> dict:
         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',
+            '基地': 'base', 'Base': 'base',
+            '港口': 'port', 'Port': 'port',
+            '价格类型': 'price_type', 'Price Type': 'price_type',
+            '导出人': 'exporter', 'Exporter': 'exporter',
+            '客户': 'customer', 'Customer': 'customer',
+            '售卖国家': 'country', 'Sales Country': 'country',
+            '汇率': 'exchange_rate', 'Exchange Rate': 'exchange_rate',
+            '运输方式': 'shipping_method', 'Shipping Method': 'shipping_method',
         }
         field = key_map.get(k, k)
         if field == 'exchange_rate':
@@ -110,12 +163,15 @@ def _parse_meta_row(value: str) -> dict:
                 v = float(v)
             except ValueError:
                 pass
+        if field == 'price_type':
+            # Support "FCA,FOB" or "FCA" or "FCA/FOB"
+            v = [p.strip().upper() for p in re.split(r'[,,/\\]+', v) if p.strip()]
         meta[field] = v
     return meta
 
 
 def _detect_col_map(headers: list) -> Optional[dict]:
-    """Detect Chinese or English column map from header row."""
+    """Detect Chinese or English column map from header row (legacy table)."""
     for h in headers:
         hs = (h or '').strip()
         if hs in CN_COL_MAP:
@@ -136,8 +192,200 @@ def _build_col_index(headers: list, col_map: dict) -> dict:
     return idx
 
 
-def _parse_sheet(ws) -> tuple:
-    """Parse one sheet. Returns (data_rows, meta, sheet_type)."""
+def _is_form_style_sheet(ws) -> bool:
+    """Detect whether a sheet uses the new form-style layout."""
+    max_row = min(ws.max_row, 20)
+    for ri in range(1, max_row + 1):
+        for ci in range(1, ws.max_column + 1):
+            val = _normalize_text(ws.cell(row=ri, column=ci).value)
+            if not val:
+                continue
+            lower = val.lower()
+            # Look for bilingual labels that only appear in form layout
+            if 'model / 型号' in lower or 'vehicle information' in lower or 'price information' in lower:
+                return True
+            if 'price per unit' in lower or 'quantity sum' in lower:
+                return True
+    return False
+
+
+def _extract_form_label(text: str) -> Optional[str]:
+    """Match form label to internal field name."""
+    text = _normalize_text(text).lower()
+    if not text:
+        return None
+    # Remove common bilingual separator patterns
+    text = re.sub(r'\s*/\s*', ' ', text)
+    text = text.strip()
+    for key, field in FORM_LABEL_MAP.items():
+        if key in text:
+            return field
+    return None
+
+
+def _extract_price_type_labels(row_values: list) -> dict:
+    """From a price section header row, extract price type -> column index.
+
+    Example row: ['Price per Unit / 每台价格', None, 'FOB', None, 'FCA']
+    Returns: {'FOB': 2, 'FCA': 4} (0-based column indices)
+    """
+    labels = {}
+    for ci, val in enumerate(row_values):
+        val = _normalize_text(val).upper()
+        if val in ('FCA', 'FOB', 'EXW', 'CIF', 'CFR'):
+            labels[val] = ci
+    return labels
+
+
+def _to_float(value) -> Optional[float]:
+    """Convert a cell value to float, return None on failure."""
+    if value is None:
+        return None
+    if isinstance(value, (int, float)):
+        return float(value)
+    s = str(value).replace(',', '').strip()
+    if not s:
+        return None
+    try:
+        return float(s)
+    except ValueError:
+        return None
+
+
+def _parse_form_sheet(ws) -> tuple:
+    """Parse one form-style sheet. Returns (data_rows, meta, sheet_lang).
+
+    sheet_lang is 'cn' or 'en'.
+    """
+    max_row = ws.max_row
+    if max_row < 3:
+        return [], {}, 'cn'
+
+    # Determine language by row 3 promotion name
+    promotion_name = _normalize_text(ws.cell(row=3, column=2).value)
+    sheet_lang = 'cn' if _has_chinese(promotion_name) else 'en'
+
+    def _first_non_empty(values: list):
+        """Return (column_index, value) of first non-empty cell (0-based)."""
+        for ci, v in enumerate(values):
+            nv = _normalize_text(v)
+            if nv:
+                return ci, nv
+        return None, ''
+
+    def _next_value(values: list, start_index: int):
+        """Return next non-empty cell value to the right of start_index."""
+        for ci in range(start_index + 1, len(values)):
+            v = values[ci]
+            if v is not None and str(v).strip():
+                return v
+        return None
+
+    # Build key-value map from label rows
+    kv = {}
+    for ri in range(1, max_row + 1):
+        row_values = [ws.cell(row=ri, column=ci).value for ci in range(1, ws.max_column + 1)]
+        label_ci, label = _first_non_empty(row_values)
+        if not label:
+            continue
+        field = _extract_form_label(label)
+        if field and field not in kv:
+            val = _next_value(row_values, label_ci)
+            if val is not None:
+                kv[field] = val
+
+    # Parse price sections
+    prices = {}  # { 'FCA': {'unit_price_usd': x, 'unit_price_rmb': y, ...}, ... }
+    current_section = None
+    current_price_types = {}
+    for ri in range(1, max_row + 1):
+        row_values = [ws.cell(row=ri, column=ci).value for ci in range(1, ws.max_column + 1)]
+        first_col_ci, first_col = _first_non_empty(row_values)
+        if not first_col:
+            current_section = None
+            current_price_types = {}
+            continue
+        lower = first_col.lower()
+
+        # Detect price section header
+        section_found = False
+        for marker_key, section in PRICE_SECTION_MARKERS.items():
+            if marker_key in lower:
+                current_section = section
+                current_price_types = _extract_price_type_labels(row_values)
+                for pt in current_price_types:
+                    if pt not in prices:
+                        prices[pt] = {}
+                section_found = True
+                break
+        if section_found:
+            continue
+
+        # Currency rows
+        if current_section and first_col.upper() in ('CNY', 'USD', 'RMB'):
+            currency = 'rmb' if first_col.upper() in ('CNY', 'RMB') else 'usd'
+            for pt, ci in current_price_types.items():
+                if ci < len(row_values):
+                    val = _to_float(row_values[ci])
+                    if val is not None:
+                        suffix = f'_{currency}'
+                        if current_section == 'unit_price':
+                            prices[pt][f'unit_price{suffix}'] = val
+                        elif current_section == 'freight':
+                            prices[pt][f'freight{suffix}'] = val
+                        elif current_section == 'add_on':
+                            prices[pt][f'add_on{suffix}'] = val
+                        elif current_section == 'quantity_sum':
+                            prices[pt][f'subtotal{suffix}'] = val
+
+    # Parse export settings row
+    meta = {}
+    for ri in range(max_row, 0, -1):
+        val = _normalize_text(ws.cell(row=ri, column=2).value)
+        if val.startswith('导出设置:') or val.lower().startswith('export settings:'):
+            meta = _parse_meta_row(val)
+            break
+
+    # Build vehicle row
+    quantity_raw = kv.get('quantity')
+    try:
+        quantity = int(quantity_raw) if quantity_raw is not None else 1
+    except (ValueError, TypeError):
+        quantity = 1
+
+    model_code = _normalize_text(kv.get('model_code', '')).upper()
+    entry = {
+        'model_code': model_code,
+        'promotion_name': promotion_name,
+        'version': _normalize_text(kv.get('version', '')),
+        'engine_code': _normalize_text(kv.get('engine_code', '')),
+        'emission_std': _normalize_text(kv.get('emission_std', '')),
+        'vehicle_type': _normalize_text(kv.get('vehicle_type', '')),
+        'quantity': quantity,
+        'config': _normalize_text(kv.get('config_desc', '')),
+        '_prices': prices,
+    }
+
+    # Add legacy-style price fields for the first/default price type
+    # so legacy consumers still see a price
+    default_pt = meta.get('price_type', ['FCA'])[0] if meta.get('price_type') else 'FCA'
+    if default_pt in prices:
+        entry['unit_price_usd'] = prices[default_pt].get('unit_price_usd')
+        entry['unit_price_rmb'] = prices[default_pt].get('unit_price_rmb')
+        entry['freight_usd'] = prices[default_pt].get('freight_usd')
+        entry['installation_usd'] = prices[default_pt].get('add_on_usd')
+
+    # If no prices found, fall back to any available price type
+    if entry.get('unit_price_usd') is None and prices:
+        first_pt = next(iter(prices))
+        entry['unit_price_usd'] = prices[first_pt].get('unit_price_usd')
+        entry['unit_price_rmb'] = prices[first_pt].get('unit_price_rmb')
+
+    return [entry], meta, sheet_lang
+
+
+def _parse_table_sheet(ws) -> tuple:
+    """Parse one legacy table-style sheet. Returns (data_rows, meta, sheet_type)."""
     max_row = ws.max_row
     if max_row < 2:
         return [], {}, 'cn'
@@ -159,7 +407,7 @@ def _parse_sheet(ws) -> tuple:
             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:
+        if '导出设置' in first_val or 'Export Settings' in first_val:
             continue
         entry = {}
         for field, ci in col_idx.items():
@@ -169,68 +417,147 @@ def _parse_sheet(ws) -> tuple:
             continue
         if entry.get('model_code'):
             entry['model_code'] = str(entry['model_code']).strip().upper()
+        # Normalize quantity
+        qty = entry.get('quantity', 1)
+        try:
+            entry['quantity'] = int(qty) if qty is not None else 1
+        except (ValueError, TypeError):
+            entry['quantity'] = 1
         rows.append(entry)
     return rows, meta, sheet_type
 
 
+def _parse_sheet(ws) -> tuple:
+    """Parse one sheet. Returns (data_rows, meta, sheet_type/lang)."""
+    if _is_form_style_sheet(ws):
+        return _parse_form_sheet(ws)
+    return _parse_table_sheet(ws)
+
+
 def load_quotation(excel_path: str) -> dict:
     """Load quotation xlsx. Returns {'meta': {...}, 'rows': [...]}.
 
-    Merges data from both Chinese and English sheets by model_code.
+    Supports both legacy table format and new form format.
+    Merges data from 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')}
+
+    all_rows = []
+    all_meta = {}
+
+    for sheet_name in wb.sheetnames:
+        ws = wb[sheet_name]
+        rows, meta, sheet_type = _parse_sheet(ws)
+        if meta:
+            all_meta.update(meta)
+        for r in rows:
+            r['_sheet_type'] = sheet_type
+        all_rows.extend(rows)
+
+    # Group rows by model_code and merge Chinese/English fields
+    grouped = {}
+    for r in all_rows:
+        mc = r.get('model_code')
+        if not mc:
+            continue
+        if mc not in grouped:
+            grouped[mc] = {
+                'model_code': mc,
+                'name_cn': '',
+                'name_en': '',
+                'version': r.get('version', ''),
+                '_prices': r.get('_prices', {}),
+                'unit_price_usd': r.get('unit_price_usd'),
+                'unit_price_rmb': r.get('unit_price_rmb'),
+                'freight_usd': r.get('freight_usd'),
+                'installation_usd': r.get('installation_usd'),
+                'config_desc_cn': '',
+                'config_desc_en': '',
+                'engine_code': r.get('engine_code', ''),
+                'quantity': r.get('quantity', 1),
+            }
+        st = r.get('_sheet_type', 'cn')
+        if st == 'cn':
+            grouped[mc]['name_cn'] = r.get('promotion_name', '') or grouped[mc]['name_cn']
+            grouped[mc]['config_desc_cn'] = r.get('config', '') or grouped[mc]['config_desc_cn']
+            # Prefer Chinese version/engine if English missing
+            if not grouped[mc]['engine_code']:
+                grouped[mc]['engine_code'] = r.get('engine_code', '')
+            if not grouped[mc]['version']:
+                grouped[mc]['version'] = r.get('version', '')
+        else:
+            grouped[mc]['name_en'] = r.get('promotion_name', '') or grouped[mc]['name_en']
+            grouped[mc]['config_desc_en'] = r.get('config', '') or grouped[mc]['config_desc_en']
+            # Prefer English version/engine if available
+            if r.get('engine_code'):
+                grouped[mc]['engine_code'] = r.get('engine_code')
+            if r.get('version'):
+                grouped[mc]['version'] = r.get('version')
+
+        # Merge prices: take union of all available price types
+        if r.get('_prices'):
+            for pt, vals in r['_prices'].items():
+                if pt not in grouped[mc]['_prices']:
+                    grouped[mc]['_prices'][pt] = vals
+
     merged = []
-    price_type = meta.get('price_type', 'FCA').upper()
-    price_field = {
-        'FOB': 'fob_price_usd',
-        'EXW': 'exw_price_usd',
-    }.get(price_type, 'fca_price_usd')
-    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(price_field) or cr.get('fca_price_usd'),
-            'unit_price_rmb': cr.get(price_field.replace('usd', 'rmb')) or 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),
-        })
+    for mc, r in grouped.items():
+        # Recompute default unit price from available price types
+        available_pts = list(r['_prices'].keys())
+        default_pt = all_meta.get('price_type', ['FCA'])[0] if all_meta.get('price_type') and all_meta['price_type'][0] in available_pts else (available_pts[0] if available_pts else None)
+        if default_pt:
+            r['unit_price_usd'] = r['_prices'][default_pt].get('unit_price_usd')
+            r['unit_price_rmb'] = r['_prices'][default_pt].get('unit_price_rmb')
+            r['freight_usd'] = r['_prices'][default_pt].get('freight_usd')
+            r['installation_usd'] = r['_prices'][default_pt].get('add_on_usd')
+        merged.append(r)
+
     wb.close()
-    return {'meta': meta, 'rows': merged}
+    return {'meta': all_meta, 'rows': merged}
 
 
-def match_vehicle_from_quotation(model_code: str, quotation: dict) -> Optional[dict]:
+def match_vehicle_from_quotation(model_code: str, quotation: dict, trade_term: str = 'FCA') -> Optional[dict]:
     """Match vehicle in quotation data. Returns dict compatible with match_vehicle().
 
-    Returns None if not found.
+    Args:
+        model_code: e.g. 'LZW6500BEVA9'
+        quotation: output of load_quotation()
+        trade_term: 'FCA', 'FOB', or 'EXW' (determines which price to return)
+
+    Returns:
+        dict with vehicle info, or None if not found.
     """
     if not model_code or not quotation:
         return None
     mc = model_code.strip().upper()
+    trade_term = trade_term.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', '')
+
+            # Pick price for requested trade term; fallback to any available
+            prices = r.get('_prices', {})
+            pt = trade_term if trade_term in prices else next(iter(prices), None)
+            unit_price = None
+            if pt:
+                unit_price = prices[pt].get('unit_price_usd')
+            if unit_price is None:
+                unit_price = r.get('unit_price_usd')
+
             return {
-                'unit_price_usd': r.get('unit_price_usd'),
+                'unit_price_usd': unit_price,
                 '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': '',
+                'series_cn': r.get('vehicle_type', ''),
+                'series_en': r.get('vehicle_type', ''),
                 'version': r.get('version', ''),
                 'config_desc': config,
                 'sheet_name': 'Quotation',
+                '_prices': prices,
+                '_matched_price_type': pt,
             }
     return None
 
@@ -239,12 +566,13 @@ 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>')
+        print('Usage: python parse_quotation.py <quotation.xlsx> [trade_term]')
         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)
+        term = sys.argv[2].upper() if len(sys.argv) > 2 else 'FCA'
+        m = match_vehicle_from_quotation(q['rows'][0]['model_code'], q, term)
         print()
         print('--- Match Result ---')
-        print(json.dumps(m, ensure_ascii=False, indent=2, default=str))
+        print(json.dumps(m, ensure_ascii=False, indent=2, default=str))