dy-dashboard/routes/contract.py
2026-10-28 11:14:17 +08:00

1217 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""合同模板管理 & 合同生成路由"""
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 '<h2>\u53c2\u6570\u9519\u8bef</h2>', 400
rows = query("SELECT * FROM contract_templates WHERE id=%s", (int(tid),))
if not rows:
return '<h2>\u6a21\u677f\u4e0d\u5b58\u5728</h2>', 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 '<h2>\u6a21\u677f\u9875\u52a0\u8f7d\u5931\u8d25</h2>', 500
name_esc = str(t['name']).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
fname_esc = str(t.get('file_name', '') or '\u672a\u77e5').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
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
})