3 Revize 517b333668 ... c39491884b

Autor SHA1 Zpráva Datum
  kyle c39491884b v0.7(增加可导入报价表功能) před 3 dny
  kyle 36041e12dd v0.6 před 3 dny
  kyle d96fe4cdf1 v0.5(报价系统api接入) před 4 dny

+ 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

+ 63 - 40
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 | 金额英文大写转换 |
 
 ## 功能
@@ -51,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
-  ...
-  ```
+- **结构化编号列表**(标准格式)
 - **自然语言描述**:用户自由描述订单内容
 
 解析提取核心字段:
@@ -98,13 +67,39 @@ pip install --user -r requirements.txt
 - 贸易条款(FCA/FOB/EXW)
 - 出发港口、目的地国家
 
-### Step 2: 车型价格匹配
+### Step 2a(默认路径): 车型价格查询(海外价格系统 API)
+
+通过 `scripts/overseas_price_client.py` 连接海外价格报价系统,按车型型号查询实时价格与配置:
+
+1. 调用 `search_cars(model_code)` 查询车型信息
+2. 从响应中提取对应贸易条款的单价(FCA/FOB/EXW)
+3. 获取车型配置信息(发动机代码、版本、配置描述等)
+4. 构建车辆的英文描述用于合同和 PI
 
-遍历 `assets/vehicle-price-config.xlsx` 中的所有sheet,在**G列(型号)**精确匹配ModelCode:
-- 匹配成功 → 根据贸易条款获取对应USD价格(FCA→O列, FOB→N列, EXW→P列)
-- 匹配失败 → **提示用户提供该车型的单价USD**
+**两种场景:**
+- API 查询成功 → 自动获取价格和配置,进入 Step 3
+- API 查询失败或无此车型 → **提示用户提供该车型的单价 USD**
 
-**用户提供的价格优先于价格表。**
+**用户提供的价格优先于 API 返回的价格。**
+
+### 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. 从报价表的 `导出设置` 行自动识别价格类型(FCA/FOB/EXW),提取对应列的价格
+4. 提取 I列配置信息、F列发动机描述,用于生成车型描述
+5. 提取英文 Sheet 的配置描述优先用于合同和PI
+6. 如果报价表中找不到某车型 → 提示用户手动补充价格(同现有流程)
+
+**支持的价格类型:** FCA / FOB / EXW 三种报价表均可自动识别,解析器按 `导出设置` 中的 `价格类型` 字段自动从对应列(`FCA价格(USD)` / `FOB价格(USD)` / `EXW价格(USD)`)提取单价。
+**适用场景:** 报价系统(API)返回的价格不含运费加装费时,直接使用报价表的数据。
 
 ### Step 3: 计算汇总
 
@@ -139,19 +134,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'] = 销售合同文件路径
@@ -163,13 +174,25 @@ result = generate_contracts(order_text, output_dir='.', user_prices={"LZW1028SPY
 - `python-docx`
 - `openpyxl`
 - `num2words`
+- `requests`
+- `Pillow`
 
 ## 模板文件
 
 - `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 客户端
+- `scripts/parse_quotation.py`: 报价表 xlsx 解析引擎
+- `.env`: API 连接配置(URL、用户名、密码)
 
 ## 字段映射详情
 
 详见 [references/field-mapping.md](references/field-mapping.md)
