|
|
@@ -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')
|