```
feat: 添加生命周期报告和AI规则管理功能 - 在Dockerfile中添加pillow依赖包用于图像处理 - 实现生命周期报告调度任务,支持日报和周报两种类型 - 新增TASK_RUN_LIFECYCLE任务常量和相关配置选项 - 扩展AI Agent服务以支持用户规则,并在分析时应用规则 - 添加AI用户规则创建、更新和查询接口 - 增加项目生命周期和财务需求分析技能 - 扩展现有模型以支持更完整的业务数据字段 - 实现飞书图片上传功能用于报告展示 ```
This commit is contained in:
@@ -15,6 +15,7 @@ from app.modules.reports.constants import (
|
||||
ReportPushStatus,
|
||||
ReportResponseKey,
|
||||
)
|
||||
from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart
|
||||
|
||||
|
||||
class ReportDeliveryMixin:
|
||||
@@ -39,12 +40,24 @@ class ReportDeliveryMixin:
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
card = FeishuService.build_basic_card(
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
)
|
||||
try:
|
||||
result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
|
||||
feishu = FeishuService(self.db)
|
||||
image_key = None
|
||||
image_alt = None
|
||||
chart_data = report.get("chart_data")
|
||||
if chart_data:
|
||||
image_result = feishu.upload_image(render_lifecycle_chart(chart_data), actor)
|
||||
image_key = (image_result.get("data") or {}).get("image_key")
|
||||
if not image_key:
|
||||
raise ValueError("Feishu image upload did not return image_key")
|
||||
image_alt = lifecycle_chart_alt(chart_data)
|
||||
card = FeishuService.build_basic_card(
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
image_key=image_key,
|
||||
image_alt=image_alt,
|
||||
)
|
||||
result = feishu.send_card(card, receive_id, receive_id_type, actor)
|
||||
except Exception as exc:
|
||||
failed_run = self.update_push_run(
|
||||
push_run.code,
|
||||
|
||||
506
app/modules/reports/services/finance_needs.py
Normal file
506
app/modules/reports/services/finance_needs.py
Normal file
@@ -0,0 +1,506 @@
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||||
from app.modules.business.constants import (
|
||||
CashFlowDirection,
|
||||
CashFlowType,
|
||||
DataQualityStatus,
|
||||
SourceSystem,
|
||||
StatusValue,
|
||||
)
|
||||
from app.modules.business.models import (
|
||||
Project,
|
||||
ProjectCashFlow,
|
||||
ProjectContract,
|
||||
ProjectMilestone,
|
||||
RiskEvent,
|
||||
SourceSyncCursor,
|
||||
WorkTask,
|
||||
)
|
||||
from app.modules.reports.constants import ReportResponseKey, ReportTitle
|
||||
|
||||
|
||||
ZERO = Decimal("0")
|
||||
FINANCE_HORIZONS = (7, 30, 90)
|
||||
|
||||
|
||||
class FinanceNeedsReportMixin:
|
||||
def project_finance_needs_report(
|
||||
self,
|
||||
project_code: str | None = None,
|
||||
department: str | None = None,
|
||||
owner: str | None = None,
|
||||
as_of: date | None = None,
|
||||
include_ai: bool = False,
|
||||
actor: str = ActorValue.API,
|
||||
) -> dict[str, Any]:
|
||||
reference = as_of or date.today()
|
||||
project_stmt = select(Project).where(
|
||||
Project.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
Project.is_active.is_(True),
|
||||
)
|
||||
if project_code:
|
||||
project_stmt = project_stmt.where(
|
||||
(Project.code == project_code) | (Project.display_code == project_code)
|
||||
)
|
||||
if department:
|
||||
project_stmt = project_stmt.where(Project.department_name == department)
|
||||
if owner:
|
||||
project_stmt = project_stmt.where(Project.owner == owner)
|
||||
projects = list(self.db.execute(project_stmt).scalars())
|
||||
codes = {project.code for project in projects}
|
||||
|
||||
contracts = list(
|
||||
self.db.execute(
|
||||
select(ProjectContract).where(
|
||||
ProjectContract.is_active.is_(True),
|
||||
ProjectContract.data_quality_status == DataQualityStatus.VALID,
|
||||
ProjectContract.project_code.in_(codes or {""}),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
flows = list(
|
||||
self.db.execute(
|
||||
select(ProjectCashFlow).where(
|
||||
ProjectCashFlow.is_active.is_(True),
|
||||
ProjectCashFlow.project_code.in_(codes or {""}),
|
||||
ProjectCashFlow.data_quality_status.notin_(
|
||||
{
|
||||
DataQualityStatus.ORPHAN_CONTRACT,
|
||||
DataQualityStatus.ORPHAN_PROJECT,
|
||||
DataQualityStatus.DELETED_PROJECT,
|
||||
}
|
||||
),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
contract_amounts: dict[str, Decimal] = defaultdict(lambda: ZERO)
|
||||
for contract in contracts:
|
||||
if contract.project_code:
|
||||
contract_amounts[contract.project_code] += contract.amount or ZERO
|
||||
flows_by_project: dict[str, list[ProjectCashFlow]] = defaultdict(list)
|
||||
for flow in flows:
|
||||
if flow.project_code:
|
||||
flows_by_project[flow.project_code].append(flow)
|
||||
|
||||
delivery_risk_codes = self._finance_delivery_risk_codes(codes, reference)
|
||||
items = [
|
||||
self._finance_project_item(
|
||||
project,
|
||||
contract_amounts.get(project.code, ZERO),
|
||||
flows_by_project.get(project.code, []),
|
||||
reference,
|
||||
project.code in delivery_risk_codes,
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
attention = sorted(
|
||||
[item for item in items if item["finance_covered"]],
|
||||
key=lambda item: (
|
||||
not item["uncovered_pending_outflow"],
|
||||
not item["delivery_risk"],
|
||||
-item["funding_need"]["30"]["lower"],
|
||||
item["project_code"],
|
||||
),
|
||||
)
|
||||
summary = self._finance_summary(items)
|
||||
quality = self._finance_data_quality(codes if project_code or department else None)
|
||||
latest_sync = self._finance_latest_sync_at()
|
||||
lines = self._finance_lines(reference, summary, quality, attention)
|
||||
chart_data = {
|
||||
"as_of": reference.isoformat(),
|
||||
"receivables": {
|
||||
"overdue": summary["overdue_receivable"],
|
||||
"due_30d": summary["horizons"]["30"]["receivable_due"],
|
||||
},
|
||||
"cashflows": {
|
||||
"confirmed_inflow": summary["confirmed_inflow"],
|
||||
"confirmed_outflow": summary["confirmed_outflow"],
|
||||
"pending_outflow": summary["pending_outflow"],
|
||||
},
|
||||
"top_projects": [
|
||||
{
|
||||
"name": item["project_name"][:18],
|
||||
"amount": item["funding_need"]["30"]["upper"],
|
||||
}
|
||||
for item in attention[:5]
|
||||
if item["funding_need"]["30"]["upper"] > 0
|
||||
],
|
||||
} if summary["data_available"] else None
|
||||
report: dict[str, Any] = {
|
||||
ReportResponseKey.TITLE: ReportTitle.PROJECT_FINANCE_NEEDS,
|
||||
"as_of": reference.isoformat(),
|
||||
"currency": "CNY",
|
||||
"source_last_sync_at": latest_sync,
|
||||
"summary": summary,
|
||||
"data_quality": quality,
|
||||
"items": items,
|
||||
"attention": attention[:10],
|
||||
ReportResponseKey.LINES: lines,
|
||||
ReportResponseKey.CONTENT: "\n".join(lines),
|
||||
"finance_chart_data": chart_data,
|
||||
"disclaimer": "项目资金安排需求不包含公司账户余额,不代表真实融资缺口。",
|
||||
}
|
||||
ai_analysis = self._finance_ai_analysis(report, actor) if include_ai else None
|
||||
report["ai_analysis"] = ai_analysis
|
||||
if ai_analysis and ai_analysis.get(AIResponseKey.OK):
|
||||
answer = str(ai_analysis.get(AIResponseKey.ANSWER) or "")[:3000]
|
||||
lines.append("- AI 资金分析:")
|
||||
lines.extend(f" {line}" for line in answer.splitlines() if line.strip())
|
||||
report[ReportResponseKey.CONTENT] = "\n".join(lines)
|
||||
return report
|
||||
|
||||
def _finance_project_item(
|
||||
self,
|
||||
project: Project,
|
||||
contract_amount: Decimal,
|
||||
flows: list[ProjectCashFlow],
|
||||
reference: date,
|
||||
delivery_risk: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not contract_amount and project.source_contract_amount:
|
||||
contract_amount = project.source_contract_amount
|
||||
actual_receipt = ZERO
|
||||
overdue = ZERO
|
||||
confirmed_inflow = ZERO
|
||||
confirmed_outflow = ZERO
|
||||
pending_outflow = ZERO
|
||||
receivable_by_horizon = {days: ZERO for days in FINANCE_HORIZONS}
|
||||
for flow in flows:
|
||||
actual = flow.actual_amount or ZERO
|
||||
planned = flow.planned_amount or ZERO
|
||||
if flow.flow_type == CashFlowType.CONTRACT_RECEIVABLE:
|
||||
actual_receipt += max(actual, ZERO)
|
||||
confirmed_inflow += max(actual, ZERO)
|
||||
if flow.payment_status == "N":
|
||||
outstanding = max(planned - max(actual, ZERO), ZERO)
|
||||
if flow.planned_date and flow.planned_date < reference:
|
||||
overdue += outstanding
|
||||
elif flow.planned_date:
|
||||
for days in FINANCE_HORIZONS:
|
||||
if flow.planned_date <= reference + timedelta(days=days):
|
||||
receivable_by_horizon[days] += outstanding
|
||||
elif flow.flow_type == CashFlowType.PROJECT_FUND:
|
||||
if flow.confirmation_status == "Y" and flow.approval_status == "1":
|
||||
if flow.direction == CashFlowDirection.INFLOW:
|
||||
confirmed_inflow += actual
|
||||
else:
|
||||
confirmed_outflow += actual
|
||||
elif (
|
||||
flow.direction == CashFlowDirection.OUTFLOW
|
||||
and flow.approval_status == "1"
|
||||
and flow.confirmation_status == "N"
|
||||
):
|
||||
pending_outflow += planned
|
||||
funding_need = {
|
||||
str(days): {
|
||||
"lower": _amount(max(pending_outflow - receivable_by_horizon[days], ZERO)),
|
||||
"upper": _amount(pending_outflow),
|
||||
}
|
||||
for days in FINANCE_HORIZONS
|
||||
}
|
||||
covered = bool(contract_amount or flows)
|
||||
if not covered:
|
||||
return {
|
||||
"project_code": project.code,
|
||||
"display_code": project.display_code,
|
||||
"project_name": project.name,
|
||||
"department": project.department_name,
|
||||
"owner": project.owner,
|
||||
"stage": project.source_stage_label or project.source_stage,
|
||||
"contract_revenue": None,
|
||||
"project_investment_context": (
|
||||
_amount(project.source_project_investment_amount)
|
||||
if project.source_project_investment_amount is not None
|
||||
else None
|
||||
),
|
||||
"actual_receipt": None,
|
||||
"overdue_receivable": None,
|
||||
"confirmed_inflow": None,
|
||||
"confirmed_outflow": None,
|
||||
"pending_outflow": None,
|
||||
"receivable_due": {str(days): None for days in FINANCE_HORIZONS},
|
||||
"funding_need": {
|
||||
str(days): {"lower": None, "upper": None} for days in FINANCE_HORIZONS
|
||||
},
|
||||
"uncovered_pending_outflow": False,
|
||||
"delivery_risk": delivery_risk,
|
||||
"finance_covered": False,
|
||||
}
|
||||
return {
|
||||
"project_code": project.code,
|
||||
"display_code": project.display_code,
|
||||
"project_name": project.name,
|
||||
"department": project.department_name,
|
||||
"owner": project.owner,
|
||||
"stage": project.source_stage_label or project.source_stage,
|
||||
"contract_revenue": _amount(contract_amount),
|
||||
"project_investment_context": _amount(
|
||||
project.source_project_investment_amount or ZERO
|
||||
),
|
||||
"actual_receipt": _amount(actual_receipt),
|
||||
"overdue_receivable": _amount(overdue),
|
||||
"confirmed_inflow": _amount(confirmed_inflow),
|
||||
"confirmed_outflow": _amount(confirmed_outflow),
|
||||
"pending_outflow": _amount(pending_outflow),
|
||||
"receivable_due": {
|
||||
str(days): _amount(receivable_by_horizon[days]) for days in FINANCE_HORIZONS
|
||||
},
|
||||
"funding_need": funding_need,
|
||||
"uncovered_pending_outflow": funding_need["30"]["lower"] > 0,
|
||||
"delivery_risk": delivery_risk,
|
||||
"finance_covered": True,
|
||||
}
|
||||
|
||||
def _finance_summary(self, items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
result = {
|
||||
"projects_total": len(items),
|
||||
"projects_covered": sum(1 for item in items if item["finance_covered"]),
|
||||
"contract_revenue": _sum_items(items, "contract_revenue"),
|
||||
"actual_receipt": _sum_items(items, "actual_receipt"),
|
||||
"overdue_receivable": _sum_items(items, "overdue_receivable"),
|
||||
"confirmed_inflow": _sum_items(items, "confirmed_inflow"),
|
||||
"confirmed_outflow": _sum_items(items, "confirmed_outflow"),
|
||||
"pending_outflow": _sum_items(items, "pending_outflow"),
|
||||
"horizons": {},
|
||||
}
|
||||
result["coverage_rate"] = round(
|
||||
result["projects_covered"] * 100 / result["projects_total"], 2
|
||||
) if result["projects_total"] else 0.0
|
||||
result["data_available"] = result["projects_covered"] > 0
|
||||
result["horizons"] = {
|
||||
str(days): {
|
||||
"receivable_due": round(
|
||||
sum(
|
||||
item["receivable_due"][str(days)] or 0
|
||||
for item in items
|
||||
), 2
|
||||
),
|
||||
"funding_need_lower": round(
|
||||
sum(
|
||||
item["funding_need"][str(days)]["lower"] or 0
|
||||
for item in items
|
||||
), 2
|
||||
),
|
||||
"funding_need_upper": round(
|
||||
sum(
|
||||
item["funding_need"][str(days)]["upper"] or 0
|
||||
for item in items
|
||||
), 2
|
||||
),
|
||||
}
|
||||
for days in FINANCE_HORIZONS
|
||||
}
|
||||
if not result["data_available"]:
|
||||
for key in (
|
||||
"contract_revenue",
|
||||
"actual_receipt",
|
||||
"overdue_receivable",
|
||||
"confirmed_inflow",
|
||||
"confirmed_outflow",
|
||||
"pending_outflow",
|
||||
):
|
||||
result[key] = None
|
||||
result["horizons"] = {
|
||||
str(days): {
|
||||
"receivable_due": None,
|
||||
"funding_need_lower": None,
|
||||
"funding_need_upper": None,
|
||||
}
|
||||
for days in FINANCE_HORIZONS
|
||||
}
|
||||
return result
|
||||
|
||||
def _finance_data_quality(self, project_codes: set[str] | None) -> dict[str, int]:
|
||||
conditions: list[Any] = [ProjectCashFlow.is_active.is_(True)]
|
||||
if project_codes is not None:
|
||||
conditions.append(ProjectCashFlow.project_code.in_(project_codes or {""}))
|
||||
rows = self.db.execute(
|
||||
select(
|
||||
ProjectCashFlow.flow_type,
|
||||
ProjectCashFlow.data_quality_status,
|
||||
func.count(),
|
||||
)
|
||||
.where(*conditions)
|
||||
.group_by(ProjectCashFlow.flow_type, ProjectCashFlow.data_quality_status)
|
||||
).all()
|
||||
counts = {
|
||||
(str(flow_type), str(quality_status)): int(count)
|
||||
for flow_type, quality_status, count in rows
|
||||
}
|
||||
by_status: dict[str, int] = defaultdict(int)
|
||||
for (_, quality_status), count in counts.items():
|
||||
by_status[quality_status] += count
|
||||
return {
|
||||
"orphan_contracts": counts.get(
|
||||
(CashFlowType.CONTRACT_RECEIVABLE, DataQualityStatus.ORPHAN_CONTRACT), 0
|
||||
),
|
||||
"orphan_receivable_projects": counts.get(
|
||||
(CashFlowType.CONTRACT_RECEIVABLE, DataQualityStatus.ORPHAN_PROJECT), 0
|
||||
),
|
||||
"orphan_funds": counts.get(
|
||||
(CashFlowType.PROJECT_FUND, DataQualityStatus.ORPHAN_PROJECT), 0
|
||||
),
|
||||
"deleted_projects": by_status[DataQualityStatus.DELETED_PROJECT],
|
||||
"paid_amount_missing": by_status[DataQualityStatus.PAID_AMOUNT_MISSING],
|
||||
"status_amount_mismatch": by_status[DataQualityStatus.STATUS_AMOUNT_MISMATCH],
|
||||
}
|
||||
|
||||
def _finance_delivery_risk_codes(self, codes: set[str], reference: date) -> set[str]:
|
||||
if not codes:
|
||||
return set()
|
||||
milestone_codes = set(
|
||||
self.db.execute(
|
||||
select(ProjectMilestone.project_code).where(
|
||||
ProjectMilestone.project_code.in_(codes),
|
||||
ProjectMilestone.is_active.is_(True),
|
||||
ProjectMilestone.is_overdue.is_(True),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
task_codes = set(
|
||||
self.db.execute(
|
||||
select(WorkTask.project_code).where(
|
||||
WorkTask.project_code.in_(codes),
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.status != StatusValue.COMPLETED,
|
||||
WorkTask.due_date.is_not(None),
|
||||
WorkTask.due_date < reference,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
event_codes = set(
|
||||
self.db.execute(
|
||||
select(RiskEvent.project_code).where(
|
||||
RiskEvent.project_code.in_(codes),
|
||||
RiskEvent.source_domain == "intasect_project_event",
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
return {str(code) for code in milestone_codes | task_codes | event_codes if code}
|
||||
|
||||
def _finance_latest_sync_at(self) -> str | None:
|
||||
datasets = {"projects", "contracts", "contract_receivables", "project_funds"}
|
||||
cursors = list(
|
||||
self.db.execute(
|
||||
select(SourceSyncCursor).where(SourceSyncCursor.dataset.in_(datasets))
|
||||
).scalars()
|
||||
)
|
||||
if (
|
||||
len(cursors) != len(datasets)
|
||||
or any(cursor.status != StatusValue.COMPLETED for cursor in cursors)
|
||||
or any(cursor.last_success_at is None for cursor in cursors)
|
||||
):
|
||||
return None
|
||||
value = min(cursor.last_success_at for cursor in cursors if cursor.last_success_at)
|
||||
return value.isoformat()
|
||||
|
||||
def _finance_lines(
|
||||
self,
|
||||
reference: date,
|
||||
summary: dict[str, Any],
|
||||
quality: dict[str, int],
|
||||
attention: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
horizon = summary["horizons"]["30"]
|
||||
if not summary["data_available"]:
|
||||
return [
|
||||
f"- 资金分析基准日:{reference.isoformat()}(人民币元)",
|
||||
f"- 财务覆盖:0/{summary['projects_total']} 个项目。",
|
||||
"- 项目财务数据未接入或无有效记录,金额不按零值解释。",
|
||||
"- 注意:源库没有公司账户余额,不能计算真实融资缺口。",
|
||||
]
|
||||
lines = [
|
||||
f"- 资金分析基准日:{reference.isoformat()}(人民币元)",
|
||||
f"- 财务覆盖:{summary['projects_covered']}/{summary['projects_total']} 个项目,"
|
||||
f"覆盖率 {summary['coverage_rate']}%",
|
||||
f"- 逾期应收:{_money(summary['overdue_receivable'])},"
|
||||
f"未来30天应收:{_money(horizon['receivable_due'])}",
|
||||
f"- 待确认支出:{_money(summary['pending_outflow'])},"
|
||||
f"已确认流入/流出:{_money(summary['confirmed_inflow'])}/"
|
||||
f"{_money(summary['confirmed_outflow'])}",
|
||||
f"- 未来30天项目资金安排需求:{_money(horizon['funding_need_lower'])}"
|
||||
f" 至 {_money(horizon['funding_need_upper'])}",
|
||||
"- 注意:该区间不包含公司账户余额,不代表真实融资缺口。",
|
||||
f"- 数据异常:孤儿合同付款 {quality['orphan_contracts']},"
|
||||
f"孤儿资金记录 {quality['orphan_funds']},"
|
||||
f"已支付缺金额 {quality['paid_amount_missing']},"
|
||||
f"状态金额不一致 {quality['status_amount_mismatch']}",
|
||||
]
|
||||
for item in attention[:5]:
|
||||
need = item["funding_need"]["30"]
|
||||
if need["upper"] <= 0 and item["overdue_receivable"] <= 0:
|
||||
continue
|
||||
lines.append(
|
||||
f" - {item['project_name']}({item['display_code'] or item['project_code']}):"
|
||||
f"资金安排 {_money(need['lower'])}-{_money(need['upper'])},"
|
||||
f"逾期应收 {_money(item['overdue_receivable'])}"
|
||||
)
|
||||
return lines
|
||||
|
||||
def _finance_ai_analysis(self, report: dict[str, Any], actor: str) -> dict[str, Any]:
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.ai_agent.skills import AISkillId
|
||||
|
||||
context = {
|
||||
"as_of": report["as_of"],
|
||||
"currency": report["currency"],
|
||||
"summary": report["summary"],
|
||||
"data_quality": report["data_quality"],
|
||||
"attention": [
|
||||
{
|
||||
"project_code": item["project_code"],
|
||||
"display_code": item["display_code"],
|
||||
"project_name": item["project_name"],
|
||||
"stage": item["stage"],
|
||||
"overdue_receivable": item["overdue_receivable"],
|
||||
"pending_outflow": item["pending_outflow"],
|
||||
"receivable_due_30d": item["receivable_due"]["30"],
|
||||
"funding_need_30d": item["funding_need"]["30"],
|
||||
"delivery_risk": item["delivery_risk"],
|
||||
}
|
||||
for item in report["attention"][:10]
|
||||
],
|
||||
"disclaimer": report["disclaimer"],
|
||||
}
|
||||
last_error: Exception | None = None
|
||||
attempts = max(1, min(get_settings().ai_analysis_max_attempts, 5))
|
||||
for _ in range(attempts):
|
||||
try:
|
||||
result = AIService(self.db).run_skill(
|
||||
AISkillId.PROJECT_FINANCE_NEEDS_ANALYSIS,
|
||||
context=context,
|
||||
actor=actor,
|
||||
)
|
||||
if result.get(AIResponseKey.PROVIDER) == AIProviderName.NOOP:
|
||||
return {AIResponseKey.OK: False, AIResponseKey.ERROR: "AI unavailable"}
|
||||
return {AIResponseKey.OK: True, **result}
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
self.db.rollback()
|
||||
return {
|
||||
AIResponseKey.OK: False,
|
||||
AIResponseKey.ERROR: str(last_error) if last_error else "AI unavailable",
|
||||
AIResponseKey.TYPE: type(last_error).__name__ if last_error else "AIUnavailable",
|
||||
"attempts": attempts,
|
||||
}
|
||||
|
||||
|
||||
def _amount(value: Decimal) -> float:
|
||||
return round(float(value), 2)
|
||||
|
||||
|
||||
def _sum_items(items: list[dict[str, Any]], key: str) -> float:
|
||||
return round(sum(float(item[key]) for item in items if item[key] is not None), 2)
|
||||
|
||||
|
||||
def _money(value: float) -> str:
|
||||
return f"¥{value:,.2f}"
|
||||
492
app/modules/reports/services/intasect_lifecycle.py
Normal file
492
app/modules/reports/services/intasect_lifecycle.py
Normal file
@@ -0,0 +1,492 @@
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||||
from app.modules.business.constants import ATTENDANCE_ABNORMAL_STATUSES, SourceSystem, StatusValue
|
||||
from app.modules.business.models import (
|
||||
AttendanceRecord,
|
||||
Employee,
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectMilestone,
|
||||
RiskEvent,
|
||||
WorkReport,
|
||||
WorkTask,
|
||||
)
|
||||
from app.modules.reports.constants import ReportResponseKey, ReportTitle, ReportType
|
||||
|
||||
|
||||
class IntasectLifecycleReportMixin:
|
||||
def source_project_lifecycle_summary(
|
||||
self,
|
||||
project_code: str | None = None,
|
||||
owner: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
project_statement = select(Project).where(
|
||||
Project.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
Project.is_active.is_(True),
|
||||
)
|
||||
if project_code:
|
||||
project_statement = project_statement.where(
|
||||
(Project.code == project_code) | (Project.display_code == project_code)
|
||||
)
|
||||
if owner:
|
||||
project_statement = project_statement.where(Project.owner == owner)
|
||||
projects = list(self.db.execute(project_statement).scalars())
|
||||
project_codes = {item.code for item in projects}
|
||||
unarchived_codes = {item.code for item in projects if not item.source_archived}
|
||||
stage_counts = Counter(
|
||||
item.source_stage_label or item.source_stage or "阶段未知" for item in projects
|
||||
)
|
||||
|
||||
covered_projects = int(
|
||||
self.db.execute(
|
||||
select(func.count(func.distinct(ProjectMember.project_code))).where(
|
||||
ProjectMember.is_active.is_(True),
|
||||
ProjectMember.project_code.in_(project_codes or {""}),
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
milestone_total = self._count_source_rows(
|
||||
ProjectMilestone,
|
||||
ProjectMilestone.is_active.is_(True),
|
||||
ProjectMilestone.project_code.in_(project_codes or {""}),
|
||||
)
|
||||
overdue_milestones = list(
|
||||
self.db.execute(
|
||||
select(ProjectMilestone, Project)
|
||||
.join(Project, Project.code == ProjectMilestone.project_code)
|
||||
.where(
|
||||
ProjectMilestone.is_active.is_(True),
|
||||
ProjectMilestone.is_overdue.is_(True),
|
||||
ProjectMilestone.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
.order_by(ProjectMilestone.plan_end.asc())
|
||||
.limit(10)
|
||||
).all()
|
||||
)
|
||||
overdue_milestone_total = self._count_source_rows(
|
||||
ProjectMilestone,
|
||||
ProjectMilestone.is_active.is_(True),
|
||||
ProjectMilestone.is_overdue.is_(True),
|
||||
ProjectMilestone.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
today = date.today()
|
||||
overdue_tasks = list(
|
||||
self.db.execute(
|
||||
select(WorkTask, Project)
|
||||
.join(Project, Project.code == WorkTask.project_code)
|
||||
.where(
|
||||
WorkTask.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.status != StatusValue.COMPLETED,
|
||||
WorkTask.due_date.is_not(None),
|
||||
WorkTask.due_date < today,
|
||||
WorkTask.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
.order_by(WorkTask.due_date.asc())
|
||||
.limit(10)
|
||||
).all()
|
||||
)
|
||||
overdue_task_total = self._count_source_rows(
|
||||
WorkTask,
|
||||
WorkTask.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.status != StatusValue.COMPLETED,
|
||||
WorkTask.due_date.is_not(None),
|
||||
WorkTask.due_date < today,
|
||||
WorkTask.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
open_events = list(
|
||||
self.db.execute(
|
||||
select(RiskEvent)
|
||||
.where(
|
||||
RiskEvent.source_domain == "intasect_project_event",
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
RiskEvent.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
.limit(10)
|
||||
).scalars()
|
||||
)
|
||||
open_event_total = self._count_source_rows(
|
||||
RiskEvent,
|
||||
RiskEvent.source_domain == "intasect_project_event",
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
RiskEvent.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
linked_tasks = self._count_source_rows(
|
||||
WorkTask,
|
||||
WorkTask.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.project_code.in_(project_codes or {""}),
|
||||
)
|
||||
return {
|
||||
"total": len(projects),
|
||||
"unarchived": len(unarchived_codes),
|
||||
"archived": sum(1 for item in projects if item.source_archived),
|
||||
"unknown_stage": sum(1 for item in projects if not item.source_stage),
|
||||
"stage_counts": dict(stage_counts.most_common()),
|
||||
"member_coverage": {
|
||||
"covered_projects": covered_projects,
|
||||
"uncovered_projects": max(len(projects) - covered_projects, 0),
|
||||
},
|
||||
"milestones": {
|
||||
"total": milestone_total,
|
||||
"overdue": overdue_milestone_total,
|
||||
},
|
||||
"linked_tasks": linked_tasks,
|
||||
"overdue_linked_tasks": overdue_task_total,
|
||||
"open_project_events": open_event_total,
|
||||
"attention": {
|
||||
"overdue_milestones": [
|
||||
{
|
||||
"project_code": project.display_code or project.code,
|
||||
"project_name": project.name,
|
||||
"owner": project.owner,
|
||||
"stage": milestone.stage_name,
|
||||
"plan_end": milestone.plan_end.isoformat() if milestone.plan_end else None,
|
||||
}
|
||||
for milestone, project in overdue_milestones
|
||||
],
|
||||
"overdue_tasks": [
|
||||
{
|
||||
"project_code": project.display_code or project.code,
|
||||
"project_name": project.name,
|
||||
"task": task.title,
|
||||
"owner": task.owner,
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
}
|
||||
for task, project in overdue_tasks
|
||||
],
|
||||
"project_events": [
|
||||
{
|
||||
"project_code": item.project_code,
|
||||
"title": item.title,
|
||||
"risk_level": item.risk_level,
|
||||
"due_date": item.due_date.isoformat() if item.due_date else None,
|
||||
}
|
||||
for item in open_events
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def personnel_lifecycle_report(
|
||||
self,
|
||||
department: str | None = None,
|
||||
employee_code: str | None = None,
|
||||
project_code: str | None = None,
|
||||
period_start: date | None = None,
|
||||
period_end: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
end = period_end or self._latest_completed_attendance_date()
|
||||
start = period_start or end
|
||||
employee_stmt = select(Employee).where(
|
||||
Employee.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
)
|
||||
if department:
|
||||
employee_stmt = employee_stmt.where(Employee.department_name == department)
|
||||
if employee_code:
|
||||
employee_stmt = employee_stmt.where(Employee.code == employee_code)
|
||||
if project_code:
|
||||
member_codes = select(ProjectMember.employee_code).where(
|
||||
ProjectMember.project_code == project_code,
|
||||
ProjectMember.is_active.is_(True),
|
||||
)
|
||||
employee_stmt = employee_stmt.where(Employee.code.in_(member_codes))
|
||||
employees = list(self.db.execute(employee_stmt).scalars())
|
||||
codes = {item.code for item in employees}
|
||||
|
||||
member_counts: dict[str, int] = defaultdict(int)
|
||||
workload: dict[str, int] = defaultdict(int)
|
||||
for code, count, total_workload in self.db.execute(
|
||||
select(
|
||||
ProjectMember.employee_code,
|
||||
func.count(func.distinct(ProjectMember.project_code)),
|
||||
func.coalesce(func.sum(ProjectMember.workload_percent), 0),
|
||||
)
|
||||
.where(
|
||||
ProjectMember.is_active.is_(True), ProjectMember.employee_code.in_(codes or {""})
|
||||
)
|
||||
.group_by(ProjectMember.employee_code)
|
||||
):
|
||||
member_counts[str(code)] = int(count)
|
||||
workload[str(code)] = int(total_workload or 0)
|
||||
|
||||
open_tasks: dict[str, int] = defaultdict(int)
|
||||
overdue_tasks: dict[str, int] = defaultdict(int)
|
||||
for code, task_status, due_date in self.db.execute(
|
||||
select(WorkTask.employee_code, WorkTask.status, WorkTask.due_date).where(
|
||||
WorkTask.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.employee_code.in_(codes or {""}),
|
||||
)
|
||||
):
|
||||
if task_status != StatusValue.COMPLETED:
|
||||
open_tasks[str(code)] += 1
|
||||
if due_date and due_date < date.today():
|
||||
overdue_tasks[str(code)] += 1
|
||||
|
||||
attendance_abnormal: dict[str, int] = defaultdict(int)
|
||||
attendance_seen: set[str] = set()
|
||||
for code, attendance_status in self.db.execute(
|
||||
select(AttendanceRecord.employee_id, AttendanceRecord.status).where(
|
||||
AttendanceRecord.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
AttendanceRecord.attendance_scope == "company",
|
||||
AttendanceRecord.is_active.is_(True),
|
||||
AttendanceRecord.work_date >= start,
|
||||
AttendanceRecord.work_date <= end,
|
||||
AttendanceRecord.employee_id.in_(codes or {""}),
|
||||
)
|
||||
):
|
||||
attendance_seen.add(str(code))
|
||||
if attendance_status in ATTENDANCE_ABNORMAL_STATUSES:
|
||||
attendance_abnormal[str(code)] += 1
|
||||
|
||||
report_counts: dict[str, int] = defaultdict(int)
|
||||
for code, count in self.db.execute(
|
||||
select(WorkReport.employee_code, func.count())
|
||||
.where(
|
||||
WorkReport.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkReport.is_active.is_(True),
|
||||
WorkReport.is_draft.is_(False),
|
||||
WorkReport.period_end >= start,
|
||||
WorkReport.period_end <= end,
|
||||
WorkReport.employee_code.in_(codes or {""}),
|
||||
)
|
||||
.group_by(WorkReport.employee_code)
|
||||
):
|
||||
report_counts[str(code)] = int(count)
|
||||
|
||||
items = []
|
||||
for employee in employees:
|
||||
item = {
|
||||
"employee_code": employee.code,
|
||||
"name": employee.name,
|
||||
"department": employee.department_name,
|
||||
"title": employee.title,
|
||||
"employment_status": employee.employment_status,
|
||||
"project_count": member_counts[employee.code],
|
||||
"planned_workload_percent": workload[employee.code],
|
||||
"open_tasks": open_tasks[employee.code],
|
||||
"overdue_tasks": overdue_tasks[employee.code],
|
||||
"attendance_records": 1 if employee.code in attendance_seen else 0,
|
||||
"attendance_abnormal": attendance_abnormal[employee.code],
|
||||
"submitted_reports": report_counts[employee.code],
|
||||
"attendance_covered": bool(employee.ding_user_id),
|
||||
}
|
||||
item["needs_attention"] = bool(
|
||||
item["overdue_tasks"]
|
||||
or item["attendance_abnormal"]
|
||||
or item["planned_workload_percent"] > 100
|
||||
)
|
||||
items.append(item)
|
||||
attention = [item for item in items if item["needs_attention"]]
|
||||
attention.sort(
|
||||
key=lambda item: (
|
||||
item["overdue_tasks"],
|
||||
item["attendance_abnormal"],
|
||||
item["planned_workload_percent"],
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
metrics = {
|
||||
"total": len(employees),
|
||||
"active": sum(1 for item in employees if item.is_active),
|
||||
"inactive": sum(1 for item in employees if not item.is_active),
|
||||
"attendance_mapped": sum(1 for item in employees if item.ding_user_id),
|
||||
"attention_total": len(attention),
|
||||
"by_department": dict(
|
||||
Counter(item.department_name or "未设置部门" for item in employees)
|
||||
),
|
||||
"period_start": start.isoformat(),
|
||||
"period_end": end.isoformat(),
|
||||
}
|
||||
lines = [
|
||||
f"- 人员总数:{metrics['total']},在职:{metrics['active']},离职/失效:{metrics['inactive']}",
|
||||
f"- 考勤映射覆盖:{metrics['attendance_mapped']}/{metrics['total']}",
|
||||
f"- 需关注人员:{metrics['attention_total']}",
|
||||
]
|
||||
for item in attention[:10]:
|
||||
lines.append(
|
||||
f" - {item['name']}({item['department'] or '未设置部门'}):"
|
||||
f"逾期任务 {item['overdue_tasks']},考勤异常 {item['attendance_abnormal']},"
|
||||
f"计划负荷 {item['planned_workload_percent']}%"
|
||||
)
|
||||
return {
|
||||
ReportResponseKey.TITLE: ReportTitle.PERSONNEL_LIFECYCLE,
|
||||
ReportResponseKey.PERIOD_START: start.isoformat(),
|
||||
ReportResponseKey.PERIOD_END: end.isoformat(),
|
||||
ReportResponseKey.METRICS: metrics,
|
||||
"items": items,
|
||||
"attention": attention[:10],
|
||||
ReportResponseKey.LINES: lines,
|
||||
ReportResponseKey.CONTENT: "\n".join(lines),
|
||||
}
|
||||
|
||||
def management_lifecycle_report(
|
||||
self,
|
||||
report_type: str,
|
||||
actor: str = ActorValue.SCHEDULER,
|
||||
include_ai: bool = True,
|
||||
reference_date: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
start, end = self._management_period(report_type, reference_date)
|
||||
projects = self.source_project_lifecycle_summary()
|
||||
personnel = self.personnel_lifecycle_report(period_start=start, period_end=end)
|
||||
finance = (
|
||||
self.project_finance_needs_report(as_of=end, include_ai=False, actor=actor)
|
||||
if get_settings().finance_needs_enabled
|
||||
else None
|
||||
)
|
||||
title = (
|
||||
ReportTitle.LIFECYCLE_DAILY
|
||||
if report_type == ReportType.DAILY
|
||||
else ReportTitle.LIFECYCLE_WEEKLY
|
||||
)
|
||||
lines = [
|
||||
f"- 统计周期:{start.isoformat()} 至 {end.isoformat()}",
|
||||
f"- 项目:总数 {projects['total']},未归档 {projects['unarchived']},"
|
||||
f"已归档 {projects['archived']},阶段未知 {projects['unknown_stage']}",
|
||||
f"- 项目成员覆盖:{projects['member_coverage']['covered_projects']}/{projects['total']}",
|
||||
f"- 里程碑:总数 {projects['milestones']['total']},逾期 {projects['milestones']['overdue']}",
|
||||
f"- 项目关联任务:{projects['linked_tasks']},逾期 {projects['overdue_linked_tasks']},"
|
||||
f"待协助/超期事项:{projects['open_project_events']}",
|
||||
*personnel[ReportResponseKey.LINES],
|
||||
]
|
||||
if finance:
|
||||
lines.extend(finance[ReportResponseKey.LINES])
|
||||
else:
|
||||
lines.append(
|
||||
"- 项目资金需求:功能未启用;采购、费用、资金、供应商首期未接入,不计为零。"
|
||||
)
|
||||
report = {
|
||||
ReportResponseKey.TITLE: title,
|
||||
ReportResponseKey.REPORT_TYPE: report_type,
|
||||
ReportResponseKey.PERIOD_START: start.isoformat(),
|
||||
ReportResponseKey.PERIOD_END: end.isoformat(),
|
||||
ReportResponseKey.METRICS: {
|
||||
"projects": projects,
|
||||
"personnel": personnel[ReportResponseKey.METRICS],
|
||||
"finance": finance["summary"] if finance else {"status": "未启用"},
|
||||
},
|
||||
"attention": {
|
||||
"projects": projects["attention"],
|
||||
"personnel": personnel["attention"],
|
||||
"finance": finance["attention"] if finance else [],
|
||||
},
|
||||
ReportResponseKey.LINES: lines,
|
||||
"chart_data": {
|
||||
"period": f"{start.isoformat()} - {end.isoformat()}",
|
||||
"projects": {
|
||||
"total": projects["total"],
|
||||
"unarchived": projects["unarchived"],
|
||||
"archived": projects["archived"],
|
||||
},
|
||||
"risks": {
|
||||
"overdue_milestones": projects["milestones"]["overdue"],
|
||||
"overdue_tasks": projects["overdue_linked_tasks"],
|
||||
"open_events": projects["open_project_events"],
|
||||
},
|
||||
"people": {
|
||||
"active": personnel[ReportResponseKey.METRICS]["active"],
|
||||
"attention": personnel[ReportResponseKey.METRICS]["attention_total"],
|
||||
"attendance_mapped": personnel[ReportResponseKey.METRICS][
|
||||
"attendance_mapped"
|
||||
],
|
||||
},
|
||||
"finance": finance["finance_chart_data"] if finance else None,
|
||||
},
|
||||
"finance": finance,
|
||||
"finance_chart_data": finance["finance_chart_data"] if finance else None,
|
||||
}
|
||||
ai_analysis = self._management_ai_analysis(report, actor) if include_ai else None
|
||||
report["ai_analysis"] = ai_analysis
|
||||
if ai_analysis and ai_analysis.get(AIResponseKey.OK):
|
||||
lines.append("- AI 管理分析:")
|
||||
answer = str(ai_analysis.get(AIResponseKey.ANSWER) or "")[:3000]
|
||||
lines.extend(f" {line}" for line in answer.splitlines() if line.strip())
|
||||
report[ReportResponseKey.CONTENT] = "\n".join(lines)
|
||||
return report
|
||||
|
||||
def _management_ai_analysis(self, report: dict[str, Any], actor: str) -> dict[str, Any]:
|
||||
from app.core.config import get_settings
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.ai_agent.skills import AISkillId
|
||||
|
||||
attention = dict(report["attention"])
|
||||
attention["finance"] = [
|
||||
{
|
||||
"project_code": item["project_code"],
|
||||
"display_code": item["display_code"],
|
||||
"project_name": item["project_name"],
|
||||
"stage": item["stage"],
|
||||
"overdue_receivable": item["overdue_receivable"],
|
||||
"pending_outflow": item["pending_outflow"],
|
||||
"funding_need_30d": item["funding_need"]["30"],
|
||||
"delivery_risk": item["delivery_risk"],
|
||||
}
|
||||
for item in attention.get("finance", [])[:10]
|
||||
]
|
||||
context = {
|
||||
"period_start": report[ReportResponseKey.PERIOD_START],
|
||||
"period_end": report[ReportResponseKey.PERIOD_END],
|
||||
"metrics": report[ReportResponseKey.METRICS],
|
||||
"attention": attention,
|
||||
}
|
||||
last_error: Exception | None = None
|
||||
max_attempts = max(1, min(get_settings().ai_analysis_max_attempts, 5))
|
||||
for _ in range(max_attempts):
|
||||
try:
|
||||
result = AIService(self.db).run_skill(
|
||||
AISkillId.PROJECT_LIFECYCLE_ANALYSIS,
|
||||
context=context,
|
||||
actor=actor,
|
||||
)
|
||||
if result.get(AIResponseKey.PROVIDER) == AIProviderName.NOOP:
|
||||
return {
|
||||
AIResponseKey.OK: False,
|
||||
AIResponseKey.ERROR: "AI provider is not configured",
|
||||
"attempts": 1,
|
||||
}
|
||||
return {AIResponseKey.OK: True, **result}
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
self.db.rollback()
|
||||
return {
|
||||
AIResponseKey.OK: False,
|
||||
AIResponseKey.ERROR: str(last_error) if last_error else "AI analysis failed",
|
||||
AIResponseKey.TYPE: type(last_error).__name__ if last_error else "AIUnavailable",
|
||||
"attempts": max_attempts,
|
||||
}
|
||||
|
||||
def _latest_completed_attendance_date(self) -> date:
|
||||
latest = self.db.execute(
|
||||
select(func.max(AttendanceRecord.work_date)).where(
|
||||
AttendanceRecord.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
AttendanceRecord.attendance_scope == "company",
|
||||
AttendanceRecord.work_date < date.today(),
|
||||
)
|
||||
).scalar()
|
||||
return latest or (date.today() - timedelta(days=1))
|
||||
|
||||
def _management_period(
|
||||
self, report_type: str, reference_date: date | None
|
||||
) -> tuple[date, date]:
|
||||
reference = reference_date or date.today()
|
||||
if report_type == ReportType.DAILY:
|
||||
target = self._latest_completed_attendance_date()
|
||||
return target, target
|
||||
end = reference - timedelta(days=reference.weekday() + 1)
|
||||
return end - timedelta(days=6), end
|
||||
|
||||
def _count_source_rows(self, model: type, *conditions: Any) -> int:
|
||||
return int(
|
||||
self.db.execute(select(func.count()).select_from(model).where(*conditions)).scalar()
|
||||
or 0
|
||||
)
|
||||
@@ -3,6 +3,7 @@ from typing import Any
|
||||
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.config import get_settings
|
||||
from app.modules.reports.constants import (
|
||||
LifecycleFilterKey,
|
||||
LifecycleResponseKey,
|
||||
@@ -63,6 +64,17 @@ class ReportLifecycleReportMixin:
|
||||
risk_stats,
|
||||
include_global_risk,
|
||||
)
|
||||
finance = (
|
||||
self.project_finance_needs_report(
|
||||
project_code=project_code,
|
||||
owner=owner,
|
||||
as_of=period_end,
|
||||
include_ai=False,
|
||||
actor=actor,
|
||||
)
|
||||
if get_settings().finance_needs_enabled
|
||||
else None
|
||||
)
|
||||
|
||||
metrics = {
|
||||
LifecycleSection.HEALTH: health,
|
||||
@@ -74,12 +86,15 @@ class ReportLifecycleReportMixin:
|
||||
LifecycleSection.SUPPLIERS: supplier_stats,
|
||||
LifecycleSection.ATTENDANCE: attendance_stats,
|
||||
LifecycleSection.RISKS: risk_stats,
|
||||
LifecycleSection.FINANCE: finance["summary"] if finance else {"status": "未启用"},
|
||||
}
|
||||
lines = self._lifecycle_lines(
|
||||
filters[LifecycleFilterKey.LABELS],
|
||||
metrics,
|
||||
recommendations,
|
||||
)
|
||||
if finance:
|
||||
lines.extend(finance[LifecycleResponseKey.LINES])
|
||||
report = {
|
||||
LifecycleResponseKey.TITLE: ReportTitle.PROJECT_LIFECYCLE,
|
||||
LifecycleResponseKey.FILTERS: filters[LifecycleFilterKey.LABELS],
|
||||
@@ -88,6 +103,9 @@ class ReportLifecycleReportMixin:
|
||||
LifecycleResponseKey.RECOMMENDATIONS: recommendations,
|
||||
LifecycleResponseKey.LINES: lines,
|
||||
LifecycleResponseKey.CONTENT: "\n".join(lines),
|
||||
"source_lifecycle": self.source_project_lifecycle_summary(project_code, owner),
|
||||
"finance": finance,
|
||||
"finance_chart_data": finance["finance_chart_data"] if finance else None,
|
||||
}
|
||||
if include_ai:
|
||||
report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor)
|
||||
|
||||
@@ -26,7 +26,16 @@ class ReportPushRunMixin:
|
||||
receive_id_type: str,
|
||||
actor: str,
|
||||
status: str = ReportPushStatus.PENDING,
|
||||
idempotency_key: str | None = None,
|
||||
) -> ReportPushRun:
|
||||
if idempotency_key:
|
||||
existing = self.db.execute(
|
||||
select(ReportPushRun).where(
|
||||
ReportPushRun.idempotency_key == idempotency_key
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
record = ReportPushRun(
|
||||
code=_next_code("PUSH"),
|
||||
report_type=report_type,
|
||||
@@ -36,6 +45,7 @@ class ReportPushRunMixin:
|
||||
status=status,
|
||||
actor=actor,
|
||||
queued_at=utc_now(),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
|
||||
@@ -3,7 +3,9 @@ from sqlalchemy.orm import Session
|
||||
from app.modules.reports.services.common import ReportQueryMixin
|
||||
from app.modules.reports.services.delivery import ReportDeliveryMixin
|
||||
from app.modules.reports.services.enterprise import ReportEnterpriseAnalyticsMixin
|
||||
from app.modules.reports.services.finance_needs import FinanceNeedsReportMixin
|
||||
from app.modules.reports.services.lifecycle import ReportLifecycleMixin
|
||||
from app.modules.reports.services.intasect_lifecycle import IntasectLifecycleReportMixin
|
||||
from app.modules.reports.services.push_runs import ReportPushRunMixin
|
||||
from app.modules.reports.services.summaries import ReportSummaryMixin
|
||||
from app.modules.reports.services.work_reports import ReportWorkReportMixin
|
||||
@@ -12,6 +14,8 @@ from app.modules.risk.services import RiskService
|
||||
|
||||
class ReportService(
|
||||
ReportDeliveryMixin,
|
||||
FinanceNeedsReportMixin,
|
||||
IntasectLifecycleReportMixin,
|
||||
ReportWorkReportMixin,
|
||||
ReportEnterpriseAnalyticsMixin,
|
||||
ReportLifecycleMixin,
|
||||
|
||||
Reference in New Issue
Block a user