企迹(qiji) 政企周报管理系统 — v0.1
后端: FastAPI + SQLAlchemy 2.0 (async) + Alembic + MinIO + Casdoor + 企微 前端: Vue 3 + Vite + TypeScript + Element Plus + Pinia 功能清单: - 8 张数据表自动建表 / Casdoor OIDC 登录 / 企微静默登录 - 双布局: 移动端(填报) + PC端(汇总管理) - 拜访记录 CRUD + MinIO 照片直传 + 缩略图预览 + 同访人草稿 - 今日纪要 (6 分类) / 工作计划 / 小微商机 / 要客拜访 CRUD - 客户档案: 备注/收支费用/联系人/归属分配/批量转移 - 客户导入导出 + 模板下载 + 搜索/分页/筛选 - 仪表盘: 四卡统计 + 填报进度 (拜访+纪要双维度) - 周报详情: 五 Tab + 按人/客户筛选 + 时间轴 - 用户管理 / 客户经理 PC 端工作台 - 企微: 催办/公告/定时提醒 / 时区修正 - Docker 部署配置 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
from uuid import uuid4
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import httpx
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
from app.utils.security import create_access_token
|
||||
|
||||
|
||||
async def get_or_create_user_from_casdoor(
|
||||
db: AsyncSession, casdoor_id: str, name: str,
|
||||
role: str = "manager", department: str = ""
|
||||
) -> User:
|
||||
"""Find existing user by casdoor_id, or create a new one."""
|
||||
result = await db.execute(select(User).where(User.casdoor_id == casdoor_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
casdoor_id=casdoor_id,
|
||||
name=name,
|
||||
role=role,
|
||||
department=department,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
else:
|
||||
# Update name/department if changed
|
||||
if user.name != name or user.department != department:
|
||||
user.name = name
|
||||
user.department = department
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def exchange_casdoor_code(code: str) -> dict | None:
|
||||
"""Exchange Casdoor OIDC authorization code for user info."""
|
||||
import logging
|
||||
logger = logging.getLogger("uvicorn")
|
||||
|
||||
token_url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.post(token_url, data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": settings.CASDOOR_CLIENT_ID,
|
||||
"client_secret": settings.CASDOOR_CLIENT_SECRET,
|
||||
"code": code,
|
||||
}, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}")
|
||||
return None
|
||||
token_data = resp.json()
|
||||
access_token = token_data.get("access_token", "")
|
||||
if not access_token:
|
||||
logger.error(f"[Casdoor] no access_token in response: {token_data}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[Casdoor] token exchange exception: {e}")
|
||||
return None
|
||||
|
||||
userinfo_url = f"{settings.CASDOOR_ENDPOINT}/api/userinfo"
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(userinfo_url, headers={
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"[Casdoor] userinfo failed: status={resp.status_code}, body={resp.text[:500]}")
|
||||
return None
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"[Casdoor] userinfo exception: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def bind_wecom_user(db: AsyncSession, casdoor_id: str, wecom_userid: str) -> User | None:
|
||||
"""Bind a WeChat Work userid to a Casdoor user."""
|
||||
result = await db.execute(select(User).where(User.casdoor_id == casdoor_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
return None
|
||||
user.wecom_userid = wecom_userid
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_wecom_id(db: AsyncSession, wecom_userid: str) -> User | None:
|
||||
"""Find user by wecom_userid."""
|
||||
result = await db.execute(select(User).where(User.wecom_userid == wecom_userid))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def build_token_for_user(user: User) -> str:
|
||||
"""Build a JWT token for the given user."""
|
||||
return create_access_token(data={
|
||||
"sub": str(user.id),
|
||||
"user_id": str(user.id),
|
||||
"casdoor_id": user.casdoor_id,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"department": user.department,
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
from datetime import date, timedelta
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
from app.models.visit import Visit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.utils.timezone import today_cst
|
||||
|
||||
|
||||
def get_week_range(reference_date: date | None = None):
|
||||
today = reference_date or date.today()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
sunday = monday + timedelta(days=6)
|
||||
return monday, sunday
|
||||
|
||||
|
||||
async def get_dashboard_stats(db: AsyncSession) -> dict:
|
||||
"""Get dashboard statistics for the current week."""
|
||||
monday, sunday = get_week_range()
|
||||
today = date.today()
|
||||
|
||||
visits_count = (await db.execute(
|
||||
select(func.count(Visit.id)).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
)).scalar() or 0
|
||||
|
||||
plans_count = (await db.execute(
|
||||
select(func.count(WorkPlan.id))
|
||||
)).scalar() or 0
|
||||
|
||||
mini_biz_count = (await db.execute(
|
||||
select(func.count(MiniBusiness.id))
|
||||
)).scalar() or 0
|
||||
|
||||
key_visit_count = (await db.execute(
|
||||
select(func.count(KeyVisit.id))
|
||||
)).scalar() or 0
|
||||
|
||||
return {
|
||||
"week_visits": visits_count,
|
||||
"work_plans": plans_count,
|
||||
"mini_business": mini_biz_count,
|
||||
"key_visits": key_visit_count,
|
||||
"week_start": str(monday),
|
||||
"week_end": str(sunday),
|
||||
}
|
||||
|
||||
|
||||
async def get_reporting_progress(db: AsyncSession) -> list[dict]:
|
||||
"""Get per-manager reporting progress for the current week."""
|
||||
monday, sunday = get_week_range()
|
||||
|
||||
# Get all managers
|
||||
managers_result = await db.execute(select(User).where(User.role == "manager"))
|
||||
managers = managers_result.scalars().all()
|
||||
|
||||
# Get visit counts per manager this week
|
||||
visits_result = await db.execute(
|
||||
select(Visit.manager_id, func.count(Visit.id))
|
||||
.where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
.group_by(Visit.manager_id)
|
||||
)
|
||||
visit_map = {str(uid): cnt for uid, cnt in visits_result.all()}
|
||||
|
||||
progress = []
|
||||
for m in managers:
|
||||
count = visit_map.get(str(m.id), 0)
|
||||
# Calculate expected working days (Mon-Fri)
|
||||
days_passed = min((date.today() - monday).days + 1, 5)
|
||||
expected = days_passed # At least 1 visit per working day
|
||||
progress.append({
|
||||
"manager_id": str(m.id),
|
||||
"manager_name": m.name,
|
||||
"department": m.department,
|
||||
"visit_count": count,
|
||||
"expected": expected,
|
||||
"completed": count >= expected,
|
||||
"has_reported_today": False, # Will be set below
|
||||
})
|
||||
|
||||
# Check today's reporting — visits OR daily notes
|
||||
today = today_cst()
|
||||
today_visits = await db.execute(
|
||||
select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
)
|
||||
today_notes = await db.execute(
|
||||
select(DailyNote.manager_id).where(DailyNote.note_date == today)
|
||||
)
|
||||
reported_today = {str(uid) for uid, in today_visits.all()} | {str(uid) for uid, in today_notes.all()}
|
||||
for p in progress:
|
||||
p["has_reported_today"] = p["manager_id"] in reported_today
|
||||
|
||||
return progress
|
||||
|
||||
|
||||
async def get_weekly_report(
|
||||
db: AsyncSession, user_id: UUID, role: str,
|
||||
filter_manager_id: UUID | None = None,
|
||||
filter_customer_id: UUID | None = None,
|
||||
) -> dict:
|
||||
"""Get full weekly report data organized by module."""
|
||||
monday, sunday = get_week_range()
|
||||
|
||||
# Base filters respecting role visibility
|
||||
customer_map = {}
|
||||
user_map = {}
|
||||
|
||||
customers_result = await db.execute(select(Customer.id, Customer.name))
|
||||
customer_map = {c.id: c.name for c in customers_result.all()}
|
||||
users_result = await db.execute(select(User.id, User.name))
|
||||
user_map = {u.id: u.name for u in users_result.all()}
|
||||
|
||||
def build_manager_filter(existing_filter=None):
|
||||
"""If role is manager, only see own data. Otherwise optionally filter by manager_id."""
|
||||
if role == "manager":
|
||||
return str(user_id)
|
||||
return str(filter_manager_id) if filter_manager_id else None
|
||||
|
||||
# ── Visits ──
|
||||
visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
if role == "manager":
|
||||
visit_query = visit_query.where(Visit.manager_id == user_id)
|
||||
elif filter_manager_id:
|
||||
visit_query = visit_query.where(Visit.manager_id == filter_manager_id)
|
||||
if filter_customer_id:
|
||||
visit_query = visit_query.where(Visit.customer_id == filter_customer_id)
|
||||
visit_query = visit_query.order_by(Visit.visit_date.desc())
|
||||
visits_result = await db.execute(visit_query)
|
||||
visits = visits_result.scalars().all()
|
||||
|
||||
visits_data = []
|
||||
for v in visits:
|
||||
visits_data.append({
|
||||
"id": str(v.id),
|
||||
"customer_id": str(v.customer_id),
|
||||
"customer_name": customer_map.get(v.customer_id, ""),
|
||||
"visit_date": str(v.visit_date),
|
||||
"visit_method": v.visit_method,
|
||||
"time_range": v.time_range,
|
||||
"communication_content": v.communication_content,
|
||||
"customer_demand": v.customer_demand,
|
||||
"companions": [str(c) for c in (v.companions or [])],
|
||||
"photos": v.photos or [],
|
||||
"manager_id": str(v.manager_id),
|
||||
"manager_name": user_map.get(v.manager_id, ""),
|
||||
"created_at": str(v.created_at),
|
||||
})
|
||||
|
||||
# ── Work Plans ──
|
||||
wp_query = select(WorkPlan)
|
||||
if role == "manager":
|
||||
wp_query = wp_query.where(WorkPlan.manager_id == user_id)
|
||||
elif filter_manager_id:
|
||||
wp_query = wp_query.where(WorkPlan.manager_id == filter_manager_id)
|
||||
if filter_customer_id:
|
||||
wp_query = wp_query.where(WorkPlan.customer_id == filter_customer_id)
|
||||
wp_result = await db.execute(wp_query)
|
||||
work_plans_data = []
|
||||
for w in wp_result.scalars():
|
||||
work_plans_data.append({
|
||||
"id": str(w.id),
|
||||
"customer_id": str(w.customer_id),
|
||||
"customer_name": customer_map.get(w.customer_id, ""),
|
||||
"plan_content": w.plan_content,
|
||||
"plan_date": str(w.plan_date),
|
||||
"manager_id": str(w.manager_id),
|
||||
"manager_name": user_map.get(w.manager_id, ""),
|
||||
"status": w.status,
|
||||
})
|
||||
|
||||
# ── Mini Business ──
|
||||
mb_query = select(MiniBusiness)
|
||||
if role == "manager":
|
||||
mb_query = mb_query.where(MiniBusiness.manager_id == user_id)
|
||||
elif filter_manager_id:
|
||||
mb_query = mb_query.where(MiniBusiness.manager_id == filter_manager_id)
|
||||
if filter_customer_id:
|
||||
mb_query = mb_query.where(MiniBusiness.customer_id == filter_customer_id)
|
||||
mb_result = await db.execute(mb_query)
|
||||
mini_biz_data = []
|
||||
for m in mb_result.scalars():
|
||||
mini_biz_data.append({
|
||||
"id": str(m.id),
|
||||
"customer_id": str(m.customer_id),
|
||||
"customer_name": customer_map.get(m.customer_id, ""),
|
||||
"product_type": m.product_type,
|
||||
"amount": m.amount,
|
||||
"follow_up_detail": m.follow_up_detail,
|
||||
"status": m.status,
|
||||
"manager_id": str(m.manager_id),
|
||||
"manager_name": user_map.get(m.manager_id, ""),
|
||||
"expected_revenue_date": m.expected_revenue_date,
|
||||
})
|
||||
|
||||
# ── Key Visits ──
|
||||
kv_query = select(KeyVisit)
|
||||
if role == "manager":
|
||||
kv_query = kv_query.where(KeyVisit.manager_id == user_id)
|
||||
elif filter_manager_id:
|
||||
kv_query = kv_query.where(KeyVisit.manager_id == filter_manager_id)
|
||||
if filter_customer_id:
|
||||
kv_query = kv_query.where(KeyVisit.customer_id == filter_customer_id)
|
||||
kv_result = await db.execute(kv_query)
|
||||
key_visits_data = []
|
||||
for k in kv_result.scalars():
|
||||
key_visits_data.append({
|
||||
"id": str(k.id),
|
||||
"customer_id": str(k.customer_id),
|
||||
"customer_name": customer_map.get(k.customer_id, ""),
|
||||
"urgency_level": k.urgency_level,
|
||||
"description": k.description,
|
||||
"progress_status": k.progress_status,
|
||||
"planned_date": k.planned_date,
|
||||
"planned_visitor": k.planned_visitor,
|
||||
"visit_target": k.visit_target,
|
||||
"manager_id": str(k.manager_id),
|
||||
"manager_name": user_map.get(k.manager_id, ""),
|
||||
})
|
||||
|
||||
# ── Daily Notes ──
|
||||
dn_query = select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday)
|
||||
if role == "manager":
|
||||
dn_query = dn_query.where(DailyNote.manager_id == user_id)
|
||||
elif filter_manager_id:
|
||||
dn_query = dn_query.where(DailyNote.manager_id == filter_manager_id)
|
||||
dn_query = dn_query.order_by(DailyNote.note_date.desc())
|
||||
dn_result = await db.execute(dn_query)
|
||||
daily_notes_data = []
|
||||
for d in dn_result.scalars():
|
||||
daily_notes_data.append({
|
||||
"id": str(d.id),
|
||||
"note_date": str(d.note_date),
|
||||
"category": d.category,
|
||||
"content": d.content,
|
||||
"time_range": d.time_range,
|
||||
"manager_id": str(d.manager_id),
|
||||
"manager_name": user_map.get(d.manager_id, ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"week_start": str(monday),
|
||||
"week_end": str(sunday),
|
||||
"visits": visits_data,
|
||||
"daily_notes": daily_notes_data,
|
||||
"work_plans": work_plans_data,
|
||||
"mini_business": mini_biz_data,
|
||||
"key_visits": key_visits_data,
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import io
|
||||
from datetime import date, timedelta
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, Border, Side
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.visit import Visit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def get_week_range(reference_date: date | None = None):
|
||||
"""Get the Monday and Sunday of the week containing reference_date (defaults to today)."""
|
||||
today = reference_date or date.today()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
sunday = monday + timedelta(days=6)
|
||||
return monday, sunday
|
||||
|
||||
|
||||
async def export_weekly_report(db: AsyncSession, reference_date: date | None = None) -> io.BytesIO:
|
||||
"""Generate a 4-sheet xlsx matching the existing weekly report template."""
|
||||
monday, sunday = get_week_range(reference_date)
|
||||
wb = Workbook()
|
||||
|
||||
# Pre-fetch lookups
|
||||
customers_result = await db.execute(select(Customer.id, Customer.name))
|
||||
customer_map = {str(c.id): c.name for c in customers_result.all()}
|
||||
users_result = await db.execute(select(User.id, User.name))
|
||||
user_map = {str(u.id): u.name for u in users_result.all()}
|
||||
|
||||
thin_border = Border(
|
||||
left=Side(style='thin'), right=Side(style='thin'),
|
||||
top=Side(style='thin'), bottom=Side(style='thin')
|
||||
)
|
||||
header_font = Font(bold=True)
|
||||
|
||||
# ── Sheet 1: 每日拜访记录 ──
|
||||
ws1 = wb.active
|
||||
ws1.title = "每日拜访记录"
|
||||
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "沟通内容", "客户需求", "同访人员", "客户经理"]
|
||||
ws1.append(headers1)
|
||||
for col in range(1, len(headers1) + 1):
|
||||
cell = ws1.cell(row=1, column=col)
|
||||
cell.font = header_font
|
||||
cell.border = thin_border
|
||||
|
||||
visits_result = await db.execute(
|
||||
select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
)
|
||||
for v in visits_result.scalars():
|
||||
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
|
||||
ws1.append([
|
||||
customer_map.get(str(v.customer_id), ""),
|
||||
str(v.visit_date),
|
||||
v.visit_method,
|
||||
v.time_range,
|
||||
v.communication_content,
|
||||
v.customer_demand,
|
||||
", ".join(companions_names),
|
||||
user_map.get(str(v.manager_id), ""),
|
||||
])
|
||||
|
||||
# ── Sheet 2: 下周工作计划 ──
|
||||
ws2 = wb.create_sheet("下周工作计划")
|
||||
headers2 = ["客户单位", "工作计划", "计划拜访时间", "客户经理", "状态"]
|
||||
ws2.append(headers2)
|
||||
for col in range(1, len(headers2) + 1):
|
||||
cell = ws2.cell(row=1, column=col)
|
||||
cell.font = header_font
|
||||
cell.border = thin_border
|
||||
|
||||
plans_result = await db.execute(select(WorkPlan))
|
||||
for p in plans_result.scalars():
|
||||
ws2.append([
|
||||
customer_map.get(str(p.customer_id), ""),
|
||||
p.plan_content,
|
||||
str(p.plan_date),
|
||||
user_map.get(str(p.manager_id), ""),
|
||||
p.status,
|
||||
])
|
||||
|
||||
# ── Sheet 3: 小微业务商机 ──
|
||||
ws3 = wb.create_sheet("小微业务商机")
|
||||
headers3 = ["客户单位", "产品类型", "金额", "跟进内容具体情况", "跟进状态", "客户经理", "预计列收时间"]
|
||||
ws3.append(headers3)
|
||||
for col in range(1, len(headers3) + 1):
|
||||
cell = ws3.cell(row=1, column=col)
|
||||
cell.font = header_font
|
||||
cell.border = thin_border
|
||||
|
||||
mb_result = await db.execute(select(MiniBusiness))
|
||||
for m in mb_result.scalars():
|
||||
ws3.append([
|
||||
customer_map.get(str(m.customer_id), ""),
|
||||
m.product_type,
|
||||
m.amount,
|
||||
m.follow_up_detail,
|
||||
m.status,
|
||||
user_map.get(str(m.manager_id), ""),
|
||||
m.expected_revenue_date,
|
||||
])
|
||||
|
||||
# ── Sheet 4: 要客拜访计划 ──
|
||||
ws4 = wb.create_sheet("要客拜访计划")
|
||||
headers4 = ["客户单位", "紧急重要度", "内容描述", "进展状态", "计划拜访时间", "计划拜访人", "拜访对象", "客户经理"]
|
||||
ws4.append(headers4)
|
||||
for col in range(1, len(headers4) + 1):
|
||||
cell = ws4.cell(row=1, column=col)
|
||||
cell.font = header_font
|
||||
cell.border = thin_border
|
||||
|
||||
kv_result = await db.execute(select(KeyVisit))
|
||||
for k in kv_result.scalars():
|
||||
ws4.append([
|
||||
customer_map.get(str(k.customer_id), ""),
|
||||
k.urgency_level,
|
||||
k.description,
|
||||
k.progress_status,
|
||||
k.planned_date,
|
||||
k.planned_visitor,
|
||||
k.visit_target,
|
||||
user_map.get(str(k.manager_id), ""),
|
||||
])
|
||||
|
||||
# Adjust column widths
|
||||
for ws in [ws1, ws2, ws3, ws4]:
|
||||
for col_cells in ws.columns:
|
||||
max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10)
|
||||
ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50)
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output
|
||||
@@ -0,0 +1,78 @@
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
from typing import Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import openpyxl
|
||||
from app.models.customer import Customer
|
||||
from app.models.visit import Visit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict:
|
||||
"""Parse old weekly report Excel and import data. Returns summary stats."""
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0}
|
||||
|
||||
# Resolve customer name -> id cache
|
||||
customers_result = await db.execute(select(Customer.id, Customer.name))
|
||||
customer_map = {c.name: c.id for c in customers_result.all()}
|
||||
|
||||
# ── Parse Sheet 1: 每日拜访记录 ──
|
||||
if "每日拜访记录" in wb.sheetnames:
|
||||
ws = wb["每日拜访记录"]
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name, visit_date_str, visit_method, time_range, content, demand, companions_str, mgr_str = \
|
||||
row[0], str(row[1]) if row[1] else str(date.today()), \
|
||||
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \
|
||||
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \
|
||||
str(row[6]) if row[6] else "", str(row[7]) if row[7] else ""
|
||||
|
||||
customer_id = customer_map.get(cust_name)
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Parse date
|
||||
try:
|
||||
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
visit_date = date.today()
|
||||
|
||||
# Check for duplicates (same day, same person, same customer)
|
||||
existing = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.visit_date == visit_date,
|
||||
Visit.manager_id == manager_id,
|
||||
Visit.customer_id == customer_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
visit = Visit(
|
||||
customer_id=customer_id,
|
||||
visit_date=visit_date,
|
||||
visit_method=visit_method if visit_method in ["上门", "电话", "微信", "出差"] else "上门",
|
||||
time_range=time_range,
|
||||
communication_content=content,
|
||||
customer_demand=demand,
|
||||
manager_id=manager_id,
|
||||
)
|
||||
db.add(visit)
|
||||
stats["visits"] += 1
|
||||
except Exception:
|
||||
stats["skipped"] += 1
|
||||
|
||||
await db.commit()
|
||||
return stats
|
||||
|
||||
|
||||
import io
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import timedelta
|
||||
from minio import Minio
|
||||
from app.config import settings
|
||||
|
||||
_client: Minio | None = None
|
||||
|
||||
|
||||
def get_minio_client() -> Minio:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = Minio(
|
||||
settings.MINIO_ENDPOINT,
|
||||
access_key=settings.MINIO_ACCESS_KEY,
|
||||
secret_key=settings.MINIO_SECRET_KEY,
|
||||
secure=settings.MINIO_SECURE,
|
||||
)
|
||||
# Ensure bucket exists
|
||||
if not _client.bucket_exists(settings.MINIO_BUCKET):
|
||||
_client.make_bucket(settings.MINIO_BUCKET)
|
||||
return _client
|
||||
|
||||
|
||||
def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str:
|
||||
"""Generate a presigned PUT URL for direct upload to MinIO."""
|
||||
client = get_minio_client()
|
||||
return client.presigned_put_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
|
||||
|
||||
|
||||
def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str:
|
||||
"""Generate a presigned GET URL for viewing/downloading an object."""
|
||||
client = get_minio_client()
|
||||
return client.presigned_get_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
|
||||
|
||||
|
||||
def delete_objects(object_keys: list[str]) -> None:
|
||||
"""Delete multiple objects from MinIO."""
|
||||
if not object_keys:
|
||||
return
|
||||
client = get_minio_client()
|
||||
from minio.deleteobjects import DeleteObject
|
||||
errors = client.remove_objects(settings.MINIO_BUCKET, [DeleteObject(k) for k in object_keys])
|
||||
for err in errors:
|
||||
pass # Log errors in production
|
||||
@@ -0,0 +1,54 @@
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.visit import Visit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.user import User
|
||||
from app.services.wecom import wecom_client
|
||||
from app.utils.timezone import today_cst
|
||||
|
||||
|
||||
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)}
|
||||
|
||||
# Get all managers
|
||||
result = await db.execute(select(User).where(User.role == "manager"))
|
||||
managers = result.scalars().all()
|
||||
|
||||
# Get managers who have reported today
|
||||
reported_visits = await db.execute(
|
||||
select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
)
|
||||
reported_notes = await db.execute(
|
||||
select(DailyNote.manager_id).where(DailyNote.note_date == today)
|
||||
)
|
||||
reported_map = {str(uid): True for uid, in reported_visits.all()}
|
||||
for uid, in reported_notes.all():
|
||||
reported_map[str(uid)] = True
|
||||
|
||||
not_reported = []
|
||||
for m in managers:
|
||||
if str(m.id) not in reported_map:
|
||||
not_reported.append(m)
|
||||
|
||||
if not_reported and managers:
|
||||
content = f"📋 今日填报提醒({today})\n\n以下同事尚未提交今日拜访记录:\n"
|
||||
for m in not_reported:
|
||||
content += f"• {m.name}\n"
|
||||
content += "\n请尽快完成今日拜访填报 🙏"
|
||||
|
||||
user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid]
|
||||
if user_ids:
|
||||
await wecom_client.send_text_message(user_ids, content)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"date": str(today),
|
||||
"total_managers": len(managers),
|
||||
"reported": len(reported_map),
|
||||
"not_reported": len(not_reported),
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import httpx
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class WecomClient:
|
||||
"""Minimal WeChat Work API client for sending app messages."""
|
||||
|
||||
def __init__(self):
|
||||
self.corp_id = settings.WECOM_CORP_ID
|
||||
self.agent_id = settings.WECOM_AGENT_ID
|
||||
self.secret = settings.WECOM_SECRET
|
||||
self._access_token: str | None = None
|
||||
|
||||
async def _get_token(self) -> str:
|
||||
if self._access_token:
|
||||
return self._access_token
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
self._access_token = data["access_token"]
|
||||
return self._access_token
|
||||
raise Exception(f"Failed to get wecom token: {data}")
|
||||
|
||||
async def get_userinfo_by_code(self, code: str) -> dict | None:
|
||||
"""Exchange OAuth2 code for userid (used in silent login)."""
|
||||
token = await self._get_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
return data
|
||||
return None
|
||||
|
||||
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
|
||||
"""Send a text app message to specified users."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False # Not configured, skip silently in dev
|
||||
token = await self._get_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
|
||||
body = {
|
||||
"touser": "|".join(user_ids),
|
||||
"msgtype": "text",
|
||||
"agentid": int(settings.WECOM_AGENT_ID),
|
||||
"text": {"content": content},
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=body, timeout=10)
|
||||
data = resp.json()
|
||||
return data.get("errcode") == 0
|
||||
|
||||
|
||||
wecom_client = WecomClient()
|
||||
Reference in New Issue
Block a user