Compare commits

..

13 Commits

Author SHA1 Message Date
houhuan 166109a1c8 feat(飞书通知): 实现营业额数据推送至飞书的功能
添加完整的飞书推送功能,包括卡片消息、富文本消息和纯文本消息三种形式
支持与昨日数据的对比展示,包含金额差值和百分比变化
添加请求签名和错误处理机制,确保推送可靠性
2025-12-11 16:38:22 +08:00
houhuan 940e493e39 fix(config): 修改截止时间为23:00以匹配业务需求
更新config.json中的cutoff_hour和cutoff_time配置,将截止时间从00:00改为23:00,以符合实际的业务运营时间要求。同时app.log中新增了当日的营业额记录。
2025-12-11 13:12:43 +08:00
houhuan fb546861b5 fix: 修正每日营收任务逻辑并更新配置
修复daily_job函数中的逻辑错误,确保正确检查和处理已存在的营收记录
将config.json中的cutoff_hour和cutoff_time更新为00:00
2025-12-10 01:01:05 +08:00
houhuan 4a3e39d76d docs: 添加Postman配置文件和更新API文档
添加Postman环境配置和API集合文件,用于接口测试
更新README和API文档,补充部署说明和接口细节
2025-12-09 15:00:57 +08:00
houhuan a46dd6eb74 docs: 添加自动化营业额系统的API文档
添加完整的API文档,包含系统概述、认证方式、公共字段说明、13个接口的详细说明(路径、参数、响应示例等),以及触发结算逻辑和配置文件说明
2025-12-09 14:52:47 +08:00
houhuan 7aae6f9e52 feat(admin): 添加管理员日志查看接口
新增/api/admin/logs接口,允许管理员通过X-Admin-Token验证后查看应用日志
同时调整飞书推送日志的响应体截取长度从200增加到500字符
2025-12-09 14:23:59 +08:00
houhuan 64baba32b4 hohoho 2025-12-09 14:14:53 +08:00
houhuan 1890a8380b 修改config 2025-12-09 13:59:03 +08:00
houhuan 5e68d97127 优化更新 2025-12-09 13:28:22 +08:00
houhuan 1b1daa581e 修复整点不生成数据bug,新增机器人推送 2025-12-09 10:19:02 +08:00
houhuan 339f8211e4 修正今日 2025-12-07 23:55:28 +08:00
houhuan 616fe10b02 提交了假数据,不需要 2025-12-07 22:07:35 +08:00
houhuan 04d70ace99 999 2025-12-07 22:05:40 +08:00
19 changed files with 915 additions and 86 deletions
+2 -2
View File
@@ -7,7 +7,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
AUTO_IMPORT_ON_START=1
RUN apk add --no-cache tzdata && \
RUN sed -i -e 's/dl-cdn.alpinelinux.org/mirrors.cloud.tencent.com/g' /etc/apk/repositories && \
apk update && apk add --no-cache tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo "Asia/Shanghai" > /etc/timezone
COPY backend/requirements.txt .
RUN python -m pip install --no-cache-dir --upgrade pip -i $PIP_INDEX_URL && \
@@ -15,7 +16,6 @@ RUN python -m pip install --no-cache-dir --upgrade pip -i $PIP_INDEX_URL && \
COPY backend/ ./backend/
COPY frontend/ ./frontend/
COPY config.json ./config.json
COPY seed/ /app/init/
RUN mkdir -p /app/data
VOLUME ["/app/data"]
EXPOSE 57778
+67 -18
View File
@@ -3,12 +3,18 @@
## 快速部署(Docker
- 1:准备环境变量(不要提交真实口令到仓库)
- 复制 `.env.example``.env` 并填写 `ADMIN_TOKEN`
- 2启动服务
- 2配置映射(确保容器读取宿主机配置与数据)
- `docker-compose.yml` 已映射:`./data:/app/data``./config.json:/app/config.json:ro`
- 3:启动服务
```bash
docker-compose up -d
docker-compose up -d --build
```
- 3:访问入口
- 4:访问入口
- 浏览器打开 `http://<服务器IP或域名>:57778`
- 5:在线重载截止时间(无需重启)
```bash
curl -X POST "http://<服务器>:57778/api/admin/reload_cutoff" -H "X-Admin-Token: $ADMIN_TOKEN"
```
## 腾讯云环境优化
- 使用腾讯云 PyPI 镜像:Dockerfile 已设置 `pip -i https://mirrors.cloud.tencent.com/pypi/simple`
@@ -16,23 +22,33 @@ docker-compose up -d
- 数据持久化:`docker-compose.yml` 将宿主机 `./data` 映射为容器 `/app/data`,数据库路径 `sqlite:///data/data.db`
## 功能概览
- 每日 23:00(服务器本地时区)自动生成并定版当日营业额
- 每日截止时间(本地时区,支持分钟级,如 `13:18`)自动生成并定版当日营业额
- 修正接口:支持按日期修正金额,前端即时一致更新;修正过程不直接在前端展示
- 审计与日志:所有生成/修正写入审计表,兼容追加到 `app.log`
- 看板:今日/昨日/前日、本周(周一~昨日)、上周(周一~周日)、本月
- 折线图:最近 7 天、本月、上月、最近 90 天;支持缩放、导出图片/CSV
- 飞书推送:卡片 → 帖子 → 文本三段式兼容;支持签名与 IP 白名单
## 项目结构
```
.
├── backend/ # Flask API + APScheduler
├── frontend/ # 单页 HTMLTailwind+Chart.js
├── seed/ # 预置数据CSVrevenue.sample.csv),构建时复制到 /app/init/
├── data/ # SQLite 数据卷(自动创建/挂载
├── Dockerfile # 生产镜像(已适配腾讯云)
├── docker-compose.yml # 一键部署
├── .env.example # 环境变量示例(不要提交真实 .env)
└── README.md
├── backend/ # Flask API + APScheduler
├── frontend/ # 单页 HTMLTailwind+Chart.js
├── data/ # SQLite 数据卷(容器映射 /app/data
├── instance/ # 历史/本地副本(当前不作为权威数据源
├── docs/
│ └── 自动化营业额系统/
│ ├── API.md # 全量接口文档
│ ├── DESIGN.md # 设计说明
│ └── PRD.md # 需求说明
├── scripts/
│ ├── deploy.ps1 # Windows 一键部署/重载/试推
│ └── deploy.sh # Linux 一键部署/重载/试推
├── Dockerfile # 生产镜像(已适配腾讯云)
├── docker-compose.yml # 一键部署(映射 data 与 config
├── .env.example # 环境变量示例(不要提交真实 .env)
├── README.md
└── app.log # 运行日志(含飞书返回体片段)
```
## 环境变量
@@ -40,6 +56,7 @@ docker-compose up -d
- `ADMIN_TOKEN`:管理修正口令;修正时在请求头加入 `X-Admin-Token`
- `TZ`:容器时区(默认 `Asia/Shanghai`
- `PORT`:应用监听端口(默认 `5000`,示例已改为 `57778`
- `AUTO_IMPORT_ON_START`:启动时自动导入 `data/import.csv`DB 为空时)
## 修正接口示例
```bash
@@ -49,10 +66,19 @@ curl -X PUT http://localhost:5000/api/admin/turnover \
-d '{"date":"2025-12-06","amount":3123.45,"reason":"调整入账"}'
```
## 在线重载截止时间(无需重启)
```bash
curl -X POST "http://localhost:57778/api/admin/reload_cutoff" -H "X-Admin-Token: $ADMIN_TOKEN"
```
## 读取运行日志(含推送返回体片段)
```bash
curl "http://localhost:57778/api/admin/logs?lines=200" -H "X-Admin-Token: $ADMIN_TOKEN"
```
## 数据预置与迁移
- 首次启动自动导入(DB为空):
1. 如果存在 `/app/data/import.csv`,优先导入该文件
2. 否则导入镜像内 `/app/init/revenue.csv`(可将 `seed/revenue.sample.csv` 重命名为 `revenue.csv`
- 首次启动自动导入(开启 `AUTO_IMPORT_ON_START=1`DB为空):
- 如果存在 `/app/data/import.csv`(宿主机 `./data/import.csv`),自动导入该文件
- CSV格式:
```
date,amount
@@ -66,13 +92,36 @@ curl -X PUT http://localhost:5000/api/admin/turnover \
--data-binary @revenue.csv
```
## 本地运行(默认数据库路径)
- 默认数据库:`sqlite:///data/data.db`(项目根目录下的 `data/data.db`
- 启动:
```powershell
$env:PORT=57778; $env:ADMIN_TOKEN="<你的口令>"; python backend/app.py
```
- 自定义路径(Windows 绝对路径示例):
```powershell
$env:DATABASE_URL="sqlite:////e:/2025Code/python/YixuanYingye/data/data.db"; python backend/app.py
```
## 飞书机器人配置
- 使用飞书自定义机器人 Webhook:`https://open.feishu.cn/open-apis/bot/v2/hook/<ID>`
- 如开启签名校验,在 `config.json` 设置 `feishu_secret`;未开启则留空
- 如启用 IP 白名单,需在机器人配置中加入服务器出口公网 IP;否则会返回 `{"code":19022,"msg":"Ip Not Allowed"}`
## API 文档
- 详见 `docs/自动化营业额系统/API.md`
## 归档建议
- 保留以下文件以便复现与审计:
- `docs/自动化营业额系统/*`、`Dockerfile`、`docker-compose.yml`、`config.json`、`app.log`
- 生产数据卷:`data/data.db`(或导出的 CSV
- 如迁移外部数据库,建议取消数据卷映射并设置 `DATABASE_URL`PostgreSQL/MySQL
## 环境优化(最优解)
- 基础镜像切换为 `python:3.11-alpine`,构建更快、体积更小
- 依赖安装走腾讯云 PyPI 镜像:`PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple`
- 自动数据导入:环境变量 `AUTO_IMPORT_ON_START=1`,启动时按顺序导入
1. `/app/data/import.csv`(宿主机 `./data/import.csv`
2. `/app/init/revenue.csv`(仓库 `seed/revenue.csv`
3. `/app/init/revenue.sample.csv`
- `/app/data/import.csv`(宿主机 `./data/import.csv`
## 生产建议
- 建议使用反向代理(Nginx)暴露 `5000` 端口,并开启 gzip
+7
View File
@@ -0,0 +1,7 @@
准备发送消息: 【益选便利店】2025-12-07的营业额:2408.7
准备发送消息: 【益选便利店】2025-12-08的营业额:3402.6
准备发送消息: 【益选便利店】2025-12-08的营业额:3629.76
准备发送消息: 【益选便利店】2025-12-06的营业额:1803.09
准备发送消息: 【益选便利店】2025-12-09的营业额:3462.53
准备发送消息: 【益选便利店】2025-12-10的营业额:3222.85
准备发送消息: 【益选便利店】2025-12-11的营业额:3550.84
+356 -52
View File
@@ -14,27 +14,36 @@ import json
import time
import csv
import io
import requests
import hmac
import hashlib
import base64
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")
def _ensure_sqlite_dir(url):
if not url.startswith('sqlite:'):
return
p = url
if p.startswith('sqlite:////'):
db_path = p.replace('sqlite:////', '/')
elif p.startswith('sqlite:///'):
db_path = os.path.join(os.getcwd(), p.replace('sqlite:///', ''))
base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
db_path = os.path.join(base_dir, "data", "data.db")
db_url_env = os.getenv('DATABASE_URL')
if db_url_env:
app.config['SQLALCHEMY_DATABASE_URI'] = db_url_env
else:
if os.name == 'nt':
app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{db_path}"
else:
return
app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:////{db_path}"
def _ensure_sqlite_dir():
d = os.path.dirname(db_path)
if d and not os.path.exists(d):
if not os.path.exists(d):
os.makedirs(d, exist_ok=True)
_ensure_sqlite_dir(app.config['SQLALCHEMY_DATABASE_URI'])
_ensure_sqlite_dir()
db = SQLAlchemy(app)
scheduler = None
class DailyRevenue(db.Model):
__tablename__ = 'daily_revenue'
id = db.Column(db.Integer, primary_key=True)
@@ -71,6 +80,102 @@ with app.app_context():
db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN note TEXT'))
db.session.commit()
def push_feishu(date_str: str, amount: float, reason: str):
cfg = load_config()
url = cfg.get("feishu_webhook_url")
if not url:
return
shop = cfg.get("shop_name", "益选便利店")
try:
d = datetime.strptime(date_str, '%Y-%m-%d').date()
except Exception:
d = datetime.now().date()
y = d - timedelta(days=1)
r = DailyRevenue.query.filter_by(date=y).first()
y_amt = (r.amount if (r and r.is_final) else None)
arrow = ''
diff_str = ''
pct_str = ''
if isinstance(y_amt, (int, float)):
diff = amount - y_amt
arrow = '🔺' if diff >= 0 else '🔻'
diff_str = f"{'+' if diff >= 0 else '-'}{abs(diff):.2f}"
if y_amt != 0:
pct = abs(diff / y_amt) * 100.0
pct_str = f"({'+' if diff >= 0 else '-'}{pct:.2f}%)"
today_line = f"**今日**:¥{amount:.2f}"
if isinstance(y_amt, (int, float)):
today_line += f" {arrow} {diff_str} {pct_str}".strip()
y_line = f"**昨日**{('暂无数据' if y_amt is None else '¥' + format(y_amt, '.2f'))}"
card = {
"config": {"wide_screen_mode": True},
"elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": f"📊 **{shop}** 营业额通知"}},
{"tag": "hr"},
{"tag": "div", "text": {"tag": "lark_md", "content": f"**日期**{date_str}"}},
{"tag": "div", "text": {"tag": "lark_md", "content": today_line}},
{"tag": "div", "text": {"tag": "lark_md", "content": y_line}},
{"tag": "note", "elements": [
{"tag": "plain_text", "content": f"原因:{reason} | 时间:{datetime.now().isoformat(timespec='seconds')}"}
]}
]
}
payload = {"msg_type": "interactive", "card": card}
secret = cfg.get("feishu_secret")
if secret:
ts = str(int(time.time()))
sign_src = ts + "\n" + secret
sign = base64.b64encode(hmac.new(secret.encode(), sign_src.encode(), digestmod=hashlib.sha256).digest()).decode()
payload.update({"timestamp": ts, "sign": sign})
def _log(s: str):
p = os.path.join(os.path.dirname(__file__), "..", "app.log")
with open(p, 'a', encoding='utf-8') as f:
f.write(s + "\n")
try:
print(s, flush=True)
except Exception:
pass
def _post_json(u: str, payload_obj: dict):
body = json.dumps(payload_obj, ensure_ascii=False).encode('utf-8')
return requests.post(u, data=body, headers={'Content-Type': 'application/json; charset=utf-8'}, timeout=5)
try:
is_feishu = ('open.feishu.cn' in url)
ok = False
if is_feishu:
resp = _post_json(url, payload)
ok = (200 <= resp.status_code < 300)
_log(f"飞书推送卡片{'成功' if ok else '失败'}: status={resp.status_code} body={resp.text[:500]}")
if not ok:
post_payload = {
"msg_type": "post",
"content": {
"post": {
"zh_cn": {
"title": f"{shop} 营业额通知",
"content": [[
{"tag":"text","text": f"日期:{date_str}\n"},
{"tag":"text","text": f"今日:¥{amount:.2f}"},
*( [{"tag":"text","text": f" {arrow} {diff_str} {pct_str}"}] if isinstance(y_amt,(int,float)) else [] ),
],[
{"tag":"text","text": f"原因:{reason}"}
]]
}
}
}
}
resp_post = _post_json(url, post_payload)
ok = (200 <= resp_post.status_code < 300)
_log(f"飞书推送POST{'成功' if ok else '失败'}: status={resp_post.status_code} body={resp_post.text[:500]}")
if not ok:
text = f"{shop}\n日期:{date_str}\n今日:¥{amount:.2f}"
if isinstance(y_amt, (int, float)):
text += f" {arrow} {diff_str} {pct_str}".strip()
text += f"\n原因:{reason}"
resp2 = _post_json(url, {"msg_type":"text","content":{"text": text}})
_log(f"飞书推送文本{'成功' if (200 <= resp2.status_code < 300) else '失败'}: status={resp2.status_code} body={resp2.text[:500]}")
except Exception as e:
_log(f"飞书推送异常: {str(e)[:200]}")
def generate_mock_revenue():
"""保持原有逻辑:生成当日模拟营业额"""
base = random.uniform(8000, 15000)
@@ -82,25 +187,71 @@ def _append_log_line(date_str: str, amount: float, shop_name: str):
line = f"准备发送消息: 【{shop_name}{date_str}的营业额:{amount}"
with open(log_path, 'a', encoding='utf-8') as f:
f.write(line + "\n")
try:
print(line, flush=True)
except Exception:
pass
def daily_job():
"""定时任务:在本地 23:00 生成并定版当日数据(不重复生成"""
def daily_job(target_date=None):
"""定时任务:生成并定版指定日期的数据(默认当日"""
with app.app_context():
cfg = load_config()
shop_name = cfg.get("shop_name", "益选便利店")
if target_date is None:
target_date = datetime.now().date()
existing = DailyRevenue.query.filter_by(date=target_date).first()
if existing:
if not existing.is_final:
existing.is_final = True
existing.source = existing.source or 'generator'
db.session.commit()
push_feishu(target_date.isoformat(), existing.amount, "daily_finalize")
else:
push_feishu(target_date.isoformat(), existing.amount, "daily_exists")
return
amount = gen_amount_for_date(target_date, cfg)
rev = DailyRevenue(date=target_date, amount=amount, is_final=True, source='generator')
db.session.add(rev)
db.session.add(AuditLog(date=target_date, old_amount=None, new_amount=amount, reason='daily_generate', actor='system', type='generate'))
db.session.commit()
_append_log_line(target_date.isoformat(), amount, shop_name)
push_feishu(target_date.isoformat(), amount, "daily_generate")
def settle_today_if_due():
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)
ct = cfg.get("cutoff_time")
ch = cfg.get("cutoff_hour", 23)
cm = 0
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
try:
p = ct.split(':')
ch = int(p[0]); cm = int(p[1])
except Exception:
pass
try:
ch = int(ch)
except Exception:
ch = 23
if ch < 0 or ch > 23:
ch = 23
if cm < 0 or cm > 59:
cm = 0
local_tz = get_localzone()
now = datetime.now(local_tz)
if (now.hour > ch) or (now.hour == ch and now.minute >= cm):
daily_job()
def settle_past_days():
"""启动检查:补录过去3天未定版的数据(防止服务器宕机漏单)"""
with app.app_context():
local_tz = get_localzone()
today = datetime.now(local_tz).date()
for i in range(1, 4):
d = today - timedelta(days=i)
existing = DailyRevenue.query.filter_by(date=d).first()
if not existing or not existing.is_final:
print(f"补录数据: {d}")
daily_job(d)
# ---- 日志解析与聚合 ----
def load_config():
@@ -166,7 +317,9 @@ def get_periods(today: date):
def api_metrics():
cfg = load_config()
shop_name = cfg.get("shop_name", "益选便利店")
today_local = datetime.now().date()
local_tz = get_localzone()
now_local = datetime.now(local_tz)
today_local = now_local.date()
periods = get_periods(today_local)
yday = periods["yesterday"]
day_before = periods["day_before"]
@@ -187,11 +340,33 @@ def api_metrics():
cur += timedelta(days=1)
return round(total, 2)
weekdays = ['周一','周二','周三','周四','周五','周六','周日']
def get_cutoff_hm():
ct = cfg.get("cutoff_time")
h = cfg.get("cutoff_hour", 23)
m = 0
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
try:
p = ct.split(':')
h = int(p[0]); m = int(p[1])
except Exception:
pass
try:
h = int(h)
except Exception:
h = 23
if h < 0 or h > 23:
h = 23
if m < 0 or m > 59:
m = 0
return h, m
ch, cm = get_cutoff_hm()
show_today = (now_local.hour > ch) or (now_local.hour == ch and now_local.minute >= cm)
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)},
"server_now": now_local.isoformat(timespec='seconds'),
"cutoff_hour": ch,
"cutoff_time": f"{ch:02d}:{cm:02d}",
"today": {"date": today_local.isoformat(), "weekday": weekdays[today_local.weekday()], "amount": (amt_for(today_local) if show_today else None)},
"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)},
@@ -202,16 +377,36 @@ def api_metrics():
@app.route("/api/series7")
def api_series7():
today_local = datetime.now().date()
local_tz = get_localzone()
now_local = datetime.now(local_tz)
today_local = now_local.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):
cfg = load_config()
ct = cfg.get("cutoff_time")
ch = cfg.get("cutoff_hour", 23)
cm = 0
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
try:
p = ct.split(':')
ch = int(p[0]); cm = int(p[1])
except Exception:
pass
try:
ch = int(ch)
except Exception:
ch = 23
if ch < 0 or ch > 23:
ch = 23
if cm < 0 or cm > 59:
cm = 0
show_today = (now_local.hour > ch) or (now_local.hour == ch and now_local.minute >= cm)
if not (show_today and 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:
@@ -226,6 +421,38 @@ def api_series7():
cur += timedelta(days=1)
return jsonify(series)
@app.route('/api/admin/reload_cutoff', methods=['POST'])
def admin_reload_cutoff():
token = os.getenv('ADMIN_TOKEN')
if token and request.headers.get('X-Admin-Token') != token:
return jsonify({"error": "unauthorized"}), 401
cfg = load_config()
ct = cfg.get("cutoff_time")
ch = cfg.get("cutoff_hour", 23)
cm = 0
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
try:
p = ct.split(':')
ch = int(p[0]); cm = int(p[1])
except Exception:
pass
try:
ch = int(ch)
except Exception:
ch = 23
if ch < 0 or ch > 23:
ch = 23
if cm < 0 or cm > 59:
cm = 0
global scheduler
if scheduler:
try:
scheduler.add_job(daily_job, "cron", hour=ch, minute=cm, id="daily", replace_existing=True)
except Exception:
pass
settle_today_if_due()
return jsonify({"ok": True, "cutoff_time": f"{ch:02d}:{cm:02d}"})
@app.route("/api/revenue")
def api_revenue():
"""查询历史营业额"""
@@ -234,6 +461,35 @@ def api_revenue():
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/audit")
def api_audit():
limit = int(request.args.get("limit", 20))
rows = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(limit).all()
return jsonify([
{
"date": r.date.isoformat(),
"old_amount": r.old_amount,
"new_amount": r.new_amount,
"reason": r.reason,
"actor": r.actor,
"type": r.type,
"created_at": r.created_at.isoformat(timespec='seconds')
} for r in rows
])
@app.route("/api/health")
def api_health():
cfg = load_config()
cutoff = int(cfg.get("cutoff_hour", 23))
today_local = datetime.now().date()
r_today = DailyRevenue.query.filter_by(date=today_local).first()
return jsonify({
"server_now": datetime.now().isoformat(timespec='seconds'),
"cutoff_hour": cutoff,
"today_finalized": bool(r_today and r_today.is_final),
"today_amount": (r_today.amount if r_today else None)
})
@app.route("/api/export")
def api_export():
"""CSV导出"""
@@ -272,8 +528,21 @@ def admin_correct():
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', '益选便利店'))
push_feishu(d.isoformat(), new_amt, reason)
return jsonify({"ok": True})
@app.route('/api/admin/test_push', methods=['POST'])
def admin_test_push():
token = os.getenv('ADMIN_TOKEN')
if token and request.headers.get('X-Admin-Token') != token:
return jsonify({"error": "unauthorized"}), 401
payload = request.get_json(silent=True) or {}
ds = payload.get('date') or datetime.now().date().isoformat()
amt = float(payload.get('amount') or 1234.56)
reason = payload.get('reason') or 'manual_test'
push_feishu(ds, amt, reason)
return jsonify({"ok": True, "pushed": {"date": ds, "amount": amt, "reason": reason}})
def import_csv_text(text: str, actor: str = 'admin'):
buf = io.StringIO(text)
reader = csv.reader(buf)
@@ -301,6 +570,7 @@ def import_csv_text(text: str, actor: str = 'admin'):
r = DailyRevenue(date=d, amount=amt, is_final=True, source='import_csv')
db.session.add(r)
db.session.add(AuditLog(date=d, old_amount=old, new_amount=amt, reason='import_csv', actor=actor, type='import_log'))
push_feishu(d.isoformat(), amt, 'import_csv')
imported += 1
db.session.commit()
return imported
@@ -316,27 +586,39 @@ def admin_import():
imported = import_csv_text(raw, actor='admin')
return jsonify({"ok": True, "imported": imported})
@app.route('/api/admin/logs', methods=['GET'])
def admin_logs():
token = os.getenv('ADMIN_TOKEN')
if token and request.headers.get('X-Admin-Token') != token:
return jsonify({"error": "unauthorized"}), 401
n = request.args.get('lines', default=200, type=int)
p = os.path.join(os.path.dirname(__file__), "..", "app.log")
if not os.path.exists(p):
return jsonify({"lines": []})
try:
with open(p, 'r', encoding='utf-8') as f:
lines = f.readlines()
tail = lines[-n:] if n > 0 else lines
return jsonify({"lines": [line.rstrip('\n') for line in tail]})
except Exception:
return jsonify({"lines": []})
def auto_import_csv_on_start():
with app.app_context():
flag = os.getenv('AUTO_IMPORT_ON_START', '1')
if str(flag) == '0':
return
paths = [
os.path.join("/app", "data", "import.csv"),
os.path.join(os.path.dirname(__file__), "..", "init", "revenue.csv"),
os.path.join(os.path.dirname(__file__), "..", "init", "revenue.sample.csv"),
]
for p in paths:
if os.path.exists(p):
with open(p, "r", encoding="utf-8") as f:
text = f.read()
import_csv_text(text, actor='bootstrap')
break
p = os.path.join("/app", "data", "import.csv")
if os.path.exists(p):
with open(p, "r", encoding="utf-8") as f:
text = f.read()
import_csv_text(text, actor='bootstrap')
def sync_log_to_db():
"""启动时将 app.log 中缺失的数据同步到 DB(只同步今天之前)"""
log_map = parse_app_log()
today_local = datetime.now().date()
local_tz = get_localzone()
today_local = datetime.now(local_tz).date()
for ds, amt in log_map.items():
d = datetime.strptime(ds, '%Y-%m-%d').date()
if d >= today_local:
@@ -350,21 +632,43 @@ def sync_log_to_db():
if __name__ == "__main__":
local_tz = get_localzone()
scheduler = BackgroundScheduler(timezone=local_tz)
scheduler.add_job(daily_job, "cron", hour=23, minute=0)
cfg = load_config()
ct = cfg.get("cutoff_time")
ch = cfg.get("cutoff_hour", 23)
cm = 0
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
try:
p = ct.split(':')
ch = int(p[0]); cm = int(p[1])
except Exception:
pass
try:
ch = int(ch)
except Exception:
ch = 23
if ch < 0 or ch > 23:
ch = 23
if cm < 0 or cm > 59:
cm = 0
scheduler.add_job(daily_job, "cron", hour=ch, minute=cm, id="daily", replace_existing=True)
scheduler.start()
with app.app_context():
sync_log_to_db()
auto_import_csv_on_start()
settle_past_days()
settle_today_if_due()
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()
local_tz = get_localzone()
now = datetime.now(local_tz)
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:
if now.minute in (0, 1):
settle_today_if_due()
yield "data: {\"type\": \"force_refresh\"}\n\n"
time.sleep(30)
return Response(stream_with_context(event_stream()), mimetype='text/event-stream')
+1
View File
@@ -5,3 +5,4 @@ APScheduler==3.10.4
python-dotenv==1.0.0
tzlocal==5.2
tzdata==2025.1
requests==2.32.3
+6 -2
View File
@@ -1,5 +1,7 @@
{
"webhook_url": "https://api.hiflow.tencent.com/engine/webhook/31/1869391857524076545",
"feishu_webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/2ee06084-a0ed-4c3f-9b0b-e264194f7ddd",
"feishu_secret": "",
"shop_name": "益选便利店",
"weekday_range": [
2900,
@@ -8,5 +10,7 @@
"weekend_range": [
1600,
2000
]
}
],
"cutoff_hour": 23,
"cutoff_time": "23:00"
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1
View File
@@ -12,4 +12,5 @@ services:
- AUTO_IMPORT_ON_START=1
volumes:
- ./data:/app/data:Z
- ./config.json:/app/config.json:ro
restart: unless-stopped
+121
View File
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>项目 API 文档(益选营业额系统)</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, "Noto Sans", "Noto Sans CJK", "PingFang SC", "Microsoft YaHei", sans-serif; margin: 40px; line-height: 1.6; color: #222; }
h1 { font-size: 24px; margin: 0 0 16px; }
h2 { font-size: 18px; margin: 24px 0 8px; }
code, pre { background: #f6f8fa; border-radius: 6px; }
code { padding: 2px 4px; }
pre { padding: 12px; overflow: auto; }
.endpoint { border-left: 4px solid #3b82f6; padding-left: 12px; margin: 16px 0; }
.small { color: #555; font-size: 13px; }
ul { padding-left: 18px; }
</style>
</head>
<body>
<h1>项目 API 文档(益选营业额系统)</h1>
<p class="small">基础地址:<code>http://&lt;服务器或域名&gt;:&lt;PORT&gt;</code>(默认 <code>57778</code> | 本地时区:<code>Asia/Shanghai</code></p>
<h2>认证</h2>
<ul>
<li>管理接口需在请求头携带 <code>X-Admin-Token: &lt;ADMIN_TOKEN&gt;</code></li>
</ul>
<h2>公共字段</h2>
<ul>
<li><code>server_now</code>:服务器当前时间(含时区)</li>
<li><code>cutoff_time</code>/<code>cutoff_hour</code>:截止时间;未到门槛时 <code>today.amount=null</code></li>
<li><code>is_final</code>:某日期是否已定版</li>
</ul>
<div class="endpoint">
<h2>GET /api/metrics</h2>
<p>指标看板:今日/昨日/前日、本周、上周、本月;达到截止时间且今日已定版时显示金额。</p>
<pre>{
"shop_name": "益选便利店",
"server_now": "2025-12-09T13:14:40+08:00",
"cutoff_hour": 13,
"cutoff_time": "13:18",
"today": { "date": "2025-12-09", "weekday": "周二", "amount": null },
"yesterday": { "date": "2025-12-08", "amount": 3629.76 },
"day_before": { "date": "2025-12-07", "amount": 2408.70 },
"this_week": { "start": "2025-12-08", "end": "2025-12-08", "total": 3629.76 },
"last_week": { "start": "2025-12-01", "end": "2025-12-07", "total": 4211.79 },
"this_month": { "start": "2025-12-01", "end": "2025-12-08", "total": 7841.55 }
}</pre>
</div>
<div class="endpoint">
<h2>GET /api/series7</h2>
<p>最近 N 天序列(默认 7,范围 7~90)。未到截止或今日未定版时,截止到昨日;未定版日期按区间估算并标记 <code>estimated=true</code></p>
<pre>[ { "date": "2025-12-06", "amount": 1803.09, "estimated": false }, ... ]</pre>
</div>
<div class="endpoint">
<h2>GET /api/revenue</h2>
<p>历史营业额查询(仅定版数据)。参数:<code>days</code> 默认 30。</p>
</div>
<div class="endpoint">
<h2>GET /api/audit</h2>
<p>审计日志。参数:<code>days</code> 默认 30。</p>
</div>
<div class="endpoint">
<h2>GET /api/health</h2>
<p>健康检查。</p>
</div>
<div class="endpoint">
<h2>GET /api/export</h2>
<p>导出 CSV(仅定版)。</p>
</div>
<div class="endpoint">
<h2>GET /</h2>
<p>看板入口(静态)。</p>
<h2>GET /admin</h2>
<p>管理入口(静态)。</p>
</div>
<div class="endpoint">
<h2>PUT /api/admin/turnover</h2>
<p>修正某日营业额(置为定版并推送)。头:<code>X-Admin-Token</code></p>
<pre>{ "date":"YYYY-MM-DD", "amount":1234.56, "reason":"调整入账", "actor":"admin" }</pre>
</div>
<div class="endpoint">
<h2>POST /api/admin/test_push</h2>
<p>试发飞书(卡片→帖子→文本)。头:<code>X-Admin-Token</code></p>
<pre>{ "date":"YYYY-MM-DD", "amount":1234.56, "reason":"manual_test" }</pre>
</div>
<div class="endpoint">
<h2>POST /api/admin/import</h2>
<p>批量导入 CSV(两列:<code>date,amount</code>)。头:<code>X-Admin-Token</code><code>Content-Type: text/csv</code></p>
</div>
<div class="endpoint">
<h2>GET /api/admin/logs</h2>
<p>读取 <code>app.log</code> 最近 N 行。参数:<code>lines</code>;头:<code>X-Admin-Token</code></p>
</div>
<div class="endpoint">
<h2>POST /api/admin/reload_cutoff</h2>
<p>在线重载截止时间(更新 APScheduler 的每日触发任务并立即执行一次结算检查)。头:<code>X-Admin-Token</code></p>
</div>
<div class="endpoint">
<h2>GET /api/events</h2>
<p>SSE 心跳事件(每 30 秒;在每小时第 0/1 分触发强制刷新与结算检查)。</p>
</div>
<h2>打印为 PDF</h2>
<ul>
<li>在浏览器中打开本文件,使用“打印”选择“另存为 PDF”。</li>
<li>或将此文件纳入归档(与 <code>API.md</code> 同内容)。</li>
</ul>
</body>
</html>
+151
View File
@@ -0,0 +1,151 @@
# 项目 API 文档(益选营业额系统)
## 概述
- 基础地址:`http://<服务器或域名>:<PORT>`(默认 `57778`,本地开发默认 `5000`
- 返回格式:`application/json`
- 本地时区:`Asia/Shanghai`,时间字段按本地时区输出
- 截止时间:支持分钟级配置(`config.json``cutoff_time`,例如 `13:18`;未设置时使用 `cutoff_hour`,分钟视为 `00`
## 认证
- 管理接口需在请求头携带 `X-Admin-Token: <ADMIN_TOKEN>`(容器环境变量或 `.env` 配置)
## 公共字段说明
- `server_now`:服务器当前时间 ISO 字符串(含时区)
- `cutoff_time` / `cutoff_hour`:当天营业额结算门槛;未到门槛时 `today.amount=null`
- `is_final`:某日期是否已定版(仅定版数据会计入汇总)
## 接口列表
### 1. 指标看板
- 路径:`GET /api/metrics`
- 说明:返回今日/昨日/前日、本周、上周、本月汇总数据;`today.amount` 仅在达到截止时间且当日已定版时显示数值
- 示例响应:
```json
{
"shop_name": "益选便利店",
"server_now": "2025-12-09T13:14:40+08:00",
"cutoff_hour": 13,
"cutoff_time": "13:18",
"today": { "date": "2025-12-09", "weekday": "周二", "amount": null },
"yesterday": { "date": "2025-12-08", "amount": 3629.76 },
"day_before": { "date": "2025-12-07", "amount": 2408.70 },
"this_week": { "start": "2025-12-08", "end": "2025-12-08", "total": 3629.76 },
"last_week": { "start": "2025-12-01", "end": "2025-12-07", "total": 4211.79 },
"this_month": { "start": "2025-12-01", "end": "2025-12-08", "total": 7841.55 }
}
```
- 代码位置:`backend/app.py:225`
### 2. 最近 7 天序列
- 路径:`GET /api/series7`
- 参数:`days`(可选,默认 7,范围 790
- 说明:返回最近 N 天每日营业额;在未到截止或今日未定版时,序列截止到昨日;未定版日期按区间估算并标记 `estimated=true`
- 示例响应:
```json
[
{ "date": "2025-12-06", "amount": 1803.09, "estimated": false },
{ "date": "2025-12-07", "amount": 2408.70, "estimated": false },
{ "date": "2025-12-08", "amount": 3629.76, "estimated": false }
]
```
- 代码位置:`backend/app.py:287`
### 3. 历史营业额查询
- 路径:`GET /api/revenue`
- 参数:`days`(可选,默认 30
- 说明:查询最近 N 天的历史营业额(仅定版数据)
- 代码位置:`backend/app.py:365`
### 4. 审计日志
- 路径:`GET /api/audit`
- 参数:`days`(可选,默认 30
- 说明:返回最近 N 天的审计记录(生成、修正、导入等)
- 代码位置:`backend/app.py:373`
### 5. 健康检查
- 路径:`GET /api/health`
- 说明:服务健康状态(数据库、调度器、时区)
- 代码位置:`backend/app.py:389`
### 6. 导出 CSV
- 路径:`GET /api/export`
- 说明:导出已定版营业额为 CSV 文本(`date,amount`
- 代码位置:`backend/app.py:402`
### 7. 单页入口
- 路径:`GET /``GET /admin`
- 说明:前端看板与管理页入口(静态文件)
- 代码位置:`backend/app.py:409``backend/app.py:414`
### 8. 管理:修正某日营业额
- 路径:`PUT /api/admin/turnover`
- 认证:`X-Admin-Token`
- 请求体:`{"date":"YYYY-MM-DD","amount":1234.56,"reason":"调整入账","actor":"admin"}`
- 说明:修正指定日期金额并置为定版;写入审计日志并推送飞书
- 示例响应:`{"ok":true}`
- 代码位置:`backend/app.py:418`
### 9. 管理:试发飞书
- 路径:`POST /api/admin/test_push`
- 认证:`X-Admin-Token`
- 请求体(可选):`{"date":"YYYY-MM-DD","amount":1234.56,"reason":"manual_test"}`
- 说明:向已配置的飞书机器人发送一条测试消息(卡片→帖子→文本兼容)
- 示例响应:`{"ok":true,"pushed":{"date":"...","amount":1234.56,"reason":"..."}}`
- 代码位置:`backend/app.py:443`
### 10. 管理:批量导入 CSV
- 路径:`POST /api/admin/import`
- 认证:`X-Admin-Token`
- 请求头:`Content-Type: text/csv`
- 请求体:CSV 内容(两列:`date,amount`
- 说明:批量导入历史数据,均置为定版并记录审计;逐条尝试推送飞书
- 示例响应:`{"ok":true,"imported":N}`
- 代码位置:`backend/app.py:487`
### 11. 管理:读取服务器日志
- 路径:`GET /api/admin/logs?lines=200`
- 认证:`X-Admin-Token`
- 说明:读取 `app.log` 最近 N 行(含飞书推送状态与返回体片段)
- 示例响应:`{"lines":["飞书推送卡片成功: status=200 body=...", ...]}`
- 代码位置:`backend/app.py:498`
### 12. 管理:在线重载截止时间
- 路径:`POST /api/admin/reload_cutoff`
- 认证:`X-Admin-Token`
- 说明:重新读取 `config.json``cutoff_time`/`cutoff_hour`,用新值更新 APScheduler 的每日触发任务,并立即执行一次结算检查
- 示例响应:`{"ok":true,"cutoff_time":"13:18"}`
- 代码位置:`backend/app.py:333`
### 13. 服务器事件(SSE
- 路径:`GET /api/events`
- 说明:服务端推送事件(每 30 秒心跳一次;在每小时第 0/1 分会强制刷新并调用结算检查)
- 事件示例:
- `data: {"type":"tick","server_now":"2025-12-09T13:14:40+08:00"}`
- `data: {"type":"force_refresh"}`(在 0/1 分触发)
- 代码位置:`backend/app.py:571`
## 触发与结算逻辑(参考)
- 结算判断:本地时区 `now >= cutoff_time` 时尝试 `daily_job()``backend/app.py:128-149`
- APScheduler:按 `hour:minute` 注册每日触发任务(`backend/app.py:490-511`
- 每日作业:
- 若当天无记录:生成金额 → 写库 → 审计 → 推送飞书
- 若当天已存在但未定版:置为定版 → 审计 → 推送飞书
- 若当天已存在且已定版:推送飞书(保底通知)
- 代码位置:`backend/app.py:101-126`
## 配置文件(关键字段)
- 路径:`/app/config.json`(容器内;通过 `docker-compose.yml` 映射宿主机 `./config.json`
- 字段:
- `feishu_webhook_url`:飞书机器人 Webhook(必填)
- `feishu_secret`:签名密钥(启用签名时填写,否则留空)
- `shop_name`:店名
- `weekday_range` / `weekend_range`:金额生成区间
- `cutoff_hour` / `cutoff_time`:截止时间(小时或分钟级)
## 环境变量
- `ADMIN_TOKEN`:管理口令(用于管理接口)
- `DATABASE_URL`:数据库连接,默认 `sqlite:////app/data/data.db`(容器),本地默认 `sqlite:///data/data.db`
- `TZ`:容器时区(默认 `Asia/Shanghai`
- `PORT`:服务端口(示例 `57778`
- `AUTO_IMPORT_ON_START`:启动时自动导入 CSV`/app/data/import.csv`
@@ -0,0 +1,83 @@
{
"info": {
"name": "PushToZhaoShang API",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Metrics",
"request": { "method": "GET", "url": "{{baseUrl}}/api/metrics" }
},
{
"name": "Series7",
"request": { "method": "GET", "url": "{{baseUrl}}/api/series7?days=7" }
},
{
"name": "Revenue",
"request": { "method": "GET", "url": "{{baseUrl}}/api/revenue?days=30" }
},
{
"name": "Audit",
"request": { "method": "GET", "url": "{{baseUrl}}/api/audit?days=30" }
},
{
"name": "Health",
"request": { "method": "GET", "url": "{{baseUrl}}/api/health" }
},
{
"name": "Export CSV",
"request": { "method": "GET", "url": "{{baseUrl}}/api/export" }
},
{
"name": "Root",
"request": { "method": "GET", "url": "{{baseUrl}}/" }
},
{
"name": "Admin Page",
"request": { "method": "GET", "url": "{{baseUrl}}/admin" }
},
{
"name": "Admin Turnover",
"request": {
"method": "PUT",
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" }, { "key": "Content-Type", "value": "application/json" } ],
"body": { "mode": "raw", "raw": "{\n \"date\": \"2025-12-06\",\n \"amount\": 3123.45,\n \"reason\": \"调整入账\",\n \"actor\": \"admin\"\n}" },
"url": "{{baseUrl}}/api/admin/turnover"
}
},
{
"name": "Admin Test Push",
"request": {
"method": "POST",
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" }, { "key": "Content-Type", "value": "application/json" } ],
"body": { "mode": "raw", "raw": "{\n \"date\": \"2025-12-09\",\n \"amount\": 1234.56,\n \"reason\": \"manual_test\"\n}" },
"url": "{{baseUrl}}/api/admin/test_push"
}
},
{
"name": "Admin Import CSV",
"request": {
"method": "POST",
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" }, { "key": "Content-Type", "value": "text/csv" } ],
"body": { "mode": "raw", "raw": "date,amount\n2025-12-01,12345.67\n2025-12-02,11890.12\n" },
"url": "{{baseUrl}}/api/admin/import"
}
},
{
"name": "Admin Logs",
"request": {
"method": "GET",
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" } ],
"url": "{{baseUrl}}/api/admin/logs?lines=200"
}
},
{
"name": "Admin Reload Cutoff",
"request": {
"method": "POST",
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" } ],
"url": "{{baseUrl}}/api/admin/reload_cutoff"
}
}
]
}
@@ -0,0 +1,8 @@
{
"id": "f6c3b3a1-3c2d-4b62-bc35-0c6ef873a001",
"name": "PushToZhaoShang-Env",
"values": [
{ "key": "baseUrl", "value": "http://localhost:57778", "enabled": true },
{ "key": "adminToken", "value": "", "enabled": true }
]
}
BIN
View File
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
param(
[string]$RepoUrl = "",
[string]$Branch = "main",
[string]$RepoDir = ".",
[string]$ComposeFile = "docker-compose.yml",
[string]$BaseUrl = "http://localhost:57778",
[string]$AdminToken = ""
)
if (!(Test-Path $RepoDir)) { New-Item -ItemType Directory -Path $RepoDir | Out-Null }
if (!(Test-Path (Join-Path $RepoDir ".git"))) {
if ($RepoUrl -eq "") { throw "RepoUrl is required for clone" }
git clone -b $Branch $RepoUrl $RepoDir
}
Set-Location $RepoDir
git remote set-url origin $RepoUrl
git fetch origin
git checkout $Branch
git pull origin $Branch
if (!(Test-Path $ComposeFile)) { throw "compose not found: $ComposeFile" }
docker compose -f $ComposeFile up -d --build
$headers = @{}
if ($AdminToken -ne "") { $headers["X-Admin-Token"] = $AdminToken }
try { Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/admin/reload_cutoff" -Headers $headers | Out-Null } catch { }
try { Invoke-RestMethod -Method Get -Uri "$BaseUrl/api/metrics" | ConvertTo-Json -Depth 3 | Write-Output } catch { }
try { Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/admin/test_push" -Headers $headers | ConvertTo-Json -Depth 3 | Write-Output } catch { }
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_URL=${1:-}
BRANCH=${2:-main}
REPO_DIR=${3:-.}
COMPOSE_FILE=${4:-docker-compose.yml}
BASE_URL=${5:-http://localhost:57778}
ADMIN_TOKEN=${6:-}
mkdir -p "$REPO_DIR"
if [ ! -d "$REPO_DIR/.git" ]; then
[ -z "$REPO_URL" ] && { echo "RepoUrl required"; exit 1; }
git clone -b "$BRANCH" "$REPO_URL" "$REPO_DIR"
fi
cd "$REPO_DIR"
git remote set-url origin "$REPO_URL"
git fetch origin
git checkout "$BRANCH"
git pull origin "$BRANCH"
[ -f "$COMPOSE_FILE" ] || { echo "compose not found: $COMPOSE_FILE"; exit 1; }
docker compose -f "$COMPOSE_FILE" up -d --build
HDR=( )
if [ -n "$ADMIN_TOKEN" ]; then HDR=( -H "X-Admin-Token: $ADMIN_TOKEN" ); fi
curl -s -X POST "${BASE_URL}/api/admin/reload_cutoff" "${HDR[@]}" || true
curl -s "${BASE_URL}/api/metrics" || true
curl -s -X POST "${BASE_URL}/api/admin/test_push" "${HDR[@]}" || true
+62
View File
@@ -0,0 +1,62 @@
import os
import sqlite3
from datetime import datetime, timedelta
def _resolve_sqlite_path(url: str) -> str:
if not url.startswith('sqlite:///'):
raise ValueError('Only sqlite URLs are supported')
return url.replace('sqlite:///', '')
def ensure_tables(conn: sqlite3.Connection) -> None:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS daily_revenue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL UNIQUE,
amount REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_final INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
source TEXT,
note TEXT
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
old_amount REAL,
new_amount REAL,
reason TEXT,
actor TEXT,
type TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.commit()
def set_yesterday(amount: float) -> None:
url = os.getenv('DATABASE_URL', 'sqlite:///data.db')
path = _resolve_sqlite_path(url)
os.makedirs(os.path.dirname(path) or '.', exist_ok=True)
conn = sqlite3.connect(path)
ensure_tables(conn)
cur = conn.cursor()
yday = (datetime.now().date() - timedelta(days=1)).isoformat()
cur.execute('SELECT amount FROM daily_revenue WHERE date=?', (yday,))
row = cur.fetchone()
old = row[0] if row else None
if row:
cur.execute('UPDATE daily_revenue SET amount=?, is_final=1, source=? , updated_at=CURRENT_TIMESTAMP WHERE date=?', (amount, 'correct', yday))
else:
cur.execute('INSERT INTO daily_revenue(date, amount, is_final, source) VALUES(?, ?, 1, ?)', (yday, amount, 'correct'))
cur.execute('INSERT INTO audit_log(date, old_amount, new_amount, reason, actor, type) VALUES(?, ?, ?, ?, ?, ?)', (yday, old, amount, 'manual_correct', 'admin', 'correct'))
conn.commit()
conn.close()
if __name__ == '__main__':
set_yesterday(3629.76)
-6
View File
@@ -1,6 +0,0 @@
date,amount
2025-12-01,12345.67
2025-12-02,11890.12
2025-12-03,13200.00
2025-12-04,11110.50
2025-12-05,14005.88
1 date amount
2 2025-12-01 12345.67
3 2025-12-02 11890.12
4 2025-12-03 13200.00
5 2025-12-04 11110.50
6 2025-12-05 14005.88
-6
View File
@@ -1,6 +0,0 @@
date,amount
2025-12-01,12345.67
2025-12-02,11890.12
2025-12-03,13200.00
2025-12-04,11110.50
2025-12-05,14005.88
1 date amount
2 2025-12-01 12345.67
3 2025-12-02 11890.12
4 2025-12-03 13200.00
5 2025-12-04 11110.50
6 2025-12-05 14005.88