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

@@ -21,7 +21,8 @@ RUN pip install --no-cache-dir \
redis==5.2.1 \ redis==5.2.1 \
celery==5.4.0 \ celery==5.4.0 \
cryptography==44.0.0 \ cryptography==44.0.0 \
pandas==2.2.3 pandas==2.2.3 \
pillow==11.0.0
COPY alembic.ini /app/alembic.ini COPY alembic.ini /app/alembic.ini
COPY alembic /app/alembic COPY alembic /app/alembic

View File

@@ -0,0 +1,358 @@
"""Add Intasect project and personnel lifecycle foundation.
Revision ID: 202607120001
Revises: 202607090001
Create Date: 2026-07-12
"""
from alembic import op
import sqlalchemy as sa
revision = "202607120001"
down_revision = "202607090001"
branch_labels = None
depends_on = None
def _indexes(table_name: str, columns: list[tuple[str, bool]]) -> None:
for column_name, unique in columns:
op.create_index(
op.f(f"ix_{table_name}_{column_name}"),
table_name,
[column_name],
unique=unique,
)
def _add_columns(table_name: str, columns: list[sa.Column]) -> None:
for column in columns:
op.add_column(table_name, column)
def upgrade() -> None:
op.create_table(
"employees",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("department_code", sa.String(length=64), nullable=True),
sa.Column("department_name", sa.String(length=128), nullable=True),
sa.Column("title", sa.String(length=128), nullable=True),
sa.Column("employment_status", sa.String(length=32), nullable=False),
sa.Column("hired_at", sa.DateTime(), nullable=True),
sa.Column("ding_user_id", sa.String(length=64), nullable=True),
sa.Column("source_system", sa.String(length=64), nullable=False),
sa.Column("external_id", sa.String(length=128), nullable=False),
sa.Column("source_created_at", sa.DateTime(), nullable=True),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_indexes(
"employees",
[
("code", True),
("name", False),
("department_code", False),
("department_name", False),
("employment_status", False),
("ding_user_id", False),
("external_id", False),
("source_updated_at", False),
("last_seen_at", False),
("is_active", False),
],
)
op.create_table(
"project_members",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("project_code", sa.String(length=64), nullable=False),
sa.Column("employee_code", sa.String(length=64), nullable=False),
sa.Column("role_name", sa.String(length=128), nullable=True),
sa.Column("is_leader", sa.Boolean(), nullable=False),
sa.Column("is_resident", sa.Boolean(), nullable=False),
sa.Column("work_mode", sa.Text(), nullable=True),
sa.Column("planned_days", sa.Integer(), nullable=True),
sa.Column("workload_percent", sa.Integer(), nullable=True),
sa.Column("source_system", sa.String(length=64), nullable=False),
sa.Column("external_id", sa.String(length=128), nullable=False),
sa.Column("source_created_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_indexes(
"project_members",
[
("code", True),
("project_code", False),
("employee_code", False),
("role_name", False),
("is_leader", False),
("external_id", False),
("last_seen_at", False),
("is_active", False),
],
)
op.create_table(
"project_milestones",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=128), nullable=False),
sa.Column("project_code", sa.String(length=64), nullable=False),
sa.Column("source_stage_id", sa.String(length=64), nullable=False),
sa.Column("stage_name", sa.String(length=128), nullable=False),
sa.Column("plan_start", sa.Date(), nullable=True),
sa.Column("plan_end", sa.Date(), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("is_overdue", sa.Boolean(), nullable=False),
sa.Column("activity_total", sa.Integer(), nullable=False),
sa.Column("activity_completed", sa.Integer(), nullable=False),
sa.Column("source_system", sa.String(length=64), nullable=False),
sa.Column("external_id", sa.String(length=128), nullable=False),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_indexes(
"project_milestones",
[
("code", True),
("project_code", False),
("source_stage_id", False),
("stage_name", False),
("plan_end", False),
("status", False),
("is_overdue", False),
("external_id", False),
("source_updated_at", False),
("last_seen_at", False),
("is_active", False),
],
)
op.create_table(
"source_sync_cursors",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("dataset", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("watermark_at", sa.DateTime(), nullable=True),
sa.Column("watermark_key", sa.String(length=128), nullable=True),
sa.Column("last_run_code", sa.String(length=64), nullable=True),
sa.Column("last_success_at", sa.DateTime(), nullable=True),
sa.Column("processed_count", sa.Integer(), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_indexes(
"source_sync_cursors",
[
("dataset", True),
("status", False),
("last_run_code", False),
("last_success_at", False),
],
)
_add_columns(
"projects",
[
sa.Column("display_code", sa.String(length=64), nullable=True),
sa.Column("department_code", sa.String(length=64), nullable=True),
sa.Column("department_name", sa.String(length=128), nullable=True),
sa.Column("owner_employee_code", sa.String(length=64), nullable=True),
sa.Column("source_stage", sa.String(length=32), nullable=True),
sa.Column("source_stage_label", sa.String(length=128), nullable=True),
sa.Column("source_archived", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("source_created_at", sa.DateTime(), nullable=True),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
],
)
_indexes(
"projects",
[
("display_code", False),
("department_code", False),
("department_name", False),
("owner_employee_code", False),
("source_stage", False),
("source_archived", False),
("source_updated_at", False),
("last_seen_at", False),
("is_active", False),
],
)
_add_columns(
"work_tasks",
[
sa.Column("employee_code", sa.String(length=64), nullable=True),
sa.Column("source_created_at", sa.DateTime(), nullable=True),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
],
)
_indexes(
"work_tasks",
[
("employee_code", False),
("source_updated_at", False),
("last_seen_at", False),
("is_active", False),
],
)
_add_columns(
"attendance_records",
[
sa.Column(
"attendance_scope", sa.String(length=32), nullable=False, server_default="company"
),
sa.Column("source_status", sa.String(length=64), nullable=True),
sa.Column("source_location_status", sa.String(length=64), nullable=True),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
],
)
_indexes(
"attendance_records",
[
("attendance_scope", False),
("source_status", False),
("source_updated_at", False),
("last_seen_at", False),
("is_active", False),
],
)
_add_columns(
"work_reports",
[
sa.Column("employee_code", sa.String(length=64), nullable=True),
sa.Column("external_id", sa.String(length=128), nullable=True),
sa.Column("is_late", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_draft", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
],
)
_indexes(
"work_reports",
[
("employee_code", False),
("external_id", False),
("is_late", False),
("is_draft", False),
("source_updated_at", False),
("last_seen_at", False),
("is_active", False),
],
)
op.add_column(
"report_push_runs", sa.Column("idempotency_key", sa.String(length=128), nullable=True)
)
op.create_index(
op.f("ix_report_push_runs_idempotency_key"),
"report_push_runs",
["idempotency_key"],
unique=True,
)
def downgrade() -> None:
op.drop_index(op.f("ix_report_push_runs_idempotency_key"), table_name="report_push_runs")
op.drop_column("report_push_runs", "idempotency_key")
index_columns = {
"work_reports": {
"employee_code",
"external_id",
"is_late",
"is_draft",
"source_updated_at",
"last_seen_at",
"is_active",
},
"attendance_records": {
"attendance_scope",
"source_status",
"source_updated_at",
"last_seen_at",
"is_active",
},
"work_tasks": {"employee_code", "source_updated_at", "last_seen_at", "is_active"},
"projects": {
"display_code",
"department_code",
"department_name",
"owner_employee_code",
"source_stage",
"source_archived",
"source_updated_at",
"last_seen_at",
"is_active",
},
}
for table_name, columns in {
"work_reports": [
"employee_code",
"external_id",
"is_late",
"is_draft",
"source_updated_at",
"last_seen_at",
"is_active",
],
"attendance_records": [
"attendance_scope",
"source_status",
"source_location_status",
"source_updated_at",
"last_seen_at",
"is_active",
],
"work_tasks": [
"employee_code",
"source_created_at",
"source_updated_at",
"last_seen_at",
"is_active",
],
"projects": [
"display_code",
"department_code",
"department_name",
"owner_employee_code",
"source_stage",
"source_stage_label",
"source_archived",
"source_created_at",
"source_updated_at",
"last_seen_at",
"is_active",
],
}.items():
for column_name in reversed(columns):
if column_name in index_columns[table_name]:
op.drop_index(op.f(f"ix_{table_name}_{column_name}"), table_name=table_name)
op.drop_column(table_name, column_name)
for table_name in ["source_sync_cursors", "project_milestones", "project_members", "employees"]:
op.drop_table(table_name)

View File

@@ -0,0 +1,118 @@
"""Add project contracts and normalized cash flows.
Revision ID: 202607120002
Revises: 202607120001
Create Date: 2026-07-12
"""
from alembic import op
import sqlalchemy as sa
revision = "202607120002"
down_revision = "202607120001"
branch_labels = None
depends_on = None
def _index(table: str, column: str, unique: bool = False) -> None:
op.create_index(op.f(f"ix_{table}_{column}"), table, [column], unique=unique)
def upgrade() -> None:
op.add_column(
"projects", sa.Column("source_contract_amount", sa.Numeric(16, 2), nullable=True)
)
op.add_column(
"projects",
sa.Column("source_project_investment_amount", sa.Numeric(16, 2), nullable=True),
)
op.create_table(
"project_contracts",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(64), nullable=False),
sa.Column("project_code", sa.String(64), nullable=True),
sa.Column("contract_type_code", sa.String(32), nullable=True),
sa.Column("contract_type_label", sa.String(128), nullable=True),
sa.Column("amount", sa.Numeric(16, 2), nullable=False),
sa.Column("signed_date", sa.Date(), nullable=True),
sa.Column("start_date", sa.Date(), nullable=True),
sa.Column("end_date", sa.Date(), nullable=True),
sa.Column("invoice_type_code", sa.String(32), nullable=True),
sa.Column("data_quality_status", sa.String(32), nullable=False),
sa.Column("source_system", sa.String(64), nullable=False),
sa.Column("external_id", sa.String(128), nullable=False),
sa.Column("source_created_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
for column, unique in (
("code", True),
("project_code", False),
("contract_type_code", False),
("signed_date", False),
("data_quality_status", False),
("external_id", False),
("last_seen_at", False),
("is_active", False),
):
_index("project_contracts", column, unique)
op.create_table(
"project_cash_flows",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(64), nullable=False),
sa.Column("project_code", sa.String(64), nullable=True),
sa.Column("contract_code", sa.String(64), nullable=True),
sa.Column("flow_type", sa.String(32), nullable=False),
sa.Column("direction", sa.String(16), nullable=False),
sa.Column("category_code", sa.String(32), nullable=True),
sa.Column("category_label", sa.String(128), nullable=True),
sa.Column("planned_amount", sa.Numeric(16, 2), nullable=False),
sa.Column("actual_amount", sa.Numeric(16, 2), nullable=True),
sa.Column("planned_date", sa.Date(), nullable=True),
sa.Column("actual_date", sa.Date(), nullable=True),
sa.Column("payment_status", sa.String(32), nullable=True),
sa.Column("invoice_status", sa.String(32), nullable=True),
sa.Column("approval_status", sa.String(32), nullable=True),
sa.Column("confirmation_status", sa.String(32), nullable=True),
sa.Column("data_quality_status", sa.String(32), nullable=False),
sa.Column("source_system", sa.String(64), nullable=False),
sa.Column("external_id", sa.String(128), nullable=False),
sa.Column("source_created_at", sa.DateTime(), nullable=True),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
for column, unique in (
("code", True),
("project_code", False),
("contract_code", False),
("flow_type", False),
("direction", False),
("category_code", False),
("planned_date", False),
("actual_date", False),
("payment_status", False),
("approval_status", False),
("data_quality_status", False),
("external_id", False),
("source_updated_at", False),
("last_seen_at", False),
("is_active", False),
):
_index("project_cash_flows", column, unique)
def downgrade() -> None:
op.drop_table("project_cash_flows")
op.drop_table("project_contracts")
op.drop_column("projects", "source_project_investment_amount")
op.drop_column("projects", "source_contract_amount")

View File

@@ -38,6 +38,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
enqueue_event_dispatch, enqueue_event_dispatch,
enqueue_legacy_project_sync, enqueue_legacy_project_sync,
enqueue_legacy_task_sync, enqueue_legacy_task_sync,
enqueue_lifecycle_report,
enqueue_project_weekly_push, enqueue_project_weekly_push,
) )
from app.modules.observability.service import ObservabilityService from app.modules.observability.service import ObservabilityService
@@ -108,6 +109,24 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
dispatch = enqueue_legacy_task_sync(actor=ActorValue.SCHEDULER) dispatch = enqueue_legacy_task_sync(actor=ActorValue.SCHEDULER)
_set_state(app, "last_legacy_task_sync_dispatch", dispatch) _set_state(app, "last_legacy_task_sync_dispatch", dispatch)
def run_daily_lifecycle() -> None:
dispatch = enqueue_lifecycle_report(
report_type="daily",
receive_id=settings.feishu_default_chat_id,
receive_id_type=FeishuReceiveIdType.CHAT_ID,
actor=ActorValue.SCHEDULER,
)
_set_state(app, "last_daily_lifecycle_dispatch", dispatch)
def run_weekly_lifecycle() -> None:
dispatch = enqueue_lifecycle_report(
report_type="weekly",
receive_id=settings.feishu_default_chat_id,
receive_id_type=FeishuReceiveIdType.CHAT_ID,
actor=ActorValue.SCHEDULER,
)
_set_state(app, "last_weekly_lifecycle_dispatch", dispatch)
def run_event_dispatch() -> None: def run_event_dispatch() -> None:
dispatch = enqueue_event_dispatch( dispatch = enqueue_event_dispatch(
limit=settings.event_dispatch_batch_size, limit=settings.event_dispatch_batch_size,
@@ -127,6 +146,25 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
finally: finally:
db.close() db.close()
if settings.lifecycle_pipeline_enabled:
scheduler.add_job(
run_daily_lifecycle,
trigger="cron",
hour=settings.daily_brief_cron_hour,
minute=settings.daily_brief_cron_minute,
id="daily_lifecycle_pipeline",
replace_existing=True,
)
scheduler.add_job(
run_weekly_lifecycle,
trigger="cron",
day_of_week=settings.weekly_project_report_day_of_week,
hour=settings.weekly_project_report_cron_hour,
minute=settings.weekly_project_report_cron_minute,
id="weekly_lifecycle_pipeline",
replace_existing=True,
)
else:
scheduler.add_job( scheduler.add_job(
run_daily_brief, run_daily_brief,
trigger="cron", trigger="cron",
@@ -159,7 +197,11 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
id="scheduler_heartbeat", id="scheduler_heartbeat",
replace_existing=True, replace_existing=True,
) )
if settings.legacy_sync_enabled and settings.legacy_project_query: if (
not settings.lifecycle_pipeline_enabled
and settings.legacy_sync_enabled
and settings.legacy_project_query
):
scheduler.add_job( scheduler.add_job(
run_legacy_project_sync, run_legacy_project_sync,
trigger="cron", trigger="cron",
@@ -168,7 +210,11 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
id="legacy_project_sync", id="legacy_project_sync",
replace_existing=True, replace_existing=True,
) )
if settings.legacy_sync_enabled and settings.legacy_task_query: if (
not settings.lifecycle_pipeline_enabled
and settings.legacy_sync_enabled
and settings.legacy_task_query
):
scheduler.add_job( scheduler.add_job(
run_legacy_task_sync, run_legacy_task_sync,
trigger="cron", trigger="cron",

View File

@@ -5,10 +5,12 @@ from app.core.background.task_queue.constants import (
TASK_PUSH_PROJECT_WEEKLY, TASK_PUSH_PROJECT_WEEKLY,
TASK_SYNC_LEGACY_PROJECTS, TASK_SYNC_LEGACY_PROJECTS,
TASK_SYNC_LEGACY_TASKS, TASK_SYNC_LEGACY_TASKS,
TASK_RUN_LIFECYCLE,
) )
from app.core.background.task_queue.dispatcher import dispatch_task from app.core.background.task_queue.dispatcher import dispatch_task
from app.core.background.task_queue.events import enqueue_event_dispatch from app.core.background.task_queue.events import enqueue_event_dispatch
from app.core.background.task_queue.legacy import enqueue_legacy_project_sync, enqueue_legacy_task_sync from app.core.background.task_queue.legacy import enqueue_legacy_project_sync, enqueue_legacy_task_sync
from app.core.background.task_queue.lifecycle import enqueue_lifecycle_report
from app.core.background.task_queue.reports import enqueue_daily_brief_push, enqueue_project_weekly_push from app.core.background.task_queue.reports import enqueue_daily_brief_push, enqueue_project_weekly_push
from app.core.background.task_queue.risk import enqueue_risk_event_generation from app.core.background.task_queue.risk import enqueue_risk_event_generation
@@ -20,11 +22,13 @@ __all__ = [
"TASK_PUSH_PROJECT_WEEKLY", "TASK_PUSH_PROJECT_WEEKLY",
"TASK_SYNC_LEGACY_PROJECTS", "TASK_SYNC_LEGACY_PROJECTS",
"TASK_SYNC_LEGACY_TASKS", "TASK_SYNC_LEGACY_TASKS",
"TASK_RUN_LIFECYCLE",
"dispatch_task", "dispatch_task",
"enqueue_daily_brief_push", "enqueue_daily_brief_push",
"enqueue_event_dispatch", "enqueue_event_dispatch",
"enqueue_legacy_project_sync", "enqueue_legacy_project_sync",
"enqueue_legacy_task_sync", "enqueue_legacy_task_sync",
"enqueue_lifecycle_report",
"enqueue_project_weekly_push", "enqueue_project_weekly_push",
"enqueue_risk_event_generation", "enqueue_risk_event_generation",
] ]

View File

@@ -4,3 +4,4 @@ TASK_GENERATE_RISK_EVENTS = "risks.generate_events"
TASK_SYNC_LEGACY_PROJECTS = "legacy.sync_projects" TASK_SYNC_LEGACY_PROJECTS = "legacy.sync_projects"
TASK_SYNC_LEGACY_TASKS = "legacy.sync_tasks" TASK_SYNC_LEGACY_TASKS = "legacy.sync_tasks"
TASK_DISPATCH_PENDING_EVENTS = "events.dispatch_pending" TASK_DISPATCH_PENDING_EVENTS = "events.dispatch_pending"
TASK_RUN_LIFECYCLE = "reports.run_lifecycle"

View File

@@ -0,0 +1,50 @@
from typing import Any
from app.core.background.task_queue.constants import TASK_RUN_LIFECYCLE
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.database import SessionLocal
from app.modules.reports.lifecycle_pipeline import LifecyclePipelineService
def enqueue_lifecycle_report(
report_type: str,
receive_id: str | None = None,
receive_id_type: str = "chat_id",
force: bool = False,
actor: str = ActorValue.SCHEDULER,
) -> dict[str, Any]:
db = SessionLocal()
try:
service = LifecyclePipelineService(db)
workflow, period_key, deduplicated = service.prepare(report_type, actor, force)
if deduplicated:
return {
"workflow_code": workflow.code,
"period_key": period_key,
"deduplicated": True,
"status": workflow.status,
}
if get_settings().task_queue_enabled:
from app.tasks import celery_app
result = celery_app.signature(
TASK_RUN_LIFECYCLE,
kwargs={
"report_type": report_type,
"receive_id": receive_id,
"receive_id_type": receive_id_type,
"force": force,
"actor": actor,
},
).apply_async()
return {
"workflow_code": workflow.code,
"period_key": period_key,
"task_id": result.id,
"deduplicated": False,
"status": "queued",
}
return service.run(report_type, receive_id, receive_id_type, force, actor)
finally:
db.close()

View File

@@ -69,6 +69,8 @@ class Settings(BaseSettings):
scheduler_enabled: bool = False scheduler_enabled: bool = False
task_queue_enabled: bool = False task_queue_enabled: bool = False
task_queue_always_eager: bool = False task_queue_always_eager: bool = False
lifecycle_pipeline_enabled: bool = False
finance_needs_enabled: bool = False
legacy_sync_enabled: bool = False legacy_sync_enabled: bool = False
celery_result_backend_url: str | None = None celery_result_backend_url: str | None = None
daily_brief_cron_hour: int = 9 daily_brief_cron_hour: int = 9
@@ -90,6 +92,7 @@ class Settings(BaseSettings):
ai_memory_enabled: bool = True ai_memory_enabled: bool = True
ai_memory_auto_write_enabled: bool = True ai_memory_auto_write_enabled: bool = True
ai_memory_recall_limit: int = 5 ai_memory_recall_limit: int = 5
ai_analysis_max_attempts: int = 3
ai_memory_forbidden_keys: list[str] = Field( ai_memory_forbidden_keys: list[str] = Field(
default_factory=lambda: [ default_factory=lambda: [
"authorization", "authorization",

View File

@@ -59,6 +59,7 @@ class AIContextKey(StrEnum):
USER_PROMPT = "user_prompt" USER_PROMPT = "user_prompt"
REQUEST_CONTEXT = "request_context" REQUEST_CONTEXT = "request_context"
ASSISTANT_ANSWER = "assistant_answer" ASSISTANT_ANSWER = "assistant_answer"
USER_RULES = "user_rules"
class AIMemoryMode(StrEnum): class AIMemoryMode(StrEnum):

View File

@@ -50,10 +50,18 @@ class AIService:
original_context = context or {} original_context = context or {}
adapter_context = dict(original_context) adapter_context = dict(original_context)
memory_service = AIMemoryService(self.db) memory_service = AIMemoryService(self.db)
memory_scope = _memory_scope(original_context)
memory_subject = _memory_subject(original_context)
user_rules = memory_service.active_rules(
scope=memory_scope,
subject=memory_subject,
)
if user_rules:
adapter_context[AIContextKey.USER_RULES] = user_rules
local_memory = memory_service.recall( local_memory = memory_service.recall(
query=prompt, query=prompt,
scope=_memory_scope(original_context), scope=memory_scope,
subject=_memory_subject(original_context), subject=memory_subject,
actor=actor, actor=actor,
) )
if local_memory: if local_memory:

View File

@@ -13,6 +13,7 @@ class AISkillId(StrEnum):
DRAFT_POLICY = "draft_policy" DRAFT_POLICY = "draft_policy"
INVESTMENT_RESEARCH = "investment_research" INVESTMENT_RESEARCH = "investment_research"
PROJECT_LIFECYCLE_ANALYSIS = "project_lifecycle_analysis" PROJECT_LIFECYCLE_ANALYSIS = "project_lifecycle_analysis"
PROJECT_FINANCE_NEEDS_ANALYSIS = "project_finance_needs_analysis"
HERMES_MEMORY_RECALL = "hermes_memory_recall" HERMES_MEMORY_RECALL = "hermes_memory_recall"
HERMES_MEMORY_WRITE = "hermes_memory_write" HERMES_MEMORY_WRITE = "hermes_memory_write"
@@ -50,10 +51,23 @@ INVESTMENT_RESEARCH_INSTRUCTIONS = (
"Risk preference: {risk_preference}." "Risk preference: {risk_preference}."
) )
PROJECT_LIFECYCLE_ANALYSIS_INSTRUCTIONS = ( PROJECT_LIFECYCLE_ANALYSIS_INSTRUCTIONS = (
"请基于项目全生命周期统计,输出中文管理分析。" "你是严谨的企业项目与人员生命周期管理分析"
"包括总体判断、前三个风险、接下来一周优先动作" "只能依据上下文中的指标、异常清单和用户规则进行分析,不得编造事实"
"请输出中文管理层分析,并明确引用关键数字作为证据。"
"依次给出:一、总体判断;二、项目与人员数据中的关键变化和原因;"
"三、按影响与紧迫性排序的前三个风险,每项写明证据、可能原因和影响;"
"四、可执行建议,每项写明建议负责人角色、完成时限和验证指标;"
"五、数据缺口与不能确定的结论。"
"用户规则优先影响分析口径和表达方式,但不得绕过安全、权限和审计限制。"
"不要审批付款、不要最终定绩效、不要下投资交易指令。" "不要审批付款、不要最终定绩效、不要下投资交易指令。"
) )
PROJECT_FINANCE_NEEDS_ANALYSIS_INSTRUCTIONS = (
"你是严谨的企业项目资金需求分析师。只能依据上下文中的确定性金额、项目状态和"
"用户规则分析,不得修改金额或编造现金余额。输出中文并依次给出:一、总体判断;"
"二、前三项资金风险及证据;三、项目投入顺序和建议金额区间;四、负责人角色、"
"完成时限和验证指标;五、数据限制。所有安排必须标注需要人工确认。"
"不得把项目资金安排需求称为公司融资缺口,不得审批付款、融资或投资交易。"
)
HERMES_MEMORY_RECALL_INSTRUCTIONS = ( HERMES_MEMORY_RECALL_INSTRUCTIONS = (
"Retrieve concise long-term memory, preferences, prior decisions, and relevant " "Retrieve concise long-term memory, preferences, prior decisions, and relevant "
"business context for this request. Return only information useful to answer it." "business context for this request. Return only information useful to answer it."
@@ -79,6 +93,11 @@ AI_SKILLS: dict[AISkillId, AISkill] = {
source=AISkillSource.REPORTS_LIFECYCLE, source=AISkillSource.REPORTS_LIFECYCLE,
instruction_template=PROJECT_LIFECYCLE_ANALYSIS_INSTRUCTIONS, instruction_template=PROJECT_LIFECYCLE_ANALYSIS_INSTRUCTIONS,
), ),
AISkillId.PROJECT_FINANCE_NEEDS_ANALYSIS: AISkill(
skill_id=AISkillId.PROJECT_FINANCE_NEEDS_ANALYSIS,
source=AISkillSource.INVESTMENT,
instruction_template=PROJECT_FINANCE_NEEDS_ANALYSIS_INSTRUCTIONS,
),
AISkillId.HERMES_MEMORY_RECALL: AISkill( AISkillId.HERMES_MEMORY_RECALL: AISkill(
skill_id=AISkillId.HERMES_MEMORY_RECALL, skill_id=AISkillId.HERMES_MEMORY_RECALL,
source=AISkillSource.AI_MEMORY, source=AISkillSource.AI_MEMORY,

View File

@@ -18,6 +18,7 @@ class AIMemorySource(StrEnum):
AUTO = "auto" AUTO = "auto"
HERMES = "hermes" HERMES = "hermes"
API = "api" API = "api"
USER_RULE = "user_rule"
class AIMemoryResponseKey(StrEnum): class AIMemoryResponseKey(StrEnum):
@@ -53,3 +54,5 @@ AI_MEMORY_CODE_PREFIX = "MEM"
AI_MEMORY_MAX_CONTENT_LENGTH = 2000 AI_MEMORY_MAX_CONTENT_LENGTH = 2000
AI_MEMORY_MAX_SUMMARY_LENGTH = 500 AI_MEMORY_MAX_SUMMARY_LENGTH = 500
AI_MEMORY_MIN_AUTO_WRITE_LENGTH = 12 AI_MEMORY_MIN_AUTO_WRITE_LENGTH = 12
AI_USER_RULE_MAX_PRIORITY = 100
AI_USER_RULE_MIN_PRIORITY = 1

View File

@@ -2,9 +2,13 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
from app.modules.ai_memory.constants import AIMemoryResponseKey, AIMemoryStatus from app.modules.ai_memory.constants import AIMemoryResponseKey, AIMemoryStatus
from app.modules.ai_memory.schemas import AIMemoryRecallRequest from app.modules.ai_memory.schemas import (
AIMemoryRecallRequest,
AIUserRuleCreate,
AIUserRuleUpdate,
)
from app.modules.ai_memory.service import AIMemoryService from app.modules.ai_memory.service import AIMemoryService
router = APIRouter(dependencies=[Depends(require_api_key)]) router = APIRouter(dependencies=[Depends(require_api_key)])
@@ -42,3 +46,60 @@ def recall_memory(
actor=principal.actor, actor=principal.actor,
) )
return {AIMemoryResponseKey.ITEMS: items} return {AIMemoryResponseKey.ITEMS: items}
@router.get("/rules")
def list_rules(
scope: str | None = None,
subject: str | None = None,
status: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return {
AIMemoryResponseKey.ITEMS: AIMemoryService(db).list_rules(
scope=scope,
subject=subject,
status_filter=status,
limit=limit,
)
}
@router.post("/rules")
def create_rule(
payload: AIUserRuleCreate,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
require_operations_enabled()
return {
AIMemoryResponseKey.DATA: AIMemoryService(db).create_rule(
content=payload.content,
scope=payload.scope,
subject=payload.subject,
priority=payload.priority,
tags=payload.tags,
actor=principal.actor,
)
}
@router.patch("/rules/{code}")
def update_rule(
code: str,
payload: AIUserRuleUpdate,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
require_operations_enabled()
return {
AIMemoryResponseKey.DATA: AIMemoryService(db).update_rule(
code=code,
content=payload.content,
priority=payload.priority,
tags=payload.tags,
enabled=payload.enabled,
actor=principal.actor,
)
}

View File

@@ -27,3 +27,18 @@ class AIMemoryRead(BaseModel):
expires_at: str | None expires_at: str | None
created_at: str created_at: str
updated_at: str updated_at: str
class AIUserRuleCreate(BaseModel):
content: str = Field(..., min_length=1, max_length=2000)
scope: str = AIMemoryScope.GLOBAL
subject: str = Field(default="company", min_length=1, max_length=128)
priority: int = Field(default=50, ge=1, le=100)
tags: list[str] = Field(default_factory=list, max_length=20)
class AIUserRuleUpdate(BaseModel):
content: str | None = Field(default=None, min_length=1, max_length=2000)
priority: int | None = Field(default=None, ge=1, le=100)
tags: list[str] | None = Field(default=None, max_length=20)
enabled: bool | None = None

View File

@@ -1,5 +1,7 @@
from typing import Any from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -17,6 +19,8 @@ from app.modules.ai_memory.constants import (
AIMemorySource, AIMemorySource,
AIMemoryStatus, AIMemoryStatus,
AIMemoryText, AIMemoryText,
AI_USER_RULE_MAX_PRIORITY,
AI_USER_RULE_MIN_PRIORITY,
) )
from app.modules.ai_memory.models import AIMemoryEntry from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.audit.constants import ( from app.modules.audit.constants import (
@@ -164,6 +168,144 @@ class AIMemoryService:
) )
return record return record
def list_rules(
self,
scope: str | None = None,
subject: str | None = None,
status_filter: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
stmt = (
select(AIMemoryEntry)
.where(AIMemoryEntry.source == AIMemorySource.USER_RULE)
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.asc())
.limit(bounded_limit(limit))
)
if scope:
stmt = stmt.where(AIMemoryEntry.scope == scope)
if subject:
stmt = stmt.where(AIMemoryEntry.subject == subject)
if status_filter:
stmt = stmt.where(AIMemoryEntry.status == status_filter)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def active_rules(
self,
scope: str = AIMemoryScope.GLOBAL,
subject: str | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
stmt = (
select(AIMemoryEntry)
.where(
AIMemoryEntry.source == AIMemorySource.USER_RULE,
AIMemoryEntry.status == AIMemoryStatus.ACTIVE,
AIMemoryEntry.scope.in_({AIMemoryScope.GLOBAL, scope}),
)
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.asc())
.limit(min(bounded_limit(limit), 50))
)
if subject:
stmt = stmt.where(
or_(
AIMemoryEntry.scope == AIMemoryScope.GLOBAL,
AIMemoryEntry.subject == subject,
)
)
return [
{
"code": item.code,
"scope": item.scope,
"subject": item.subject,
"rule": item.content,
"priority": item.importance,
}
for item in self.db.execute(stmt).scalars()
]
def create_rule(
self,
content: str,
scope: str,
subject: str,
priority: int,
tags: list[str] | None,
actor: str,
) -> dict[str, Any]:
self._validate_rule(content, priority)
record = self._create_entry(
scope=scope,
subject=subject,
content=_truncate(content.strip(), AI_MEMORY_MAX_CONTENT_LENGTH),
summary=_truncate(content.strip(), AI_MEMORY_MAX_SUMMARY_LENGTH),
tags=["user-rule", *(tags or [])],
source=AIMemorySource.USER_RULE,
importance=priority,
status_value=AIMemoryStatus.ACTIVE,
actor=actor,
audit_action=AuditAction.AI_RULE_CREATE,
)
return serialize_model(record)
def update_rule(
self,
code: str,
content: str | None,
priority: int | None,
tags: list[str] | None,
enabled: bool | None,
actor: str,
) -> dict[str, Any]:
record = self.db.execute(
select(AIMemoryEntry).where(
AIMemoryEntry.code == code,
AIMemoryEntry.source == AIMemorySource.USER_RULE,
)
).scalar_one_or_none()
if record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AI rule not found")
if content is not None:
self._validate_rule(content, priority or record.importance)
record.content = _truncate(content.strip(), AI_MEMORY_MAX_CONTENT_LENGTH)
record.summary = _truncate(content.strip(), AI_MEMORY_MAX_SUMMARY_LENGTH)
if priority is not None:
self._validate_rule(record.content, priority)
record.importance = priority
if tags is not None:
record.tags = ["user-rule", *tags]
if enabled is not None:
record.status = AIMemoryStatus.ACTIVE if enabled else AIMemoryStatus.ARCHIVED
self.db.commit()
self.db.refresh(record)
self.audit.log(
AuditLogCreate(
actor=actor,
source=AuditSource.AI_MEMORY,
action=AuditAction.AI_RULE_UPDATE,
target_type=AuditTargetType.AI_MEMORY,
target_id=record.code,
risk_level=AuditRiskLevel.MEDIUM,
response_payload={
AIMemoryPayloadKey.CODE: record.code,
AIMemoryPayloadKey.STATUS: record.status,
AIMemoryPayloadKey.IMPORTANCE: record.importance,
},
)
)
return serialize_model(record)
def _validate_rule(self, content: str, priority: int) -> None:
if not AI_USER_RULE_MIN_PRIORITY <= priority <= AI_USER_RULE_MAX_PRIORITY:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="AI rule priority is out of range",
)
if _contains_forbidden_value(content, get_settings().ai_memory_forbidden_keys):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="AI rule contains secret-like content",
)
def count_by_status(self) -> dict[str, int]: def count_by_status(self) -> dict[str, int]:
rows = self.db.execute( rows = self.db.execute(
select(AIMemoryEntry.status, func.count()).group_by(AIMemoryEntry.status) select(AIMemoryEntry.status, func.count()).group_by(AIMemoryEntry.status)
@@ -181,9 +323,13 @@ class AIMemoryService:
importance: int, importance: int,
status_value: str, status_value: str,
actor: str, actor: str,
audit_action: str = AuditAction.AI_MEMORY_WRITE,
) -> AIMemoryEntry: ) -> AIMemoryEntry:
record = AIMemoryEntry( record = AIMemoryEntry(
code=f"{AI_MEMORY_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}", code=(
f"{AI_MEMORY_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}-"
f"{uuid4().hex[:8]}"
),
scope=scope, scope=scope,
subject=subject, subject=subject,
content=content, content=content,
@@ -201,7 +347,7 @@ class AIMemoryService:
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.AI_MEMORY, source=AuditSource.AI_MEMORY,
action=AuditAction.AI_MEMORY_WRITE, action=audit_action,
target_type=AuditTargetType.AI_MEMORY, target_type=AuditTargetType.AI_MEMORY,
target_id=record.code, target_id=record.code,
risk_level=AuditRiskLevel.LOW, risk_level=AuditRiskLevel.LOW,

View File

@@ -10,12 +10,15 @@ class AuditAction(StrEnum):
FEISHU_LONG_CONNECTION_EVENT = "long_connection_event" FEISHU_LONG_CONNECTION_EVENT = "long_connection_event"
FEISHU_SEND_TEXT = "send_text" FEISHU_SEND_TEXT = "send_text"
FEISHU_SEND_CARD = "send_card" FEISHU_SEND_CARD = "send_card"
FEISHU_UPLOAD_IMAGE = "upload_image"
LEGACY_SYNC_PROJECTS = "sync_projects" LEGACY_SYNC_PROJECTS = "sync_projects"
LEGACY_SYNC_TASKS = "sync_tasks" LEGACY_SYNC_TASKS = "sync_tasks"
RISK_EVENT_ACTION = "risk_event_action" RISK_EVENT_ACTION = "risk_event_action"
REPORT_PUSH = "report_push" REPORT_PUSH = "report_push"
AI_MEMORY_RECALL = "ai.memory_recall" AI_MEMORY_RECALL = "ai.memory_recall"
AI_MEMORY_WRITE = "ai.memory_write" AI_MEMORY_WRITE = "ai.memory_write"
AI_RULE_CREATE = "ai.rule_create"
AI_RULE_UPDATE = "ai.rule_update"
ENTERPRISE_ANALYTICS = "enterprise_analytics" ENTERPRISE_ANALYTICS = "enterprise_analytics"
EVENT_DISPATCH = "event.dispatch" EVENT_DISPATCH = "event.dispatch"
HEARTBEAT = "heartbeat" HEARTBEAT = "heartbeat"

View File

@@ -54,6 +54,25 @@ class SourceSystem(StrEnum):
LEGACY_MYSQL = "legacy_mysql" LEGACY_MYSQL = "legacy_mysql"
class CashFlowDirection(StrEnum):
INFLOW = "inflow"
OUTFLOW = "outflow"
class CashFlowType(StrEnum):
CONTRACT_RECEIVABLE = "contract_receivable"
PROJECT_FUND = "project_fund"
class DataQualityStatus(StrEnum):
VALID = "valid"
ORPHAN_CONTRACT = "orphan_contract"
ORPHAN_PROJECT = "orphan_project"
DELETED_PROJECT = "deleted_project"
PAID_AMOUNT_MISSING = "paid_amount_missing"
STATUS_AMOUNT_MISMATCH = "status_amount_mismatch"
class AccountType(StrEnum): class AccountType(StrEnum):
BANK = "bank" BANK = "bank"

View File

@@ -2,6 +2,14 @@ from app.modules.business.models.attendance import AttendanceRecord
from app.modules.business.models.finance import Expense, FundAccount, Procurement from app.modules.business.models.finance import Expense, FundAccount, Procurement
from app.modules.business.models.governance import PerformanceMetric, Policy, Standard from app.modules.business.models.governance import PerformanceMetric, Policy, Standard
from app.modules.business.models.legacy import LegacySyncRun from app.modules.business.models.legacy import LegacySyncRun
from app.modules.business.models.lifecycle import (
Employee,
ProjectCashFlow,
ProjectContract,
ProjectMember,
ProjectMilestone,
SourceSyncCursor,
)
from app.modules.business.models.projects import Project, WorkTask from app.modules.business.models.projects import Project, WorkTask
from app.modules.business.models.reports import ReportPushRun, WorkReport from app.modules.business.models.reports import ReportPushRun, WorkReport
from app.modules.business.models.risks import RiskEvent, RiskEventAction from app.modules.business.models.risks import RiskEvent, RiskEventAction
@@ -11,17 +19,23 @@ from app.modules.business.models.suppliers import Supplier
__all__ = [ __all__ = [
"AttendanceRecord", "AttendanceRecord",
"Expense", "Expense",
"Employee",
"FundAccount", "FundAccount",
"LegacySyncRun", "LegacySyncRun",
"PerformanceMetric", "PerformanceMetric",
"Policy", "Policy",
"Procurement", "Procurement",
"Project", "Project",
"ProjectCashFlow",
"ProjectContract",
"ProjectMember",
"ProjectMilestone",
"ReportPushRun", "ReportPushRun",
"RiskEvent", "RiskEvent",
"RiskEventAction", "RiskEventAction",
"Standard", "Standard",
"Supplier", "Supplier",
"SourceSyncCursor",
"WorkReport", "WorkReport",
"WorkTask", "WorkTask",
] ]

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import Date, DateTime, Integer, String, Text from sqlalchemy import Boolean, Date, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
@@ -28,3 +28,9 @@ class AttendanceRecord(Base, TimestampMixin):
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL)
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True) note: Mapped[str | None] = mapped_column(Text, nullable=True)
attendance_scope: Mapped[str] = mapped_column(String(32), default="company", index=True)
source_status: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_location_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)

View File

@@ -0,0 +1,135 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, Integer, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.modules.business.constants import SourceSystem, StatusValue
from app.modules.business.models.common import TimestampMixin
class Employee(Base, TimestampMixin):
__tablename__ = "employees"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
name: Mapped[str] = mapped_column(String(128), index=True)
department_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
department_name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
employment_status: Mapped[str] = mapped_column(
String(32), default=StatusValue.UNKNOWN, index=True
)
hired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
ding_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.LEGACY_MYSQL)
external_id: Mapped[str] = mapped_column(String(128), index=True)
source_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class ProjectMember(Base, TimestampMixin):
__tablename__ = "project_members"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
project_code: Mapped[str] = mapped_column(String(64), index=True)
employee_code: Mapped[str] = mapped_column(String(64), index=True)
role_name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
is_leader: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_resident: Mapped[bool] = mapped_column(Boolean, default=False)
work_mode: Mapped[str | None] = mapped_column(Text, nullable=True)
planned_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
workload_percent: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.LEGACY_MYSQL)
external_id: Mapped[str] = mapped_column(String(128), index=True)
source_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class ProjectMilestone(Base, TimestampMixin):
__tablename__ = "project_milestones"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(128), unique=True, index=True)
project_code: Mapped[str] = mapped_column(String(64), index=True)
source_stage_id: Mapped[str] = mapped_column(String(64), index=True)
stage_name: Mapped[str] = mapped_column(String(128), index=True)
plan_start: Mapped[date | None] = mapped_column(Date, nullable=True)
plan_end: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), default=StatusValue.UNKNOWN, index=True)
is_overdue: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
activity_total: Mapped[int] = mapped_column(Integer, default=0)
activity_completed: Mapped[int] = mapped_column(Integer, default=0)
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.LEGACY_MYSQL)
external_id: Mapped[str] = mapped_column(String(128), index=True)
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class ProjectContract(Base, TimestampMixin):
__tablename__ = "project_contracts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
contract_type_code: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
contract_type_label: Mapped[str | None] = mapped_column(String(128), nullable=True)
amount: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
signed_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
invoice_type_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
data_quality_status: Mapped[str] = mapped_column(String(32), index=True)
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.LEGACY_MYSQL)
external_id: Mapped[str] = mapped_column(String(128), index=True)
source_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class ProjectCashFlow(Base, TimestampMixin):
__tablename__ = "project_cash_flows"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
contract_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
flow_type: Mapped[str] = mapped_column(String(32), index=True)
direction: Mapped[str] = mapped_column(String(16), index=True)
category_code: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
category_label: Mapped[str | None] = mapped_column(String(128), nullable=True)
planned_amount: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
actual_amount: Mapped[Decimal | None] = mapped_column(Numeric(16, 2), nullable=True)
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
payment_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
invoice_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
approval_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
confirmation_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
data_quality_status: Mapped[str] = mapped_column(String(32), index=True)
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.LEGACY_MYSQL)
external_id: Mapped[str] = mapped_column(String(128), index=True)
source_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class SourceSyncCursor(Base, TimestampMixin):
__tablename__ = "source_sync_cursors"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dataset: Mapped[str] = mapped_column(String(64), unique=True, index=True)
status: Mapped[str] = mapped_column(String(32), default=StatusValue.PENDING, index=True)
watermark_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
watermark_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
last_run_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
last_success_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
processed_count: Mapped[int] = mapped_column(Integer, default=0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -1,7 +1,7 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import Date, DateTime, Integer, Numeric, String, Text from sqlalchemy import Boolean, Date, DateTime, Integer, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
@@ -27,11 +27,28 @@ class Project(Base, TimestampMixin):
risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True)
budget_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) budget_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
source_contract_amount: Mapped[Decimal | None] = mapped_column(
Numeric(16, 2), nullable=True
)
source_project_investment_amount: Mapped[Decimal | None] = mapped_column(
Numeric(16, 2), nullable=True
)
start_date: Mapped[date | None] = mapped_column(Date, nullable=True) start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL)
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
display_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
department_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
department_name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
owner_employee_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_stage: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
source_stage_label: Mapped[str | None] = mapped_column(String(128), nullable=True)
source_archived: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
source_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class WorkTask(Base, TimestampMixin): class WorkTask(Base, TimestampMixin):
__tablename__ = "work_tasks" __tablename__ = "work_tasks"
@@ -49,3 +66,8 @@ class WorkTask(Base, TimestampMixin):
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL)
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
employee_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import JSON, Date, DateTime, Integer, String, Text from sqlalchemy import Boolean, JSON, Date, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue from app.core.constants import ActorValue
@@ -30,6 +30,13 @@ class WorkReport(Base, TimestampMixin):
risk_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True) risk_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True)
status: Mapped[str] = mapped_column(String(64), default=StatusValue.GENERATED, index=True) status: Mapped[str] = mapped_column(String(64), default=StatusValue.GENERATED, index=True)
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL)
employee_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
is_late: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_draft: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class ReportPushRun(Base, TimestampMixin): class ReportPushRun(Base, TimestampMixin):
__tablename__ = "report_push_runs" __tablename__ = "report_push_runs"
@@ -47,3 +54,4 @@ class ReportPushRun(Base, TimestampMixin):
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
queued_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) queued_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True, index=True)

