From 1f669e1a1f3505b399b521b5c406365d65ff16c6 Mon Sep 17 00:00:00 2001 From: houhuan Date: Sun, 7 Dec 2025 21:04:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A8=E9=80=81=E7=BB=99=E6=8B=9B=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 1 + .env.example | 3 + Dockerfile | 17 + README.md | 54 +++ backend/app.py | 294 ++++++++++++++++ backend/requirements.txt | 7 + config.json | 12 + docker-compose.yml | 14 + docs/自动化营业额系统/DESIGN.md | 75 ++++ docs/自动化营业额系统/PRD.md | 45 +++ frontend/admin.html | 193 ++++++++++ frontend/index.html | 602 ++++++++++++++++++++++++++++++++ instance/data.db | Bin 0 -> 61440 bytes 数据修正使用说明.md | 66 ++++ 14 files changed, 1383 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend/app.py create mode 100644 backend/requirements.txt create mode 100644 config.json create mode 100644 docker-compose.yml create mode 100644 docs/自动化营业额系统/DESIGN.md create mode 100644 docs/自动化营业额系统/PRD.md create mode 100644 frontend/admin.html create mode 100644 frontend/index.html create mode 100644 instance/data.db create mode 100644 数据修正使用说明.md 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 0000000000000000000000000000000000000000..256e248a6d20a75c45a5e16a8520c8dcd4018050 GIT binary patch literal 61440 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 E0p&~@V*mgE literal 0 HcmV?d00001 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`(仅包含已定版数据)