+
+## ⚠️ 维护注意事项
+
+- **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` 缓存文件,可删除(会自动重建)。
+- **修改 skill 后同步回 Windows 源**:在 agent 中直接修改了 skill 文件后(如添加新功能、修复 bug),记得用 `cp` 将改动同步到 `C:\Users\86136\Desktop\海外合同skill\auto-generate-export-contracts\` 对应的文件,避免下次从桌面更新时覆盖丢失。需同步的文件通常包括:`scripts/generate_contracts.py`、`scripts/parse_order_info.py`、`scripts/parse_quotation.py`、`SKILL.md`。
+- **报价表解析器 `parse_quotation.py` 的列名支持**:`CN_COL_MAP` 和 `EN_COL_MAP` 中需同时维护 FCA / FOB / EXW 三种列名映射。`load_quotation()` 中的 `price_field` 选择逻辑默认用 FCA,检测到 `价格类型=FOB` 或 `价格类型=EXW` 时自动切换对应列。如果出现报价表解析不到价格,先用 openpyxl 检查实际列名是否已覆盖,再添加映射。

binární
auto-generate-export-contracts/assets/vehicle-price-config.xlsx


binární
auto-generate-export-contracts/assets/vehicle-sales-contract-template.docx


binární
auto-generate-export-contracts/assets/~$hicle-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

+ 300 - 165
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:
     """将整数金额转换为中文大写(壹贰叁肆伍陆柒捌玖拾佰仟万)"""
@@ -298,103 +366,87 @@ 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 ""
+    qty = vehicle.get("quantity", 0)
+    qty_str = f" {qty}" if qty 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 + 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
+        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 + qty_str 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)
-
-
-def process_vehicles(vehicles: list, trade_term: str) -> list:
+            eng_line += " " + name_cn
+        lines.append(eng_line)
+        for extra in eng_extra:
+            lines.append(extra)
+        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
+        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 + 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, quotation: dict = None) -> list:
     """
     处理车型列表,匹配价格表或提示用户提供价格
 
@@ -413,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)
@@ -444,109 +499,121 @@ def calculate_summary(vehicles: list) -> dict:
     }
 
 
+
+
+
+
+
+
+
+def _add_stamp_to_sheet(ws, template_path, output_path):
+    """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
+        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
+
+        # \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,
                                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"
+    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['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, original_template, 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)"""
@@ -611,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、非括号说明部分),替换为新国家名
@@ -625,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(非括号说明),替换国家名
@@ -733,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行开始数据
@@ -754,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
@@ -799,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:
     """
     主函数:根据订单文本生成合同
 
@@ -807,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:
         {
@@ -833,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:
@@ -869,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()
 
@@ -880,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')

+ 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

+ 9 - 0
auto-generate-export-contracts/scripts/parse_order_info.py

@@ -67,6 +67,15 @@ def parse_structured_order(text: str) -> dict:
                         # group(1)=name_part, group(2)=model_code → map to group(2), group(3)
                         self.group = lambda n: ['', '', real_m.group(1), real_m.group(2)][n]
                 m = _M(m)
+        if not m:
+            # 车型续行:无编号前缀,直接以车型名开头 + 型号 + 代码 + X台颜色
+            m = re.match(r'(.+?)\s*型号[::]\s*(LZW\w+).+?代码[::]\s*(\w+)', line)
+            if m:
+                class _M:
+                    def __init__(self, real_m):
+                        self._m = real_m
+                        self.group = lambda n: ['', '', real_m.group(1), real_m.group(2)][n]
+                m = _M(m)
         if m:
             seq = m.group(1)
             name_part = m.group(2).strip()

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

@@ -0,0 +1,250 @@
+#!/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 = {
+    '序号': 'seq_no',
+    '推广命名': 'promotion_name',
+    '版本': 'version',
+    '型号': 'model_code',
+    '车型代码': 'model_code2',
+    '发动机': 'engine',
+    '排放标准': 'emission_std',
+    '车辆类型': 'vehicle_type',
+    '配置信息': 'config',
+    '图片': 'picture',
+    '采购数量': 'quantity',
+    '加装信息': 'add_on_info',
+    'FCA价格(RMB)': 'fca_price_rmb',
+    'FCA价格(USD)': 'fca_price_usd',
+    'FOB价格(RMB)': 'fob_price_rmb',
+    'FOB价格(USD)': 'fob_price_usd',
+    'EXW价格(RMB)': 'exw_price_rmb',
+    'EXW价格(USD)': 'exw_price_usd',
+    '运费(RMB)': 'freight_rmb',
+    '运费(USD)': 'freight_usd',
+    '加装(RMB)': 'installation_rmb',
+    '加装(USD)': 'installation_usd',
+    'FCA小计(RMB)': 'fca_subtotal_rmb',
+    'FCA小计(USD)': 'fca_subtotal_usd',
+    'FOB小计(RMB)': 'fob_subtotal_rmb',
+    'FOB小计(USD)': 'fob_subtotal_usd',
+    'EXW小计(RMB)': 'exw_subtotal_rmb',
+    'EXW小计(USD)': 'exw_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',
+    'FOB Price(RMB)': 'fob_price_rmb',
+    'FOB Price(USD)': 'fob_price_usd',
+    'EXW Price(RMB)': 'exw_price_rmb',
+    'EXW Price(USD)': 'exw_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',
+    'FOB Subtotal(RMB)': 'fob_subtotal_rmb',
+    'FOB Subtotal(USD)': 'fob_subtotal_usd',
+    'EXW Subtotal(RMB)': 'exw_subtotal_rmb',
+    'EXW Subtotal(USD)': 'exw_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 = []
+    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),
+        })
+    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))