from datetime import date, datetime, timedelta from decimal import Decimal from typing import Any from sqlalchemy import func, select from sqlalchemy.orm import Session from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.business.models import ( AttendanceRecord, Expense, FundAccount, Procurement, Project, RiskEvent, WorkReport, WorkTask, ) from app.modules.business.service import serialize_model from app.modules.feishu.service import FeishuService from app.modules.risk.service import RiskService DONE_STATUSES = {"完成", "已完成", "关闭", "done", "completed", "closed"} PENDING_APPROVAL_STATUSES = {"草稿", "审批中", "待审批", "pending"} PROJECT_CLOSED_STATUSES = {"验收", "已完成", "复盘", "归档", "关闭", "closed"} def _money(value: Decimal | int | float | None) -> str: """Format a numeric value as a two-decimal money string.""" amount = Decimal(value or 0) return f"{amount:,.2f}" def _json_safe(value: Any) -> Any: """Convert nested report payloads into JSON-storable values.""" if isinstance(value, Decimal): return float(value) if isinstance(value, (datetime, date)): return value.isoformat() if isinstance(value, list): return [_json_safe(item) for item in value] if isinstance(value, dict): return {key: _json_safe(item) for key, item in value.items()} return value def _next_code(prefix: str) -> str: """Build a compact unique code for generated report records.""" return f"{prefix}-{datetime.utcnow():%Y%m%d%H%M%S%f}" class ReportService: """Build operational reports and push them through Feishu.""" def __init__(self, db: Session): self.db = db self.risks = RiskService(db) def _count(self, model: type, *conditions: Any) -> int: stmt = select(func.count()).select_from(model) for condition in conditions: stmt = stmt.where(condition) return int(self.db.execute(stmt).scalar() or 0) def daily_brief(self) -> dict: project_count = self._count(Project) task_count = self._count(WorkTask) procurement_pending = self._count( Procurement, Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), ) expense_pending = self._count( Expense, Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), ) fund_total = ( self.db.execute(select(func.sum(FundAccount.current_balance))).scalar() or Decimal("0") ) risk_summary = self.risks.summary() attendance = self.attendance_summary() lines = [ f"- 项目总数:{project_count}", f"- 任务总数:{task_count}", f"- 待处理采购:{procurement_pending}", f"- 待处理费用:{expense_pending}", f"- 当前账户总余额:{_money(fund_total)}", f"- 今日打卡记录:{attendance['total']},异常:{attendance['abnormal_total']}", f"- 逾期任务:{len(risk_summary['overdue_tasks'])}", f"- 延期项目:{len(risk_summary['delayed_projects'])}", f"- 超预算项目:{len(risk_summary['over_budget_projects'])}", f"- 资金风险账户:{len(risk_summary['fund_risks'])}", f"- 供应商风险:{len(risk_summary['supplier_risks'])}", f"- 打开风险事件:{len(risk_summary['open_events'])}", f"- 综合风险等级:{risk_summary['risk_level']}", ] return {"title": "每日经营晨报", "lines": lines, "content": "\n".join(lines)} def project_weekly(self) -> dict: active = self._count( Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), ) delayed = self.risks.delayed_projects() over_budget = self.risks.over_budget_projects() open_risks = self.risks.list_events(status_filter="open") lines = [ f"- 活跃项目:{active}", f"- 延期项目:{len(delayed)}", f"- 超预算项目:{len(over_budget)}", f"- 打开风险事件:{len(open_risks)}", "- 需要管理层关注:", ] for item in delayed[:10]: lines.append(f" - 延期:{item.get('code')} {item.get('name')},负责人 {item.get('owner')}") for item in over_budget[:10]: lines.append(f" - 超预算:{item.get('code')} {item.get('name')}") return {"title": "项目周报", "lines": lines, "content": "\n".join(lines)} def attendance_summary(self, work_date: date | None = None) -> dict[str, Any]: """Summarize attendance records for one business day.""" target_date = work_date or date.today() rows = self.db.execute( select(AttendanceRecord.status, func.count()) .where(AttendanceRecord.work_date == target_date) .group_by(AttendanceRecord.status) ).all() status_counts = {str(status): int(count) for status, count in rows} abnormal_total = sum( count for status, count in status_counts.items() if status in {"迟到", "早退", "缺卡", "旷工", "异常"} ) total = sum(status_counts.values()) lines = [ f"- 日期:{target_date.isoformat()}", f"- 打卡记录:{total}", f"- 异常记录:{abnormal_total}", ] for status, count in sorted(status_counts.items()): lines.append(f"- {status}:{count}") return { "title": "打卡汇总", "work_date": target_date.isoformat(), "total": total, "abnormal_total": abnormal_total, "status_counts": status_counts, "lines": lines, "content": "\n".join(lines), } def generate_work_report( self, report_type: str = "daily", reporter: str = "system", department: str | None = None, project_code: str | None = None, period_start: date | None = None, period_end: date | None = None, persist: bool = True, actor: str = "api", ) -> dict[str, Any]: """Generate a daily or weekly operating report, optionally persisting it.""" start, end = self._resolve_period(report_type, period_start, period_end) metrics = self._report_metrics(start, end, project_code, department) risk_summary = _json_safe(self.risks.summary()) title = "经营日报" if report_type == "daily" else "经营周报" lines = self._work_report_lines(title, start, end, metrics, risk_summary) report = { "title": title, "report_type": report_type, "period_start": start.isoformat(), "period_end": end.isoformat(), "lines": lines, "content": "\n".join(lines), "metrics": metrics, "risk_summary": risk_summary, } record_data = None if persist: record = WorkReport( code=_next_code(f"REPORT-{report_type.upper()}"), report_type=report_type, title=title, reporter=reporter, department=department, project_code=project_code, period_start=start, period_end=end, content=report["content"], metrics=metrics, risk_summary=risk_summary, ) self.db.add(record) self.db.commit() self.db.refresh(record) record_data = serialize_model(record) AuditService(self.db).log( AuditLogCreate( actor=actor, source="reports", action=f"generate_{report_type}_report", target_type="work-reports", target_id=str(record.id), response_payload=record_data, ) ) return {"report": report, "data": record_data} def _resolve_period( self, report_type: str, period_start: date | None, period_end: date | None, ) -> tuple[date, date]: today = date.today() if report_type == "daily": start = period_start or period_end or today return start, period_end or start end = period_end or today start = period_start or end - timedelta(days=6) return start, end def _report_metrics( self, start: date, end: date, project_code: str | None, department: str | None, ) -> dict[str, Any]: task_filters = [ WorkTask.due_date.is_not(None), WorkTask.due_date >= start, WorkTask.due_date <= end, ] procurement_filters = [Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES)] expense_filters = [Expense.approval_status.in_(PENDING_APPROVAL_STATUSES)] attendance_filters = [ AttendanceRecord.work_date >= start, AttendanceRecord.work_date <= end, ] if project_code: task_filters.append(WorkTask.project_code == project_code) procurement_filters.append(Procurement.project_code == project_code) expense_filters.append(Expense.project_code == project_code) attendance_filters.append(AttendanceRecord.project_code == project_code) if department: expense_filters.append(Expense.department == department) attendance_filters.append(AttendanceRecord.department == department) completed_tasks = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *task_filters) overdue_tasks = self._count( WorkTask, WorkTask.due_date < date.today(), WorkTask.status.notin_(DONE_STATUSES), *task_filters, ) return { "projects_total": self._count(Project), "active_projects": self._count( Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), ), "tasks_total": self._count(WorkTask, *task_filters), "tasks_completed": completed_tasks, "tasks_overdue": overdue_tasks, "procurements_pending": self._count(Procurement, *procurement_filters), "expenses_pending": self._count(Expense, *expense_filters), "attendance_total": self._count(AttendanceRecord, *attendance_filters), "open_risk_events": self._count(RiskEvent, RiskEvent.status == "open"), } def _work_report_lines( self, title: str, start: date, end: date, metrics: dict[str, Any], risk_summary: dict[str, Any], ) -> list[str]: return [ f"- 报告:{title}", f"- 周期:{start.isoformat()} 至 {end.isoformat()}", f"- 项目:总数 {metrics['projects_total']},活跃 {metrics['active_projects']}", f"- 任务:总数 {metrics['tasks_total']},完成 {metrics['tasks_completed']}", f"- 逾期任务:{metrics['tasks_overdue']}", f"- 待处理采购:{metrics['procurements_pending']}", f"- 待处理费用:{metrics['expenses_pending']}", f"- 打卡记录:{metrics['attendance_total']}", f"- 打开风险事件:{metrics['open_risk_events']}", f"- 综合风险等级:{risk_summary['risk_level']}", ] def push_report( self, report: dict, receive_id: str | None, receive_id_type: str, actor: str, ) -> dict: card = FeishuService.build_basic_card(report["title"], report["lines"]) return FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)