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,182 @@
from io import BytesIO
from typing import Any
from PIL import Image, ImageDraw, ImageFont
WIDTH = 1200
HEIGHT = 720
FINANCE_HEIGHT = 1200
BACKGROUND = (248, 250, 252)
FOREGROUND = (17, 24, 39)
MUTED = (75, 85, 99)
GRID = (209, 213, 219)
SERIES = ((37, 99, 235), (245, 158, 11), (220, 38, 38))
def render_lifecycle_chart(chart_data: dict[str, Any]) -> bytes:
finance = chart_data.get("finance") or {}
image = Image.new("RGB", (WIDTH, FINANCE_HEIGHT if finance else HEIGHT), BACKGROUND)
draw = ImageDraw.Draw(image)
title_font = _font(36)
section_font = _font(28)
label_font = _font(22)
value_font = _font(22)
draw.text((60, 35), "Lifecycle Analysis", fill=FOREGROUND, font=title_font)
draw.text((60, 82), str(chart_data.get("period") or ""), fill=MUTED, font=label_font)
projects = chart_data.get("projects") or {}
draw.text((60, 135), "Project Portfolio", fill=FOREGROUND, font=section_font)
_stacked_bar(
draw,
y=190,
values=[
("Unarchived", int(projects.get("unarchived") or 0), SERIES[0]),
("Archived", int(projects.get("archived") or 0), GRID),
],
total=max(int(projects.get("total") or 0), 1),
label_font=label_font,
value_font=value_font,
)
risks = chart_data.get("risks") or {}
draw.text((60, 295), "Current Risks", fill=FOREGROUND, font=section_font)
_horizontal_bars(
draw,
y=345,
values=[
("Overdue milestones", int(risks.get("overdue_milestones") or 0)),
("Overdue tasks", int(risks.get("overdue_tasks") or 0)),
("Open events", int(risks.get("open_events") or 0)),
],
label_font=label_font,
value_font=value_font,
)
if finance:
cashflows = finance.get("cashflows") or {}
receivables = finance.get("receivables") or {}
draw.text((60, 640), "Project Finance Needs (CNY)", fill=FOREGROUND, font=section_font)
_horizontal_bars(
draw,
y=695,
values=[
("Confirmed inflow", int(cashflows.get("confirmed_inflow") or 0)),
("Confirmed outflow", int(cashflows.get("confirmed_outflow") or 0)),
("Pending outflow", int(cashflows.get("pending_outflow") or 0)),
("Overdue receivable", int(receivables.get("overdue") or 0)),
("Receivable due 30d", int(receivables.get("due_30d") or 0)),
],
label_font=label_font,
value_font=value_font,
)
top_projects = finance.get("top_projects") or []
draw.text((650, 640), "Top 5 Funding Needs", fill=FOREGROUND, font=section_font)
_horizontal_bars(
draw,
x=650,
y=695,
width=480,
values=[
(str(item.get("name") or "Project"), int(item.get("amount") or 0))
for item in top_projects[:5]
],
label_font=label_font,
value_font=value_font,
)
people = chart_data.get("people") or {}
draw.text((650, 295), "People Coverage", fill=FOREGROUND, font=section_font)
_horizontal_bars(
draw,
x=650,
y=345,
width=480,
values=[
("Active", int(people.get("active") or 0)),
("Needs attention", int(people.get("attention") or 0)),
("Attendance mapped", int(people.get("attendance_mapped") or 0)),
],
label_font=label_font,
value_font=value_font,
)
output = BytesIO()
image.save(output, format="PNG", optimize=True)
return output.getvalue()
def lifecycle_chart_alt(chart_data: dict[str, Any]) -> str:
projects = chart_data.get("projects") or {}
risks = chart_data.get("risks") or {}
people = chart_data.get("people") or {}
content = (
f"项目总数 {projects.get('total', 0)},未归档 {projects.get('unarchived', 0)}"
f"里程碑逾期 {risks.get('overdue_milestones', 0)},任务逾期 {risks.get('overdue_tasks', 0)}"
f"在职人员 {people.get('active', 0)},需关注人员 {people.get('attention', 0)}"
)
finance = chart_data.get("finance") or {}
if finance:
cashflows = finance.get("cashflows") or {}
content += (
f",已确认资金流入 {cashflows.get('confirmed_inflow', 0)} 元,"
f"待确认支出 {cashflows.get('pending_outflow', 0)}"
)
return content
def _stacked_bar(
draw: ImageDraw.ImageDraw,
y: int,
values: list[tuple[str, int, tuple[int, int, int]]],
total: int,
label_font: ImageFont.ImageFont,
value_font: ImageFont.ImageFont,
) -> None:
x = 60
width = 1070
cursor = x
for label, value, color in values:
segment = round(width * value / total)
if segment:
draw.rectangle((cursor, y, cursor + segment, y + 42), fill=color)
cursor += segment
label_x = x
for label, value, color in values:
draw.rectangle((label_x, y + 62, label_x + 18, y + 80), fill=color)
draw.text((label_x + 28, y + 56), f"{label} {value}", fill=FOREGROUND, font=value_font)
label_x += 280
def _horizontal_bars(
draw: ImageDraw.ImageDraw,
y: int,
values: list[tuple[str, int]],
label_font: ImageFont.ImageFont,
value_font: ImageFont.ImageFont,
x: int = 60,
width: int = 520,
) -> None:
maximum = max((value for _, value in values), default=0) or 1
for index, (label, value) in enumerate(values):
row_y = y + index * 90
draw.text((x, row_y), label, fill=MUTED, font=label_font)
bar_y = row_y + 34
draw.rectangle((x, bar_y, x + width, bar_y + 24), fill=GRID)
bar_width = round(width * value / maximum)
if bar_width:
draw.rectangle(
(x, bar_y, x + bar_width, bar_y + 24),
fill=SERIES[index % len(SERIES)],
)
label = f"{value:,}"
label_width = draw.textlength(label, font=value_font)
draw.text((x + width - label_width, row_y), label, fill=FOREGROUND, font=value_font)
def _font(size: int) -> ImageFont.ImageFont:
try:
return ImageFont.truetype("DejaVuSans.ttf", size=size)
except OSError:
return ImageFont.load_default(size=size)

