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:
user9994793890
2026-07-09 11:26:23 +08:00
parent c2dabee453
commit 9383612712
10 changed files with 2232 additions and 404 deletions

656
README.md
View File

@@ -1,369 +1,349 @@
# 订单日报系统 - 本地运行指南 # 订单日报系统 - Python Flask版本
这是一个基于 Next.js + TypeScript 的订单日报系统,用于自动生成公交等企业的订单日报。 一个基于Python Flask的订单日报自动生成系统,支持按企业或用户拆分,自定义字段,定时生成Excel报表。
## 功能特性
- ✅ 定时任务:每天8:01自动生成前一天日报
- ✅ 配置管理:创建、编辑、删除、启用/禁用配置
- ✅ 排序功能:配置列表支持上移下移排序
- ✅ 自定义字段:选择报表显示的字段,中文列名
- ✅ 求和功能:支持对多个字段求和
- ✅ 按企业/用户拆分:支持按企业ID或用户ID生成日报
- ✅ 预设时间:昨天、最近3天、最近7天
- ✅ 自定义时间:支持任意时间范围
- ✅ 批量下载:历史记录支持单个和批量下载
- ✅ Excel导出:生成格式化的Excel文件
## 系统要求 ## 系统要求
### 必需软件 - Python 3.8+
- pip(Python包管理器)
1. **Node.js** (版本要求) ## 安装步骤
- **推荐版本**:Node.js 20+ 或 Node.js 24+
- **最低版本**:Node.js 18.17+
- **下载地址**:https://nodejs.org/
2. **pnpm** (包管理器) ### 1. 安装Python
- **安装命令**:
#### Windows
1. 访问 https://www.python.org/downloads/
2. 下载 Python 3.8 或更高版本
3. 安装时勾选 "Add Python to PATH"
#### 验证安装
```bash ```bash
npm install -g pnpm python --version
pip --version
``` ```
- **说明**:本项目强制使用pnpm,禁止使用npm或yarn
### 可选软件(用于生产环境) ### 2. 安装依赖
- **PM2**:用于进程管理和自动重启 在项目根目录执行:
```bash ```bash
npm install -g pm2 pip install -r requirements.txt
``` ```
## 本地运行步骤 ### 3. 数据库配置
### 1. 安装 Node.js 数据库连接信息已配置在 `lib/db.py` 中:
- 地址:haoslm2.xicp.net:10216
- 数据库:yltcharge
- 用户名:root
- 密码:DsideaL147258369
#### Windows系统 如需修改,编辑 `lib/db.py` 文件中的 `DB_CONFIG`。
- 访问 https://nodejs.org/
- 下载 LTS 版本(推荐 Node.js 20+)
- 双击安装包,按提示完成安装
- 安装完成后,打开命令提示符验证:
```bash
node --version
npm --version
```
#### macOS系统 ### 4. 初始化数据库表
- 使用 Homebrew 安装:
```bash
brew install node@20
```
- 或访问官网下载安装包
#### Linux系统 (Ubuntu/Debian)
```bash
# 使用 NodeSource 仓库
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# 验证安装
node --version
npm --version
```
### 2. 安装 pnpm
```bash
# 全局安装 pnpm
npm install -g pnpm
# 验证安装
pnpm --version
```
### 3. 克隆或下载项目
```bash
# 如果使用 Git
git clone <项目地址>
cd <项目目录>
# 或者直接下载项目压缩包并解压
```
### 4. 安装项目依赖
```bash
# 进入项目目录
cd <项目目录>
# 安装所有依赖(必须使用 pnpm)
pnpm install
```
**安装时间**:首次安装约需5-10分钟,取决于网络速度。
### 5. 配置数据库连接
项目已配置好数据库连接,默认连接:
- **地址**:haoslm2.xicp.net:10216
- **数据库**:yltcharge
- **用户名**:root
- **密码**:DsideaL147258369
如果需要修改,编辑 `src/lib/db.ts` 文件。
### 6. 初始化数据库表
首次运行时,需要初始化数据库表:
#### 方式1:通过API初始化(推荐)
启动服务后,访问或调用:
```bash
# 启动服务
pnpm run dev
# 在浏览器访问或使用 curl
# 访问地址:http://localhost:5000/api/init-tables
# 或使用命令:
curl -X POST http://localhost:5000/api/init-tables
```
#### 方式2:手动执行SQL
如果无法访问API,可以手动在数据库执行建表SQL(参考 `src/lib/init-tables.ts`)。
### 7. 启动开发服务器
```bash
# 启动开发环境(带热更新)
pnpm run dev
```
**启动成功标志**:
```
✓ Compiled in xxxms
✓ Ready in xxxms
Local: http://localhost:5000
```
### 8. 访问应用
打开浏览器,访问:
- **本地地址**:http://localhost:5000
- **功能页面**:
- 配置管理:创建和管理日报配置
- 日报生成:选择配置和时间范围生成日报
- 历史记录:查看和下载已生成的日报
## 生产环境部署
### 1. 构建生产版本
```bash
# 构建优化的生产版本
pnpm run build
```
### 2. 启动生产服务器
#### 方式1:直接启动
```bash
pnpm run start
```
#### 方式2:使用 PM2(推荐)
```bash
# 安装 PM2
npm install -g pm2
# 启动服务
pm2 start scripts/start.sh --name "daily-report"
# 查看状态
pm2 status
# 查看日志
pm2 logs daily-report
# 设置开机自启
pm2 startup
pm2 save
```
### 3. 配置定时任务
项目内置定时任务,每天8:01自动生成日报:
- 定时任务在 `src/server.ts` 中配置
- 使用 node-cron 实现
- 北京时间(UTC+8)
如果需要修改定时任务时间,编辑 `src/server.ts`:
```typescript
// 修改定时任务时间(cron表达式)
cron.schedule('1 8 * * *', async () => {
// 每天8:01执行
console.log('开始生成日报...');
}, {
timezone: "Asia/Shanghai"
});
```
## 常见问题
### 1. pnpm 安装失败
**问题**:`pnpm: command not found`
**解决**:
```bash
# 使用 npm 安装 pnpm
npm install -g pnpm
# 或使用 corepack(Node.js 16.10+)
corepack enable
corepack prepare pnpm@latest --activate
```
### 2. Node.js 版本过低
**问题**:`error: The engine "node" is incompatible`
**解决**:
- 升级 Node.js 到 20+ 版本
- 或修改 `package.json` 中的 engines 配置
### 3. 数据库连接失败
**问题**:`Error: connect ETIMEDOUT` 或 `Error: connect ECONNREFUSED`
**解决**:
1. 检查数据库地址和端口是否正确
2. 检查防火墙是否允许访问
3. 检查数据库用户名和密码
4. 编辑 `src/lib/db.ts` 修改连接配置
### 4. 端口占用
**问题**:`Error: listen EADDRINUSE: address already in use :::5000`
**解决**:
```bash
# Windows
netstat -ano | findstr :5000
taskkill /PID <进程ID> /F
# Linux/macOS
lsof -ti:5000 | xargs kill -9
```
### 5. Excel 文件无法下载
**问题**:点击下载按钮无反应
**解决**:
- 检查 `public/reports/` 目录是否存在
- 检查文件是否成功生成(查看历史记录)
- 浏览器可能拦截下载,检查浏览器设置
### 6. 定时任务不执行
**问题**:8:01没有自动生成日报
**解决**:
1. 确认服务是否持续运行(不要关闭终端)
2. 检查是否有启用的配置(is_active=1)
3. 查看日志是否有错误信息
4. 手动测试定时任务:
```bash
# 访问测试接口(如果有)
curl http://localhost:5000/api/test-cron
```
## 项目目录说明
```
/workspace/projects/
├── src/ ← 主程序目录
│ ├── app/ ← Next.js 应用
│ │ ├── page.tsx ← 主页面(配置、生成、历史)
│ │ ├── api/ ← API 接口
│ │ └── layout.tsx ← 应用布局
│ ├── lib/ ← 核心业务逻辑
│ │ ├── db.ts ← 数据库连接
│ │ ├── report-generator.ts ← 日报生成
│ │ └ init-tables.ts ← 建表脚本
│ │ └ field-mapping.ts ← 字段映射
│ ├── server.ts ← 服务启动(含定时任务)
│ └ components/ ← UI 组件
│ └ hooks/ ← React Hooks
├── scripts/ ← 启动脚本
│ ├── dev.sh ← 开发启动
│ ├── build.sh ← 构建脚本
│ └ start.sh ← 生产启动
├── public/ ← 静态资源
│ └ reports/ ← Excel 文件存储
├── package.json ← 依赖配置
├── tsconfig.json ← TS 配置
├── .coze ← 启动配置
├── AGENTS.md ← 项目说明
└── README.md ← 本文件
```
## 运行命令总结
```bash
# 1. 安装依赖
pnpm install
# 2. 开发环境启动(带热更新)
pnpm run dev
# 访问:http://localhost:5000
# 3. 构建生产版本
pnpm run build
# 4. 生产环境启动
pnpm run start
# 5. 代码检查
pnpm run validate
# 6. TypeScript 类型检查
pnpm run ts-check
# 7. 代码格式检查
pnpm run lint
```
## 数据库初始化
首次运行需要初始化数据库表: 首次运行需要初始化数据库表:
### 自动创建的表(不修改原表) 启动服务后,访问或调用:
```bash
curl -X POST http://localhost:5000/api/init-tables
```
1. **t_daily_report_config** - 配置表 或在浏览器中访问:
- 存储用户的日报配置 ```
- 包含字段选择、求和字段等 http://localhost:5000/api/init-tables
```
2. **t_daily_report_history** - 历史表 ## 运行方式
- 记录每次日报生成的历史
- 包含文件路径、统计信息等
**重要**:原表 `t_equipment_charge_order` 只读取,结构和内容都不会修改。 ### 开发环境(推荐)
## 技术栈说明 #### Windows
```bash
scripts\dev.bat
```
- **Next.js 16**:React全栈框架(App Router模式) 或直接运行:
- **React 19**:前端UI库 ```bash
- **TypeScript 5**:类型安全的JavaScript python app.py
- **Tailwind CSS 4**:实用优先的CSS框架 ```
- **shadcn/ui**:基于Radix UI的组件库
- **mysql2**:MySQL数据库驱动
- **node-cron**:定时任务调度器
- **xlsx**:Excel文件生成库
## 许可和说明 ### 生产环境
- 本项目仅供内部使用 ```bash
- 数据库连接信息已预配置 python app.py
- 定时任务使用北京时间(Asia/Shanghai) ```
- Excel文件存储在 `public/reports/` 目录
## 获取帮助 ### 访问应用
如有问题,请查看: 启动后在浏览器访问:
1. 本README文件的常见问题部分 ```
2. `AGENTS.md` 项目说明文档 http://localhost:5000
3. 检查日志文件(如有) ```
4. 查看API返回的错误信息
## 使用指南
### 1. 创建配置
1. 点击"配置管理"标签
2. 点击"创建配置"按钮
3. 填写配置信息:
- 配置名称:如"79路公交日报"
- 拆分方式:选择"按企业ID"或"按用户ID"
- 选择企业/用户:从下拉列表选择
- 选择字段:勾选需要显示的字段
- 求和字段:勾选需要求和的字段(可选)
4. 点击"保存"
### 2. 生成日报
1. 点击"日报生成"标签
2. 选择配置
3. 选择时间范围:
- 点击预设按钮(昨天、最近3天、最近7天)
- 或自定义时间范围
4. 点击"生成日报"
5. 生成成功后点击"下载Excel"
### 3. 查看历史
1. 点击"历史记录"标签
2. 查看所有生成的日报记录
3. 点击"下载"按钮下载Excel文件
### 4. 定时任务
系统会在每天8:01(北京时间)自动为所有启用的配置生成日报。
## 项目结构
```
.
├── app.py # Flask主应用
├── requirements.txt # Python依赖
├── lib/ # 业务逻辑模块
│ ├── __init__.py
│ ├── db.py # 数据库连接
│ ├── field_mapping.py # 字段映射(英文->中文)
│ └── report_generator.py # 日报生成核心逻辑
├── templates/ # HTML模板
│ └── index.html # 主页面
├── public/ # 静态文件
│ └── reports/ # 生成的Excel文件
├── scripts/ # 启动脚本
│ ├── dev.bat # Windows开发环境启动
│ └── start.bat # Windows生产环境启动
└── README.md # 说明文档
```
## API接口
### 配置管理
- `GET /api/config` - 获取所有配置
- `POST /api/config` - 创建配置
- `PUT /api/config/<id>` - 更新配置
- `DELETE /api/config/<id>` - 删除配置
- `POST /api/config/<id>/toggle` - 启用/禁用配置
- `POST /api/config/<id>/move` - 移动配置排序
### 企业和用户
- `GET /api/entities?type=company` - 获取企业列表
- `GET /api/entities?type=user` - 获取用户列表
### 字段
- `GET /api/fields` - 获取所有可用字段
### 日报生成
- `POST /api/report/generate` - 生成日报
### 历史记录
- `GET /api/report/history` - 获取历史记录(分页)
### 文件下载
- `GET /api/download?path=<file_path>` - 下载Excel文件
### 数据库初始化
- `POST /api/init-tables` - 初始化数据库表
## 数据库表说明
### t_daily_report_config(配置表)
存储日报配置信息:
- id: 配置ID
- config_name: 配置名称
- split_type: 拆分方式(company_id/user_id)
- split_value: 拆分值
- split_name: 拆分显示名称
- selected_fields: 选择的字段列表(JSON)
- sum_fields: 求和字段列表(JSON)
- is_active: 是否启用
- sort_order: 排序顺序
- create_time: 创建时间
- update_time: 更新时间
### t_daily_report_history(历史表)
存储日报生成历史:
- id: 历史ID
- report_date: 报表日期
- start_time: 开始时间
- end_time: 结束时间
- split_type: 拆分方式
- split_value: 拆分值
- split_name: 拆分显示名称
- config_id: 配置ID
- total_orders: 订单总数
- total_amount: 总金额
- sum_results: 求和结果(JSON)
- file_path: 文件路径
- status: 状态(0=失败,1=成功)
- create_time: 创建时间
## 常见问题
### 1. 端口5000被占用
修改 `app.py` 中的端口:
```python
port = int(os.environ.get('PORT', 5001)) # 改为其他端口
```
或设置环境变量:
```bash
set PORT=5001 # Windows
export PORT=5001 # Linux/Mac
python app.py
```
### 2. 数据库连接失败
检查:
- 数据库地址和端口是否正确
- 防火墙是否放行
- 数据库用户名和密码是否正确
测试连接:
```python
python -c "from lib.db import execute_query; print(execute_query('SELECT 1'))"
```
### 3. 定时任务不执行
确保:
- 服务持续运行(不要关闭终端)
- 至少有一个启用的配置
- 系统时区正确(Asia/Shanghai)
### 4. Excel文件无法下载
检查:
- `public/reports/` 目录是否存在
- 文件是否已生成
- 浏览器是否允许下载
### 5. 中文乱码
确保:
- 数据库字符集为 utf8mb4
- Python文件编码为 UTF-8
- HTML页面编码为 UTF-8
## 开发说明
### 添加新字段
编辑 `lib/field_mapping.py`,在 `FIELD_MAPPING` 字典中添加:
```python
'new_field': '新字段中文名'
```
### 修改定时任务时间
编辑 `app.py`,修改:
```python
scheduler.add_job(scheduled_job, 'cron', hour=8, minute=1) # 改为其他时间
```
### 修改数据库配置
编辑 `lib/db.py`,修改 `DB_CONFIG`:
```python
DB_CONFIG = {
'host': '新地址',
'port': 新端口,
'user': '新用户名',
'password': '新密码',
'database': '新数据库',
}
```
## 部署建议
### 使用Gunicorn(生产环境)
```bash
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
### 使用Supervisor管理进程
创建 `/etc/supervisor/conf.d/daily-report.conf`:
```ini
[program:daily-report]
command=/usr/bin/python /path/to/app.py
directory=/path/to/project
autostart=true
autorestart=true
stderr_logfile=/var/log/daily-report.err.log
stdout_logfile=/var/log/daily-report.out.log
```
### 使用Nginx反向代理
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## 技术栈
- **后端**: Python 3.8+ / Flask 3.0
- **数据库**: MySQL / Apache Doris
- **数据库驱动**: PyMySQL
- **Excel处理**: openpyxl
- **定时任务**: APScheduler
- **前端**: HTML + CSS + JavaScript(原生)
## 许可证
MIT License
## 联系方式
如有问题,请联系开发团队。
--- ---
**最后更新时间**:2025-07-08 **最后更新**: 2025-07-08

449
app.py Normal file
View File

@@ -0,0 +1,449 @@
"""
Flask主应用
订单日报系统
"""
import os
import json
from datetime import datetime
from flask import Flask, request, jsonify, send_from_directory, render_template
from flask_cors import CORS
from apscheduler.schedulers.background import BackgroundScheduler
import pytz
from lib.db import execute_query, execute_update, execute_insert
from lib.field_mapping import get_all_fields_with_display
from lib.report_generator import generate_daily_report
# 创建Flask应用
app = Flask(__name__,
template_folder='templates',
static_folder='public')
CORS(app)
# 确保public/reports目录存在
os.makedirs(os.path.join('public', 'reports'), exist_ok=True)
# ==================== 页面路由 ====================
@app.route('/')
def index():
"""主页"""
return render_template('index.html')
# ==================== 配置管理API ====================
@app.route('/api/config', methods=['GET'])
def get_configs():
"""获取所有配置"""
try:
configs = execute_query(
'SELECT * FROM t_daily_report_config ORDER BY sort_order ASC, id DESC'
)
# 解析JSON字段
for config in configs:
config['selected_fields'] = json.loads(config['selected_fields'])
config['sum_fields'] = json.loads(config['sum_fields']) if config['sum_fields'] else []
return jsonify({'success': True, 'data': configs})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/config', methods=['POST'])
def create_config():
"""创建配置"""
try:
data = request.json
config_name = data.get('config_name')
split_type = data.get('split_type')
split_value = data.get('split_value')
split_name = data.get('split_name', '')
selected_fields = data.get('selected_fields', [])
sum_fields = data.get('sum_fields', [])
if not config_name or not split_type or not split_value:
return jsonify({'success': False, 'message': '缺少必填字段'}), 400
if not selected_fields:
return jsonify({'success': False, 'message': '请至少选择一个字段'}), 400
# 生成ID
config_id = int(datetime.now().timestamp() * 1000)
# 获取最大排序值
max_sort = execute_query('SELECT MAX(sort_order) as max_sort FROM t_daily_report_config')
sort_order = (max_sort[0]['max_sort'] or 0) + 1
sql = """
INSERT INTO t_daily_report_config
(id, config_name, split_type, split_value, split_name, selected_fields,
sum_fields, is_active, sort_order, create_time, update_time)
VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s, NOW(), NOW())
"""
params = (
config_id,
config_name,
split_type,
split_value,
split_name,
json.dumps(selected_fields, ensure_ascii=False),
json.dumps(sum_fields, ensure_ascii=False),
sort_order
)
execute_insert(sql, params)
return jsonify({
'success': True,
'message': '配置创建成功',
'data': {'id': config_id}
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/config/<int:config_id>', methods=['PUT'])
def update_config(config_id):
"""更新配置"""
try:
data = request.json
config_name = data.get('config_name')
split_type = data.get('split_type')
split_value = data.get('split_value')
split_name = data.get('split_name', '')
selected_fields = data.get('selected_fields', [])
sum_fields = data.get('sum_fields', [])
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, update_time = NOW()
WHERE id = %s
"""
params = (
config_name,
split_type,
split_value,
split_name,
json.dumps(selected_fields, ensure_ascii=False),
json.dumps(sum_fields, ensure_ascii=False),
config_id
)
execute_update(sql, params)
return jsonify({'success': True, 'message': '配置更新成功'})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/config/<int:config_id>', methods=['DELETE'])
def delete_config(config_id):
"""删除配置"""
try:
execute_update('DELETE FROM t_daily_report_config WHERE id = %s', (config_id,))
return jsonify({'success': True, 'message': '配置删除成功'})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/config/<int:config_id>/toggle', methods=['POST'])
def toggle_config(config_id):
"""启用/禁用配置"""
try:
execute_update(
'UPDATE t_daily_report_config SET is_active = NOT is_active, update_time = NOW() WHERE id = %s',
(config_id,)
)
return jsonify({'success': True, 'message': '操作成功'})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/config/<int:config_id>/move', methods=['POST'])
def move_config(config_id):
"""移动配置排序"""
try:
direction = request.json.get('direction') # up 或 down
# 获取当前配置
current = execute_query(
'SELECT sort_order FROM t_daily_report_config WHERE id = %s',
(config_id,)
)
if not current:
return jsonify({'success': False, 'message': '配置不存在'}), 404
current_order = current[0]['sort_order']
if direction == 'up':
# 找到上一个配置
prev = execute_query(
'SELECT id, sort_order FROM t_daily_report_config WHERE sort_order < %s ORDER BY sort_order DESC LIMIT 1',
(current_order,)
)
if prev:
# 交换排序
execute_update(
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
(prev[0]['sort_order'], config_id)
)
execute_update(
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
(current_order, prev[0]['id'])
)
elif direction == 'down':
# 找到下一个配置
next_config = execute_query(
'SELECT id, sort_order FROM t_daily_report_config WHERE sort_order > %s ORDER BY sort_order ASC LIMIT 1',
(current_order,)
)
if next_config:
# 交换排序
execute_update(
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
(next_config[0]['sort_order'], config_id)
)
execute_update(
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
(current_order, next_config[0]['id'])
)
return jsonify({'success': True, 'message': '移动成功'})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
# ==================== 企业和用户列表API ====================
@app.route('/api/entities', methods=['GET'])
def get_entities():
"""获取企业或用户列表"""
try:
entity_type = request.args.get('type', 'company')
if entity_type == 'company':
# 获取企业列表
companies = execute_query(
'SELECT DISTINCT company_id, company_name FROM t_company ORDER BY company_name'
)
return jsonify({'success': True, 'data': companies})
else:
# 获取用户列表
users = execute_query(
'SELECT DISTINCT user_id, user_name, order_type FROM t_equipment_charge_order WHERE user_id IS NOT NULL ORDER BY user_name'
)
return jsonify({'success': True, 'data': users})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
# ==================== 字段列表API ====================
@app.route('/api/fields', methods=['GET'])
def get_fields():
"""获取所有可用字段"""
try:
fields = get_all_fields_with_display()
return jsonify({'success': True, 'data': fields})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
# ==================== 日报生成API ====================
@app.route('/api/report/generate', methods=['POST'])
def generate_report():
"""生成日报"""
try:
data = request.json
config_id = data.get('config_id')
if not config_id:
return jsonify({'success': False, 'message': '请选择配置'}), 400
# 解析时间参数
start_time = None
end_time = None
if data.get('start_time'):
start_time = datetime.strptime(data['start_time'], '%Y-%m-%d %H:%M:%S')
if data.get('end_time'):
end_time = datetime.strptime(data['end_time'], '%Y-%m-%d %H:%M:%S')
result = generate_daily_report(config_id, start_time, end_time)
if result['success']:
return jsonify(result)
else:
return jsonify(result), 400
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
# ==================== 历史记录API ====================
@app.route('/api/report/history', methods=['GET'])
def get_report_history():
"""获取日报生成历史"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 20))
offset = (page - 1) * page_size
# 查询总数
total_result = execute_query('SELECT COUNT(*) as total FROM t_daily_report_history')
total = total_result[0]['total']
# 查询数据
history = execute_query(
'SELECT * FROM t_daily_report_history ORDER BY create_time DESC LIMIT %s OFFSET %s',
(page_size, offset)
)
# 解析JSON字段
for item in history:
item['sum_results'] = json.loads(item['sum_results']) if item['sum_results'] else {}
return jsonify({
'success': True,
'data': {
'list': history,
'total': total,
'page': page,
'page_size': page_size
}
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
# ==================== 数据库初始化API ====================
@app.route('/api/init-tables', methods=['POST'])
def init_tables():
"""初始化数据库表"""
try:
# 创建配置表
create_config_table = """
CREATE TABLE IF NOT EXISTS t_daily_report_config (
id BIGINT NOT NULL COMMENT '配置ID',
config_name VARCHAR(100) NOT NULL COMMENT '配置名称',
split_type VARCHAR(20) NOT NULL COMMENT '拆分方式',
split_value VARCHAR(100) NOT NULL COMMENT '拆分值',
split_name VARCHAR(200) NULL COMMENT '拆分显示名称',
selected_fields VARCHAR(3000) NOT NULL COMMENT '选择的字段列表',
sum_fields VARCHAR(3000) NULL COMMENT '求和字段列表',
is_active TINYINT NULL COMMENT '是否启用',
sort_order INT NULL COMMENT '排序顺序',
create_time DATETIME NULL COMMENT '创建时间',
update_time DATETIME NULL COMMENT '更新时间'
)
UNIQUE KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES("replication_num" = "1")
"""
execute_update(create_config_table)
# 创建历史表
create_history_table = """
CREATE TABLE IF NOT EXISTS t_daily_report_history (
id BIGINT NOT NULL COMMENT '历史ID',
report_date VARCHAR(20) NULL COMMENT '报表日期',
start_time VARCHAR(50) NULL COMMENT '开始时间',
end_time VARCHAR(50) NULL COMMENT '结束时间',
split_type VARCHAR(20) NULL COMMENT '拆分方式',
split_value VARCHAR(100) NULL COMMENT '拆分值',
split_name VARCHAR(200) NULL COMMENT '拆分显示名称',
config_id BIGINT NULL COMMENT '配置ID',
total_orders INT NULL COMMENT '订单总数',
total_amount DECIMAL(10,2) NULL COMMENT '总金额',
sum_results VARCHAR(2000) NULL COMMENT '求和结果',
file_path VARCHAR(500) NULL COMMENT '文件路径',
status TINYINT NULL COMMENT '状态',
create_time DATETIME NULL COMMENT '创建时间'
)
UNIQUE KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES("replication_num" = "1")
"""
execute_update(create_history_table)
return jsonify({'success': True, 'message': '数据库表初始化成功'})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
# ==================== 文件下载API ====================
@app.route('/api/download', methods=['GET'])
def download_file():
"""下载文件"""
try:
file_path = request.args.get('path')
if not file_path:
return jsonify({'success': False, 'message': '文件路径不能为空'}), 400
# 安全检查:确保路径在public目录下
if not file_path.startswith('/reports/'):
return jsonify({'success': False, 'message': '无效的文件路径'}), 400
filename = file_path.replace('/reports/', '')
reports_dir = os.path.join('public', 'reports')
return send_from_directory(reports_dir, filename, as_attachment=True)
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
# ==================== 定时任务 ====================
def scheduled_job():
"""定时任务:每天8:01自动生成日报"""
print(f'[{datetime.now()}] 开始执行定时任务...')
try:
# 获取所有启用的配置
configs = execute_query(
'SELECT id, config_name FROM t_daily_report_config WHERE is_active = 1'
)
if not configs:
print('没有启用的配置,跳过生成')
return
print(f'找到 {len(configs)} 个启用的配置')
# 为每个配置生成日报
for config in configs:
print(f'正在生成: {config["config_name"]}')
result = generate_daily_report(config['id'])
if result['success']:
print(f' ✓ 成功: {result["total_orders"]} 条订单, 金额: {result["total_amount"]}')
else:
print(f' ✗ 失败: {result["message"]}')
print('定时任务执行完成')
except Exception as e:
print(f'定时任务执行失败: {e}')
# 启动定时任务
scheduler = BackgroundScheduler(timezone='Asia/Shanghai')
scheduler.add_job(scheduled_job, 'cron', hour=8, minute=1)
scheduler.start()
print('定时任务已启动:每天 08:01 执行')
# ==================== 启动应用 ====================
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
print(f'启动服务器: http://localhost:{port}')
app.run(host='0.0.0.0', port=port, debug=True)

17
lib/__init__.py Normal file
View 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
View 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
View 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
View 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

View File

@@ -1,39 +1,22 @@
# Python 依赖包列表 # Python Flask 日报系统依赖
# 订单日报系统 - Python辅助工具依赖
# 数据处理和分析 # Web框架
pandas>=2.0.0 Flask==3.0.0
numpy>=1.24.0 Flask-CORS==4.0.0
openpyxl>=3.1.0 # Excel文件处理
xlrd>=2.0.1 # 读取旧版Excel文件
xlsxwriter>=3.1.0 # Excel写入
# 数据库连接 # 数据库连接
mysql-connector-python>=8.0.0 PyMySQL==1.1.0
pymysql>=1.1.0 cryptography==41.0.7
sqlalchemy>=2.0.0
# Web开发和API # Excel处理
flask>=3.0.0 openpyxl==3.1.2
fastapi>=0.100.0
requests>=2.31.0
uvicorn>=0.23.0
# 定时任务 # 定时任务
schedule>=1.2.0 APScheduler==3.10.4
apscheduler>=3.10.0
# 日期时间处理 # 工具库
python-dateutil>=2.8.0 python-dateutil==2.8.2
pytz>=2023.3 pytz==2023.3
# 日志和调试 # 开发工具(可选)
loguru>=0.7.0 python-dotenv==1.0.0
# 数据可视化(可选)
matplotlib>=3.7.0
seaborn>=0.12.0
# 其他实用工具
tqdm>=4.66.0 # 进度条
python-dotenv>=1.0.0 # 环境变量管理

View File

@@ -1,32 +1,13 @@
@echo off @echo off
REM Windows开发环境启动脚本 REM Windows开发环境启动脚本
REM 设置端口 echo Starting Flask development server...
set PORT=5000 echo Working directory: %cd%
if not defined DEPLOY_RUN_PORT set DEPLOY_RUN_PORT=%PORT%
REM 检查并清除端口占用 REM 设置环境变量
echo Checking port %DEPLOY_RUN_PORT%... set FLASK_APP=app.py
netstat -ano | findstr :%DEPLOY_RUN_PORT% | findstr LISTENING >nul set FLASK_ENV=development
if %errorlevel% equ 0 ( set FLASK_DEBUG=1
echo Port %DEPLOY_RUN_PORT% is in use, attempting to clear...
for /f "tokens=5" %%a in ('netstat -ano ^| findstr :%DEPLOY_RUN_PORT% ^| findstr LISTENING') do (
echo Killing process %%a on port %DEPLOY_RUN_PORT%
taskkill /PID %%a /F >nul 2>&1
)
timeout /t 2 /nobreak >nul
echo Port %DEPLOY_RUN_PORT% cleared.
) else (
echo Port %DEPLOY_RUN_PORT% is free.
)
REM 设置工作目录 REM 启动Flask应用
if not defined COZE_WORKSPACE_PATH set COZE_WORKSPACE_PATH=%cd% python app.py
echo Starting HTTP service on port %DEPLOY_RUN_PORT% for dev...
echo Working directory: %COZE_WORKSPACE_PATH%
REM 启动开发服务器
cd /d "%COZE_WORKSPACE_PATH%"
set PORT=%DEPLOY_RUN_PORT%
pnpm tsx watch src/server.ts

View File

@@ -1,15 +1,12 @@
@echo off @echo off
REM Windows生产环境启动脚本 REM Windows生产环境启动脚本
REM 设置端口 echo Starting Flask production server...
set PORT=5000 echo Working directory: %cd%
if not defined DEPLOY_RUN_PORT set DEPLOY_RUN_PORT=%PORT%
REM 设置工作目录 REM 设置环境变量
if not defined COZE_WORKSPACE_PATH set COZE_WORKSPACE_PATH=%cd% set FLASK_APP=app.py
set FLASK_ENV=production
cd /d "%COZE_WORKSPACE_PATH%" REM 启动Flask应用(生产环境)
python app.py
echo Starting HTTP service on port %DEPLOY_RUN_PORT% for deploy...
set PORT=%DEPLOY_RUN_PORT%
node dist/server.js

957
templates/index.html Normal file
View File

@@ -0,0 +1,957 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>订单日报系统</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: #f5f7fa;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #2c3e50;
}
.tabs {
display: flex;
border-bottom: 2px solid #e1e8ed;
margin-bottom: 20px;
}
.tab {
padding: 12px 24px;
cursor: pointer;
border: none;
background: none;
font-size: 16px;
color: #666;
transition: all 0.3s;
}
.tab:hover {
color: #3498db;
}
.tab.active {
color: #3498db;
border-bottom: 2px solid #3498db;
margin-bottom: -2px;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #555;
}
.form-control {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-control:focus {
outline: none;
border-color: #3498db;
}
select {
background: white;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #229954;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-warning {
background: #f39c12;
color: white;
}
.btn-warning:hover {
background: #d35400;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e1e8ed;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
tr:hover {
background: #f8f9fa;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
max-height: 300px;
overflow-y: auto;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 5px;
min-width: 200px;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-active {
background: #d4edda;
color: #155724;
}
.status-inactive {
background: #f8d7da;
color: #721c24;
}
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 15px;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.empty {
text-align: center;
padding: 40px;
color: #999;
}
.action-buttons {
display: flex;
gap: 5px;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
}
.modal.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 30px;
max-width: 800px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 20px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}
.close-btn:hover {
color: #333;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 20px;
}
.pagination button {
padding: 6px 12px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
}
.pagination button:hover:not(:disabled) {
background: #f0f0f0;
}
.pagination button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.pagination span {
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>订单日报系统</h1>
<div class="tabs">
<button class="tab active" onclick="switchTab('config')">配置管理</button>
<button class="tab" onclick="switchTab('generate')">日报生成</button>
<button class="tab" onclick="switchTab('history')">历史记录</button>
</div>
<!-- 配置管理 -->
<div id="config-tab" class="tab-content active">
<div class="card">
<button class="btn btn-primary" onclick="showCreateModal()">创建配置</button>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>配置名称</th>
<th>拆分方式</th>
<th>拆分值</th>
<th>字段数</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="config-list">
<tr><td colspan="6" class="loading">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 日报生成 -->
<div id="generate-tab" class="tab-content">
<div class="card">
<h3 style="margin-bottom: 20px;">生成日报</h3>
<div class="form-group">
<label>选择配置 *</label>
<select id="generate-config" class="form-control">
<option value="">请选择配置</option>
</select>
</div>
<div class="form-group">
<label>时间范围</label>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<button class="btn btn-sm btn-primary" onclick="setPresetTime('yesterday')">昨天</button>
<button class="btn btn-sm btn-primary" onclick="setPresetTime('last3days')">最近3天</button>
<button class="btn btn-sm btn-primary" onclick="setPresetTime('last7days')">最近7天</button>
</div>
<div style="display: flex; gap: 10px;">
<div style="flex: 1;">
<input type="datetime-local" id="start-time" class="form-control">
</div>
<div style="flex: 1;">
<input type="datetime-local" id="end-time" class="form-control">
</div>
</div>
</div>
<button class="btn btn-success" onclick="generateReport()">生成日报</button>
<div id="generate-result" style="margin-top: 20px;"></div>
</div>
</div>
<!-- 历史记录 -->
<div id="history-tab" class="tab-content">
<div class="card">
<button class="btn btn-primary" onclick="refreshHistory()">刷新</button>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>报表日期</th>
<th>时间范围</th>
<th>配置名称</th>
<th>拆分值</th>
<th>订单数</th>
<th>总金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="history-list">
<tr><td colspan="8" class="loading">加载中...</td></tr>
</tbody>
</table>
<div class="pagination" id="pagination"></div>
</div>
</div>
</div>
<!-- 创建/编辑配置模态框 -->
<div id="config-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="modal-title">创建配置</h3>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<div class="form-group">
<label>配置名称 *</label>
<input type="text" id="config-name" class="form-control" placeholder="请输入配置名称">
</div>
<div class="form-group">
<label>拆分方式 *</label>
<select id="split-type" class="form-control" onchange="loadEntities()">
<option value="">请选择拆分方式</option>
<option value="company_id">按企业ID</option>
<option value="user_id">按用户ID</option>
</select>
</div>
<div class="form-group">
<label>选择企业/用户 *</label>
<select id="split-value" class="form-control">
<option value="">请先选择拆分方式</option>
</select>
</div>
<div class="form-group">
<label>选择字段 *</label>
<div class="checkbox-group" id="fields-list">
<div class="loading">加载中...</div>
</div>
</div>
<div class="form-group">
<label>求和字段(可选)</label>
<div class="checkbox-group" id="sum-fields-list">
<div class="loading">加载中...</div>
</div>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button class="btn" onclick="closeModal()">取消</button>
<button class="btn btn-primary" onclick="saveConfig()">保存</button>
</div>
</div>
</div>
<script>
let editingConfigId = null;
let allFields = [];
let currentPage = 1;
const pageSize = 20;
// 切换标签页
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(`${tabName}-tab`).classList.add('active');
if (tabName === 'config') {
loadConfigs();
} else if (tabName === 'generate') {
loadGenerateConfigs();
} else if (tabName === 'history') {
loadHistory();
}
}
// 加载配置列表
async function loadConfigs() {
try {
const response = await fetch('/api/config');
const result = await response.json();
if (result.success) {
renderConfigList(result.data);
} else {
alert('加载配置失败: ' + result.message);
}
} catch (error) {
alert('加载配置失败: ' + error.message);
}
}
// 渲染配置列表
function renderConfigList(configs) {
const tbody = document.getElementById('config-list');
if (configs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty">暂无配置,请点击"创建配置"按钮</td></tr>';
return;
}
tbody.innerHTML = configs.map(config => `
<tr>
<td>${config.config_name}</td>
<td>${config.split_type === 'company_id' ? '按企业ID' : '按用户ID'}</td>
<td>${config.split_name || config.split_value}</td>
<td>${config.selected_fields.length}</td>
<td>
<span class="status-badge ${config.is_active ? 'status-active' : 'status-inactive'}">
${config.is_active ? '已启用' : '已禁用'}
</span>
</td>
<td>
<div class="action-buttons">
<button class="btn btn-sm btn-warning" onclick="toggleConfig(${config.id})">
${config.is_active ? '禁用' : '启用'}
</button>
<button class="btn btn-sm btn-primary" onclick="editConfig(${config.id})">编辑</button>
<button class="btn btn-sm btn-danger" onclick="deleteConfig(${config.id})">删除</button>
<button class="btn btn-sm" onclick="moveConfig(${config.id}, 'up')">↑</button>
<button class="btn btn-sm" onclick="moveConfig(${config.id}, 'down')">↓</button>
</div>
</td>
</tr>
`).join('');
}
// 显示创建模态框
function showCreateModal() {
editingConfigId = null;
document.getElementById('modal-title').textContent = '创建配置';
document.getElementById('config-name').value = '';
document.getElementById('split-type').value = '';
document.getElementById('split-value').innerHTML = '<option value="">请先选择拆分方式</option>';
loadFields();
document.getElementById('config-modal').classList.add('active');
}
// 加载字段列表
async function loadFields() {
try {
const response = await fetch('/api/fields');
const result = await response.json();
if (result.success) {
allFields = result.data;
renderFields();
}
} catch (error) {
console.error('加载字段失败:', error);
}
}
// 渲染字段列表
function renderFields(selectedFields = [], sumFields = []) {
const fieldsList = document.getElementById('fields-list');
const sumFieldsList = document.getElementById('sum-fields-list');
fieldsList.innerHTML = allFields.map(field => `
<label class="checkbox-item">
<input type="checkbox" value="${field.field}"
${selectedFields.includes(field.field) ? 'checked' : ''}>
${field.display}
</label>
`).join('');
sumFieldsList.innerHTML = allFields.map(field => `
<label class="checkbox-item">
<input type="checkbox" value="${field.field}"
${sumFields.includes(field.field) ? 'checked' : ''}>
${field.display}
</label>
`).join('');
}
// 加载企业/用户列表
async function loadEntities() {
const splitType = document.getElementById('split-type').value;
const select = document.getElementById('split-value');
if (!splitType) {
select.innerHTML = '<option value="">请先选择拆分方式</option>';
return;
}
select.innerHTML = '<option value="">加载中...</option>';
try {
const response = await fetch(`/api/entities?type=${splitType === 'company_id' ? 'company' : 'user'}`);
const result = await response.json();
if (result.success) {
const entities = result.data;
select.innerHTML = '<option value="">请选择</option>' +
entities.map(e => {
const value = splitType === 'company_id' ? e.company_id : e.user_id;
const name = splitType === 'company_id' ? e.company_name : e.user_name;
const type = e.order_type === 3 ? '(企业用户)' : '(普通用户)';
return `<option value="${value}" data-name="${name}">${name} ${splitType === 'user_id' ? type : ''}</option>`;
}).join('');
}
} catch (error) {
console.error('加载列表失败:', error);
}
}
// 保存配置
async function saveConfig() {
const configName = document.getElementById('config-name').value;
const splitType = document.getElementById('split-type').value;
const splitValueSelect = document.getElementById('split-value');
const splitValue = splitValueSelect.value;
const splitName = splitValueSelect.options[splitValueSelect.selectedIndex].dataset.name || '';
const selectedFields = Array.from(document.querySelectorAll('#fields-list input:checked')).map(cb => cb.value);
const sumFields = Array.from(document.querySelectorAll('#sum-fields-list input:checked')).map(cb => cb.value);
if (!configName || !splitType || !splitValue) {
alert('请填写所有必填字段');
return;
}
if (selectedFields.length === 0) {
alert('请至少选择一个字段');
return;
}
const data = {
config_name: configName,
split_type: splitType,
split_value: splitValue,
split_name: splitName,
selected_fields: selectedFields,
sum_fields: sumFields
};
try {
let response;
if (editingConfigId) {
response = await fetch(`/api/config/${editingConfigId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await response.json();
if (result.success) {
alert(editingConfigId ? '配置更新成功' : '配置创建成功');
closeModal();
loadConfigs();
} else {
alert('保存失败: ' + result.message);
}
} catch (error) {
alert('保存失败: ' + error.message);
}
}
// 编辑配置
async function editConfig(configId) {
try {
const response = await fetch('/api/config');
const result = await response.json();
if (result.success) {
const config = result.data.find(c => c.id === configId);
if (config) {
editingConfigId = configId;
document.getElementById('modal-title').textContent = '编辑配置';
document.getElementById('config-name').value = config.config_name;
document.getElementById('split-type').value = config.split_type;
await loadEntities();
document.getElementById('split-value').value = config.split_value;
await loadFields();
renderFields(config.selected_fields, config.sum_fields);
document.getElementById('config-modal').classList.add('active');
}
}
} catch (error) {
alert('加载配置失败: ' + error.message);
}
}
// 删除配置
async function deleteConfig(configId) {
if (!confirm('确定要删除这个配置吗?')) {
return;
}
try {
const response = await fetch(`/api/config/${configId}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
alert('配置删除成功');
loadConfigs();
} else {
alert('删除失败: ' + result.message);
}
} catch (error) {
alert('删除失败: ' + error.message);
}
}
// 启用/禁用配置
async function toggleConfig(configId) {
try {
const response = await fetch(`/api/config/${configId}/toggle`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
loadConfigs();
} else {
alert('操作失败: ' + result.message);
}
} catch (error) {
alert('操作失败: ' + error.message);
}
}
// 移动配置
async function moveConfig(configId, direction) {
try {
const response = await fetch(`/api/config/${configId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction })
});
const result = await response.json();
if (result.success) {
loadConfigs();
} else {
alert('移动失败: ' + result.message);
}
} catch (error) {
alert('移动失败: ' + error.message);
}
}
// 关闭模态框
function closeModal() {
document.getElementById('config-modal').classList.remove('active');
}
// 加载生成配置列表
async function loadGenerateConfigs() {
try {
const response = await fetch('/api/config');
const result = await response.json();
if (result.success) {
const select = document.getElementById('generate-config');
select.innerHTML = '<option value="">请选择配置</option>' +
result.data.map(c => `<option value="${c.id}">${c.config_name}</option>`).join('');
}
} catch (error) {
console.error('加载配置失败:', error);
}
}
// 设置预设时间
function setPresetTime(preset) {
const now = new Date();
let start, end;
if (preset === 'yesterday') {
end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0);
start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
} else if (preset === 'last3days') {
end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0);
start = new Date(end.getTime() - 3 * 24 * 60 * 60 * 1000);
} else if (preset === 'last7days') {
end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0);
start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000);
}
document.getElementById('start-time').value = formatDateTimeLocal(start);
document.getElementById('end-time').value = formatDateTimeLocal(end);
}
// 格式化日期时间
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
// 生成日报
async function generateReport() {
const configId = document.getElementById('generate-config').value;
const startTime = document.getElementById('start-time').value;
const endTime = document.getElementById('end-time').value;
if (!configId) {
alert('请选择配置');
return;
}
if (!startTime || !endTime) {
alert('请选择时间范围');
return;
}
const resultDiv = document.getElementById('generate-result');
resultDiv.innerHTML = '<div class="alert alert-info">正在生成日报,请稍候...</div>';
try {
const response = await fetch('/api/report/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config_id: parseInt(configId),
start_time: startTime.replace('T', ' ') + ':00',
end_time: endTime.replace('T', ' ') + ':00'
})
});
const result = await response.json();
if (result.success) {
resultDiv.innerHTML = `
<div class="alert alert-success">
<strong>日报生成成功!</strong><br>
订单数量: ${result.total_orders}<br>
总金额: ¥${result.total_amount.toFixed(2)}<br>
<a href="/api/download?path=${result.file_path}" target="_blank" class="btn btn-sm btn-success" style="margin-top: 10px;">下载Excel</a>
</div>
`;
} else {
resultDiv.innerHTML = `<div class="alert alert-error">生成失败: ${result.message}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="alert alert-error">生成失败: ${error.message}</div>`;
}
}
// 加载历史记录
async function loadHistory(page = 1) {
currentPage = page;
try {
const response = await fetch(`/api/report/history?page=${page}&page_size=${pageSize}`);
const result = await response.json();
if (result.success) {
renderHistory(result.data.list);
renderPagination(result.data.total, page);
}
} catch (error) {
console.error('加载历史记录失败:', error);
}
}
// 渲染历史记录
function renderHistory(history) {
const tbody = document.getElementById('history-list');
if (history.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="empty">暂无历史记录</td></tr>';
return;
}
tbody.innerHTML = history.map(item => `
<tr>
<td>${item.report_date}</td>
<td>${item.start_time || '-'} ~ ${item.end_time || '-'}</td>
<td>${item.config_name || '-'}</td>
<td>${item.split_name || item.split_value}</td>
<td>${item.total_orders}</td>
<td>¥${(item.total_amount || 0).toFixed(2)}</td>
<td>
<span class="status-badge ${item.status === 1 ? 'status-active' : 'status-inactive'}">
${item.status === 1 ? '成功' : '失败'}
</span>
</td>
<td>
${item.file_path ? `<a href="/api/download?path=${item.file_path}" target="_blank" class="btn btn-sm btn-primary">下载</a>` : '-'}
</td>
</tr>
`).join('');
}
// 渲染分页
function renderPagination(total, currentPage) {
const totalPages = Math.ceil(total / pageSize);
const pagination = document.getElementById('pagination');
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
pagination.innerHTML = `
<button ${currentPage === 1 ? 'disabled' : ''} onclick="loadHistory(${currentPage - 1})">上一页</button>
<span>第 ${currentPage} / ${totalPages} 页,共 ${total} 条</span>
<button ${currentPage === totalPages ? 'disabled' : ''} onclick="loadHistory(${currentPage + 1})">下一页</button>
`;
}
// 刷新历史记录
function refreshHistory() {
loadHistory(currentPage);
}
// 初始化
loadConfigs();
loadFields();
</script>
</body>
</html>