1217 lines
52 KiB
Python
1217 lines
52 KiB
Python
"""合同模板管理 & 合同生成路由"""
|
||
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('&', '&').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
|
||
})
|