Переглянути джерело

v0.5(报价系统api接入)

kyle 4 днів тому
батько
коміт
d96fe4cdf1

+ 5 - 0
auto-generate-export-contracts/.env

@@ -0,0 +1,5 @@
+# Overseas Price API Configuration
+# Copy this file and fill in your credentials
+PRICE_API_URL=https://overseasprice.weleads.cn
+PRICE_API_USERNAME=haiwai
+PRICE_API_PASSWORD=112233

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

@@ -24,7 +24,8 @@ pip install --user -r requirements.txt
 | 包名 | 版本要求 | 用途 |
 |------|---------|------|
 | `python-docx` | >=1.1.0 | 生成/修改 Word 销售合同 |
-| `openpyxl` | >=3.1.0 | 生成/修改 Excel Proforma Invoice、读取价格表 |
+ | `openpyxl` | >=3.1.0 | 生成/修改 Excel Proforma Invoice |
+ | `requests` | >=2.31.0 | 调用海外价格系统 API |
 | `num2words` | >=0.5.13 | 金额英文大写转换 |
 
 ## 功能
@@ -98,13 +99,22 @@ pip install --user -r requirements.txt
 - 贸易条款(FCA/FOB/EXW)
 - 出发港口、目的地国家
 
-### Step 2: 车型价格匹配
+### Step 2: 车型价格查询(海外价格系统 API)
 
-遍历 `assets/vehicle-price-config.xlsx` 中的所有sheet,在**G列(型号)**精确匹配ModelCode:
-- 匹配成功 → 根据贸易条款获取对应USD价格(FCA→O列, FOB→N列, EXW→P列)
-- 匹配失败 → **提示用户提供该车型的单价USD**
+通过 `scripts/overseas_price_client.py` 连接海外价格报价系统,按车型型号查询实时价格与配置:
 
-**用户提供的价格优先于价格表。**
+1. 调用 `search_cars(model_code)` 查询车型信息
+2. 从响应中提取对应贸易条款的单价(FCA/FOB/EXW)
+3. 获取车型配置信息(发动机代码、版本、配置描述等)
+4. 构建车辆的英文描述用于合同和 PI
+
+**两种场景:**
+- API 查询成功 → 自动获取价格和配置,进入 Step 3
+- API 查询失败或无此车型 → **提示用户提供该车型的单价 USD**
+
+**用户提供的价格优先于 API 返回的价格。**
+
+**前置配置:** 在 `.env` 文件中填写 API 连接信息(URL、用户名、密码)。
 
 ### Step 3: 计算汇总
 
