generate_contracts.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 主生成脚本:根据订单信息生成销售合同和Proforma Invoice
  5. """
  6. import os
  7. import sys
  8. import shutil
  9. import re
  10. from datetime import datetime
  11. # 添加脚本目录到路径
  12. script_dir = os.path.dirname(os.path.abspath(__file__))
  13. sys.path.insert(0, script_dir)
  14. from parse_order_info import parse_order_info
  15. from match_vehicle import match_vehicle, build_vehicle_description
  16. from number_to_words import format_say_us_only, calculate_payment_split
  17. # 模板正文默认字号:12pt
  18. from docx.shared import Pt
  19. DEFAULT_FONT_SIZE = Pt(12)
  20. def copy_run_format(src_run, dst_run):
  21. """复制源run的格式到目标run,字号默认12pt"""
  22. dst_run.bold = src_run.bold
  23. dst_run.italic = src_run.italic
  24. dst_run.font.size = src_run.font.size or DEFAULT_FONT_SIZE
  25. if src_run.font.name:
  26. dst_run.font.name = src_run.font.name
  27. def set_run_format(run, bold=None, size=None):
  28. """设置run格式,字号默认12pt"""
  29. run.bold = bold
  30. run.font.size = size or DEFAULT_FONT_SIZE
  31. def get_asset_path(filename: str) -> str:
  32. """获取assets目录下的文件路径"""
  33. return os.path.join(script_dir, '..', 'assets', filename)
  34. def generate_contract_no() -> str:
  35. """生成合同编号: HLXYWPA{年月日}"""
  36. return f"HLXYWPA{datetime.now().strftime('%Y%m%d')}"
  37. def format_date() -> str:
  38. """格式化当前日期"""
  39. return datetime.now().strftime('%Y-%m-%d')
  40. def format_date_for_cell():
  41. """Excel单元格日期格式(仅日期,无时间)"""
  42. from datetime import datetime
  43. now = datetime.now()
  44. return datetime(now.year, now.month, now.day)
  45. def get_port_names(port_input: str) -> tuple:
  46. """获取港口的中英文名称,返回 (cn_name, en_name)"""
  47. port_map = {
  48. '广州南沙': ('中国广州南沙港口', 'Guangzhou nansha Port'),
  49. '广州': ('中国广州港口', 'Guangzhou Port'),
  50. '深圳': ('中国深圳港口', 'Shenzhen Port'),
  51. '上海': ('中国上海港口', 'Shanghai Port'),
  52. '天津': ('中国天津港口', 'Tianjin Port'),
  53. '青岛': ('中国青岛港口', 'Qingdao Port'),
  54. '宁波': ('中国宁波港口', 'Ningbo Port'),
  55. '厦门': ('中国厦门港口', 'Xiamen Port'),
  56. }
  57. port_input = port_input.strip()
  58. # 先尝试中文匹配
  59. for cn, (cn_name, en_name) in port_map.items():
  60. if cn in port_input:
  61. return cn_name, en_name
  62. # 再尝试英文匹配
  63. for cn, (cn_name, en_name) in port_map.items():
  64. if en_name.lower() in port_input.lower():
  65. return cn_name, en_name
  66. # 默认返回输入值
  67. return port_input, port_input
  68. def number_to_chinese(num: int) -> str:
  69. """将整数金额转换为中文大写(壹贰叁肆伍陆柒捌玖拾佰仟万)"""
  70. if num == 0:
  71. return '零'
  72. if num < 0:
  73. return '负' + number_to_chinese(-num)
  74. units = ['', '拾', '佰', '仟']
  75. big_units = ['', '万', '亿']
  76. nums = '零壹贰叁肆伍陆柒捌玖'
  77. def convert_group(n):
  78. if n == 0:
  79. return ''
  80. s = ''
  81. zero = False
  82. for i in range(3, -1, -1):
  83. d = (n // (10 ** i)) % 10
  84. if d == 0:
  85. if s and not zero:
  86. zero = True
  87. else:
  88. if zero:
  89. s += '零'
  90. zero = False
  91. s += nums[d] + units[i]
  92. return s
  93. groups = []
  94. idx = 0
  95. while num > 0:
  96. g = num % 10000
  97. num //= 10000
  98. if g > 0:
  99. gs = convert_group(g)
  100. if gs:
  101. groups.append(gs + big_units[idx])
  102. else:
  103. groups.append(big_units[idx])
  104. elif groups:
  105. groups.append('零')
  106. idx += 1
  107. result = ''.join(reversed(groups))
  108. result = result.replace('零零', '零')
  109. result = result.replace('零万', '万')
  110. result = result.replace('零亿', '亿')
  111. result = result.rstrip('零')
  112. return result
  113. def fill_paragraph_value(para, marker: str, value: str) -> bool:
  114. """
  115. 在段落中 marker 标记后填入 value,保留原有格式。
  116. 不直接设置 para.text(这会破坏所有 run 格式)。
  117. """
  118. text = para.text
  119. if marker not in text:
  120. return False
  121. parts = text.split(marker, 1)
  122. if len(parts) < 2:
  123. return False
  124. after = parts[1].strip()
  125. if after and after != value.strip():
  126. # 已有内容且不是我们要填的值,视为已填充
  127. return False
  128. # 找到最后一个 run
  129. if not para.runs:
  130. run = para.add_run(value)
  131. run.font.size = DEFAULT_FONT_SIZE
  132. return True
  133. # 从后往前找:优先修改空白占位符 run,否则在末尾追加
  134. for i in range(len(para.runs) - 1, -1, -1):
  135. r = para.runs[i]
  136. if not r.text.strip():
  137. # 空白 run,填入值,并复制前一个非空 run 的格式
  138. r.text = value
  139. src = None
  140. for j in range(i - 1, -1, -1):
  141. if para.runs[j].text:
  142. src = para.runs[j]
  143. break
  144. if src:
  145. copy_run_format(src, r)
  146. if src.font.color and src.font.color.rgb:
  147. r.font.color.rgb = src.font.color.rgb
  148. else:
  149. r.font.size = DEFAULT_FONT_SIZE
  150. return True
  151. elif r.text.strip():
  152. # 最后一个非空 run,在其后追加新 run,复制格式
  153. new_run = para.add_run(value)
  154. copy_run_format(r, new_run)
  155. if r.font.color and r.font.color.rgb:
  156. new_run.font.color.rgb = r.font.color.rgb
  157. return True
  158. return False
  159. def delete_table_row(table, row):
  160. """从表格中真正删除一行(使用XML操作)"""
  161. tbl = table._tbl
  162. tr = row._tr
  163. tbl.remove(tr)
  164. def set_cell_text(cell, text):
  165. """设置单元格文本:删除除第一个段落外的所有段落,清空第一个段落并填入新文本"""
  166. tc = cell._tc
  167. # 删除除第一个外的所有段落(从后往前删)
  168. while len(cell.paragraphs) > 1:
  169. p = cell.paragraphs[-1]._p
  170. tc.remove(p)
  171. # 清空并填充第一个段落
  172. para = cell.paragraphs[0]
  173. style = para.style
  174. para.clear()
  175. run = para.add_run(text)
  176. para.style = style
  177. def set_cell_multiline(cell, text):
  178. """设置单元格多段落文本:按换行符拆分为多个段落,保留模板格式。
  179. 模板中 Description 单元格的格式为:
  180. Para 0: Model: ...
  181. Para 1: (配置描述)
  182. Para 2: Color: ...
  183. Para 3: 型号:...
  184. Para 4: (配置描述)
  185. Para 5: 颜色:...
  186. """
  187. from docx.oxml.ns import qn
  188. from copy import deepcopy
  189. tc = cell._tc
  190. # 保存第一个段落的完整 XML 作为模板
  191. first_p = cell.paragraphs[0]._p
  192. template_pPr = first_p.find(qn('w:pPr'))
  193. # 删除除第一个外的所有段落(从后往前删)
  194. while len(cell.paragraphs) > 1:
  195. p = cell.paragraphs[-1]._p
  196. tc.remove(p)
  197. # 清空第一个段落
  198. first_para = cell.paragraphs[0]
  199. first_para.clear()
  200. # 按换行符拆分
  201. lines = text.split('\n')
  202. # 第一行填入第一个段落
  203. if lines:
  204. first_para.add_run(lines[0])
  205. # 后续行追加为新段落
  206. for line in lines[1:]:
  207. new_p = tc.makeelement(qn('w:p'), {})
  208. # 复制段落属性(对齐、缩进等)
  209. if template_pPr is not None:
  210. new_p.append(deepcopy(template_pPr))
  211. # 添加 run
  212. new_r = new_p.makeelement(qn('w:r'), {})
  213. new_t = new_r.makeelement(qn('w:t'), {})
  214. new_t.text = line
  215. new_t.set(qn('xml:space'), 'preserve')
  216. new_r.append(new_t)
  217. new_p.append(new_r)
  218. tc.append(new_p)
  219. def build_full_description(vehicle: dict, match_result: dict = None) -> str:
  220. """
  221. 构建销售合同中 Commodity 表格的完整货物描述。
  222. 格式参考模板:
  223. Model: {model_code}, {engine_code} {中文车型名}
  224. {英文车型名}
  225. {中文配置}
  226. {英文配置}
  227. Color: {color}
  228. 型号:{model_code}, {engine_code} {英文车型名}
  229. """
  230. model_code = vehicle.get('model_code', '')
  231. engine_code = vehicle.get('engine_code', '')
  232. color = vehicle.get('color', '1')
  233. name_cn = vehicle.get('name_cn', '')
  234. name_en = vehicle.get('name_en', '')
  235. lines = []
  236. if match_result:
  237. # 有价格表匹配,使用价格表信息
  238. desc_cn = match_result.get('description_cn', '')
  239. series_en = match_result.get('series_en', '')
  240. config_desc = match_result.get('config_desc', '')
  241. # 分离中英文配置描述
  242. config_cn = ''
  243. config_en = ''
  244. if config_desc:
  245. config_parts = [p.strip() for p in config_desc.split('\n') if p.strip()]
  246. for part in config_parts:
  247. if re.search(r'[\u4e00-\u9fff]', part):
  248. config_cn = part
  249. else:
  250. config_en = part
  251. # Model 行(英文)
  252. model_line = f"Model: {model_code}, {engine_code}"
  253. if series_en:
  254. model_line += f" {series_en}"
  255. lines.append(model_line)
  256. # 英文配置描述
  257. if config_en:
  258. lines.append(config_en)
  259. else:
  260. # 无价格表匹配,使用订单信息
  261. model_line = f"Model: {model_code}, {engine_code}"
  262. if name_en:
  263. model_line += f" {name_en}"
  264. elif name_cn:
  265. model_line += f" {name_cn}"
  266. lines.append(model_line)
  267. # Color (English)
  268. if color and color != '1':
  269. lines.append(f"Color: {color}")
  270. else:
  271. lines.append("Color: ")
  272. # 型号行(中文标签)- 带中文描述
  273. model_cn_line = f"型号:{model_code}, {engine_code}"
  274. if match_result and match_result.get('description_cn'):
  275. model_cn_line += f" {match_result['description_cn']}"
  276. elif name_cn:
  277. model_cn_line += f" {name_cn}"
  278. elif name_en:
  279. model_cn_line += f" {name_en}"
  280. lines.append(model_cn_line)
  281. # 中文配置描述(仅提取包含中文的配置行)
  282. if match_result and match_result.get('config_desc'):
  283. config_parts = [p.strip() for p in match_result['config_desc'].split('\n') if p.strip()]
  284. for part in config_parts:
  285. if re.search(r'[\u4e00-\u9fff]', part):
  286. lines.append(part)
  287. # 颜色行(中文)
  288. if color and color != '1':
  289. lines.append(f"颜色:{color}")
  290. else:
  291. lines.append("颜色:")
  292. return '\n'.join(lines)
  293. def process_vehicles(vehicles: list, trade_term: str) -> list:
  294. """
  295. 处理车型列表,匹配价格表或提示用户提供价格
  296. Returns:
  297. (processed_vehicles, missing_prices)
  298. """
  299. processed = []
  300. missing = []
  301. for v in vehicles:
  302. model_code = v.get('model_code', '')
  303. # 如果用户已提供单价,直接使用
  304. if v.get('unit_price_usd') is not None:
  305. processed.append(v)
  306. continue
  307. # 从价格表匹配
  308. match_result = match_vehicle(model_code, trade_term)
  309. if match_result and match_result.get('unit_price_usd'):
  310. v['unit_price_usd'] = match_result['unit_price_usd']
  311. v['config_desc'] = build_vehicle_description(v, match_result)
  312. v['_match_result'] = match_result
  313. processed.append(v)
  314. else:
  315. # 未匹配到价格
  316. missing.append(v)
  317. return processed, missing
  318. def calculate_summary(vehicles: list) -> dict:
  319. """计算汇总信息"""
  320. total_qty = sum(v['quantity'] for v in vehicles)
  321. total_amount = sum(v['unit_price_usd'] * v['quantity'] for v in vehicles)
  322. deposit_30, balance_70 = calculate_payment_split(total_amount)
  323. return {
  324. 'total_qty': total_qty,
  325. 'total_amount': round(total_amount, 2),
  326. 'total_amount_int': round(total_amount),
  327. 'deposit_30': deposit_30,
  328. 'balance_70': balance_70,
  329. 'amount_in_words': format_say_us_only(total_amount),
  330. 'amount_in_chinese': number_to_chinese(round(total_amount)),
  331. }
  332. def generate_proforma_invoice(order: dict, vehicles: list, summary: dict,
  333. contract_no: str, output_dir: str) -> str:
  334. """生成Proforma Invoice"""
  335. import openpyxl
  336. from openpyxl.utils import get_column_letter
  337. # 复制模板
  338. template_path = get_asset_path('proforma-invoice-template.xlsx')
  339. output_filename = f"{contract_no}-Proforma Invoice.xlsx"
  340. output_path = os.path.join(output_dir, output_filename)
  341. shutil.copy(template_path, output_path)
  342. # 打开并修改
  343. wb = openpyxl.load_workbook(output_path)
  344. ws = wb['INVOICE']
  345. # 买方信息
  346. ws['B7'] = f"TO: {order.get('buyer_en', '')}"
  347. ws['G7'] = f"Contract NO.: {contract_no}"
  348. ws['H8'] = format_date_for_cell()
  349. ws['B8'] = f"ADD: {order.get('address_en', '')}"
  350. ws['B9'] = f"Tel: {order.get('tel', '')}"
  351. # 港口信息
  352. port_cn, port_en = get_port_names(order.get('departure_port', '广州南沙'))
  353. trade_term = order.get('trade_term', 'FCA')
  354. # 商品明细 (B17-H22)
  355. start_row = 17
  356. for i, v in enumerate(vehicles):
  357. row = start_row + i
  358. if row > 22:
  359. break # 模板最多支持到22行
  360. desc = v.get('config_desc') or build_vehicle_description(v, v.get('_match_result'))
  361. if not desc:
  362. desc = f"Wuling {v['model_code']}, {v['engine_code']}"
  363. ws.cell(row=row, column=2, value=desc) # B列: DESCRIPTION
  364. ws.cell(row=row, column=3, value=v['quantity']) # C列: QTY
  365. ws.cell(row=row, column=4, value='UNIT') # D列
  366. ws.cell(row=row, column=5, value='USD') # E列
  367. ws.cell(row=row, column=6, value=v['unit_price_usd']) # F列: U.PRICE
  368. ws.cell(row=row, column=7, value='USD') # G列
  369. # H列保留公式 =C*F
  370. # 清除未使用的行(如果车辆少于模板默认行数)
  371. # 注意: cell.value = None 在 openpyxl 中不能真正清除单元格,必须用 _value = None
  372. for row in range(start_row + len(vehicles), 23):
  373. for col in range(2, 9):
  374. ws.cell(row=row, column=col)._value = None
  375. # 汇总信息
  376. ws['E23'] = f" {trade_term} {port_en} "
  377. ws['B24'] = summary['amount_in_words']
  378. # Delivery terms
  379. ws['B27'] = f"1. Delivery terms: {trade_term} {port_en} "
  380. # Payment terms
  381. payment_text = (
  382. f"3. Payment terms:\n"
  383. f"1. 30% of the Total Amount (USD {summary['deposit_30']:,}) shall be paid to Seller within 5 working days from order confirmation.\n"
  384. f"2. 70% of the Total Amount (USD {summary['balance_70']:,}) shall be paid to Seller within 10 working days before delivery. "
  385. )
  386. ws['B29'] = payment_text
  387. wb.save(output_path)
  388. wb.close()
  389. return output_path
  390. def accept_all_revisions(doc):
  391. """
  392. 清除文档中的所有修订标记(Track Changes)和批注。
  393. 解决WPS/Word模板中未接受修订导致内容重复显示的问题。
  394. 策略:仅处理正文段落(不遍历表格),避免误删表格中的图片。
  395. """
  396. body = doc.element.body
  397. ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
  398. from docx.oxml.ns import qn
  399. # 仅处理 body 直接子元素中的段落(跳过表格内的内容,保护图片)
  400. for p in body.findall(qn('w:p')):
  401. # 删除段落内的 <w:del> 元素
  402. for del_elem in list(p.iter(f'{{{ns}}}del')):
  403. parent = del_elem.getparent()
  404. if parent is not None:
  405. parent.remove(del_elem)
  406. # 删除段落内的 <w:ins> 元素
  407. for ins_elem in list(p.iter(f'{{{ns}}}ins')):
  408. parent = ins_elem.getparent()
  409. if parent is not None:
  410. parent.remove(ins_elem)
  411. # 删除批注标记
  412. for tag in ['commentRangeStart', 'commentRangeEnd', 'commentReference']:
  413. for elem in list(p.iter(f'{{{ns}}}{tag}')):
  414. parent = elem.getparent()
  415. if parent is not None:
  416. parent.remove(elem)
  417. def generate_sales_contract(order: dict, vehicles: list, summary: dict,
  418. contract_no: str, output_dir: str) -> str:
  419. """生成销售合同 (Word)"""
  420. from docx import Document
  421. # 复制模板
  422. template_path = get_asset_path('vehicle-sales-contract-template.docx')
  423. output_filename = f"{contract_no}-车辆销售合同.docx"
  424. output_path = os.path.join(output_dir, output_filename)
  425. shutil.copy(template_path, output_path)
  426. # 打开并修改
  427. doc = Document(output_path)
  428. # 先接受所有修订、删除批注,避免WPS显示重复内容
  429. accept_all_revisions(doc)
  430. port_cn, port_en = get_port_names(order.get('departure_port', '广州南沙'))
  431. trade_term = order.get('trade_term', 'FCA')
  432. # 1. 替换段落文本(保留原有格式)
  433. for para in doc.paragraphs:
  434. text = para.text
  435. if not text:
  436. continue
  437. # 合同编号: No.: → No.: HLXYWPA...
  438. if 'No.:' in text and not re.search(r'No\.:\s*\S', text):
  439. fill_paragraph_value(para, 'No.:', f' {contract_no}')
  440. # 签署日期
  441. if 'Signature Date:' in text and not re.search(r'Signature Date:\s*\d', text):
  442. fill_paragraph_value(para, 'Signature Date:', f' {format_date()}')
  443. # 买方英文
  444. if text.strip() == 'Buyer:':
  445. fill_paragraph_value(para, 'Buyer:', f' {order.get("buyer_en", "")}')
  446. # 买方中文
  447. if text.strip() == '买方:':
  448. fill_paragraph_value(para, '买方:', f'{order.get("buyer_cn", "")}')
  449. # 英文地址(匹配 ADD: 或 ADD : 等变体,且内容较短说明是占位符)
  450. if re.search(r'ADD\s*[::]', text) and len(text.strip()) < 10:
  451. # 查找实际标记(支持空格和不间断空格)
  452. m = re.search(r'(ADD\s*[::])', text)
  453. if m:
  454. fill_paragraph_value(para, m.group(1), f' {order.get("address_en", "")}')
  455. # 中文地址
  456. if re.search(r'地址\s*[::]', text) and len(text.strip()) < 10:
  457. m = re.search(r'(地址\s*[::])', text)
  458. if m:
  459. fill_paragraph_value(para, m.group(1), f'{order.get("address_cn", "")}')
  460. # 电话
  461. if text.strip() == 'Tel电话:':
  462. fill_paragraph_value(para, 'Tel电话:', f' {order.get("tel", "")}')
  463. # 出口目的地(英文)
  464. if 'Export Destination:' in text:
  465. if para.runs:
  466. for run in para.runs:
  467. if run.text == ' ' and run.bold is None:
  468. run.text = f' {order.get("destination_country", "")} '
  469. break
  470. # 出口目的地(中文)
  471. if '出口目的地:' in text:
  472. dest_cn = order.get('destination_country', '')
  473. if dest_cn and para.runs:
  474. # 在":"后插入国家名,保留后面的(下称"区域")
  475. for run in para.runs:
  476. if '(下称' in run.text:
  477. run.text = run.text.replace('(下称', f'{dest_cn}(下称')
  478. break
  479. # 贸易条款(中英文同段落)
  480. if 'Incoterms:' in text:
  481. if para.runs:
  482. src = para.runs[0] # 保留第一个run的格式(bold=True)
  483. para.clear()
  484. # 英文部分
  485. run_en = para.add_run(f"Incoterms: {trade_term} {port_en}")
  486. copy_run_format(src, run_en)
  487. # 中文部分
  488. run_cn = para.add_run(f"贸易条款:{trade_term} {port_cn}")
  489. set_run_format(run_cn, bold=None)
  490. # 付款条款英文
  491. if '30% of the Total Amount' in text and 'shall be paid' in text:
  492. if para.runs:
  493. src = para.runs[-1]
  494. para.clear()
  495. run = para.add_run(
  496. f"30% of the Total Amount shall be paid to the Seller by T/T before the production. "
  497. f"70% of the Total Amount shall be paid to the Seller by the Buyer by means of T/T before the delivery of vehicles."
  498. )
  499. copy_run_format(src, run)
  500. # 付款条款中文
  501. if '生产前,买方向卖方通过电汇支付' in text:
  502. if para.runs:
  503. src = para.runs[-1]
  504. para.clear()
  505. run = para.add_run(
  506. f"生产前,买方向卖方通过电汇支付本合同项下30% 货款。"
  507. f"发运前,买方向卖方通过电汇支付本合同项下70% 货款。"
  508. )
  509. copy_run_format(src, run)
  510. # 2. 插入买方信息(签字区域,卖方信息之后)
  511. buyer_en = order.get('buyer_en', '')
  512. buyer_cn = order.get('buyer_cn', '')
  513. if buyer_en or buyer_cn:
  514. # 找到第二个 "Signature 签字:" (买方签字区) 前的空白段落
  515. sig_count = 0
  516. buyer_sig_idx = -1
  517. for i, para in enumerate(doc.paragraphs):
  518. if 'Signature' in para.text and '签字' in para.text:
  519. sig_count += 1
  520. if sig_count == 2:
  521. buyer_sig_idx = i
  522. break
  523. if buyer_sig_idx > 0:
  524. # 从卖方Title之后向前找空段落,按顺序填入Buyer(EN)和买方(CN)
  525. empty_slots = []
  526. for i in range(buyer_sig_idx - 1, max(buyer_sig_idx - 6, 0), -1):
  527. if not doc.paragraphs[i].text.strip():
  528. empty_slots.append(i)
  529. else:
  530. break # 遇到非空段落就停止
  531. empty_slots.reverse() # 正序(从上到下)
  532. if len(empty_slots) >= 2 and buyer_en:
  533. run = doc.paragraphs[empty_slots[0]].add_run(f'Buyer: {buyer_en}')
  534. set_run_format(run, bold=True)
  535. if len(empty_slots) >= 2 and buyer_cn:
  536. run = doc.paragraphs[empty_slots[1]].add_run(f'买方:{buyer_cn}')
  537. set_run_format(run, bold=True)
  538. elif len(empty_slots) >= 1 and buyer_cn:
  539. run = doc.paragraphs[empty_slots[0]].add_run(f'买方:{buyer_cn}')
  540. set_run_format(run, bold=True)
  541. # 3. 替换表格内容
  542. for table in doc.tables:
  543. first_cell_text = table.rows[0].cells[0].text.strip() if table.rows else ''
  544. if 'DESCRIPTION' in first_cell_text or '货物描述' in first_cell_text:
  545. _fill_vehicle_table(table, vehicles, summary, trade_term, port_cn, port_en)
  546. # 银行账户表和商标表保持原样
  547. doc.save(output_path)
  548. return output_path
  549. def _fill_vehicle_table(table, vehicles, summary, trade_term, port_cn, port_en):
  550. """填充销售合同中的车辆明细表(保留格式 + 删除多余行)"""
  551. data_start_row = 1 # 表头后第1行开始数据
  552. # 先找到汇总行位置
  553. summary_row_idx = None
  554. for idx, row in enumerate(table.rows):
  555. if idx == 0:
  556. continue
  557. row_text = ' '.join(cell.text for cell in row.cells)
  558. if 'Total' in row_text or '总数量' in row_text or 'SAY USD' in row_text:
  559. summary_row_idx = idx
  560. break
  561. # 确定可用的数据行(汇总行之前的行)
  562. available_data_rows = []
  563. for idx in range(data_start_row, len(table.rows)):
  564. if idx == summary_row_idx:
  565. break
  566. available_data_rows.append(idx)
  567. # 1. 填充车辆数据
  568. for i, v in enumerate(vehicles):
  569. if i >= len(available_data_rows):
  570. break
  571. row = table.rows[available_data_rows[i]]
  572. desc = build_full_description(v, v.get('_match_result'))
  573. if len(row.cells) > 0:
  574. set_cell_multiline(row.cells[0], desc)
  575. if len(row.cells) > 1:
  576. set_cell_text(row.cells[1], str(v['quantity']))
  577. if len(row.cells) > 2:
  578. set_cell_text(row.cells[2], str(v['unit_price_usd']))
  579. if len(row.cells) > 3:
  580. set_cell_text(row.cells[3], str(round(v['unit_price_usd'] * v['quantity'], 2)))
  581. # 2. 先填充汇总行(在删除操作之前,避免索引失效)
  582. if summary_row_idx is not None and summary_row_idx < len(table.rows):
  583. summary_row = table.rows[summary_row_idx]
  584. amount_en = summary['amount_in_words'].replace('SAY US ', '').replace(' only', '')
  585. amount_cn = summary['amount_in_chinese']
  586. total_text = (
  587. f"Total: {summary['total_qty']} units 总数量: {summary['total_qty']} 辆 \n"
  588. f"Total Amount: USD {summary['total_amount']:.2f} 总金额: 美元 {summary['total_amount']:.2f}\n"
  589. f"(Say USD: {amount_en} ONLY)\n"
  590. f"(合计美元:{amount_cn}美元整)\n"
  591. f"{trade_term} {port_en} {trade_term} {port_cn}"
  592. )
  593. for cell in summary_row.cells:
  594. set_cell_multiline(cell, total_text)
  595. # 3. 删除多余空白行(从后往前删,跳过汇总行)
  596. filled_count = min(len(vehicles), len(available_data_rows))
  597. rows_to_delete = []
  598. for idx in range(data_start_row + filled_count, len(table.rows)):
  599. if idx == summary_row_idx:
  600. break
  601. rows_to_delete.append(table.rows[idx])
  602. for row in reversed(rows_to_delete):
  603. delete_table_row(table, row)
  604. def generate_contracts(order_text: str, output_dir: str = '.',
  605. user_prices: dict = None) -> dict:
  606. """
  607. 主函数:根据订单文本生成合同
  608. Args:
  609. order_text: 订单信息文本
  610. output_dir: 输出目录
  611. user_prices: 用户手动提供的价格 {model_code: price}
  612. Returns:
  613. {
  614. 'pi_path': str,
  615. 'contract_path': str,
  616. 'contract_no': str,
  617. 'summary': dict,
  618. 'missing_prices': list,
  619. }
  620. """
  621. # 1. 解析订单
  622. order = parse_order_info(order_text)
  623. # 2. 生成合同编号
  624. contract_no = generate_contract_no()
  625. # 3. 处理车型价格
  626. vehicles = order.get('vehicles', [])
  627. trade_term = order.get('trade_term', 'FCA')
  628. # 应用用户提供的价格
  629. if user_prices:
  630. for v in vehicles:
  631. if v['model_code'] in user_prices:
  632. v['unit_price_usd'] = user_prices[v['model_code']]
  633. processed, missing = process_vehicles(vehicles, trade_term)
  634. # 如果有缺失价格,返回提示信息
  635. if missing:
  636. return {
  637. 'success': False,
  638. 'missing_prices': missing,
  639. 'message': f"以下车型在价格表中未找到,请提供单价USD: {[v['model_code'] for v in missing]}",
  640. }
  641. # 4. 计算汇总
  642. summary = calculate_summary(processed)
  643. # 5. 生成文件
  644. os.makedirs(output_dir, exist_ok=True)
  645. pi_path = generate_proforma_invoice(order, processed, summary, contract_no, output_dir)
  646. contract_path = generate_sales_contract(order, processed, summary, contract_no, output_dir)
  647. return {
  648. 'success': True,
  649. 'contract_no': contract_no,
  650. 'pi_path': pi_path,
  651. 'contract_path': contract_path,
  652. 'summary': summary,
  653. 'missing_prices': [],
  654. }
  655. if __name__ == '__main__':
  656. import argparse
  657. parser = argparse.ArgumentParser(description='Generate export contracts from order info')
  658. parser.add_argument('order_file', help='Path to order info text file')
  659. parser.add_argument('-o', '--output', default='.', help='Output directory')
  660. parser.add_argument('-p', '--prices', help='JSON string of manual prices {"LZW1028SPY": 6176}')
  661. args = parser.parse_args()
  662. with open(args.order_file, 'r', encoding='utf-8') as f:
  663. order_text = f.read()
  664. user_prices = None
  665. if args.prices:
  666. import json
  667. user_prices = json.loads(args.prices)
  668. result = generate_contracts(order_text, args.output, user_prices)
  669. import json as json_mod
  670. sys.stdout.reconfigure(encoding='utf-8')
  671. print(json_mod.dumps(result, ensure_ascii=False, indent=2))