milkfloat / scheduler.py
AdrianLi33's picture
Update scheduler.py
557b481 verified
Raw
History Blame Contribute Delete
34.3 kB
# StockFlow โ€” ๆ›ดๆ–ฐๆ™‚้–“๏ผš2026-07-24 15:08
"""
StockFlow โ€” ็จ็ซ‹ๆŽ’็จ‹่…ณๆœฌ v3.2๏ผˆๅคšๅบ—็ฎก็† Phase 3๏ผ‰
ๆ–ฐๅขž๏ผšๆ—ฅๅ ฑ/้€ฑๅ ฑๆ”นๆˆใ€Œ้€ๅบ—ใ€่จˆ็ฎ—่ˆ‡็™ผ้€ โ€”โ€” ๆฏๅ€‹ LINE ็พค็ต„ไพ็…ง่‡ชๅทฑ็ถๅฎš็š„ๅบ—ๅฎถ๏ผŒ
ๅชๆ”ถๅˆฐ่ฉฒๅบ—็š„ๅบซๅญ˜่ณ‡ๆ–™๏ผŒไธๅŒๅบ—ไน‹้–“ๅฎŒๅ…จไธๆœƒๆททๅœจไธ€่ตท
ๆฒฟ็”จ๏ผšschema ้ท็งป่ˆ‡ main.py ไฟๆŒๅŒๆญฅ๏ผˆstores / store_id ้Žๆฟพ / ไธ€ๆฌกๆ€ง้ท็งปไฟ่ญท๏ผ‰
ๆฒฟ็”จ๏ผšLINE Messaging API ๆŽจๆ’ญ้€š็Ÿฅใ€90 ๅคฉ่ˆŠ่ณ‡ๆ–™ๆธ…้™ค
"""
import os, time, logging
from datetime import datetime, timedelta, date, timezone
import psycopg2
import psycopg2.extras
import requests
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
LINE_TOKEN = os.getenv("LINE_CHANNEL_ACCESS_TOKEN", "")
CHECK_INTERVAL = int(os.getenv("SCHEDULER_INTERVAL_SEC", 60))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("stockflow.scheduler")
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ่ณ‡ๆ–™ๅบซ
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def get_conn():
return psycopg2.connect(DATABASE_URL, cursor_factory=psycopg2.extras.RealDictCursor)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ๆธ…้™ค่ˆŠ่ณ‡ๆ–™๏ผˆ่ทจๅบ—๏ผŒไพๆ—ฅๆœŸๅณๅฏ๏ผŒไธ้œ€่ฆ store ้Žๆฟพ๏ผ‰
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def purge_old_records():
TW = timezone(timedelta(hours=8))
cutoff = (datetime.now(TW).date() - timedelta(days=90)).isoformat()
try:
conn = get_conn(); cur = conn.cursor()
cur.execute(
"DELETE FROM inventory_records WHERE recorded_date < %s RETURNING id",
(cutoff,)
)
deleted = cur.rowcount
conn.commit(); cur.close(); conn.close()
if deleted:
log.info("ๅทฒๆธ…้™ค %d ็ญ†่ถ…้Ž 90 ๅคฉ็š„่ˆŠ็›ค้ปž่จ˜้Œ„", deleted)
except Exception as e:
log.error("ๆธ…้™ค่ˆŠ่ณ‡ๆ–™ๅคฑๆ•—๏ผš%s", e)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ็ต„่ฃๅ ฑ่กจ๏ผˆโ˜… Phase 3๏ผšไพ store_id ้Žๆฟพ๏ผŒ้€ๅบ—็จ็ซ‹่จˆ็ฎ—๏ผ‰
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def build_report(store_id: int):
try:
conn = get_conn(); cur = conn.cursor()
cur.execute("""
SELECT DISTINCT ON (p.id)
r.id AS record_id,
p.id AS product_id,
p.name AS product_name,
p.unit, p.unit_price::float,
p.min_threshold AS thresh,
r.recorded_date::text,
r.order_date::text,
r.staff_name
FROM products p
LEFT JOIN inventory_records r ON r.product_id = p.id AND r.store_id = %s
WHERE p.active = TRUE AND p.store_id = %s
ORDER BY p.id, r.recorded_date DESC NULLS LAST
""", (store_id, store_id))
records = cur.fetchall()
if not records:
cur.close(); conn.close()
return None
from collections import Counter
dates = [r["recorded_date"] for r in records if r.get("recorded_date")]
latest = Counter(dates).most_common(1)[0][0] if dates else datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d")
items = []
for rec in records:
if not rec["record_id"]:
items.append({
"name": rec["product_name"], "pid": rec["product_id"],
"unit": rec["unit"], "price": rec["unit_price"],
"total": 0, "thresh": rec["thresh"] or 0,
"diff": -(rec["thresh"] or 0), "cost": 0,
"staff": "ๆœช่จญๅฎš", "order": "ๆœช่จญๅฎš", "batches": [],
})
continue
cur.execute("""
SELECT expiry_date::text, quantity
FROM inventory_batches WHERE record_id=%s ORDER BY expiry_date
""", (rec["record_id"],))
batches = cur.fetchall()
total_qty = sum(b["quantity"] for b in batches)
thresh = rec["thresh"] or 0
items.append({
"name": rec["product_name"], "pid": rec["product_id"],
"unit": rec["unit"], "price": rec["unit_price"],
"total": total_qty, "thresh": thresh,
"diff": total_qty - thresh, "cost": round(total_qty * rec["unit_price"], 0),
"staff": rec["staff_name"] or "ๆœช่จญๅฎš",
"order": rec["order_date"] or "ๆœช่จญๅฎš", "batches": batches,
})
cur.close(); conn.close()
TW = timezone(timedelta(hours=8))
now = datetime.now(TW)
today = now.date()
now_str = now.strftime("%Y-%m-%d %H:%M")
day7_start = today - timedelta(days=7)
day21_start = today - timedelta(days=21)
conn2 = get_conn(); cur2 = conn2.cursor()
cur2.execute("""
SELECT p.id AS pid,
COALESCE(SUM(c.quantity) FILTER (WHERE c.recorded_date BETWEEN %s AND %s), 0) AS qty_7d,
COALESCE(SUM(c.quantity) FILTER (WHERE c.recorded_date BETWEEN %s AND %s), 0) AS qty_21d
FROM products p
LEFT JOIN consumption_records c ON c.product_id = p.id
WHERE p.active = TRUE AND p.store_id = %s GROUP BY p.id
""", (day7_start, today, day21_start, today, store_id))
cons_map = {row["pid"]: row for row in cur2.fetchall()}
cur2.close(); conn2.close()
import math
def calc_suggest(item):
if item["thresh"] == 0:
return 0
c = cons_map.get(item["pid"], {})
q7 = float(c.get("qty_7d", 0))
q21 = float(c.get("qty_21d", 0))
if q21 > 0:
target = math.ceil((q7 / 7) * 3 + item["thresh"])
else:
target = item["thresh"]
return max(0, target - item["total"])
exp_threshold = today + timedelta(days=2)
expiring_soon = []
for item in items:
for b in item["batches"]:
d = b["expiry_date"]
if d and today.isoformat() <= d <= exp_threshold.isoformat():
expiring_soon.append({"name": item["name"], "unit": item["unit"], "expiry": d, "qty": b["quantity"]})
expiring_soon.sort(key=lambda x: x["expiry"])
for item in items:
item["suggest"] = calc_suggest(item)
item["need"] = item["suggest"] > 0
need_items = [i for i in items if i["need"]]
has_expiring = bool(expiring_soon)
lines = [
f"๐Ÿ“ฆ ๅบซๅญ˜ๆ—ฅๅ ฑ {latest}",
f"็™ผ้€ๆ™‚้–“๏ผš{now_str}",
"โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•",
]
if need_items:
lines.append("โš ๏ธ ไปฅไธ‹ๅ“้ …้œ€่ฆๅซ่ฒจ๏ผš")
for item in need_items:
c = cons_map.get(item["pid"], {})
q7 = float(c.get("qty_7d", 0))
q21 = float(c.get("qty_21d", 0))
hint = f"๏ผˆ่ฟ‘7ๅคฉๆ—ฅๅ‡ {q7/7:.1f} / ่ฟ‘21ๅคฉๆฏ3ๅคฉๅ‡ {q21/7:.1f}๏ผ‰" if q21 > 0 else ""
lines.append(
f"โš ๏ธใ€{item['name']}ใ€‘\n"
f" ็พๆœ‰๏ผš{item['total']} {item['unit']}๏ผˆๅฎ‰ๅ…จๅบซๅญ˜ {item['thresh']}๏ผ‰\n"
f" ๅปบ่ญฐๅซ่ฒจ๏ผš{item['suggest']} {item['unit']} {hint}"
)
else:
lines.append("โœ… ๆ‰€ๆœ‰ๅ“้ …ๅบซๅญ˜ๅ……่ถณ๏ผŒ็„ก้œ€ๅซ่ฒจใ€‚")
if has_expiring:
lines.append("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
lines.append("๐Ÿ• ๅณๅฐ‡้ŽๆœŸ๏ผˆ2ๅคฉๅ…ง๏ผ‰๏ผš")
for e in expiring_soon:
lines.append(f" ใƒป{e['name']}ใ€€ๆ•ˆๆœŸ {e['expiry']}ใ€€{e['qty']} {e['unit']}")
lines.append("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
return "\n\n".join(lines) if need_items else "\n".join(lines)
except Exception as e:
log.error("็ต„่ฃๆฏๆ—ฅๅ ฑ่กจๅคฑๆ•—๏ผˆstore_id=%s๏ผ‰๏ผš%s", store_id, e)
return None
def build_weekly_report(store_id: int):
"""
ๆฏ้€ฑไบŒๆถˆ่€—่ถจๅ‹ข้€ฑๅ ฑ๏ผˆไพๅบ—้Žๆฟพ๏ผ‰ใ€‚
ๅช้กฏ็คบใ€Œๆœฌ้€ฑๆถˆ่€— โ‰ฅ ไธŠ้€ฑใ€๏ผˆ็ด…่‰ฒ่ถจๅ‹ข๏ผ‰็š„ๅ“้ …๏ผŒไปฅๅŠๅปบ่ญฐๅซ่ฒจ้‡ใ€‚
"""
try:
TW = timezone(timedelta(hours=8))
today = datetime.now(TW).date()
weekday = today.weekday()
week_start = today - timedelta(days=weekday)
lw_start = week_start - timedelta(days=7)
lw_end = week_start - timedelta(days=1)
now_str = datetime.now(TW).strftime("%Y-%m-%d %H:%M")
conn = get_conn(); cur = conn.cursor()
cur.execute("""
SELECT
p.id, p.name, p.unit, p.unit_price::float,
COALESCE(SUM(c.quantity) FILTER (
WHERE c.recorded_date BETWEEN %s AND %s
), 0) AS this_week,
COALESCE(SUM(c.quantity) FILTER (
WHERE c.recorded_date BETWEEN %s AND %s
), 0) AS last_week
FROM products p
LEFT JOIN consumption_records c ON c.product_id = p.id
WHERE p.active = TRUE AND p.store_id = %s
GROUP BY p.id, p.name, p.unit, p.unit_price
ORDER BY p.id
""", (week_start, today, lw_start, lw_end, store_id))
consumption = cur.fetchall()
cur.execute("""
SELECT DISTINCT ON (p.id)
p.id AS product_id,
p.min_threshold,
COALESCE((
SELECT SUM(b.quantity)
FROM inventory_records r2
JOIN inventory_batches b ON b.record_id = r2.id
WHERE r2.product_id = p.id AND r2.store_id = %s
ORDER BY r2.recorded_date DESC
LIMIT 1
), 0) AS current_qty
FROM products p
WHERE p.active = TRUE AND p.store_id = %s
ORDER BY p.id
""", (store_id, store_id))
inv_map = {row["product_id"]: row for row in cur.fetchall()}
cur.close(); conn.close()
red_items = [c for c in consumption if int(c["this_week"]) > int(c["last_week"]) and int(c["this_week"]) > 0]
if not red_items:
return (
f"๐Ÿ“Š ๆถˆ่€—้€ฑๅ ฑ {today}\n"
f"โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n"
f"โœ… ๆœฌ้€ฑๆ‰€ๆœ‰ๅ“้ …ๆถˆ่€—้‡ๆญฃๅธธ๏ผŒ็„กไธŠๅ‡่ถจๅ‹ขใ€‚"
)
line_parts = [
f"๐Ÿ“Š ๆถˆ่€—้€ฑๅ ฑ {today}",
f"๏ผˆๆฏ้€ฑไบŒๆŽจๆ’ญ๏ผ‰",
f"โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•",
f"๐Ÿ”บ ไปฅไธ‹ๅ“้ …ๆœฌ้€ฑๆถˆ่€—ไธŠๅ‡๏ผš",
]
for c in red_items:
inv = inv_map.get(c["id"])
cur_qty = int(inv["current_qty"]) if inv else 0
thresh = int(inv["min_threshold"]) if inv else 0
suggest = max(0, thresh - cur_qty)
tw = int(c["this_week"])
lw = int(c["last_week"])
block = [
f"๐Ÿ”บใ€{c['name']}ใ€‘",
f" ๆœฌ้€ฑ {tw} {c['unit']}๏ผˆไธŠ้€ฑ {lw}๏ผ‰โ†‘",
f" ็›ฎๅ‰ๅบซๅญ˜๏ผš{cur_qty} {c['unit']}",
]
if suggest > 0:
block.append(f" โš ๏ธ ๅปบ่ญฐๅซ่ฒจ๏ผš{suggest} {c['unit']}")
line_parts.append("\n".join(block))
line_parts.append("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
line_parts.append(f"็™ผ้€ๆ™‚้–“๏ผš{now_str}")
return "\n\n".join(line_parts)
except Exception as e:
log.error("็ต„่ฃ้€ฑๅ ฑๅคฑๆ•—๏ผˆstore_id=%s๏ผ‰๏ผš%s", store_id, e)
return None
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# LINE Messaging API ๆŽจๆ’ญ
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def send_line(to_id: str, text: str) -> bool:
if not LINE_TOKEN:
log.warning("LINE_CHANNEL_ACCESS_TOKEN ๆœช่จญๅฎš๏ผŒ่ทณ้Ž LINE ้€š็Ÿฅ")
return False
if not to_id:
log.warning("LINE ๆŽฅๆ”ถๅฐ่ฑก ID ๆœช่จญๅฎš๏ผŒ่ทณ้Ž LINE ้€š็Ÿฅ")
return False
try:
resp = requests.post(
"https://api.line.me/v2/bot/message/push",
headers={
"Authorization": f"Bearer {LINE_TOKEN}",
"Content-Type": "application/json",
},
json={
"to": to_id,
"messages": [{"type": "text", "text": text}],
},
timeout=15,
)
if resp.status_code == 200:
log.info("LINE ๆŽจๆ’ญๆˆๅŠŸ โ†’ %s", to_id)
return True
log.error("LINE API ้Œฏ่ชค %d๏ผš%s", resp.status_code, resp.text[:200])
return False
except Exception as e:
log.error("LINE ้€ฃ็ทš็•ฐๅธธ๏ผš%s", e)
return False
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ไธปๆŽ’็จ‹ๅˆคๆ–ท๏ผˆโ˜… Phase 3๏ผš้€ๅบ—่จˆ็ฎ—ๅ ฑ่กจ๏ผŒๅŒๅบ—ๅคš็พค็ต„ๅ…ฑ็”จไธ€ไปฝ็ฎ—ๅฅฝ็š„ๅ…งๅฎน๏ผ‰
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def check_and_send():
"""
ๆฏๆฌก่ผช่ฉขๅ‘ผๅซไธ€ๆฌกใ€‚
่ฎ€ line_groups ่กจ๏ผŒๅช่™•็†ใ€Œๅทฒ็ถๅฎšๅบ—ๅฎถใ€๏ผˆstore_id ไธ็‚บ NULL๏ผ‰็š„็พค็ต„๏ผŒ
ๆฏๅ€‹็พค็ต„ไพ็…ง่‡ชๅทฑ็š„ๆŽ’็จ‹ๆ™‚้–“๏ผŒๆ”ถๅˆฐ่‡ชๅทฑ็ถๅฎšๅบ—ๅฎถ็š„ๆ—ฅๅ ฑ/้€ฑๅ ฑใ€‚
"""
TW = timezone(timedelta(hours=8))
now = datetime.now(TW)
today_str = now.strftime("%Y-%m-%d")
current_hhmm = now.strftime("%H:%M")
is_tuesday = now.weekday() == 1 # 0=้€ฑไธ€, 1=้€ฑไบŒ
try:
conn = get_conn(); cur = conn.cursor()
cur.execute("""
SELECT group_id, store_id,
schedule_time::text, active, last_sent_date::text,
weekly_time::text, weekly_active, last_weekly_date::text
FROM line_groups
WHERE store_id IS NOT NULL
""")
groups = cur.fetchall()
cur.close(); conn.close()
if not groups:
return
daily_reports = {} # store_id -> ๅทฒ็ต„ๅฅฝ็š„ๆ—ฅๅ ฑๆ–‡ๅญ—๏ผˆๅŒๅบ—ๅคš็พค็ต„ๅ…ฑ็”จ๏ผŒ้ฟๅ…้‡่ค‡ๆŸฅ่ฉข๏ผ‰
weekly_reports = {} # store_id -> ๅทฒ็ต„ๅฅฝ็š„้€ฑๅ ฑๆ–‡ๅญ—
for group in groups:
gid = group["group_id"]
store_id = group["store_id"]
# โ”€โ”€ ๆฏๆ—ฅๆ—ฅๅ ฑ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if group["active"]:
sched_hhmm = group["schedule_time"][:5]
last_sent = group["last_sent_date"] or ""
if current_hhmm >= sched_hhmm and last_sent != today_str:
if store_id not in daily_reports:
daily_reports[store_id] = build_report(store_id)
report = daily_reports[store_id]
if report:
ok = send_line(gid, report)
if ok:
conn = get_conn(); cur = conn.cursor()
cur.execute(
"UPDATE line_groups SET last_sent_date=%s, updated_at=NOW() WHERE group_id=%s",
(today_str, gid)
)
conn.commit(); cur.close(); conn.close()
# โ”€โ”€ ๆฏ้€ฑไบŒ้€ฑๅ ฑ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if is_tuesday and group.get("weekly_active", True):
weekly_hhmm = (group.get("weekly_time") or "09:00")[:5]
last_weekly = group.get("last_weekly_date") or ""
if current_hhmm >= weekly_hhmm and last_weekly != today_str:
if store_id not in weekly_reports:
weekly_reports[store_id] = build_weekly_report(store_id)
report = weekly_reports[store_id]
if report:
ok = send_line(gid, report)
if ok:
conn = get_conn(); cur = conn.cursor()
cur.execute(
"UPDATE line_groups SET last_weekly_date=%s, updated_at=NOW() WHERE group_id=%s",
(today_str, gid)
)
conn.commit(); cur.close(); conn.close()
except Exception as e:
log.error("LINE ็พค็ต„ๆŽ’็จ‹็•ฐๅธธ๏ผš%s", e)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ่ณ‡ๆ–™่กจๅˆๅง‹ๅŒ–๏ผˆ่ˆ‡ main.py init_db() ไฟๆŒๅŒๆญฅ๏ผ‰
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def ensure_schema():
try:
conn = get_conn(); cur = conn.cursor()
cur.execute("SELECT pg_advisory_lock(123456789)")
cur.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
""")
# โ”€โ”€ stores โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
cur.execute("""
CREATE TABLE IF NOT EXISTS stores (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
code TEXT UNIQUE NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute("SELECT COUNT(*) AS n FROM stores")
if cur.fetchone()["n"] == 0:
cur.execute(
"INSERT INTO stores (name, code) VALUES (%s,%s) ON CONFLICT DO NOTHING",
("้ ่จญๅบ—", "DEFAULT")
)
cur.execute("SELECT id FROM stores ORDER BY id ASC LIMIT 1")
default_store_id = cur.fetchone()["id"]
# โ”€โ”€ users โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
cur.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'staff'
CHECK (role IN ('superadmin','admin','staff')),
store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL")
cur.execute("""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_role_check' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users DROP CONSTRAINT users_role_check;
END IF;
ALTER TABLE users ADD CONSTRAINT users_role_check
CHECK (role IN ('superadmin','admin','staff'));
END$$
""")
cur.execute("SELECT 1 FROM schema_migrations WHERE name='v3_admin_to_superadmin_once'")
if not cur.fetchone():
cur.execute("UPDATE users SET role='superadmin', store_id=NULL WHERE role='admin'")
cur.execute("INSERT INTO schema_migrations (name) VALUES ('v3_admin_to_superadmin_once')")
cur.execute("UPDATE users SET store_id=%s WHERE role='staff' AND store_id IS NULL", (default_store_id,))
# โ”€โ”€ products โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
cur.execute("""
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
unit TEXT NOT NULL DEFAULT '็“ถ',
unit_price NUMERIC(10,2) NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
min_threshold INTEGER NOT NULL DEFAULT 0,
default_shelf_days INTEGER,
category TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute("ALTER TABLE products ADD COLUMN IF NOT EXISTS min_threshold INTEGER NOT NULL DEFAULT 0")
cur.execute("ALTER TABLE products ADD COLUMN IF NOT EXISTS default_shelf_days INTEGER")
cur.execute("ALTER TABLE products ADD COLUMN IF NOT EXISTS category TEXT")
cur.execute("ALTER TABLE products ADD COLUMN IF NOT EXISTS store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL")
cur.execute("UPDATE products SET store_id=%s WHERE store_id IS NULL", (default_store_id,))
cur.execute("""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'products_name_key' AND conrelid = 'products'::regclass
) THEN
ALTER TABLE products DROP CONSTRAINT products_name_key;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'products_store_id_name_key' AND conrelid = 'products'::regclass
) THEN
ALTER TABLE products ADD CONSTRAINT products_store_id_name_key UNIQUE (store_id, name);
END IF;
END$$
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS inventory_records (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
recorded_date DATE NOT NULL,
min_threshold INTEGER NOT NULL DEFAULT 0,
order_date DATE,
staff_name TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute("ALTER TABLE inventory_records ADD COLUMN IF NOT EXISTS product_id INTEGER REFERENCES products(id) ON DELETE SET NULL")
cur.execute("ALTER TABLE inventory_records ADD COLUMN IF NOT EXISTS store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL")
cur.execute("UPDATE inventory_records SET store_id=%s WHERE store_id IS NULL", (default_store_id,))
cur.execute("""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'inventory_records_recorded_date_key'
AND conrelid = 'inventory_records'::regclass
) THEN
ALTER TABLE inventory_records DROP CONSTRAINT inventory_records_recorded_date_key;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'inventory_records_product_id_recorded_date_key'
AND conrelid = 'inventory_records'::regclass
) THEN
ALTER TABLE inventory_records
ADD CONSTRAINT inventory_records_product_id_recorded_date_key
UNIQUE (product_id, recorded_date);
END IF;
END$$
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS inventory_batches (
id SERIAL PRIMARY KEY,
record_id INTEGER NOT NULL
REFERENCES inventory_records(id) ON DELETE CASCADE,
expiry_date DATE NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS staff_settings (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
)
""")
cur.execute("ALTER TABLE staff_settings ADD COLUMN IF NOT EXISTS store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL")
cur.execute("UPDATE staff_settings SET store_id=%s WHERE store_id IS NULL", (default_store_id,))
cur.execute("""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'staff_settings_name_key' AND conrelid = 'staff_settings'::regclass
) THEN
ALTER TABLE staff_settings DROP CONSTRAINT staff_settings_name_key;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'staff_settings_store_id_name_key' AND conrelid = 'staff_settings'::regclass
) THEN
ALTER TABLE staff_settings ADD CONSTRAINT staff_settings_store_id_name_key UNIQUE (store_id, name);
END IF;
END$$
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS schedules (
id SERIAL PRIMARY KEY,
schedule_time TIME NOT NULL DEFAULT '21:00',
active_receiver TEXT,
last_sent_date DATE,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
""")
# โ”€โ”€ line_groups๏ผˆโ˜… Phase 3๏ผšstore_id ็”จไพ†่จ˜้Œ„็ถๅฎš็š„ๅบ—ๅฎถ๏ผ‰โ”€โ”€
cur.execute("""
CREATE TABLE IF NOT EXISTS line_groups (
id SERIAL PRIMARY KEY,
group_id TEXT UNIQUE NOT NULL,
group_name TEXT,
schedule_time TIME NOT NULL DEFAULT '21:00',
active BOOLEAN NOT NULL DEFAULT TRUE,
last_sent_date DATE,
weekly_time TIME NOT NULL DEFAULT '09:00',
weekly_active BOOLEAN NOT NULL DEFAULT TRUE,
last_weekly_date DATE,
joined_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute("ALTER TABLE line_groups ADD COLUMN IF NOT EXISTS weekly_time TIME NOT NULL DEFAULT '09:00'")
cur.execute("ALTER TABLE line_groups ADD COLUMN IF NOT EXISTS weekly_active BOOLEAN NOT NULL DEFAULT TRUE")
cur.execute("ALTER TABLE line_groups ADD COLUMN IF NOT EXISTS last_weekly_date DATE")
cur.execute("ALTER TABLE line_groups ADD COLUMN IF NOT EXISTS store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL")
# โ˜… ๆณจๆ„๏ผšline_groups ็š„ store_id ไธๅšใ€Œ่‡ชๅ‹•ๅ›žๅกซ้ ่จญๅบ—ใ€๏ผ
# ้€™ๆ˜ฏใ€Œ็พค็ต„่ฆๆ”ถๅ“ชๅฎถๅบ—็š„้€š็Ÿฅใ€็š„็ถๅฎš็‹€ๆ…‹๏ผŒๅฟ…้ ˆ็”ฑไฝฟ็”จ่€…ๅœจ็พค็ต„่ฃก่ผธๅ…ฅใ€Œ็ถๅฎš ๅบ—ไปฃ็ขผใ€ๆ‰่จญๅฎš๏ผŒ
# ไธ่ƒฝๅƒๅ…ถไป–่กจไธ€ๆจฃ่‡ชๅ‹•ๅ‡่จญๆ˜ฏ้ ่จญๅบ—๏ผŒๅฆๅ‰‡ๆœƒๆŠŠๆ‰€ๆœ‰ๆ—ขๆœ‰็พค็ต„้Œฏ่ชคๅœฐ็ถๅˆฐๅŒไธ€ๅฎถๅบ—ใ€‚
# Phase 1~2 ้ท็งปๆ™‚ๅฆ‚ๆžœๅทฒ็ถ“่ชคๆŠŠ่ˆŠ็พค็ต„ backfill ้Ž store_id๏ผŒ้€™่ฃกไธๆœƒๅ†ๅ‹•ๅฎƒ๏ผŒ็ถญๆŒ็พ็‹€ใ€‚
cur.execute("""
CREATE TABLE IF NOT EXISTS consumption_records (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
recorded_date DATE NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
staff_name TEXT,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (product_id, recorded_date)
)
""")
cur.execute("ALTER TABLE consumption_records ADD COLUMN IF NOT EXISTS store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL")
cur.execute("UPDATE consumption_records SET store_id=%s WHERE store_id IS NULL", (default_store_id,))
cur.execute("""
CREATE TABLE IF NOT EXISTS purchase_orders (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
product_name TEXT NOT NULL,
unit TEXT NOT NULL,
quantity INTEGER NOT NULL,
ordered_by TEXT NOT NULL,
ordered_date DATE NOT NULL,
arrived BOOLEAN NOT NULL DEFAULT FALSE,
arrived_date DATE,
arrived_by TEXT,
arrived_quantity INTEGER,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS arrived_quantity INTEGER")
cur.execute("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL")
cur.execute("UPDATE purchase_orders SET store_id=%s WHERE store_id IS NULL", (default_store_id,))
# โ”€โ”€ product_categories โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
cur.execute("""
CREATE TABLE IF NOT EXISTS product_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute("ALTER TABLE product_categories ADD COLUMN IF NOT EXISTS store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL")
cur.execute("UPDATE product_categories SET store_id=%s WHERE store_id IS NULL", (default_store_id,))
cur.execute("""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'product_categories_name_key' AND conrelid = 'product_categories'::regclass
) THEN
ALTER TABLE product_categories DROP CONSTRAINT product_categories_name_key;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'product_categories_store_id_name_key' AND conrelid = 'product_categories'::regclass
) THEN
ALTER TABLE product_categories ADD CONSTRAINT product_categories_store_id_name_key UNIQUE (store_id, name);
END IF;
END$$
""")
cur.execute("SELECT COUNT(*) AS n FROM schedules")
if cur.fetchone()["n"] == 0:
cur.execute("INSERT INTO schedules (schedule_time) VALUES ('21:00')")
# โ”€โ”€ ๆธ…็†ๅญค็ซ‹่จ˜้Œ„ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
cur.execute("""
DELETE FROM inventory_batches WHERE record_id IN (
SELECT id FROM inventory_records WHERE product_id IS NULL
)
""")
cur.execute("DELETE FROM inventory_records WHERE product_id IS NULL")
conn.commit(); cur.close(); conn.close()
log.info("่ณ‡ๆ–™่กจ็ตๆง‹็ขบ่ชๅฎŒๆˆ")
except Exception as e:
log.error("่ณ‡ๆ–™่กจๅˆๅง‹ๅŒ–ๅคฑๆ•—๏ผš%s", e)
raise
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ไธป่ฟดๅœˆ
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def main():
log.info("StockFlow Scheduler ๅ•Ÿๅ‹•๏ผŒ่ผช่ฉข้–“้š” %d ็ง’", CHECK_INTERVAL)
for attempt in range(1, 11):
try:
ensure_schema()
break
except Exception:
log.warning("่ณ‡ๆ–™่กจๅˆๅง‹ๅŒ–็ฌฌ %d ๆฌกๅคฑๆ•—๏ผŒ5 ็ง’ๅพŒ้‡่ฉฆ", attempt)
time.sleep(5)
else:
log.error("้€ฃ็บŒๅคฑๆ•— 10 ๆฌก๏ผŒ็จ‹ๅบ้€€ๅ‡บ")
return
last_purge_date = None
while True:
TW = timezone(timedelta(hours=8))
today = datetime.now(TW).date()
if last_purge_date != today:
purge_old_records()
last_purge_date = today
check_and_send()
time.sleep(CHECK_INTERVAL)
if __name__ == "__main__":
main()