Files
ylt_diy/lib/report_generator.py
user9994793890 9822fbabb0 fix: 修复连接器ID显示名称和特来电数据显示问题
Coze-Commit-Type: user
Coze-User-ID: 3722323274763196
Coze-Conversation-ID: 9894087
2026-07-13 13:21:06 +08:00

688 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""
日报生成核心逻辑
"""
import os
import json
from datetime import datetime, timedelta
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
import pytz
from lib.db import execute_query, execute_insert
from lib.field_mapping import get_field_display_name
# ID字段到名称的映射配置
# key: 订单表中的ID字段名
# value: (关联表名, 关联表ID字段, 关联表名称字段)
ID_TO_NAME_MAPPING = {
'user_id': ('t_user', 'id', 'user_name'),
'company_id': ('t_company', 'id', 'company_name'),
'station_id': ('t_station', 'id', 'station_name'),
'equipment_id': ('t_connector', 'id', 'connector_name'),
}
# 特来电表字段映射到报表字段
# t_hlht_tld 字段 → 报表字段
TELECOM_FIELD_MAPPING = {
'order_id': 'order_no', # 订单号
'station_id': 'station_id', # 场站ID
'operator_name': 'station_name', # 运营商名称 → 场站名称
'connector_name': 'equipment_name', # 连接器名称 → 设备名称
'charge_start_time': 'charge_begin_time', # 开始充电时间
'charge_end_time': 'charge_end_time', # 结束充电时间
'total_power': 'charge_degree', # 总电量
'start_soc': 'charge_begin_soc', # 开始SOC
'end_soc': 'charge_end_soc', # 结束SOC
'elec_money': 'charge_elecfee_amount', # 电费
'service_money': 'charge_service_amount', # 服务费
'total_money': 'total_money', # 总金额
'sharp_power': 'sharp_electricity', # 尖时段电量
'peak_power': 'peak_electricity', # 峰时段电量
'flat_power': 'flat_electricity', # 平时段电量
'valley_power': 'valley_electricity', # 谷时段电量
'vehicle_self_no': 'company_name', # 车量自编号 → 企业名称
}
# 特来电数据中名称字段到ID字段的映射用于匹配selected_fields中的ID字段
TELECOM_NAME_TO_ID_FIELDS = {
'company_name': 'company_id',
'station_name': 'station_id',
'equipment_name': 'equipment_id',
}
def query_telecom_orders(vehicle_self_nos, start_time_str, end_time_str):
"""
查询特来电平台的订单数据
Args:
vehicle_self_nos: 车量自编号列表(对应企业名称)
start_time_str: 开始时间字符串
end_time_str: 结束时间字符串
Returns:
list: 转换后的订单数据,字段映射为报表字段
"""
if not vehicle_self_nos:
return []
# 构建查询条件
placeholders = ', '.join(['%s'] * len(vehicle_self_nos))
sql = f"""
SELECT
order_id,
station_id,
operator_name,
connector_name,
charge_start_time,
charge_end_time,
total_power,
start_soc,
end_soc,
elec_money,
service_money,
total_money,
sharp_power,
peak_power,
flat_power,
valley_power,
vehicle_self_no
FROM t_hlht_tld
WHERE vehicle_self_no IN ({placeholders})
AND second_source != '驿来特'
AND charge_end_time >= %s
AND charge_end_time < %s
ORDER BY charge_end_time DESC
"""
params = tuple(vehicle_self_nos) + (start_time_str, end_time_str)
try:
results = execute_query(sql, params)
print(f"[特来电] 查询到 {len(results)} 条订单")
except Exception as e:
print(f"[特来电] 查询失败: {e}")
return []
# 转换字段名
converted_orders = []
for row in results:
converted = {}
for telecom_field, report_field in TELECOM_FIELD_MAPPING.items():
if telecom_field in row:
converted[report_field] = row[telecom_field]
# 将名称字段复制到对应的ID字段以便ID转名称逻辑能正确处理
# 例如company_name → company_id值相同这样 build_id_to_name_map 会查询 t_company 表
# 但由于值是名称而不是ID查询会失败所以我们需要特殊处理
for name_field, id_field in TELECOM_NAME_TO_ID_FIELDS.items():
if name_field in converted:
converted[id_field] = converted[name_field]
# 添加数据来源标记
converted['data_source'] = '特来电'
converted_orders.append(converted)
return converted_orders
def build_id_to_name_map(orders, selected_fields):
"""
构建ID到名称的映射字典
Args:
orders: 订单数据列表
selected_fields: 选中的字段列表
Returns:
dict: {field_name: {id_value: name_value}}
"""
id_name_maps = {}
for field in selected_fields:
if field not in ID_TO_NAME_MAPPING:
continue
table_name, id_field, name_field = ID_TO_NAME_MAPPING[field]
# 收集所有需要查询的ID值
id_values = set()
telecom_name_values = {} # 特来电数据中的名称值(直接使用)
for order in orders:
val = order.get(field)
if val is not None and val != '' and val != 0:
# 检查是否是特来电订单
if order.get('data_source') == '特来电' and field in TELECOM_NAME_TO_ID_FIELDS.values():
# 特来电数据中的ID字段实际是名称直接使用
telecom_name_values[str(val)] = str(val)
else:
id_values.add(str(val))
# 合并特来电的名称映射
if telecom_name_values:
id_name_maps[field] = telecom_name_values
print(f"[ID转换] {field}: 特来电数据直接使用名称 {len(telecom_name_values)}")
# 查询驿来特数据的ID映射
if not id_values:
continue
# 批量查询名称
placeholders = ', '.join(['%s'] * len(id_values))
sql = f"SELECT {id_field} AS id_val, {name_field} AS name_val FROM {table_name} WHERE {id_field} IN ({placeholders})"
try:
results = execute_query(sql, tuple(id_values))
if field not in id_name_maps:
id_name_maps[field] = {}
id_name_maps[field].update({str(row['id_val']): row['name_val'] for row in results})
print(f"[ID转换] {field}: 查询到 {len(results)} 个名称映射")
except Exception as e:
print(f"[ID转换] {field} 查询失败: {e}")
if field not in id_name_maps:
id_name_maps[field] = {}
return id_name_maps
def calculate_time_period_electricity(orders, time_periods_config):
"""
计算分时段电量
从 t_equipment_charge_order_detail 表查询分时电量数据,
根据时段配置计算各时段的电量总和。
Args:
orders: 订单数据列表
time_periods_config: 时段配置 {sharp: [11,12], peak: [9,10], flat: [7,8], valley: [0,1,2,3]}
Returns:
dict: {order_no: {sharp_electricity: x, peak_electricity: y, flat_electricity: z, valley_electricity: w}}
"""
if not time_periods_config or not any(time_periods_config.values()):
return {}
# 收集所有订单号
order_nos = [order.get('order_no') for order in orders if order.get('order_no')]
if not order_nos:
return {}
# 构建小时到时段的映射
hour_to_period = {}
for period_key, hours in time_periods_config.items():
for hour in hours:
hour_to_period[hour] = period_key
if not hour_to_period:
return {}
# 批量查询订单的分时电量
placeholders = ', '.join(['%s'] * len(order_nos))
sql = f"""
SELECT order_no, charge_degree, charge_startr_time, time_flag
FROM t_equipment_charge_order_detail
WHERE order_no IN ({placeholders})
ORDER BY order_no, charge_startr_time
"""
try:
details = execute_query(sql, tuple(order_nos))
print(f"[时段电量] 查询到 {len(details)} 条分时记录")
except Exception as e:
print(f"[时段电量] 查询失败: {e}")
return {}
# 按订单号分组计算各时段电量
result = {}
for order_no in order_nos:
result[order_no] = {
'sharp_electricity': 0.0,
'peak_electricity': 0.0,
'flat_electricity': 0.0,
'valley_electricity': 0.0
}
for detail in details:
order_no = detail.get('order_no')
if order_no not in result:
continue
charge_degree = float(detail.get('charge_degree', 0) or 0)
charge_start_time = detail.get('charge_startr_time')
if not charge_start_time:
continue
# 获取小时数
if isinstance(charge_start_time, datetime):
hour = charge_start_time.hour
elif isinstance(charge_start_time, str):
try:
hour = datetime.strptime(str(charge_start_time), '%Y-%m-%d %H:%M:%S').hour
except:
continue
else:
continue
# 根据小时确定时段
period_key = hour_to_period.get(hour)
if period_key and f'{period_key}_electricity' in result[order_no]:
result[order_no][f'{period_key}_electricity'] += charge_degree
return result
def get_beijing_time():
"""获取北京时间"""
beijing_tz = pytz.timezone('Asia/Shanghai')
return datetime.now(beijing_tz)
def generate_daily_report(config_id, start_time=None, end_time=None):
"""
生成日报
Args:
config_id: 配置ID
start_time: 开始时间可选默认为昨天8点
end_time: 结束时间可选默认为今天8点
Returns:
dict: 生成结果,包含 success, message, file_path, total_orders, total_amount, sum_results
"""
try:
# 获取配置信息
configs = execute_query(
'SELECT * FROM t_daily_report_config WHERE id = %s',
(config_id,)
)
if not configs:
return {'success': False, 'message': '配置不存在'}
config = configs[0]
# 解析字段
selected_fields = json.loads(config['selected_fields'])
sum_fields = json.loads(config['sum_fields']) if config['sum_fields'] else []
# 设置时间范围
if not start_time or not end_time:
now = get_beijing_time()
end_time = now.replace(hour=8, minute=0, second=0, microsecond=0)
start_time = end_time - timedelta(days=1)
# 格式化时间
start_time_str = start_time.strftime('%Y-%m-%d %H:%M:%S')
end_time_str = end_time.strftime('%Y-%m-%d %H:%M:%S')
print(f"[日报生成] 开始生成日报")
print(f" 配置ID: {config_id}")
print(f" 配置名称: {config['config_name']}")
print(f" 拆分方式: {config['split_type']} = {config['split_value']}")
print(f" 时间范围: {start_time_str}{end_time_str}")
print(f" 选择字段: {len(selected_fields)}")
# 构建查询SQL - 过滤掉虚拟字段(时段电量等)
# 虚拟字段不是数据库表中的实际字段,需要动态计算
TIME_PERIOD_FIELDS = {'sharp_electricity', 'peak_electricity', 'flat_electricity', 'valley_electricity'}
db_fields = [f for f in selected_fields if f not in TIME_PERIOD_FIELDS]
# 确保至少有 order_no 字段用于关联分时数据
if 'order_no' not in db_fields:
db_fields.append('order_no')
fields_str = ', '.join(db_fields)
# 检查是否为多选值(逗号分隔)
split_values = [v.strip() for v in config['split_value'].split(',') if v.strip()]
if len(split_values) > 1:
# 多选:使用 IN 查询
placeholders = ', '.join(['%s'] * len(split_values))
sql = f"""
SELECT {fields_str}
FROM t_equipment_charge_order
WHERE state = 3
AND report_time >= %s
AND report_time < %s
AND {config['split_type']} IN ({placeholders})
ORDER BY report_time DESC
"""
params = [start_time_str, end_time_str] + split_values
else:
# 单选:使用 = 查询
sql = f"""
SELECT {fields_str}
FROM t_equipment_charge_order
WHERE state = 3
AND report_time >= %s
AND report_time < %s
AND {config['split_type']} = %s
ORDER BY report_time DESC
"""
params = [start_time_str, end_time_str, config['split_value']]
# 执行查询
orders = execute_query(sql, tuple(params))
print(f"[日报生成] 驿来特查询到 {len(orders)} 条订单")
# 添加数据来源标记
for order in orders:
order['data_source'] = '驿来特'
# 检查是否需要合并特来电数据
merge_telecom = config.get('merge_telecom', 0)
telecom_vehicle_no = config.get('telecom_vehicle_no', '')
if merge_telecom and config['split_type'] == 'company_id' and telecom_vehicle_no:
# 解析车量自编号(支持逗号分隔的多个)
vehicle_nos = [v.strip() for v in telecom_vehicle_no.split(',') if v.strip()]
if vehicle_nos:
print(f"[日报生成] 开始合并特来电数据,车量自编号: {vehicle_nos}")
telecom_orders = query_telecom_orders(vehicle_nos, start_time_str, end_time_str)
if telecom_orders:
# 将特来电数据添加到订单列表
orders.extend(telecom_orders)
print(f"[日报生成] 合并后总订单数: {len(orders)}")
if not orders:
# 即使没有数据,也保存一条失败记录
save_report_history(
report_date=start_time.strftime('%Y-%m-%d'),
start_time=start_time_str,
end_time=end_time_str,
split_type=config['split_type'],
split_value=config['split_value'],
split_name=config.get('split_name', config['split_value']),
config_id=config_id,
total_orders=0,
total_amount=0,
sum_results={},
file_path='',
status=0
)
return {
'success': False,
'message': f'时间范围内没有符合条件的订单数据(查询条件:{start_time_str}{end_time_str}{config["split_type"]}={config["split_value"]}state=3'
}
# 解析时段配置并计算分时段电量
time_periods_config = json.loads(config['time_periods']) if config.get('time_periods') else {}
if time_periods_config and any(time_periods_config.values()):
print(f"[日报生成] 时段配置: {time_periods_config}")
time_period_electricity = calculate_time_period_electricity(orders, time_periods_config)
# 将分时段电量添加到订单数据中(仅对驿来特订单,特来电订单已有数据)
for order in orders:
# 特来电订单已有尖峰平谷电量数据,不需要重新计算
if order.get('data_source') == '特来电':
continue
order_no = order.get('order_no')
if order_no and order_no in time_period_electricity:
ep = time_period_electricity[order_no]
order['sharp_electricity'] = ep['sharp_electricity']
order['peak_electricity'] = ep['peak_electricity']
order['flat_electricity'] = ep['flat_electricity']
order['valley_electricity'] = ep['valley_electricity']
print(f"[日报生成] 已计算 {len(time_period_electricity)} 条订单的分时段电量")
# 计算求和(保留三位小数)
sum_results = {}
for field in sum_fields:
if field in orders[0]:
total = sum(float(order.get(field, 0) or 0) for order in orders)
sum_results[field] = round(total, 3)
# 构建ID到名称的映射
id_name_maps = build_id_to_name_map(orders, selected_fields)
# 生成Excel文件
file_path = generate_excel(
orders=orders,
selected_fields=selected_fields,
sum_fields=sum_fields,
sum_results=sum_results,
config_name=config['config_name'],
split_name=config.get('split_name', config['split_value']),
start_time=start_time_str,
end_time=end_time_str,
id_name_maps=id_name_maps,
field_custom_names=config.get('field_custom_names')
)
print(f"[日报生成] Excel文件已生成: {file_path}")
# 计算总金额
total_amount = sum(float(order.get('total_money', 0) or 0) for order in orders)
# 保存历史记录
print(f"[日报生成] 准备保存历史记录file_path={file_path}")
history_id = save_report_history(
report_date=start_time.strftime('%Y-%m-%d'),
start_time=start_time_str,
end_time=end_time_str,
split_type=config['split_type'],
split_value=config['split_value'],
split_name=config.get('split_name', config['split_value']),
config_id=config_id,
total_orders=len(orders),
total_amount=total_amount,
sum_results=sum_results,
file_path=file_path,
status=1
)
print(f"[日报生成] 历史记录已保存ID={history_id}")
print(f"[日报生成] ✓ 生成成功")
print(f" 订单数量: {len(orders)}")
print(f" 总金额: {total_amount}")
print(f" 文件路径: {file_path}")
print(f" 历史记录ID: {history_id}")
return {
'success': True,
'message': '日报生成成功',
'file_path': file_path,
'total_orders': len(orders),
'total_amount': total_amount,
'sum_results': sum_results,
'history_id': history_id
}
except Exception as e:
print(f"[日报生成] ✗ 生成失败: {str(e)}")
import traceback
traceback.print_exc()
# 保存失败记录
try:
save_report_history(
report_date=start_time.strftime('%Y-%m-%d') if start_time else datetime.now().strftime('%Y-%m-%d'),
start_time=start_time_str if 'start_time_str' in locals() else '',
end_time=end_time_str if 'end_time_str' in locals() else '',
split_type=config['split_type'] if 'config' in locals() and config else '',
split_value=config['split_value'] if 'config' in locals() and config else '',
split_name=config.get('split_name', '') if 'config' in locals() and config else '',
config_id=config_id,
total_orders=0,
total_amount=0,
sum_results={},
file_path='',
status=0
)
print(f"[日报生成] 已保存失败记录到历史表")
except Exception as save_error:
print(f"[日报生成] 保存失败记录时出错: {str(save_error)}")
return {
'success': False,
'message': f'生成日报失败: {str(e)}'
}
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):
"""
生成Excel文件
Args:
id_name_maps: ID到名称的映射字典格式为 {field_name: {id_value: name_value}}
field_custom_names: 字段自定义名头字典,格式为 {field_key: custom_name}
Returns:
str: 文件路径
"""
if id_name_maps is None:
id_name_maps = {}
# 创建reports目录
reports_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'public', 'reports')
os.makedirs(reports_dir, exist_ok=True)
# 生成文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'daily_report_{config_name}_{split_name}_{timestamp}.xlsx'
file_path = os.path.join(reports_dir, filename)
# 创建工作簿
wb = Workbook()
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
field_custom_names = field_custom_names or {}
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')
# 写入数据
for row_idx, order in enumerate(orders, header_row + 1):
for col_idx, field in enumerate(selected_fields, 1):
cell = ws.cell(row=row_idx, column=col_idx)
value = order.get(field, '')
# 格式化时间字段
if field in ['report_time', 'start_time', 'end_time', 'create_time', 'update_time', 'pay_time']:
if isinstance(value, datetime):
value = value.strftime('%Y-%m-%d %H:%M:%S')
# 将ID转换为名称
if field in id_name_maps and value is not None and value != '':
name_map = id_name_maps[field]
name_value = name_map.get(str(value))
if name_value:
value = name_value
cell.value = value
# 写入求和行
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)
for col_idx, field in enumerate(selected_fields, 1):
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' # 保留三位小数
# 调整列宽
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),
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 的列字母转换函数
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)
# 保存文件
wb.save(file_path)
# 返回相对路径
return f'/reports/{filename}'
def save_report_history(report_date, start_time, end_time, split_type, split_value,
split_name, config_id, total_orders, total_amount,
sum_results, file_path, status):
"""
保存日报生成历史记录
Returns:
int: 历史记录ID
"""
# 生成ID
history_id = int(datetime.now().timestamp() * 1000)
print(f"[保存历史] 准备保存历史记录")
print(f" ID: {history_id}")
print(f" file_path: '{file_path}'")
print(f" status: {status}")
sql = """
INSERT INTO t_daily_report_history
(id, report_date, start_time, end_time, split_type, split_value, split_name,
config_id, total_orders, total_amount, sum_results, file_path, status, create_time)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
"""
params = (
history_id,
report_date,
start_time,
end_time,
split_type,
split_value,
split_name,
config_id,
total_orders,
total_amount,
json.dumps(sum_results, ensure_ascii=False),
file_path,
status
)
print(f"[保存历史] 执行SQL插入...")
result = execute_insert(sql, params)
print(f"[保存历史] 插入完成返回ID: {result}")
return history_id