unis_manager/app/routers/export.py

393 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""数据导出(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, "每周重点工作")