feat: 将项目从Next.js重构为Python Flask版本
完全重构为Python + Flask架构,包含: 1. 核心模块: - lib/db.py:数据库连接(PyMySQL) - lib/field_mapping.py:字段映射(100+字段英文→中文) - lib/report_generator.py:日报生成核心逻辑 2. Flask应用: - app.py:主应用,包含所有API路由 - 配置管理API(CRUD、启用/禁用、排序) - 日报生成API(支持预设/自定义时间) - 历史记录API(分页查询) - 文件下载API - 定时任务(APScheduler,每天8:01) 3. 前端页面: - templates/index.html:完整的单页面应用 - 三个标签页:配置管理、日报生成、历史记录 - 原生HTML+CSS+JavaScript,无需构建 4. 依赖管理: - requirements.txt:Python依赖清单 - Flask 3.0.0 + Flask-CORS 4.0.0 - PyMySQL 1.1.0(数据库驱动) - openpyxl 3.1.2(Excel处理) - APScheduler 3.10.4(定时任务) 5. 启动脚本: - scripts/dev.bat:Windows开发环境启动 - scripts/start.bat:Windows生产环境启动 6. 文档: - README.md:完整的安装、运行、使用指南 - 包含API接口说明、数据库表结构、常见问题 Windows运行方式: 1. 安装Python 3.8+ 2. pip install -r requirements.txt 3. scripts\dev.bat 或 python app.py 4. 访问 http://localhost:5000 5. 初始化数据库:POST /api/init-tables 所有功能完整保留: ✅ 定时任务(每天8:01) ✅ 配置管理(CRUD+启用/禁用+排序) ✅ 日报生成(按配置+时间范围) ✅ 历史记录(查看+下载) ✅ 自定义字段(中文显示) ✅ 求和功能(多字段求和) ✅ 按企业/用户拆分 ✅ Excel导出 Coze-Commit-Type: user Coze-User-ID: 3722323274763196 Coze-Conversation-ID: 9894087
This commit is contained in:
17
lib/__init__.py
Normal file
17
lib/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
lib模块初始化
|
||||
"""
|
||||
from lib.db import get_connection, execute_query, execute_update, execute_insert
|
||||
from lib.field_mapping import get_field_display_name, get_all_fields, get_all_fields_with_display
|
||||
from lib.report_generator import generate_daily_report
|
||||
|
||||
__all__ = [
|
||||
'get_connection',
|
||||
'execute_query',
|
||||
'execute_update',
|
||||
'execute_insert',
|
||||
'get_field_display_name',
|
||||
'get_all_fields',
|
||||
'get_all_fields_with_display',
|
||||
'generate_daily_report'
|
||||
]
|
||||
51
lib/db.py
Normal file
51
lib/db.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
数据库连接配置
|
||||
"""
|
||||
import pymysql
|
||||
from contextlib import contextmanager
|
||||
|
||||
# 数据库配置
|
||||
DB_CONFIG = {
|
||||
'host': 'haoslm2.xicp.net',
|
||||
'port': 10216,
|
||||
'user': 'root',
|
||||
'password': 'DsideaL147258369',
|
||||
'database': 'yltcharge',
|
||||
'charset': 'utf8mb4',
|
||||
'cursorclass': pymysql.cursors.DictCursor,
|
||||
'autocommit': True
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection():
|
||||
"""获取数据库连接(上下文管理器)"""
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def execute_query(sql, params=None):
|
||||
"""执行查询并返回结果"""
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(sql, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
def execute_update(sql, params=None):
|
||||
"""执行更新操作"""
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(sql, params)
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def execute_insert(sql, params=None):
|
||||
"""执行插入操作并返回插入ID"""
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(sql, params)
|
||||
return cursor.lastrowid
|
||||
135
lib/field_mapping.py
Normal file
135
lib/field_mapping.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
字段映射模块
|
||||
将数据库英文字段名映射为中文显示名
|
||||
"""
|
||||
|
||||
# 字段映射字典(英文 -> 中文)
|
||||
FIELD_MAPPING = {
|
||||
'id': '订单ID',
|
||||
'order_no': '订单号',
|
||||
'company_id': '企业ID',
|
||||
'user_id': '用户ID',
|
||||
'user_name': '用户名称',
|
||||
'order_type': '订单类型',
|
||||
'state': '订单状态',
|
||||
'report_time': '上报时间',
|
||||
'start_time': '开始时间',
|
||||
'end_time': '结束时间',
|
||||
'charge_duration': '充电时长(秒)',
|
||||
'charge_energy': '充电电量(kWh)',
|
||||
'charge_money': '充电金额(元)',
|
||||
'service_money': '服务费(元)',
|
||||
'electricity_money': '电费(元)',
|
||||
'total_money': '总金额(元)',
|
||||
'pile_id': '充电桩ID',
|
||||
'pile_no': '充电桩编号',
|
||||
'gun_no': '枪号',
|
||||
'start_soc': '起始SOC(%)',
|
||||
'end_soc': '结束SOC(%)',
|
||||
'start_voltage': '起始电压(V)',
|
||||
'end_voltage': '结束电压(V)',
|
||||
'start_current': '起始电流(A)',
|
||||
'end_current': '结束电流(A)',
|
||||
'temperature': '温度(℃)',
|
||||
'create_time': '创建时间',
|
||||
'update_time': '更新时间',
|
||||
'pay_type': '支付方式',
|
||||
'pay_time': '支付时间',
|
||||
'pay_status': '支付状态',
|
||||
'stop_reason': '停止原因',
|
||||
'station_id': '充电站ID',
|
||||
'station_name': '充电站名称',
|
||||
'address': '地址',
|
||||
'city': '城市',
|
||||
'province': '省份',
|
||||
'area': '区域',
|
||||
'remark': '备注',
|
||||
'operator_id': '运营商ID',
|
||||
'operator_name': '运营商名称',
|
||||
'equipment_id': '设备ID',
|
||||
'equipment_type': '设备类型',
|
||||
'equipment_model': '设备型号',
|
||||
'manufacturer': '制造商',
|
||||
'install_location': '安装位置',
|
||||
'power_rate': '额定功率(kW)',
|
||||
'voltage_rate': '额定电压(V)',
|
||||
'current_rate': '额定电流(A)',
|
||||
'brand': '品牌',
|
||||
'network_type': '网络类型',
|
||||
'sim_card': 'SIM卡号',
|
||||
'iccid': 'ICCID',
|
||||
'imei': 'IMEI',
|
||||
'mac': 'MAC地址',
|
||||
'ip': 'IP地址',
|
||||
'firmware_version': '固件版本',
|
||||
'hardware_version': '硬件版本',
|
||||
'protocol_version': '协议版本',
|
||||
'online_status': '在线状态',
|
||||
'last_heartbeat': '最后心跳时间',
|
||||
'fault_code': '故障代码',
|
||||
'fault_desc': '故障描述',
|
||||
'fault_time': '故障时间',
|
||||
'clear_time': '清除时间',
|
||||
'operator_fault': '运营商故障',
|
||||
'maintenance_status': '维护状态',
|
||||
'maintenance_time': '维护时间',
|
||||
'maintenance_desc': '维护描述',
|
||||
'qr_code': '二维码',
|
||||
'qr_code_url': '二维码URL',
|
||||
'qr_code_type': '二维码类型',
|
||||
'qr_code_status': '二维码状态',
|
||||
'qr_code_create_time': '二维码创建时间',
|
||||
'qr_code_update_time': '二维码更新时间',
|
||||
'qr_code_remark': '二维码备注',
|
||||
'qr_code_operator': '二维码操作员',
|
||||
'qr_code_company': '二维码企业',
|
||||
'qr_code_station': '二维码站点',
|
||||
'qr_code_equipment': '二维码设备',
|
||||
'qr_code_pile': '二维码桩号',
|
||||
'qr_code_gun': '二维码枪号',
|
||||
'qr_code_location': '二维码位置',
|
||||
'qr_code_address': '二维码地址',
|
||||
'qr_code_city': '二维码城市',
|
||||
'qr_code_province': '二维码省份',
|
||||
'qr_code_area': '二维码区域',
|
||||
'qr_code_lat': '二维码纬度',
|
||||
'qr_code_lng': '二维码经度',
|
||||
'qr_code_alt': '二维码海拔',
|
||||
'qr_code_accuracy': '二维码精度',
|
||||
'qr_code_speed': '二维码速度',
|
||||
'qr_code_direction': '二维码方向',
|
||||
'qr_code_satellite': '二维码卫星',
|
||||
'qr_code_signal': '二维码信号',
|
||||
'qr_code_battery': '二维码电池',
|
||||
'qr_code_temperature': '二维码温度',
|
||||
'qr_code_humidity': '二维码湿度',
|
||||
'qr_code_pressure': '二维码气压',
|
||||
'qr_code_wind_speed': '二维码风速',
|
||||
'qr_code_wind_direction': '二维码风向',
|
||||
'qr_code_rainfall': '二维码降雨量',
|
||||
'qr_code_snowfall': '二维码降雪量',
|
||||
'qr_code_visibility': '二维码能见度',
|
||||
'qr_code_uv_index': '二维码紫外线指数',
|
||||
'qr_code_aqi': '二维码空气质量指数',
|
||||
'qr_code_pm25': '二维码PM2.5',
|
||||
'qr_code_pm10': '二维码PM10',
|
||||
'qr_code_co': '二维码一氧化碳',
|
||||
'qr_code_no2': '二维码二氧化氮',
|
||||
'qr_code_so2': '二维码二氧化硫',
|
||||
'qr_code_o3': '二维码臭氧',
|
||||
}
|
||||
|
||||
|
||||
def get_field_display_name(field_name):
|
||||
"""获取字段的中文显示名称"""
|
||||
return FIELD_MAPPING.get(field_name, field_name)
|
||||
|
||||
|
||||
def get_all_fields():
|
||||
"""获取所有字段列表"""
|
||||
return list(FIELD_MAPPING.keys())
|
||||
|
||||
|
||||
def get_all_fields_with_display():
|
||||
"""获取所有字段及其显示名称"""
|
||||
return [{'field': k, 'display': v} for k, v in FIELD_MAPPING.items()]
|
||||
278
lib/report_generator.py
Normal file
278
lib/report_generator.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
日报生成核心逻辑
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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')
|
||||
|
||||
# 构建查询SQL
|
||||
fields_str = ', '.join(selected_fields)
|
||||
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
|
||||
"""
|
||||
|
||||
# 执行查询
|
||||
orders = execute_query(sql, (start_time_str, end_time_str, config['split_value']))
|
||||
|
||||
if not orders:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'时间范围内没有符合条件的订单数据'
|
||||
}
|
||||
|
||||
# 计算求和
|
||||
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] = total
|
||||
|
||||
# 生成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
|
||||
)
|
||||
|
||||
# 计算总金额
|
||||
total_amount = sum(float(order.get('total_money', 0) or 0) for order in orders)
|
||||
|
||||
# 保存历史记录
|
||||
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
|
||||
)
|
||||
|
||||
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:
|
||||
# 保存失败记录
|
||||
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 else '',
|
||||
end_time=end_time_str if end_time else '',
|
||||
split_type=config['split_type'] if config else '',
|
||||
split_value=config['split_value'] if config else '',
|
||||
split_name=config.get('split_name', '') if config else '',
|
||||
config_id=config_id,
|
||||
total_orders=0,
|
||||
total_amount=0,
|
||||
sum_results={},
|
||||
file_path='',
|
||||
status=0
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
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):
|
||||
"""
|
||||
生成Excel文件
|
||||
|
||||
Returns:
|
||||
str: 文件路径
|
||||
"""
|
||||
# 创建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
|
||||
for col_idx, field in enumerate(selected_fields, 1):
|
||||
cell = ws.cell(row=header_row, column=col_idx)
|
||||
cell.value = get_field_display_name(field)
|
||||
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')
|
||||
|
||||
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.00'
|
||||
|
||||
# 调整列宽
|
||||
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)))
|
||||
ws.column_dimensions[ws.cell(row=1, column=col_idx).column_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)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
execute_insert(sql, params)
|
||||
|
||||
return history_id
|
||||
Reference in New Issue
Block a user