更改总计逻辑
This commit is contained in:
4
app.py
4
app.py
@@ -14,6 +14,7 @@ from lib.report_generator import generate_daily_report
|
||||
from lib.api import register_blueprints
|
||||
from lib.db_init import init_database_tables
|
||||
from lib.logger import log_info, log_error, log_warning
|
||||
from lib.api.download_api import cleanup_old_temp_zips
|
||||
|
||||
# 创建 Flask 应用
|
||||
app = Flask(__name__,
|
||||
@@ -89,8 +90,9 @@ def scheduled_job():
|
||||
# 启动定时任务
|
||||
scheduler = BackgroundScheduler(timezone='Asia/Shanghai')
|
||||
scheduler.add_job(scheduled_job, 'cron', hour=8, minute=1)
|
||||
scheduler.add_job(cleanup_old_temp_zips, 'interval', hours=1)
|
||||
scheduler.start()
|
||||
log_info('定时任务已启动:每天 08:01 执行', 'app')
|
||||
log_info('定时任务已启动:每天 08:01 执行日报生成,每小时清理临时文件', 'app')
|
||||
|
||||
|
||||
# ==================== 启动应用 ====================
|
||||
|
||||
@@ -2,39 +2,203 @@
|
||||
文件下载 API
|
||||
"""
|
||||
import os
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from flask import request, jsonify, send_from_directory
|
||||
from lib.db import execute_query
|
||||
from lib.logger import log_info, log_error
|
||||
from . import download_bp
|
||||
|
||||
|
||||
def get_reports_dir():
|
||||
"""获取报表文件目录"""
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'public', 'reports')
|
||||
|
||||
|
||||
def get_temp_dir():
|
||||
"""获取临时zip文件目录"""
|
||||
temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'public', 'temp_zip')
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
return temp_dir
|
||||
|
||||
|
||||
def cleanup_old_temp_zips():
|
||||
"""清理1小时前的临时zip文件"""
|
||||
try:
|
||||
temp_dir = get_temp_dir()
|
||||
now = datetime.now().timestamp()
|
||||
count = 0
|
||||
for filename in os.listdir(temp_dir):
|
||||
if filename.endswith('.zip'):
|
||||
filepath = os.path.join(temp_dir, filename)
|
||||
if now - os.path.getmtime(filepath) > 3600: # 1小时前的
|
||||
os.remove(filepath)
|
||||
count += 1
|
||||
if count > 0:
|
||||
log_info(f'[下载API] 清理了 {count} 个过期临时zip文件', 'download')
|
||||
except Exception as e:
|
||||
log_error(f'[下载API] 清理临时文件失败: {e}', 'download')
|
||||
|
||||
|
||||
@download_bp.route('/download', methods=['GET'])
|
||||
def download_file():
|
||||
"""下载文件"""
|
||||
"""下载文件
|
||||
支持两种方式:
|
||||
1. path: 直接传文件路径
|
||||
2. id: 传历史记录ID,从数据库查询文件路径
|
||||
"""
|
||||
try:
|
||||
file_path = request.args.get('path')
|
||||
print(f"[下载] 请求路径:{file_path}")
|
||||
history_id = request.args.get('id')
|
||||
|
||||
log_info(f'[下载API] 请求,path={file_path}, id={history_id}', 'download')
|
||||
|
||||
# 如果传了id,从历史记录表查询文件路径
|
||||
if history_id:
|
||||
result = execute_query(
|
||||
'SELECT file_path, status FROM t_daily_report_history WHERE id = %s LIMIT 1',
|
||||
(history_id,)
|
||||
)
|
||||
if not result:
|
||||
log_error(f'[下载API] 历史记录不存在,id={history_id}', 'download')
|
||||
return jsonify({'success': False, 'message': '历史记录不存在'}), 404
|
||||
|
||||
history = result[0]
|
||||
if history.get('status') != 1:
|
||||
log_error(f'[下载API] 生成失败的记录无法下载,id={history_id}', 'download')
|
||||
return jsonify({'success': False, 'message': '该记录生成失败,无法下载'}), 400
|
||||
|
||||
file_path = history.get('file_path')
|
||||
if not file_path:
|
||||
log_error(f'[下载API] 文件路径为空,id={history_id}', 'download')
|
||||
return jsonify({'success': False, 'message': '文件路径不能为空'}), 400
|
||||
|
||||
log_info(f'[下载API] 从历史记录查询到文件路径: {file_path}', 'download')
|
||||
|
||||
if not file_path:
|
||||
return jsonify({'success': False, 'message': '文件路径不能为空'}), 400
|
||||
|
||||
# 安全检查:确保路径在 public 目录下
|
||||
if not file_path.startswith('/reports/'):
|
||||
# 安全检查:确保路径在 public/reports 目录下
|
||||
if not file_path.startswith('/reports/') and not file_path.startswith('/public/reports/'):
|
||||
return jsonify({'success': False, 'message': '无效的文件路径'}), 400
|
||||
|
||||
filename = file_path.replace('/reports/', '')
|
||||
# 使用绝对路径
|
||||
reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'public', 'reports')
|
||||
# 提取文件名
|
||||
if file_path.startswith('/public/reports/'):
|
||||
filename = file_path.replace('/public/reports/', '')
|
||||
else:
|
||||
filename = file_path.replace('/reports/', '')
|
||||
|
||||
print(f"[下载] 文件目录:{reports_dir}")
|
||||
print(f"[下载] 文件名:{filename}")
|
||||
print(f"[下载] 文件是否存在:{os.path.exists(os.path.join(reports_dir, filename))}")
|
||||
|
||||
# 列出目录中的所有文件
|
||||
if os.path.exists(reports_dir):
|
||||
print(f"[下载] 目录中的文件:{os.listdir(reports_dir)}")
|
||||
reports_dir = get_reports_dir()
|
||||
full_path = os.path.join(reports_dir, filename)
|
||||
if not os.path.exists(full_path):
|
||||
log_error(f'[下载API] 文件不存在: {full_path}', 'download')
|
||||
return jsonify({'success': False, 'message': '文件不存在'}), 404
|
||||
|
||||
log_info(f'[下载API] 下载文件: {filename}', 'download')
|
||||
return send_from_directory(reports_dir, filename, as_attachment=True)
|
||||
except Exception as e:
|
||||
print(f"[下载] 错误:{str(e)}")
|
||||
log_error(f'[下载API] 错误: {e}', 'download')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@download_bp.route('/download/batch', methods=['POST'])
|
||||
def batch_download():
|
||||
"""批量打包下载
|
||||
接收 ids 数组,将对应的报表文件打包成zip后下载
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
ids = data.get('ids', [])
|
||||
|
||||
log_info(f'[下载API] 批量下载,数量: {len(ids)}', 'download')
|
||||
|
||||
if not ids or len(ids) == 0:
|
||||
return jsonify({'success': False, 'message': '请选择要下载的记录'}), 400
|
||||
|
||||
# 清理旧的临时文件
|
||||
cleanup_old_temp_zips()
|
||||
|
||||
# 查询所有成功的历史记录
|
||||
placeholders = ', '.join(['%s'] * len(ids))
|
||||
result = execute_query(
|
||||
f'SELECT id, file_path, config_name, report_date, status FROM t_daily_report_history WHERE id IN ({placeholders})',
|
||||
tuple(ids)
|
||||
)
|
||||
|
||||
if not result:
|
||||
return jsonify({'success': False, 'message': '未找到对应的历史记录'}), 404
|
||||
|
||||
reports_dir = get_reports_dir()
|
||||
temp_dir = get_temp_dir()
|
||||
|
||||
# 收集要打包的文件
|
||||
files_to_zip = []
|
||||
failed_names = []
|
||||
|
||||
for item in result:
|
||||
if item.get('status') != 1:
|
||||
failed_names.append(f"{item.get('config_name', '未知')} (生成失败)")
|
||||
continue
|
||||
|
||||
file_path = item.get('file_path')
|
||||
if not file_path:
|
||||
failed_names.append(f"{item.get('config_name', '未知')} (无文件)")
|
||||
continue
|
||||
|
||||
# 提取文件名
|
||||
if file_path.startswith('/public/reports/'):
|
||||
filename = file_path.replace('/public/reports/', '')
|
||||
elif file_path.startswith('/reports/'):
|
||||
filename = file_path.replace('/reports/', '')
|
||||
else:
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
full_path = os.path.join(reports_dir, filename)
|
||||
if not os.path.exists(full_path):
|
||||
failed_names.append(f"{item.get('config_name', '未知')} (文件不存在)")
|
||||
continue
|
||||
|
||||
# 使用配置名作为zip内的文件名,避免重名
|
||||
config_name = item.get('config_name', '未知')
|
||||
report_date = item.get('report_date', '')
|
||||
ext = os.path.splitext(filename)[1]
|
||||
if report_date:
|
||||
zip_inner_name = f"{config_name}_{report_date}{ext}"
|
||||
else:
|
||||
zip_inner_name = f"{config_name}{ext}"
|
||||
|
||||
files_to_zip.append({
|
||||
'full_path': full_path,
|
||||
'inner_name': zip_inner_name
|
||||
})
|
||||
|
||||
if len(files_to_zip) == 0:
|
||||
return jsonify({'success': False, 'message': '没有可下载的文件'}), 400
|
||||
|
||||
# 生成zip文件名
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
zip_filename = f'批量下载_{timestamp}.zip'
|
||||
zip_path = os.path.join(temp_dir, zip_filename)
|
||||
|
||||
# 打包
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for file_info in files_to_zip:
|
||||
zf.write(file_info['full_path'], file_info['inner_name'])
|
||||
|
||||
log_info(f'[下载API] 批量打包完成,共 {len(files_to_zip)} 个文件,失败 {len(failed_names)} 个', 'download')
|
||||
|
||||
# 返回zip文件
|
||||
from flask import send_file
|
||||
return send_file(
|
||||
zip_path,
|
||||
as_attachment=True,
|
||||
download_name=zip_filename,
|
||||
mimetype='application/zip'
|
||||
)
|
||||
except Exception as e:
|
||||
log_error(f'[下载API] 批量下载错误: {e}', 'download')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
@@ -20,7 +20,7 @@ from . import sum_data_bp
|
||||
def collect_sum_data():
|
||||
"""从历史记录采集求和数据"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
history_id = data.get('history_id')
|
||||
log_info(f'[求和数据API] 采集求和数据,历史记录ID: {history_id or "全部"}', 'api')
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill
|
||||
import pytz
|
||||
|
||||
from lib.db import execute_query, execute_insert
|
||||
from lib.db import execute_query, execute_insert, execute_update
|
||||
from lib.field_mapping import get_field_display_name
|
||||
from lib.logger import log_info, log_error, log_warning
|
||||
|
||||
@@ -328,6 +328,10 @@ def generate_daily_report(config_id, start_time=None, end_time=None):
|
||||
log_info(f" 时间范围: {start_time_str} 至 {end_time_str}", 'report')
|
||||
log_info(f" 选择字段: {len(selected_fields)} 个", 'report')
|
||||
|
||||
# 删除同一配置、同一日期、同一拆分的旧数据(覆盖)
|
||||
report_date = start_time.strftime('%Y-%m-%d')
|
||||
delete_old_history(config_id, report_date, config['split_type'], config['split_value'])
|
||||
|
||||
# 构建查询SQL - 过滤掉虚拟字段(时段电量等)
|
||||
# 虚拟字段不是数据库表中的实际字段,需要动态计算
|
||||
TIME_PERIOD_FIELDS = {'sharp_electricity', 'peak_electricity', 'flat_electricity', 'valley_electricity'}
|
||||
@@ -835,19 +839,25 @@ def generate_excel(orders, selected_fields, sum_fields, sum_results,
|
||||
config_id=config_id,
|
||||
month=month,
|
||||
split_type=split_type,
|
||||
split_value=split_value
|
||||
split_value=split_value,
|
||||
end_date=report_date
|
||||
)
|
||||
|
||||
if monthly_result['success'] and monthly_result['data']:
|
||||
total_degree = monthly_result['data']['total_degree']
|
||||
total_days = monthly_result['data']['total_days']
|
||||
today_degree_from_table = monthly_result['data'].get('today_degree', 0)
|
||||
missing_dates = monthly_result['data'].get('missing_dates', [])
|
||||
expected_days = monthly_result['data'].get('expected_days', 0)
|
||||
|
||||
# 如果当天的数据还没采集进 sum_data 表,手动加上当天的
|
||||
today_charge_degree = sum_results.get('charge_degree', 0)
|
||||
if today_degree_from_table == 0 and today_charge_degree > 0:
|
||||
total_degree = round(total_degree + today_charge_degree, 3)
|
||||
total_days = total_days + 1
|
||||
# 加上当天后,缺失日期也相应减少一天
|
||||
if report_date in missing_dates:
|
||||
missing_dates = [d for d in missing_dates if d != report_date]
|
||||
|
||||
# 月度总计行(在合计行下面空一行)
|
||||
monthly_row = sum_row + 2 if sum_fields else sum_row + 1
|
||||
@@ -860,10 +870,19 @@ def generate_excel(orders, selected_fields, sum_fields, sum_results,
|
||||
break
|
||||
|
||||
# 第一列显示标题
|
||||
ws.cell(row=monthly_row, column=1).value = f'当月累计充电量(共{total_days}天)'
|
||||
title_text = f'当月累计充电量(共{total_days}天,截止{report_date})'
|
||||
if missing_dates and len(missing_dates) > 0:
|
||||
# 最多显示5个缺失日期,超过的用"...共N天"表示
|
||||
if len(missing_dates) <= 5:
|
||||
missing_str = '、'.join([d[5:] for d in missing_dates]) # 只显示月-日
|
||||
else:
|
||||
missing_str = '、'.join([d[5:] for d in missing_dates[:5]]) + f'...共{len(missing_dates)}天'
|
||||
title_text += f'\n(缺:{missing_str})'
|
||||
|
||||
ws.cell(row=monthly_row, column=1).value = title_text
|
||||
ws.cell(row=monthly_row, column=1).font = monthly_font
|
||||
ws.cell(row=monthly_row, column=1).fill = monthly_fill
|
||||
ws.cell(row=monthly_row, column=1).alignment = Alignment(horizontal='center', vertical='center')
|
||||
ws.cell(row=monthly_row, column=1).alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
ws.cell(row=monthly_row, column=1).border = thin_border
|
||||
|
||||
# 其他列也填充背景色
|
||||
@@ -882,7 +901,11 @@ def generate_excel(orders, selected_fields, sum_fields, sum_results,
|
||||
cell.alignment = Alignment(horizontal='right', vertical='center')
|
||||
cell.number_format = '0.000'
|
||||
|
||||
ws.row_dimensions[monthly_row].height = 25
|
||||
# 如果有缺失日期,行高加大一点
|
||||
if missing_dates and len(missing_dates) > 0:
|
||||
ws.row_dimensions[monthly_row].height = 40
|
||||
else:
|
||||
ws.row_dimensions[monthly_row].height = 25
|
||||
except Exception as e:
|
||||
log_error(f"[日报生成] 写入月度总计时出错: {e}", 'report')
|
||||
|
||||
@@ -978,4 +1001,83 @@ def save_report_history(report_date, start_time, end_time, split_type, split_val
|
||||
result = execute_insert(sql, params)
|
||||
log_info(f"[保存历史] 插入完成,返回ID: {result}", 'report')
|
||||
|
||||
return history_id
|
||||
return history_id
|
||||
|
||||
|
||||
def delete_old_history(config_id, report_date, split_type, split_value):
|
||||
"""
|
||||
删除同一配置、同一日期、同一拆分的旧历史记录,包括:
|
||||
1. 旧的报表文件
|
||||
2. 旧的求和数据(自动采集的)
|
||||
3. 旧的历史记录
|
||||
|
||||
Args:
|
||||
config_id: 配置ID
|
||||
report_date: 报表日期(YYYY-MM-DD)
|
||||
split_type: 拆分方式
|
||||
split_value: 拆分值
|
||||
"""
|
||||
try:
|
||||
log_info(f"[删除旧数据] 检查是否有旧数据,配置ID={config_id}, 日期={report_date}, 拆分={split_type}={split_value}", 'report')
|
||||
|
||||
# 查询旧的历史记录
|
||||
old_histories = execute_query(
|
||||
'''SELECT id, file_path FROM t_daily_report_history
|
||||
WHERE config_id = %s AND report_date = %s AND split_type = %s AND split_value = %s''',
|
||||
(config_id, report_date, split_type, split_value)
|
||||
)
|
||||
|
||||
if not old_histories:
|
||||
log_info("[删除旧数据] 没有找到旧数据", 'report')
|
||||
return
|
||||
|
||||
log_info(f"[删除旧数据] 找到 {len(old_histories)} 条旧记录", 'report')
|
||||
|
||||
reports_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'public', 'reports')
|
||||
|
||||
for history in old_histories:
|
||||
history_id = history['id']
|
||||
file_path = history.get('file_path', '')
|
||||
|
||||
# 删除报表文件
|
||||
if file_path:
|
||||
try:
|
||||
if file_path.startswith('/reports/'):
|
||||
filename = file_path.replace('/reports/', '')
|
||||
elif file_path.startswith('/public/reports/'):
|
||||
filename = file_path.replace('/public/reports/', '')
|
||||
else:
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
full_path = os.path.join(reports_dir, filename)
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
log_info(f"[删除旧数据] 已删除文件: {full_path}", 'report')
|
||||
except Exception as e:
|
||||
log_warning(f"[删除旧数据] 删除文件失败: {e}", 'report')
|
||||
|
||||
# 删除求和数据(自动采集的)
|
||||
try:
|
||||
execute_update(
|
||||
'''DELETE FROM t_daily_report_sum_data
|
||||
WHERE report_date = %s AND config_id = %s
|
||||
AND split_type = %s AND split_value = %s AND data_source = 'auto' ''',
|
||||
(report_date, config_id, split_type, split_value)
|
||||
)
|
||||
log_info(f"[删除旧数据] 已删除旧的求和数据", 'report')
|
||||
except Exception as e:
|
||||
log_warning(f"[删除旧数据] 删除求和数据失败: {e}", 'report')
|
||||
|
||||
# 删除历史记录
|
||||
try:
|
||||
execute_update(
|
||||
'DELETE FROM t_daily_report_history WHERE id = %s',
|
||||
(history_id,)
|
||||
)
|
||||
log_info(f"[删除旧数据] 已删除历史记录 ID={history_id}", 'report')
|
||||
except Exception as e:
|
||||
log_warning(f"[删除旧数据] 删除历史记录失败: {e}", 'report')
|
||||
|
||||
log_info("[删除旧数据] 旧数据清理完成", 'report')
|
||||
except Exception as e:
|
||||
log_error(f"[删除旧数据] 异常: {e}", 'report')
|
||||
@@ -670,33 +670,41 @@ def export_sum_data_to_excel(start_date=None, end_date=None, config_id=None, dat
|
||||
return {'success': False, 'message': str(e)}
|
||||
|
||||
|
||||
def get_monthly_charge_degree_total(config_id=None, month=None, split_type=None, split_value=None):
|
||||
def get_monthly_charge_degree_total(config_id=None, month=None, split_type=None, split_value=None, end_date=None):
|
||||
"""
|
||||
获取当月充电电量累计(从求和数据表查询,对账后的准确数据)
|
||||
- 如果当天已经生成了日报并采集,自动包含在内,不会重复计算
|
||||
- 以配置当前拆分为准,只统计匹配的拆分值数据
|
||||
- 截止到 end_date(报表日期),不包含 end_date 之后的数据
|
||||
- 返回缺失的日期列表(从月初到 end_date 之间缺少哪些天的数据)
|
||||
|
||||
Args:
|
||||
config_id: 配置ID(可选,不传则统计所有配置)
|
||||
month: 月份,格式 YYYY-MM(可选,默认当月)
|
||||
split_type: 拆分类型(可选,company_id/user_id/station_id)
|
||||
split_value: 拆分值,逗号分隔的字符串(可选,用于过滤)
|
||||
end_date: 截止日期,格式 YYYY-MM-DD(可选,默认当月最后一天)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
success: bool,
|
||||
data: {
|
||||
total_degree: float, // 当月累计充电电量(kWh)
|
||||
total_degree: float, // 累计充电电量(kWh)
|
||||
total_days: int, // 有数据的天数
|
||||
total_orders: int, // 累计订单数
|
||||
today_degree: float, // 当天充电电量(kWh)(如果有的话)
|
||||
today_degree: float, // 报表日期当天的电量(kWh)
|
||||
month: str, // 统计月份
|
||||
detail: list // 按天明细
|
||||
detail: list, // 按天明细
|
||||
end_date: str, // 截止日期
|
||||
missing_dates: list, // 缺失的日期列表
|
||||
expected_days: int // 应该有的天数(从月初到end_date)
|
||||
},
|
||||
message: str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 默认当月
|
||||
if not month:
|
||||
month = datetime.now().strftime('%Y-%m')
|
||||
@@ -704,6 +712,17 @@ def get_monthly_charge_degree_total(config_id=None, month=None, split_type=None,
|
||||
# 拼接日期模糊匹配前缀
|
||||
date_prefix = month + '-'
|
||||
|
||||
# 计算截止日期(默认报表日期或当月最后一天)
|
||||
if end_date:
|
||||
end_date_str = end_date
|
||||
else:
|
||||
# 取当月最后一天
|
||||
if month.endswith('-12'):
|
||||
next_month = str(int(month[:4]) + 1) + '-01'
|
||||
else:
|
||||
next_month = month[:5] + str(int(month[5:7]) + 1).zfill(2)
|
||||
end_date_str = (datetime.strptime(next_month + '-01', '%Y-%m-%d') - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
|
||||
# 构建查询SQL
|
||||
sql = """
|
||||
SELECT
|
||||
@@ -713,8 +732,9 @@ def get_monthly_charge_degree_total(config_id=None, month=None, split_type=None,
|
||||
FROM t_daily_report_sum_data
|
||||
WHERE sum_field_key = 'charge_degree'
|
||||
AND report_date LIKE %s
|
||||
AND report_date <= %s
|
||||
"""
|
||||
params = [date_prefix + '%']
|
||||
params = [date_prefix + '%', end_date_str]
|
||||
|
||||
if config_id:
|
||||
sql += ' AND config_id = %s'
|
||||
@@ -733,11 +753,18 @@ def get_monthly_charge_degree_total(config_id=None, month=None, split_type=None,
|
||||
|
||||
rows = execute_query(sql, tuple(params))
|
||||
|
||||
# 计算从月初到end_date应该有多少天
|
||||
start_of_month = month + '-01'
|
||||
start_date_obj = datetime.strptime(start_of_month, '%Y-%m-%d')
|
||||
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d')
|
||||
expected_days = (end_date_obj - start_date_obj).days + 1
|
||||
|
||||
# 收集已有数据的日期
|
||||
existing_dates = set()
|
||||
total_degree = 0.0
|
||||
total_orders = 0
|
||||
total_days = len(rows)
|
||||
today_degree = 0.0
|
||||
today_str = datetime.now().strftime('%Y-%m-%d')
|
||||
detail = []
|
||||
|
||||
for row in rows:
|
||||
@@ -745,14 +772,23 @@ def get_monthly_charge_degree_total(config_id=None, month=None, split_type=None,
|
||||
orders = int(row['total_orders'] or 0)
|
||||
total_degree += degree
|
||||
total_orders += orders
|
||||
existing_dates.add(row['report_date'])
|
||||
detail.append({
|
||||
'report_date': row['report_date'],
|
||||
'degree': round(degree, 3),
|
||||
'orders': orders
|
||||
})
|
||||
if row['report_date'] == today_str:
|
||||
if row['report_date'] == end_date_str:
|
||||
today_degree = degree
|
||||
|
||||
# 计算缺失的日期
|
||||
missing_dates = []
|
||||
for i in range(expected_days):
|
||||
date_obj = start_date_obj + timedelta(days=i)
|
||||
date_str = date_obj.strftime('%Y-%m-%d')
|
||||
if date_str not in existing_dates:
|
||||
missing_dates.append(date_str)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'data': {
|
||||
@@ -761,7 +797,10 @@ def get_monthly_charge_degree_total(config_id=None, month=None, split_type=None,
|
||||
'total_orders': total_orders,
|
||||
'today_degree': round(today_degree, 3),
|
||||
'month': month,
|
||||
'detail': detail
|
||||
'detail': detail,
|
||||
'end_date': end_date_str,
|
||||
'missing_dates': missing_dates,
|
||||
'expected_days': expected_days
|
||||
},
|
||||
'message': '查询成功'
|
||||
}
|
||||
|
||||
@@ -872,3 +872,137 @@ color: #666;
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* 生成中动态效果 */
|
||||
.generate-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.generate-loading-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #bee5eb;
|
||||
border-top-color: #17a2b8;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.generate-loading-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.generate-loading-progress {
|
||||
font-size: 13px;
|
||||
color: #31708f;
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.generate-progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #bee5eb;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.generate-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #17a2b8, #3498db);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.generate-item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.generate-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
border: 1px solid #d1ecf1;
|
||||
}
|
||||
|
||||
.generate-item-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.generate-item-icon.pending {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.generate-item-icon.running {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
color: #17a2b8;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.generate-item-icon.success {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.generate-item-icon.fail {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.generate-item-name {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.generate-item.running .generate-item-name {
|
||||
font-weight: 600;
|
||||
color: #17a2b8;
|
||||
}
|
||||
|
||||
.generate-item-status {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.generate-item.running .generate-item-status {
|
||||
color: #17a2b8;
|
||||
}
|
||||
|
||||
.generate-item.success .generate-item-status {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.generate-item.fail .generate-item-status {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,50 @@ async function batchDownloadHistory() {
|
||||
|
||||
const ids = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||
|
||||
// 逐个下载
|
||||
ids.forEach(id => {
|
||||
window.open(`/api/download?id=${id}`, '_blank');
|
||||
});
|
||||
// 检查是否有生成失败的记录
|
||||
const failedCount = Array.from(checkboxes).filter(cb => cb.dataset.status !== '1').length;
|
||||
const successCount = checkboxes.length - failedCount;
|
||||
|
||||
if (successCount === 0) {
|
||||
alert('选中的记录都是生成失败的,无法下载');
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
if (!confirm(`选中的 ${checkboxes.length} 条记录中有 ${failedCount} 条是生成失败的,无法下载。\n是否打包下载成功的 ${successCount} 条?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/download/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: ids })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, '');
|
||||
a.download = `批量下载_${timestamp}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
try {
|
||||
const result = await response.json();
|
||||
alert('下载失败: ' + (result.message || '未知错误'));
|
||||
} catch (e) {
|
||||
alert('下载失败: 服务器返回错误');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('下载失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期查询
|
||||
@@ -159,7 +199,7 @@ function renderHistory(list) {
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><input type="checkbox" class="history-checkbox" value="${item.id}"></td>
|
||||
<td><input type="checkbox" class="history-checkbox" value="${item.id}" data-status="${item.status}" data-name="${configName}"></td>
|
||||
<td>${reportDate}</td>
|
||||
<td>${timeRange}</td>
|
||||
<td>${configName}</td>
|
||||
|
||||
@@ -193,7 +193,7 @@ function stopGeneratePolling() {
|
||||
function initGenerateState() {
|
||||
const state = getGenerateState();
|
||||
if (state) {
|
||||
const { startTime } = state;
|
||||
const { startTime, configIds } = state;
|
||||
const now = Date.now();
|
||||
const elapsed = now - startTime;
|
||||
|
||||
@@ -208,11 +208,73 @@ function initGenerateState() {
|
||||
|
||||
const resultDiv = document.getElementById('generate-result');
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = '<div class="alert alert-info">检测到有正在生成的报表,请稍候...</div>';
|
||||
let html = '<div class="generate-loading">';
|
||||
html += '<div class="generate-loading-header">';
|
||||
html += '<div class="spinner"></div>';
|
||||
html += '<span class="generate-loading-title">检测到有正在生成的报表</span>';
|
||||
html += `<span class="generate-loading-progress">${configIds.length}个配置</span>`;
|
||||
html += '</div>';
|
||||
html += '<div class="generate-progress-bar">';
|
||||
html += '<div class="generate-progress-fill" style="width: 100%; animation: pulse 1.5s ease-in-out infinite;"></div>';
|
||||
html += '</div>';
|
||||
html += '<div style="font-size: 13px; color: #31708f; margin-top: 4px;">';
|
||||
html += '正在后台生成中,完成后将自动恢复...';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
resultDiv.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染生成中动态效果
|
||||
function renderGenerateLoading(configNames, currentIndex) {
|
||||
const total = configNames.length;
|
||||
const progress = Math.round((currentIndex / total) * 100);
|
||||
|
||||
let html = '<div class="generate-loading">';
|
||||
|
||||
html += '<div class="generate-loading-header">';
|
||||
html += '<div class="spinner"></div>';
|
||||
html += '<span class="generate-loading-title">正在生成日报</span>';
|
||||
html += `<span class="generate-loading-progress">${currentIndex}/${total}</span>`;
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="generate-progress-bar">';
|
||||
html += `<div class="generate-progress-fill" style="width: ${progress}%"></div>`;
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="generate-item-list">';
|
||||
configNames.forEach((name, index) => {
|
||||
let status = 'pending';
|
||||
let icon = '○';
|
||||
let statusText = '等待中';
|
||||
|
||||
if (index < currentIndex) {
|
||||
status = 'success';
|
||||
icon = '✓';
|
||||
statusText = '已完成';
|
||||
} else if (index === currentIndex) {
|
||||
status = 'running';
|
||||
icon = '●';
|
||||
statusText = '生成中...';
|
||||
}
|
||||
|
||||
html += `<div class="generate-item ${status}">`;
|
||||
html += `<span class="generate-item-icon ${status}">${icon}</span>`;
|
||||
html += `<span class="generate-item-name">${name}</span>`;
|
||||
html += `<span class="generate-item-status">${statusText}</span>`;
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
|
||||
const resultDiv = document.getElementById('generate-result');
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成日报(支持多选)
|
||||
async function generateReport() {
|
||||
if (isGenerating) {
|
||||
@@ -237,20 +299,23 @@ async function generateReport() {
|
||||
setGenerateControlsDisabled(true);
|
||||
|
||||
const configIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||
const configNames = Array.from(checkboxes).map(cb => cb.dataset.name);
|
||||
const generateStartTime = Date.now();
|
||||
saveGenerateState(configIds, generateStartTime);
|
||||
startGeneratePolling();
|
||||
|
||||
const resultDiv = document.getElementById('generate-result');
|
||||
resultDiv.innerHTML = '<div class="alert alert-info">正在批量生成日报,请稍候...</div>';
|
||||
|
||||
renderGenerateLoading(configNames, 0);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let results = [];
|
||||
|
||||
for (const cb of checkboxes) {
|
||||
for (let i = 0; i < checkboxes.length; i++) {
|
||||
const cb = checkboxes[i];
|
||||
const configId = parseInt(cb.value);
|
||||
const configName = cb.dataset.name;
|
||||
|
||||
renderGenerateLoading(configNames, i);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/report/generate', {
|
||||
@@ -298,7 +363,10 @@ async function generateReport() {
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
resultDiv.innerHTML = html;
|
||||
const resultDiv = document.getElementById('generate-result');
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
isGenerating = false;
|
||||
setGenerateControlsDisabled(false);
|
||||
|
||||
@@ -182,6 +182,11 @@ async function loadSumData(page = 1) {
|
||||
const configName = document.getElementById('sum-data-config-name').value;
|
||||
const dataSource = document.getElementById('sum-data-source').value;
|
||||
|
||||
const listTbody = document.getElementById('sum-data-list');
|
||||
if (listTbody) {
|
||||
listTbody.innerHTML = '<tr><td colspan="12" class="loading">加载中...</td></tr>';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', '1');
|
||||
params.append('page_size', '5000');
|
||||
@@ -196,10 +201,14 @@ async function loadSumData(page = 1) {
|
||||
if (result.success) {
|
||||
renderSumDataList(result.data.list || []);
|
||||
} else {
|
||||
document.getElementById('sum-data-list').innerHTML = '<tr><td colspan="12" class="empty">加载失败: ' + (result.message || '未知错误') + '</td></tr>';
|
||||
if (listTbody) {
|
||||
listTbody.innerHTML = '<tr><td colspan="12" class="empty">加载失败: ' + (result.message || '未知错误') + '</td></tr>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('sum-data-list').innerHTML = '<tr><td colspan="12" class="empty">加载失败: ' + error.message + '</td></tr>';
|
||||
if (listTbody) {
|
||||
listTbody.innerHTML = '<tr><td colspan="12" class="empty">加载失败: ' + error.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,10 +233,14 @@ function toggleSelectAllSumData() {
|
||||
async function collectSumDataFromHistory() {
|
||||
if (!confirm('确定要从所有成功的历史记录中重新采集求和数据吗?\n(会先清除旧的自动采集数据)')) return;
|
||||
|
||||
const resultDiv = document.getElementById('sum-data-list');
|
||||
resultDiv.innerHTML = '<tr><td colspan="12" class="empty">正在采集中,请稍候...</td></tr>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sum-data/collect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
@@ -235,9 +248,11 @@ async function collectSumDataFromHistory() {
|
||||
loadSumData(1);
|
||||
} else {
|
||||
alert('采集失败: ' + (result.message || '未知错误'));
|
||||
loadSumData(1);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('采集失败: ' + error.message);
|
||||
loadSumData(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,7 +573,13 @@ async function exportSumData() {
|
||||
alert('没有可导出的数据');
|
||||
return;
|
||||
}
|
||||
window.open(result.file_path, '_blank');
|
||||
const a = document.createElement('a');
|
||||
a.href = result.file_path;
|
||||
a.download = result.filename || '求和数据导出.xlsx';
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
alert('导出失败: ' + (result.message || '未知错误'));
|
||||
}
|
||||
|
||||
@@ -372,8 +372,8 @@
|
||||
<script src="/public/static/js/common.js?v=2026071507"></script>
|
||||
<script src="/public/static/js/config.js?v=2026071507"></script>
|
||||
<script src="/public/static/js/entity.js?v=2026071507"></script>
|
||||
<script src="/public/static/js/report.js?v=2026072102"></script>
|
||||
<script src="/public/static/js/history.js?v=2026072103"></script>
|
||||
<script src="/public/static/js/sum_data.js?v=2026071507"></script>
|
||||
<script src="/public/static/js/report.js?v=2026072103"></script>
|
||||
<script src="/public/static/js/history.js?v=2026072104"></script>
|
||||
<script src="/public/static/js/sum_data.js?v=2026072103"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user