commit 1f669e1a1f3505b399b521b5c406365d65ff16c6 Author: houhuan Date: Sun Dec 7 21:04:24 2025 +0800 推送给招商 diff --git a/.env b/.env new file mode 100644 index 0000000..1db0d4e --- /dev/null +++ b/.env @@ -0,0 +1 @@ +ADMIN_TOKEN=IMC_1f6a9a9c6e6c4f69bb536f3d0d8f8c7f7b6a2e4d8f3c1a5b2e7d9c4a1f0b7e6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..38ed7ba --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +ADMIN_TOKEN=your_admin_token_here +# DATABASE_URL=sqlite:///data/data.db +# TZ=Asia/Shanghai diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..527fbf6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# 多阶段构建:前端静态 → 后端镜像 +FROM python:3.11-slim +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + TZ=Asia/Shanghai +RUN apt-get update && apt-get install -y --no-install-recommends tzdata && \ + ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && dpkg-reconfigure -f noninteractive tzdata && \ + rm -rf /var/lib/apt/lists/* +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -i https://mirrors.cloud.tencent.com/pypi/simple -r requirements.txt +COPY backend/ ./backend/ +COPY frontend/ ./frontend/ +COPY config.json ./config.json +VOLUME ["/app/data"] +EXPOSE 57778 +CMD ["python", "backend/app.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b90548 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# 自动化营业额数据推送与展示系统 + +## 快速部署(Docker) +- 1:准备环境变量(不要提交真实口令到仓库) + - 复制 `.env.example` 到 `.env` 并填写 `ADMIN_TOKEN` +- 2:启动服务 +```bash +docker-compose up -d +``` +- 3:访问入口 + - 浏览器打开 `http://<服务器IP或域名>:57778` + +## 腾讯云环境优化 +- 使用腾讯云 PyPI 镜像:Dockerfile 已设置 `pip -i https://mirrors.cloud.tencent.com/pypi/simple` +- 设置时区为 `Asia/Shanghai`:Dockerfile 安装并配置 `tzdata` +- 数据持久化:`docker-compose.yml` 将宿主机 `./data` 映射为容器 `/app/data`,数据库路径 `sqlite:///data/data.db` + +## 功能概览 +- 每日 23:00(服务器本地时区)自动生成并定版当日营业额 +- 修正接口:支持按日期修正金额,前端即时一致更新;修正过程不直接在前端展示 +- 审计与日志:所有生成/修正写入审计表,兼容追加到 `app.log` +- 看板:今日/昨日/前日、本周(周一~昨日)、上周(周一~周日)、本月 +- 折线图:最近 7 天、本月、上月、最近 90 天;支持缩放、导出图片/CSV + +## 项目结构 +``` +. +├── backend/ # Flask API + APScheduler +├── frontend/ # 单页 HTML(Tailwind+Chart.js) +├── data/ # SQLite 数据卷(自动创建/挂载) +├── Dockerfile # 生产镜像(已适配腾讯云) +├── docker-compose.yml # 一键部署 +├── .env.example # 环境变量示例(不要提交真实 .env) +└── README.md +``` + +## 环境变量 +- `DATABASE_URL`:数据库连接字符串(默认 `sqlite:///data/data.db`) +- `ADMIN_TOKEN`:管理修正口令;修正时在请求头加入 `X-Admin-Token` +- `TZ`:容器时区(默认 `Asia/Shanghai`) +- `PORT`:应用监听端口(默认 `5000`,示例已改为 `57778`) + +## 修正接口示例 +```bash +curl -X PUT http://localhost:5000/api/admin/turnover \ + -H "Content-Type: application/json" \ + -H "X-Admin-Token: $ADMIN_TOKEN" \ + -d '{"date":"2025-12-06","amount":3123.45,"reason":"调整入账"}' +``` + +## 生产建议 +- 建议使用反向代理(Nginx)暴露 `5000` 端口,并开启 gzip +- 建议通过 `docker-compose` 的 `restart: unless-stopped` 保持服务稳定 +- 如需外部数据库,设置 `DATABASE_URL`(例如 PostgreSQL),并移除数据卷映射 diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..1558e29 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,294 @@ +from flask import Flask, jsonify, request, send_from_directory, Response +from flask import stream_with_context +from dotenv import load_dotenv +from flask_cors import CORS +from flask_sqlalchemy import SQLAlchemy +from apscheduler.schedulers.background import BackgroundScheduler +from tzlocal import get_localzone +from sqlalchemy import text +from datetime import datetime, timedelta, date +import re +import random +import os +import json +import time + +load_dotenv() +app = Flask(__name__, static_folder="../frontend", static_url_path="/static") +CORS(app) +app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///data.db") +db = SQLAlchemy(app) + +class DailyRevenue(db.Model): + __tablename__ = 'daily_revenue' + id = db.Column(db.Integer, primary_key=True) + date = db.Column(db.Date, nullable=False, unique=True) + amount = db.Column(db.Float, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + is_final = db.Column(db.Boolean, default=False, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + source = db.Column(db.String(20)) + note = db.Column(db.Text) + +class AuditLog(db.Model): + __tablename__ = 'audit_log' + id = db.Column(db.Integer, primary_key=True) + date = db.Column(db.Date, nullable=False) + old_amount = db.Column(db.Float) + new_amount = db.Column(db.Float) + reason = db.Column(db.Text) + actor = db.Column(db.String(50)) + type = db.Column(db.String(20)) # generate/correct/import_log + created_at = db.Column(db.DateTime, default=datetime.utcnow) + +with app.app_context(): + db.create_all() + # SQLite 动态扩展列(首次添加) + cols = [row[1] for row in db.session.execute(text('PRAGMA table_info(daily_revenue)')).fetchall()] + if 'is_final' not in cols: + db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN is_final INTEGER NOT NULL DEFAULT 0')) + if 'updated_at' not in cols: + db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN updated_at DATETIME')) + if 'source' not in cols: + db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN source TEXT')) + if 'note' not in cols: + db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN note TEXT')) + db.session.commit() + +def generate_mock_revenue(): + """保持原有逻辑:生成当日模拟营业额""" + base = random.uniform(8000, 15000) + trend = random.uniform(0.95, 1.05) + return round(base * trend, 2) + +def _append_log_line(date_str: str, amount: float, shop_name: str): + log_path = os.path.join(os.path.dirname(__file__), "..", "app.log") + line = f"准备发送消息: 【{shop_name}】{date_str}的营业额:{amount}" + with open(log_path, 'a', encoding='utf-8') as f: + f.write(line + "\n") + +def daily_job(): + """定时任务:在本地 23:00 生成并定版当日数据(不重复生成)""" + cfg = load_config() + shop_name = cfg.get("shop_name", "益选便利店") + today = datetime.now().date() + existing = DailyRevenue.query.filter_by(date=today).first() + if existing: + if not existing.is_final: + existing.is_final = True + existing.source = existing.source or 'generator' + db.session.commit() + return + amount = generate_mock_revenue() + rev = DailyRevenue(date=today, amount=amount, is_final=True, source='generator') + db.session.add(rev) + db.session.add(AuditLog(date=today, old_amount=None, new_amount=amount, reason='daily_generate', actor='system', type='generate')) + db.session.commit() + _append_log_line(today.isoformat(), amount, shop_name) + +# ---- 日志解析与聚合 ---- +def load_config(): + cfg_path = os.path.join(os.path.dirname(__file__), "..", "config.json") + try: + with open(cfg_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {"shop_name": "益选便利店", "weekday_range": [2800, 3600], "weekend_range": [1600, 2000]} + +def parse_app_log(): + log_path = os.path.join(os.path.dirname(__file__), "..", "app.log") + data = {} + if not os.path.exists(log_path): + return data + p1 = re.compile(r"准备发送消息: 【.*?】(\d{4}-\d{2}-\d{2})的营业额:(\d+(?:\.\d+)?)") + p2 = re.compile(r"准备发送消息: (\d{4}-\d{2}-\d{2}):(\d+(?:\.\d+)?)") + with open(log_path, "r", encoding="utf-8") as f: + for line in f: + m = p1.search(line) or p2.search(line) + if m: + d, amt = m.group(1), float(m.group(2)) + data[d] = amt + return data + +def gen_amount_for_date(d: date, cfg: dict): + wk = d.weekday() + lo, hi = (cfg.get("weekend_range", [1600, 2000]) if wk >= 5 else cfg.get("weekday_range", [2800, 3600])) + return round(random.uniform(lo, hi), 2) + +def sum_period(dates_map, start: date, end: date, cfg): + total = 0.0 + cur = start + while cur <= end: + key = cur.isoformat() + total += dates_map.get(key, gen_amount_for_date(cur, cfg)) + cur += timedelta(days=1) + return round(total, 2) + +def get_periods(today: date): + # 不包含今天 + yday = today - timedelta(days=1) + day_before = today - timedelta(days=2) + # 本周(周一到昨日) + monday = yday - timedelta(days=yday.weekday()) + week_start = monday + week_end = yday + # 上周(上周一到上周日) + last_week_end = week_start - timedelta(days=1) + last_week_start = last_week_end - timedelta(days=6) + # 本月(当月1号到昨日) + month_start = yday.replace(day=1) + month_end = yday + return { + "yesterday": yday, + "day_before": day_before, + "this_week": (week_start, week_end), + "last_week": (last_week_start, last_week_end), + "this_month": (month_start, month_end), + } + +@app.route("/api/metrics") +def api_metrics(): + cfg = load_config() + shop_name = cfg.get("shop_name", "益选便利店") + today_local = datetime.now().date() + periods = get_periods(today_local) + yday = periods["yesterday"] + day_before = periods["day_before"] + tw_s, tw_e = periods["this_week"] + lw_s, lw_e = periods["last_week"] + tm_s, tm_e = periods["this_month"] + # DB 为唯一来源 + def amt_for(d: date): + r = DailyRevenue.query.filter_by(date=d).first() + return r.amount if (r and r.is_final) else None + # 周区间求和:仅统计已定版日期 + def sum_final(start: date, end: date): + cur, total = start, 0.0 + while cur <= end: + r = DailyRevenue.query.filter_by(date=cur).first() + if r and r.is_final: + total += r.amount + cur += timedelta(days=1) + return round(total, 2) + weekdays = ['周一','周二','周三','周四','周五','周六','周日'] + out = { + "shop_name": shop_name, + "server_now": datetime.now().isoformat(timespec='seconds'), + "cutoff_hour": 23, + "today": {"date": today_local.isoformat(), "weekday": weekdays[today_local.weekday()], "amount": amt_for(today_local)}, + "yesterday": {"date": yday.isoformat(), "amount": amt_for(yday)}, + "day_before": {"date": day_before.isoformat(), "amount": amt_for(day_before)}, + "this_week": {"start": tw_s.isoformat(), "end": tw_e.isoformat(), "total": sum_final(tw_s, tw_e)}, + "last_week": {"start": lw_s.isoformat(), "end": lw_e.isoformat(), "total": sum_final(lw_s, lw_e)}, + "this_month": {"start": tm_s.isoformat(), "end": tm_e.isoformat(), "total": sum_final(tm_s, tm_e)}, + } + return jsonify(out) + +@app.route("/api/series7") +def api_series7(): + today_local = datetime.now().date() + days = int(request.args.get('days', 7)) + days = max(7, min(days, 90)) + # 结束日期:若今日已定版则含今日,否则到昨日 + end = today_local + r_today = DailyRevenue.query.filter_by(date=today_local).first() + if not (r_today and r_today.is_final): + end = today_local - timedelta(days=1) + start = end - timedelta(days=days-1) + cfg = load_config() + series = [] + cur = start + while cur <= end: + r = DailyRevenue.query.filter_by(date=cur).first() + if r and r.is_final: + amt = r.amount + est = False + else: + amt = gen_amount_for_date(cur, cfg) + est = True + series.append({"date": cur.isoformat(), "amount": amt, "estimated": est}) + cur += timedelta(days=1) + return jsonify(series) + +@app.route("/api/revenue") +def api_revenue(): + """查询历史营业额""" + days = int(request.args.get("days", 30)) + start = datetime.utcnow().date() - timedelta(days=days) + rows = DailyRevenue.query.filter(DailyRevenue.date >= start).order_by(DailyRevenue.date.desc()).all() + return jsonify([{"date": r.date.isoformat(), "amount": r.amount} for r in rows]) + +@app.route("/api/export") +def api_export(): + """CSV导出""" + rows = DailyRevenue.query.order_by(DailyRevenue.date.desc()).all() + csv = "date,amount\n" + "\n".join([f"{r.date},{r.amount}" for r in rows if r.is_final]) + return csv, 200, {"Content-Type": "text/csv; charset=utf-8", "Content-Disposition": "attachment; filename=revenue.csv"} + +@app.route("/") +def index(): + """SPA入口""" + return send_from_directory(app.static_folder, "index.html") + +@app.route("/admin") +def admin_page(): + return send_from_directory(app.static_folder, "admin.html") + +@app.route('/api/admin/turnover', methods=['PUT']) +def admin_correct(): + token = os.getenv('ADMIN_TOKEN') + if token and request.headers.get('X-Admin-Token') != token: + return jsonify({"error": "unauthorized"}), 401 + payload = request.get_json(force=True) + d = datetime.strptime(payload['date'], '%Y-%m-%d').date() + new_amt = float(payload['amount']) + reason = payload.get('reason', 'manual_correct') + actor = payload.get('actor', 'admin') + r = DailyRevenue.query.filter_by(date=d).first() + old = r.amount if r else None + if r: + r.amount = new_amt + r.is_final = True + r.source = 'correct' + else: + r = DailyRevenue(date=d, amount=new_amt, is_final=True, source='correct') + db.session.add(r) + db.session.add(AuditLog(date=d, old_amount=old, new_amount=new_amt, reason=reason, actor=actor, type='correct')) + db.session.commit() + _append_log_line(d.isoformat(), new_amt, load_config().get('shop_name', '益选便利店')) + return jsonify({"ok": True}) + +def sync_log_to_db(): + """启动时将 app.log 中缺失的数据同步到 DB(只同步今天之前)""" + log_map = parse_app_log() + today_local = datetime.now().date() + for ds, amt in log_map.items(): + d = datetime.strptime(ds, '%Y-%m-%d').date() + if d >= today_local: + continue + r = DailyRevenue.query.filter_by(date=d).first() + if not r: + db.session.add(DailyRevenue(date=d, amount=amt, is_final=True, source='import_log')) + db.session.add(AuditLog(date=d, old_amount=None, new_amount=amt, reason='import_log', actor='system', type='import_log')) + db.session.commit() + +if __name__ == "__main__": + local_tz = get_localzone() + scheduler = BackgroundScheduler(timezone=local_tz) + scheduler.add_job(daily_job, "cron", hour=23, minute=0) + scheduler.start() + with app.app_context(): + sync_log_to_db() + app.run(host="0.0.0.0", port=int(os.getenv("PORT", "5000"))) + +@app.route('/api/events') +def sse_events(): + def event_stream(): + while True: + now = datetime.now() + payload = {"type": "tick", "server_now": now.isoformat(timespec='seconds')} + yield f"data: {json.dumps(payload)}\n\n" + if now.hour == 23 and now.minute == 1: + yield "data: {\"type\": \"force_refresh\"}\n\n" + time.sleep(30) + return Response(stream_with_context(event_stream()), mimetype='text/event-stream') diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d8b6b98 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +Flask==2.3.3 +Flask-CORS==4.0.0 +Flask-SQLAlchemy==3.0.5 +APScheduler==3.10.4 +python-dotenv==1.0.0 +tzlocal==5.2 +tzdata==2025.1 diff --git a/config.json b/config.json new file mode 100644 index 0000000..fedf371 --- /dev/null +++ b/config.json @@ -0,0 +1,12 @@ +{ + "webhook_url": "https://api.hiflow.tencent.com/engine/webhook/31/1869391857524076545", + "shop_name": "益选便利店", + "weekday_range": [ + 2900, + 3600 + ], + "weekend_range": [ + 1600, + 2000 + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..70a18e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.9" +services: + revenue: + build: . + ports: + - "57778:57778" + environment: + - DATABASE_URL=sqlite:///data/data.db + - TZ=Asia/Shanghai + - ADMIN_TOKEN=${ADMIN_TOKEN} + - PORT=57778 + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/docs/自动化营业额系统/DESIGN.md b/docs/自动化营业额系统/DESIGN.md new file mode 100644 index 0000000..dbcd5fe --- /dev/null +++ b/docs/自动化营业额系统/DESIGN.md @@ -0,0 +1,75 @@ +# 自动化营业额数据推送与展示系统 技术架构 + +## 技术栈 +- 后端:FastAPI(Python) +- 定时:APScheduler(内嵌)/ Celery(可选) +- 数据库:SQLite(开发默认)/ PostgreSQL(生产) +- 前端:React + Tailwind(或 Vue + Tailwind) +- 部署:Docker + Docker Compose + +## 系统架构 +``` +[Generator & Scheduler] -> [FastAPI REST] -> [DB] + | + -> [Web UI] + -> [Notifier] +``` + +## 数据模型 +- 表:`turnover` + - `id` INTEGER PK + - `date` DATE UNIQUE + - `amount` DECIMAL(12,2) + - `created_at` TIMESTAMP +- 表:`config` + - `key` TEXT PK + - `value` TEXT + +## API 契约 +- `GET /api/turnover/today` 返回当日数据 +- `GET /api/turnover/range?start=YYYY-MM-DD&end=YYYY-MM-DD` 区间查询 +- `GET /api/turnover/export.csv` 导出 CSV +- `POST /api/admin/recompute?date=YYYY-MM-DD` 重新生成指定日期(管理员) + +## 定时方案 +- APScheduler `cron` 每日 `00:05` 触发生成任务 +- 任务幂等:同日重复执行将覆盖/更新当日记录 + +## 容器化 +- `backend`:FastAPI + APScheduler +- `frontend`:静态构建产物挂载到 `nginx` 或 `vite preview` +- `db`:SQLite 挂载卷 / 可切换 PostgreSQL 服务 +- `notifier`:邮件/Webhook 可选模块 + +## Docker Compose 示例 +```yaml +services: + backend: + build: ./backend + env_file: .env + volumes: + - ./data:/app/data + ports: + - "8000:8000" + frontend: + build: ./frontend + ports: + - "3000:3000" + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: example + volumes: + - ./pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" +``` + +## 异常与告警 +- 生成失败:记录日志并触发邮件/Webhook(启用时) +- API 错误:统一异常处理中返回标准错误响应 + +## 安全与配置 +- `.env` 管理数据库与通知凭据 +- 简易登录:基于环境变量开关,默认关闭 + diff --git a/docs/自动化营业额系统/PRD.md b/docs/自动化营业额系统/PRD.md new file mode 100644 index 0000000..6c9c832 --- /dev/null +++ b/docs/自动化营业额系统/PRD.md @@ -0,0 +1,45 @@ +# 自动化营业额数据推送与展示系统 PRD + +## 项目概述 +- 目标:摆脱第三方平台依赖,实现每日自动生成、持久化、展示营业额数据,并通过 Docker 一键部署。 +- 使用人群:运营(查看与导出)、技术(运维与配置)。 + +## 用户角色 +- 管理员:配置任务、查看与导出数据、触发重跑、查看告警。 +- 访客:查看仪表盘与历史数据。 + +## 核心功能 +- 数据生成:保留现有假数据逻辑(可优化为可参数化),每日定时生成。 +- 数据存储:SQLite(默认)/ PostgreSQL(生产),结构化存储并持久化历史。 +- Web 展示:响应式仪表盘、趋势图、表格历史查询、区间筛选与导出。 +- 定时执行:APScheduler(内嵌)或 Celery(可选扩展)。 +- 容器化:Dockerfile + Docker Compose 一键部署。 +- 通知提醒:邮件或 Webhook(可选),失败时发送告警。 +- 访问控制:简易登录(可选),基于环境变量开关。 + +## 页面与交互 +- 首页仪表盘 + - 当日营业额、近7/30日趋势图、同比环比(基于模拟规则)。 +- 历史数据页 + - 时间区间筛选、列表与图表联动、CSV/Excel 导出。 +- 配置管理页(可选) + - 定时规则、数据生成参数、通知渠道配置。 + +## 验收标准 +- 每日自动生成与写库,无需人工干预。 +- Web 界面在 PC 与移动端均能正常展示与操作。 +- 历史数据可查询并支持 CSV/Excel 导出。 +- Docker Compose 可一键启动全部服务并运行定时任务。 +- 失败自动告警可在启用时生效。 + +## 非功能需求 +- 可维护:配置项集中于 `.env` 与 `config.json`。 +- 可扩展:后端 API 与任务模块化,便于替换生成逻辑。 +- 安全:不提交敏感信息到代码仓库;登录开关默认关闭。 + +## 里程碑 +- M1:后端 API + 数据模型 + 定时任务(APScheduler)。 +- M2:前端响应式展示 + 区间查询 + 导出。 +- M3:Docker 化与部署文档。 +- M4:告警通知与简单登录(可选)。 + diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..af9dcaa --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,193 @@ + + + + + + 数据修正 - IMC益选便利店 + + + + + +
+
+
+ +
+
+
+

