mirror of
https://github.com/henry4682/linebot_finance.git
synced 2026-05-16 04:41:52 +00:00
add app files
This commit is contained in:
1
app/.python-version
Normal file
1
app/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
17
app/Dockerfile
Normal file
17
app/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 安裝 uv
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 複製 uv 設定檔
|
||||||
|
COPY pyproject.toml .
|
||||||
|
COPY uv.lock* .
|
||||||
|
|
||||||
|
# 安裝依賴
|
||||||
|
RUN uv sync --frozen
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
0
app/README.md
Normal file
0
app/README.md
Normal file
100
app/create_rich_menu.py
Normal file
100
app/create_rich_menu.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv("../.env")
|
||||||
|
|
||||||
|
ACCESS_TOKEN = os.getenv("LINE_CHANNEL_ACCESS_TOKEN")
|
||||||
|
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
|
||||||
|
|
||||||
|
def create_image():
|
||||||
|
W, H = 2500, 843
|
||||||
|
img = Image.new("RGB", (W, H), color="#FFFFFF")
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 分隔線
|
||||||
|
draw.line([(833, 0), (833, H)], fill="#DDDDDD", width=4)
|
||||||
|
draw.line([(1666, 0), (1666, H)], fill="#DDDDDD", width=4)
|
||||||
|
|
||||||
|
# 背景色
|
||||||
|
draw.rectangle([0, 0, 833, H], fill="#F0F9FF")
|
||||||
|
draw.rectangle([834, 0, 1666, H], fill="#F0FFF4")
|
||||||
|
draw.rectangle([1667, 0, W, H], fill="#FFF9F0")
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
("記帳", "類別 金額", 416),
|
||||||
|
("查今天", "今日明細", 1249),
|
||||||
|
("查本月", "本月統計", 2083),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Windows 支援中文的字型
|
||||||
|
font_large = ImageFont.truetype("C:/Windows/Fonts/msjh.ttc", 120)
|
||||||
|
font_mid = ImageFont.truetype("C:/Windows/Fonts/msjh.ttc", 80)
|
||||||
|
font_small = ImageFont.truetype("C:/Windows/Fonts/msjh.ttc", 55)
|
||||||
|
except:
|
||||||
|
font_large = ImageFont.load_default()
|
||||||
|
font_mid = font_large
|
||||||
|
font_small = font_large
|
||||||
|
|
||||||
|
for title, subtitle, x in sections:
|
||||||
|
draw.text((x, 280), title, font=font_mid, anchor="mm", fill="#222222")
|
||||||
|
draw.text((x, 450), subtitle, font=font_small, anchor="mm", fill="#888888")
|
||||||
|
|
||||||
|
img.save("rich_menu.png")
|
||||||
|
print("✅ 圖片建立完成")
|
||||||
|
|
||||||
|
def create_rich_menu():
|
||||||
|
data = {
|
||||||
|
"size": {"width": 2500, "height": 843},
|
||||||
|
"selected": True,
|
||||||
|
"name": "Finance Menu",
|
||||||
|
"chatBarText": "選單",
|
||||||
|
"areas": [
|
||||||
|
{
|
||||||
|
"bounds": {"x": 0, "y": 0, "width": 833, "height": 843},
|
||||||
|
"action": {"type": "message", "text": "記帳說明"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bounds": {"x": 833, "y": 0, "width": 833, "height": 843},
|
||||||
|
"action": {"type": "message", "text": "查今天"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bounds": {"x": 1666, "y": 0, "width": 834, "height": 843},
|
||||||
|
"action": {"type": "message", "text": "查本月"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
res = requests.post(
|
||||||
|
"https://api.line.me/v2/bot/richmenu",
|
||||||
|
headers={**HEADERS, "Content-Type": "application/json"},
|
||||||
|
json=data
|
||||||
|
)
|
||||||
|
rich_menu_id = res.json()["richMenuId"]
|
||||||
|
print(f"✅ Rich Menu 建立:{rich_menu_id}")
|
||||||
|
return rich_menu_id
|
||||||
|
|
||||||
|
def upload_image(rich_menu_id):
|
||||||
|
with open("rich_menu.png", "rb") as f:
|
||||||
|
res = requests.post(
|
||||||
|
f"https://api-data.line.me/v2/bot/richmenu/{rich_menu_id}/content",
|
||||||
|
headers={**HEADERS, "Content-Type": "image/png"},
|
||||||
|
data=f
|
||||||
|
)
|
||||||
|
print(f"✅ 圖片上傳:{res.status_code}")
|
||||||
|
|
||||||
|
def set_default(rich_menu_id):
|
||||||
|
res = requests.post(
|
||||||
|
f"https://api.line.me/v2/bot/user/all/richmenu/{rich_menu_id}",
|
||||||
|
headers=HEADERS
|
||||||
|
)
|
||||||
|
print(f"✅ 設為預設選單:{res.status_code} {res.text}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_image()
|
||||||
|
rich_menu_id = create_rich_menu()
|
||||||
|
upload_image(rich_menu_id)
|
||||||
|
set_default(rich_menu_id)
|
||||||
|
print("\n🎉 Rich Menu 設定完成!重新開啟 LINE Bot 查看效果")
|
||||||
216
app/invoice_fetcher.py
Normal file
216
app/invoice_fetcher.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import anthropic
|
||||||
|
import urllib3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, text
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
load_dotenv("../.env")
|
||||||
|
|
||||||
|
EINVOICE_USER = os.getenv("EINVOICE_USER")
|
||||||
|
EINVOICE_PASS = os.getenv("EINVOICE_PASS")
|
||||||
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
|
||||||
|
|
||||||
|
# 本地直接連 localhost
|
||||||
|
DATABASE_URL = os.getenv("LOCAL_DATABASE_URL")
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
class Transaction(Base):
|
||||||
|
__tablename__ = "transactions"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(String)
|
||||||
|
category = Column(String)
|
||||||
|
amount = Column(Float)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
def solve_captcha(img_b64: str) -> str:
|
||||||
|
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||||
|
msg = client.messages.create(
|
||||||
|
model="claude-haiku-4-5-20251001",
|
||||||
|
max_tokens=10,
|
||||||
|
messages=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": img_b64
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "這是驗證碼圖片,只有5個數字,只回傳這5個數字,不要其他任何文字"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
return msg.content[0].text.strip()
|
||||||
|
|
||||||
|
async def login_and_get_token() -> str | None:
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(headless=True)
|
||||||
|
page = await browser.new_page()
|
||||||
|
|
||||||
|
# 載入登入頁拿 login_challenge
|
||||||
|
browser = await p.chromium.launch(headless=False)
|
||||||
|
page = await browser.new_page()
|
||||||
|
await page.goto("https://www.einvoice.nat.gov.tw/accounts/login/mw")
|
||||||
|
await page.wait_for_timeout(8000)
|
||||||
|
url = page.url
|
||||||
|
print(f"目前 URL: {url}")
|
||||||
|
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
fragment = url.split("?")[-1] if "?" in url else ""
|
||||||
|
params = parse_qs(fragment)
|
||||||
|
login_challenge = params.get("login_challenge", [None])[0]
|
||||||
|
print(f"login_challenge: {login_challenge}")
|
||||||
|
|
||||||
|
# 拿驗證碼
|
||||||
|
res = requests.get(
|
||||||
|
"https://service-mc.einvoice.nat.gov.tw/act/login/api/act002i/captcha",
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
captcha_data = res.json()
|
||||||
|
captcha_token = captcha_data["token"]
|
||||||
|
captcha_text = solve_captcha(captcha_data["image"])
|
||||||
|
print(f"驗證碼: {captcha_text}")
|
||||||
|
|
||||||
|
# 登入
|
||||||
|
res = requests.post(
|
||||||
|
"https://service-mc.einvoice.nat.gov.tw/act/login/api/client/doLogin",
|
||||||
|
json={
|
||||||
|
"loginType": "U",
|
||||||
|
"userType": "MW",
|
||||||
|
"loginChallenge": login_challenge,
|
||||||
|
"captchaToken": captcha_token,
|
||||||
|
"captcha": captcha_text,
|
||||||
|
"customId": EINVOICE_USER,
|
||||||
|
"password": EINVOICE_PASS,
|
||||||
|
},
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
data = res.json()
|
||||||
|
redirect_url = data.get("redirectTo")
|
||||||
|
print(f"redirectTo: {redirect_url}")
|
||||||
|
|
||||||
|
if not redirect_url:
|
||||||
|
print(f"登入失敗: {data}")
|
||||||
|
await browser.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 跟隨 redirect 讓 token 存進 localStorage
|
||||||
|
await page.goto(redirect_url)
|
||||||
|
await page.wait_for_load_state("domcontentloaded")
|
||||||
|
await page.wait_for_timeout(8000) # 等久一點
|
||||||
|
|
||||||
|
url = page.url
|
||||||
|
print(f"redirect 後 URL: {url}")
|
||||||
|
|
||||||
|
# 印出所有 localStorage
|
||||||
|
# 同時檢查 localStorage 和 sessionStorage
|
||||||
|
local_keys = await page.evaluate("Object.keys(localStorage)")
|
||||||
|
session_keys = await page.evaluate("Object.keys(sessionStorage)")
|
||||||
|
print("localStorage keys:", local_keys)
|
||||||
|
print("sessionStorage keys:", session_keys)
|
||||||
|
await page.wait_for_timeout(3000)
|
||||||
|
for key in session_keys:
|
||||||
|
val = await page.evaluate(f"sessionStorage.getItem('{key}')")
|
||||||
|
print(f" session {key}: {val[:80] if val else None}")
|
||||||
|
|
||||||
|
token = await page.evaluate("sessionStorage.getItem('token') || localStorage.getItem('token')")
|
||||||
|
print(f"token: {token[:30] if token else 'None'}")
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def fetch_invoices(token: str, days: int = 7) -> list:
|
||||||
|
end_date = datetime.now()
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
# 格式要有毫秒
|
||||||
|
def to_iso(dt):
|
||||||
|
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
||||||
|
|
||||||
|
headers = {"authorization": f"Bearer {token}"} # 不去掉 L
|
||||||
|
|
||||||
|
res = requests.post(
|
||||||
|
"https://service-mc.einvoice.nat.gov.tw/btc/cloud/api/btc502w/getSearchCarrierInvoiceListJWT",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"cardCode": "",
|
||||||
|
"carrierId2": "",
|
||||||
|
"searchStartDate": to_iso(start_date),
|
||||||
|
"searchEndDate": to_iso(end_date),
|
||||||
|
"invoiceStatus": "all",
|
||||||
|
"isSearchAll": "true"
|
||||||
|
},
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
print(f"JWT status: {res.status_code}")
|
||||||
|
print(f"JWT response: {res.text[:200]}")
|
||||||
|
jwt_token = res.text.strip().strip('"')
|
||||||
|
|
||||||
|
res = requests.post(
|
||||||
|
"https://service-mc.einvoice.nat.gov.tw/btc/cloud/api/btc502w/searchCarrierInvoice",
|
||||||
|
headers=headers,
|
||||||
|
json={"token": jwt_token},
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
await page.wait_for_timeout(3000)
|
||||||
|
print(f"Invoice status: {res.status_code}")
|
||||||
|
print(f"Invoice response: {res.text[:300]}")
|
||||||
|
print(f"拿到 {len(res.json().get('invoices', []))} 筆發票")
|
||||||
|
# return res.json().get("content", [])
|
||||||
|
|
||||||
|
def save_invoices(invoices: list):
|
||||||
|
db = SessionLocal()
|
||||||
|
saved = 0
|
||||||
|
try:
|
||||||
|
for inv in invoices:
|
||||||
|
existing = db.query(Transaction).filter(
|
||||||
|
Transaction.note == inv["invoiceNumber"]
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
db.add(Transaction(
|
||||||
|
user_id="auto_import",
|
||||||
|
category=inv["sellerName"],
|
||||||
|
amount=inv["totalAmount"],
|
||||||
|
note=inv["invoiceNumber"],
|
||||||
|
created_at=datetime.fromisoformat(
|
||||||
|
inv["invoiceDate"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
))
|
||||||
|
saved += 1
|
||||||
|
db.commit()
|
||||||
|
print(f"✅ 新增 {saved} 筆,略過 {len(invoices) - saved} 筆重複")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("開始抓取發票...")
|
||||||
|
token = await login_and_get_token()
|
||||||
|
if not token:
|
||||||
|
print("登入失敗")
|
||||||
|
return
|
||||||
|
|
||||||
|
invoices = await fetch_invoices(token)
|
||||||
|
print(f"拿到 {len(invoices)} 筆發票")
|
||||||
|
for inv in invoices:
|
||||||
|
print(f" {inv['invoiceDate'][:10]} {inv['sellerName']} ${inv['totalAmount']}")
|
||||||
|
|
||||||
|
save_invoices(invoices)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
208
app/main.py
Normal file
208
app/main.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
|
from linebot.v3 import WebhookHandler
|
||||||
|
from linebot.v3.messaging import (
|
||||||
|
Configuration,
|
||||||
|
ApiClient,
|
||||||
|
MessagingApi,
|
||||||
|
ReplyMessageRequest,
|
||||||
|
TextMessage,
|
||||||
|
)
|
||||||
|
from linebot.v3.webhooks import MessageEvent, TextMessageContent, FollowEvent
|
||||||
|
from linebot.v3.exceptions import InvalidSignatureError
|
||||||
|
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, text
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# LINE 設定
|
||||||
|
configuration = Configuration(access_token=os.getenv("LINE_CHANNEL_ACCESS_TOKEN"))
|
||||||
|
handler = WebhookHandler(os.getenv("LINE_CHANNEL_SECRET"))
|
||||||
|
|
||||||
|
# DB 設定
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# 資料表
|
||||||
|
class Transaction(Base):
|
||||||
|
__tablename__ = "transactions"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(String)
|
||||||
|
category = Column(String)
|
||||||
|
amount = Column(Float)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Webhook endpoint
|
||||||
|
@app.post("/webhook")
|
||||||
|
async def webhook(request: Request):
|
||||||
|
signature = request.headers.get("X-Line-Signature", "")
|
||||||
|
body = await request.body()
|
||||||
|
try:
|
||||||
|
handler.handle(body.decode(), signature)
|
||||||
|
except InvalidSignatureError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
@handler.add(MessageEvent, message=TextMessageContent)
|
||||||
|
def handle_message(event):
|
||||||
|
user_id = event.source.user_id
|
||||||
|
text = event.message.text.strip()
|
||||||
|
reply = parse_and_save(user_id, text)
|
||||||
|
|
||||||
|
with ApiClient(configuration) as api_client:
|
||||||
|
line_bot_api = MessagingApi(api_client)
|
||||||
|
line_bot_api.reply_message(
|
||||||
|
ReplyMessageRequest(
|
||||||
|
reply_token=event.reply_token,
|
||||||
|
messages=[TextMessage(text=reply)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@handler.add(FollowEvent)
|
||||||
|
def handle_follow(event):
|
||||||
|
with ApiClient(configuration) as api_client:
|
||||||
|
line_bot_api = MessagingApi(api_client)
|
||||||
|
line_bot_api.reply_message(
|
||||||
|
ReplyMessageRequest(
|
||||||
|
reply_token=event.reply_token,
|
||||||
|
messages=[TextMessage(text=(
|
||||||
|
"👋 歡迎使用 Myfinance 記帳 Bot!\n\n"
|
||||||
|
"📝 記帳格式:\n"
|
||||||
|
"類別 金額\n"
|
||||||
|
"類別 金額 備註\n\n"
|
||||||
|
"範例:\n"
|
||||||
|
"早餐 80\n"
|
||||||
|
"午餐 120 便當\n"
|
||||||
|
"交通 50 捷運\n\n"
|
||||||
|
"📊 查詢指令:\n"
|
||||||
|
"查今天 → 今日明細\n"
|
||||||
|
"查本月 → 本月統計\n\n"
|
||||||
|
"開始記帳吧!💪"
|
||||||
|
))]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_and_save(user_id: str, text: str) -> str:
|
||||||
|
# 查詢指令
|
||||||
|
if text == "查今天":
|
||||||
|
return query_today(user_id)
|
||||||
|
if text == "查本月":
|
||||||
|
return query_month(user_id)
|
||||||
|
if text == "記帳說明":
|
||||||
|
return (
|
||||||
|
"📝 記帳格式:\n"
|
||||||
|
"類別 金額\n"
|
||||||
|
"類別 金額 備註\n\n"
|
||||||
|
"範例:\n"
|
||||||
|
"早餐 80\n"
|
||||||
|
"午餐 120 便當\n"
|
||||||
|
"交通 50 捷運\n\n"
|
||||||
|
"輸入後 Bot 會自動記錄 ✅"
|
||||||
|
)
|
||||||
|
# 刪除指令:刪除 1 或 刪除 早餐
|
||||||
|
if text.startswith("刪除 "):
|
||||||
|
target = text[3:].strip()
|
||||||
|
return delete_transaction(user_id, target)
|
||||||
|
|
||||||
|
# 記帳格式:「早餐 80」或「早餐 80 備註」
|
||||||
|
match = re.match(r"^(\S+)\s+(\d+(?:\.\d+)?)(?:\s+(.+))?$", text)
|
||||||
|
if match:
|
||||||
|
category = match.group(1)
|
||||||
|
amount = float(match.group(2))
|
||||||
|
note = match.group(3)
|
||||||
|
save_transaction(user_id, category, amount, note)
|
||||||
|
return f"✅ 已記錄:{category} ${amount:.0f}" + (f"({note})" if note else "")
|
||||||
|
|
||||||
|
return "格式錯誤 😅\n記帳請輸入:類別 金額\n例如:早餐 80\n\n查詢請輸入:查今天 / 查本月"
|
||||||
|
|
||||||
|
def save_transaction(user_id, category, amount, note):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.add(Transaction(user_id=user_id, category=category, amount=amount, note=note))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def delete_transaction(user_id: str, target: str) -> str:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
today = datetime.now().date()
|
||||||
|
rows = db.query(Transaction).filter(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
text("DATE(created_at) = :today")
|
||||||
|
).params(today=today).all()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return "今天還沒有記錄 📭"
|
||||||
|
|
||||||
|
# 用編號刪除
|
||||||
|
if target.isdigit():
|
||||||
|
idx = int(target) - 1
|
||||||
|
if idx < 0 or idx >= len(rows):
|
||||||
|
return f"沒有第 {target} 筆記錄,請先輸入「查今天」確認編號"
|
||||||
|
row = rows[idx]
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return f"✅ 已刪除:{row.category} ${row.amount:.0f}"
|
||||||
|
|
||||||
|
# 用類別刪除(刪最後一筆)
|
||||||
|
matched = [r for r in rows if r.category == target]
|
||||||
|
if not matched:
|
||||||
|
return f"今天沒有「{target}」的記錄"
|
||||||
|
row = matched[-1]
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return f"✅ 已刪除:{row.category} ${row.amount:.0f}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def query_today(user_id):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
today = datetime.now().date()
|
||||||
|
rows = db.query(Transaction).filter(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
text("DATE(created_at) = :today")
|
||||||
|
).params(today=today).all()
|
||||||
|
if not rows:
|
||||||
|
return "今天還沒有記錄 📭"
|
||||||
|
total = sum(r.amount for r in rows)
|
||||||
|
lines = [
|
||||||
|
f"{i+1}. {r.category} ${r.amount:.0f}" + (f"({r.note})" if r.note else "")
|
||||||
|
for i, r in enumerate(rows)
|
||||||
|
]
|
||||||
|
return "📋 今日記錄:\n" + "\n".join(lines) + f"\n\n💰 合計:${total:.0f}\n\n🗑 刪除請輸入:刪除 編號\n例如:刪除 1"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def query_month(user_id):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
now = datetime.now()
|
||||||
|
rows = db.query(Transaction).filter(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
text("EXTRACT(YEAR FROM created_at) = :year AND EXTRACT(MONTH FROM created_at) = :month")
|
||||||
|
).params(year=now.year, month=now.month).all()
|
||||||
|
if not rows:
|
||||||
|
return "本月還沒有記錄 📭"
|
||||||
|
total = sum(r.amount for r in rows)
|
||||||
|
# 依類別統計
|
||||||
|
summary = {}
|
||||||
|
for r in rows:
|
||||||
|
summary[r.category] = summary.get(r.category, 0) + r.amount
|
||||||
|
lines = [f"{cat}:${amt:.0f}" for cat, amt in sorted(summary.items(), key=lambda x: -x[1])]
|
||||||
|
return f"📊 本月統計({now.month}月):\n" + "\n".join(lines) + f"\n\n💰 總計:${total:.0f}"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
20
app/pyproject.toml
Normal file
20
app/pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[project]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"anthropic>=0.84.0",
|
||||||
|
"ddddocr==1.4.11",
|
||||||
|
"fastapi>=0.135.1",
|
||||||
|
"line-bot-sdk>=3.22.0",
|
||||||
|
"numpy>=2.4.2",
|
||||||
|
"pillow>=12.1.1",
|
||||||
|
"playwright>=1.58.0",
|
||||||
|
"psycopg2-binary>=2.9.11",
|
||||||
|
"pytesseract>=0.3.13",
|
||||||
|
"python-dotenv>=1.2.2",
|
||||||
|
"sqlalchemy>=2.0.48",
|
||||||
|
"uvicorn>=0.41.0",
|
||||||
|
]
|
||||||
0
app/requirement.txt
Normal file
0
app/requirement.txt
Normal file
BIN
app/rich_menu.png
Normal file
BIN
app/rich_menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
1344
app/uv.lock
generated
Normal file
1344
app/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user