feat: 仪表盘+催办在休息日跳过填报检查
- 新增 is_working_day() 工具函数,自动跳过周末 + 可配置法定假日 - 仪表盘填报进度新增 is_rest_day 字段,休息日显示灰「休」印章 - 催办调度改用 is_working_day() 替代原始 weekday 检查 - 系统设置新增「法定节假日」配置卡片,支局长可管理假日日期 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,7 @@ async def update_config(
|
||||
):
|
||||
"""Update a single config key. Only directors can change settings."""
|
||||
value = body.get("value", "")
|
||||
valid_keys = {"notification_time"}
|
||||
valid_keys = {"notification_time", "holidays"}
|
||||
|
||||
if key not in valid_keys:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的配置项: {key}")
|
||||
@@ -75,11 +75,23 @@ async def update_config(
|
||||
h, m = _parse_time(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# Persist to DB
|
||||
await _set_config(db, key, value)
|
||||
# Reschedule the APScheduler job
|
||||
await reschedule_daily_check(h, m)
|
||||
return {"key": key, "value": value, "scheduled": f"{h:02d}:{m:02d}"}
|
||||
|
||||
if key == "holidays":
|
||||
# Validate: comma-separated YYYY-MM-DD dates
|
||||
if value.strip():
|
||||
for s in value.split(","):
|
||||
s = s.strip()
|
||||
if s:
|
||||
try:
|
||||
from datetime import date
|
||||
date.fromisoformat(s)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"日期格式错误: {s},应为 YYYY-MM-DD")
|
||||
await _set_config(db, key, value)
|
||||
return {"key": key, "value": value}
|
||||
|
||||
await _set_config(db, key, value)
|
||||
return {"key": key, "value": value}
|
||||
|
||||
@@ -11,7 +11,8 @@ 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.utils.timezone import today_cst
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.utils.timezone import today_cst, is_working_day
|
||||
|
||||
|
||||
def get_week_range(reference_date: date | None = None):
|
||||
@@ -78,8 +79,22 @@ 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
|
||||
|
||||
# Query leave records for today — build a set of managers on leave
|
||||
today = today_cst()
|
||||
is_rest = not is_working_day(today, holidays)
|
||||
|
||||
leaves_result = await db.execute(
|
||||
select(Leave).where(and_(
|
||||
Leave.start_date <= today,
|
||||
@@ -107,6 +122,7 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
|
||||
"completed": count >= expected,
|
||||
"has_reported_today": False, # Will be set below
|
||||
"on_leave": leave is not None,
|
||||
"is_rest_day": is_rest,
|
||||
"leave_info": {
|
||||
"leave_type": leave.leave_type,
|
||||
"start_date": str(leave.start_date),
|
||||
|
||||
@@ -7,16 +7,29 @@ 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.wecom import wecom_client
|
||||
from app.utils.timezone import today_cst
|
||||
from app.utils.timezone import today_cst, is_working_day
|
||||
|
||||
|
||||
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()
|
||||
weekday = today.weekday()
|
||||
if weekday >= 5: # Skip weekends
|
||||
return {"status": "weekend", "date": str(today)}
|
||||
|
||||
# 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
|
||||
|
||||
if not is_working_day(today, holidays):
|
||||
return {"status": "rest_day", "date": str(today)}
|
||||
|
||||
# Get all users who need to report
|
||||
result = await db.execute(
|
||||
|
||||
@@ -11,3 +11,21 @@ def today_cst() -> date:
|
||||
def parse_date(s: str) -> date:
|
||||
"""Parse a date string that may be YYYY-MM-DD or a full ISO datetime."""
|
||||
return date.fromisoformat(s[:10])
|
||||
|
||||
|
||||
def is_working_day(d: date | None = None, holidays: 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)
|
||||
|
||||
Note: makeup workdays (调休, where Saturday/Sunday becomes a workday)
|
||||
are not currently handled. If needed, add a 'workdays' config set.
|
||||
"""
|
||||
d = d or today_cst()
|
||||
if d.weekday() >= 5: # Saturday or Sunday
|
||||
return False
|
||||
if holidays and d in holidays:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -76,7 +76,8 @@ async function handleExport() {
|
||||
} catch (e: any) { ElMessage.error('导出失败') }
|
||||
}
|
||||
|
||||
function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' {
|
||||
function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_day' {
|
||||
if (p.is_rest_day) return 'rest_day'
|
||||
if (p.on_leave) return 'on_leave'
|
||||
if (p.has_reported_today && p.completed) return 'full'
|
||||
if (p.has_reported_today && !p.completed) return 'catching'
|
||||
@@ -206,26 +207,27 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' {
|
||||
>
|
||||
<!-- Chinese Seal Watermark -->
|
||||
<div class="seal-stamp" :class="`seal-stamp--${rowState(p)}`" aria-hidden="true">
|
||||
<span class="seal-char">{{ { full: '满', catching: '追', missing: '未', on_leave: '假' }[rowState(p)] }}</span>
|
||||
<span class="seal-char">{{ { full: '', catching: '', missing: '填', on_leave: '' }[rowState(p)] }}</span>
|
||||
<span class="seal-char">{{ { full: '满', catching: '追', missing: '未', on_leave: '假', rest_day: '休' }[rowState(p)] }}</span>
|
||||
<span class="seal-char">{{ { full: '', catching: '', missing: '填', on_leave: '', rest_day: '' }[rowState(p)] }}</span>
|
||||
</div>
|
||||
|
||||
<div class="progress-info">
|
||||
<span class="progress-name">
|
||||
<el-link type="primary" :underline="false" @click="goWeeklyReport(p.manager_id)">{{ p.manager_name }}</el-link>
|
||||
<span class="progress-status" :class="`progress-status--${rowState(p)}`">
|
||||
{{ { full: '本周已满', catching: '今日已填', missing: '今日未填', on_leave: '请假中' }[rowState(p)] }}
|
||||
{{ { full: '本周已满', catching: '今日已填', missing: '今日未填', on_leave: '请假中', rest_day: '休息日' }[rowState(p)] }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="progress-count" v-if="!p.on_leave">{{ p.visit_count }} / {{ p.expected }} 条</span>
|
||||
</div>
|
||||
<el-progress
|
||||
v-if="!p.on_leave"
|
||||
v-if="!p.on_leave && !p.is_rest_day"
|
||||
:percentage="Math.min(100, Math.round((p.visit_count / Math.max(p.expected, 1)) * 100))"
|
||||
:color="rowState(p) === 'full' ? '#4A6741' : rowState(p) === 'catching' ? '#C4934A' : '#B8472E'"
|
||||
:stroke-width="12"
|
||||
/>
|
||||
<span v-else class="leave-text">请假中</span>
|
||||
<span v-else-if="p.on_leave" class="leave-text">请假中</span>
|
||||
<span v-else-if="p.is_rest_day" class="rest-text">休息日</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -325,6 +327,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' {
|
||||
.progress-row--catching { border-left-color: var(--gold); background: rgba(196,147,74,0.03); }
|
||||
.progress-row--full { border-left-color: var(--sage); background: rgba(74,103,65,0.02); }
|
||||
.progress-row--on_leave { border-left-color: #5B7FA5; }
|
||||
.progress-row--rest_day { border-left-color: #9CA3AF; background: rgba(156,163,175,0.02); }
|
||||
|
||||
/* ═══ Chinese Seal Stamp Watermark ═══ */
|
||||
.seal-stamp {
|
||||
@@ -381,6 +384,12 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' {
|
||||
color: #5B7FA5;
|
||||
}
|
||||
|
||||
.seal-stamp--rest_day {
|
||||
border-color: #9CA3AF;
|
||||
outline-color: #9CA3AF;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.seal-char {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 20px;
|
||||
@@ -415,5 +424,6 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' {
|
||||
}
|
||||
|
||||
.leave-text { font-size: 13px; color: #5B7FA5; font-family: var(--font-body); }
|
||||
.rest-text { font-size: 13px; color: #9CA3AF; font-family: var(--font-body); }
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,8 @@ const announceLoading = ref(false)
|
||||
const dailyCheckLoading = ref(false)
|
||||
const notificationTime = ref('17:30')
|
||||
const notificationTimeLoading = ref(false)
|
||||
const holidays = ref('')
|
||||
const holidaysLoading = ref(false)
|
||||
|
||||
const importLoading = ref(false)
|
||||
const importFile = ref<File | null>(null)
|
||||
@@ -32,6 +34,9 @@ onMounted(async () => {
|
||||
if (res.data?.notification_time) {
|
||||
notificationTime.value = res.data.notification_time
|
||||
}
|
||||
if (res.data?.holidays) {
|
||||
holidays.value = res.data.holidays
|
||||
}
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
@@ -75,6 +80,16 @@ async function handleSaveNotificationTime() {
|
||||
} finally { notificationTimeLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleSaveHolidays() {
|
||||
holidaysLoading.value = true
|
||||
try {
|
||||
await api.put('/system-config/holidays', { value: holidays.value })
|
||||
ElMessage.success('节假日已更新')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
} finally { holidaysLoading.value = false }
|
||||
}
|
||||
|
||||
function handleImportFile(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files?.[0]) importFile.value = target.files[0]
|
||||
@@ -205,6 +220,37 @@ async function handleImportPreview() {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Holidays -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
法定节假日
|
||||
</div>
|
||||
</template>
|
||||
<div class="setting-row">
|
||||
<div class="setting-col" style="flex:1">
|
||||
<label class="setting-label">节假日日期(逗号分隔,格式 YYYY-MM-DD)</label>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start">
|
||||
<el-input
|
||||
v-model="holidays"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="如: 2026-10-01, 2026-10-02, 2026-10-05"
|
||||
style="flex:1"
|
||||
/>
|
||||
<el-button type="primary" :loading="holidaysLoading" @click="handleSaveHolidays" size="small">保存</el-button>
|
||||
</div>
|
||||
<p style="color:var(--warm-gray);font-size:12px;margin-top:6px">配置后,仪表盘和催办提醒在这些日期将跳过填报检查(周末自动跳过无需配置)</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Import -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>
|
||||
|
||||
Reference in New Issue
Block a user