View File

@@ -14,6 +14,10 @@ class ReportTitle(StrEnum):
WORK_WEEKLY = "经营周报"
PROJECT_LIFECYCLE = "项目全生命周期报告"
ENTERPRISE_ANALYTICS = "企业只读运营分析"
LIFECYCLE_DAILY = "项目与人员生命周期日报"
LIFECYCLE_WEEKLY = "项目与人员生命周期周报"
PERSONNEL_LIFECYCLE = "人员生命周期分析"
PROJECT_FINANCE_NEEDS = "项目资金需求分析"
class ReportStatus(StrEnum):
@@ -37,10 +41,12 @@ class ReportPushKey(StrEnum):
class ReportErrorDetail(StrEnum):
PUSH_RUN_NOT_FOUND = "Report push run not found"
INVALID_LIFECYCLE_REPORT_TYPE = "Lifecycle report type must be daily or weekly"
class LifecycleSection(StrEnum):
HEALTH = "health"
FINANCE = "finance"
PROJECTS = "projects"
TASKS = "tasks"
PROCUREMENTS = "procurements"

View File

@@ -0,0 +1,229 @@
from datetime import date
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.utils.time import utc_now
from app.modules.feishu.service import FeishuService
from app.modules.legacy_mysql.intasect import IntasectSyncService
from app.modules.reports.constants import ReportPushStatus, ReportType
from app.modules.reports.services import ReportService
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.workflows.models import WorkflowInstance
from app.modules.workflows.service import WorkflowService
class LifecyclePipelineService:
def __init__(self, db: Session):
self.db = db
self.workflows = WorkflowService(db)
def period_key(self, report_type: str, reference_date: date | None = None) -> str:
start, end = ReportService(self.db)._management_period(report_type, reference_date)
period_value = end.isoformat() if report_type == ReportType.DAILY else start.isoformat()
return f"{report_type}:{period_value}"
def find(self, period_key: str) -> WorkflowInstance | None:
return self.db.execute(
select(WorkflowInstance).where(
WorkflowInstance.workflow_type == WorkflowType.LIFECYCLE_REPORT,
WorkflowInstance.aggregate_type == "report_period",
WorkflowInstance.aggregate_id == period_key,
)
).scalar_one_or_none()
def prepare(
self,
report_type: str,
actor: str,
force: bool = False,
) -> tuple[WorkflowInstance, str, bool]:
period_key = self.period_key(report_type)
existing = self.find(period_key)
if existing is not None and existing.status == WorkflowStatus.COMPLETED and not force:
return existing, period_key, True
workflow = self.workflows.start_or_update(
workflow_type=WorkflowType.LIFECYCLE_REPORT,
aggregate_type="report_period",
aggregate_id=period_key,
status_value=WorkflowStatus.RUNNING,
action="queued",
actor=actor,
payload={"report_type": report_type, "period_key": period_key, "force": force},
)
return workflow, period_key, False
def run(
self,
report_type: str,
receive_id: str | None = None,
receive_id_type: str = "chat_id",
force: bool = False,
actor: str = ActorValue.SCHEDULER,
) -> dict[str, Any]:
workflow, period_key, deduplicated = self.prepare(report_type, actor, force)
if deduplicated:
return {
"workflow_code": workflow.code,
"period_key": period_key,
"deduplicated": True,
"status": workflow.status,
}
try:
workflow = self.workflows.start_or_update(
workflow_type=WorkflowType.LIFECYCLE_REPORT,
aggregate_type="report_period",
aggregate_id=period_key,
status_value=WorkflowStatus.RUNNING,
action="source_sync",
actor=actor,
payload={"report_type": report_type},
)
sync_result = IntasectSyncService(self.db).sync_all(
run_code=workflow.code,
force_full=False,
)
self.workflows.start_or_update(
workflow_type=WorkflowType.LIFECYCLE_REPORT,
aggregate_type="report_period",
aggregate_id=period_key,
status_value=WorkflowStatus.RUNNING,
action="analysis",
actor=actor,
payload={
"datasets": {name: result["processed"] for name, result in sync_result.items()}
},
)
report_service = ReportService(self.db)
report = report_service.management_lifecycle_report(
report_type=report_type,
actor=actor,
include_ai=True,
)
settings = get_settings()
target_receive_id = receive_id or settings.feishu_default_chat_id
ai_analysis = report.get("ai_analysis") or {}
if not ai_analysis.get("ok"):
notified = self._notify_ai_unavailable(
target_receive_id,
receive_id_type,
period_key,
actor,
)
failed = self.workflows.start_or_update(
workflow_type=WorkflowType.LIFECYCLE_REPORT,
aggregate_type="report_period",
aggregate_id=period_key,
status_value=WorkflowStatus.FAILED,
action="ai_unavailable",
actor=actor,
payload={
"ai_unavailable": True,
"notified": notified,
"error_type": ai_analysis.get("type") or "AIUnavailable",
},
)
return {
"workflow_code": failed.code,
"period_key": period_key,
"deduplicated": False,
"status": failed.status,
"ai_unavailable": True,
"notified": notified,
}
idempotency_key = period_key
if force:
idempotency_key = f"{period_key}:force:{utc_now():%Y%m%d%H%M%S%f}"
push_run = report_service.create_push_run(
report_type=report_type,
title=str(report["title"]),
receive_id=target_receive_id,
receive_id_type=receive_id_type,
actor=actor,
status=ReportPushStatus.PENDING,
idempotency_key=idempotency_key,
)
if push_run.status != ReportPushStatus.SUCCESS:
report_service.push_report(
report,
target_receive_id,
receive_id_type,
actor,
push_run_code=push_run.code,
)
completed = self.workflows.start_or_update(
workflow_type=WorkflowType.LIFECYCLE_REPORT,
aggregate_type="report_period",
aggregate_id=period_key,
status_value=WorkflowStatus.COMPLETED,
action="pushed",
actor=actor,
payload={"push_run_code": push_run.code, "idempotency_key": idempotency_key},
)
return {
"workflow_code": completed.code,
"period_key": period_key,
"push_run_code": push_run.code,
"deduplicated": False,
"status": completed.status,
}
except Exception as exc:
self.db.rollback()
self.workflows.start_or_update(
workflow_type=WorkflowType.LIFECYCLE_REPORT,
aggregate_type="report_period",
aggregate_id=period_key,
status_value=WorkflowStatus.FAILED,
action="failed",
actor=actor,
payload={"error": str(exc)[:2000]},
)
self._notify_failure(receive_id, receive_id_type, period_key, exc, actor)
raise
def _notify_ai_unavailable(
self,
receive_id: str | None,
receive_id_type: str,
period_key: str,
actor: str,
) -> bool:
settings = get_settings()
if (
not receive_id
or not settings.feishu_app_id
or not settings.feishu_app_secret
):
return False
FeishuService(self.db).send_text(
f"生命周期报告 {period_key}AI 当前不可用,本次分析报告未发送。请检查模型服务。",
receive_id,
receive_id_type,
actor,
)
return True
def _notify_failure(
self,
receive_id: str | None,
receive_id_type: str,
period_key: str,
error: Exception,
actor: str,
) -> None:
settings = get_settings()
target = receive_id or settings.feishu_default_chat_id
if not target or not settings.feishu_app_id or not settings.feishu_app_secret:
return
try:
FeishuService(self.db).send_text(
f"生命周期报告 {period_key} 执行失败:{type(error).__name__}",
target,
receive_id_type,
actor,
)
except Exception:
self.db.rollback()

