"""数据导出(Excel / xlsx)。 提供四类导出,均可带上页面当前的筛选条件: - /export/custom 定制交付项目明细 - /export/custom-updates 定制项目周期记录 - /export/projects 常规项目管理 - /export/updates 常规项目周期执行记录 - /export/staff 人力资源库(含项目负载) - /export/focus 每周重点工作 """ from __future__ import annotations from fastapi import APIRouter, Depends, Query from fastapi.responses import Response from sqlalchemy.orm import Session from ..constants import CUSTOM_STAGE_MAP, STATUS_MAP from ..db import get_db from ..export import build_xlsx, xlsx_response from ..models import ( CustomProject, CustomUpdate, Period, Project, Staff, Task, Update, WeeklyFocus, ) from .board import worst_status from .custom import _sort_by_entry router = APIRouter() def _stage_name(code: str | None) -> str: return CUSTOM_STAGE_MAP.get(code or "", (code or "", "", 0))[0] def _resp(content: bytes, filename: str) -> Response: body, headers = xlsx_response(content, filename) return Response(content=body, headers=headers) # ---------------------------------------------------------------- 定制交付 @router.get("/export/custom") def export_custom( region: str | None = None, stage: str | None = None, keyword: str | None = None, archived: str = Query("false", pattern="^(true|false|all)$"), db: Session = Depends(get_db), ): q = db.query(CustomProject) if archived == "false": q = q.filter(CustomProject.archived.is_(False)) elif archived == "true": q = q.filter(CustomProject.archived.is_(True)) if region: q = q.filter(CustomProject.region == region) if stage: q = q.filter(CustomProject.stage == stage) if keyword: q = q.filter(CustomProject.name.contains(keyword)) projects = _sort_by_entry(q.all()) periods = {p.id: p for p in db.query(Period).all()} headers = [ "项目名称", "区域", "客户", "商务阶段", "负责人", "合同额(元)", "入库时间", "签单时间", "交付时间", "验收时间", "计收时间", "开发进度(%)", "收入确认比例(%)", "可确认收入(元)", "已开票(元)", "待开票(元)", "已回款(元)", "待回款(元)", "已计收(元)", "待计收(元)", "最近更新周期", "重点", "归档", "备注", ] rows = [] for c in projects: d = c.to_dict() p = periods.get(c.period_id) rows.append( [ c.name, c.region or "", c.customer or "", _stage_name(c.stage), c.owner or "", c.amount, c.entry_date or "", c.sign_date or "", c.deliver_date or "", c.accept_date or "", c.revenue_date or "", round(c.progress or 0, 1), d["revenue_ratio"], d["confirmed_amount"], c.invoice_amount, d["pending_invoice"], c.received_amount, d["pending_received"], c.revenue_amount, d["pending_amount"], p.to_dict()["full_label"] if p else "", "是" if c.is_key else "", "是" if c.archived else "", (c.remark or "").replace("\n", " ")[:200], ] ) dicts = [c.to_dict() for c in projects] def total(key: str) -> float: return round(sum(d.get(key) or 0 for d in dicts), 2) blank = [""] * 24 totals = ["合计", "", "", f"{len(projects)} 个项目", ""] totals += [total("amount")] # 合同额 totals += [""] * 6 # 五个环节日期 + 开发进度 totals += ["", total("confirmed_amount")] # 确认比例 / 可确认收入 totals += [total("invoice_amount"), total("pending_invoice")] totals += [total("received_amount"), total("pending_received")] totals += [total("revenue_amount"), total("pending_amount")] totals += ["", "", "", ""] rows.append(blank) rows.append(totals) content = build_xlsx( "定制交付项目", headers, rows, widths=[46, 10, 18, 14, 12, 14, 12, 12, 12, 12, 12, 11, 14, 16, 14, 14, 14, 14, 14, 14, 18, 7, 7, 40], title="定制交付项目明细(入库 → 签单 → 交付 → 验收 → 计收)", money_cols=(6, 14, 15, 16, 17, 18, 19, 20), ) return _resp(content, "定制交付项目明细") @router.get("/export/custom-updates") def export_custom_updates( period_id: int | None = None, year: int | None = None, month: int | None = None, db: Session = Depends(get_db), ): from .board import resolve_period_ids period_ids = resolve_period_ids(db, period_id, year, month) q = db.query(CustomUpdate) if period_ids: q = q.filter(CustomUpdate.period_id.in_(period_ids)) ups = q.order_by(CustomUpdate.period_id, CustomUpdate.custom_project_id).all() headers = [ "周期", "项目名称", "区域", "阶段", "执行情况", "进度(%)", "本期新增下单(元)", "本期新增计收(元)", "风险", "下一步", ] rows = [] for u in ups: cp = u.project rows.append( [ u.period.to_dict()["full_label"] if u.period else "", cp.name if cp else "", (cp.region or "") if cp else "", _stage_name(u.stage), (u.content or "").replace("\n", " ")[:500], u.progress, u.amount_delta, u.revenue_delta, (u.risk or "").replace("\n", " ")[:200], (u.next_step or "").replace("\n", " ")[:200], ] ) content = build_xlsx( "定制项目记录", headers, rows, widths=[18, 42, 10, 14, 70, 10, 16, 16, 30, 30], title="定制项目周期记录", money_cols=(7, 8), ) return _resp(content, "定制项目周期记录") # ---------------------------------------------------------------- 常规项目 @router.get("/export/projects") def export_projects( category_id: int | None = None, keyword: str | None = None, archived: str = Query("all", pattern="^(true|false|all)$"), db: Session = Depends(get_db), ): q = db.query(Project) if archived == "false": q = q.filter(Project.archived.is_(False)) elif archived == "true": q = q.filter(Project.archived.is_(True)) if category_id: q = q.filter(Project.category_id == category_id) if keyword: q = q.filter(Project.name.contains(keyword)) projects = q.order_by(Project.is_key.desc(), Project.name).all() periods = {p.id: p for p in db.query(Period).all()} headers = [ "项目名称", "分类", "负责人", "状态", "优先级", "重点", "进度(%)", "标签", "子任务数", "执行记录数", "最近更新周期", "归档", ] status_names = { "not_started": "未开始", "in_progress": "进行中", "done": "已完成", "at_risk": "风险", "blocked": "阻塞", "paused": "暂停/终止", } rows = [] for p in projects: ups = sorted(p.updates, key=lambda u: u.period_id or 0, reverse=True) last = periods.get(ups[0].period_id) if ups else None rows.append( [ p.name, p.category.name if p.category else "未分类", p.owner or "", status_names.get(p.status, p.status), (p.priority or "").upper(), "是" if p.is_key else "", round(p.progress or 0, 1), "、".join(t.name for t in p.tags), len(p.tasks), len(p.updates), last.to_dict()["full_label"] if last else "", "是" if p.archived else "", ] ) content = build_xlsx( "项目管理", headers, rows, widths=[46, 12, 12, 10, 9, 7, 10, 24, 11, 12, 18, 7], title="项目管理清单", ) return _resp(content, "项目管理清单") @router.get("/export/updates") def export_updates( period_id: int | None = None, year: int | None = None, month: int | None = None, project_id: int | None = None, db: Session = Depends(get_db), ): from .board import resolve_period_ids period_ids = resolve_period_ids(db, period_id, year, month) q = db.query(Update) if period_ids: q = q.filter(Update.period_id.in_(period_ids)) if project_id: q = q.filter(Update.project_id == project_id) ups = q.order_by(Update.period_id, Update.project_id, Update.id).all() status_names = { "not_started": "未开始", "in_progress": "进行中", "done": "已完成", "at_risk": "风险", "blocked": "阻塞", "paused": "暂停/终止", } headers = [ "周期", "项目", "子任务", "执行情况", "进度(%)", "状态", "风险", "下一步", "来源", ] rows = [] for u in ups: rows.append( [ u.period.to_dict()["full_label"] if u.period else "", u.project.name if u.project else "", u.task.name if u.task else "", (u.content or "").replace("\n", " ")[:500], u.progress, status_names.get(u.status, u.status), (u.risk or "").replace("\n", " ")[:200], (u.next_step or "").replace("\n", " ")[:200], {"import": "导入", "ai": "AI整理", "manual": "手工"}.get(u.source, u.source), ] ) content = build_xlsx( "周期执行记录", headers, rows, widths=[18, 30, 22, 70, 10, 10, 30, 30, 10], title="周期执行记录", ) return _resp(content, "周期执行记录") # ---------------------------------------------------------------- 人力资源库 @router.get("/export/staff") def export_staff( archived: str = Query("false", pattern="^(true|false|all)$"), db: Session = Depends(get_db), ): from .staff import _workload q = db.query(Staff) if archived == "false": q = q.filter(Staff.archived.is_(False)) elif archived == "true": q = q.filter(Staff.archived.is_(True)) members = q.order_by(Staff.sort_order, Staff.id).all() load = _workload(db) headers = [ "序号", "姓名", "归属小组", "人员归属", "在管项目", "重点项目", "定制项目", "定制合同额(元)", "定制待计收(元)", "最近更新", "说明", "归档", ] rows = [] for i, m in enumerate(members, 1): w = load.get(m.name, {}) rows.append( [ i, m.name, m.group or "", m.affiliation or "", w.get("project_count", 0), w.get("key_project_count", 0), w.get("custom_count", 0), w.get("custom_amount", 0), w.get("custom_pending", 0), w.get("last_update") or "", (m.note or "").replace("\n", " "), "是" if m.archived else "", ] ) content = build_xlsx( "人力资源库", headers, rows, widths=[6, 12, 14, 10, 10, 10, 10, 16, 16, 16, 52, 7], title="软件开发部人力资源库", money_cols=(8, 9), ) return _resp(content, "人力资源库") # ---------------------------------------------------------------- 每周重点工作 @router.get("/export/focus") def export_focus( archived: str = Query("false", pattern="^(true|false|all)$"), status: str | None = None, keyword: str | None = None, db: Session = Depends(get_db), ): q = db.query(WeeklyFocus) if archived == "false": q = q.filter(WeeklyFocus.archived.is_(False)) elif archived == "true": q = q.filter(WeeklyFocus.archived.is_(True)) if status: q = q.filter(WeeklyFocus.status == status) if keyword: q = q.filter(WeeklyFocus.content.contains(keyword) | WeeklyFocus.detail.contains(keyword)) periods = {p.id: p for p in db.query(Period).all()} items = sorted( q.all(), key=lambda f: (-(periods[f.period_id].sort_key if f.period_id in periods else 0), -f.id), ) headers = ["序号", "工作内容", "说明", "预计完成时间", "录入周", "执行状态", "是否延期", "结果反馈", "更新"] rows = [] for i, f in enumerate(items, 1): period = periods.get(f.period_id) rows.append( [ i, f.content, (f.detail or "").replace("\n", " "), f.due_date or "", period.to_dict()["full_label"] if period else "", STATUS_MAP.get(f.status, (f.status,))[0], "是" if f.delayed else "", (f.result or "").replace("\n", " "), f.updated_at.strftime("%Y-%m-%d %H:%M") if f.updated_at else "", ] ) content = build_xlsx( "每周重点工作", headers, rows, widths=[6, 46, 54, 14, 14, 12, 10, 54, 17], title="每周重点工作", ) return _resp(content, "每周重点工作")