+ + 数据修正控制台 +

+ < 返回看板 +
+ +
+
+ + +
+
+ +
+ ¥ + +
+
+
+ + +
+
+ +
+ + +
+
+ + + +
+
+
+
+ + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c1a40fc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,602 @@ + + + + + IMC益选便利店营业额数据看板 + + + + + + + + + +
+
+
+ +
+
+
+
+
+

+ IMC 益选便利店 +

+
+ + 实时监控中 + | + LAST UPDATE: - +
+
+
+
+
+ +
+ +
+
+ 今日营业额 +
+ - + - +
+
+
Loading...
+
+ +
+
+ 昨日营业额 +
+ - + - +
+
+
-
+
+ +
+
+ 前日营业额 +
+ - + - +
+
+
-
+
+ +
+
+ 本周累计 + - +
+
-
+
+ +
+
+ 上周累计 + - +
+
-
+
+ +
+
+ 本月累计 + - +
+
-
+
+
+ +
+
+
+ + + + +
+
+ + +
+
+
+ +
+
+
+
+ + + + diff --git a/instance/data.db b/instance/data.db new file mode 100644 index 0000000..256e248 Binary files /dev/null and b/instance/data.db differ diff --git a/数据修正使用说明.md b/数据修正使用说明.md new file mode 100644 index 0000000..28b25cb --- /dev/null +++ b/数据修正使用说明.md @@ -0,0 +1,66 @@ +# 数据修正使用说明 + +## 目的 +- 指导如何在不暴露修正过程的前提下,修正某天营业额数据,并确保前端即时一致更新。 +- 所有修正与生成均写入审计表和 `app.log`,便于稽核与追踪。 + +## 前置准备 +- 在项目根目录创建或编辑 `.env`,设置管理口令: + - `ADMIN_TOKEN=你的口令` +- 后端运行于 `http://localhost:5000`(Docker 或本地均可)。 + +## 修正接口 +- URL:`PUT /api/admin/turnover` +- 鉴权:请求头 `X-Admin-Token: <你的口令>`(来自 `.env`) +- Content-Type:`application/json` +- 请求体: + - `date`:`YYYY-MM-DD` + - `amount`:数值(营业额) + - `reason`:字符串(修正原因) + - `actor`:可选,执行者标识 + +## 请求示例 +- curl(Windows PowerShell 请使用完整双引号并转义): +``` +curl -X PUT http://localhost:5000/api/admin/turnover ^ + -H "Content-Type: application/json" ^ + -H "X-Admin-Token: <你的口令>" ^ + -d "{\"date\":\"2025-12-06\",\"amount\":3123.45,\"reason\":\"调整入账\"}" +``` +- PowerShell(推荐): +``` +$body = @{ date = "2025-12-06"; amount = 3123.45; reason = "调整入账" } | ConvertTo-Json +Invoke-RestMethod -Method Put -Uri "http://localhost:5000/api/admin/turnover" -Headers @{"X-Admin-Token"="<你的口令>"} -ContentType "application/json" -Body $body +``` +- Postman: + - Method: PUT;URL: `http://localhost:5000/api/admin/turnover` + - Headers: `X-Admin-Token: <你的口令>`;`Content-Type: application/json` + - Body(JSON):`{"date":"2025-12-06","amount":3123.45,"reason":"调整入账"}` + +## 成功结果与前端效果 +- 接口返回:`{"ok": true}` +- 后端行为: + - 写入/更新 `daily_revenue` 对应日期的记录,并置 `is_final=true` + - 写入 `audit_log` 审计记录 + - 追加一行到 `app.log`(与历史格式一致) +- 前端:每 60 秒轮询 `GET /api/metrics` 与 `GET /api/series7`,修正后下一次轮询即可显示新值;修正过程不在前端展示。 + +## 生成与定版规则(一致性) +- 每天本地时间 `23:00` 自动生成并定版当日数据(`is_final=true`)。 +- 已定版的数据不会被定时任务覆盖;如需变更,请使用修正接口。 +- `CSV` 导出仅包含已定版数据。 + +## 审计与回滚 +- 审计表:`audit_log` + - 字段:`date`、`old_amount`、`new_amount`、`reason`、`actor`、`type(generate/correct/import_log)`、`created_at` +- 回滚方式:再次调用修正接口,将 `amount` 改回目标值,并注明 `reason`(例如:`回滚`)。 + +## 常见问题 +- 401 未授权:检查 `.env` 中 `ADMIN_TOKEN` 与请求头 `X-Admin-Token` 是否一致。 +- 前端未更新:等待轮询或手动刷新;确保后端正在运行并能访问 `GET /api/metrics` 与 `GET /api/series7`。 +- 时间与周次显示不符:系统按本地时区与“周一为周起、周日为周止”的规则计算区间。 + +## 相关接口(只读) +- 指标:`GET /api/metrics`(含 `today`:日期与周几;未到 23:00 或未定版则 `amount=null`) +- 7 日序列:`GET /api/series7`(今日定版后纳入,否则截止到昨日) +- 导出:`GET /api/export`(仅包含已定版数据)