diff --git a/backend/app/api/system_config.py b/backend/app/api/system_config.py index 860965d..e7d6749 100644 --- a/backend/app/api/system_config.py +++ b/backend/app/api/system_config.py @@ -6,6 +6,7 @@ from sqlalchemy import select from app.database import get_db from app.middleware.auth import get_current_user, require_director from app.models.system_config import SystemConfig +from app.services.holidays import refresh_holidays from app.services.scheduler_manager import reschedule_daily_check router = APIRouter(prefix="/system-config", tags=["SystemConfig"]) @@ -95,3 +96,16 @@ async def update_config( await _set_config(db, key, 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)}") diff --git a/backend/app/main.py b/backend/app/main.py index 606c97e..7d11e4b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves from app.models.system_config import SystemConfig +from app.services.holidays import refresh_holidays 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]) except (ValueError, IndexError): 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_scheduler(notification_hour, notification_minute) diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index 8c7feb0..821c9e9 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -11,7 +11,7 @@ from app.models.mini_business import MiniBusiness from app.models.key_visit import KeyVisit from app.models.daily_note import DailyNote 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 @@ -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()} - # Load holiday config - holiday_row = await db.get(SystemConfig, "holidays") - 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 + # Load holiday data (auto-refreshes from API if not cached) + holidays, workdays = await load_holiday_sets(db) # Query leave records for today — build a set of managers on leave 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( select(Leave).where(and_( diff --git a/backend/app/services/holidays.py b/backend/app/services/holidays.py new file mode 100644 index 0000000..87e627f --- /dev/null +++ b/backend/app/services/holidays.py @@ -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 diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 7c202dd..33e192c 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -7,7 +7,7 @@ from app.models.user import User from app.models.leave import Leave from app.models.work_plan import WorkPlan 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.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.""" today = today_cst() - # Load holiday config - holiday_row = await db.get(SystemConfig, "holidays") - 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 + # Load holiday data (auto-refreshes from API if not cached) + holidays, workdays = await load_holiday_sets(db) - if not is_working_day(today, holidays): + if not is_working_day(today, holidays, workdays): return {"status": "rest_day", "date": str(today)} # Get all users who need to report diff --git a/backend/app/utils/timezone.py b/backend/app/utils/timezone.py index 7afc14a..739a287 100644 --- a/backend/app/utils/timezone.py +++ b/backend/app/utils/timezone.py @@ -13,19 +13,30 @@ def parse_date(s: str) -> date: 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. Returns False for: - - Saturdays and Sundays (weekday >= 5) - - Dates in the holidays set (configurable via system_config) + - Saturdays and Sundays (weekday >= 5), UNLESS in the workdays set (调休) + - Dates in the holidays set (weekday holidays like 春节/国庆) - Note: makeup workdays (调休, where Saturday/Sunday becomes a workday) - are not currently handled. If needed, add a 'workdays' config set. + Returns True for: + - Monday-Friday, unless in holidays set + - Saturday/Sunday that are in the workdays set (调休 makeup days) """ 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 + + # Weekday holiday if holidays and d in holidays: return False + return True diff --git a/frontend/src/views/desktop/Settings.vue b/frontend/src/views/desktop/Settings.vue index 2d145d9..db8d4be 100644 --- a/frontend/src/views/desktop/Settings.vue +++ b/frontend/src/views/desktop/Settings.vue @@ -17,6 +17,7 @@ const notificationTime = ref('17:30') const notificationTimeLoading = ref(false) const holidays = ref('') const holidaysLoading = ref(false) +const holidaysRefreshing = ref(false) const importLoading = ref(false) const importFile = ref(null) @@ -90,6 +91,19 @@ async function handleSaveHolidays() { } 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) { const target = e.target as HTMLInputElement if (target.files?.[0]) importFile.value = target.files[0] @@ -246,7 +260,10 @@ async function handleImportPreview() { /> 保存 -

配置后,仪表盘和催办提醒在这些日期将跳过填报检查(周末自动跳过无需配置)

+

+ 数据来源:apisbo.com 中国节假日 API · 周末自动跳过无需配置 + 从 API 刷新 +