"""合同模板管理 & 合同生成路由"""
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
})