"""合同模板管理 & 合同生成路由""" import os import re import uuid import json from datetime import datetime from flask import Blueprint, request, jsonify, session, send_file from services.db import add_operation_log, query from services.contract_ledger import ( add_event, add_file, create_record, ensure_schema as ensure_ledger_schema, new_contract_no, parse_json, ) from routes.auth import login_required, operation_required, operation_required_compat import openpyxl from openpyxl.utils import get_column_letter from copy import copy contract_bp = Blueprint('contract', __name__) TEMPLATE_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads', 'contract_templates') OUTPUT_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads', 'contract_output') os.makedirs(TEMPLATE_FOLDER, exist_ok=True) os.makedirs(OUTPUT_FOLDER, exist_ok=True) # ─── 数字转中文大写 ─── _CN_NUM = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'] _CN_UNIT = ['', '拾', '佰', '仟'] _CN_BIG = ['', '万', '亿'] def _num_to_cn(amount): """金额转中文大写(精确到分)""" if amount is None: return '零元整' try: amount = round(float(amount), 2) except: return '零元整' if amount < 0: return '负' + _num_to_cn(-amount) integer_part = int(amount) decimal_part = int(round((amount - integer_part) * 100)) result = '' if integer_part > 0: # 转换整数部分 str_int = str(integer_part) n = len(str_int) zero_flag = False for i, ch in enumerate(str_int): p = n - i - 1 # 当前位数 d = int(ch) if d == 0: zero_flag = True else: if zero_flag: result += '零' zero_flag = False result += _CN_NUM[d] + _CN_UNIT[p % 4] if p % 4 == 0 and p > 0: result += _CN_BIG[p // 4] result += '元' if decimal_part == 0: result += '整' else: if decimal_part >= 10: result += _CN_NUM[decimal_part // 10] + '角' if decimal_part % 10 != 0: result += _CN_NUM[decimal_part % 10] + '分' else: pass else: if integer_part > 0: result += '零' result += _CN_NUM[decimal_part] + '分' if not result: result = '零元整' return result # ─── 模板管理 ─── @contract_bp.route('/api/contract/templates') @login_required def contract_templates(): """获取所有合同模板(含绑定供应商名称)""" templates = query( "SELECT ct.*, s.supplier_name AS bound_supplier_name " "FROM contract_templates ct " "LEFT JOIN suppliers s ON ct.supplier_id = s.id " "ORDER BY ct.id DESC" ) for t in templates: for f in ['created_at', 'updated_at']: if t.get(f) and hasattr(t[f], 'strftime'): t[f] = t[f].strftime('%Y-%m-%d %H:%M:%S') return jsonify({"code": 0, "data": templates}) @contract_bp.route('/api/contract/template/download', methods=['GET']) def contract_template_download(): """下载合同模板(以模板名称为文件名)""" tid = request.args.get('id', '').strip() if not tid or not tid.isdigit(): return jsonify({"code": 400, "msg": "参数错误"}), 400 rows = query("SELECT * FROM contract_templates WHERE id=%s", (int(tid),)) if not rows: return jsonify({"code": 400, "msg": "模板不存在"}), 400 t = rows[0] # 从 file_path 中提取文件名,拼接实际路径 filename = os.path.basename(t['file_path']) filepath = os.path.join(TEMPLATE_FOLDER, filename) if not os.path.exists(filepath): return jsonify({"code": 400, "msg": "文件不存在"}), 400 download_name = t['name'] + '.xlsx' return send_file(filepath, as_attachment=True, download_name=download_name) @contract_bp.route('/api/contract/template/preview', methods=['GET']) def contract_template_preview(): """预览合同模板内容(读取 Excel 返回 JSON 数据)""" tid = request.args.get('id', '').strip() if not tid or not tid.isdigit(): return jsonify({"code": 400, "msg": "参数错误"}), 400 rows = query("SELECT * FROM contract_templates WHERE id=%s", (int(tid),)) if not rows: return jsonify({"code": 400, "msg": "模板不存在"}), 400 t = rows[0] filename = os.path.basename(t['file_path']) filepath = os.path.join(TEMPLATE_FOLDER, filename) if not os.path.exists(filepath): return jsonify({"code": 400, "msg": "文件不存在"}), 400 try: wb = openpyxl.load_workbook(filepath, data_only=True) ws = wb.active sheet_name = ws.title if ws.title else 'Sheet1' # 读取所有行数据 rows_data = [] merged_ranges = [] for merge in ws.merged_cells.ranges: merged_ranges.append({ 'min_row': merge.min_row, 'max_row': merge.max_row, 'min_col': merge.min_col, 'max_col': merge.max_col }) for row in ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), max_col=min(ws.max_column, 20)): row_data = [] for cell in row: val = cell.value if val is not None: if isinstance(val, datetime): val = val.strftime('%Y-%m-%d %H:%M:%S') else: val = str(val) row_data.append(val if val else '') rows_data.append(row_data) wb.close() return jsonify({ "code": 0, "data": { "sheet_name": sheet_name, "max_row": ws.max_row, "max_column": ws.max_column, "rows": rows_data, "merged_cells": merged_ranges } }) except Exception as e: return jsonify({"code": 500, "msg": f"读取模板失败:{str(e)}"}), 500 @contract_bp.route('/api/contract/template/preview_page', methods=['GET']) def contract_template_preview_page(): """Preview contract template with SheetJS (like web Excel)""" tid = request.args.get('id', '').strip() if not tid or not tid.isdigit(): return '

\u53c2\u6570\u9519\u8bef

', 400 rows = query("SELECT * FROM contract_templates WHERE id=%s", (int(tid),)) if not rows: return '

\u6a21\u677f\u4e0d\u5b58\u5728