View File

@@ -3,11 +3,16 @@ from datetime import date
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.background.task_queue import enqueue_daily_brief_push, enqueue_project_weekly_push
from app.core.background.task_queue import (
enqueue_daily_brief_push,
enqueue_lifecycle_report,
enqueue_project_weekly_push,
)
from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
from app.modules.reports.constants import ReportPushKey
from app.modules.reports.schemas import (
LifecycleRunRequest,
PushReportRequest,
ReportResponse,
WorkReportGenerateRequest,
@@ -77,6 +82,56 @@ def attendance_summary(
return ReportService(db).attendance_summary(work_date)
@router.get("/personnel-lifecycle")
def personnel_lifecycle_report(
department: str | None = None,
employee_code: str | None = None,
project_code: str | None = None,
period_start: date | None = None,
period_end: date | None = None,
db: Session = Depends(get_db),
) -> dict:
return ReportService(db).personnel_lifecycle_report(
department=department,
employee_code=employee_code,
project_code=project_code,
period_start=period_start,
period_end=period_end,
)
@router.get("/project-finance-needs")
def project_finance_needs_report(
project_code: str | None = None,
department: str | None = None,
as_of: date | None = None,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return ReportService(db).project_finance_needs_report(
project_code=project_code,
department=department,
as_of=as_of,
include_ai=False,
actor=principal.actor,
)
@router.post("/lifecycle/enqueue")
def enqueue_lifecycle(
payload: LifecycleRunRequest,
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
require_operations_enabled()
return enqueue_lifecycle_report(
report_type=payload.report_type,
receive_id=payload.receive_id,
receive_id_type=payload.receive_id_type,
force=payload.force,
actor=principal.actor,
)
@router.get("/push-runs")
def list_push_runs(
status: str | None = None,

View File

@@ -28,3 +28,10 @@ class WorkReportGenerateRequest(BaseModel):
period_end: date | None = None
persist: bool = False
actor: str = ActorValue.API
class LifecycleRunRequest(BaseModel):
report_type: ReportType = ReportType.DAILY
receive_id: str | None = None
receive_id_type: str = FeishuReceiveIdType.CHAT_ID
force: bool = False

View File

@@ -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,

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

View 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
)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,