From fdd232989bc2cef254eac886595bed17f0b3407c Mon Sep 17 00:00:00 2001 From: haoslm <6083297@qq.com> Date: Tue, 14 Jul 2026 14:39:38 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BE=8E=E5=8C=96=E8=A1=A8=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/api/config_api.py | 17 ++- lib/api/sum_data_api.py | 26 ++++- lib/db_init.py | 13 +++ lib/report_generator.py | 218 +++++++++++++++++++++++++++++++------ lib/sum_data_collector.py | 93 ++++++++++++++++ public/static/js/config.js | 17 ++- templates/index.html | 21 ++-- 7 files changed, 356 insertions(+), 49 deletions(-) diff --git a/lib/api/config_api.py b/lib/api/config_api.py index be1ca03..2c19137 100644 --- a/lib/api/config_api.py +++ b/lib/api/config_api.py @@ -42,6 +42,7 @@ def create_config(): merge_telecom = 1 if data.get('merge_telecom') else 0 telecom_vehicle_no = data.get('telecom_vehicle_no', '') field_custom_names = data.get('field_custom_names', {}) + show_monthly_total = 1 if data.get('show_monthly_total') else 0 if not config_name or not split_type or not split_value: return jsonify({'success': False, 'message': '缺少必填字段'}), 400 @@ -59,8 +60,9 @@ def create_config(): sql = """ INSERT INTO t_daily_report_config (id, config_name, split_type, split_value, split_name, selected_fields, - sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names, is_active, sort_order, create_time, update_time) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1, %s, NOW(), NOW()) + sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names, + show_monthly_total, is_active, sort_order, create_time, update_time) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1, %s, NOW(), NOW()) """ params = ( @@ -75,6 +77,7 @@ def create_config(): merge_telecom, telecom_vehicle_no, json.dumps(field_custom_names, ensure_ascii=False) if field_custom_names else None, + show_monthly_total, sort_order ) @@ -104,12 +107,14 @@ def update_config(config_id): merge_telecom = 1 if data.get('merge_telecom') else 0 telecom_vehicle_no = data.get('telecom_vehicle_no', '') field_custom_names = data.get('field_custom_names', {}) + show_monthly_total = 1 if data.get('show_monthly_total') else 0 sql = """ UPDATE t_daily_report_config SET config_name = %s, split_type = %s, split_value = %s, split_name = %s, selected_fields = %s, sum_fields = %s, time_periods = %s, - merge_telecom = %s, telecom_vehicle_no = %s, field_custom_names = %s, update_time = NOW() + merge_telecom = %s, telecom_vehicle_no = %s, field_custom_names = %s, + show_monthly_total = %s, update_time = NOW() WHERE id = %s """ @@ -124,6 +129,7 @@ def update_config(config_id): merge_telecom, telecom_vehicle_no, json.dumps(field_custom_names, ensure_ascii=False) if field_custom_names else None, + show_monthly_total, config_id ) @@ -166,8 +172,8 @@ def copy_config(config_id): # 插入新配置 execute_insert( '''INSERT INTO t_daily_report_config - (id, config_name, split_type, split_value, split_name, selected_fields, sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names, is_active, sort_order, create_time, update_time) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())''', + (id, config_name, split_type, split_value, split_name, selected_fields, sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names, show_monthly_total, is_active, sort_order, create_time, update_time) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())''', ( new_id, new_name, @@ -180,6 +186,7 @@ def copy_config(config_id): original.get('merge_telecom', 0), original.get('telecom_vehicle_no', ''), original.get('field_custom_names', None), + original.get('show_monthly_total', 0), original['is_active'], sort_order ) diff --git a/lib/api/sum_data_api.py b/lib/api/sum_data_api.py index f1918df..50d55ed 100644 --- a/lib/api/sum_data_api.py +++ b/lib/api/sum_data_api.py @@ -8,7 +8,8 @@ from lib.sum_data_collector import ( add_sum_data, update_sum_data, delete_sum_data, - batch_delete_sum_data + batch_delete_sum_data, + get_monthly_charge_degree_total ) from . import sum_data_bp @@ -128,3 +129,26 @@ def batch_delete_sum_data_api(): import traceback traceback.print_exc() return jsonify({'success': False, 'message': str(e)}), 500 + + +@sum_data_bp.route('/sum-data/monthly-total', methods=['GET']) +def monthly_charge_degree_total(): + """获取指定配置当月充电电量累计(从sum_data表查询,对账后的数据) + + 参数: + config_id: 配置ID(可选,不传则查所有配置总和) + month: 月份,格式 YYYY-MM(可选,默认当月) + """ + try: + config_id = request.args.get('config_id') + month = request.args.get('month') + + result = get_monthly_charge_degree_total(config_id, month) + if result['success']: + return jsonify(result) + else: + return jsonify(result), 400 + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'message': str(e)}), 500 diff --git a/lib/db_init.py b/lib/db_init.py index 990aa7b..c6863da 100644 --- a/lib/db_init.py +++ b/lib/db_init.py @@ -94,6 +94,19 @@ def init_database_tables(): execute_update(create_sum_data_table) print("[数据库] t_daily_report_sum_data 表已就绪") + # 给配置表增加 show_monthly_total 字段 + try: + execute_update( + "ALTER TABLE t_daily_report_config ADD COLUMN show_monthly_total TINYINT NULL COMMENT '是否显示当月充电量总计' " + ) + print("[数据库] 配置表新增 show_monthly_total 字段成功") + except Exception as e: + # 字段已存在会报错,忽略 + if "Duplicate" in str(e) or "duplicate" in str(e) or "Exists" in str(e) or "exists" in str(e): + pass + else: + print(f"[数据库] 新增 show_monthly_total 字段时出错(可能已存在): {e}") + # 数据迁移:给 sort_order 为 NULL 的配置记录按 id 顺序赋值 try: null_count = execute_query( diff --git a/lib/report_generator.py b/lib/report_generator.py index 1ef0e0e..6e23da8 100644 --- a/lib/report_generator.py +++ b/lib/report_generator.py @@ -471,7 +471,10 @@ def generate_daily_report(config_id, start_time=None, end_time=None): end_time=end_time_str, id_name_maps=id_name_maps, field_custom_names=config.get('field_custom_names'), - has_supplement=has_supplement + has_supplement=has_supplement, + show_monthly_total=config.get('show_monthly_total', 0) == 1, + config_id=config['id'], + report_date=start_time.strftime('%Y-%m-%d') ) print(f"[日报生成] Excel文件已生成: {file_path}") @@ -560,7 +563,8 @@ def generate_daily_report(config_id, start_time=None, end_time=None): def generate_excel(orders, selected_fields, sum_fields, sum_results, config_name, split_name, start_time, end_time, - id_name_maps=None, field_custom_names=None, has_supplement=False): + id_name_maps=None, field_custom_names=None, has_supplement=False, + show_monthly_total=False, config_id=None, report_date=None): """ 生成Excel文件 @@ -568,6 +572,9 @@ def generate_excel(orders, selected_fields, sum_fields, sum_results, id_name_maps: ID到名称的映射字典,格式为 {field_name: {id_value: name_value}} field_custom_names: 字段自定义名头字典,格式为 {field_key: custom_name} has_supplement: 是否有补单记录 + show_monthly_total: 是否显示当月充电量总计 + config_id: 配置ID(用于查询月度累计) + report_date: 报表日期,格式 YYYY-MM-DD Returns: str: 文件路径 @@ -596,40 +603,91 @@ def generate_excel(orders, selected_fields, sum_fields, sum_results, ws = wb.active ws.title = '日报' - # 写入标题 - ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(selected_fields)) - title_cell = ws.cell(row=1, column=1) - title_cell.value = f'{config_name} - {split_name} 日报' - title_cell.font = Font(size=16, bold=True) - title_cell.alignment = Alignment(horizontal='center') - - # 写入时间范围 - ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=len(selected_fields)) - time_cell = ws.cell(row=2, column=1) - time_cell.value = f'统计时间: {start_time} 至 {end_time}' - time_cell.alignment = Alignment(horizontal='center') - - # 写入表头(中文) - header_row = 4 + # 表头从第1行开始 + header_row = 1 field_custom_names = field_custom_names or {} + + # 导入边框样式 + from openpyxl.styles import Border, Side + + thin_border = Border( + left=Side(style='thin', color='000000'), + right=Side(style='thin', color='000000'), + top=Side(style='thin', color='000000'), + bottom=Side(style='thin', color='000000') + ) + + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + header_font = Font(bold=True, color='FFFFFF', size=11) + header_alignment = Alignment(horizontal='center', vertical='center') + + even_row_fill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid') + + sum_fill = PatternFill(start_color='FFF2CC', end_color='FFF2CC', fill_type='solid') + sum_font = Font(bold=True, size=11) + + monthly_fill = PatternFill(start_color='D6E4F0', end_color='D6E4F0', fill_type='solid') + monthly_font = Font(bold=True, color='1F4E78', size=11) + + # 数字字段列表(靠右对齐,保留三位小数) + numeric_fields = { + # 电量类 + 'charge_degree', 'charge_begin_degree', 'charge_end_degree', 'charge_ah', + 'sharp_electricity', 'peak_electricity', 'flat_electricity', 'valley_electricity', + 'charge_times_degree', 'subsidy_degree', + # 金额类(应收) + 'receivable_electric_fee', 'receivable_service_fee', 'receivable_total_fee', + # 金额类(实际) + 'charge_elecfee_amount', 'charge_elecfee_cost_amount', 'charge_service_amount', 'charging_amt', + # 金额类(支付) + 'actual_pay_amount', 'pay_amount', + # 金额类(单价) + 'charge_unit_price', 'charge_unit_cost', 'charge_unit_service_fee', + # 金额类(结算) + 'settle_electric_fee', 'settle_service_fee', 'settle_fee', 'settle_coupon_amount', + # 金额类(优惠券) + 'coupon_amount', 'coupon_total_amount', 'elecfee_coupon_amount', 'servicefee_coupon_amount', + # 金额类(发票) + 'invoice_fee', + # 金额类(活动) + 'activity_electric_fee', 'activity_service_fee', 'activity_total_fee', + 'company_activity_total_fee', 'company_activity_service_fee', 'company_activity_electric_fee', + # 金额类(补贴/运营商/佣金) + 'subsidy_fee', 'operator_income', 'plat_service_fee', + 'com_service_fee', 'com_electric_fee', 'com_total_fee', 'com_charge_discount', + # 兼容字段(旧版命名) + 'total_money', 'total_amount', 'pay_money', 'discount_money', 'service_money', + 'electricity_money', 'parking_money', 'other_money', 'peak_valley_diff_money', + # 时长类 + 'total_duration', 'charging_duration', 'charge_duration', + # SOC类 + 'start_soc', 'end_soc', 'total_soc', 'charge_begin_soc', 'charge_end_soc', 'charge_cur_soc', + } + + # 保留三位小数的字段(金额、电量等) + decimal3_fields = numeric_fields + + # 写入表头 for col_idx, field in enumerate(selected_fields, 1): cell = ws.cell(row=header_row, column=col_idx) - # 优先使用自定义名头 if field in field_custom_names and field_custom_names[field]: display_name = field_custom_names[field] - # 如果是ID字段且有名称映射,表头显示为名称而不是ID elif field in id_name_maps and id_name_maps[field]: - # 将"用户ID"改为"用户","企业ID"改为"企业"等 display_name = get_field_display_name(field).replace('ID', '') else: display_name = get_field_display_name(field) cell.value = display_name - cell.font = Font(bold=True) - cell.fill = PatternFill(start_color='D3D3D3', end_color='D3D3D3', fill_type='solid') - cell.alignment = Alignment(horizontal='center') + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = thin_border + + ws.row_dimensions[header_row].height = 25 # 写入数据 - for row_idx, order in enumerate(orders, header_row + 1): + data_start_row = header_row + 1 + for row_idx, order in enumerate(orders, data_start_row): + is_even = (row_idx - data_start_row) % 2 == 1 for col_idx, field in enumerate(selected_fields, 1): cell = ws.cell(row=row_idx, column=col_idx) value = order.get(field, '') @@ -647,33 +705,123 @@ def generate_excel(orders, selected_fields, sum_fields, sum_results, value = name_value cell.value = value + cell.border = thin_border + + # 数字字段靠右对齐,其他居中或靠左 + if field in numeric_fields: + cell.alignment = Alignment(horizontal='right', vertical='center') + if isinstance(value, (int, float)): + if field in decimal3_fields: + cell.number_format = '0.000' + elif field in ['order_no', 'vehicle_no', 'gun_no', 'connector_no']: + cell.alignment = Alignment(horizontal='center', vertical='center') + else: + cell.alignment = Alignment(horizontal='left', vertical='center') + + # 斑马纹(偶数行) + if is_even: + cell.fill = even_row_fill + + ws.row_dimensions[row_idx].height = 22 # 写入求和行 + last_data_row = header_row + len(orders) + sum_row = last_data_row + 1 if sum_fields: - sum_row = header_row + len(orders) + 1 ws.cell(row=sum_row, column=1).value = '合计' - ws.cell(row=sum_row, column=1).font = Font(bold=True) + ws.cell(row=sum_row, column=1).font = sum_font + ws.cell(row=sum_row, column=1).fill = sum_fill + ws.cell(row=sum_row, column=1).alignment = Alignment(horizontal='center', vertical='center') + ws.cell(row=sum_row, column=1).border = thin_border for col_idx, field in enumerate(selected_fields, 1): + cell = ws.cell(row=sum_row, column=col_idx) + cell.fill = sum_fill + cell.border = thin_border if field in sum_fields and field in sum_results: - cell = ws.cell(row=sum_row, column=col_idx) cell.value = sum_results[field] - cell.font = Font(bold=True) - cell.number_format = '0.000' # 保留三位小数 + cell.font = sum_font + cell.alignment = Alignment(horizontal='right', vertical='center') + cell.number_format = '0.000' + + ws.row_dimensions[sum_row].height = 25 - # 调整列宽 + # 写入当月充电量累计总计行(从求和采集表查询,对账后数据) + if show_monthly_total and config_id and report_date: + try: + from lib.sum_data_collector import get_monthly_charge_degree_total + month = report_date[:7] + monthly_result = get_monthly_charge_degree_total(config_id, month) + + 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) + + # 如果当天的数据还没采集进 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 + + # 月度总计行(在合计行下面空一行) + monthly_row = sum_row + 2 if sum_fields else sum_row + 1 + + # 找 charge_degree 字段所在的列 + charge_degree_col = None + for col_idx, field in enumerate(selected_fields, 1): + if field == 'charge_degree': + charge_degree_col = col_idx + break + + # 第一列显示标题 + ws.cell(row=monthly_row, column=1).value = f'当月累计充电量(共{total_days}天)' + 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).border = thin_border + + # 其他列也填充背景色 + for col_idx in range(2, len(selected_fields) + 1): + cell = ws.cell(row=monthly_row, column=col_idx) + cell.fill = monthly_fill + cell.border = thin_border + + if charge_degree_col: + cell = ws.cell(row=monthly_row, column=charge_degree_col) + cell.value = total_degree + cell.font = monthly_font + cell.alignment = Alignment(horizontal='right', vertical='center') + cell.number_format = '0.000' + + ws.row_dimensions[monthly_row].height = 25 + except Exception as e: + print(f"[日报生成] 写入月度总计时出错: {e}") + + # 调整列宽(中文按2个字符宽度计算) for col_idx, field in enumerate(selected_fields, 1): - max_length = len(get_field_display_name(field)) - for row in ws.iter_rows(min_row=header_row + 1, max_row=header_row + len(orders), + header_name = get_field_display_name(field) + if field in field_custom_names and field_custom_names[field]: + header_name = field_custom_names[field] + cn_count = sum(1 for c in str(header_name) if '\u4e00' <= c <= '\u9fff') + max_length = len(str(header_name)) + cn_count + + for row in ws.iter_rows(min_row=data_start_row, max_row=last_data_row, min_col=col_idx, max_col=col_idx): for cell in row: - if cell.value: - max_length = max(max_length, len(str(cell.value))) - # 使用 openpyxl 的列字母转换函数 + if cell.value is not None and cell.value != '': + cell_str = str(cell.value) + cn_count = sum(1 for c in cell_str if '\u4e00' <= c <= '\u9fff') + cell_len = len(cell_str) + cn_count + max_length = max(max_length, cell_len) + from openpyxl.utils import get_column_letter col_letter = get_column_letter(col_idx) ws.column_dimensions[col_letter].width = min(max_length + 2, 50) + # 冻结首行 + ws.freeze_panes = 'A2' + # 保存文件 wb.save(file_path) diff --git a/lib/sum_data_collector.py b/lib/sum_data_collector.py index 9c36059..8e5444f 100644 --- a/lib/sum_data_collector.py +++ b/lib/sum_data_collector.py @@ -407,3 +407,96 @@ def batch_delete_sum_data(ids): import traceback traceback.print_exc() return {'success': False, 'message': str(e)} + + +def get_monthly_charge_degree_total(config_id=None, month=None): + """ + 获取当月充电电量累计(从求和数据表查询,对账后的准确数据) + + 注意: + - 数据来源于 t_daily_report_sum_data 表(线下对账后的数据,可能被手动修改过) + - 只统计 sum_field_key = 'charge_degree' 的记录 + - 如果当天已经生成了日报并采集,自动包含在内,不会重复计算 + + Args: + config_id: 配置ID(可选,不传则统计所有配置) + month: 月份,格式 YYYY-MM(可选,默认当月) + + Returns: + dict: { + success: bool, + data: { + total_degree: float, // 当月累计充电电量(kWh) + total_days: int, // 有数据的天数 + total_orders: int, // 累计订单数 + today_degree: float, // 当天充电电量(kWh)(如果有的话) + month: str, // 统计月份 + detail: list // 按天明细 + }, + message: str + } + """ + try: + # 默认当月 + if not month: + month = datetime.now().strftime('%Y-%m') + + # 拼接日期模糊匹配前缀 + date_prefix = month + '-' + + # 构建查询SQL + sql = """ + SELECT + report_date, + SUM(sum_value) as total_degree, + SUM(total_orders) as total_orders + FROM t_daily_report_sum_data + WHERE sum_field_key = 'charge_degree' + AND report_date LIKE %s + """ + params = [date_prefix + '%'] + + if config_id: + sql += ' AND config_id = %s' + params.append(config_id) + + sql += ' GROUP BY report_date ORDER BY report_date ASC' + + rows = execute_query(sql, tuple(params)) + + 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: + degree = float(row['total_degree'] or 0) + orders = int(row['total_orders'] or 0) + total_degree += degree + total_orders += orders + detail.append({ + 'report_date': row['report_date'], + 'degree': round(degree, 3), + 'orders': orders + }) + if row['report_date'] == today_str: + today_degree = degree + + return { + 'success': True, + 'data': { + 'total_degree': round(total_degree, 3), + 'total_days': total_days, + 'total_orders': total_orders, + 'today_degree': round(today_degree, 3), + 'month': month, + 'detail': detail + }, + 'message': '查询成功' + } + except Exception as e: + import traceback + traceback.print_exc() + return {'success': False, 'message': str(e)} diff --git a/public/static/js/config.js b/public/static/js/config.js index 10005a4..7340bca 100644 --- a/public/static/js/config.js +++ b/public/static/js/config.js @@ -80,6 +80,11 @@ function showCreateModal() { loadFields(); initTimePeriodEditor(); clearAllTimePeriods(); + + // 重置月度总计开关 + const showMonthlyTotalCheckbox = document.getElementById('show-monthly-total'); + if (showMonthlyTotalCheckbox) showMonthlyTotalCheckbox.checked = false; + document.getElementById('config-modal').classList.add('active'); } @@ -469,6 +474,9 @@ async function saveConfig() { // 获取合并特来电配置 const mergeTelecom = document.getElementById('merge-telecom')?.checked || false; const telecomVehicleNo = document.getElementById('telecom-vehicle-no')?.value.trim() || ''; + + // 获取月度总计开关 + const showMonthlyTotal = document.getElementById('show-monthly-total')?.checked || false; if (!configName || !splitType || !splitValue) { alert('请填写所有必填字段'); @@ -508,7 +516,8 @@ async function saveConfig() { time_periods: timePeriods, merge_telecom: mergeTelecom, telecom_vehicle_no: telecomVehicleNo, - field_custom_names: fieldCustomNamesData + field_custom_names: fieldCustomNamesData, + show_monthly_total: showMonthlyTotal }; try { @@ -583,6 +592,12 @@ async function editConfig(configId) { telecomVehicleNoInput.value = config.telecom_vehicle_no || ''; } + // 加载月度总计开关 + const showMonthlyTotalCheckbox = document.getElementById('show-monthly-total'); + if (showMonthlyTotalCheckbox) { + showMonthlyTotalCheckbox.checked = config.show_monthly_total === 1 || config.show_monthly_total === true; + } + document.getElementById('config-modal').classList.add('active'); } } diff --git a/templates/index.html b/templates/index.html index a702e89..a9c0fd9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,7 +4,7 @@