feat: 添加生命周期报告和AI规则管理功能

- 在Dockerfile中添加pillow依赖包用于图像处理
- 实现生命周期报告调度任务,支持日报和周报两种类型
- 新增TASK_RUN_LIFECYCLE任务常量和相关配置选项
- 扩展AI Agent服务以支持用户规则,并在分析时应用规则
- 添加AI用户规则创建、更新和查询接口
- 增加项目生命周期和财务需求分析技能
- 扩展现有模型以支持更完整的业务数据字段
- 实现飞书图片上传功能用于报告展示
```
This commit is contained in:
2026-07-12 17:44:49 +08:00
parent bf309ecdf7
commit 9cf7c44393
45 changed files with 5036 additions and 49 deletions

View 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}"