', 400 t = rows[0] # Load template HTML and fill placeholders template_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates', 'contract_preview.html') try: with open(template_path, 'r', encoding='utf-8') as f: html = f.read() except Exception: return '

\u6a21\u677f\u9875\u52a0\u8f7d\u5931\u8d25

', 500 name_esc = str(t['name']).replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') fname_esc = str(t.get('file_name', '') or '\u672a\u77e5').replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') upload_time = str(t.get('created_at', '') or '\u672a\u77e5') dl_url = f'/api/contract/template/download?id={tid}' html = html.replace('{{TITLE}}', name_esc) html = html.replace('{{FILENAME}}', fname_esc) html = html.replace('{{UPLOAD_TIME}}', upload_time) html = html.replace('{{DOWNLOAD_URL}}', dl_url) return html, 200, {'Content-Type': 'text/html; charset=utf-8'} @contract_bp.route('/api/contract/template/upload', methods=['POST']) @login_required def contract_template_upload(): """上传合同模板""" # 上传属于“编辑上传”操作权限,支持角色权限与用户级单独拒绝。 if not operation_required('contract_templates', 'edit'): return jsonify({"code": 403, "msg": "当前账号没有上传合同模板权限"}) name = request.form.get('name', '').strip() order_type = request.form.get('order_type', 'all').strip() supplier_id = request.form.get('supplier_id', '').strip() file = request.files.get('file') if not name: return jsonify({"code": 400, "msg": "模板名称不能为空"}) if not file or not file.filename: return jsonify({"code": 400, "msg": "请选择文件"}) if not file.filename.endswith('.xlsx'): return jsonify({"code": 400, "msg": "仅支持 .xlsx 格式"}) # 如果绑定供应商,检查是否已有绑定模板 if supplier_id and supplier_id.isdigit(): supplier_id = int(supplier_id) existing = query("SELECT id, name FROM contract_templates WHERE supplier_id=%s", (supplier_id,)) if existing: return jsonify({"code": 400, "msg": f"该供应商已绑定模板「{existing[0]['name']}」,请先删除或解绑后再操作"}) else: supplier_id = None # 保存文件 ext = 'xlsx' safe_name = f"{uuid.uuid4().hex}.{ext}" filepath = os.path.join(TEMPLATE_FOLDER, safe_name) file.save(filepath) now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') query( "INSERT INTO contract_templates (name, file_path, file_name, order_type, supplier_id, created_at, updated_at) " "VALUES (%s, %s, %s, %s, %s, %s, %s)", (name, f'/static/uploads/contract_templates/{safe_name}', file.filename, order_type, supplier_id, now, now) ) return jsonify({"code": 0, "msg": "上传成功"}) @contract_bp.route('/api/contract/template/delete', methods=['POST']) @login_required def contract_template_delete(): """删除合同模板""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json tid = data.get('id') if not tid: return jsonify({"code": 400, "msg": "参数错误"}) rows = query("SELECT file_path FROM contract_templates WHERE id=%s", (tid,)) if rows: filepath = os.path.join(TEMPLATE_FOLDER, os.path.basename(rows[0]['file_path'])) if os.path.exists(filepath): os.remove(filepath) query("DELETE FROM contract_templates WHERE id=%s", (tid,)) return jsonify({"code": 0, "msg": "已删除"}) @contract_bp.route('/api/contract/template/update', methods=['POST']) @login_required def contract_template_update(): """编辑合同模板(名称/适用类型/绑定供应商/更换文件)""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) tid = request.form.get('id', '').strip() name = request.form.get('name', '').strip() order_type = request.form.get('order_type', 'all').strip() supplier_id_raw = request.form.get('supplier_id', '').strip() file = request.files.get('file') if not tid or not tid.isdigit(): return jsonify({"code": 400, "msg": "参数错误"}) tid = int(tid) if not name: return jsonify({"code": 400, "msg": "模板名称不能为空"}) # 检查模板是否存在 old = query("SELECT * FROM contract_templates WHERE id=%s", (tid,)) if not old: return jsonify({"code": 400, "msg": "模板不存在"}) old = old[0] # 处理绑定供应商 new_supplier_id = None if supplier_id_raw and supplier_id_raw.isdigit(): new_supplier_id = int(supplier_id_raw) # 检查该供应商是否已被其他模板绑定 conflict = query( "SELECT id, name FROM contract_templates WHERE supplier_id=%s AND id!=%s LIMIT 1", (new_supplier_id, tid) ) if conflict: return jsonify({"code": 400, "msg": f"该供应商已绑定模板「{conflict[0]['name']}」,请先解绑"}) # 处理文件更换 file_path = old['file_path'] file_name = old['file_name'] if file and file.filename: if not file.filename.endswith('.xlsx'): return jsonify({"code": 400, "msg": "仅支持 .xlsx 格式"}) ext = 'xlsx' safe_name = f"{uuid.uuid4().hex}.{ext}" new_filepath = os.path.join(TEMPLATE_FOLDER, safe_name) file.save(new_filepath) file_path = f'/static/uploads/contract_templates/{safe_name}' file_name = file.filename # 删除旧文件 old_file = os.path.join(TEMPLATE_FOLDER, os.path.basename(old['file_path'])) if os.path.exists(old_file): os.remove(old_file) now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') query( "UPDATE contract_templates SET name=%s, order_type=%s, supplier_id=%s, file_path=%s, file_name=%s, updated_at=%s WHERE id=%s", (name, order_type, new_supplier_id, file_path, file_name, now, tid) ) return jsonify({"code": 0, "msg": "已更新"}) # ─── 合同生成 ─── def _get_order_data(order_ids, order_type): """根据订单类型和ID获取订单数据""" if order_type == 'domestic': table = 'domestic_orders' elif order_type == 'overseas': table = 'overseas_orders' elif order_type == 'packaging': table = 'packaging_orders' else: return [] # 使用参数化查询构造安全的IN子句 # 海外和包材表使用 供应商 字段名,国内表使用 供货商 supplier_col = '`供应商`' if order_type in ('overseas', 'packaging') else '`供货商`' placeholders = ','.join(['%s'] * len(order_ids)) rows = query(f"SELECT * FROM {table} WHERE id IN ({placeholders}) ORDER BY {supplier_col} ASC, id ASC", order_ids) # 统一字段名(海外字段名不同:产品编码/货品名称,包材金额字段名不同:合计) result = [] for r in rows: result.append({ 'order_id': r.get('id'), '编码': r.get('货品编号', '') or r.get('编码', '') or r.get('产品编码', ''), '名称': r.get('货品名称', '') or r.get('名称', ''), '规格': r.get('规格', '') or r.get('包装规格', ''), '数量': r.get('数量', 0) or 0, '单价': r.get('单价', 0) or 0, '金额': r.get('金额', 0) or r.get('合计', 0) or 0, '供货商': r.get('供货商', '') or r.get('供应商', ''), '备注': r.get('备注', '') or r.get('下单备注', ''), }) return result def _group_by_supplier(orders): """按供货商分组订单""" groups = {} for o in orders: sup = o['供货商'] or '未指定' if sup not in groups: groups[sup] = [] groups[sup].append(o) return groups def _detect_column_map(ws, header_row_num): """智能检测模板表头各列对应的数据字段""" # 字段关键词库:header关键词 → 数据字段名 FIELD_KEYWORDS = [ (['编码', '编号', 'code', 'sku'], '编码'), (['名称', '名字', '品名', 'name', 'product'], '名称'), (['规格', '型号', '包装', 'spec', 'package'], '规格'), (['数量', '订购数', 'qty', 'quantity', '订货'], '数量'), (['单价', '价格', 'unit', 'price'], '单价'), (['金额', '合计', '总价', '小计', 'amount', 'total', 'sum'], '金额'), (['备注', '说明', 'remark', 'note', '备注说明'], '备注'), ] col_map = {} # {column_index: field_name} for col in range(1, ws.max_column + 1): cell = ws.cell(row=header_row_num, column=col) if not cell.value: continue header_text = str(cell.value).strip() for keywords, field in FIELD_KEYWORDS: if any(kw in header_text for kw in keywords): col_map[col] = field break return col_map def _find_header_and_data_rows(ws): """找到表头行和数据起始行""" # 优先找 {{products_table}} 标记 for row in ws.iter_rows(min_row=1, max_row=min(ws.max_row, 50), max_col=ws.max_column): for cell in row: if cell.value and isinstance(cell.value, str) and '{{products_table}}' in cell.value: cell.value = '' # 清除标记 # 标记所在行就是表头行,数据从下一行开始 return cell.row, cell.row + 1 # 其次:扫描表头行(包含编码+名称等字段标识的行) # 要求:至少2个不同单元格各自包含关键词(排除所有关键词挤在一个单元格的段落行) for r in range(1, min(ws.max_row + 1, 20)): row_keywords = [] has_section_marker = False for col in range(1, ws.max_column + 1): v = ws.cell(row=r, column=col).value if v and isinstance(v, str): val = v.strip() # 跳过章节标题行(一、二、三...) if any(val.startswith(m) for m in ['一、', '二、', '三、', '四、', '五、', '六、', '七、', '八、', '九、', '十、']): has_section_marker = True break # 检查单个单元格是否包含关键词 for kw in ['编码', '编号', '产品', '品名', '名称', '数量', '单价', '金额', '规格', '备注']: if kw in val: row_keywords.append(kw) break if has_section_marker: continue # 至少2个不同单元格各自包含关键词 if len(row_keywords) >= 2 and r < ws.max_row: # 检查下一行是数据行(有数值或编码) next_v = ws.cell(row=r + 1, column=1).value if next_v and not ('合计' in str(next_v) or '大写' in str(next_v) or '{{' in str(next_v)): return r, r + 1 return None, None def _generate_contract(template_path, supplier, products, output_path, contract_no=None): """生成一份合同 Excel(固定格式 + 关键词替换)""" wb = openpyxl.load_workbook(template_path) ws = wb.active # 计算合计数量和合计金额 total = sum((float(p.get('数量', 0)) or 0) * (float(p.get('单价', 0)) or 0) for p in products) total_qty = sum(int(float(p.get('数量', 0)) or 0) for p in products) now_str = datetime.now().strftime('%Y年%m月%d日') now_date = datetime.now().strftime('%Y年%m月%d日') contract_no = contract_no or f"CGHTDY-JC-{datetime.now().strftime('%Y%m%d')}" # ── 第一步:修复模板中的特殊符号字符(如 Excel 特殊字体符号)── _NORMALIZE_CHARS = str.maketrans({ '\u00fe': '\u2611', # þ (thorn) → ☑ (checked checkbox, Wingdings 映射) }) for row in ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=ws.max_column): for cell in row: if cell.value and isinstance(cell.value, str): new_val = cell.value.translate(_NORMALIZE_CHARS) if new_val != cell.value: cell.value = new_val # ── 第二步:替换固定单元格(不依赖占位符,直接写入) ── ws['A2'] = f"合同编号:{contract_no}" ws['D2'] = f"日期:{now_date}" # D3 写入供应商全称(保留乙方:前缀) d3_orig = ws['D3'].value if d3_orig and isinstance(d3_orig, str) and '乙方' in d3_orig: if ':' in str(d3_orig): ws['D3'] = str(d3_orig).split(':')[0] + ':' + supplier elif ':' in str(d3_orig): ws['D3'] = str(d3_orig).split(':')[0] + ':' + supplier else: ws['D3'] = '乙方:' + supplier else: ws['D3'] = '乙方:' + supplier # ── 第三步:关键词搜索替换(乙方/签字日期等可变位置) ── for row in ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=ws.max_column): for cell in row: if cell.value and isinstance(cell.value, str): val = cell.value # 乙方盖章行:替换冒号后的内容为供应商全称 if '乙方' in val and ('盖章' in val or '签章' in val): if ':' in val: prefix = val.split(':')[0] + ':' cell.value = prefix + supplier elif ':' in val: prefix = val.split(':')[0] + ':' cell.value = prefix + supplier elif '乙方' in val: # 如果只有"乙方"没有冒号,也替换 cell.value = supplier # 签字日期行:保留前缀,替换日期 if '签字日期' in val: if ':' in val: cell.value = val.split(':')[0] + ':' + now_date elif ':' in val: cell.value = val.split(':')[0] + ':' + now_date # 占位符替换(兼容旧模板) val2 = cell.value if isinstance(cell.value, str) else '' if val2: val2 = val2.replace('{{contract_no}}', contract_no) val2 = val2.replace('{{date}}', now_date) val2 = val2.replace('{{supplier_name}}', supplier) val2 = val2.replace('{{buyer_name}}', '深圳市蝶雅云联信息技术有限公司') val2 = val2.replace('{{total_amount}}', str(round(total, 2))) val2 = val2.replace('{{total_cn}}', _num_to_cn(total)) if val2 != cell.value: cell.value = val2 # ── 第四步:检测表头行和数据起始行 ── header_row, data_row = _find_header_and_data_rows(ws) if header_row is None: for r in range(1, ws.max_row + 1): for col in range(1, ws.max_column + 1): v = ws.cell(row=r, column=col).value if v and isinstance(v, str) and '{{products_table}}' in v: header_row = r data_row = r + 1 ws.cell(row=r, column=col).value = '' break if header_row: break if header_row is None or data_row is None or data_row > ws.max_row: data_row = 7 header_row = data_row - 1 # ── 第五步:固定列映射(旭东指定规则) ── # A=编码, B=名称, C=空, D=数量, E=单价, F=金额 # 先用智能检测看看有没有匹配到,再覆盖为固定规则 col_map = _detect_column_map(ws, header_row) # 最终使用固定列映射(覆盖智能检测的结果) col_map = {1: '编码', 2: '名称', 3: None, 4: '数量', 5: '单价', 6: '金额', 7: '备注'} # 获取模板样例行 sample_cells = {} if data_row <= ws.max_row: for col in range(1, ws.max_column + 1): sc = ws.cell(row=data_row, column=col) sample_cells[col] = { 'value': sc.value, 'font': copy(sc.font), 'alignment': copy(sc.alignment), 'border': copy(sc.border), 'fill': copy(sc.fill), 'number_format': sc.number_format, } num_products = len(products) if num_products == 0: num_products = 1 insert_count = num_products - 1 # ── 第八步:手动移位行内容(避免 openpyxl insert_rows 破坏合并单元格)── # 先记录从 data_row(含)到 max_row 的所有合并单元格范围 saved_merges = [] for m in ws.merged_cells.ranges: if m.min_row >= data_row: saved_merges.append({ 'min_row': m.min_row, 'max_row': m.max_row, 'min_col': m.min_col, 'max_col': m.max_col }) # 记录非主合并单元格位置(写入时跳过) skip_cells = set() for m in saved_merges: for rr in range(m['min_row'], m['max_row'] + 1): for cc in range(m['min_col'], m['max_col'] + 1): if rr != m['min_row'] or cc != m['min_col']: skip_cells.add((rr, cc)) # 保存 data_row 到 max_row 的所有单元格值 + 样式 saved_cells = {} from openpyxl.styles import Border, Side, Alignment for r in range(data_row, ws.max_row + 1): for c in range(1, ws.max_column + 1): if (r, c) in skip_cells: continue cell = ws.cell(row=r, column=c) v = cell.value if v is not None: saved_cells[(r, c)] = { 'value': copy(v), 'border': copy(cell.border), 'alignment': copy(cell.alignment), } # 解除 data_row 及之后的合并单元格 for m in list(ws.merged_cells.ranges): if m.min_row >= data_row: ws.unmerge_cells(str(m)) # 清空 data_row 到 max_row 的内容 for r in range(data_row, ws.max_row + 1): for c in range(1, ws.max_column + 1): cell = ws.cell(row=r, column=c) cell.value = None # 将保存的内容下移 insert_count 行(从后往前写,避免覆盖),恢复样式 thin_side = Side(style='thin') for (old_r, old_c), data in sorted(saved_cells.items(), key=lambda x: -x[0][0]): new_r = old_r + insert_count new_cell = ws.cell(row=new_r, column=old_c) new_cell.value = data['value'] # 恢复边框和对齐样式 if data.get('border'): new_cell.border = data['border'] if data.get('alignment'): new_cell.alignment = data['alignment'] # 重新应用合并单元格(下移相同偏移量) for m in saved_merges: ws.merge_cells( start_row=m['min_row'] + insert_count, start_column=m['min_col'], end_row=m['max_row'] + insert_count, end_column=m['max_col'] ) # 合并 "以上含税价合计:" 的 A-C 列,设置居中 + 细边框 total_row = data_row + num_products try: ws.merge_cells(start_row=total_row, start_column=1, end_row=total_row, end_column=3) except Exception: pass # 合计行整体加细边框 + 居中(仅 A-G 列,不超过 7) TABLE_COLS = 7 for cc in range(1, TABLE_COLS + 1): cell = ws.cell(row=total_row, column=cc) cell.border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side) cell.alignment = Alignment(horizontal='center', vertical='center') # ── 第九步:填充产品数据 ── for i, prod in enumerate(products): r = data_row + i # A列:编码 cell_a = ws.cell(row=r, column=1) cell_a.value = prod.get('编码', '') if 1 in sample_cells: _copy_style(cell_a, sample_cells[1]) # B列:名称 cell_b = ws.cell(row=r, column=2) cell_b.value = prod.get('名称', '') if 2 in sample_cells: _copy_style(cell_b, sample_cells[2]) # C列:留空 cell_c = ws.cell(row=r, column=3) cell_c.value = '' if 3 in sample_cells: _copy_style(cell_c, sample_cells[3]) # D列:数量(用 thin 边框,不做样式复制,确保有边框) cell_d = ws.cell(row=r, column=4) cell_d.value = int(float(prod.get('数量', 0)) or 0) cell_d.border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side) cell_d.alignment = Alignment(horizontal='center', vertical='center') # E列:单价 cell_e = ws.cell(row=r, column=5) cell_e.value = float(prod.get('单价', 0)) or 0 if 5 in sample_cells: _copy_style(cell_e, sample_cells[5]) # F列:金额 = E*D(公式) cell_f = ws.cell(row=r, column=6) cell_f.value = f"=E{r}*D{r}" if 6 in sample_cells: _copy_style(cell_f, sample_cells[6]) # G列:备注(固定为空) cell_g = ws.cell(row=r, column=7) cell_g.value = '' if 7 in sample_cells: _copy_style(cell_g, sample_cells[7]) # ── 第十步:更新合计行 ── if total_row <= ws.max_row: d_letter = get_column_letter(4) ws.cell(row=total_row, column=4).value = f"=SUM({d_letter}{data_row}:{d_letter}{data_row + num_products - 1})" f_letter = get_column_letter(6) ws.cell(row=total_row, column=6).value = f"=SUM({f_letter}{data_row}:{f_letter}{data_row + num_products - 1})" # 中文大写行 cn_row = total_row + 1 if cn_row <= ws.max_row: has_da = '大写' in str(ws.cell(row=cn_row, column=1).value or '') has_db = '大写' in str(ws.cell(row=cn_row, column=2).value or '') if has_da or has_db: ws.cell(row=cn_row, column=2).value = _num_to_cn(total) # ── 第十一步:表格美化(统一边框 + 统一行高 + 清除多余列)── TABLE_COLS = 7 # A-G列,模板表格只用到这些列 table_end = min(total_row + 1, ws.max_row) # 统一产品表格区域行高(表头到大写行) row_height = 28 for r in range(header_row, table_end + 1): ws.row_dimensions[r].height = row_height for r in range(header_row, table_end + 1): for c in range(1, max(ws.max_column, 9) + 1): cell = ws.cell(row=r, column=c) if c <= TABLE_COLS: # A-G列确保有 thin 边框(大写行 G 列补上,合计行已加) if not cell.border.left.style and not cell.border.right.style: cell.border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side) cell.alignment = Alignment(horizontal='center', vertical='center') else: # H列及以后清除边框 cell.border = Border() cell.value = None # ── 第十二步:调整列宽,使内容完全显示(仅扫描产品表格区域)── for col_idx in range(1, TABLE_COLS + 1): max_len = 0 col_letter = get_column_letter(col_idx) # 扫描表头行 + 产品数据行 + 合计行(不含合同段落文本) scan_start = header_row scan_end = total_row for row_idx in range(scan_start, scan_end + 1): cell = ws.cell(row=row_idx, column=col_idx) if cell.value: val = str(cell.value) length = sum(2 if ord(c) > 127 else 1 for c in val) if length > max_len: max_len = length if max_len > 0: ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 60) # ── 第十二步:保存 ── wb.save(output_path) wb.close() return contract_no def _copy_style(cell, sample): """复制单元格样式""" if sample.get('font'): cell.font = copy(sample['font']) if sample.get('alignment'): cell.alignment = copy(sample['alignment']) if sample.get('border'): cell.border = copy(sample['border']) if sample.get('fill'): cell.fill = copy(sample['fill']) if sample.get('number_format'): cell.number_format = sample['number_format'] # ─── 供货商分组查询 API(前端预览用)─── @contract_bp.route('/api/contract/supplier_groups', methods=['POST']) @login_required def contract_supplier_groups(): """查询选中订单的供货商分组信息(预览合同份数 + 模板匹配情况)""" data = request.json order_ids = data.get('order_ids', []) order_type = data.get('order_type', 'domestic') if not order_ids: return jsonify({"code": 400, "msg": "请选择订单"}) order_ids = [int(i) for i in order_ids if str(i).isdigit()] if not order_ids: return jsonify({"code": 400, "msg": "无效的订单ID"}) orders = _get_order_data(order_ids, order_type) groups = _group_by_supplier(orders) # 构建供应商名称→ID映射 supplier_names = list(groups.keys()) placeholders = ','.join(['%s'] * len(supplier_names)) supplier_rows = query(f"SELECT id, supplier_name FROM suppliers WHERE supplier_name IN ({placeholders})", supplier_names) sup_name_to_id = {} for row in supplier_rows: name = (row['supplier_name'] or '').strip() if name: sup_name_to_id[name] = row['id'] # 模糊补漏 for sname in supplier_names: if sname not in sup_name_to_id: for row in supplier_rows: dbname = (row['supplier_name'] or '').strip() if dbname and (dbname in sname or sname in dbname): sup_name_to_id[sname] = row['id'] break # 通用模板 universal = query( "SELECT id, name FROM contract_templates WHERE supplier_id IS NULL AND order_type IN (%s, 'all') ORDER BY id DESC LIMIT 1", (order_type,) ) result = [] for sup, products in groups.items(): sup_id = sup_name_to_id.get(sup) bound_template = None if sup_id: bt = query( "SELECT id, name FROM contract_templates WHERE supplier_id=%s LIMIT 1", (sup_id,) ) if bt: bound_template = {'id': bt[0]['id'], 'name': bt[0]['name']} result.append({ 'supplier': sup, 'supplier_id': sup_id, 'count': len(products), 'template_type': 'bound' if bound_template else ('universal' if universal else 'none'), 'bound_template': bound_template, 'universal_template_name': (universal[0]['name'] if universal else None) if not bound_template else None }) return jsonify({"code": 0, "data": result}) # ─── 合同台账 API ─── LEDGER_STATUSES = ('已生成', '待签署', '已签署', '执行中', '已完成', '已终止', '已作废') def _ledger_record(record_id): ensure_ledger_schema() rows = query("SELECT * FROM contract_records WHERE id=%s AND is_deleted=0", (record_id,)) return rows[0] if rows else None def _ledger_row(row): result = dict(row) for key in ('created_at', 'updated_at'): if result.get(key) and hasattr(result[key], 'strftime'): result[key] = result[key].strftime('%Y-%m-%d %H:%M:%S') elif result.get(key) is not None: result[key] = str(result[key]) for key in ('signed_at', 'effective_date', 'expiry_date'): if result.get(key) and hasattr(result[key], 'isoformat'): result[key] = result[key].isoformat() elif result.get(key) is not None: result[key] = str(result[key])[:10] result['total_amount'] = float(result.get('total_amount') or 0) result['order_ids'] = parse_json(result.get('order_ids_json'), []) result['orders'] = parse_json(result.get('orders_json'), []) return result @contract_bp.route('/api/contract/ledger/list') @login_required def contract_ledger_list(): if not operation_required('contract_templates', 'view'): return jsonify({'code': 403, 'msg': '无合同台账查看权限'}), 403 ensure_ledger_schema() try: page = max(1, int(request.args.get('page', 1))) size = min(100, max(1, int(request.args.get('size', 20)))) except (TypeError, ValueError): page, size = 1, 20 search = (request.args.get('search') or '').strip() status = (request.args.get('status') or '').strip() order_type = (request.args.get('order_type') or '').strip() date_from = (request.args.get('date_from') or '').strip() date_to = (request.args.get('date_to') or '').strip() where = ['is_deleted=0'] params = [] if search: where.append('(contract_no LIKE %s OR supplier_name LIKE %s OR template_name LIKE %s)') params.extend([f'%{search}%'] * 3) if status: where.append('status=%s') params.append(status) if order_type: where.append('order_type=%s') params.append(order_type) if date_from: where.append('created_at >= %s') params.append(f'{date_from} 00:00:00') if date_to: where.append('created_at <= %s') params.append(f'{date_to} 23:59:59') where_sql = ' AND '.join(where) total = query(f"SELECT COUNT(*) AS total FROM contract_records WHERE {where_sql}", params)[0]['total'] rows = query( f"SELECT * FROM contract_records WHERE {where_sql} " "ORDER BY created_at DESC, id DESC LIMIT %s OFFSET %s", params + [size, (page - 1) * size], ) from datetime import date, timedelta today = date.today() data = [] for row in rows: item = _ledger_row(row) item['file_count'] = query("SELECT COUNT(*) AS n FROM contract_files WHERE contract_id=%s", (row['id'],))[0]['n'] expiry = row.get('expiry_date') item['expiring_soon'] = bool(expiry and today <= expiry <= today + timedelta(days=30)) data.append(item) return jsonify({'code': 0, 'data': {'rows': data, 'total': int(total), 'page': page, 'size': size}}) @contract_bp.route('/api/contract/ledger/detail') @login_required def contract_ledger_detail(): if not operation_required('contract_templates', 'view'): return jsonify({'code': 403, 'msg': '无合同台账查看权限'}), 403 record_id = request.args.get('id', type=int) row = _ledger_record(record_id) if record_id else None if not row: return jsonify({'code': 404, 'msg': '合同台账记录不存在'}), 404 item = _ledger_row(row) files = query( "SELECT id, version, file_path, file_name, generated_at, generated_by_name, is_current, remark " "FROM contract_files WHERE contract_id=%s ORDER BY version DESC, id DESC", (record_id,) ) for file in files: if file.get('generated_at') and hasattr(file['generated_at'], 'strftime'): file['generated_at'] = file['generated_at'].strftime('%Y-%m-%d %H:%M:%S') file['is_current'] = int(file.get('is_current') or 0) events = query( "SELECT id, action, detail, user_name, created_at FROM contract_events " "WHERE contract_id=%s ORDER BY created_at DESC, id DESC LIMIT 100", (record_id,) ) for event in events: if event.get('created_at') and hasattr(event['created_at'], 'strftime'): event['created_at'] = event['created_at'].strftime('%Y-%m-%d %H:%M:%S') return jsonify({'code': 0, 'data': {'record': item, 'files': files, 'events': events}}) @contract_bp.route('/api/contract/ledger/update', methods=['POST']) @login_required def contract_ledger_update(): if not operation_required('contract_templates', 'edit'): return jsonify({'code': 403, 'msg': '无合同台账编辑权限'}), 403 data = request.get_json(silent=True) or {} record_id = data.get('id') row = _ledger_record(record_id) if str(record_id or '').isdigit() else None if not row: return jsonify({'code': 404, 'msg': '合同台账记录不存在'}), 404 status = (data.get('status') or row.get('status') or '已生成').strip() if status not in LEDGER_STATUSES: return jsonify({'code': 400, 'msg': '合同状态无效'}), 400 fields = { 'status': status, 'signer': (data.get('signer') or '').strip(), 'signed_at': data.get('signed_at') or None, 'effective_date': data.get('effective_date') or None, 'expiry_date': data.get('expiry_date') or None, 'payment_terms': (data.get('payment_terms') or '').strip(), 'delivery_terms': (data.get('delivery_terms') or '').strip(), 'remark': (data.get('remark') or '').strip(), } operator_id = session.get('user_id') operator_name = session.get('real_name') or session.get('username') or '' query( """UPDATE contract_records SET status=%s, signer=%s, signed_at=%s, effective_date=%s, expiry_date=%s, payment_terms=%s, delivery_terms=%s, remark=%s, updated_by=%s, updated_by_name=%s WHERE id=%s""", (fields['status'], fields['signer'], fields['signed_at'], fields['effective_date'], fields['expiry_date'], fields['payment_terms'], fields['delivery_terms'], fields['remark'], operator_id, operator_name, record_id), ) changes = ';'.join(f'{key}:{row.get(key) or "-"} → {fields[key] or "-"}' for key in fields if str(row.get(key) or '') != str(fields[key] or '')) add_event(record_id, '编辑合同台账', changes or '保存合同信息', operator_id, operator_name) add_operation_log('编辑合同台账', f'{row.get("contract_no")} {changes}', page='合同台账管理') return jsonify({'code': 0, 'msg': '合同台账已保存'}) @contract_bp.route('/api/contract/ledger/delete', methods=['POST']) @login_required def contract_ledger_delete(): if not operation_required('contract_templates', 'delete'): return jsonify({'code': 403, 'msg': '无合同台账删除权限'}), 403 data = request.get_json(silent=True) or {} record_id = data.get('id') row = _ledger_record(record_id) if str(record_id or '').isdigit() else None if not row: return jsonify({'code': 404, 'msg': '合同台账记录不存在'}), 404 operator_id = session.get('user_id') operator_name = session.get('real_name') or session.get('username') or '' query("UPDATE contract_records SET is_deleted=1, status='已作废', updated_by=%s, updated_by_name=%s WHERE id=%s", (operator_id, operator_name, record_id)) add_event(record_id, '作废合同', '合同台账记录已作废,文件保留', operator_id, operator_name) add_operation_log('作废合同', f'{row.get("contract_no")} {row.get("supplier_name")}', page='合同台账管理') return jsonify({'code': 0, 'msg': '合同已作废'}) @contract_bp.route('/api/contract/ledger/file') @login_required def contract_ledger_file(): if not operation_required('contract_templates', 'view'): return jsonify({'code': 403, 'msg': '无合同台账查看权限'}), 403 record_id = request.args.get('id', type=int) file_id = request.args.get('file_id', type=int) row = _ledger_record(record_id) if record_id else None if not row: return jsonify({'code': 404, 'msg': '合同台账记录不存在'}), 404 file_row = None if file_id: found = query("SELECT * FROM contract_files WHERE id=%s AND contract_id=%s", (file_id, record_id)) file_row = found[0] if found else None if file_row: file_path, file_name = file_row['file_path'], file_row['file_name'] else: file_path, file_name = row.get('current_file_path'), row.get('current_file_name') if not file_path: return jsonify({'code': 404, 'msg': '合同文件不存在'}), 404 safe_name = os.path.basename(file_path) actual_path = os.path.join(OUTPUT_FOLDER, safe_name) if not os.path.isfile(actual_path): return jsonify({'code': 404, 'msg': '合同文件不存在'}), 404 return send_file(actual_path, as_attachment=True, download_name=file_name or safe_name) @contract_bp.route('/api/contract/ledger/regenerate', methods=['POST']) @login_required def contract_ledger_regenerate(): if not operation_required('contract_templates', 'edit'): return jsonify({'code': 403, 'msg': '无合同台账编辑权限'}), 403 data = request.get_json(silent=True) or {} record_id = data.get('id') row = _ledger_record(record_id) if str(record_id or '').isdigit() else None if not row: return jsonify({'code': 404, 'msg': '合同台账记录不存在'}), 404 template = query("SELECT * FROM contract_templates WHERE id=%s", (row.get('template_id'),)) if not template: return jsonify({'code': 400, 'msg': '原模板已不存在,无法重新生成'}), 400 orders = parse_json(row.get('orders_json'), []) output_name = f"采购合同_{row['supplier_name']}_{datetime.now():%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:4]}.xlsx" output_path = os.path.join(OUTPUT_FOLDER, output_name) _generate_contract(os.path.join(TEMPLATE_FOLDER, os.path.basename(template[0]['file_path'])), row['supplier_name'], orders, output_path, contract_no=row['contract_no']) version = (query("SELECT COALESCE(MAX(version),0) AS v FROM contract_files WHERE contract_id=%s", (record_id,))[0]['v'] or 0) + 1 operator_id = session.get('user_id') operator_name = session.get('real_name') or session.get('username') or '' file_path = f'/static/uploads/contract_output/{output_name}' add_file(record_id, version, file_path, output_name, operator_id, operator_name, '重新生成') query("UPDATE contract_records SET current_file_path=%s, current_file_name=%s, updated_by=%s, updated_by_name=%s WHERE id=%s", (file_path, output_name, operator_id, operator_name, record_id)) add_event(record_id, '重新生成合同', f'生成第 {version} 版文件', operator_id, operator_name) add_operation_log('重新生成合同', f'{row.get("contract_no")} 第 {version} 版', page='合同台账管理') return jsonify({'code': 0, 'msg': f'已生成第 {version} 版', 'data': {'version': version, 'file_path': file_path, 'file_name': output_name}}) # ─── 生成合同 API ─── @contract_bp.route('/api/contract/generate', methods=['POST']) @login_required def contract_generate(): """根据选中的订单生成合同(按供货商分组,自动匹配绑定模板)""" data = request.json order_ids = data.get('order_ids', []) order_type = data.get('order_type', 'domestic') template_id = data.get('template_id') permission_page = { 'domestic': 'orders_domestic', 'overseas': 'orders_overseas', 'packaging': 'orders_packaging', }.get(order_type) if not permission_page: return jsonify({"code": 400, "msg": "订单类型错误"}) if not operation_required_compat(permission_page, 'batch_contract', 'edit'): return jsonify({"code": 403, "msg": "权限不足,当前账号没有批量生成合同权限"}) if not order_ids: return jsonify({"code": 400, "msg": "请选择订单"}) order_ids = [int(i) for i in order_ids if str(i).isdigit()] if not order_ids: return jsonify({"code": 400, "msg": "无效的订单ID"}) # 获取订单数据 orders = _get_order_data(order_ids, order_type) if not orders: return jsonify({"code": 400, "msg": "未找到订单数据"}) # 按供货商分组 groups = _group_by_supplier(orders) # 准备一份供应商ID对照表(供货商名称 → 供应商ID) supplier_names = list(groups.keys()) placeholders = ','.join(['%s'] * len(supplier_names)) supplier_rows = query( f"SELECT id, supplier_name, short_name, contract_short_name " f"FROM suppliers WHERE supplier_name IN ({placeholders})", supplier_names, ) # 构建名称→ID映射(模糊匹配,取第一个匹配到的) sup_name_to_id = {} sup_name_to_contract_short = {} for row in supplier_rows: name = (row['supplier_name'] or '').strip() if name: sup_name_to_id[name] = row['id'] sup_name_to_contract_short[name] = (row.get('contract_short_name') or row.get('short_name') or '').strip() # 对未匹配上的,尝试模糊查找 for sname in supplier_names: if sname not in sup_name_to_id: for row in supplier_rows: dbname = (row['supplier_name'] or '').strip() if dbname and (dbname in sname or sname in dbname): sup_name_to_id[sname] = row['id'] sup_name_to_contract_short[sname] = (row.get('contract_short_name') or row.get('short_name') or '').strip() break # 如果指定了 template_id,对所有组统一使用该模板 if template_id: templates_list = query("SELECT * FROM contract_templates WHERE id=%s", (template_id,)) if not templates_list: return jsonify({"code": 400, "msg": "指定的模板不存在"}) template_map = {s: templates_list[0] for s in groups.keys()} template_source = 'manual' else: # 自动匹配:每个供货商先查绑定模板,没有则用通用模板 # 预加载所有通用模板 universal_templates = query( "SELECT * FROM contract_templates WHERE supplier_id IS NULL AND order_type IN (%s, 'all') ORDER BY id DESC", (order_type,) ) template_map = {} for sname in groups.keys(): sup_id = sup_name_to_id.get(sname) if sup_id: bound = query( "SELECT * FROM contract_templates WHERE supplier_id=%s ORDER BY id DESC LIMIT 1", (sup_id,) ) if bound: template_map[sname] = bound[0] continue # 没有绑定模板,用通用模板 if universal_templates: template_map[sname] = universal_templates[0] template_source = 'auto' if not template_map: return jsonify({"code": 400, "msg": "未找到任何可用的合同模板,请先上传"}) ensure_ledger_schema() operator_id = session.get('user_id') operator_name = session.get('real_name') or session.get('username') or '' # 生成合同 generated = [] for supplier, products in groups.items(): tmpl = template_map.get(supplier) if not tmpl: continue template_path = os.path.join(TEMPLATE_FOLDER, os.path.basename(tmpl['file_path'])) if not os.path.exists(template_path): generated.append({ 'supplier': supplier, 'product_count': len(products), 'contract_no': None, 'file_path': None, 'file_name': None, 'error': f"模板文件不存在:{tmpl['name']}" }) continue output_name = f"采购合同_{supplier}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:4]}.xlsx" output_path = os.path.join(OUTPUT_FOLDER, output_name) contract_no = new_contract_no(order_type, sup_name_to_contract_short.get(supplier)) # 重新计算金额 = 数量 * 单价,保持与合同模板内一致 for p in products: p['金额'] = (float(p.get('数量', 0)) or 0) * (float(p.get('单价', 0)) or 0) contract_no = _generate_contract(template_path, supplier, products, output_path, contract_no=contract_no) # 用 数量*单价 计算总金额,确保与合同内一致 total_amount = sum(float(p['金额']) for p in products) generated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') order_ids = list(dict.fromkeys( int(p['order_id']) for p in products if str(p.get('order_id') or '').isdigit() )) file_path = f'/static/uploads/contract_output/{output_name}' contract_id = create_record( contract_no=contract_no, supplier_id=sup_name_to_id.get(supplier), supplier_name=supplier, buyer_name='深圳市蝶雅云联信息技术有限公司', order_type=order_type, template_id=tmpl.get('id'), template_name=tmpl.get('name', ''), order_count=len(order_ids), product_count=len(products), total_amount=total_amount, status='已生成', order_ids=order_ids, orders=products, current_file_path=file_path, current_file_name=output_name, created_by=operator_id, created_by_name=operator_name, ) add_file(contract_id, 1, file_path, output_name, operator_id, operator_name) add_event(contract_id, '生成合同', f'使用模板「{tmpl.get("name", "")}」生成 {len(products)} 个货品', operator_id, operator_name) generated.append({ 'contract_id': contract_id, 'supplier': supplier, 'product_count': len(products), 'contract_no': contract_no, 'total_amount': total_amount, 'generated_at': generated_at, 'file_path': file_path, 'file_name': output_name, 'template_name': tmpl['name'], 'bound': tmpl.get('supplier_id') is not None }) return jsonify({ "code": 0, "msg": f"已生成 {len(generated)} 份合同", "data": generated, "template_source": template_source })