@@ -168,7 +178,8 @@ result = generate_contracts(order_text, output_dir='.', user_prices={"LZW1028SPY
 
 - `assets/proforma-invoice-template.xlsx`: Proforma Invoice模板
 - `assets/vehicle-sales-contract-template.docx`: 销售合同模板
-- `assets/vehicle-price-config.xlsx`: 车型价格配置表(多sheet)
+ - `scripts/overseas_price_client.py`: 海外价格系统 API 客户端
+ - `.env`: API 连接配置(URL、用户名、密码)
 
 ## 字段映射详情
 

BIN
auto-generate-export-contracts/assets/vehicle-price-config.xlsx


BIN
auto-generate-export-contracts/assets/vehicle-sales-contract-template.docx


+ 3 - 1
auto-generate-export-contracts/requirements.txt

@@ -1,3 +1,5 @@
 python-docx>=1.1.0
 openpyxl>=3.1.0
-num2words>=0.5.13
+num2words>=0.5.13
+requests>=2.31.0
+Pillow>=11.3.0

+ 136 - 155
auto-generate-export-contracts/scripts/generate_contracts.py

@@ -298,102 +298,84 @@ COLOR_MAP = {
 
 
 def build_full_description(vehicle: dict, match_result: dict = None) -> str:
-    """
-    构建销售合同中 Commodity 表格的完整货物描述。
-    格式严格对齐模板:
-      Model: {model_code}, {engine_code} {中文车型名}
-      {中文配置描述}
-      Color: {color_en} {color_code}
-      型号:{model_code}, {engine_code} {英文车型名/中文车型名}
-      {英文配置描述}
-      颜色:{color_cn} {color_code}
-    """
-    model_code = vehicle.get('model_code', '')
-    engine_code = vehicle.get('engine_code', '')
-    color = vehicle.get('color', '')
-    name_cn = vehicle.get('name_cn', '')
-    name_en = vehicle.get('name_en', '')
-
-    # 颜色中英文映射
+    model_code = vehicle.get("model_code", "")
+    engine_code = vehicle.get("engine_code", "")
+    color = vehicle.get("color", "")
+    name_cn = vehicle.get("name_cn", "")
+    name_en = vehicle.get("name_en", "")
     color_en, color_cn = COLOR_MAP.get(color, (color, color))
-    color_display = color if color else ''
-
+    color_display = color if color else ""
     lines = []
-
     if match_result:
-        desc_cn = match_result.get('description_cn', '')
-        series_en = match_result.get('series_en', '')
-        # 用价格表的engine_code作为详细规格(如 "VR5\n(1.999L)\n5MT"),但Model行用用户提供的engine_code
-        engine_specs = match_result.get('engine_code', '').replace('\n', ' ')
-        config_desc = match_result.get('config_desc', '')
-
-        # 分离中英文配置描述
-        config_cn = ''
-        config_en = ''
-        if config_desc:
-            config_parts = [p.strip() for p in config_desc.split('\n') if p.strip()]
-            for part in config_parts:
-                if re.search(r'[\u4e00-\u9fff]', part):
-                    config_cn = part
-                else:
-                    config_en = part
-
-        # === 英文部分 ===
-        # Model 行: Model: LZW5030XXYLGHUG, AGMC
-        model_line = f"Model: {model_code}, {engine_code}"
-        if desc_cn:
-            model_line += f" {desc_cn}"
-        lines.append(model_line)
-
-        # 中文配置描述(在英文Model行之后,如 "基本型 (5座...)")
-        if config_cn:
-            lines.append(config_cn)
-
-        # Color (English)
-        lines.append(f"Color: {color_en}" if color_display else "Color: ")
-
-        # === 中文部分 ===
-        # 型号行
-        model_cn_line = f"型号:{model_code}, {engine_code}"
+        series_en = match_result.get("series_en", "")
+        desc_cn = match_result.get("description_cn", "")
+        desc_en = match_result.get("description_en", "") or series_en
+        engine_code_raw = match_result.get("engine_code", "")
+        eng_lines = [l.strip() for l in engine_code_raw.split(chr(10)) if l.strip()]
+        eng_first = eng_lines[0] if eng_lines else engine_code
+        eng_extra = eng_lines[1:] if len(eng_lines) > 1 else []
+        eng_first_en = re.sub(r"[\u4e00-\u9fff\uff00-\uffef\u3000-\u303f\uff10-\uff19\uff21-\uff3a\uff41-\uff5a]+", "", eng_first).strip()
+        config_desc = match_result.get("config_desc", "")
+        parts = [p.strip() for p in config_desc.split(chr(10)) if p.strip()]
+        config_en = ""
+        config_cn = ""
+        for part in parts:
+            if re.search(r"[\u4e00-\u9fff]", part):
+                config_cn = part
+            else:
+                config_en = part
+        eng_line = "Model: " + model_code + ", " + eng_first_en
         if series_en:
-            model_cn_line += f" {series_en}"
+            eng_line += " " + series_en
         elif name_en:
-            model_cn_line += f" {name_en}"
-        elif desc_cn:
-            model_cn_line += f" {desc_cn}"
-        lines.append(model_cn_line)
-
-        # 英文配置描述
+            eng_line += " " + name_en
+        lines.append(eng_line)
+        for extra in eng_extra:
+            if not re.search(r"[\u4e00-\u9fff]", extra):
+                lines.append(extra)
         if config_en:
             lines.append(config_en)
-
-        # 颜色(中文)
-        lines.append(f"颜色:{color_cn}" if color_display else "颜色:")
+        elif config_cn:
+            lines.append(config_cn)
+        lines.append("Color: " + color_en if color_display else "Color: ")
+        cn_line = chr(22411) + chr(21495) + chr(65306) + model_code + ", " + eng_first
+        if desc_cn:
+            cn_line += " " + desc_cn
+        elif name_cn:
+            cn_line += " " + name_cn
+        lines.append(cn_line)
+        for extra in eng_extra:
+            if not re.search(r"[\u4e00-\u9fff]", extra):
+                lines.append(extra)
+        if config_cn:
+            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))
     else:
-        # 无价格表匹配,使用订单信息
-        # === 英文部分 ===
-        model_line = f"Model: {model_code}, {engine_code}"
-        if name_cn:
-            model_line += f" {name_cn}"
-        elif name_en:
-            model_line += f" {name_en}"
-        lines.append(model_line)
-
-        lines.append(f"Color: {color_en}" if color_display else "Color: ")
-
-        # === 中文部分 ===
-        model_cn_line = f"型号:{model_code}, {engine_code}"
+        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
+        eng_extra = eng_lines[1:] if len(eng_lines) > 1 else []
+        eng_first_en = re.sub(r"[\u4e00-\u9fff\uff00-\uffef\u3000-\u303f]+", "", eng_first).strip()
+        eng_line = "Model: " + model_code + ", " + eng_first_en
         if name_en:
-            model_cn_line += f" {name_en}"
+            eng_line += " " + name_en
         elif name_cn:
-            model_cn_line += f" {name_cn}"
-        lines.append(model_cn_line)
-
-        lines.append(f"颜色:{color_cn}" if color_display else "颜色:")
-
-    return '\n'.join(lines)
-
-
+            eng_line += " " + name_cn
+        lines.append(eng_line)
+        for extra in eng_extra:
+            lines.append(extra)
+        lines.append("Color: " + color_en if color_display else "Color: ")
+        cn_line = chr(22411) + chr(21495) + chr(65306) + model_code + ", " + eng_first
+        if name_cn:
+            cn_line += " " + name_cn
+        elif name_en:
+            cn_line += " " + name_en
+        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))
+    return chr(10).join(lines)
 def process_vehicles(vehicles: list, trade_term: str) -> list:
     """
     处理车型列表,匹配价格表或提示用户提供价格
@@ -444,109 +426,108 @@ def calculate_summary(vehicles: list) -> dict:
     }
 
 
+
+
+
+
+
+
+
+def _add_stamp_to_sheet(ws, template_path, output_path):
+    """Add stamp image. Clean old drawing files first."""
+    import zipfile, os, tempfile
+    from openpyxl.drawing.image import Image
+    from io import BytesIO
+    try:
+        img_data = None
+        with zipfile.ZipFile(template_path, "r") as tz:
+            if "xl/media/image1.png" in tz.namelist():
+                img_data = tz.read("xl/media/image1.png")
+        if not img_data:
+            return
+        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)
+    except Exception as e:
+        print("Stamp warning:", e)
 def generate_proforma_invoice(order: dict, vehicles: list, summary: dict,
                                contract_no: str, output_dir: str) -> str:
-    """生成Proforma Invoice"""
+    """Generate Proforma Invoice."""
     import openpyxl
     from openpyxl.utils import get_column_letter
+    from openpyxl.drawing.image import Image
+    from io import BytesIO
+    import zipfile
 
-    # 复制模板
-    template_path = get_asset_path('proforma-invoice-template.xlsx')
-    output_filename = f"{contract_no}-Proforma Invoice.xlsx"
+    template_path = get_asset_path("proforma-invoice-template.xlsx")
+    output_filename = contract_no + "-Proforma Invoice.xlsx"
     output_path = os.path.join(output_dir, output_filename)
     shutil.copy(template_path, output_path)
-
-    # 打开并修改
     wb = openpyxl.load_workbook(output_path)
-    ws = wb['INVOICE']
+    ws = wb[chr(73) + chr(78) + chr(86) + chr(79) + chr(73) + chr(67) + chr(69)]
 
-    # 买方信息
-    ws['B7'] = f"TO:  {order.get('buyer_en', '')}"
-    ws['G7'] = f"Contract NO.: {contract_no}"
-    ws['H8'] = format_date_for_cell()
-    ws['B8'] = f"ADD: {order.get('address_en', '')}"
-    ws['B9'] = f"Tel: {order.get('tel', '')}"
+    ws["B7"] = "TO:  " + order.get("buyer_en", "")
+    ws["G7"] = "Contract NO.: " + contract_no
+    ws["H8"] = format_date_for_cell()
+    ws["B8"] = "ADD: " + order.get("address_en", "")
+    ws["B9"] = "Tel: " + order.get("tel", "")
 
-    # 港口信息
-    port_cn, port_en = get_port_names(order.get('departure_port', '广州南沙'))
-    trade_term = order.get('trade_term', 'FCA')
+    port_cn, port_en = get_port_names(order.get("departure_port", "广州南沙"))
+    trade_term = order.get("trade_term", "FCA")
 
-    # 商品明细 (B17-H22)
     start_row = 17
     for i, v in enumerate(vehicles):
         row = start_row + i
         if row > 22:
-            break  # 模板最多支持到22行
-
-        desc = v.get('config_desc') or build_vehicle_description(v, v.get('_match_result'))
+            break
+        desc = v.get("config_desc") or build_vehicle_description(v, v.get("_match_result"))
         if not desc:
-            desc = f"Wuling {v['model_code']}, {v['engine_code']}"
-
-        ws.cell(row=row, column=2, value=desc)  # B列: DESCRIPTION
-        ws.cell(row=row, column=3, value=v['quantity'])  # C列: QTY
-        ws.cell(row=row, column=4, value='UNIT')  # D列
-        ws.cell(row=row, column=5, value='USD')  # E列
-        ws.cell(row=row, column=6, value=v['unit_price_usd'])  # F列: U.PRICE
-        ws.cell(row=row, column=7, value='USD')  # G列
-        # H列保留公式 =C*F
-
-    # 清除未使用的行(如果车辆少于模板默认行数)
-    # 注意: cell.value = None 在 openpyxl 中不能真正清除单元格,必须用 _value = None
+            desc = "Wuling " + v["model_code"] + ", " + v["engine_code"]
+        ws.cell(row=row, column=2, value=desc)
+        ws.cell(row=row, column=3, value=v["quantity"])
+        ws.cell(row=row, column=4, value="UNIT")
+        ws.cell(row=row, column=5, value="USD")
+        ws.cell(row=row, column=6, value=v["unit_price_usd"])
+        ws.cell(row=row, column=7, value="USD")
+
     for row in range(start_row + len(vehicles), 23):
         for col in range(2, 9):
             ws.cell(row=row, column=col)._value = None
 
-    # 汇总信息
-    ws['E23'] = f"  {trade_term}   {port_en} "
-    ws['B24'] = summary['amount_in_words']
+    ws["E23"] = "  " + trade_term + "   " + port_en + " "
+    ws["B24"] = summary["amount_in_words"]
+    ws["B27"] = "1. Delivery terms:  " + trade_term + " " + port_en + "  "
 
-    # Delivery terms
-    ws['B27'] = f"1. Delivery terms:  {trade_term} {port_en}  "
+    payment_text = ("3. Payment terms:\n" +
+        "1. 30% of the Total Amount (USD " + str(summary["deposit_30"]) + ") shall be paid to Seller within 5 working days from order confirmation.\n" +
+        "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
 
-    # Payment terms
-    payment_text = (
-        f"3. Payment terms:\n"
-        f"1. 30% of the Total Amount (USD {summary['deposit_30']:,}) shall be paid to Seller within 5 working days from order confirmation.\n"
-        f"2. 70% of the Total Amount (USD {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)
 
     wb.save(output_path)
     wb.close()
-
     return output_path
 
 
 def accept_all_revisions(doc):
-    """
-    清除文档中的所有修订标记(Track Changes)和批注。
-    解决WPS/Word模板中未接受修订导致内容重复显示的问题。
-    策略:仅处理正文段落(不遍历表格),避免误删表格中的图片。
-    """
     body = doc.element.body
-    ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
+    ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
     from docx.oxml.ns import qn
-    
-    # 仅处理 body 直接子元素中的段落(跳过表格内的内容,保护图片)
-    for p in body.findall(qn('w:p')):
-        # 删除段落内的 <w:del> 元素
-        for del_elem in list(p.iter(f'{{{ns}}}del')):
-            parent = del_elem.getparent()
-            if parent is not None:
-                parent.remove(del_elem)
-        # 删除段落内的 <w:ins> 元素
-        for ins_elem in list(p.iter(f'{{{ns}}}ins')):
-            parent = ins_elem.getparent()
-            if parent is not None:
-                parent.remove(ins_elem)
-        # 删除批注标记
-        for tag in ['commentRangeStart', 'commentRangeEnd', 'commentReference']:
-            for elem in list(p.iter(f'{{{ns}}}{tag}')):
+    for p in body.findall(qn("w:p")):
+        for te in ["del", "ins"]:
+            for elem in list(p.iter("{" + ns + "}" + te)):
+                parent = elem.getparent()
+                if parent is not None:
+                    parent.remove(elem)
+        for tg in ["commentRangeStart", "commentRangeEnd", "commentReference"]:
+            for elem in list(p.iter("{" + ns + "}" + tg)):
                 parent = elem.getparent()
                 if parent is not None:
                     parent.remove(elem)
-
-
 def generate_sales_contract(order: dict, vehicles: list, summary: dict,
                             contract_no: str, output_dir: str) -> str:
     """生成销售合同 (Word)"""

+ 146 - 130
auto-generate-export-contracts/scripts/match_vehicle.py

@@ -1,164 +1,180 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-"""
-车型匹配脚本:在价格配置表中按ModelCode匹配车型信息
+"""Vehicle matching: query the overseas price API instead of Excel.
+
+This replaces the old Excel-based price config lookup. Now all pricing
+and configuration data comes from the overseas price system API.
 """
 
