feat: 集成 apisbo.com 中国节假日 API 自动获取+缓存
- 新增 holidays.py 服务:按年获取节假日数据,缓存到 system_config - 区分 holiday(工作日假日)和 workday(调休上班日)两种类型 - is_working_day() 支持调休判断:周末在 workdays 集合中视为工作日 - 启动时自动从 API 刷新当年+明年数据,失败则使用缓存 - 系统设置新增「从 API 刷新」按钮,支局长可手动触发 - 支持 2010-2026 年数据,API 免费免鉴权 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.auth import get_current_user, require_director
|
from app.middleware.auth import get_current_user, require_director
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
|
from app.services.holidays import refresh_holidays
|
||||||
from app.services.scheduler_manager import reschedule_daily_check
|
from app.services.scheduler_manager import reschedule_daily_check
|
||||||
|
|
||||||
router = APIRouter(prefix="/system-config", tags=["SystemConfig"])
|
router = APIRouter(prefix="/system-config", tags=["SystemConfig"])
|
||||||
@@ -95,3 +96,16 @@ async def update_config(
|
|||||||
|
|
||||||
await _set_config(db, key, value)
|
await _set_config(db, key, value)
|
||||||
return {"key": key, "value": value}
|
return {"key": key, "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh-holidays")
|
||||||
|
async def refresh_holidays_endpoint(
|
||||||
|
current_user: dict = Depends(require_director),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Fetch the latest Chinese holiday data from apisbo.com and cache it."""
|
||||||
|
try:
|
||||||
|
result = await refresh_holidays(db)
|
||||||
|
return {"status": "ok", **result}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"刷新节假日数据失败: {str(e)}")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.api import router as api_router
|
|||||||
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
||||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves
|
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
|
from app.services.holidays import refresh_holidays
|
||||||
from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
|
from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +85,11 @@ async def lifespan(app: FastAPI):
|
|||||||
notification_hour, notification_minute = int(parts[0]), int(parts[1])
|
notification_hour, notification_minute = int(parts[0]), int(parts[1])
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
|
# Auto-refresh Chinese holiday data from API on startup
|
||||||
|
try:
|
||||||
|
await refresh_holidays(db)
|
||||||
|
except Exception:
|
||||||
|
pass # Use cached data if API unavailable
|
||||||
|
|
||||||
# Start daily reporting scheduler
|
# Start daily reporting scheduler
|
||||||
start_scheduler(notification_hour, notification_minute)
|
start_scheduler(notification_hour, notification_minute)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from app.models.mini_business import MiniBusiness
|
|||||||
from app.models.key_visit import KeyVisit
|
from app.models.key_visit import KeyVisit
|
||||||
from app.models.daily_note import DailyNote
|
from app.models.daily_note import DailyNote
|
||||||
from app.models.leave import Leave
|
from app.models.leave import Leave
|
||||||
from app.models.system_config import SystemConfig
|
from app.services.holidays import load_holiday_sets
|
||||||
from app.utils.timezone import today_cst, is_working_day
|
from app.utils.timezone import today_cst, is_working_day
|
||||||
|
|
||||||
|
|
||||||
@@ -79,21 +79,12 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
|
|||||||
)
|
)
|
||||||
visit_map = {str(uid): cnt for uid, cnt in visits_result.all()}
|
visit_map = {str(uid): cnt for uid, cnt in visits_result.all()}
|
||||||
|
|
||||||
# Load holiday config
|
# Load holiday data (auto-refreshes from API if not cached)
|
||||||
holiday_row = await db.get(SystemConfig, "holidays")
|
holidays, workdays = await load_holiday_sets(db)
|
||||||
holidays: set[date] = set()
|
|
||||||
if holiday_row and holiday_row.value:
|
|
||||||
for s in holiday_row.value.split(","):
|
|
||||||
s = s.strip()
|
|
||||||
if s:
|
|
||||||
try:
|
|
||||||
holidays.add(date.fromisoformat(s))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Query leave records for today — build a set of managers on leave
|
# Query leave records for today — build a set of managers on leave
|
||||||
today = today_cst()
|
today = today_cst()
|
||||||
is_rest = not is_working_day(today, holidays)
|
is_rest = not is_working_day(today, holidays, workdays)
|
||||||
|
|
||||||
leaves_result = await db.execute(
|
leaves_result = await db.execute(
|
||||||
select(Leave).where(and_(
|
select(Leave).where(and_(
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Chinese holiday data service — fetches from apisbo.com and caches to system_config."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from datetime import date
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
|
||||||
|
|
||||||
|
HOLIDAY_API = "https://api.apisbo.com/holidays/year"
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_config(db: AsyncSession, key: str) -> str | None:
|
||||||
|
row = await db.get(SystemConfig, key)
|
||||||
|
return row.value if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_config(db: AsyncSession, key: str, value: str):
|
||||||
|
row = await db.get(SystemConfig, key)
|
||||||
|
if row:
|
||||||
|
row.value = value
|
||||||
|
else:
|
||||||
|
db.add(SystemConfig(key=key, value=value))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_holidays(db: AsyncSession, year: int | None = None) -> dict:
|
||||||
|
"""Fetch Chinese holidays for a year from apisbo.com and cache to DB.
|
||||||
|
|
||||||
|
Caches two config keys:
|
||||||
|
- holidays: comma-separated rest-day dates (holidays + weekends already handled by weekday check)
|
||||||
|
We only store holiday dates here, since weekends are auto-detected.
|
||||||
|
- workdays: comma-separated makeup workday dates (调休, when Sat/Sun becomes a workday)
|
||||||
|
|
||||||
|
Returns dict with counts.
|
||||||
|
"""
|
||||||
|
year = year or date.today().year
|
||||||
|
|
||||||
|
# Fetch current year + next year on first load
|
||||||
|
years_to_fetch = {year}
|
||||||
|
existing = await _get_config(db, f"holidays_{year}")
|
||||||
|
if not existing:
|
||||||
|
years_to_fetch.add(year + 1)
|
||||||
|
|
||||||
|
all_holidays: list[str] = []
|
||||||
|
all_workdays: list[str] = []
|
||||||
|
|
||||||
|
for y in years_to_fetch:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(f"{HOLIDAY_API}/{y}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if result.get("code") != 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
holidays = []
|
||||||
|
workdays = []
|
||||||
|
for item in result.get("data", []):
|
||||||
|
d = item["date"]
|
||||||
|
if item["type"] == "holiday" and date.fromisoformat(d).weekday() < 5:
|
||||||
|
# Only cache weekday-holidays (weekend holidays are already skipped)
|
||||||
|
holidays.append(d)
|
||||||
|
elif item["type"] == "workday":
|
||||||
|
# Makeup workday — Saturday/Sunday that becomes a workday
|
||||||
|
workdays.append(d)
|
||||||
|
|
||||||
|
# Store per-year for reference, and accumulate for runtime use
|
||||||
|
await _set_config(db, f"holidays_{y}", ",".join(holidays))
|
||||||
|
await _set_config(db, f"workdays_{y}", ",".join(workdays))
|
||||||
|
all_holidays.extend(holidays)
|
||||||
|
all_workdays.extend(workdays)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# API unavailable — fall back to existing cached data
|
||||||
|
cached_h = await _get_config(db, f"holidays_{y}")
|
||||||
|
if cached_h:
|
||||||
|
all_holidays.extend([d for d in cached_h.split(",") if d.strip()])
|
||||||
|
cached_w = await _get_config(db, f"workdays_{y}")
|
||||||
|
if cached_w:
|
||||||
|
all_workdays.extend([d for d in cached_w.split(",") if d.strip()])
|
||||||
|
|
||||||
|
# Write runtime config keys (used by is_working_day)
|
||||||
|
if all_holidays or all_workdays:
|
||||||
|
await _set_config(db, "holidays", ",".join(sorted(all_holidays)))
|
||||||
|
await _set_config(db, "workdays", ",".join(sorted(all_workdays)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"holidays": len(all_holidays),
|
||||||
|
"workdays": len(all_workdays),
|
||||||
|
"years": sorted(years_to_fetch),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_holiday_sets(db: AsyncSession) -> tuple[set[date], set[date]]:
|
||||||
|
"""Load holiday and workday date sets from cached config.
|
||||||
|
|
||||||
|
Returns (holidays_set, workdays_set).
|
||||||
|
- holidays_set: dates that are rest days (weekday holidays)
|
||||||
|
- workdays_set: dates that are workdays despite being weekends (调休)
|
||||||
|
"""
|
||||||
|
holidays: set[date] = set()
|
||||||
|
workdays: set[date] = set()
|
||||||
|
|
||||||
|
# Try runtime config first
|
||||||
|
h_val = await _get_config(db, "holidays")
|
||||||
|
if h_val:
|
||||||
|
for s in h_val.split(","):
|
||||||
|
s = s.strip()
|
||||||
|
if s:
|
||||||
|
try:
|
||||||
|
holidays.add(date.fromisoformat(s))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
w_val = await _get_config(db, "workdays")
|
||||||
|
if w_val:
|
||||||
|
for s in w_val.split(","):
|
||||||
|
s = s.strip()
|
||||||
|
if s:
|
||||||
|
try:
|
||||||
|
workdays.add(date.fromisoformat(s))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If no data cached yet, try to refresh
|
||||||
|
if not holidays and not workdays:
|
||||||
|
try:
|
||||||
|
await refresh_holidays(db)
|
||||||
|
# Reload after refresh
|
||||||
|
return await load_holiday_sets(db)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return holidays, workdays
|
||||||
@@ -7,7 +7,7 @@ from app.models.user import User
|
|||||||
from app.models.leave import Leave
|
from app.models.leave import Leave
|
||||||
from app.models.work_plan import WorkPlan
|
from app.models.work_plan import WorkPlan
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.system_config import SystemConfig
|
from app.services.holidays import load_holiday_sets
|
||||||
from app.services.wecom import wecom_client
|
from app.services.wecom import wecom_client
|
||||||
from app.utils.timezone import today_cst, is_working_day
|
from app.utils.timezone import today_cst, is_working_day
|
||||||
|
|
||||||
@@ -16,19 +16,10 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
|
|||||||
"""Check today's reporting progress and send wecom reminders to managers who haven't reported."""
|
"""Check today's reporting progress and send wecom reminders to managers who haven't reported."""
|
||||||
today = today_cst()
|
today = today_cst()
|
||||||
|
|
||||||
# Load holiday config
|
# Load holiday data (auto-refreshes from API if not cached)
|
||||||
holiday_row = await db.get(SystemConfig, "holidays")
|
holidays, workdays = await load_holiday_sets(db)
|
||||||
holidays: set[date] = set()
|
|
||||||
if holiday_row and holiday_row.value:
|
|
||||||
for s in holiday_row.value.split(","):
|
|
||||||
s = s.strip()
|
|
||||||
if s:
|
|
||||||
try:
|
|
||||||
holidays.add(date.fromisoformat(s))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not is_working_day(today, holidays):
|
if not is_working_day(today, holidays, workdays):
|
||||||
return {"status": "rest_day", "date": str(today)}
|
return {"status": "rest_day", "date": str(today)}
|
||||||
|
|
||||||
# Get all users who need to report
|
# Get all users who need to report
|
||||||
|
|||||||
@@ -13,19 +13,30 @@ def parse_date(s: str) -> date:
|
|||||||
return date.fromisoformat(s[:10])
|
return date.fromisoformat(s[:10])
|
||||||
|
|
||||||
|
|
||||||
def is_working_day(d: date | None = None, holidays: set[date] | None = None) -> bool:
|
def is_working_day(d: date | None = None, holidays: set[date] | None = None, workdays: set[date] | None = None) -> bool:
|
||||||
"""Check if a date is a Chinese working day.
|
"""Check if a date is a Chinese working day.
|
||||||
|
|
||||||
Returns False for:
|
Returns False for:
|
||||||
- Saturdays and Sundays (weekday >= 5)
|
- Saturdays and Sundays (weekday >= 5), UNLESS in the workdays set (调休)
|
||||||
- Dates in the holidays set (configurable via system_config)
|
- Dates in the holidays set (weekday holidays like 春节/国庆)
|
||||||
|
|
||||||
Note: makeup workdays (调休, where Saturday/Sunday becomes a workday)
|
Returns True for:
|
||||||
are not currently handled. If needed, add a 'workdays' config set.
|
- Monday-Friday, unless in holidays set
|
||||||
|
- Saturday/Sunday that are in the workdays set (调休 makeup days)
|
||||||
"""
|
"""
|
||||||
d = d or today_cst()
|
d = d or today_cst()
|
||||||
if d.weekday() >= 5: # Saturday or Sunday
|
is_weekend = d.weekday() >= 5
|
||||||
|
|
||||||
|
# 调休: weekend that becomes a workday
|
||||||
|
if is_weekend and workdays and d in workdays:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Normal weekend
|
||||||
|
if is_weekend:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Weekday holiday
|
||||||
if holidays and d in holidays:
|
if holidays and d in holidays:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const notificationTime = ref('17:30')
|
|||||||
const notificationTimeLoading = ref(false)
|
const notificationTimeLoading = ref(false)
|
||||||
const holidays = ref('')
|
const holidays = ref('')
|
||||||
const holidaysLoading = ref(false)
|
const holidaysLoading = ref(false)
|
||||||
|
const holidaysRefreshing = ref(false)
|
||||||
|
|
||||||
const importLoading = ref(false)
|
const importLoading = ref(false)
|
||||||
const importFile = ref<File | null>(null)
|
const importFile = ref<File | null>(null)
|
||||||
@@ -90,6 +91,19 @@ async function handleSaveHolidays() {
|
|||||||
} finally { holidaysLoading.value = false }
|
} finally { holidaysLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRefreshHolidays() {
|
||||||
|
holidaysRefreshing.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.post('/system-config/refresh-holidays')
|
||||||
|
ElMessage.success(`节假日数据已刷新:${res.data.holidays} 个假日,${res.data.workdays} 个调休`)
|
||||||
|
// Reload the config display
|
||||||
|
const cfg = await api.get('/system-config')
|
||||||
|
if (cfg.data?.holidays) holidays.value = cfg.data.holidays
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.detail || '刷新失败')
|
||||||
|
} finally { holidaysRefreshing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
function handleImportFile(e: Event) {
|
function handleImportFile(e: Event) {
|
||||||
const target = e.target as HTMLInputElement
|
const target = e.target as HTMLInputElement
|
||||||
if (target.files?.[0]) importFile.value = target.files[0]
|
if (target.files?.[0]) importFile.value = target.files[0]
|
||||||
@@ -246,7 +260,10 @@ async function handleImportPreview() {
|
|||||||
/>
|
/>
|
||||||
<el-button type="primary" :loading="holidaysLoading" @click="handleSaveHolidays" size="small">保存</el-button>
|
<el-button type="primary" :loading="holidaysLoading" @click="handleSaveHolidays" size="small">保存</el-button>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--warm-gray);font-size:12px;margin-top:6px">配置后,仪表盘和催办提醒在这些日期将跳过填报检查(周末自动跳过无需配置)</p>
|
<p style="color:var(--warm-gray);font-size:12px;margin-top:6px">
|
||||||
|
数据来源:apisbo.com 中国节假日 API · 周末自动跳过无需配置
|
||||||
|
<el-button :loading="holidaysRefreshing" @click="handleRefreshHolidays" size="small" text type="primary" style="margin-left:8px">从 API 刷新</el-button>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|||||||
Reference in New Issue
Block a user