feat: 三项联动 — 计划过期提醒 + 拜访自动关闭 + 批量制定
1. 计划过期自动提醒: - scheduler.py 新增 check_overdue_plans() - 每日 9:00 检查过期计划(plan_date < today, status=计划中) - 按经理汇总 → 企微推送提醒消息 2. 拜访后自动关闭计划: - create_visit 后自动将匹配的计划标记为「已完成」 - 条件: customer_id 匹配 + status=计划中 + plan_date <= visit_date 3. 亮灯表批量制定: - 红灯/黄灯卡片右上角 ☐ 复选框 - 勾选后出现「批量制定计划」按钮 - 弹窗统一设置日期+内容 → 批量 POST Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -151,6 +151,19 @@ async def create_visit(
|
|||||||
customer.last_visit_manager_id = uuid.UUID(current_user["user_id"])
|
customer.last_visit_manager_id = uuid.UUID(current_user["user_id"])
|
||||||
db.add(customer)
|
db.add(customer)
|
||||||
|
|
||||||
|
# Auto-complete matching work plans for this customer
|
||||||
|
from app.models.work_plan import WorkPlan
|
||||||
|
plans_result = await db.execute(
|
||||||
|
select(WorkPlan).where(
|
||||||
|
WorkPlan.customer_id == data.customer_id,
|
||||||
|
WorkPlan.status == "计划中",
|
||||||
|
WorkPlan.plan_date <= parse_date(data.visit_date),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for plan in plans_result.scalars().all():
|
||||||
|
plan.status = "已完成"
|
||||||
|
append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访自动完成"}])
|
||||||
|
|
||||||
# Create draft copies for companions
|
# Create draft copies for companions
|
||||||
for companion_id in data.companions:
|
for companion_id in data.companions:
|
||||||
if companion_id != uuid.UUID(current_user["user_id"]):
|
if companion_id != uuid.UUID(current_user["user_id"]):
|
||||||
|
|||||||
+8
-1
@@ -7,7 +7,7 @@ from app.database import engine, Base, async_session
|
|||||||
from app.api import router as api_router
|
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
|
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary
|
||||||
from app.services.scheduler import check_daily_reporting
|
from app.services.scheduler import check_daily_reporting, check_overdue_plans
|
||||||
|
|
||||||
_scheduler = AsyncIOScheduler()
|
_scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
@@ -18,6 +18,12 @@ async def _scheduled_check():
|
|||||||
await check_daily_reporting(db)
|
await check_daily_reporting(db)
|
||||||
|
|
||||||
|
|
||||||
|
async def _scheduled_overdue_check():
|
||||||
|
"""Check for overdue plans and remind managers (9:00 AM)."""
|
||||||
|
async with async_session() as db:
|
||||||
|
await check_overdue_plans(db)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup: create tables if not exists (for dev convenience)
|
# Startup: create tables if not exists (for dev convenience)
|
||||||
@@ -51,6 +57,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Start daily reporting scheduler (17:30 CST = 09:30 UTC)
|
# Start daily reporting scheduler (17:30 CST = 09:30 UTC)
|
||||||
_scheduler.add_job(_scheduled_check, "cron", hour=17, minute=30, id="daily_check")
|
_scheduler.add_job(_scheduled_check, "cron", hour=17, minute=30, id="daily_check")
|
||||||
|
_scheduler.add_job(_scheduled_overdue_check, "cron", hour=9, minute=0, id="overdue_check")
|
||||||
_scheduler.start()
|
_scheduler.start()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.models.visit import Visit
|
from app.models.visit import Visit
|
||||||
from app.models.daily_note import DailyNote
|
from app.models.daily_note import DailyNote
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.work_plan import WorkPlan
|
||||||
|
from app.models.customer import Customer
|
||||||
from app.services.wecom import wecom_client
|
from app.services.wecom import wecom_client
|
||||||
from app.utils.timezone import today_cst
|
from app.utils.timezone import today_cst
|
||||||
|
|
||||||
@@ -85,3 +87,62 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
|
|||||||
"reported": len(reported_map),
|
"reported": len(reported_map),
|
||||||
"not_reported": len(not_reported),
|
"not_reported": len(not_reported),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def check_overdue_plans(db: AsyncSession) -> dict:
|
||||||
|
"""Check for overdue work plans and remind managers (runs at 9:00 AM)."""
|
||||||
|
today = today_cst()
|
||||||
|
if today.weekday() >= 5:
|
||||||
|
return {"status": "weekend", "date": str(today)}
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(WorkPlan).where(
|
||||||
|
WorkPlan.status == "计划中",
|
||||||
|
WorkPlan.plan_date < today,
|
||||||
|
).order_by(WorkPlan.manager_id, WorkPlan.plan_date)
|
||||||
|
)
|
||||||
|
overdue = result.scalars().all()
|
||||||
|
|
||||||
|
if not overdue:
|
||||||
|
return {"status": "ok", "date": str(today), "overdue": 0}
|
||||||
|
|
||||||
|
# Group by manager
|
||||||
|
by_manager: dict[str, list] = {}
|
||||||
|
for p in overdue:
|
||||||
|
mid = str(p.manager_id)
|
||||||
|
by_manager.setdefault(mid, []).append(p)
|
||||||
|
|
||||||
|
users_result = await db.execute(
|
||||||
|
select(User).where(User.id.in_([uid for uid in by_manager.keys()]))
|
||||||
|
)
|
||||||
|
user_map = {str(u.id): u for u in users_result.scalars().all()}
|
||||||
|
|
||||||
|
for mid, plans in by_manager.items():
|
||||||
|
user = user_map.get(mid)
|
||||||
|
if not user or not user.wecom_userid:
|
||||||
|
continue
|
||||||
|
names = "、".join(f"{p.customer_id}" for p in plans[:5])
|
||||||
|
# Get customer names
|
||||||
|
cust_result = await db.execute(
|
||||||
|
select(Customer.name).where(Customer.id.in_([p.customer_id for p in plans[:5]]))
|
||||||
|
)
|
||||||
|
cust_names = [r[0] for r in cust_result.all()]
|
||||||
|
|
||||||
|
plan_lines = "".join(f"- {n} (计划 {p.plan_date})\n" for p, n in zip(plans[:5], cust_names))
|
||||||
|
content = (
|
||||||
|
f"📅 拜访计划过期提醒\n\n"
|
||||||
|
f"以下 {len(plans)} 个拜访计划已过期,请尽快安排拜访:\n"
|
||||||
|
f"{plan_lines}"
|
||||||
|
)
|
||||||
|
if len(plans) > 5:
|
||||||
|
content += f"... 还有 {len(plans) - 5} 个过期计划\n"
|
||||||
|
content += f"\n👉 查看详情:https://qj.dhdx.fun/light-board"
|
||||||
|
|
||||||
|
await wecom_client.send_text_message([user.wecom_userid], content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"date": str(today),
|
||||||
|
"overdue": len(overdue),
|
||||||
|
"managers_affected": len(by_manager),
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { dashboardApi } from '@/api/dashboard'
|
import { dashboardApi } from '@/api/dashboard'
|
||||||
@@ -14,13 +14,58 @@ const board = ref<any>(null)
|
|||||||
const expandedManagers = ref<Set<string>>(new Set())
|
const expandedManagers = ref<Set<string>>(new Set())
|
||||||
const monthOffset = ref(0)
|
const monthOffset = ref(0)
|
||||||
|
|
||||||
// ── Dialog state ──
|
// ── Single dialog state ──
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const selectedCustomer = ref<any>(null)
|
const selectedCustomer = ref<any>(null)
|
||||||
const planDate = ref(todayStr())
|
const planDate = ref(todayStr())
|
||||||
const planContent = ref('')
|
const planContent = ref('')
|
||||||
const planSaving = ref(false)
|
const planSaving = ref(false)
|
||||||
|
|
||||||
|
// ── Batch selection ──
|
||||||
|
const selectedCards = ref<Set<string>>(new Set())
|
||||||
|
const batchDialogVisible = ref(false)
|
||||||
|
const batchPlanDate = ref(todayStr())
|
||||||
|
const batchPlanContent = ref('')
|
||||||
|
const batchSaving = ref(false)
|
||||||
|
|
||||||
|
watch(expandedManagers, () => { selectedCards.value.clear() })
|
||||||
|
|
||||||
|
function toggleCardSelect(customerId: string) {
|
||||||
|
const s = new Set(selectedCards.value)
|
||||||
|
if (s.has(customerId)) s.delete(customerId)
|
||||||
|
else s.add(customerId)
|
||||||
|
selectedCards.value = s
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchDialog() {
|
||||||
|
if (selectedCards.value.size === 0) { ElMessage.warning('请先勾选客户卡片'); return }
|
||||||
|
batchPlanContent.value = ''
|
||||||
|
batchPlanDate.value = todayStr()
|
||||||
|
batchDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchCreate() {
|
||||||
|
if (!batchPlanContent.value.trim()) { ElMessage.warning('请输入计划内容'); return }
|
||||||
|
batchSaving.value = true
|
||||||
|
let created = 0
|
||||||
|
for (const cid of selectedCards.value) {
|
||||||
|
try {
|
||||||
|
await api.post('/work-plans/', {
|
||||||
|
customer_id: cid,
|
||||||
|
plan_content: batchPlanContent.value.trim(),
|
||||||
|
plan_date: batchPlanDate.value,
|
||||||
|
status: '计划中',
|
||||||
|
})
|
||||||
|
created++
|
||||||
|
} catch (_) { /* continue */ }
|
||||||
|
}
|
||||||
|
ElMessage.success(`已为 ${created} 个客户制定计划`)
|
||||||
|
batchDialogVisible.value = false
|
||||||
|
selectedCards.value.clear()
|
||||||
|
batchSaving.value = false
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
|
||||||
const referenceMonth = computed(() => {
|
const referenceMonth = computed(() => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setMonth(d.getMonth() + monthOffset.value)
|
d.setMonth(d.getMonth() + monthOffset.value)
|
||||||
@@ -159,7 +204,14 @@ const statusLabel: Record<string, string> = { green: '本月已拜访', yellow:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="expandedManagers.has(m.manager_id)" class="customer-grid">
|
<div v-if="expandedManagers.has(m.manager_id)" class="customer-grid">
|
||||||
<div v-for="cust in m.customers" :key="cust.customer_id" class="customer-card" :class="`customer-card--${cust.status}`" @click="openCustomerDialog(cust)">
|
<div class="batch-actions" v-if="selectedCards.size > 0" style="width:100%;margin-bottom:8px">
|
||||||
|
<el-button type="primary" size="small" @click="openBatchDialog">📋 批量制定计划 ({{ selectedCards.size }}个)</el-button>
|
||||||
|
<el-button size="small" @click="selectedCards.clear()">取消选择</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-for="cust in m.customers" :key="cust.customer_id" class="customer-card" :class="['customer-card--' + cust.status, { 'card-selected': selectedCards.has(cust.customer_id) }]" @click="openCustomerDialog(cust)">
|
||||||
|
<div v-if="cust.status !== 'green' && cust.status !== 'gray'" class="card-check" @click.stop="toggleCardSelect(cust.customer_id)">
|
||||||
|
<span v-if="selectedCards.has(cust.customer_id)">☑</span><span v-else>☐</span>
|
||||||
|
</div>
|
||||||
<div class="card-status-stripe"></div>
|
<div class="card-status-stripe"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-name-row">
|
<div class="card-name-row">
|
||||||
@@ -270,6 +322,23 @@ const statusLabel: Record<string, string> = { green: '本月已拜访', yellow:
|
|||||||
</el-link>
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- ═══ Batch Plan Dialog ═══ -->
|
||||||
|
<el-dialog v-model="batchDialogVisible" title="批量制定拜访计划" width="480px">
|
||||||
|
<p style="margin:0 0 12px;color:var(--c-text-muted)">将为 <strong>{{ selectedCards.size }}</strong> 个客户统一制定拜访计划:</p>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="计划时间">
|
||||||
|
<el-date-picker v-model="batchPlanDate" type="date" style="width:100%" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="计划内容">
|
||||||
|
<el-input v-model="batchPlanContent" type="textarea" :rows="3" placeholder="统一的拜访计划内容" :disabled="batchSaving" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="batchSaving" @click="handleBatchCreate">批量制定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -376,4 +445,15 @@ const statusLabel: Record<string, string> = { green: '本月已拜访', yellow:
|
|||||||
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
|
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
|
||||||
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
|
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
|
||||||
.plan-form-row { display: flex; gap: 8px; align-items: center; }
|
.plan-form-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
|
||||||
|
/* ═══ Card Checkbox ═══ */
|
||||||
|
.card-check {
|
||||||
|
position: absolute; top: 4px; right: 4px;
|
||||||
|
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; font-size: 14px; color: var(--warm-gray);
|
||||||
|
border-radius: 4px; background: rgba(255,255,255,0.8);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.card-check:hover { color: var(--ink); background: rgba(196,147,74,0.1); }
|
||||||
|
.customer-card.card-selected { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(196,147,74,0.2); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user