app.py 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704
  1. import uuid
  2. import sys
  3. import json
  4. import atexit
  5. import threading
  6. from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, jsonify, abort, current_app
  7. from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
  8. from werkzeug.security import generate_password_hash, check_password_hash
  9. from werkzeug.utils import secure_filename
  10. from apscheduler.schedulers.background import BackgroundScheduler
  11. from apscheduler.triggers.interval import IntervalTrigger
  12. from sqlalchemy import func
  13. from sqlalchemy.exc import SQLAlchemyError
  14. from models import db, SystemConfig, User, Department, CompanyEntity, ContractType, Contract, Counterparty, ContractAttachment, ContractVersion
  15. from flask import url_for, render_template_string
  16. from flask_mail import Mail, Message
  17. from datetime import datetime, timedelta
  18. from apscheduler.triggers.cron import CronTrigger
  19. import pytz
  20. from apscheduler.triggers.cron import CronTrigger
  21. from pytz import timezone
  22. from flask import send_file, make_response, abort
  23. import os
  24. from urllib.parse import quote
  25. # -----------------------------
  26. # 路径处理(PyInstaller 兼容)
  27. # -----------------------------
  28. if getattr(sys, 'frozen', False):
  29. # PyInstaller 打包后
  30. base_path = sys._MEIPASS
  31. instance_path = os.path.join(os.path.dirname(sys.executable), 'instance')
  32. else:
  33. base_path = os.path.abspath(".")
  34. instance_path = os.path.join(base_path, 'instance')
  35. os.makedirs(instance_path, exist_ok=True)
  36. # -----------------------------
  37. # Flask 初始化
  38. # -----------------------------
  39. app = Flask(__name__,
  40. template_folder=os.path.join(base_path, 'templates'),
  41. static_folder=os.path.join(base_path, 'static'))
  42. app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(instance_path, 'contracts.db')
  43. app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
  44. app.config['SECRET_KEY'] = 'dev-secret-key'
  45. app.config['UPLOAD_FOLDER'] = os.path.join(instance_path, 'uploads')
  46. app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB upload limit
  47. # ✅ 允许 APScheduler 在无请求上下文下生成完整 URL(解决 url_for 报错)
  48. app.config['SERVER_NAME'] = '127.0.0.1:8082'
  49. app.config['PREFERRED_URL_SCHEME'] = 'http'
  50. # 创建上传目录
  51. os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
  52. os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'icons'), exist_ok=True)
  53. os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'contracts'), exist_ok=True)
  54. # SQLAlchemy 初始化
  55. db.init_app(app)
  56. # Flask-Login
  57. login_manager = LoginManager(app)
  58. login_manager.login_view = 'login'
  59. # -----------------------------
  60. # 用户加载
  61. # -----------------------------
  62. @login_manager.user_loader
  63. def load_user(user_id):
  64. return db.session.get(User, int(user_id))
  65. # -----------------------------
  66. # 数据库初始化
  67. # -----------------------------
  68. def init_db():
  69. with app.app_context():
  70. db.create_all()
  71. if not SystemConfig.query.first():
  72. print("数据库已创建,请访问 /initial_setup 初始化系统")
  73. def generate_contract_number():
  74. """生成新合同编号,格式 HT-YYYY-XXXX"""
  75. from sqlalchemy import func
  76. year = datetime.now().year
  77. prefix = f"HT-{year}-"
  78. # 查询今年已有的合同数量,用作流水号
  79. last_contract = Contract.query.filter(Contract.contract_number.like(f"{prefix}%")) \
  80. .order_by(Contract.contract_number.desc()).first()
  81. if last_contract and last_contract.contract_number:
  82. try:
  83. last_number = int(last_contract.contract_number.split('-')[-1])
  84. except ValueError:
  85. last_number = 0
  86. else:
  87. last_number = 0
  88. new_number = last_number + 1
  89. return f"{prefix}{str(new_number).zfill(4)}"
  90. # -----------------------------
  91. # 合同到期检查(已优化)
  92. # -----------------------------
  93. def generate_contract_number_by_type(type_id):
  94. """根据合同类型生成编号,优先使用 prefix"""
  95. ctype = ContractType.query.get(type_id)
  96. if not ctype:
  97. return "HT-XXXX"
  98. # 优先使用 prefix,其次使用 code,兜底用 HT+ID
  99. type_code = ctype.prefix or ctype.code or f"HT{ctype.id}"
  100. year = datetime.now().year
  101. prefix = f"{type_code}-{year}-"
  102. # 查询该类型已有合同数量,获取流水号
  103. last_contract = Contract.query.filter(
  104. Contract.type_id == type_id,
  105. Contract.contract_number.like(f"{prefix}%")
  106. ).order_by(Contract.contract_number.desc()).first()
  107. if last_contract and last_contract.contract_number:
  108. try:
  109. last_number = int(last_contract.contract_number.split('-')[-1])
  110. except ValueError:
  111. last_number = 0
  112. else:
  113. last_number = 0
  114. new_number = last_number + 1
  115. return f"{prefix}{str(new_number).zfill(4)}"
  116. # 先创建 Mail 对象,不传 app
  117. mail = Mail()
  118. def load_mail_config():
  119. """
  120. 从 SystemConfig 加载邮件配置并初始化 Mail(自动处理 TLS/SSL)
  121. """
  122. try:
  123. config = SystemConfig.query.first()
  124. except Exception as e:
  125. app.logger.error(f"读取 SystemConfig 时出错: {e}")
  126. config = None
  127. if not config:
  128. print("⚠️ 未找到邮箱配置(SystemConfig 为空),邮件功能未启用")
  129. return
  130. try:
  131. mail_port = int(getattr(config, 'mail_port', 0))
  132. except Exception:
  133. mail_port = 0
  134. mail_server = getattr(config, 'mail_server', None)
  135. mail_username = getattr(config, 'mail_username', None)
  136. mail_password = getattr(config, 'mail_password', None)
  137. # 自动判断 TLS/SSL
  138. mail_use_tls = False
  139. mail_use_ssl = False
  140. if mail_port == 465:
  141. # SSL端口
  142. mail_use_ssl = True
  143. mail_use_tls = False
  144. elif mail_port == 587:
  145. # STARTTLS端口
  146. mail_use_tls = True
  147. mail_use_ssl = False
  148. else:
  149. # 普通SMTP端口
  150. mail_use_tls = False
  151. mail_use_ssl = False
  152. app.config.update(
  153. MAIL_SERVER=mail_server,
  154. MAIL_PORT=mail_port,
  155. MAIL_USERNAME=mail_username,
  156. MAIL_PASSWORD=mail_password,
  157. MAIL_USE_TLS=mail_use_tls,
  158. MAIL_USE_SSL=mail_use_ssl
  159. )
  160. try:
  161. mail.init_app(app)
  162. print(f"✅ 邮件模块已初始化,SERVER={mail_server} PORT={mail_port} TLS={mail_use_tls} SSL={mail_use_ssl}")
  163. if mail_use_tls and mail_use_ssl:
  164. print("⚠️ 警告:TLS和SSL同时启用,可能导致邮件发送失败")
  165. except Exception as e:
  166. print(f"❌ 邮件模块初始化失败: {e}")
  167. def check_expiring_contracts():
  168. with app.app_context():
  169. today = datetime.now().date()
  170. in_30_days = today + timedelta(days=30)
  171. # 查询合同
  172. expiring_contracts = Contract.query.filter(
  173. Contract.end_date <= in_30_days,
  174. Contract.end_date >= today,
  175. Contract.is_active == True
  176. ).all()
  177. expired_contracts = Contract.query.filter(
  178. Contract.end_date < today,
  179. Contract.is_active == True
  180. ).all()
  181. # 构建合同链接
  182. def get_contract_url(contract_id):
  183. try:
  184. return url_for('view_contract', contract_id=contract_id, _external=True)
  185. except RuntimeError:
  186. return f"http://127.0.0.1:8082/view_contract/{contract_id}"
  187. # 处理合同数据,计算剩余天数或逾期天数
  188. def build_contract_data(contract, expired=False):
  189. delta_days = (contract.end_date - today).days
  190. return {
  191. 'name': contract.name,
  192. 'contract_number': getattr(contract, 'contract_number', ''), # 改这里
  193. 'end_date': contract.end_date.strftime('%Y-%m-%d'),
  194. 'days_left': max(delta_days, 0),
  195. 'days_overdue': abs(min(delta_days, 0)),
  196. 'url': get_contract_url(contract.id)
  197. }
  198. expiring_data = [build_contract_data(c) for c in expiring_contracts]
  199. expired_data = [build_contract_data(c, expired=True) for c in expired_contracts]
  200. if not expiring_data and not expired_data:
  201. print("[定时任务] 无到期或即将到期合同。")
  202. return
  203. print(f"[定时任务] 已找到 {len(expiring_contracts)} 个即将到期合同,{len(expired_contracts)} 个已到期合同。")
  204. # 渲染邮件 HTML
  205. html_content = render_template_string("""
  206. <div style="font-family: Arial, sans-serif; max-width: 900px; margin: auto; padding: 20px;">
  207. <h2 style="color: #007BFF;">合同到期提醒 - 汇总</h2>
  208. <p>管理员您好,系统在 {{ now_date }} 检测到以下合同的到期情况:</p>
  209. {% if expiring %}
  210. <h3 style="color:#1a73e8;">📅 即将到期(30天内)</h3>
  211. <table style="width:100%; border-collapse: collapse; margin-bottom:18px;">
  212. <thead>
  213. <tr style="background:#f8f9fa;">
  214. <th style="padding:8px; border:1px solid #e3e6ea; text-align:left;">合同名称</th>
  215. <th style="padding:8px; border:1px solid #e3e6ea; text-align:left;">合同编号</th>
  216. <th style="padding:8px; border:1px solid #e3e6ea; text-align:left;">到期日期</th>
  217. <th style="padding:8px; border:1px solid #e3e6ea; text-align:left;">剩余天数</th>
  218. <th style="padding:8px; border:1px solid #e3e6ea;">操作</th>
  219. </tr>
  220. </thead>
  221. <tbody>
  222. {% for c in expiring %}
  223. <tr>
  224. <td style="padding:8px; border:1px solid #e3e6ea;">{{ c.name }}</td>
  225. <td style="padding:8px; border:1px solid #e3e6ea;">{{ c.contract_number }}</td>
  226. <td style="padding:8px; border:1px solid #e3e6ea;">{{ c.end_date }}</td>
  227. <td style="padding:8px; border:1px solid #e3e6ea;">{{ c.days_left }} 天</td>
  228. <td style="padding:8px; border:1px solid #e3e6ea;"><a href="{{ c.url }}" style="color:#1a73e8;">查看</a></td>
  229. </tr>
  230. {% endfor %}
  231. </tbody>
  232. </table>
  233. {% endif %}
  234. {% if expired %}
  235. <h3 style="color:#d93025;">⚠️ 已到期(请尽快处理)</h3>
  236. <table style="width:100%; border-collapse: collapse; margin-bottom:18px;">
  237. <thead>
  238. <tr style="background:#f8f9fa;">
  239. <th style="padding:8px; border:1px solid #e3e6ea; text-align:left;">合同名称</th>
  240. <th style="padding:8px; border:1px solid #e3e6ea; text-align:left;">合同编号</th>
  241. <th style="padding:8px; border:1px solid #e3e6ea; text-align:left;">到期日期</th>
  242. <th style="padding:8px; border:1px solid #e3e6ea;">逾期天数</th>
  243. <th style="padding:8px; border:1px solid #e3e6ea;">操作</th>
  244. </tr>
  245. </thead>
  246. <tbody>
  247. {% for c in expired %}
  248. <tr>
  249. <td style="padding:8px; border:1px solid #e3e6ea;">{{ c.name }}</td>
  250. <td style="padding:8px; border:1px solid #e3e6ea;">{{ c.contract_number }}</td>
  251. <td style="padding:8px; border:1px solid #e3e6ea;">{{ c.end_date }}</td>
  252. <td style="padding:8px; border:1px solid #e3e6ea;">{{ c.days_overdue }} 天</td>
  253. <td style="padding:8px; border:1px solid #e3e6ea;"><a href="{{ c.url }}" style="color:#d93025;">查看</a></td>
  254. </tr>
  255. {% endfor %}
  256. </tbody>
  257. </table>
  258. {% endif %}
  259. <p style="color:#666; font-size:13px;">此邮件由合同管理系统自动发送。如有问题请登录系统查看或联系系统管理员。<br>{{ now_full }}</p>
  260. </div>
  261. """, expiring=expiring_data, expired=expired_data,
  262. now_date=today.strftime("%Y-%m-%d"),
  263. now_full=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
  264. # 邮件发送
  265. try:
  266. subject = f"【合同到期提醒】{today.strftime('%Y-%m-%d')} - 即将到期 {len(expiring_data)} / 已到期 {len(expired_data)}"
  267. sender = app.config.get('MAIL_USERNAME') or f"no-reply@{app.config.get('MAIL_SERVER') or 'local'}"
  268. # 从数据库获取管理员邮箱
  269. admin_users = User.query.filter_by(is_admin=True).all()
  270. recipients = [u.email for u in admin_users if u.email]
  271. if not recipients:
  272. print("⚠️ 没有找到管理员邮箱,邮件发送被跳过")
  273. return
  274. msg = Message(subject=subject,
  275. recipients=recipients,
  276. html=html_content,
  277. sender=sender)
  278. def _send():
  279. try:
  280. with app.app_context():
  281. mail.send(msg)
  282. print(
  283. f"✅ 已发送到期提醒邮件给 {recipients}(即将到期: {len(expiring_data)},已到期: {len(expired_data)})")
  284. except Exception as e:
  285. print(f"❌ 邮件发送失败: {e}")
  286. t = threading.Thread(target=_send, daemon=True)
  287. t.start()
  288. except Exception as e:
  289. print(f"❌ 构造或发送邮件时发生异常: {e}")
  290. app.logger.error(f"check_expiring_contracts error: {e}")
  291. # -----------------------------
  292. # APScheduler 调度(根据配置动态设置)
  293. # -----------------------------
  294. scheduler = BackgroundScheduler(timezone=pytz.timezone('Asia/Shanghai'))
  295. # -----------------------------
  296. # APScheduler 调度(根据配置动态设置)
  297. # -----------------------------
  298. scheduler = BackgroundScheduler(timezone=pytz.timezone('Asia/Shanghai'))
  299. def get_scheduler_config():
  300. """从系统配置获取定时任务设置"""
  301. # 确保在应用上下文中执行数据库查询
  302. with app.app_context():
  303. config = SystemConfig.query.first()
  304. if not config:
  305. # 默认值
  306. return {
  307. 'frequency': 'daily',
  308. 'weekdays': [],
  309. 'month_day': 1,
  310. 'hour': 9,
  311. 'minute': 0
  312. }
  313. return {
  314. 'frequency': config.scheduler_frequency or 'daily',
  315. 'weekdays': config.scheduler_weekdays or [],
  316. 'month_day': config.scheduler_month_day or 1,
  317. 'hour': config.scheduler_hour or 9,
  318. 'minute': config.scheduler_minute or 0
  319. }
  320. def create_scheduler_trigger():
  321. """根据配置创建触发器"""
  322. config = get_scheduler_config()
  323. if config['frequency'] == 'daily':
  324. # 每天执行
  325. return CronTrigger(
  326. hour=config['hour'],
  327. minute=config['minute'],
  328. timezone='Asia/Shanghai'
  329. )
  330. elif config['frequency'] == 'weekly':
  331. # 每周执行
  332. return CronTrigger(
  333. day_of_week=','.join(map(str, config['weekdays'])),
  334. hour=config['hour'],
  335. minute=config['minute'],
  336. timezone='Asia/Shanghai'
  337. )
  338. elif config['frequency'] == 'monthly':
  339. # 每月执行
  340. return CronTrigger(
  341. day=config['month_day'],
  342. hour=config['hour'],
  343. minute=config['minute'],
  344. timezone='Asia/Shanghai'
  345. )
  346. else:
  347. # 默认每天执行
  348. return CronTrigger(
  349. hour=9,
  350. minute=0,
  351. timezone='Asia/Shanghai'
  352. )
  353. # 添加定时任务
  354. scheduler.add_job(
  355. check_expiring_contracts,
  356. create_scheduler_trigger(),
  357. id='daily_contract_check',
  358. replace_existing=True
  359. )
  360. # 开始调度
  361. if not hasattr(app, 'scheduler_started'):
  362. try:
  363. scheduler.start()
  364. app.scheduler_started = True
  365. # 打印定时任务配置
  366. config = get_scheduler_config()
  367. if config['frequency'] == 'daily':
  368. print(f"⏰ Scheduler 已启动(每天 {config['hour']}:{config['minute']} 检查合同到期)")
  369. elif config['frequency'] == 'weekly':
  370. weekdays = ', '.join(
  371. [['周一', '周二', '周三', '周四', '周五', '周六', '周日'][int(d)] for d in config['weekdays']])
  372. print(f"⏰ Scheduler 已启动(每周 {weekdays} {config['hour']}:{config['minute']} 检查合同到期)")
  373. elif config['frequency'] == 'monthly':
  374. print(f"⏰ Scheduler 已启动(每月 {config['month_day']}号 {config['hour']}:{config['minute']} 检查合同到期)")
  375. except Exception as e:
  376. print(f"⚠️ Scheduler 启动时出错: {e}")
  377. # 程序退出时关闭调度器
  378. atexit.register(lambda: scheduler.shutdown(wait=False))
  379. # -----------------------------
  380. # 路由
  381. # -----------------------------
  382. @app.route('/')
  383. def index():
  384. if not SystemConfig.query.first():
  385. return redirect(url_for('initial_setup'))
  386. return redirect(url_for('login'))
  387. @app.route('/initial_setup', methods=['GET', 'POST'])
  388. def initial_setup():
  389. if SystemConfig.query.first():
  390. return redirect(url_for('index'))
  391. if request.method == 'POST':
  392. config = SystemConfig(
  393. site_name=request.form['site_name'],
  394. mail_server=request.form['mail_server'],
  395. mail_port=int(request.form['mail_port']),
  396. mail_username=request.form['mail_username'],
  397. mail_password=request.form['mail_password'],
  398. mail_use_tls='mail_use_tls' in request.form
  399. )
  400. db.session.add(config)
  401. admin = User(
  402. username=request.form['admin_username'],
  403. password=generate_password_hash(request.form['admin_password']),
  404. name=request.form['admin_name'],
  405. department=request.form['admin_department'],
  406. email=request.form['admin_email'],
  407. is_admin=True,
  408. can_create=True,
  409. can_view=True,
  410. can_edit=True,
  411. can_delete=True
  412. )
  413. db.session.add(admin)
  414. departments = ['行政部', '财务部', '法务部', '市场部']
  415. for dept in departments:
  416. db.session.add(Department(name=dept))
  417. entities = ['总公司', '分公司A', '分公司B']
  418. for entity in entities:
  419. db.session.add(CompanyEntity(name=entity))
  420. contract_types = ['采购合同', '销售合同', '服务合同', '租赁合同']
  421. for ct in contract_types:
  422. db.session.add(ContractType(name=ct))
  423. db.session.commit()
  424. flash('系统初始化成功,请使用管理员账号登录', 'success')
  425. return redirect(url_for('login'))
  426. return render_template('initial_setup.html')
  427. # -----------------------------
  428. # 登录/登出
  429. # -----------------------------
  430. @app.route('/login', methods=['GET', 'POST'])
  431. def login():
  432. if current_user.is_authenticated:
  433. return redirect(url_for('dashboard'))
  434. if request.method == 'POST':
  435. username = request.form['username']
  436. password = request.form['password']
  437. user = User.query.filter_by(username=username).first()
  438. if user and check_password_hash(user.password, password):
  439. login_user(user)
  440. next_page = request.args.get('next')
  441. return redirect(next_page or url_for('dashboard'))
  442. else:
  443. flash('用户名或密码错误', 'danger')
  444. return render_template('login.html')
  445. @app.route('/logout')
  446. @login_required
  447. def logout():
  448. logout_user()
  449. return redirect(url_for('login'))
  450. # -----------------------------
  451. # 仪表盘
  452. # -----------------------------
  453. @app.route('/dashboard')
  454. @login_required
  455. def dashboard():
  456. # 使用东八区时间
  457. tz = timezone('Asia/Shanghai')
  458. today = datetime.now(tz).date()
  459. # 使用多个单独的查询来统计,避免复杂的SQL CASE语句
  460. total = Contract.query.count()
  461. active = Contract.query.filter(Contract.is_active == True).count()
  462. expiring = Contract.query.filter(
  463. Contract.end_date.between(today, today + timedelta(days=30)),
  464. Contract.is_active == True
  465. ).count()
  466. expired_already = Contract.query.filter(
  467. Contract.end_date < today,
  468. Contract.is_active == True
  469. ).count()
  470. uncollected = Contract.query.filter(
  471. Contract.collected_date.is_(None),
  472. Contract.is_active == True
  473. ).count()
  474. terminated = Contract.query.filter(Contract.is_active == False).count()
  475. stats = {
  476. 'total': total,
  477. 'active': active,
  478. 'expiring': expiring,
  479. 'expired_already': expired_already,
  480. 'uncollected': uncollected,
  481. 'terminated': terminated
  482. }
  483. # 即将到期合同(30天内)- 只包含激活状态的合同
  484. expiring_soon = Contract.query.filter(
  485. Contract.end_date.between(today, today + timedelta(days=30)),
  486. Contract.is_active == True
  487. ).order_by(Contract.end_date).limit(5).all()
  488. # 已到期合同 - 只包含激活状态的合同
  489. expired_contracts = Contract.query.filter(
  490. Contract.end_date < today,
  491. Contract.is_active == True
  492. ).order_by(Contract.end_date.desc()).limit(5).all()
  493. # 已终止合同 - 状态为不激活
  494. terminated_contracts = Contract.query.filter(
  495. Contract.is_active == False
  496. ).order_by(Contract.end_date.desc()).limit(5).all()
  497. # 最近添加的合同
  498. recent_contracts = Contract.query.order_by(Contract.created_at.desc()).limit(5).all()
  499. # 待收回合同 - 只包含激活状态的合同
  500. uncollected_contracts = Contract.query.filter(
  501. Contract.collected_date.is_(None),
  502. Contract.is_active == True
  503. ).order_by(Contract.end_date.desc()).limit(5).all()
  504. # 公司证件类合同 - 只包含激活状态的合同
  505. company_docs = Contract.query.join(ContractType).filter(
  506. ContractType.name == "公司证件",
  507. Contract.is_active == True
  508. ).order_by(Contract.end_date.desc()).limit(5).all()
  509. # 公用模板合同 - 只包含激活状态的合同
  510. public_templates = Contract.query.join(ContractType).filter(
  511. ContractType.name == "公用模板",
  512. Contract.is_active == True
  513. ).order_by(Contract.created_at.desc()).limit(5).all()
  514. return render_template(
  515. 'dashboard.html',
  516. stats=stats,
  517. expiring_soon=expiring_soon,
  518. expired_contracts=expired_contracts,
  519. terminated_contracts=terminated_contracts,
  520. recent_contracts=recent_contracts,
  521. uncollected_contracts=uncollected_contracts,
  522. company_docs=company_docs,
  523. public_templates=public_templates,
  524. today=today
  525. )
  526. @app.route('/contracts')
  527. @login_required # 添加登录要求
  528. def contract_list():
  529. if not current_user.can_view:
  530. flash('您没有查看合同的权限', 'danger')
  531. return redirect(url_for('dashboard'))
  532. # 获取 type 参数
  533. type_filter = request.args.get('type')
  534. today = datetime.now().date()
  535. if type_filter == 'all':
  536. # 全部合同
  537. contracts = Contract.query.order_by(Contract.end_date.desc()).all()
  538. elif type_filter == 'active':
  539. # 有效合同
  540. contracts = Contract.query.filter_by(is_active=True).order_by(Contract.end_date.desc()).all()
  541. elif type_filter == 'expiring':
  542. # 即将到期合同(30天内)
  543. end_date_limit = today + timedelta(days=30)
  544. contracts = Contract.query.filter(
  545. Contract.end_date >= today,
  546. Contract.end_date <= end_date_limit,
  547. Contract.is_active == True
  548. ).order_by(Contract.end_date.asc()).all()
  549. elif type_filter == 'expired':
  550. # 已到期合同
  551. contracts = Contract.query.filter(
  552. Contract.end_date < today,
  553. Contract.is_active == True
  554. ).order_by(Contract.end_date.desc()).all()
  555. elif type_filter == 'uncollected':
  556. # 待收回合同
  557. contracts = Contract.query.filter(
  558. Contract.collected_date.is_(None),
  559. Contract.is_active == True
  560. ).order_by(Contract.end_date.desc()).all()
  561. elif type_filter == 'terminated':
  562. # 已终止合同
  563. contracts = Contract.query.filter_by(is_active=False).order_by(Contract.end_date.desc()).all()
  564. elif type_filter == 'recent':
  565. # 最近添加的合同
  566. contracts = Contract.query.order_by(Contract.created_at.desc()).limit(10).all()
  567. elif type_filter == 'company_doc':
  568. # 公司证件
  569. contracts = Contract.query.join(ContractType).filter(
  570. ContractType.name == '公司证件',
  571. Contract.is_active == True
  572. ).order_by(Contract.end_date.desc()).all()
  573. elif type_filter == 'template':
  574. # 公用模板
  575. contracts = Contract.query.join(ContractType).filter(
  576. ContractType.name == '公用模板',
  577. Contract.is_active == True
  578. ).order_by(Contract.created_at.desc()).all()
  579. else:
  580. # 默认:全部合同
  581. contracts = Contract.query.order_by(Contract.end_date.desc()).all()
  582. # 准备合同数据
  583. contracts_data = []
  584. for contract in contracts:
  585. attachments = [
  586. {
  587. 'id': att.id,
  588. 'filename': att.filename,
  589. 'url': url_for('view_attachment', attachment_id=att.id),
  590. 'download_url': url_for('download_file', attachment_id=att.id)
  591. } for att in contract.attachments
  592. ]
  593. contracts_data.append({
  594. 'contract': contract,
  595. 'attachments': attachments
  596. })
  597. return render_template('contract_list.html', contracts=contracts_data)
  598. # -----------------------------
  599. # 创建合同
  600. # -----------------------------
  601. @app.route('/contract/create', methods=['GET', 'POST'])
  602. @login_required
  603. def create_contract():
  604. if not current_user.can_create:
  605. flash('您没有创建合同的权限', 'danger')
  606. return redirect(url_for('contract_list'))
  607. contract_types = ContractType.query.all()
  608. company_entities = CompanyEntity.query.all()
  609. default_number = ""
  610. if contract_types:
  611. # 默认使用第一个合同类型生成编号
  612. default_number = generate_contract_number_by_type(contract_types[0].id)
  613. if request.method == 'POST':
  614. try:
  615. contract_number = request.form['contract_number'] or default_number
  616. contract = Contract(
  617. contract_number=contract_number,
  618. name=request.form['name'],
  619. type_id=request.form['type_id'],
  620. company_entity_id=request.form['company_entity_id'],
  621. signer_id=current_user.id,
  622. start_date=datetime.strptime(request.form['start_date'], '%Y-%m-%d').date(),
  623. end_date=datetime.strptime(request.form['end_date'], '%Y-%m-%d').date(),
  624. remind_before=int(request.form['remind_before']) if request.form['remind_before'] else None,
  625. notes=request.form['notes'],
  626. creator_id=current_user.id,
  627. signing_method=request.form.get('signing_method'),
  628. collected_date=datetime.strptime(request.form['collected_date'], '%Y-%m-%d').date()
  629. if request.form.get('collected_date') else None,
  630. storage_box=request.form.get('storage_box')
  631. )
  632. db.session.add(contract)
  633. db.session.flush() # 获取合同ID以便添加附件
  634. # 保存合同方
  635. counterparties = request.form.getlist('counterparty_name')
  636. for cp in counterparties:
  637. if cp.strip():
  638. db.session.add(Counterparty(name=cp.strip(), contract_id=contract.id))
  639. # 保存初始版本
  640. save_version(contract, current_user.id)
  641. # ------------------------------
  642. # 处理附件上传 - 使用UUID生成文件名
  643. files = request.files.getlist('new_attachments') # 注意字段名改为 new_attachments
  644. upload_folder = os.path.join(app.config['UPLOAD_FOLDER'], 'contracts')
  645. os.makedirs(upload_folder, exist_ok=True)
  646. # 文件类型白名单
  647. ALLOWED_EXTENSIONS = {'pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp',
  648. 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'}
  649. MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
  650. for file in files:
  651. if file.filename == '':
  652. continue
  653. # 验证文件扩展名
  654. filename = file.filename
  655. ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
  656. if ext not in ALLOWED_EXTENSIONS:
  657. flash(f'文件 {filename} 类型不被允许', 'warning')
  658. continue
  659. # 验证文件大小
  660. file.seek(0, os.SEEK_END)
  661. file_size = file.tell()
  662. file.seek(0)
  663. if file_size > MAX_FILE_SIZE:
  664. flash(f'文件 {filename} 超过5MB大小限制', 'warning')
  665. continue
  666. # 使用UUID生成唯一文件名
  667. unique_id = uuid.uuid4().hex
  668. safe_filename = secure_filename(f"{unique_id}_{filename}")
  669. filepath = os.path.join(upload_folder, safe_filename)
  670. try:
  671. file.save(filepath)
  672. except Exception as e:
  673. flash(f'保存文件 {filename} 失败: {str(e)}', 'danger')
  674. continue
  675. # 创建附件记录
  676. attachment = ContractAttachment(
  677. contract_id=contract.id,
  678. filename=filename,
  679. filepath=filepath
  680. )
  681. db.session.add(attachment)
  682. db.session.commit()
  683. flash('合同创建成功', 'success')
  684. return redirect(url_for('view_contract', contract_id=contract.id))
  685. except Exception as e:
  686. db.session.rollback()
  687. flash(f'创建合同失败: {str(e)}', 'danger')
  688. app.logger.error(f'Contract creation failed: {str(e)}')
  689. return render_template('contract_form.html',
  690. contract_types=contract_types,
  691. company_entities=company_entities,
  692. default_number=default_number)
  693. @app.route('/contract/generate_number/<int:type_id>')
  694. @login_required
  695. def generate_number(type_id):
  696. """根据合同类型生成编号(线程安全)"""
  697. ctype = ContractType.query.get(type_id)
  698. if not ctype:
  699. return jsonify({"number": "HT-XXXX"})
  700. type_code = ctype.prefix or ctype.code or f"HT{ctype.id}"
  701. year = datetime.now().year
  702. prefix = f"{type_code}-{year}-"
  703. try:
  704. # 事务锁定,避免并发重复
  705. last_number = db.session.query(
  706. func.max(func.substr(Contract.contract_number, -4, 4).cast(db.Integer))
  707. ).filter(
  708. Contract.contract_number.like(f"{prefix}%")
  709. ).scalar() or 0
  710. new_number = last_number + 1
  711. return jsonify({"number": f"{prefix}{str(new_number).zfill(4)}"})
  712. except SQLAlchemyError as e:
  713. db.session.rollback()
  714. return jsonify({"number": f"{prefix}0001"})
  715. @app.route('/contract/<int:contract_id>')
  716. @login_required
  717. def view_contract(contract_id):
  718. contract = Contract.query.get_or_404(contract_id)
  719. if not current_user.can_view:
  720. flash('您没有查看合同的权限', 'danger')
  721. return redirect(url_for('contract_list'))
  722. # 获取合同方
  723. counterparties = Counterparty.query.filter_by(contract_id=contract_id).all()
  724. # 获取附件,并为前端预览添加 url/download_url
  725. attachments = ContractAttachment.query.filter_by(contract_id=contract_id).all()
  726. for att in attachments:
  727. att.url = url_for('view_attachment', attachment_id=att.id) # 浏览器直接预览
  728. att.download_url = url_for('download_file', attachment_id=att.id) # 下载用
  729. return render_template(
  730. 'contract_view.html',
  731. contract=contract,
  732. counterparties=counterparties,
  733. attachments=attachments
  734. )
  735. @app.route('/attachments/<int:attachment_id>')
  736. @login_required
  737. def view_attachment(attachment_id):
  738. attachment = ContractAttachment.query.get_or_404(attachment_id)
  739. filename = os.path.basename(attachment.filepath)
  740. upload_folder = os.path.join(app.config['UPLOAD_FOLDER'], 'contracts')
  741. full_path = os.path.join(upload_folder, filename)
  742. if not os.path.exists(full_path):
  743. abort(404)
  744. ext = os.path.splitext(filename)[1].lower()
  745. if ext in ['.pdf', '.jpg', '.jpeg', '.png', '.gif']:
  746. # 直接浏览器预览
  747. return send_from_directory(upload_folder, filename)
  748. else:
  749. # 其他文件下载
  750. return send_from_directory(upload_folder, filename, as_attachment=True)
  751. # -----------------------------
  752. # 编辑合同
  753. # -----------------------------
  754. @app.route('/contract/edit/<int:contract_id>', methods=['GET', 'POST'])
  755. @login_required
  756. def edit_contract(contract_id):
  757. contract = Contract.query.get_or_404(contract_id)
  758. if not current_user.can_edit:
  759. flash('您没有编辑合同的权限', 'danger')
  760. return redirect(url_for('view_contract', contract_id=contract.id))
  761. if request.method == 'POST':
  762. try:
  763. # 保存历史版本
  764. save_version(contract, current_user.id)
  765. # 更新合同字段
  766. contract.contract_number = request.form['contract_number']
  767. contract.name = request.form['name']
  768. contract.type_id = request.form['type_id']
  769. contract.company_entity_id = request.form['company_entity_id']
  770. contract.start_date = datetime.strptime(request.form['start_date'], '%Y-%m-%d').date()
  771. contract.end_date = datetime.strptime(request.form['end_date'], '%Y-%m-%d').date()
  772. contract.remind_before = int(request.form['remind_before']) if request.form['remind_before'] else None
  773. contract.notes = request.form['notes']
  774. contract.signing_method = request.form.get('signing_method')
  775. contract.collected_date = datetime.strptime(request.form['collected_date'],
  776. '%Y-%m-%d').date() if request.form.get(
  777. 'collected_date') else None
  778. contract.storage_box = request.form.get('storage_box')
  779. # 更新合同方
  780. Counterparty.query.filter_by(contract_id=contract.id).delete()
  781. counterparties = request.form.getlist('counterparty_name')
  782. for cp in counterparties:
  783. if cp.strip():
  784. db.session.add(Counterparty(name=cp.strip(), contract_id=contract.id))
  785. # 删除选中的旧附件
  786. delete_ids = request.form.getlist('delete_attachment')
  787. for att_id in delete_ids:
  788. attachment = ContractAttachment.query.get(att_id)
  789. if attachment and attachment.contract_id == contract.id:
  790. try:
  791. if os.path.exists(attachment.filepath):
  792. os.remove(attachment.filepath)
  793. except Exception as e:
  794. app.logger.error(f"删除附件文件失败: {attachment.filepath}, 错误: {str(e)}")
  795. db.session.delete(attachment)
  796. # 上传新附件 - 关键修复点
  797. files = request.files.getlist('new_attachments')
  798. upload_folder = os.path.join(app.config['UPLOAD_FOLDER'], 'contracts')
  799. os.makedirs(upload_folder, exist_ok=True)
  800. # 文件类型白名单
  801. ALLOWED_EXTENSIONS = {'pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp',
  802. 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'}
  803. MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
  804. for file in files:
  805. if file.filename == '':
  806. continue
  807. # 验证文件扩展名
  808. filename = file.filename
  809. ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
  810. if ext not in ALLOWED_EXTENSIONS:
  811. flash(f'文件 {filename} 类型不被允许', 'warning')
  812. continue
  813. # 验证文件大小
  814. file.seek(0, os.SEEK_END)
  815. file_size = file.tell()
  816. file.seek(0)
  817. if file_size > MAX_FILE_SIZE:
  818. flash(f'文件 {filename} 超过5MB大小限制', 'warning')
  819. continue
  820. # 使用UUID生成唯一文件名
  821. unique_id = uuid.uuid4().hex
  822. safe_filename = secure_filename(f"{unique_id}_{filename}")
  823. filepath = os.path.join(upload_folder, safe_filename)
  824. try:
  825. file.save(filepath)
  826. except Exception as e:
  827. flash(f'保存文件 {filename} 失败: {str(e)}', 'danger')
  828. continue
  829. # 创建附件记录
  830. new_attachment = ContractAttachment(
  831. contract_id=contract.id,
  832. filename=filename, # 原始文件名
  833. filepath=filepath # 服务器存储路径
  834. )
  835. db.session.add(new_attachment)
  836. db.session.commit()
  837. flash('合同更新成功', 'success')
  838. return redirect(url_for('view_contract', contract_id=contract.id))
  839. except Exception as e:
  840. db.session.rollback()
  841. flash(f'更新合同失败: {str(e)}', 'danger')
  842. app.logger.error(f'Contract update failed: {str(e)}')
  843. # 渲染编辑页面
  844. contract_types = ContractType.query.all()
  845. company_entities = CompanyEntity.query.all()
  846. counterparties = Counterparty.query.filter_by(contract_id=contract.id).all()
  847. attachments = ContractAttachment.query.filter_by(contract_id=contract.id).all()
  848. return render_template(
  849. 'contract_form.html',
  850. contract=contract,
  851. contract_types=contract_types,
  852. company_entities=company_entities,
  853. counterparties=counterparties,
  854. attachments=attachments
  855. )
  856. def save_version(contract, user_id):
  857. """Save current contract state as a version"""
  858. data = {
  859. 'contract_number': contract.contract_number,
  860. 'name': contract.name,
  861. 'type_id': contract.type_id,
  862. 'company_entity_id': contract.company_entity_id,
  863. 'start_date': str(contract.start_date),
  864. 'end_date': str(contract.end_date),
  865. 'remind_before': contract.remind_before,
  866. 'notes': contract.notes,
  867. 'counterparties': [cp.name for cp in contract.counterparties],
  868. # 新增字段
  869. 'signing_method': contract.signing_method,
  870. 'collected_date': str(contract.collected_date) if contract.collected_date else None,
  871. 'storage_box': contract.storage_box,
  872. 'is_active': contract.is_active # 添加合同状态
  873. }
  874. max_version = db.session.query(db.func.max(ContractVersion.version)).filter_by(
  875. contract_id=contract.id).scalar() or 0
  876. version = ContractVersion(
  877. contract_id=contract.id,
  878. version=max_version + 1,
  879. data=json.dumps(data, ensure_ascii=False, indent=2),
  880. modified_by=user_id
  881. )
  882. db.session.add(version)
  883. @app.route('/contract/delete/<int:contract_id>', methods=['POST'])
  884. @login_required
  885. def delete_contract(contract_id):
  886. contract = Contract.query.get_or_404(contract_id)
  887. if not current_user.can_delete:
  888. flash('您没有删除合同的权限', 'danger')
  889. return redirect(url_for('view_contract', contract_id=contract_id))
  890. try:
  891. contract.is_active = False
  892. db.session.commit()
  893. flash('合同已标记为终止', 'success')
  894. except Exception as e:
  895. db.session.rollback()
  896. flash(f'终止合同失败: {str(e)}', 'danger')
  897. app.logger.error(f'Contract termination failed: {str(e)}')
  898. return redirect(url_for('contract_list'))
  899. @app.route('/contract/renew/<int:contract_id>', methods=['GET', 'POST'])
  900. @login_required
  901. def renew_contract(contract_id):
  902. original_contract = Contract.query.get_or_404(contract_id)
  903. if not current_user.can_create:
  904. flash('您没有创建合同的权限', 'danger')
  905. return redirect(url_for('view_contract', contract_id=contract_id))
  906. if request.method == 'POST':
  907. try:
  908. # 自动生成续签合同编号
  909. existing_renewals = Contract.query.filter(
  910. Contract.contract_number.like(f"{original_contract.contract_number}-续签%")
  911. ).all()
  912. renewal_number = len(existing_renewals) + 1
  913. new_contract_number = f"{original_contract.contract_number}-续签{renewal_number:02d}"
  914. contract = Contract(
  915. contract_number=new_contract_number,
  916. name=request.form['name'],
  917. type_id=request.form['type_id'],
  918. company_entity_id=request.form['company_entity_id'],
  919. signer_id=current_user.id,
  920. start_date=datetime.strptime(request.form['start_date'], '%Y-%m-%d').date(),
  921. end_date=datetime.strptime(request.form['end_date'], '%Y-%m-%d').date(),
  922. remind_before=int(request.form['remind_before']) if request.form['remind_before'] else None,
  923. notes=request.form['notes'],
  924. creator_id=current_user.id,
  925. original_contract_id=original_contract.id,
  926. # 新增字段(可以默认继承原合同)
  927. signing_method=original_contract.signing_method,
  928. collected_date=original_contract.collected_date,
  929. storage_box=original_contract.storage_box
  930. )
  931. db.session.add(contract)
  932. db.session.commit()
  933. # 复制合同方
  934. counterparties = Counterparty.query.filter_by(contract_id=original_contract.id).all()
  935. for cp in counterparties:
  936. db.session.add(Counterparty(name=cp.name, contract_id=contract.id))
  937. # 保存初始版本
  938. save_version(contract, current_user.id)
  939. # 处理附件上传 - 使用UUID生成文件名
  940. files = request.files.getlist('new_attachments') # 注意字段名改为 new_attachments
  941. upload_folder = os.path.join(app.config['UPLOAD_FOLDER'], 'contracts')
  942. os.makedirs(upload_folder, exist_ok=True)
  943. # 文件类型白名单
  944. ALLOWED_EXTENSIONS = {'pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp',
  945. 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'}
  946. MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
  947. for file in files:
  948. if file.filename == '':
  949. continue
  950. # 验证文件扩展名
  951. filename = file.filename
  952. ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
  953. if ext not in ALLOWED_EXTENSIONS:
  954. flash(f'文件 {filename} 类型不被允许', 'warning')
  955. continue
  956. # 验证文件大小
  957. file.seek(0, os.SEEK_END)
  958. file_size = file.tell()
  959. file.seek(0)
  960. if file_size > MAX_FILE_SIZE:
  961. flash(f'文件 {filename} 超过5MB大小限制', 'warning')
  962. continue
  963. # 使用UUID生成唯一文件名
  964. unique_id = uuid.uuid4().hex
  965. safe_filename = secure_filename(f"{unique_id}_{filename}")
  966. filepath = os.path.join(upload_folder, safe_filename)
  967. try:
  968. file.save(filepath)
  969. except Exception as e:
  970. flash(f'保存文件 {filename} 失败: {str(e)}', 'danger')
  971. continue
  972. # 创建附件记录
  973. attachment = ContractAttachment(
  974. contract_id=contract.id,
  975. filename=filename,
  976. filepath=filepath
  977. )
  978. db.session.add(attachment)
  979. db.session.commit()
  980. flash('合同续签成功', 'success')
  981. return redirect(url_for('view_contract', contract_id=contract.id))
  982. except Exception as e:
  983. db.session.rollback()
  984. flash(f'续签合同失败: {str(e)}', 'danger')
  985. app.logger.error(f'Contract renewal failed: {str(e)}')
  986. # GET 请求预填充
  987. contract_types = ContractType.query.all()
  988. company_entities = CompanyEntity.query.all()
  989. counterparties = Counterparty.query.filter_by(contract_id=contract_id).all()
  990. new_start_date = original_contract.end_date + timedelta(days=1)
  991. new_end_date = new_start_date + timedelta(days=365)
  992. return render_template('contract_form.html',
  993. contract=original_contract,
  994. contract_types=contract_types,
  995. company_entities=company_entities,
  996. counterparties=counterparties,
  997. renew=True,
  998. new_start_date=new_start_date.strftime('%Y-%m-%d'),
  999. new_end_date=new_end_date.strftime('%Y-%m-%d'))
  1000. @app.route('/contract/<int:contract_id>/versions')
  1001. @login_required
  1002. def contract_versions(contract_id):
  1003. contract = Contract.query.get_or_404(contract_id)
  1004. # 相关合同 IDs
  1005. contract_ids = set([contract.id])
  1006. if contract.original_contract_id:
  1007. contract_ids.add(contract.original_contract_id)
  1008. # 加上续签合同
  1009. renewals = Contract.query.filter(Contract.original_contract_id == contract.id).all()
  1010. contract_ids.update([c.id for c in renewals])
  1011. # 查询所有版本
  1012. versions = ContractVersion.query.filter(
  1013. ContractVersion.contract_id.in_(contract_ids)
  1014. ).join(Contract).order_by(
  1015. Contract.contract_number.asc(),
  1016. ContractVersion.version.desc()
  1017. ).all()
  1018. # 生成 parsed_versions
  1019. parsed_versions = []
  1020. for v in versions:
  1021. try:
  1022. data_dict = json.loads(v.data)
  1023. except Exception as e:
  1024. data_dict = {"error": f"解析失败: {str(e)}"}
  1025. parsed_versions.append({
  1026. 'version_obj': v,
  1027. 'data': data_dict,
  1028. 'belongs_to': v.contract
  1029. })
  1030. # ====== 调试输出 ======
  1031. print("=== 调试: parsed_versions 内容 ===")
  1032. for idx, pv in enumerate(parsed_versions):
  1033. print(f"{idx+1}. 合同编号: {pv['belongs_to'].contract_number}, 版本: {pv['version_obj'].version}, 修改人: {pv['version_obj'].modifier.name}")
  1034. print(f" 数据: {pv['data']}")
  1035. print("=== 调试结束 ===")
  1036. contract_types = {t.id: t.name for t in ContractType.query.all()}
  1037. company_entities = {e.id: e.name for e in CompanyEntity.query.all()}
  1038. return render_template(
  1039. 'contract_versions.html',
  1040. contract=contract,
  1041. parsed_versions=parsed_versions,
  1042. contract_types=contract_types,
  1043. company_entities=company_entities
  1044. )
  1045. @app.route('/contract/attachment/delete/<int:attachment_id>', methods=['POST'])
  1046. @login_required
  1047. def delete_attachment(attachment_id):
  1048. attachment = ContractAttachment.query.get_or_404(attachment_id)
  1049. contract_id = attachment.contract_id
  1050. if not current_user.can_edit:
  1051. flash('您没有编辑合同的权限', 'danger')
  1052. return redirect(url_for('view_contract', contract_id=contract_id))
  1053. try:
  1054. # Delete file from filesystem
  1055. if os.path.exists(attachment.filepath):
  1056. os.remove(attachment.filepath)
  1057. # Delete record from database
  1058. db.session.delete(attachment)
  1059. db.session.commit()
  1060. flash('附件已删除', 'success')
  1061. except Exception as e:
  1062. db.session.rollback()
  1063. flash(f'删除附件失败: {str(e)}', 'danger')
  1064. app.logger.error(f'Attachment deletion failed: {str(e)}')
  1065. return redirect(url_for('view_contract', contract_id=contract_id))
  1066. # -----------------------------
  1067. # 下载附件
  1068. # -----------------------------
  1069. @app.route('/download/<int:attachment_id>')
  1070. @login_required
  1071. def download_file(attachment_id):
  1072. # 获取附件对象
  1073. attachment = ContractAttachment.query.get_or_404(attachment_id)
  1074. # 构建文件路径
  1075. filename = os.path.basename(attachment.filepath)
  1076. upload_folder = os.path.join(app.config['UPLOAD_FOLDER'], 'contracts')
  1077. file_path = os.path.join(upload_folder, filename)
  1078. # 检查文件是否存在
  1079. if not os.path.exists(file_path):
  1080. abort(404)
  1081. # 使用 download_name 参数设置用户友好的文件名
  1082. return send_file(
  1083. file_path,
  1084. as_attachment=True,
  1085. download_name=attachment.filename
  1086. )
  1087. @app.route('/users')
  1088. @login_required
  1089. def user_list():
  1090. if not current_user.is_admin:
  1091. flash('您没有权限访问此页面', 'danger')
  1092. return redirect(url_for('dashboard'))
  1093. users = User.query.order_by(User.is_admin.desc(), User.username).all()
  1094. return render_template('user_list.html', users=users)
  1095. @app.route('/user/create', methods=['GET', 'POST'])
  1096. @login_required
  1097. def create_user():
  1098. if not current_user.is_admin:
  1099. flash('您没有权限访问此页面', 'danger')
  1100. return redirect(url_for('dashboard'))
  1101. if request.method == 'POST':
  1102. try:
  1103. user = User(
  1104. username=request.form['username'],
  1105. password=generate_password_hash(request.form['password']),
  1106. name=request.form['name'],
  1107. department=request.form['department'],
  1108. email=request.form['email'],
  1109. is_admin='is_admin' in request.form,
  1110. can_create='can_create' in request.form,
  1111. can_view='can_view' in request.form,
  1112. can_edit='can_edit' in request.form,
  1113. can_delete='can_delete' in request.form
  1114. )
  1115. db.session.add(user)
  1116. db.session.commit()
  1117. flash('用户创建成功', 'success')
  1118. return redirect(url_for('user_list'))
  1119. except Exception as e:
  1120. db.session.rollback()
  1121. flash(f'创建用户失败: {str(e)}', 'danger')
  1122. app.logger.error(f'User creation failed: {str(e)}')
  1123. return render_template('user_form.html')
  1124. @app.route('/user/edit/<int:user_id>', methods=['GET', 'POST'])
  1125. @login_required
  1126. def edit_user(user_id):
  1127. if not current_user.is_admin:
  1128. flash('您没有权限访问此页面', 'danger')
  1129. return redirect(url_for('dashboard'))
  1130. user = User.query.get_or_404(user_id)
  1131. if request.method == 'POST':
  1132. try:
  1133. user.username = request.form['username']
  1134. if request.form['password']:
  1135. user.password = generate_password_hash(request.form['password'])
  1136. user.name = request.form['name']
  1137. user.department = request.form['department']
  1138. user.email = request.form['email']
  1139. user.is_admin = 'is_admin' in request.form
  1140. user.can_create = 'can_create' in request.form
  1141. user.can_view = 'can_view' in request.form
  1142. user.can_edit = 'can_edit' in request.form
  1143. user.can_delete = 'can_delete' in request.form
  1144. db.session.commit()
  1145. flash('用户信息更新成功', 'success')
  1146. return redirect(url_for('user_list'))
  1147. except Exception as e:
  1148. db.session.rollback()
  1149. flash(f'更新用户信息失败: {str(e)}', 'danger')
  1150. app.logger.error(f'User update failed: {str(e)}')
  1151. return render_template('user_form.html', user=user)
  1152. @app.route('/user/delete/<int:user_id>', methods=['POST'])
  1153. @login_required
  1154. def delete_user(user_id):
  1155. if not current_user.is_admin:
  1156. flash('您没有权限访问此页面', 'danger')
  1157. return redirect(url_for('dashboard'))
  1158. if current_user.id == user_id:
  1159. flash('不能删除当前登录的用户', 'danger')
  1160. return redirect(url_for('user_list'))
  1161. user = User.query.get_or_404(user_id)
  1162. try:
  1163. db.session.delete(user)
  1164. db.session.commit()
  1165. flash('用户删除成功', 'success')
  1166. except Exception as e:
  1167. db.session.rollback()
  1168. flash(f'删除用户失败: {str(e)}', 'danger')
  1169. app.logger.error(f'User deletion failed: {str(e)}')
  1170. return redirect(url_for('user_list'))
  1171. @app.route('/system/config', methods=['GET', 'POST'])
  1172. @login_required
  1173. def system_config():
  1174. if not current_user.is_admin:
  1175. flash('您没有权限访问此页面', 'danger')
  1176. return redirect(url_for('dashboard'))
  1177. config = SystemConfig.query.first()
  1178. if request.method == 'POST':
  1179. config.site_name = request.form['site_name']
  1180. config.mail_server = request.form['mail_server']
  1181. config.mail_port = int(request.form['mail_port'])
  1182. config.mail_username = request.form['mail_username']
  1183. config.mail_password = request.form['mail_password']
  1184. config.mail_use_tls = 'mail_use_tls' in request.form
  1185. db.session.commit()
  1186. flash('系统配置更新成功', 'success')
  1187. return redirect(url_for('system_config'))
  1188. departments = Department.query.all()
  1189. entities = CompanyEntity.query.all()
  1190. contract_types = ContractType.query.all()
  1191. return render_template('system_config.html', config=config,
  1192. departments=departments,
  1193. entities=entities,
  1194. contract_types=contract_types)
  1195. @app.route('/system/config/department/add', methods=['POST'])
  1196. @login_required
  1197. def add_department():
  1198. data = request.get_json()
  1199. dept = Department(name=data['name'])
  1200. db.session.add(dept)
  1201. db.session.commit()
  1202. return '', 204
  1203. @app.route('/system/config/entity/add', methods=['POST'])
  1204. @login_required
  1205. def add_entity():
  1206. data = request.get_json()
  1207. entity = CompanyEntity(name=data['name'])
  1208. db.session.add(entity)
  1209. db.session.commit()
  1210. return '', 204
  1211. @app.route('/system/config/type/add', methods=['POST'])
  1212. @login_required
  1213. def add_contract_type():
  1214. data = request.get_json()
  1215. ct = ContractType(name=data['name'])
  1216. db.session.add(ct)
  1217. db.session.commit()
  1218. return '', 204
  1219. @app.route('/system/config/<string:type>/delete/<int:id>', methods=['POST'])
  1220. @login_required
  1221. def delete_config_item(type, id):
  1222. model = {'department': Department, 'entity': CompanyEntity, 'type': ContractType}[type]
  1223. item = model.query.get_or_404(id)
  1224. db.session.delete(item)
  1225. db.session.commit()
  1226. return '', 204
  1227. @app.route('/system/config/scheduler', methods=['POST'])
  1228. @login_required
  1229. def save_scheduler_config():
  1230. if not current_user.is_admin:
  1231. return jsonify({'error': '无权访问'}), 403
  1232. data = request.get_json()
  1233. # 确保在应用上下文中执行数据库操作
  1234. with app.app_context():
  1235. config = SystemConfig.query.first()
  1236. if not config:
  1237. return jsonify({'error': '系统配置未初始化'}), 400
  1238. try:
  1239. config.scheduler_frequency = data.get('frequency', 'daily')
  1240. config.scheduler_weekdays = data.get('weekdays', [])
  1241. config.scheduler_month_day = data.get('month_day', 1)
  1242. config.scheduler_hour = data.get('hour', 9)
  1243. config.scheduler_minute = data.get('minute', 0)
  1244. db.session.commit()
  1245. # 更新定时任务
  1246. scheduler.reschedule_job(
  1247. 'daily_contract_check',
  1248. trigger=create_scheduler_trigger()
  1249. )
  1250. return jsonify({'status': 'success'})
  1251. except Exception as e:
  1252. db.session.rollback()
  1253. return jsonify({'error': str(e)}), 500
  1254. def update_scheduler_job(config):
  1255. """根据配置更新定时任务"""
  1256. scheduler.remove_job('daily_contract_check')
  1257. if config.scheduler_frequency == 'daily':
  1258. # 每天执行
  1259. scheduler.add_job(
  1260. check_expiring_contracts,
  1261. CronTrigger(
  1262. hour=config.scheduler_hour,
  1263. minute=config.scheduler_minute,
  1264. timezone='Asia/Shanghai'
  1265. ),
  1266. id='daily_contract_check'
  1267. )
  1268. elif config.scheduler_frequency == 'weekly':
  1269. # 每周执行
  1270. scheduler.add_job(
  1271. check_expiring_contracts,
  1272. CronTrigger(
  1273. day_of_week=','.join(map(str, config.scheduler_weekdays)),
  1274. hour=config.scheduler_hour,
  1275. minute=config.scheduler_minute,
  1276. timezone='Asia/Shanghai'
  1277. ),
  1278. id='daily_contract_check'
  1279. )
  1280. elif config.scheduler_frequency == 'monthly':
  1281. # 每月执行
  1282. scheduler.add_job(
  1283. check_expiring_contracts,
  1284. CronTrigger(
  1285. day=config.scheduler_month_day,
  1286. hour=config.scheduler_hour,
  1287. minute=config.scheduler_minute,
  1288. timezone='Asia/Shanghai'
  1289. ),
  1290. id='daily_contract_check'
  1291. )
  1292. print(f"⏰ 定时任务已更新: {config.scheduler_frequency} {config.scheduler_hour}:{config.scheduler_minute}")
  1293. @app.route('/api/contracts/expiring')
  1294. @login_required
  1295. def api_expiring_contracts():
  1296. if not current_user.can_view:
  1297. return jsonify({'error': '无权访问'}), 403
  1298. days = request.args.get('days', default=30, type=int)
  1299. today = datetime.now().date() # 改用本地时间
  1300. end_date = today + timedelta(days=days)
  1301. contracts = Contract.query.filter(
  1302. Contract.end_date <= end_date,
  1303. Contract.end_date >= today,
  1304. Contract.is_active == True
  1305. ).order_by(Contract.end_date).all()
  1306. result = [{
  1307. 'id': c.id,
  1308. 'name': c.name,
  1309. 'contract_number': c.contract_number,
  1310. 'end_date': c.end_date.strftime('%Y-%m-%d'),
  1311. 'days_left': (c.end_date - today).days,
  1312. 'url': url_for('view_contract', contract_id=c.id)
  1313. } for c in contracts]
  1314. return jsonify(result)
  1315. @app.errorhandler(404)
  1316. def page_not_found(e):
  1317. return render_template('404.html'), 404
  1318. @app.errorhandler(403)
  1319. def forbidden(e):
  1320. return render_template('403.html'), 403
  1321. @app.errorhandler(500)
  1322. def internal_server_error(e):
  1323. db.session.rollback()
  1324. return render_template('500.html'), 500
  1325. @app.route('/uploads/icons/<filename>')
  1326. def uploaded_icon(filename):
  1327. icon_dir = os.path.join(app.instance_path, 'uploads', 'icons')
  1328. return send_from_directory(icon_dir, filename)
  1329. @app.context_processor
  1330. def inject_config():
  1331. # 从数据库获取配置,假设你有 Config 模型
  1332. config = current_app.config.get('SITE_CONFIG') # 或者从数据库取
  1333. return dict(config=config)
  1334. @app.context_processor
  1335. def inject_now():
  1336. # 返回当前时间戳,避免浏览器缓存旧图标
  1337. return {'now_timestamp': int(datetime.utcnow().timestamp())}
  1338. @app.route('/profile', methods=['GET', 'POST'])#个人资料
  1339. @login_required
  1340. def profile():
  1341. if request.method == 'POST':
  1342. try:
  1343. current_user.name = request.form['name']
  1344. current_user.department = request.form['department']
  1345. current_user.email = request.form['email']
  1346. if request.form['password']:
  1347. current_user.password = generate_password_hash(request.form['password'])
  1348. db.session.commit()
  1349. flash('个人资料更新成功', 'success')
  1350. except Exception as e:
  1351. db.session.rollback()
  1352. flash(f'更新失败: {str(e)}', 'danger')
  1353. return render_template('profile.html', user=current_user)
  1354. @app.route('/contract/delete/permanent/<int:contract_id>', methods=['POST'])
  1355. @login_required
  1356. def delete_contract_permanent(contract_id):
  1357. contract = Contract.query.get_or_404(contract_id)
  1358. if not current_user.can_delete:
  1359. flash('您没有删除合同的权限', 'danger')
  1360. return redirect(url_for('view_contract', contract_id=contract_id))
  1361. try:
  1362. # 删除附件文件
  1363. for attachment in contract.attachments:
  1364. if os.path.exists(attachment.filepath):
  1365. os.remove(attachment.filepath)
  1366. db.session.delete(attachment)
  1367. # 删除合同方
  1368. for cp in contract.counterparties:
  1369. db.session.delete(cp)
  1370. # 删除合同版本
  1371. for version in contract.versions:
  1372. db.session.delete(version)
  1373. db.session.delete(contract)
  1374. db.session.commit()
  1375. flash('合同已彻底删除', 'success')
  1376. return redirect(url_for('contract_list'))
  1377. except Exception as e:
  1378. db.session.rollback()
  1379. flash(f'删除合同失败: {str(e)}', 'danger')
  1380. app.logger.error(f'Permanent contract deletion failed: {str(e)}')
  1381. return redirect(url_for('view_contract', contract_id=contract_id))
  1382. @app.route('/api/contracts/uncollected')
  1383. @login_required
  1384. def api_uncollected_contracts():
  1385. if not current_user.can_view:
  1386. return jsonify({'error': '无权访问'}), 403
  1387. contracts = Contract.query.filter(
  1388. Contract.collected_date.is_(None)
  1389. ).order_by(Contract.end_date.desc()).all()
  1390. result = [{
  1391. 'id': c.id,
  1392. 'name': c.name,
  1393. 'contract_number': c.contract_number,
  1394. 'end_date': c.end_date.strftime('%Y-%m-%d') if c.end_date else None,
  1395. 'url': url_for('view_contract', contract_id=c.id)
  1396. } for c in contracts]
  1397. return jsonify(result)
  1398. # 设置合同类型前缀
  1399. @app.route('/system/config/type/set_prefix/<int:type_id>', methods=['POST'])
  1400. @login_required
  1401. def set_contract_type_prefix(type_id):
  1402. from flask import request, jsonify
  1403. data = request.get_json()
  1404. prefix = data.get('prefix', '').strip()
  1405. ct = ContractType.query.get_or_404(type_id)
  1406. ct.prefix = prefix
  1407. db.session.commit()
  1408. return jsonify({"status": "ok"})
  1409. @login_required
  1410. def download_attachment(attachment_id):
  1411. attachment = ContractAttachment.query.get_or_404(attachment_id)
  1412. file_path = os.path.join(UPLOAD_FOLDER, attachment.filename)
  1413. if os.path.exists(file_path):
  1414. return send_from_directory(UPLOAD_FOLDER, attachment.filename, as_attachment=True)
  1415. else:
  1416. abort(404)
  1417. @app.route('/favicon.ico')
  1418. def favicon():
  1419. return send_from_directory(
  1420. os.path.join(app.root_path, 'static'),
  1421. 'favicon.ico',
  1422. mimetype='image/vnd.microsoft.icon'
  1423. )
  1424. # 在应用启动时调用
  1425. if __name__ == '__main__':
  1426. if not os.path.exists(os.path.join(app.instance_path, 'contracts.db')):
  1427. init_db()
  1428. with app.app_context():
  1429. load_mail_config()
  1430. app.run(debug=False, port=8082)