-import os
-import openpyxl
+import re
+import logging
 
-# 价格列映射
-PRICE_COLUMN_MAP = {
-    'FOB': 'N',
-    'FCA': 'O',
-    'EXW': 'P',
-}
+from overseas_price_client import get_client, OverseasPriceClient
 
-PRICE_COL_INDEX = {
-    'FOB': 13,  # N列 (0-based index)
-    'FCA': 14,  # O列
-    'EXW': 15,  # P列
+logger = logging.getLogger(__name__)
+
+# Map trade terms to expected API field names (try multiple conventions)
+TRADE_PRICE_FIELDS = {
+    'FCA': ['fca_usd', 'fcaUSD', 'fcaPrice', 'fca_price', 'fca'],
+    'FOB': ['fob_usd', 'fobUSD', 'fobPrice', 'fob_price', 'fob'],
+    'EXW': ['exw_usd', 'exwUSD', 'exwPrice', 'exw_price', 'exw'],
 }
 
+_CLIENT_CACHE = None
 
-def get_price_config_path():
-    """获取价格配置表路径"""
-    script_dir = os.path.dirname(os.path.abspath(__file__))
-    return os.path.join(script_dir, '..', 'assets', 'vehicle-price-config.xlsx')
 
+def _get_client():
+    global _CLIENT_CACHE
+    if _CLIENT_CACHE is None:
+        _CLIENT_CACHE = get_client()
+    return _CLIENT_CACHE
+
+
+def _pick(d, *keys):
+    """Get first non-None value from dict using a list of keys."""
+    for k in keys:
+        v = d.get(k)
+        if v is not None:
+            return v
+    return None
+
+
+def _try_price(car, trade_term):
+    """Try to find the unit price for the given trade term in the API response."""
+    fields = TRADE_PRICE_FIELDS.get(trade_term.upper(), TRADE_PRICE_FIELDS['FCA'])
+    for field in fields:
+        val = car.get(field)
+        if val is not None:
+            try:
+                return round(float(val), 2)
+            except (ValueError, TypeError):
+                continue
+    return None
+
+
+def match_vehicle(model_code, trade_term='FCA'):
+    """Query the overseas price API for a vehicle by model code.
 
-def match_vehicle(model_code: str, trade_term: str = 'FCA'):
-    """
-    在价格配置表中匹配车型
-    
     Args:
-        model_code: 车型型号,如 LZW5030XXYLGHUG
-        trade_term: 贸易条款,如 FCA/FOB/EXW
-    
+        model_code: e.g. 'LZW5030XXYLGHUG'
+        trade_term: 'FCA', 'FOB', or 'EXW'
+
     Returns:
-        dict or None: {
-            'unit_price_usd': float,
-            'description_cn': str,
-            'description_en': str,
-            'model_code': str,
-            'engine_code': str,
-            'version': str,
-            'config_desc': str,
-            'sheet_name': str,
-        }
+        dict with vehicle info, or None if not found.
+        Fields: unit_price_usd, model_code, engine_code, description_cn,
+                description_en, series_cn, series_en, version, config_desc
     """
     if not model_code:
         return None
-    
+
     model_code = model_code.strip().upper()
     trade_term = trade_term.upper()
-    
-    # 默认使用FCA价格列
-    price_col_idx = PRICE_COL_INDEX.get(trade_term, 14)
-    
-    config_path = get_price_config_path()
-    if not os.path.exists(config_path):
-        print(f"[WARN] Price config not found: {config_path}")
+
+    try:
+        client = _get_client()
+        cars = client.search_cars(model_code)
+    except Exception as e:
+        logger.error('API search failed for %s: %s', model_code, e)
         return None
-    
-    wb = openpyxl.load_workbook(config_path, data_only=True)
-    
-    for sheet_name in wb.sheetnames:
-        ws = wb[sheet_name]
-        for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=False):
-            # G列是型号 (index 6)
-            cell_g = row[6] if len(row) > 6 else None
-            if cell_g and cell_g.value:
-                # ModelCode可能包含换行符,如 "LZW5030XXYLGHUG\n国六B(RDE)"
-                cell_val = str(cell_g.value).strip().upper()
-                # 分割换行符取第一行作为ModelCode
-                cell_model = cell_val.split('\n')[0].strip()
-                if cell_model == model_code:
-                    # 找到匹配
-                    result = {
-                        'model_code': model_code,
-                        'sheet_name': sheet_name,
-                    }
-                    
-                    # B列: 市场名称
-                    result['description_cn'] = str(row[1].value).strip() if row[1].value else ''
-                    # C列: 版本
-                    result['version'] = str(row[2].value).strip() if row[2].value else ''
-                    # F列: 系别 (中英文)
-                    series_val = str(row[5].value).strip() if row[5].value else ''
-                    result['description_en'] = series_val
-                    # 分离F列中英文
-                    series_lines = [l.strip() for l in series_val.split('\n') if l.strip()]
-                    result['series_cn'] = series_lines[0] if series_lines else ''
-                    result['series_en'] = series_lines[1] if len(series_lines) > 1 else ''
-                    # I列: 发动机代码
-                    result['engine_code'] = str(row[8].value).strip() if row[8].value else ''
-                    # J列: 主要配置描述
-                    result['config_desc'] = str(row[9].value).strip() if row[9].value else ''
-                    
-                    # 价格列
-                    price_cell = row[price_col_idx] if len(row) > price_col_idx else None
-                    if price_cell and price_cell.value:
-                        try:
-                            result['unit_price_usd'] = round(float(price_cell.value), 2)
-                        except (ValueError, TypeError):
-                            result['unit_price_usd'] = None
-                    else:
-                        result['unit_price_usd'] = None
-                    
-                    wb.close()
-                    return result
-    
-    wb.close()
-    return None
 
+    if not cars:
+        logger.warning('No API results for model code: %s', model_code)
+        return None
 
-def build_vehicle_description(vehicle: dict, match_result: dict = None) -> str:
-    """
-    构建PI中的商品描述文本
-    
-    格式示例: Wuling LZW5030XXYLGHUG, AGMC,1.999L
+    # Find exact model_code match (API uses 'model' field for LZW code)
+    matched = None
+    for car in cars:
+        mc = _pick(car, 'model', 'modelCode', 'model_code')
+        if mc and str(mc).strip().upper() == model_code:
+            matched = car
+            break
+
+    if matched is None:
+        matched = cars[0]
+        logger.info('No exact match for %s, using first result', model_code)
+
+    price = _try_price(matched, trade_term)
+
+    # Extract fields from actual API response format
+    engine_code = _pick(matched, 'engine_code', 'engineCode', 'engine')
+    version = _pick(matched, 'version', 'vehicleVersion', 'vehicle_version')
+    description_cn = _pick(matched, 'promotion_name', 'marketNameCn', 'descriptionCn', 'nameCn')
+    description_en = ''
+    series_raw = _pick(matched, 'series', 'seriesEn', 'series_en', 'seriesName')
+    if series_raw:
+        parts = series_raw.split(chr(10))
+        series_cn = parts[0].strip() if len(parts) > 0 else ''
+        series_en = parts[1].strip() if len(parts) > 1 else parts[0].strip()
+    else:
+        series_cn = ''
+        series_en = ''
+    config_desc = _pick(matched, 'configuration', 'configDesc', 'config_desc',
+                        'configDescription', 'config_description')
+
+    # Build config_desc if it's missing but other fields are available
+    if not config_desc:
+        parts = []
+        if description_cn:
+            parts.append(description_cn)
+        if version:
+            parts.append(version)
+        if engine_code:
+            parts.append(engine_code)
+        if parts:
+            config_desc = '\n'.join(parts)
+
+    # Build description_en from series_en if not directly available
+    if not description_en and series_en:
+        description_en = series_en
+
+    return {
+        'unit_price_usd': price,
+        'model_code': model_code,
+        'engine_code': engine_code or '',
+        'description_cn': description_cn or '',
+        'description_en': description_en or '',
+        'series_cn': series_cn or '',
+        'series_en': series_en or '',
+        'version': version or '',
+        'config_desc': config_desc or '',
+        'sheet_name': 'API',
+        '_raw': matched,
+    }
+
+
+def build_vehicle_description(vehicle, match_result=None):
+    """Build a short one-line vehicle description for PI.
+
+    Format: Wuling {model_code}, {engine_code}, {displacement}
     """
-    import re
-    
     model_code = vehicle.get('model_code', '')
     engine_code = vehicle.get('engine_code', '')
-    
-    # 尝试从匹配结果获取排量信息
+
+    if match_result:
+        if not engine_code:
+            engine_code = match_result.get('engine_code', '')
+        if not model_code:
+            model_code = match_result.get('model_code', '')
+
     displacement = ''
-    
-    # 1. 从配置描述中提取排量,如 "(1.999L)"
+
+    # Extract displacement from config_desc
     if match_result and match_result.get('config_desc'):
         m = re.search(r'\(([\d.]+L)\)', match_result['config_desc'])
         if m:
             displacement = m.group(1)
-    
-    # 2. 从发动机代码中提取排量,如 "VR5\n(1.999L)\n5MT"
-    if not displacement and match_result and match_result.get('engine_code'):
-        m = re.search(r'\(([\d.]+L)\)', match_result['engine_code'])
-        if m:
-            displacement = m.group(1)
-    
-    # 3. 从版本信息推断
-    if not displacement and vehicle.get('version'):
-        m = re.search(r'([\d.]+L)', vehicle['version'])
+
+    # Extract from engine_code
+    if not displacement and engine_code:
+        m = re.search(r'\(([\d.]+L)\)', engine_code)
         if m:
             displacement = m.group(1)
-    
-    # 4. 从车型名称中推断(如 "2.0L 5MT")
-    if not displacement and vehicle.get('name_cn'):
-        m = re.search(r'([\d.]+L)', vehicle['name_cn'])
+
+    # Extract from version
+    if not displacement and match_result and match_result.get('version'):
+        m = re.search(r'([\d.]+L)', match_result['version'])
         if m:
             displacement = m.group(1)
-    
-    parts = [p for p in [f'Wuling {model_code}', engine_code, displacement] if p]
-    return ', '.join(parts)
 
-
-if __name__ == '__main__':
-    # 测试
-    result = match_vehicle('LZW5030XXYLGHUG', 'FCA')
-    print(result)
-    
-    result2 = match_vehicle('LZW1028SPY', 'FCA')
-    print(result2)
+    parts = [p for p in ['Wuling ' + model_code, engine_code, displacement] if p]
+    return ', '.join(parts)

+ 197 - 0
auto-generate-export-contracts/scripts/overseas_price_client.py

@@ -0,0 +1,197 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Overseas Price API Python client.
+
+Replaces the Excel-based price config by directly querying
+the overseas price system API.
+Mirrors the Java OverseasPriceApi behavior.
+
+Configuration: env vars or .env file.
+    PRICE_API_URL      - API base URL
+    PRICE_API_USERNAME - login username
+    PRICE_API_PASSWORD - login password
+"""
+
+import os
+import logging
+from datetime import datetime
+from typing import Any, Optional
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+
+def _load_config():
+    basedir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    dotenv_path = os.path.join(basedir, '.env')
+    if os.path.exists(dotenv_path):
+        with open(dotenv_path, 'r', encoding='utf-8') as f:
+            for line in f:
+                line = line.strip()
+                if not line or line.startswith('#'):
+                    continue
+                if '=' not in line:
+                    continue
+                key, _, val = line.partition('=')
+                key = key.strip()
+                val = val.strip().strip('"').strip("'")
+                if key not in os.environ:
+                    os.environ[key] = val
+    return {
+        'url': os.environ.get('PRICE_API_URL', ''),
+        'username': os.environ.get('PRICE_API_USERNAME', ''),
+        'password': os.environ.get('PRICE_API_PASSWORD', ''),
+    }
+
+
+def get_client():
+    cfg = _load_config()
+    if not cfg['url']:
+        raise ValueError(
+            'Overseas price API not configured. '
+            'Set PRICE_API_URL / PRICE_API_USERNAME / PRICE_API_PASSWORD in .env'
+        )
+    return OverseasPriceClient(cfg['url'], cfg['username'], cfg['password'])
+
+
+class OverseasPriceClient:
+    """API client for the overseas price system."""
+
+    _COOKIE_EXPIRE_SECONDS = 24 * 60 * 60
+
+    def __init__(self, base_url, username, password):
+        self._base_url = base_url.rstrip('/')
+        self._username = username
+        self._password = password
+        self._session = requests.Session()
+        self._cookie_header = None
+        self._cookie_expire_at = 0
+
+    def _is_session_alive(self):
+        now_ts = datetime.now().timestamp()
+        return bool(self._cookie_header) and now_ts < self._cookie_expire_at
+
+    def ensure_login(self):
+        if self._is_session_alive():
+            return
+        self._login()
+
+    def _login(self):
+        url = '{}/login'.format(self._base_url)
+        # Suppress SSL warnings for self-signed certs
+        import urllib3
+        urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+        body = {'username': self._username, 'password': self._password, 'remember': True}
+        resp = self._session.post(url, json=body, timeout=30, verify=False)
+        resp.raise_for_status()
+        login_data = resp.json()
+        code = login_data.get('code', -1)
+        if code != 0:
+            raise RuntimeError('Login failed (code={}): {}'.format(code, login_data.get('message', '')))
+        cookie_parts = []
+        for cookie in resp.cookies:
+            cookie_parts.append('{}={}'.format(cookie.name, cookie.value))
+        self._cookie_header = '; '.join(cookie_parts)
+        self._cookie_expire_at = datetime.now().timestamp() + self._COOKIE_EXPIRE_SECONDS
+        if not self._cookie_header:
+            raise RuntimeError('Login failed: no cookie returned')
+
+    def search_cars(self, keyword, page=1, per_page=20):
+        self.ensure_login()
+        url = '{}/api/cars'.format(self._base_url)
+        params = {'keyword': keyword, 'page': page, 'per_page': per_page}
+        data = self._request('GET', url, params=params)
+        cars = self._extract_cars_node(data)
+        return cars if isinstance(cars, list) else []
+
+    def get_countries(self):
+        self.ensure_login()
+        url = '{}/api/countries'.format(self._base_url)
+        data = self._request('GET', url)
+        countries = self._extract_countries_node(data)
+        return countries if isinstance(countries, list) else []
+
+    def get_cars_by_country(self, country_id, vehicle_type=None):
+        self.ensure_login()
+        url = '{}/api/countries/{}/cars'.format(self._base_url, country_id)
+        params = {}
+        if vehicle_type:
+            params['keyword'] = vehicle_type
+        return self._request('GET', url, params=params)
+
+    def _request(self, method, url, **kwargs):
+        headers = kwargs.pop('headers', {})
+        headers.setdefault('Content-Type', 'application/json')
+        headers.setdefault('Accept', 'application/json')
+        if self._cookie_header:
+            headers['Cookie'] = self._cookie_header
+        kwargs['headers'] = headers
+        kwargs.setdefault('timeout', 60)
+        kwargs.setdefault('verify', False)
+        try:
+            resp = self._session.request(method, url, **kwargs)
+            resp.raise_for_status()
+            result = resp.json()
+        except requests.RequestException as e:
+            logger.warning('API request failed: %s %s - %s', method, url, e)
+            return None
+        except ValueError:
+            logger.warning('API response not JSON: %s %s', method, url)
+            return None
+        if isinstance(result, list):
+            return result
+        if isinstance(result, dict):
+            code = result.get('code', -1)
+            if code != 0:
+                logger.warning('API error (code=%s): %s %s - %s', code, method, url, result.get('message', ''))
+                return None
+            return result.get('data')
+        return result
+
+    @staticmethod
+    def _extract_list_from_data(data):
+        if data is None:
+            return None
+        if isinstance(data, list):
+            return data
+        if isinstance(data, dict):
+            if isinstance(data.get('list'), list):
+                return data['list']
+            if isinstance(data.get('items'), list):
+                return data['items']
+            if isinstance(data.get('cars'), list):
+                return data['cars']
+        return None
+
+    @staticmethod
+    def _extract_cars_node(root):
+        if root is None:
+            return None
+        if isinstance(root, list):
+            return root
+        if isinstance(root, dict):
+            for key in ('list', 'cars', 'items'):
+                val = root.get(key)
+                if isinstance(val, list):
+                    return val
+            data = root.get('data', {})
+            if isinstance(data, dict):
+                for key in ('list', 'items', 'cars'):
+                    val = data.get(key)
+                    if isinstance(val, list):
+                        return val
+        return None
+
+    @staticmethod
+    def _extract_countries_node(root):
+        if root is None:
+            return None
+        if isinstance(root, list):
+            return root
+        if isinstance(root, dict):
+            if isinstance(root.get('data'), list):
+                return root['data']
+            if isinstance(root.get('countries'), list):
+                return root['countries']
+        return None