View File

@@ -11,6 +11,7 @@ from app.modules.feishu.constants import (
FEISHU_AUTH_MISSING, FEISHU_AUTH_MISSING,
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS, FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS,
FEISHU_MESSAGE_PATH, FEISHU_MESSAGE_PATH,
FEISHU_IMAGE_PATH,
FEISHU_RECEIVE_ID_MISSING, FEISHU_RECEIVE_ID_MISSING,
FEISHU_SUCCESS_CODE, FEISHU_SUCCESS_CODE,
FEISHU_TENANT_TOKEN_PATH, FEISHU_TENANT_TOKEN_PATH,
@@ -103,6 +104,32 @@ class FeishuClient:
{FeishuPayloadKey.TEXT: text}, {FeishuPayloadKey.TEXT: text},
) )
def upload_image(
self,
image: bytes,
filename: str = "lifecycle-report.png",
) -> dict[str, Any]:
token = self._get_tenant_access_token()
url = f"{self.settings.feishu_base_url}{FEISHU_IMAGE_PATH}"
headers = {HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(token=token)}
with httpx.Client(timeout=30) as client:
response = client.post(
url,
headers=headers,
data={FeishuPayloadKey.IMAGE_TYPE: FeishuPayloadKey.MESSAGE},
files={
FeishuPayloadKey.IMAGE: (filename, image, "image/png"),
},
)
response.raise_for_status()
data = response.json()
if data.get(FeishuPayloadKey.CODE) != FEISHU_SUCCESS_CODE:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={FeishuPayloadKey.FEISHU_ERROR: data},
)
return data
def send_card( def send_card(
self, self,
card: dict[str, Any], card: dict[str, Any],

View File

@@ -2,6 +2,7 @@ import json
import re import re
from typing import Any from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue from app.core.constants import ActorValue
@@ -9,6 +10,8 @@ from app.core.config import get_settings
from app.modules.ai_agent.service import AIService from app.modules.ai_agent.service import AIService
from app.modules.ai_agent.constants import AIResponseKey from app.modules.ai_agent.constants import AIResponseKey
from app.modules.audit.constants import AuditSource from app.modules.audit.constants import AuditSource
from app.modules.ai_memory.constants import AIMemoryStatus
from app.modules.ai_memory.service import AIMemoryService
from app.modules.feishu.constants import ( from app.modules.feishu.constants import (
FEISHU_AI_REPLY_TITLE, FEISHU_AI_REPLY_TITLE,
FEISHU_MENTION_PATTERN, FEISHU_MENTION_PATTERN,
@@ -20,6 +23,7 @@ from app.modules.feishu.constants import (
FeishuReplyType, FeishuReplyType,
) )
from app.modules.reports.constants import ReportResponseKey from app.modules.reports.constants import ReportResponseKey
from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart
from app.modules.feishu.service import FeishuService from app.modules.feishu.service import FeishuService
from app.modules.reports.services import ReportService from app.modules.reports.services import ReportService
from app.modules.risk.constants import RiskSummaryKey from app.modules.risk.constants import RiskSummaryKey
@@ -32,6 +36,22 @@ RISK_KEYWORDS = ("风险", "预警", "risk")
AI_COMMAND_PREFIXES = ("", "ai ", "AI ", "/ask ") AI_COMMAND_PREFIXES = ("", "ai ", "AI ", "/ask ")
RISK_TITLE = "风险预警" RISK_TITLE = "风险预警"
DEFAULT_AI_PROMPT = "请说明你能做什么。" DEFAULT_AI_PROMPT = "请说明你能做什么。"
RULE_TITLE = "AI 学习规则"
RULE_CREATE_PATTERN = re.compile(r"^学习规则(?:\s+(\d{1,3}))?\s*[:]\s*(.*)$")
RULE_DISABLE_PATTERN = re.compile(r"^停用规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
RULE_ENABLE_PATTERN = re.compile(r"^启用规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
RULE_LIST_COMMANDS = {"查看规则", "规则列表"}
RULE_COMMAND_PREFIXES = ("学习规则", "查看规则", "规则列表", "停用规则", "启用规则")
RULE_COMMAND_HELP = (
"规则指令格式:\n"
"学习规则:<规则内容>\n"
"学习规则 80<规则内容>\n"
"查看规则\n"
"停用规则 <规则编号>\n"
"启用规则 <规则编号>"
)
PROJECT_FINANCE_PATTERN = re.compile(r"^项目资金\s+(.+)$")
FINANCE_COMMANDS = {"资金需求", "未来30天资金需求"}
def _parse_content_text(content: Any) -> str: def _parse_content_text(content: Any) -> str:
@@ -125,6 +145,24 @@ class FeishuCommandService:
lowered = command_text.lower() lowered = command_text.lower()
provider_response: dict[str, Any] | None = None provider_response: dict[str, Any] | None = None
rule_result = self._handle_rule_command(
command_text,
chat_id=chat_id,
actor=actor,
auto_reply=auto_reply,
)
if rule_result is not None:
return rule_result
finance_result = self._handle_finance_command(
command_text,
chat_id=chat_id,
actor=actor,
auto_reply=auto_reply,
)
if finance_result is not None:
return finance_result
if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS): if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS):
report = ReportService(self.db).daily_brief() report = ReportService(self.db).daily_brief()
if auto_reply: if auto_reply:
@@ -235,6 +273,233 @@ class FeishuCommandService:
provider_response, provider_response,
) )
def _handle_finance_command(
self,
command_text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
project_match = PROJECT_FINANCE_PATTERN.fullmatch(command_text)
if command_text not in FINANCE_COMMANDS and project_match is None:
return None
command = (
FeishuCommandName.PROJECT_FINANCE
if project_match
else FeishuCommandName.FINANCE_NEEDS
)
if not get_settings().finance_needs_enabled:
content = "项目资金需求分析尚未启用,请先配置并启用财务只读同步。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"项目资金需求分析",
content,
provider_response,
)
project_code = project_match.group(1).strip() if project_match else None
service = ReportService(self.db)
preview = service.project_finance_needs_report(
project_code=project_code,
include_ai=False,
actor=actor,
)
if project_code and not preview["items"]:
content = f"未找到项目“{project_code}”,请使用稳定项目编号或展示编号。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"项目资金需求分析",
content,
provider_response,
)
if not preview["summary"]["data_available"]:
content = "项目财务数据未接入或无有效记录,暂不生成资金分析报告。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"项目资金需求分析",
content,
provider_response,
)
report = service.project_finance_needs_report(
project_code=project_code,
include_ai=True,
actor=actor,
)
ai_analysis = report.get("ai_analysis") or {}
if not ai_analysis.get(AIResponseKey.OK):
content = "AI 当前不可用,本次项目资金分析报告未发送。请检查模型服务。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"AI 暂不可用",
content,
provider_response,
)
provider_response = None
if auto_reply:
provider_response = self._send_finance_card_if_configured(chat_id, report, actor)
return _command_result(
command,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
def _send_finance_card_if_configured(
self,
chat_id: str | None,
report: dict[str, Any],
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
chart_data = {
"period": report.get("as_of"),
"finance": report.get("finance_chart_data"),
}
image_result = self.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")
card = FeishuService.build_basic_card(
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
image_key=image_key,
image_alt=lifecycle_chart_alt(chart_data),
)
return self.feishu.send_card(card, receive_id=chat_id, actor=actor)
def _handle_rule_command(
self,
command_text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
if not command_text.startswith(RULE_COMMAND_PREFIXES):
return None
if command_text.startswith("停用规则"):
command = FeishuCommandName.RULE_DISABLE
elif command_text.startswith("启用规则"):
command = FeishuCommandName.RULE_ENABLE
elif command_text.startswith(("查看规则", "规则列表")):
command = FeishuCommandName.RULE_LIST
else:
command = FeishuCommandName.RULE_CREATE
content = RULE_COMMAND_HELP
try:
create_match = RULE_CREATE_PATTERN.fullmatch(command_text)
disable_match = RULE_DISABLE_PATTERN.fullmatch(command_text)
enable_match = RULE_ENABLE_PATTERN.fullmatch(command_text)
memory = AIMemoryService(self.db)
if create_match:
priority = int(create_match.group(1) or 50)
rule_content = create_match.group(2).strip()
if not rule_content:
content = f"规则内容不能为空。\n\n{RULE_COMMAND_HELP}"
elif not 1 <= priority <= 100:
content = "规则优先级必须在 1 到 100 之间。"
else:
rule = memory.create_rule(
content=rule_content,
scope="global",
subject="company",
priority=priority,
tags=["feishu"],
actor=actor,
)
content = (
"规则已学习。\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
"状态:已启用"
)
elif command_text in RULE_LIST_COMMANDS:
command = FeishuCommandName.RULE_LIST
rules = memory.list_rules(
status_filter=AIMemoryStatus.ACTIVE,
limit=20,
)
if not rules:
content = "当前没有已启用的学习规则。"
else:
lines = ["当前已启用的学习规则:"]
for rule in rules:
rule_text = str(rule["content"])
if len(rule_text) > 80:
rule_text = f"{rule_text[:80]}"
lines.append(
f"{rule['code']}|优先级 {rule['importance']}"
f"{rule['scope']}/{rule['subject']}\n{rule_text}"
)
content = "\n\n".join(lines)
elif disable_match or enable_match:
enabled = enable_match is not None
command = (
FeishuCommandName.RULE_ENABLE
if enabled
else FeishuCommandName.RULE_DISABLE
)
match = enable_match or disable_match
rule = memory.update_rule(
code=match.group(1),
content=None,
priority=None,
tags=None,
enabled=enabled,
actor=actor,
)
state = "已启用" if enabled else "已停用"
content = (
f"规则{state}\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
f"状态:{state}"
)
except HTTPException as exc:
detail = str(exc.detail)
if "secret-like" in detail:
content = "规则疑似包含密码、令牌或其他密钥信息,已拒绝学习。"
elif exc.status_code == 404:
content = "没有找到该规则,请先发送“查看规则”确认规则编号。"
elif "priority" in detail:
content = "规则优先级必须在 1 到 100 之间。"
else:
content = "规则未保存,请检查指令内容后重试。"
provider_response = None
if auto_reply:
provider_response = self._send_text_if_configured(chat_id, content, actor)
return _command_result(
command,
FeishuReplyType.TEXT,
RULE_TITLE,
content,
provider_response,
)
def _send_card_if_configured( def _send_card_if_configured(
self, self,
chat_id: str | None, chat_id: str | None,

View File

@@ -24,10 +24,17 @@ class FeishuPayloadKey(StrEnum):
CONFIG = "config" CONFIG = "config"
CONTENT = "content" CONTENT = "content"
DIV = "div" DIV = "div"
DATA = "data"
ELEMENTS = "elements" ELEMENTS = "elements"
EXPIRE = "expire" EXPIRE = "expire"
FEISHU_ERROR = "feishu_error" FEISHU_ERROR = "feishu_error"
HEADER = "header" HEADER = "header"
IMAGE = "image"
IMAGE_KEY = "image_key"
IMAGE_TYPE = "image_type"
IMG = "img"
IMG_KEY = "img_key"
ALT = "alt"
EVENT = "event" EVENT = "event"
EVENT_ID = "event_id" EVENT_ID = "event_id"
EVENT_TYPE = "event_type" EVENT_TYPE = "event_type"
@@ -78,6 +85,12 @@ class FeishuCommandResultKey(StrEnum):
class FeishuCommandName(StrEnum): class FeishuCommandName(StrEnum):
RULE_CREATE = "rule_create"
RULE_LIST = "rule_list"
RULE_DISABLE = "rule_disable"
RULE_ENABLE = "rule_enable"
FINANCE_NEEDS = "finance_needs"
PROJECT_FINANCE = "project_finance"
DAILY_BRIEF = "daily_brief" DAILY_BRIEF = "daily_brief"
PROJECT_WEEKLY = "project_weekly" PROJECT_WEEKLY = "project_weekly"
ATTENDANCE_SUMMARY = "attendance_summary" ATTENDANCE_SUMMARY = "attendance_summary"
@@ -106,6 +119,7 @@ class FeishuCardKey(StrEnum):
FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal" FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
FEISHU_MESSAGE_PATH = "/im/v1/messages" FEISHU_MESSAGE_PATH = "/im/v1/messages"
FEISHU_IMAGE_PATH = "/im/v1/images"
FEISHU_DEFAULT_OPEN_API_DOMAIN = "https://open.feishu.cn" FEISHU_DEFAULT_OPEN_API_DOMAIN = "https://open.feishu.cn"
FEISHU_AUTH_MISSING = "Feishu app credentials are not configured" FEISHU_AUTH_MISSING = "Feishu app credentials are not configured"
FEISHU_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required" FEISHU_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required"

View File

@@ -89,8 +89,51 @@ class FeishuService:
) )
return result return result
def upload_image(
self,
image: bytes,
actor: str = ActorValue.SYSTEM,
) -> dict[str, Any]:
result = self.client.upload_image(image)
self.audit.log(
AuditLogCreate(
actor=actor,
source=AuditSource.FEISHU,
action=AuditAction.FEISHU_UPLOAD_IMAGE,
request_payload={"content_type": "image/png", "size": len(image)},
response_payload=result,
)
)
return result
@staticmethod @staticmethod
def build_basic_card(title: str, lines: list[str]) -> dict[str, Any]: def build_basic_card(
title: str,
lines: list[str],
image_key: str | None = None,
image_alt: str | None = None,
) -> dict[str, Any]:
elements: list[dict[str, Any]] = []
if image_key:
elements.append(
{
FeishuPayloadKey.TAG: FeishuPayloadKey.IMG,
FeishuPayloadKey.IMG_KEY: image_key,
FeishuPayloadKey.ALT: {
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
FeishuPayloadKey.CONTENT: image_alt or "生命周期数据图",
},
}
)
elements.append(
{
FeishuPayloadKey.TAG: FeishuPayloadKey.DIV,
FeishuPayloadKey.TEXT: {
FeishuPayloadKey.TAG: FeishuPayloadKey.LARK_MARKDOWN,
FeishuPayloadKey.CONTENT: "\n".join(lines) or FEISHU_EMPTY_CARD_TEXT,
},
}
)
return { return {
FeishuPayloadKey.CONFIG: {FeishuPayloadKey.WIDE_SCREEN_MODE: True}, FeishuPayloadKey.CONFIG: {FeishuPayloadKey.WIDE_SCREEN_MODE: True},
FeishuPayloadKey.HEADER: { FeishuPayloadKey.HEADER: {
@@ -99,13 +142,5 @@ class FeishuService:
FeishuPayloadKey.CONTENT: title, FeishuPayloadKey.CONTENT: title,
} }
}, },
FeishuPayloadKey.ELEMENTS: [ FeishuPayloadKey.ELEMENTS: elements,
{
FeishuPayloadKey.TAG: FeishuPayloadKey.DIV,
FeishuPayloadKey.TEXT: {
FeishuPayloadKey.TAG: FeishuPayloadKey.LARK_MARKDOWN,
FeishuPayloadKey.CONTENT: "\n".join(lines) or FEISHU_EMPTY_CARD_TEXT,
},
}
],
} }

File diff suppressed because it is too large Load Diff

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 = "经营周报" WORK_WEEKLY = "经营周报"
PROJECT_LIFECYCLE = "项目全生命周期报告" PROJECT_LIFECYCLE = "项目全生命周期报告"
ENTERPRISE_ANALYTICS = "企业只读运营分析" ENTERPRISE_ANALYTICS = "企业只读运营分析"
LIFECYCLE_DAILY = "项目与人员生命周期日报"
LIFECYCLE_WEEKLY = "项目与人员生命周期周报"
PERSONNEL_LIFECYCLE = "人员生命周期分析"
PROJECT_FINANCE_NEEDS = "项目资金需求分析"
class ReportStatus(StrEnum): class ReportStatus(StrEnum):
@@ -37,10 +41,12 @@ class ReportPushKey(StrEnum):
class ReportErrorDetail(StrEnum): class ReportErrorDetail(StrEnum):
PUSH_RUN_NOT_FOUND = "Report push run not found" PUSH_RUN_NOT_FOUND = "Report push run not found"
INVALID_LIFECYCLE_REPORT_TYPE = "Lifecycle report type must be daily or weekly"
class LifecycleSection(StrEnum): class LifecycleSection(StrEnum):
HEALTH = "health" HEALTH = "health"
FINANCE = "finance"
PROJECTS = "projects" PROJECTS = "projects"
TASKS = "tasks" TASKS = "tasks"
PROCUREMENTS = "procurements" 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 fastapi import APIRouter, Depends
from sqlalchemy.orm import Session 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.database import get_db
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
from app.modules.reports.constants import ReportPushKey from app.modules.reports.constants import ReportPushKey
from app.modules.reports.schemas import ( from app.modules.reports.schemas import (
LifecycleRunRequest,
PushReportRequest, PushReportRequest,
ReportResponse, ReportResponse,
WorkReportGenerateRequest, WorkReportGenerateRequest,
@@ -77,6 +82,56 @@ def attendance_summary(
return ReportService(db).attendance_summary(work_date) 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") @router.get("/push-runs")
def list_push_runs( def list_push_runs(
status: str | None = None, status: str | None = None,

View File

@@ -28,3 +28,10 @@ class WorkReportGenerateRequest(BaseModel):
period_end: date | None = None period_end: date | None = None
persist: bool = False persist: bool = False
actor: str = ActorValue.API 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, ReportPushStatus,
ReportResponseKey, ReportResponseKey,
) )
from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart
class ReportDeliveryMixin: class ReportDeliveryMixin:
@@ -39,12 +40,24 @@ class ReportDeliveryMixin:
actor=actor, actor=actor,
) )
) )
try:
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( card = FeishuService.build_basic_card(
report[ReportResponseKey.TITLE], report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES], report[ReportResponseKey.LINES],
image_key=image_key,
image_alt=image_alt,
) )
try: result = feishu.send_card(card, receive_id, receive_id_type, actor)
result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
except Exception as exc: except Exception as exc:
failed_run = self.update_push_run( failed_run = self.update_push_run(
push_run.code, 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.constants import ActorValue
from app.core.config import get_settings
from app.modules.reports.constants import ( from app.modules.reports.constants import (
LifecycleFilterKey, LifecycleFilterKey,
LifecycleResponseKey, LifecycleResponseKey,
@@ -63,6 +64,17 @@ class ReportLifecycleReportMixin:
risk_stats, risk_stats,
include_global_risk, 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 = { metrics = {
LifecycleSection.HEALTH: health, LifecycleSection.HEALTH: health,
@@ -74,12 +86,15 @@ class ReportLifecycleReportMixin:
LifecycleSection.SUPPLIERS: supplier_stats, LifecycleSection.SUPPLIERS: supplier_stats,
LifecycleSection.ATTENDANCE: attendance_stats, LifecycleSection.ATTENDANCE: attendance_stats,
LifecycleSection.RISKS: risk_stats, LifecycleSection.RISKS: risk_stats,
LifecycleSection.FINANCE: finance["summary"] if finance else {"status": "未启用"},
} }
lines = self._lifecycle_lines( lines = self._lifecycle_lines(
filters[LifecycleFilterKey.LABELS], filters[LifecycleFilterKey.LABELS],
metrics, metrics,
recommendations, recommendations,
) )
if finance:
lines.extend(finance[LifecycleResponseKey.LINES])
report = { report = {
LifecycleResponseKey.TITLE: ReportTitle.PROJECT_LIFECYCLE, LifecycleResponseKey.TITLE: ReportTitle.PROJECT_LIFECYCLE,
LifecycleResponseKey.FILTERS: filters[LifecycleFilterKey.LABELS], LifecycleResponseKey.FILTERS: filters[LifecycleFilterKey.LABELS],
@@ -88,6 +103,9 @@ class ReportLifecycleReportMixin:
LifecycleResponseKey.RECOMMENDATIONS: recommendations, LifecycleResponseKey.RECOMMENDATIONS: recommendations,
LifecycleResponseKey.LINES: lines, LifecycleResponseKey.LINES: lines,
LifecycleResponseKey.CONTENT: "\n".join(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: if include_ai:
report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor) report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor)

View File

@@ -26,7 +26,16 @@ class ReportPushRunMixin:
receive_id_type: str, receive_id_type: str,
actor: str, actor: str,
status: str = ReportPushStatus.PENDING, status: str = ReportPushStatus.PENDING,
idempotency_key: str | None = None,
) -> ReportPushRun: ) -> 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( record = ReportPushRun(
code=_next_code("PUSH"), code=_next_code("PUSH"),
report_type=report_type, report_type=report_type,
@@ -36,6 +45,7 @@ class ReportPushRunMixin:
status=status, status=status,
actor=actor, actor=actor,
queued_at=utc_now(), queued_at=utc_now(),
idempotency_key=idempotency_key,
) )
self.db.add(record) self.db.add(record)
self.db.commit() 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.common import ReportQueryMixin
from app.modules.reports.services.delivery import ReportDeliveryMixin from app.modules.reports.services.delivery import ReportDeliveryMixin
from app.modules.reports.services.enterprise import ReportEnterpriseAnalyticsMixin 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.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.push_runs import ReportPushRunMixin
from app.modules.reports.services.summaries import ReportSummaryMixin from app.modules.reports.services.summaries import ReportSummaryMixin
from app.modules.reports.services.work_reports import ReportWorkReportMixin from app.modules.reports.services.work_reports import ReportWorkReportMixin
@@ -12,6 +14,8 @@ from app.modules.risk.services import RiskService
class ReportService( class ReportService(
ReportDeliveryMixin, ReportDeliveryMixin,
FinanceNeedsReportMixin,
IntasectLifecycleReportMixin,
ReportWorkReportMixin, ReportWorkReportMixin,
ReportEnterpriseAnalyticsMixin, ReportEnterpriseAnalyticsMixin,
ReportLifecycleMixin, ReportLifecycleMixin,

View File

@@ -7,6 +7,7 @@ class WorkflowType(StrEnum):
LEGACY_SYNC_MONITOR = "legacy_sync_monitor" LEGACY_SYNC_MONITOR = "legacy_sync_monitor"
ENTERPRISE_ANALYTICS = "enterprise_analytics" ENTERPRISE_ANALYTICS = "enterprise_analytics"
AI_MEMORY_CAPTURE = "ai_memory_capture" AI_MEMORY_CAPTURE = "ai_memory_capture"
LIFECYCLE_REPORT = "lifecycle_report"
class WorkflowStatus(StrEnum): class WorkflowStatus(StrEnum):

View File

@@ -1,4 +1,5 @@
from typing import Any from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -79,7 +80,10 @@ class WorkflowService:
self.db.add( self.db.add(
WorkflowAction( WorkflowAction(
code=f"{WORKFLOW_ACTION_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}", code=(
f"{WORKFLOW_ACTION_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}-"
f"{uuid4().hex[:8]}"
),
workflow_code=record.code, workflow_code=record.code,
action=action, action=action,
actor=actor, actor=actor,

View File

@@ -1,6 +1,7 @@
from app.tasks.app import celery_app from app.tasks.app import celery_app
from app.tasks import events as _events # noqa: F401 from app.tasks import events as _events # noqa: F401
from app.tasks import legacy as _legacy # noqa: F401 from app.tasks import legacy as _legacy # noqa: F401
from app.tasks import lifecycle as _lifecycle # noqa: F401
from app.tasks import reports as _reports # noqa: F401 from app.tasks import reports as _reports # noqa: F401
from app.tasks import risk as _risk # noqa: F401 from app.tasks import risk as _risk # noqa: F401

33
app/tasks/lifecycle.py Normal file
View File

@@ -0,0 +1,33 @@
from typing import Any
from app.core.background.task_queue.constants import TASK_RUN_LIFECYCLE
from app.core.constants import ActorValue
from app.core.database import SessionLocal
from app.modules.reports.lifecycle_pipeline import LifecyclePipelineService
from app.tasks.app import celery_app
@celery_app.task(
name=TASK_RUN_LIFECYCLE,
autoretry_for=(Exception,),
retry_backoff=True,
retry_kwargs={"max_retries": 3},
)
def run_lifecycle_report(
report_type: str,
receive_id: str | None = None,
receive_id_type: str = "chat_id",
force: bool = False,
actor: str = ActorValue.SCHEDULER,
) -> dict[str, Any]:
db = SessionLocal()
try:
return LifecyclePipelineService(db).run(
report_type,
receive_id,
receive_id_type,
force,
actor,
)
finally:
db.close()

View File

@@ -4,17 +4,23 @@ from app.modules.audit.models import AuditLog
from app.modules.business.models import ( from app.modules.business.models import (
AttendanceRecord, AttendanceRecord,
Expense, Expense,
Employee,
FundAccount, FundAccount,
LegacySyncRun, LegacySyncRun,
PerformanceMetric, PerformanceMetric,
Policy, Policy,
Procurement, Procurement,
Project, Project,
ProjectCashFlow,
ProjectContract,
ProjectMember,
ProjectMilestone,
ReportPushRun, ReportPushRun,
RiskEvent, RiskEvent,
RiskEventAction, RiskEventAction,
Standard, Standard,
Supplier, Supplier,
SourceSyncCursor,
WorkReport, WorkReport,
WorkTask, WorkTask,
) )
@@ -25,8 +31,13 @@ from app.modules.workflows.models import WorkflowAction, WorkflowInstance
_MODELS = [ _MODELS = [
AuditLog, AuditLog,
Employee,
FeishuEventReceipt, FeishuEventReceipt,
Project, Project,
ProjectCashFlow,
ProjectContract,
ProjectMember,
ProjectMilestone,
WorkTask, WorkTask,
Procurement, Procurement,
Expense, Expense,
@@ -46,6 +57,7 @@ _MODELS = [
WorkflowAction, WorkflowAction,
AIMemoryEntry, AIMemoryEntry,
SystemHeartbeat, SystemHeartbeat,
SourceSyncCursor,
] ]

View File

@@ -20,5 +20,6 @@ dependencies:
- celery==5.4.0 - celery==5.4.0
- cryptography==44.0.0 - cryptography==44.0.0
- pandas==2.2.3 - pandas==2.2.3
- pillow==11.0.0
- pytest==8.3.4 - pytest==8.3.4
- ruff==0.8.4 - ruff==0.8.4

View File

@@ -1,7 +1,7 @@
import json import json
import os import os
import tempfile import tempfile
from datetime import date, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -49,8 +49,35 @@ from app.modules.events.constants import (
) )
from app.modules.events.services import EventService from app.modules.events.services import EventService
from app.modules.business.registry import get_domain_model from app.modules.business.registry import get_domain_model
from app.modules.business.models import (
Employee,
Project,
ProjectCashFlow,
ProjectContract,
ProjectMember,
ProjectMilestone,
)
from app.modules.business.service import _model_payload, serialize_model from app.modules.business.service import _model_payload, serialize_model
from app.modules.legacy_mysql.services import LegacyMySQLService from app.modules.legacy_mysql.services import LegacyMySQLService
from app.modules.legacy_mysql.intasect import (
CONTRACT_RECEIVABLE_SQL,
CONTRACT_SQL,
EPOCH,
IntasectSyncService,
PROJECT_FUND_SQL,
_contract_payload,
_contract_receivable_payload,
_employee_payload,
_project_fund_payload,
_project_payload,
)
from app.modules.business.constants import (
CashFlowDirection,
CashFlowType,
DataQualityStatus,
)
from app.modules.ai_memory.service import AIMemoryService
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.observability.constants import ( from app.modules.observability.constants import (
HeartbeatComponent, HeartbeatComponent,
ObservabilityKey, ObservabilityKey,
@@ -66,6 +93,13 @@ from app.modules.reports.constants import (
ReportTitle, ReportTitle,
ReportType, ReportType,
) )
from app.modules.reports.lifecycle_pipeline import LifecyclePipelineService
from app.modules.reports.chart import render_lifecycle_chart
from app.modules.reports.services import ReportService
from app.modules.feishu.service import FeishuService
from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.events import FeishuEventService
from app.modules.feishu.constants import FeishuEventSource
from app.modules.risk.constants import RiskEventActionValue from app.modules.risk.constants import RiskEventActionValue
from app.modules.workflows.constants import WorkflowStatus, WorkflowType from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.workflows.models import WorkflowInstance from app.modules.workflows.models import WorkflowInstance
@@ -178,6 +212,106 @@ def test_feishu_webhook_routes_message_event() -> None:
assert AUDIT_REDACTED_VALUE in audit_payload assert AUDIT_REDACTED_VALUE in audit_payload
def test_feishu_rule_commands_create_list_disable_and_enable(monkeypatch) -> None:
monkeypatch.setattr(
FeishuService,
"send_text",
lambda *args, **kwargs: pytest.fail("auto_reply=False must not send to Feishu"),
)
rule_text = "每日建议必须说明负责人角色、截止时间和验收指标"
created = client.post(
"/api/v1/integrations/feishu/commands/preview",
headers=headers,
json={"text": f"学习规则 80{rule_text}", "auto_reply": False},
)
assert created.status_code == 200
assert created.json()["command"] == "rule_create"
assert "规则已学习" in created.json()["content"]
db = SessionLocal()
try:
rule = db.execute(
select(AIMemoryEntry).where(AIMemoryEntry.content == rule_text)
).scalar_one()
assert rule.importance == 80
assert rule.scope == "global"
assert rule.subject == "company"
assert "feishu" in rule.tags
service = FeishuCommandService(db)
listed = service.handle_text("查看规则", auto_reply=False)
assert listed["command"] == "rule_list"
assert rule.code in listed["content"]
disabled = service.handle_text(f"停用规则 {rule.code}", auto_reply=False)
assert disabled["command"] == "rule_disable"
assert "已停用" in disabled["content"]
db.refresh(rule)
assert rule.status == AIMemoryStatus.ARCHIVED
enabled = service.handle_text(f"启用规则 {rule.code}", auto_reply=False)
assert enabled["command"] == "rule_enable"
assert "已启用" in enabled["content"]
db.refresh(rule)
assert rule.status == AIMemoryStatus.ACTIVE
finally:
db.close()
def test_feishu_rule_command_preserves_sender_and_rejects_invalid_input() -> None:
payload = {
"schema": "2.0",
"header": {
"event_id": "evt-smoke-rule-actor-001",
"event_type": "im.message.receive_v1",
"token": "test-feishu-token",
},
"event": {
"sender": {"sender_id": {"open_id": "ou_rule_teacher"}},
"message": {
"chat_id": "oc_test",
"message_id": "om_smoke_rule_actor_001",
"message_type": "text",
"content": json.dumps(
{"text": "学习规则:风险建议先写事实依据再写行动"}
),
},
},
}
db = SessionLocal()
try:
result = FeishuEventService(db).handle_event(
payload,
source=FeishuEventSource.WEBHOOK,
auto_reply=False,
)
assert result["result"]["command"] == "rule_create"
rule = db.execute(
select(AIMemoryEntry).where(
AIMemoryEntry.content == "风险建议先写事实依据再写行动"
)
).scalar_one()
assert rule.actor == "ou_rule_teacher"
empty = FeishuCommandService(db).handle_text("学习规则:", auto_reply=False)
assert empty["command"] == "rule_create"
assert "不能为空" in empty["content"]
invalid_priority = FeishuCommandService(db).handle_text(
"学习规则 101先写结论",
auto_reply=False,
)
assert "1 到 100" in invalid_priority["content"]
secret = FeishuCommandService(db).handle_text(
"学习规则:请保存 password=example",
auto_reply=False,
)
assert "已拒绝学习" in secret["content"]
finally:
db.close()
def test_v3_request_id_health_and_metrics() -> None: def test_v3_request_id_health_and_metrics() -> None:
db = SessionLocal() db = SessionLocal()
try: try:
@@ -1052,3 +1186,757 @@ def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None:
finally: finally:
monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False) monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False)
get_settings.cache_clear() get_settings.cache_clear()
def test_intasect_mapping_uses_stable_ids_and_preserves_unknown_stage() -> None:
seen_at = datetime(2026, 7, 12, 9, 0)
project = _project_payload(
{
"source_id": 99,
"business_code": None,
"pro_sn": None,
"name": "Lifecycle Project",
"mgr_deptid": 7,
"mgr_deptname": "Delivery",
"mgr_user_id": 8,
"mgr_user_name": "Manager",
"project_stage": "ZZYGD",
"stage_label": "ZZYGD",
"archive_flag": "0",
"source_created_at": seen_at,
"source_updated_at": seen_at,
"contract_date": None,
"project_information": None,
},
seen_at,
)
employee = _employee_payload(
{
"source_id": 8,
"employee_name": "Employee",
"dept_id": 7,
"dept_name": "Delivery",
"employment_status": "0",
"ding_id": None,
"title": "Engineer",
"hired_date": None,
"source_created_at": seen_at,
"source_updated_at": seen_at,
},
seen_at,
)
assert project["code"] == "INTASECT-PROJECT-99"
assert project["display_code"] is None
assert project["source_stage_label"] == "ZZYGD"
assert project["progress_percent"] == 0
assert employee["code"] == "INTASECT-EMPLOYEE-8"
assert employee["ding_user_id"] is None
def test_intasect_full_sync_marks_missing_projects_inactive() -> None:
row = {
"source_key": "99101",
"source_id": 99101,
"business_code": "B-99101",
"pro_sn": None,
"name": "Synced Project",
"mgr_deptid": None,
"mgr_deptname": None,
"mgr_user_id": None,
"mgr_user_name": None,
"project_stage": "XMQD",
"stage_label": "项目启动",
"archive_flag": "0",
"source_created_at": datetime(2026, 1, 1),
"source_updated_at": datetime(2026, 7, 1),
"contract_date": None,
"project_done_date": None,
"project_information": None,
}
class FakeSource:
def __init__(self, rows):
self.rows = rows
def fetch_page(self, dataset, after_key, watermark_at, limit):
assert dataset == "projects"
assert watermark_at == EPOCH
return self.rows if not after_key else []
db = SessionLocal()
try:
IntasectSyncService(db, FakeSource([row])).sync_dataset("projects", "RUN-1")
project = db.execute(
select(Project).where(Project.external_id == "99101")
).scalar_one()
assert project.is_active is True
assert project.display_code == "B-99101"
IntasectSyncService(db, FakeSource([])).sync_dataset("projects", "RUN-2")
db.refresh(project)
assert project.is_active is False
finally:
db.close()
def test_personnel_lifecycle_does_not_treat_missing_ding_mapping_as_absence() -> None:
db = SessionLocal()
try:
employee = Employee(
code="INTASECT-EMPLOYEE-99102",
name="Lifecycle Employee",
department_name="Delivery",
employment_status="在职",
source_system="legacy_mysql",
external_id="99102",
ding_user_id=None,
is_active=True,
)
project = Project(
code="INTASECT-PROJECT-99102",
name="Lifecycle Report Project",
status=StatusValue.RUNNING,
source_system="legacy_mysql",
external_id="99102",
source_archived=False,
is_active=True,
)
db.add_all([employee, project])
db.flush()
db.add(
ProjectMember(
code="INTASECT-MEMBER-99102",
project_code=project.code,
employee_code=employee.code,
workload_percent=120,
source_system="legacy_mysql",
external_id="99102",
is_active=True,
)
)
db.add(
ProjectMilestone(
code="INTASECT-MILESTONE-99102",
project_code=project.code,
source_stage_id="stage-1",
stage_name="项目启动",
plan_end=date.today() - timedelta(days=1),
status=StatusValue.RUNNING,
is_overdue=True,
source_system="legacy_mysql",
external_id="99102",
is_active=True,
)
)
db.commit()
report = ReportService(db).personnel_lifecycle_report(
employee_code=employee.code,
project_code=project.code,
)
item = report["items"][0]
assert item["attendance_covered"] is False
assert item["attendance_abnormal"] == 0
assert item["needs_attention"] is True
management = ReportService(db).management_lifecycle_report(
ReportType.DAILY,
include_ai=True,
)
assert management["metrics"]["projects"]["milestones"]["overdue"] >= 1
assert management["ai_analysis"]["ok"] is False
assert "降级为确定性基础报告" not in management["content"]
assert "首期未接入" in management["content"]
finally:
db.close()
def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
monkeypatch.setattr(
IntasectSyncService,
"sync_all",
lambda self, run_code, force_full=False, batch_size=500: {
"projects": {"processed": 1}
},
)
monkeypatch.setattr(
ReportService,
"management_lifecycle_report",
lambda self, report_type, actor, include_ai: {
"title": "Lifecycle",
"report_type": report_type,
"lines": ["ok"],
"content": "ok",
"ai_analysis": {"ok": True, "answer": "analysis"},
},
)
monkeypatch.setattr(ReportService, "push_report", lambda self, *args, **kwargs: {"ok": True})
db = SessionLocal()
try:
service = LifecyclePipelineService(db)
first = service.run(ReportType.WEEKLY, actor="pytest")
second = service.run(ReportType.WEEKLY, actor="pytest")
assert first["deduplicated"] is False
assert second["deduplicated"] is True
assert first["workflow_code"] == second["workflow_code"]
finally:
db.close()
def test_lifecycle_enqueue_respects_read_only_guard(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
try:
response = client.post(
"/api/v1/reports/lifecycle/enqueue",
headers=headers,
json={"report_type": "daily"},
)
assert response.status_code == 405
finally:
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
get_settings.cache_clear()
def test_lifecycle_report_api_validates_filters_and_report_type() -> None:
response = client.get(
"/api/v1/reports/personnel-lifecycle",
headers=headers,
params={"department": "Delivery"},
)
assert response.status_code == 200
assert "metrics" in response.json()
invalid = client.post(
"/api/v1/reports/lifecycle/enqueue",
headers=headers,
json={"report_type": "monthly"},
)
assert invalid.status_code == 422
def test_ai_unavailable_sends_notice_without_business_report(monkeypatch) -> None:
monkeypatch.setattr(
IntasectSyncService,
"sync_all",
lambda self, run_code, force_full=False, batch_size=500: {
"projects": {"processed": 1}
},
)
monkeypatch.setattr(
ReportService,
"management_lifecycle_report",
lambda self, report_type, actor, include_ai: {
"title": "Lifecycle",
"report_type": report_type,
"lines": ["must not be sent"],
"content": "must not be sent",
"ai_analysis": {"ok": False, "type": "TimeoutError"},
},
)
monkeypatch.setattr(
ReportService,
"push_report",
lambda self, *args, **kwargs: pytest.fail("business report must not be sent"),
)
monkeypatch.setattr(
LifecyclePipelineService,
"_notify_ai_unavailable",
lambda self, *args, **kwargs: True,
)
db = SessionLocal()
try:
result = LifecyclePipelineService(db).run(ReportType.DAILY, actor="pytest")
assert result["status"] == WorkflowStatus.FAILED
assert result["ai_unavailable"] is True
assert result["notified"] is True
finally:
db.close()
def test_user_rules_are_prioritized_in_ai_context(monkeypatch) -> None:
captured: dict = {}
class RuleAwareAdapter:
provider_name = "rule-aware"
def ask(self, prompt, context):
captured["context"] = context
return {"answer": "followed", "raw": {}}
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "false")
get_settings.cache_clear()
monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: RuleAwareAdapter())
db = SessionLocal()
try:
rule = AIMemoryService(db).create_rule(
content="所有项目风险建议必须注明负责人角色和完成时间",
scope="global",
subject="company",
priority=90,
tags=["report"],
actor="pytest",
)
from app.modules.ai_agent.service import AIService
response = AIService(db).ask("分析项目风险", actor="pytest")
assert response["answer"] == "followed"
assert captured["context"]["user_rules"][0]["rule"] == rule["content"]
AIMemoryService(db).update_rule(
code=rule["code"],
content=None,
priority=None,
tags=None,
enabled=False,
actor="pytest",
)
assert all(item["code"] != rule["code"] for item in AIMemoryService(db).active_rules())
finally:
db.close()
monkeypatch.delenv("AI_MEMORY_AUTO_WRITE_ENABLED", raising=False)
get_settings.cache_clear()
def test_lifecycle_chart_is_uploaded_and_embedded_in_feishu_card(monkeypatch) -> None:
chart_data = {
"period": "2026-07-11",
"projects": {"total": 100, "unarchived": 70, "archived": 30},
"risks": {"overdue_milestones": 5, "overdue_tasks": 8, "open_events": 3},
"people": {"active": 60, "attention": 7, "attendance_mapped": 42},
}
png = render_lifecycle_chart(chart_data)
assert png.startswith(b"\x89PNG\r\n\x1a\n")
captured: dict = {}
monkeypatch.setattr(
FeishuService,
"upload_image",
lambda self, image, actor: {"data": {"image_key": "img_test"}},
)
def fake_send_card(self, card, receive_id, receive_id_type, actor):
captured["card"] = card
return {"code": 0}
monkeypatch.setattr(FeishuService, "send_card", fake_send_card)
db = SessionLocal()
try:
ReportService(db).push_report(
{
"title": "Lifecycle",
"report_type": "daily",
"lines": ["AI analysis"],
"content": "AI analysis",
"chart_data": chart_data,
},
"chat-test",
"chat_id",
"pytest",
)
assert captured["card"]["elements"][0]["tag"] == "img"
assert captured["card"]["elements"][0]["img_key"] == "img_test"
finally:
db.close()
def test_user_rule_api_creates_and_disables_rule(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "false")
get_settings.cache_clear()
try:
created = client.post(
"/api/v1/ai/rules",
headers=headers,
json={
"content": "日报分析先说明延期项目,再给出负责人和时限",
"scope": "global",
"subject": "company",
"priority": 80,
"tags": ["daily"],
},
)
assert created.status_code == 200
code = created.json()["data"]["code"]
disabled = client.patch(
f"/api/v1/ai/rules/{code}",
headers=headers,
json={"enabled": False},
)
assert disabled.status_code == 200
assert disabled.json()["data"]["status"] == "archived"
finally:
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
get_settings.cache_clear()
def test_intasect_finance_mapping_normalizes_units_and_excludes_sensitive_fields() -> None:
seen_at = datetime(2026, 7, 12, 10, 0)
project = _project_payload(
{
"source_id": 99301,
"business_code": "FIN-99301",
"pro_sn": None,
"name": "Finance Project",
"mgr_deptid": None,
"mgr_deptname": None,
"mgr_user_id": None,
"mgr_user_name": None,
"project_stage": "JD50",
"stage_label": "执行中",
"archive_flag": "0",
"contract_money": "12.34",
"project_invest_amount": "56.78",
"contract_date": None,
"project_done_date": None,
"project_information": None,
"source_created_at": seen_at,
"source_updated_at": seen_at,
},
seen_at,
)
contract = _contract_payload(
{
"source_id": 501,
"project_id": 99301,
"linked_project_id": 99301,
"project_del_flag": "0",
"contract_type_code": "3",
"contract_type_label": "监理合同",
"contract_amount": "123400",
"contract_date": date(2026, 1, 1),
"date_start": date(2026, 1, 1),
"date_end": date(2026, 12, 31),
"invoice_type_code": "D1",
"source_created_at": seen_at,
},
seen_at,
)
receivable = _contract_receivable_payload(
{
"source_id": 601,
"source_contract_id": 501,
"linked_contract_id": 501,
"project_id": 99301,
"linked_project_id": 99301,
"project_del_flag": "0",
"category_code": "HTQSH",
"category_label": "合同签署后",
"planned_amount": "1000",
"actual_amount": "200",
"planned_date": date(2026, 7, 20),
"actual_date": date(2026, 7, 10),
"payment_status": "N",
"invoice_status": "Y",
"source_created_at": seen_at,
},
seen_at,
)
fund = _project_fund_payload(
{
"source_id": 701,
"project_id": 99301,
"linked_project_id": 99301,
"project_del_flag": "0",
"cost_class": "0",
"category_code": "2",
"category_label": "投标保证金",
"planned_amount": "3000",
"approval_status": "1",
"confirm_status": "Y",
"trade_time": datetime(2026, 7, 11, 9, 0),
"source_created_at": seen_at,
"source_updated_at": seen_at,
},
seen_at,
)
assert project["source_contract_amount"] == 123400
assert project["source_project_investment_amount"] == 567800
assert contract["amount"] == 123400
assert receivable["direction"] == CashFlowDirection.INFLOW
assert receivable["data_quality_status"] == DataQualityStatus.STATUS_AMOUNT_MISMATCH
assert fund["direction"] == CashFlowDirection.OUTFLOW
assert fund["actual_amount"] == 3000
for sql in (CONTRACT_SQL, CONTRACT_RECEIVABLE_SQL, PROJECT_FUND_SQL):
lowered = sql.lower()
assert "bank_account" not in lowered
assert "phone" not in lowered
assert "payee_mobile" not in lowered
def test_project_finance_needs_calculates_horizons_and_funding_range() -> None:
db = SessionLocal()
project_code = "INTASECT-PROJECT-99302"
reference = date(2026, 7, 12)
try:
project = Project(
code=project_code,
display_code="FIN-99302",
name="Funding Needs Project",
status=StatusValue.RUNNING,
source_system="legacy_mysql",
external_id="99302",
source_archived=False,
is_active=True,
)
db.add(project)
db.add(
ProjectContract(
code="INTASECT-CONTRACT-99302",
project_code=project_code,
amount=10000,
data_quality_status=DataQualityStatus.VALID,
source_system="legacy_mysql",
external_id="99302",
is_active=True,
)
)
db.add_all(
[
ProjectCashFlow(
code="INTASECT-RECEIVABLE-9930201",
project_code=project_code,
contract_code="INTASECT-CONTRACT-99302",
flow_type=CashFlowType.CONTRACT_RECEIVABLE,
direction=CashFlowDirection.INFLOW,
planned_amount=1000,
actual_amount=200,
planned_date=reference + timedelta(days=8),
payment_status="N",
data_quality_status=DataQualityStatus.STATUS_AMOUNT_MISMATCH,
source_system="legacy_mysql",
external_id="receivable:9930201",
is_active=True,
),
ProjectCashFlow(
code="INTASECT-RECEIVABLE-9930202",
project_code=project_code,
contract_code="INTASECT-CONTRACT-99302",
flow_type=CashFlowType.CONTRACT_RECEIVABLE,
direction=CashFlowDirection.INFLOW,
planned_amount=500,
actual_amount=0,
planned_date=reference - timedelta(days=1),
payment_status="N",
data_quality_status=DataQualityStatus.VALID,
source_system="legacy_mysql",
external_id="receivable:9930202",
is_active=True,
),
ProjectCashFlow(
code="INTASECT-FUND-9930203",
project_code=project_code,
flow_type=CashFlowType.PROJECT_FUND,
direction=CashFlowDirection.OUTFLOW,
planned_amount=1500,
approval_status="1",
confirmation_status="N",
data_quality_status=DataQualityStatus.VALID,
source_system="legacy_mysql",
external_id="fund:9930203",
is_active=True,
),
ProjectCashFlow(
code="INTASECT-FUND-9930204",
project_code=project_code,
flow_type=CashFlowType.PROJECT_FUND,
direction=CashFlowDirection.OUTFLOW,
planned_amount=300,
actual_amount=300,
approval_status="1",
confirmation_status="Y",
data_quality_status=DataQualityStatus.VALID,
source_system="legacy_mysql",
external_id="fund:9930204",
is_active=True,
),
]
)
db.commit()
report = ReportService(db).project_finance_needs_report(
project_code=project_code,
as_of=reference,
)
item = report["items"][0]
assert item["actual_receipt"] == 200
assert item["overdue_receivable"] == 500
assert item["receivable_due"]["7"] == 0
assert item["receivable_due"]["30"] == 800
assert item["funding_need"]["7"] == {"lower": 1500, "upper": 1500}
assert item["funding_need"]["30"] == {"lower": 700, "upper": 1500}
assert report["summary"]["confirmed_outflow"] == 300
assert report["disclaimer"].startswith("项目资金安排需求不包含公司账户余额")
assert "不代表真实融资缺口" in report["content"]
finally:
db.close()
def test_project_finance_needs_does_not_render_missing_data_as_zero() -> None:
db = SessionLocal()
project_code = "INTASECT-PROJECT-99304"
try:
db.add(
Project(
code=project_code,
name="No Finance Data Project",
status=StatusValue.RUNNING,
source_system="legacy_mysql",
external_id="99304",
source_archived=False,
is_active=True,
)
)
db.commit()
report = ReportService(db).project_finance_needs_report(project_code=project_code)
assert report["summary"]["data_available"] is False
assert report["summary"]["contract_revenue"] is None
assert report["items"][0]["pending_outflow"] is None
assert "金额不按零值解释" in report["content"]
assert report["finance_chart_data"] is None
finally:
db.close()
def test_finance_sync_soft_deactivates_missing_contract() -> None:
row = {
"source_key": "99303",
"source_id": 99303,
"project_id": 99303,
"linked_project_id": 99303,
"project_del_flag": "0",
"contract_type_code": "3",
"contract_type_label": "监理合同",
"contract_amount": 5000,
"contract_date": date(2026, 1, 1),
"date_start": None,
"date_end": None,
"invoice_type_code": "D1",
"source_created_at": datetime(2026, 1, 1),
}
class FakeSource:
def __init__(self, rows):
self.rows = rows
def fetch_page(self, dataset, after_key, watermark_at, limit):
assert dataset == "contracts"
assert limit == 500
return self.rows if not after_key else []
db = SessionLocal()
try:
IntasectSyncService(db, FakeSource([row])).sync_dataset("contracts", "FIN-RUN-1")
contract = db.execute(
select(ProjectContract).where(ProjectContract.external_id == "99303")
).scalar_one()
assert contract.is_active is True
IntasectSyncService(db, FakeSource([])).sync_dataset("contracts", "FIN-RUN-2")
db.refresh(contract)
assert contract.is_active is False
finally:
db.close()
def test_finance_api_and_feishu_command_fail_closed_when_ai_unavailable(monkeypatch) -> None:
monkeypatch.setenv("FINANCE_NEEDS_ENABLED", "true")
get_settings.cache_clear()
try:
response = client.get(
"/api/v1/reports/project-finance-needs",
headers=headers,
params={"project_code": "INTASECT-PROJECT-99302", "as_of": "2026-07-12"},
)
assert response.status_code == 200
assert response.json()["currency"] == "CNY"
db = SessionLocal()
try:
result = FeishuCommandService(db).handle_text(
"项目资金 FIN-99302",
actor="ou_finance_test",
auto_reply=False,
)
assert result["command"] == "project_finance"
assert result["reply_type"] == "text"
assert "AI 当前不可用" in result["content"]
assert "Funding Needs Project" not in result["content"]
finally:
db.close()
finally:
monkeypatch.delenv("FINANCE_NEEDS_ENABLED", raising=False)
get_settings.cache_clear()
def test_feishu_finance_command_returns_ai_analysis_when_available(monkeypatch) -> None:
class FinanceAdapter:
provider_name = "finance-test"
def ask(self, prompt, context):
assert context["summary"]["data_available"] is True
assert "user_rules" in context
assert "owner" not in json.dumps(context["attention"], ensure_ascii=False)
return {"answer": "优先安排关键项目资金,并由财务负责人复核。", "raw": {}}
monkeypatch.setenv("FINANCE_NEEDS_ENABLED", "true")
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "false")
get_settings.cache_clear()
monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: FinanceAdapter())
db = SessionLocal()
rule = None
try:
rule = AIMemoryService(db).create_rule(
content="资金建议必须要求人工确认",
scope="global",
subject="company",
priority=95,
tags=["finance"],
actor="pytest",
)
result = FeishuCommandService(db).handle_text(
"项目资金 FIN-99302",
actor="ou_finance_test",
auto_reply=False,
)
assert result["command"] == "project_finance"
assert result["reply_type"] == "card"
assert "优先安排关键项目资金" in result["content"]
finally:
if rule is not None:
AIMemoryService(db).update_rule(
code=rule["code"],
content=None,
priority=None,
tags=None,
enabled=False,
actor="pytest",
)
db.close()
monkeypatch.delenv("FINANCE_NEEDS_ENABLED", raising=False)
monkeypatch.delenv("AI_MEMORY_AUTO_WRITE_ENABLED", raising=False)
get_settings.cache_clear()
def test_lifecycle_chart_renders_finance_section() -> None:
png = render_lifecycle_chart(
{
"period": "2026-07-12",
"projects": {"total": 1, "unarchived": 1, "archived": 0},
"risks": {},
"people": {},
"finance": {
"cashflows": {
"confirmed_inflow": 1000,
"confirmed_outflow": 300,
"pending_outflow": 700,
},
"top_projects": [{"name": "Project A", "amount": 700}],
},
}
)
assert png.startswith(b"\x89PNG\r\n\x1a\n")