|
@@ -298,102 +298,84 @@ COLOR_MAP = {
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_full_description(vehicle: dict, match_result: dict = None) -> str:
|
|
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_en, color_cn = COLOR_MAP.get(color, (color, color))
|
|
|
- color_display = color if color else ''
|
|
|
|
|
-
|
|
|
|
|
|
|
+ color_display = color if color else ""
|
|
|
lines = []
|
|
lines = []
|
|
|
-
|
|
|
|
|
if match_result:
|
|
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:
|
|
if series_en:
|
|
|
- model_cn_line += f" {series_en}"
|
|
|
|
|
|
|
+ eng_line += " " + series_en
|
|
|
elif name_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:
|
|
if config_en:
|
|
|
lines.append(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:
|
|
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:
|
|
if name_en:
|
|
|
- model_cn_line += f" {name_en}"
|
|
|
|
|
|
|
+ eng_line += " " + name_en
|
|
|
elif name_cn:
|
|
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:
|
|
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,
|
|
def generate_proforma_invoice(order: dict, vehicles: list, summary: dict,
|
|
|
contract_no: str, output_dir: str) -> str:
|
|
contract_no: str, output_dir: str) -> str:
|
|
|
- """生成Proforma Invoice"""
|
|
|
|
|
|
|
+ """Generate Proforma Invoice."""
|
|
|
import openpyxl
|
|
import openpyxl
|
|
|
from openpyxl.utils import get_column_letter
|
|
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)
|
|
output_path = os.path.join(output_dir, output_filename)
|
|
|
shutil.copy(template_path, output_path)
|
|
shutil.copy(template_path, output_path)
|
|
|
-
|
|
|
|
|
- # 打开并修改
|
|
|
|
|
wb = openpyxl.load_workbook(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
|
|
start_row = 17
|
|
|
for i, v in enumerate(vehicles):
|
|
for i, v in enumerate(vehicles):
|
|
|
row = start_row + i
|
|
row = start_row + i
|
|
|
if row > 22:
|
|
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:
|
|
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 row in range(start_row + len(vehicles), 23):
|
|
|
for col in range(2, 9):
|
|
for col in range(2, 9):
|
|
|
ws.cell(row=row, column=col)._value = None
|
|
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.save(output_path)
|
|
|
wb.close()
|
|
wb.close()
|
|
|
-
|
|
|
|
|
return output_path
|
|
return output_path
|
|
|
|
|
|
|
|
|
|
|
|
|
def accept_all_revisions(doc):
|
|
def accept_all_revisions(doc):
|
|
|
- """
|
|
|
|
|
- 清除文档中的所有修订标记(Track Changes)和批注。
|
|
|
|
|
- 解决WPS/Word模板中未接受修订导致内容重复显示的问题。
|
|
|
|
|
- 策略:仅处理正文段落(不遍历表格),避免误删表格中的图片。
|
|
|
|
|
- """
|
|
|
|
|
body = doc.element.body
|
|
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
|
|
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()
|
|
parent = elem.getparent()
|
|
|
if parent is not None:
|
|
if parent is not None:
|
|
|
parent.remove(elem)
|
|
parent.remove(elem)
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
def generate_sales_contract(order: dict, vehicles: list, summary: dict,
|
|
def generate_sales_contract(order: dict, vehicles: list, summary: dict,
|
|
|
contract_no: str, output_dir: str) -> str:
|
|
contract_no: str, output_dir: str) -> str:
|
|
|
"""生成销售合同 (Word)"""
|
|
"""生成销售合同 (Word)"""
|