```
feat: 添加生命周期报告和AI规则管理功能 - 在Dockerfile中添加pillow依赖包用于图像处理 - 实现生命周期报告调度任务,支持日报和周报两种类型 - 新增TASK_RUN_LIFECYCLE任务常量和相关配置选项 - 扩展AI Agent服务以支持用户规则,并在分析时应用规则 - 添加AI用户规则创建、更新和查询接口 - 增加项目生命周期和财务需求分析技能 - 扩展现有模型以支持更完整的业务数据字段 - 实现飞书图片上传功能用于报告展示 ```
This commit is contained in:
@@ -21,7 +21,8 @@ RUN pip install --no-cache-dir \
|
||||
redis==5.2.1 \
|
||||
celery==5.4.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 /app/alembic
|
||||
|
||||
358
alembic/versions/202607120001_intasect_lifecycle_foundation.py
Normal file
358
alembic/versions/202607120001_intasect_lifecycle_foundation.py
Normal 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)
|
||||
118
alembic/versions/202607120002_project_finance_needs.py
Normal file
118
alembic/versions/202607120002_project_finance_needs.py
Normal 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")
|
||||
@@ -38,6 +38,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
enqueue_event_dispatch,
|
||||
enqueue_legacy_project_sync,
|
||||
enqueue_legacy_task_sync,
|
||||
enqueue_lifecycle_report,
|
||||
enqueue_project_weekly_push,
|
||||
)
|
||||
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)
|
||||
_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:
|
||||
dispatch = enqueue_event_dispatch(
|
||||
limit=settings.event_dispatch_batch_size,
|
||||
@@ -127,23 +146,42 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
scheduler.add_job(
|
||||
run_daily_brief,
|
||||
trigger="cron",
|
||||
hour=settings.daily_brief_cron_hour,
|
||||
minute=settings.daily_brief_cron_minute,
|
||||
id="daily_brief_push",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_project_weekly,
|
||||
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="project_weekly_push",
|
||||
replace_existing=True,
|
||||
)
|
||||
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(
|
||||
run_daily_brief,
|
||||
trigger="cron",
|
||||
hour=settings.daily_brief_cron_hour,
|
||||
minute=settings.daily_brief_cron_minute,
|
||||
id="daily_brief_push",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_project_weekly,
|
||||
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="project_weekly_push",
|
||||
replace_existing=True,
|
||||
)
|
||||
if settings.event_dispatch_enabled:
|
||||
scheduler.add_job(
|
||||
run_event_dispatch,
|
||||
@@ -159,7 +197,11 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
id="scheduler_heartbeat",
|
||||
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(
|
||||
run_legacy_project_sync,
|
||||
trigger="cron",
|
||||
@@ -168,7 +210,11 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
id="legacy_project_sync",
|
||||
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(
|
||||
run_legacy_task_sync,
|
||||
trigger="cron",
|
||||
|
||||
@@ -5,10 +5,12 @@ from app.core.background.task_queue.constants import (
|
||||
TASK_PUSH_PROJECT_WEEKLY,
|
||||
TASK_SYNC_LEGACY_PROJECTS,
|
||||
TASK_SYNC_LEGACY_TASKS,
|
||||
TASK_RUN_LIFECYCLE,
|
||||
)
|
||||
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.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.risk import enqueue_risk_event_generation
|
||||
|
||||
@@ -20,11 +22,13 @@ __all__ = [
|
||||
"TASK_PUSH_PROJECT_WEEKLY",
|
||||
"TASK_SYNC_LEGACY_PROJECTS",
|
||||
"TASK_SYNC_LEGACY_TASKS",
|
||||
"TASK_RUN_LIFECYCLE",
|
||||
"dispatch_task",
|
||||
"enqueue_daily_brief_push",
|
||||
"enqueue_event_dispatch",
|
||||
"enqueue_legacy_project_sync",
|
||||
"enqueue_legacy_task_sync",
|
||||
"enqueue_lifecycle_report",
|
||||
"enqueue_project_weekly_push",
|
||||
"enqueue_risk_event_generation",
|
||||
]
|
||||
|
||||
@@ -4,3 +4,4 @@ TASK_GENERATE_RISK_EVENTS = "risks.generate_events"
|
||||
TASK_SYNC_LEGACY_PROJECTS = "legacy.sync_projects"
|
||||
TASK_SYNC_LEGACY_TASKS = "legacy.sync_tasks"
|
||||
TASK_DISPATCH_PENDING_EVENTS = "events.dispatch_pending"
|
||||
TASK_RUN_LIFECYCLE = "reports.run_lifecycle"
|
||||
|
||||
50
app/core/background/task_queue/lifecycle.py
Normal file
50
app/core/background/task_queue/lifecycle.py
Normal 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()
|
||||
@@ -69,6 +69,8 @@ class Settings(BaseSettings):
|
||||
scheduler_enabled: bool = False
|
||||
task_queue_enabled: bool = False
|
||||
task_queue_always_eager: bool = False
|
||||
lifecycle_pipeline_enabled: bool = False
|
||||
finance_needs_enabled: bool = False
|
||||
legacy_sync_enabled: bool = False
|
||||
celery_result_backend_url: str | None = None
|
||||
daily_brief_cron_hour: int = 9
|
||||
@@ -90,6 +92,7 @@ class Settings(BaseSettings):
|
||||
ai_memory_enabled: bool = True
|
||||
ai_memory_auto_write_enabled: bool = True
|
||||
ai_memory_recall_limit: int = 5
|
||||
ai_analysis_max_attempts: int = 3
|
||||
ai_memory_forbidden_keys: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"authorization",
|
||||
|
||||
@@ -59,6 +59,7 @@ class AIContextKey(StrEnum):
|
||||
USER_PROMPT = "user_prompt"
|
||||
REQUEST_CONTEXT = "request_context"
|
||||
ASSISTANT_ANSWER = "assistant_answer"
|
||||
USER_RULES = "user_rules"
|
||||
|
||||
|
||||
class AIMemoryMode(StrEnum):
|
||||
|
||||
@@ -50,10 +50,18 @@ class AIService:
|
||||
original_context = context or {}
|
||||
adapter_context = dict(original_context)
|
||||
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(
|
||||
query=prompt,
|
||||
scope=_memory_scope(original_context),
|
||||
subject=_memory_subject(original_context),
|
||||
scope=memory_scope,
|
||||
subject=memory_subject,
|
||||
actor=actor,
|
||||
)
|
||||
if local_memory:
|
||||
|
||||
@@ -13,6 +13,7 @@ class AISkillId(StrEnum):
|
||||
DRAFT_POLICY = "draft_policy"
|
||||
INVESTMENT_RESEARCH = "investment_research"
|
||||
PROJECT_LIFECYCLE_ANALYSIS = "project_lifecycle_analysis"
|
||||
PROJECT_FINANCE_NEEDS_ANALYSIS = "project_finance_needs_analysis"
|
||||
HERMES_MEMORY_RECALL = "hermes_memory_recall"
|
||||
HERMES_MEMORY_WRITE = "hermes_memory_write"
|
||||
|
||||
@@ -50,10 +51,23 @@ INVESTMENT_RESEARCH_INSTRUCTIONS = (
|
||||
"Risk preference: {risk_preference}."
|
||||
)
|
||||
PROJECT_LIFECYCLE_ANALYSIS_INSTRUCTIONS = (
|
||||
"请基于项目全生命周期统计,输出中文管理层分析。"
|
||||
"包括总体判断、前三个风险、接下来一周优先动作。"
|
||||
"你是严谨的企业项目与人员生命周期管理分析师。"
|
||||
"只能依据上下文中的指标、异常清单和用户规则进行分析,不得编造事实。"
|
||||
"请输出中文管理层分析,并明确引用关键数字作为证据。"
|
||||
"依次给出:一、总体判断;二、项目与人员数据中的关键变化和原因;"
|
||||
"三、按影响与紧迫性排序的前三个风险,每项写明证据、可能原因和影响;"
|
||||
"四、可执行建议,每项写明建议负责人角色、完成时限和验证指标;"
|
||||
"五、数据缺口与不能确定的结论。"
|
||||
"用户规则优先影响分析口径和表达方式,但不得绕过安全、权限和审计限制。"
|
||||
"不要审批付款、不要最终定绩效、不要下投资交易指令。"
|
||||
)
|
||||
PROJECT_FINANCE_NEEDS_ANALYSIS_INSTRUCTIONS = (
|
||||
"你是严谨的企业项目资金需求分析师。只能依据上下文中的确定性金额、项目状态和"
|
||||
"用户规则分析,不得修改金额或编造现金余额。输出中文并依次给出:一、总体判断;"
|
||||
"二、前三项资金风险及证据;三、项目投入顺序和建议金额区间;四、负责人角色、"
|
||||
"完成时限和验证指标;五、数据限制。所有安排必须标注需要人工确认。"
|
||||
"不得把项目资金安排需求称为公司融资缺口,不得审批付款、融资或投资交易。"
|
||||
)
|
||||
HERMES_MEMORY_RECALL_INSTRUCTIONS = (
|
||||
"Retrieve concise long-term memory, preferences, prior decisions, and relevant "
|
||||
"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,
|
||||
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(
|
||||
skill_id=AISkillId.HERMES_MEMORY_RECALL,
|
||||
source=AISkillSource.AI_MEMORY,
|
||||
|
||||
@@ -18,6 +18,7 @@ class AIMemorySource(StrEnum):
|
||||
AUTO = "auto"
|
||||
HERMES = "hermes"
|
||||
API = "api"
|
||||
USER_RULE = "user_rule"
|
||||
|
||||
|
||||
class AIMemoryResponseKey(StrEnum):
|
||||
@@ -53,3 +54,5 @@ AI_MEMORY_CODE_PREFIX = "MEM"
|
||||
AI_MEMORY_MAX_CONTENT_LENGTH = 2000
|
||||
AI_MEMORY_MAX_SUMMARY_LENGTH = 500
|
||||
AI_MEMORY_MIN_AUTO_WRITE_LENGTH = 12
|
||||
AI_USER_RULE_MAX_PRIORITY = 100
|
||||
AI_USER_RULE_MIN_PRIORITY = 1
|
||||
|
||||
@@ -2,9 +2,13 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.schemas import AIMemoryRecallRequest
|
||||
from app.modules.ai_memory.schemas import (
|
||||
AIMemoryRecallRequest,
|
||||
AIUserRuleCreate,
|
||||
AIUserRuleUpdate,
|
||||
)
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
@@ -42,3 +46,60 @@ def recall_memory(
|
||||
actor=principal.actor,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,3 +27,18 @@ class AIMemoryRead(BaseModel):
|
||||
expires_at: str | None
|
||||
created_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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -17,6 +19,8 @@ from app.modules.ai_memory.constants import (
|
||||
AIMemorySource,
|
||||
AIMemoryStatus,
|
||||
AIMemoryText,
|
||||
AI_USER_RULE_MAX_PRIORITY,
|
||||
AI_USER_RULE_MIN_PRIORITY,
|
||||
)
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.audit.constants import (
|
||||
@@ -164,6 +168,144 @@ class AIMemoryService:
|
||||
)
|
||||
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]:
|
||||
rows = self.db.execute(
|
||||
select(AIMemoryEntry.status, func.count()).group_by(AIMemoryEntry.status)
|
||||
@@ -181,9 +323,13 @@ class AIMemoryService:
|
||||
importance: int,
|
||||
status_value: str,
|
||||
actor: str,
|
||||
audit_action: str = AuditAction.AI_MEMORY_WRITE,
|
||||
) -> 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,
|
||||
subject=subject,
|
||||
content=content,
|
||||
@@ -201,7 +347,7 @@ class AIMemoryService:
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.AI_MEMORY,
|
||||
action=AuditAction.AI_MEMORY_WRITE,
|
||||
action=audit_action,
|
||||
target_type=AuditTargetType.AI_MEMORY,
|
||||
target_id=record.code,
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
|
||||
@@ -10,12 +10,15 @@ class AuditAction(StrEnum):
|
||||
FEISHU_LONG_CONNECTION_EVENT = "long_connection_event"
|
||||
FEISHU_SEND_TEXT = "send_text"
|
||||
FEISHU_SEND_CARD = "send_card"
|
||||
FEISHU_UPLOAD_IMAGE = "upload_image"
|
||||
LEGACY_SYNC_PROJECTS = "sync_projects"
|
||||
LEGACY_SYNC_TASKS = "sync_tasks"
|
||||
RISK_EVENT_ACTION = "risk_event_action"
|
||||
REPORT_PUSH = "report_push"
|
||||
AI_MEMORY_RECALL = "ai.memory_recall"
|
||||
AI_MEMORY_WRITE = "ai.memory_write"
|
||||
AI_RULE_CREATE = "ai.rule_create"
|
||||
AI_RULE_UPDATE = "ai.rule_update"
|
||||
ENTERPRISE_ANALYTICS = "enterprise_analytics"
|
||||
EVENT_DISPATCH = "event.dispatch"
|
||||
HEARTBEAT = "heartbeat"
|
||||
|
||||
@@ -54,6 +54,25 @@ class SourceSystem(StrEnum):
|
||||
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):
|
||||
BANK = "bank"
|
||||
|
||||
|
||||
@@ -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.governance import PerformanceMetric, Policy, Standard
|
||||
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.reports import ReportPushRun, WorkReport
|
||||
from app.modules.business.models.risks import RiskEvent, RiskEventAction
|
||||
@@ -11,17 +19,23 @@ from app.modules.business.models.suppliers import Supplier
|
||||
__all__ = [
|
||||
"AttendanceRecord",
|
||||
"Expense",
|
||||
"Employee",
|
||||
"FundAccount",
|
||||
"LegacySyncRun",
|
||||
"PerformanceMetric",
|
||||
"Policy",
|
||||
"Procurement",
|
||||
"Project",
|
||||
"ProjectCashFlow",
|
||||
"ProjectContract",
|
||||
"ProjectMember",
|
||||
"ProjectMilestone",
|
||||
"ReportPushRun",
|
||||
"RiskEvent",
|
||||
"RiskEventAction",
|
||||
"Standard",
|
||||
"Supplier",
|
||||
"SourceSyncCursor",
|
||||
"WorkReport",
|
||||
"WorkTask",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 app.core.database import Base
|
||||
@@ -28,3 +28,9 @@ class AttendanceRecord(Base, TimestampMixin):
|
||||
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL)
|
||||
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=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)
|
||||
|
||||
135
app/modules/business/models/lifecycle.py
Normal file
135
app/modules/business/models/lifecycle.py
Normal 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)
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import date, datetime
|
||||
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 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)
|
||||
budget_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)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL)
|
||||
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):
|
||||
__tablename__ = "work_tasks"
|
||||
@@ -49,3 +66,8 @@ class WorkTask(Base, TimestampMixin):
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL)
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 app.core.constants import ActorValue
|
||||
@@ -30,6 +30,13 @@ class WorkReport(Base, TimestampMixin):
|
||||
risk_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(64), default=StatusValue.GENERATED, index=True)
|
||||
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):
|
||||
__tablename__ = "report_push_runs"
|
||||
@@ -47,3 +54,4 @@ class ReportPushRun(Base, TimestampMixin):
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
queued_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=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)
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.modules.feishu.constants import (
|
||||
FEISHU_AUTH_MISSING,
|
||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS,
|
||||
FEISHU_MESSAGE_PATH,
|
||||
FEISHU_IMAGE_PATH,
|
||||
FEISHU_RECEIVE_ID_MISSING,
|
||||
FEISHU_SUCCESS_CODE,
|
||||
FEISHU_TENANT_TOKEN_PATH,
|
||||
@@ -103,6 +104,32 @@ class FeishuClient:
|
||||
{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(
|
||||
self,
|
||||
card: dict[str, Any],
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.constants import AIResponseKey
|
||||
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 (
|
||||
FEISHU_AI_REPLY_TITLE,
|
||||
FEISHU_MENTION_PATTERN,
|
||||
@@ -20,6 +23,7 @@ from app.modules.feishu.constants import (
|
||||
FeishuReplyType,
|
||||
)
|
||||
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.reports.services import ReportService
|
||||
from app.modules.risk.constants import RiskSummaryKey
|
||||
@@ -32,6 +36,22 @@ RISK_KEYWORDS = ("风险", "预警", "risk")
|
||||
AI_COMMAND_PREFIXES = ("问 ", "ai ", "AI ", "/ask ")
|
||||
RISK_TITLE = "风险预警"
|
||||
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:
|
||||
@@ -125,6 +145,24 @@ class FeishuCommandService:
|
||||
lowered = command_text.lower()
|
||||
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):
|
||||
report = ReportService(self.db).daily_brief()
|
||||
if auto_reply:
|
||||
@@ -235,6 +273,233 @@ class FeishuCommandService:
|
||||
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(
|
||||
self,
|
||||
chat_id: str | None,
|
||||
|
||||
@@ -24,10 +24,17 @@ class FeishuPayloadKey(StrEnum):
|
||||
CONFIG = "config"
|
||||
CONTENT = "content"
|
||||
DIV = "div"
|
||||
DATA = "data"
|
||||
ELEMENTS = "elements"
|
||||
EXPIRE = "expire"
|
||||
FEISHU_ERROR = "feishu_error"
|
||||
HEADER = "header"
|
||||
IMAGE = "image"
|
||||
IMAGE_KEY = "image_key"
|
||||
IMAGE_TYPE = "image_type"
|
||||
IMG = "img"
|
||||
IMG_KEY = "img_key"
|
||||
ALT = "alt"
|
||||
EVENT = "event"
|
||||
EVENT_ID = "event_id"
|
||||
EVENT_TYPE = "event_type"
|
||||
@@ -78,6 +85,12 @@ class FeishuCommandResultKey(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"
|
||||
PROJECT_WEEKLY = "project_weekly"
|
||||
ATTENDANCE_SUMMARY = "attendance_summary"
|
||||
@@ -106,6 +119,7 @@ class FeishuCardKey(StrEnum):
|
||||
|
||||
FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
|
||||
FEISHU_MESSAGE_PATH = "/im/v1/messages"
|
||||
FEISHU_IMAGE_PATH = "/im/v1/images"
|
||||
FEISHU_DEFAULT_OPEN_API_DOMAIN = "https://open.feishu.cn"
|
||||
FEISHU_AUTH_MISSING = "Feishu app credentials are not configured"
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required"
|
||||
|
||||
@@ -89,8 +89,51 @@ class FeishuService:
|
||||
)
|
||||
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
|
||||
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 {
|
||||
FeishuPayloadKey.CONFIG: {FeishuPayloadKey.WIDE_SCREEN_MODE: True},
|
||||
FeishuPayloadKey.HEADER: {
|
||||
@@ -99,13 +142,5 @@ class FeishuService:
|
||||
FeishuPayloadKey.CONTENT: title,
|
||||
}
|
||||
},
|
||||
FeishuPayloadKey.ELEMENTS: [
|
||||
{
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.DIV,
|
||||
FeishuPayloadKey.TEXT: {
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.LARK_MARKDOWN,
|
||||
FeishuPayloadKey.CONTENT: "\n".join(lines) or FEISHU_EMPTY_CARD_TEXT,
|
||||
},
|
||||
}
|
||||
],
|
||||
FeishuPayloadKey.ELEMENTS: elements,
|
||||
}
|
||||
|
||||
1143
app/modules/legacy_mysql/intasect.py
Normal file
1143
app/modules/legacy_mysql/intasect.py
Normal file
File diff suppressed because it is too large
Load Diff
182
app/modules/reports/chart.py
Normal file
182
app/modules/reports/chart.py
Normal 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)
|
||||
@@ -14,6 +14,10 @@ class ReportTitle(StrEnum):
|
||||
WORK_WEEKLY = "经营周报"
|
||||
PROJECT_LIFECYCLE = "项目全生命周期报告"
|
||||
ENTERPRISE_ANALYTICS = "企业只读运营分析"
|
||||
LIFECYCLE_DAILY = "项目与人员生命周期日报"
|
||||
LIFECYCLE_WEEKLY = "项目与人员生命周期周报"
|
||||
PERSONNEL_LIFECYCLE = "人员生命周期分析"
|
||||
PROJECT_FINANCE_NEEDS = "项目资金需求分析"
|
||||
|
||||
|
||||
class ReportStatus(StrEnum):
|
||||
@@ -37,10 +41,12 @@ class ReportPushKey(StrEnum):
|
||||
|
||||
class ReportErrorDetail(StrEnum):
|
||||
PUSH_RUN_NOT_FOUND = "Report push run not found"
|
||||
INVALID_LIFECYCLE_REPORT_TYPE = "Lifecycle report type must be daily or weekly"
|
||||
|
||||
|
||||
class LifecycleSection(StrEnum):
|
||||
HEALTH = "health"
|
||||
FINANCE = "finance"
|
||||
PROJECTS = "projects"
|
||||
TASKS = "tasks"
|
||||
PROCUREMENTS = "procurements"
|
||||
|
||||
229
app/modules/reports/lifecycle_pipeline.py
Normal file
229
app/modules/reports/lifecycle_pipeline.py
Normal 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()
|
||||
@@ -3,11 +3,16 @@ from datetime import date
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.background.task_queue import enqueue_daily_brief_push, enqueue_project_weekly_push
|
||||
from app.core.background.task_queue import (
|
||||
enqueue_daily_brief_push,
|
||||
enqueue_lifecycle_report,
|
||||
enqueue_project_weekly_push,
|
||||
)
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
|
||||
from app.modules.reports.constants import ReportPushKey
|
||||
from app.modules.reports.schemas import (
|
||||
LifecycleRunRequest,
|
||||
PushReportRequest,
|
||||
ReportResponse,
|
||||
WorkReportGenerateRequest,
|
||||
@@ -77,6 +82,56 @@ def attendance_summary(
|
||||
return ReportService(db).attendance_summary(work_date)
|
||||
|
||||
|
||||
@router.get("/personnel-lifecycle")
|
||||
def personnel_lifecycle_report(
|
||||
department: str | None = None,
|
||||
employee_code: str | None = None,
|
||||
project_code: str | None = None,
|
||||
period_start: date | None = None,
|
||||
period_end: date | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return ReportService(db).personnel_lifecycle_report(
|
||||
department=department,
|
||||
employee_code=employee_code,
|
||||
project_code=project_code,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/project-finance-needs")
|
||||
def project_finance_needs_report(
|
||||
project_code: str | None = None,
|
||||
department: str | None = None,
|
||||
as_of: date | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return ReportService(db).project_finance_needs_report(
|
||||
project_code=project_code,
|
||||
department=department,
|
||||
as_of=as_of,
|
||||
include_ai=False,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/lifecycle/enqueue")
|
||||
def enqueue_lifecycle(
|
||||
payload: LifecycleRunRequest,
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return enqueue_lifecycle_report(
|
||||
report_type=payload.report_type,
|
||||
receive_id=payload.receive_id,
|
||||
receive_id_type=payload.receive_id_type,
|
||||
force=payload.force,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/push-runs")
|
||||
def list_push_runs(
|
||||
status: str | None = None,
|
||||
|
||||
@@ -28,3 +28,10 @@ class WorkReportGenerateRequest(BaseModel):
|
||||
period_end: date | None = None
|
||||
persist: bool = False
|
||||
actor: str = ActorValue.API
|
||||
|
||||
|
||||
class LifecycleRunRequest(BaseModel):
|
||||
report_type: ReportType = ReportType.DAILY
|
||||
receive_id: str | None = None
|
||||
receive_id_type: str = FeishuReceiveIdType.CHAT_ID
|
||||
force: bool = False
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.modules.reports.constants import (
|
||||
ReportPushStatus,
|
||||
ReportResponseKey,
|
||||
)
|
||||
from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart
|
||||
|
||||
|
||||
class ReportDeliveryMixin:
|
||||
@@ -39,12 +40,24 @@ class ReportDeliveryMixin:
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
card = FeishuService.build_basic_card(
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
)
|
||||
try:
|
||||
result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
|
||||
feishu = FeishuService(self.db)
|
||||
image_key = None
|
||||
image_alt = None
|
||||
chart_data = report.get("chart_data")
|
||||
if chart_data:
|
||||
image_result = feishu.upload_image(render_lifecycle_chart(chart_data), actor)
|
||||
image_key = (image_result.get("data") or {}).get("image_key")
|
||||
if not image_key:
|
||||
raise ValueError("Feishu image upload did not return image_key")
|
||||
image_alt = lifecycle_chart_alt(chart_data)
|
||||
card = FeishuService.build_basic_card(
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
image_key=image_key,
|
||||
image_alt=image_alt,
|
||||
)
|
||||
result = feishu.send_card(card, receive_id, receive_id_type, actor)
|
||||
except Exception as exc:
|
||||
failed_run = self.update_push_run(
|
||||
push_run.code,
|
||||
|
||||
506
app/modules/reports/services/finance_needs.py
Normal file
506
app/modules/reports/services/finance_needs.py
Normal file
@@ -0,0 +1,506 @@
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||||
from app.modules.business.constants import (
|
||||
CashFlowDirection,
|
||||
CashFlowType,
|
||||
DataQualityStatus,
|
||||
SourceSystem,
|
||||
StatusValue,
|
||||
)
|
||||
from app.modules.business.models import (
|
||||
Project,
|
||||
ProjectCashFlow,
|
||||
ProjectContract,
|
||||
ProjectMilestone,
|
||||
RiskEvent,
|
||||
SourceSyncCursor,
|
||||
WorkTask,
|
||||
)
|
||||
from app.modules.reports.constants import ReportResponseKey, ReportTitle
|
||||
|
||||
|
||||
ZERO = Decimal("0")
|
||||
FINANCE_HORIZONS = (7, 30, 90)
|
||||
|
||||
|
||||
class FinanceNeedsReportMixin:
|
||||
def project_finance_needs_report(
|
||||
self,
|
||||
project_code: str | None = None,
|
||||
department: str | None = None,
|
||||
owner: str | None = None,
|
||||
as_of: date | None = None,
|
||||
include_ai: bool = False,
|
||||
actor: str = ActorValue.API,
|
||||
) -> dict[str, Any]:
|
||||
reference = as_of or date.today()
|
||||
project_stmt = select(Project).where(
|
||||
Project.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
Project.is_active.is_(True),
|
||||
)
|
||||
if project_code:
|
||||
project_stmt = project_stmt.where(
|
||||
(Project.code == project_code) | (Project.display_code == project_code)
|
||||
)
|
||||
if department:
|
||||
project_stmt = project_stmt.where(Project.department_name == department)
|
||||
if owner:
|
||||
project_stmt = project_stmt.where(Project.owner == owner)
|
||||
projects = list(self.db.execute(project_stmt).scalars())
|
||||
codes = {project.code for project in projects}
|
||||
|
||||
contracts = list(
|
||||
self.db.execute(
|
||||
select(ProjectContract).where(
|
||||
ProjectContract.is_active.is_(True),
|
||||
ProjectContract.data_quality_status == DataQualityStatus.VALID,
|
||||
ProjectContract.project_code.in_(codes or {""}),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
flows = list(
|
||||
self.db.execute(
|
||||
select(ProjectCashFlow).where(
|
||||
ProjectCashFlow.is_active.is_(True),
|
||||
ProjectCashFlow.project_code.in_(codes or {""}),
|
||||
ProjectCashFlow.data_quality_status.notin_(
|
||||
{
|
||||
DataQualityStatus.ORPHAN_CONTRACT,
|
||||
DataQualityStatus.ORPHAN_PROJECT,
|
||||
DataQualityStatus.DELETED_PROJECT,
|
||||
}
|
||||
),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
contract_amounts: dict[str, Decimal] = defaultdict(lambda: ZERO)
|
||||
for contract in contracts:
|
||||
if contract.project_code:
|
||||
contract_amounts[contract.project_code] += contract.amount or ZERO
|
||||
flows_by_project: dict[str, list[ProjectCashFlow]] = defaultdict(list)
|
||||
for flow in flows:
|
||||
if flow.project_code:
|
||||
flows_by_project[flow.project_code].append(flow)
|
||||
|
||||
delivery_risk_codes = self._finance_delivery_risk_codes(codes, reference)
|
||||
items = [
|
||||
self._finance_project_item(
|
||||
project,
|
||||
contract_amounts.get(project.code, ZERO),
|
||||
flows_by_project.get(project.code, []),
|
||||
reference,
|
||||
project.code in delivery_risk_codes,
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
attention = sorted(
|
||||
[item for item in items if item["finance_covered"]],
|
||||
key=lambda item: (
|
||||
not item["uncovered_pending_outflow"],
|
||||
not item["delivery_risk"],
|
||||
-item["funding_need"]["30"]["lower"],
|
||||
item["project_code"],
|
||||
),
|
||||
)
|
||||
summary = self._finance_summary(items)
|
||||
quality = self._finance_data_quality(codes if project_code or department else None)
|
||||
latest_sync = self._finance_latest_sync_at()
|
||||
lines = self._finance_lines(reference, summary, quality, attention)
|
||||
chart_data = {
|
||||
"as_of": reference.isoformat(),
|
||||
"receivables": {
|
||||
"overdue": summary["overdue_receivable"],
|
||||
"due_30d": summary["horizons"]["30"]["receivable_due"],
|
||||
},
|
||||
"cashflows": {
|
||||
"confirmed_inflow": summary["confirmed_inflow"],
|
||||
"confirmed_outflow": summary["confirmed_outflow"],
|
||||
"pending_outflow": summary["pending_outflow"],
|
||||
},
|
||||
"top_projects": [
|
||||
{
|
||||
"name": item["project_name"][:18],
|
||||
"amount": item["funding_need"]["30"]["upper"],
|
||||
}
|
||||
for item in attention[:5]
|
||||
if item["funding_need"]["30"]["upper"] > 0
|
||||
],
|
||||
} if summary["data_available"] else None
|
||||
report: dict[str, Any] = {
|
||||
ReportResponseKey.TITLE: ReportTitle.PROJECT_FINANCE_NEEDS,
|
||||
"as_of": reference.isoformat(),
|
||||
"currency": "CNY",
|
||||
"source_last_sync_at": latest_sync,
|
||||
"summary": summary,
|
||||
"data_quality": quality,
|
||||
"items": items,
|
||||
"attention": attention[:10],
|
||||
ReportResponseKey.LINES: lines,
|
||||
ReportResponseKey.CONTENT: "\n".join(lines),
|
||||
"finance_chart_data": chart_data,
|
||||
"disclaimer": "项目资金安排需求不包含公司账户余额,不代表真实融资缺口。",
|
||||
}
|
||||
ai_analysis = self._finance_ai_analysis(report, actor) if include_ai else None
|
||||
report["ai_analysis"] = ai_analysis
|
||||
if ai_analysis and ai_analysis.get(AIResponseKey.OK):
|
||||
answer = str(ai_analysis.get(AIResponseKey.ANSWER) or "")[:3000]
|
||||
lines.append("- AI 资金分析:")
|
||||
lines.extend(f" {line}" for line in answer.splitlines() if line.strip())
|
||||
report[ReportResponseKey.CONTENT] = "\n".join(lines)
|
||||
return report
|
||||
|
||||
def _finance_project_item(
|
||||
self,
|
||||
project: Project,
|
||||
contract_amount: Decimal,
|
||||
flows: list[ProjectCashFlow],
|
||||
reference: date,
|
||||
delivery_risk: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not contract_amount and project.source_contract_amount:
|
||||
contract_amount = project.source_contract_amount
|
||||
actual_receipt = ZERO
|
||||
overdue = ZERO
|
||||
confirmed_inflow = ZERO
|
||||
confirmed_outflow = ZERO
|
||||
pending_outflow = ZERO
|
||||
receivable_by_horizon = {days: ZERO for days in FINANCE_HORIZONS}
|
||||
for flow in flows:
|
||||
actual = flow.actual_amount or ZERO
|
||||
planned = flow.planned_amount or ZERO
|
||||
if flow.flow_type == CashFlowType.CONTRACT_RECEIVABLE:
|
||||
actual_receipt += max(actual, ZERO)
|
||||
confirmed_inflow += max(actual, ZERO)
|
||||
if flow.payment_status == "N":
|
||||
outstanding = max(planned - max(actual, ZERO), ZERO)
|
||||
if flow.planned_date and flow.planned_date < reference:
|
||||
overdue += outstanding
|
||||
elif flow.planned_date:
|
||||
for days in FINANCE_HORIZONS:
|
||||
if flow.planned_date <= reference + timedelta(days=days):
|
||||
receivable_by_horizon[days] += outstanding
|
||||
elif flow.flow_type == CashFlowType.PROJECT_FUND:
|
||||
if flow.confirmation_status == "Y" and flow.approval_status == "1":
|
||||
if flow.direction == CashFlowDirection.INFLOW:
|
||||
confirmed_inflow += actual
|
||||
else:
|
||||
confirmed_outflow += actual
|
||||
elif (
|
||||
flow.direction == CashFlowDirection.OUTFLOW
|
||||
and flow.approval_status == "1"
|
||||
and flow.confirmation_status == "N"
|
||||
):
|
||||
pending_outflow += planned
|
||||
funding_need = {
|
||||
str(days): {
|
||||
"lower": _amount(max(pending_outflow - receivable_by_horizon[days], ZERO)),
|
||||
"upper": _amount(pending_outflow),
|
||||
}
|
||||
for days in FINANCE_HORIZONS
|
||||
}
|
||||
covered = bool(contract_amount or flows)
|
||||
if not covered:
|
||||
return {
|
||||
"project_code": project.code,
|
||||
"display_code": project.display_code,
|
||||
"project_name": project.name,
|
||||
"department": project.department_name,
|
||||
"owner": project.owner,
|
||||
"stage": project.source_stage_label or project.source_stage,
|
||||
"contract_revenue": None,
|
||||
"project_investment_context": (
|
||||
_amount(project.source_project_investment_amount)
|
||||
if project.source_project_investment_amount is not None
|
||||
else None
|
||||
),
|
||||
"actual_receipt": None,
|
||||
"overdue_receivable": None,
|
||||
"confirmed_inflow": None,
|
||||
"confirmed_outflow": None,
|
||||
"pending_outflow": None,
|
||||
"receivable_due": {str(days): None for days in FINANCE_HORIZONS},
|
||||
"funding_need": {
|
||||
str(days): {"lower": None, "upper": None} for days in FINANCE_HORIZONS
|
||||
},
|
||||
"uncovered_pending_outflow": False,
|
||||
"delivery_risk": delivery_risk,
|
||||
"finance_covered": False,
|
||||
}
|
||||
return {
|
||||
"project_code": project.code,
|
||||
"display_code": project.display_code,
|
||||
"project_name": project.name,
|
||||
"department": project.department_name,
|
||||
"owner": project.owner,
|
||||
"stage": project.source_stage_label or project.source_stage,
|
||||
"contract_revenue": _amount(contract_amount),
|
||||
"project_investment_context": _amount(
|
||||
project.source_project_investment_amount or ZERO
|
||||
),
|
||||
"actual_receipt": _amount(actual_receipt),
|
||||
"overdue_receivable": _amount(overdue),
|
||||
"confirmed_inflow": _amount(confirmed_inflow),
|
||||
"confirmed_outflow": _amount(confirmed_outflow),
|
||||
"pending_outflow": _amount(pending_outflow),
|
||||
"receivable_due": {
|
||||
str(days): _amount(receivable_by_horizon[days]) for days in FINANCE_HORIZONS
|
||||
},
|
||||
"funding_need": funding_need,
|
||||
"uncovered_pending_outflow": funding_need["30"]["lower"] > 0,
|
||||
"delivery_risk": delivery_risk,
|
||||
"finance_covered": True,
|
||||
}
|
||||
|
||||
def _finance_summary(self, items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
result = {
|
||||
"projects_total": len(items),
|
||||
"projects_covered": sum(1 for item in items if item["finance_covered"]),
|
||||
"contract_revenue": _sum_items(items, "contract_revenue"),
|
||||
"actual_receipt": _sum_items(items, "actual_receipt"),
|
||||
"overdue_receivable": _sum_items(items, "overdue_receivable"),
|
||||
"confirmed_inflow": _sum_items(items, "confirmed_inflow"),
|
||||
"confirmed_outflow": _sum_items(items, "confirmed_outflow"),
|
||||
"pending_outflow": _sum_items(items, "pending_outflow"),
|
||||
"horizons": {},
|
||||
}
|
||||
result["coverage_rate"] = round(
|
||||
result["projects_covered"] * 100 / result["projects_total"], 2
|
||||
) if result["projects_total"] else 0.0
|
||||
result["data_available"] = result["projects_covered"] > 0
|
||||
result["horizons"] = {
|
||||
str(days): {
|
||||
"receivable_due": round(
|
||||
sum(
|
||||
item["receivable_due"][str(days)] or 0
|
||||
for item in items
|
||||
), 2
|
||||
),
|
||||
"funding_need_lower": round(
|
||||
sum(
|
||||
item["funding_need"][str(days)]["lower"] or 0
|
||||
for item in items
|
||||
), 2
|
||||
),
|
||||
"funding_need_upper": round(
|
||||
sum(
|
||||
item["funding_need"][str(days)]["upper"] or 0
|
||||
for item in items
|
||||
), 2
|
||||
),
|
||||
}
|
||||
for days in FINANCE_HORIZONS
|
||||
}
|
||||
if not result["data_available"]:
|
||||
for key in (
|
||||
"contract_revenue",
|
||||
"actual_receipt",
|
||||
"overdue_receivable",
|
||||
"confirmed_inflow",
|
||||
"confirmed_outflow",
|
||||
"pending_outflow",
|
||||
):
|
||||
result[key] = None
|
||||
result["horizons"] = {
|
||||
str(days): {
|
||||
"receivable_due": None,
|
||||
"funding_need_lower": None,
|
||||
"funding_need_upper": None,
|
||||
}
|
||||
for days in FINANCE_HORIZONS
|
||||
}
|
||||
return result
|
||||
|
||||
def _finance_data_quality(self, project_codes: set[str] | None) -> dict[str, int]:
|
||||
conditions: list[Any] = [ProjectCashFlow.is_active.is_(True)]
|
||||
if project_codes is not None:
|
||||
conditions.append(ProjectCashFlow.project_code.in_(project_codes or {""}))
|
||||
rows = self.db.execute(
|
||||
select(
|
||||
ProjectCashFlow.flow_type,
|
||||
ProjectCashFlow.data_quality_status,
|
||||
func.count(),
|
||||
)
|
||||
.where(*conditions)
|
||||
.group_by(ProjectCashFlow.flow_type, ProjectCashFlow.data_quality_status)
|
||||
).all()
|
||||
counts = {
|
||||
(str(flow_type), str(quality_status)): int(count)
|
||||
for flow_type, quality_status, count in rows
|
||||
}
|
||||
by_status: dict[str, int] = defaultdict(int)
|
||||
for (_, quality_status), count in counts.items():
|
||||
by_status[quality_status] += count
|
||||
return {
|
||||
"orphan_contracts": counts.get(
|
||||
(CashFlowType.CONTRACT_RECEIVABLE, DataQualityStatus.ORPHAN_CONTRACT), 0
|
||||
),
|
||||
"orphan_receivable_projects": counts.get(
|
||||
(CashFlowType.CONTRACT_RECEIVABLE, DataQualityStatus.ORPHAN_PROJECT), 0
|
||||
),
|
||||
"orphan_funds": counts.get(
|
||||
(CashFlowType.PROJECT_FUND, DataQualityStatus.ORPHAN_PROJECT), 0
|
||||
),
|
||||
"deleted_projects": by_status[DataQualityStatus.DELETED_PROJECT],
|
||||
"paid_amount_missing": by_status[DataQualityStatus.PAID_AMOUNT_MISSING],
|
||||
"status_amount_mismatch": by_status[DataQualityStatus.STATUS_AMOUNT_MISMATCH],
|
||||
}
|
||||
|
||||
def _finance_delivery_risk_codes(self, codes: set[str], reference: date) -> set[str]:
|
||||
if not codes:
|
||||
return set()
|
||||
milestone_codes = set(
|
||||
self.db.execute(
|
||||
select(ProjectMilestone.project_code).where(
|
||||
ProjectMilestone.project_code.in_(codes),
|
||||
ProjectMilestone.is_active.is_(True),
|
||||
ProjectMilestone.is_overdue.is_(True),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
task_codes = set(
|
||||
self.db.execute(
|
||||
select(WorkTask.project_code).where(
|
||||
WorkTask.project_code.in_(codes),
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.status != StatusValue.COMPLETED,
|
||||
WorkTask.due_date.is_not(None),
|
||||
WorkTask.due_date < reference,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
event_codes = set(
|
||||
self.db.execute(
|
||||
select(RiskEvent.project_code).where(
|
||||
RiskEvent.project_code.in_(codes),
|
||||
RiskEvent.source_domain == "intasect_project_event",
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
return {str(code) for code in milestone_codes | task_codes | event_codes if code}
|
||||
|
||||
def _finance_latest_sync_at(self) -> str | None:
|
||||
datasets = {"projects", "contracts", "contract_receivables", "project_funds"}
|
||||
cursors = list(
|
||||
self.db.execute(
|
||||
select(SourceSyncCursor).where(SourceSyncCursor.dataset.in_(datasets))
|
||||
).scalars()
|
||||
)
|
||||
if (
|
||||
len(cursors) != len(datasets)
|
||||
or any(cursor.status != StatusValue.COMPLETED for cursor in cursors)
|
||||
or any(cursor.last_success_at is None for cursor in cursors)
|
||||
):
|
||||
return None
|
||||
value = min(cursor.last_success_at for cursor in cursors if cursor.last_success_at)
|
||||
return value.isoformat()
|
||||
|
||||
def _finance_lines(
|
||||
self,
|
||||
reference: date,
|
||||
summary: dict[str, Any],
|
||||
quality: dict[str, int],
|
||||
attention: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
horizon = summary["horizons"]["30"]
|
||||
if not summary["data_available"]:
|
||||
return [
|
||||
f"- 资金分析基准日:{reference.isoformat()}(人民币元)",
|
||||
f"- 财务覆盖:0/{summary['projects_total']} 个项目。",
|
||||
"- 项目财务数据未接入或无有效记录,金额不按零值解释。",
|
||||
"- 注意:源库没有公司账户余额,不能计算真实融资缺口。",
|
||||
]
|
||||
lines = [
|
||||
f"- 资金分析基准日:{reference.isoformat()}(人民币元)",
|
||||
f"- 财务覆盖:{summary['projects_covered']}/{summary['projects_total']} 个项目,"
|
||||
f"覆盖率 {summary['coverage_rate']}%",
|
||||
f"- 逾期应收:{_money(summary['overdue_receivable'])},"
|
||||
f"未来30天应收:{_money(horizon['receivable_due'])}",
|
||||
f"- 待确认支出:{_money(summary['pending_outflow'])},"
|
||||
f"已确认流入/流出:{_money(summary['confirmed_inflow'])}/"
|
||||
f"{_money(summary['confirmed_outflow'])}",
|
||||
f"- 未来30天项目资金安排需求:{_money(horizon['funding_need_lower'])}"
|
||||
f" 至 {_money(horizon['funding_need_upper'])}",
|
||||
"- 注意:该区间不包含公司账户余额,不代表真实融资缺口。",
|
||||
f"- 数据异常:孤儿合同付款 {quality['orphan_contracts']},"
|
||||
f"孤儿资金记录 {quality['orphan_funds']},"
|
||||
f"已支付缺金额 {quality['paid_amount_missing']},"
|
||||
f"状态金额不一致 {quality['status_amount_mismatch']}",
|
||||
]
|
||||
for item in attention[:5]:
|
||||
need = item["funding_need"]["30"]
|
||||
if need["upper"] <= 0 and item["overdue_receivable"] <= 0:
|
||||
continue
|
||||
lines.append(
|
||||
f" - {item['project_name']}({item['display_code'] or item['project_code']}):"
|
||||
f"资金安排 {_money(need['lower'])}-{_money(need['upper'])},"
|
||||
f"逾期应收 {_money(item['overdue_receivable'])}"
|
||||
)
|
||||
return lines
|
||||
|
||||
def _finance_ai_analysis(self, report: dict[str, Any], actor: str) -> dict[str, Any]:
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.ai_agent.skills import AISkillId
|
||||
|
||||
context = {
|
||||
"as_of": report["as_of"],
|
||||
"currency": report["currency"],
|
||||
"summary": report["summary"],
|
||||
"data_quality": report["data_quality"],
|
||||
"attention": [
|
||||
{
|
||||
"project_code": item["project_code"],
|
||||
"display_code": item["display_code"],
|
||||
"project_name": item["project_name"],
|
||||
"stage": item["stage"],
|
||||
"overdue_receivable": item["overdue_receivable"],
|
||||
"pending_outflow": item["pending_outflow"],
|
||||
"receivable_due_30d": item["receivable_due"]["30"],
|
||||
"funding_need_30d": item["funding_need"]["30"],
|
||||
"delivery_risk": item["delivery_risk"],
|
||||
}
|
||||
for item in report["attention"][:10]
|
||||
],
|
||||
"disclaimer": report["disclaimer"],
|
||||
}
|
||||
last_error: Exception | None = None
|
||||
attempts = max(1, min(get_settings().ai_analysis_max_attempts, 5))
|
||||
for _ in range(attempts):
|
||||
try:
|
||||
result = AIService(self.db).run_skill(
|
||||
AISkillId.PROJECT_FINANCE_NEEDS_ANALYSIS,
|
||||
context=context,
|
||||
actor=actor,
|
||||
)
|
||||
if result.get(AIResponseKey.PROVIDER) == AIProviderName.NOOP:
|
||||
return {AIResponseKey.OK: False, AIResponseKey.ERROR: "AI unavailable"}
|
||||
return {AIResponseKey.OK: True, **result}
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
self.db.rollback()
|
||||
return {
|
||||
AIResponseKey.OK: False,
|
||||
AIResponseKey.ERROR: str(last_error) if last_error else "AI unavailable",
|
||||
AIResponseKey.TYPE: type(last_error).__name__ if last_error else "AIUnavailable",
|
||||
"attempts": attempts,
|
||||
}
|
||||
|
||||
|
||||
def _amount(value: Decimal) -> float:
|
||||
return round(float(value), 2)
|
||||
|
||||
|
||||
def _sum_items(items: list[dict[str, Any]], key: str) -> float:
|
||||
return round(sum(float(item[key]) for item in items if item[key] is not None), 2)
|
||||
|
||||
|
||||
def _money(value: float) -> str:
|
||||
return f"¥{value:,.2f}"
|
||||
492
app/modules/reports/services/intasect_lifecycle.py
Normal file
492
app/modules/reports/services/intasect_lifecycle.py
Normal file
@@ -0,0 +1,492 @@
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||||
from app.modules.business.constants import ATTENDANCE_ABNORMAL_STATUSES, SourceSystem, StatusValue
|
||||
from app.modules.business.models import (
|
||||
AttendanceRecord,
|
||||
Employee,
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectMilestone,
|
||||
RiskEvent,
|
||||
WorkReport,
|
||||
WorkTask,
|
||||
)
|
||||
from app.modules.reports.constants import ReportResponseKey, ReportTitle, ReportType
|
||||
|
||||
|
||||
class IntasectLifecycleReportMixin:
|
||||
def source_project_lifecycle_summary(
|
||||
self,
|
||||
project_code: str | None = None,
|
||||
owner: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
project_statement = select(Project).where(
|
||||
Project.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
Project.is_active.is_(True),
|
||||
)
|
||||
if project_code:
|
||||
project_statement = project_statement.where(
|
||||
(Project.code == project_code) | (Project.display_code == project_code)
|
||||
)
|
||||
if owner:
|
||||
project_statement = project_statement.where(Project.owner == owner)
|
||||
projects = list(self.db.execute(project_statement).scalars())
|
||||
project_codes = {item.code for item in projects}
|
||||
unarchived_codes = {item.code for item in projects if not item.source_archived}
|
||||
stage_counts = Counter(
|
||||
item.source_stage_label or item.source_stage or "阶段未知" for item in projects
|
||||
)
|
||||
|
||||
covered_projects = int(
|
||||
self.db.execute(
|
||||
select(func.count(func.distinct(ProjectMember.project_code))).where(
|
||||
ProjectMember.is_active.is_(True),
|
||||
ProjectMember.project_code.in_(project_codes or {""}),
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
milestone_total = self._count_source_rows(
|
||||
ProjectMilestone,
|
||||
ProjectMilestone.is_active.is_(True),
|
||||
ProjectMilestone.project_code.in_(project_codes or {""}),
|
||||
)
|
||||
overdue_milestones = list(
|
||||
self.db.execute(
|
||||
select(ProjectMilestone, Project)
|
||||
.join(Project, Project.code == ProjectMilestone.project_code)
|
||||
.where(
|
||||
ProjectMilestone.is_active.is_(True),
|
||||
ProjectMilestone.is_overdue.is_(True),
|
||||
ProjectMilestone.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
.order_by(ProjectMilestone.plan_end.asc())
|
||||
.limit(10)
|
||||
).all()
|
||||
)
|
||||
overdue_milestone_total = self._count_source_rows(
|
||||
ProjectMilestone,
|
||||
ProjectMilestone.is_active.is_(True),
|
||||
ProjectMilestone.is_overdue.is_(True),
|
||||
ProjectMilestone.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
today = date.today()
|
||||
overdue_tasks = list(
|
||||
self.db.execute(
|
||||
select(WorkTask, Project)
|
||||
.join(Project, Project.code == WorkTask.project_code)
|
||||
.where(
|
||||
WorkTask.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.status != StatusValue.COMPLETED,
|
||||
WorkTask.due_date.is_not(None),
|
||||
WorkTask.due_date < today,
|
||||
WorkTask.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
.order_by(WorkTask.due_date.asc())
|
||||
.limit(10)
|
||||
).all()
|
||||
)
|
||||
overdue_task_total = self._count_source_rows(
|
||||
WorkTask,
|
||||
WorkTask.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.status != StatusValue.COMPLETED,
|
||||
WorkTask.due_date.is_not(None),
|
||||
WorkTask.due_date < today,
|
||||
WorkTask.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
open_events = list(
|
||||
self.db.execute(
|
||||
select(RiskEvent)
|
||||
.where(
|
||||
RiskEvent.source_domain == "intasect_project_event",
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
RiskEvent.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
.limit(10)
|
||||
).scalars()
|
||||
)
|
||||
open_event_total = self._count_source_rows(
|
||||
RiskEvent,
|
||||
RiskEvent.source_domain == "intasect_project_event",
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
RiskEvent.project_code.in_(unarchived_codes or {""}),
|
||||
)
|
||||
linked_tasks = self._count_source_rows(
|
||||
WorkTask,
|
||||
WorkTask.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.project_code.in_(project_codes or {""}),
|
||||
)
|
||||
return {
|
||||
"total": len(projects),
|
||||
"unarchived": len(unarchived_codes),
|
||||
"archived": sum(1 for item in projects if item.source_archived),
|
||||
"unknown_stage": sum(1 for item in projects if not item.source_stage),
|
||||
"stage_counts": dict(stage_counts.most_common()),
|
||||
"member_coverage": {
|
||||
"covered_projects": covered_projects,
|
||||
"uncovered_projects": max(len(projects) - covered_projects, 0),
|
||||
},
|
||||
"milestones": {
|
||||
"total": milestone_total,
|
||||
"overdue": overdue_milestone_total,
|
||||
},
|
||||
"linked_tasks": linked_tasks,
|
||||
"overdue_linked_tasks": overdue_task_total,
|
||||
"open_project_events": open_event_total,
|
||||
"attention": {
|
||||
"overdue_milestones": [
|
||||
{
|
||||
"project_code": project.display_code or project.code,
|
||||
"project_name": project.name,
|
||||
"owner": project.owner,
|
||||
"stage": milestone.stage_name,
|
||||
"plan_end": milestone.plan_end.isoformat() if milestone.plan_end else None,
|
||||
}
|
||||
for milestone, project in overdue_milestones
|
||||
],
|
||||
"overdue_tasks": [
|
||||
{
|
||||
"project_code": project.display_code or project.code,
|
||||
"project_name": project.name,
|
||||
"task": task.title,
|
||||
"owner": task.owner,
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
}
|
||||
for task, project in overdue_tasks
|
||||
],
|
||||
"project_events": [
|
||||
{
|
||||
"project_code": item.project_code,
|
||||
"title": item.title,
|
||||
"risk_level": item.risk_level,
|
||||
"due_date": item.due_date.isoformat() if item.due_date else None,
|
||||
}
|
||||
for item in open_events
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def personnel_lifecycle_report(
|
||||
self,
|
||||
department: str | None = None,
|
||||
employee_code: str | None = None,
|
||||
project_code: str | None = None,
|
||||
period_start: date | None = None,
|
||||
period_end: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
end = period_end or self._latest_completed_attendance_date()
|
||||
start = period_start or end
|
||||
employee_stmt = select(Employee).where(
|
||||
Employee.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
)
|
||||
if department:
|
||||
employee_stmt = employee_stmt.where(Employee.department_name == department)
|
||||
if employee_code:
|
||||
employee_stmt = employee_stmt.where(Employee.code == employee_code)
|
||||
if project_code:
|
||||
member_codes = select(ProjectMember.employee_code).where(
|
||||
ProjectMember.project_code == project_code,
|
||||
ProjectMember.is_active.is_(True),
|
||||
)
|
||||
employee_stmt = employee_stmt.where(Employee.code.in_(member_codes))
|
||||
employees = list(self.db.execute(employee_stmt).scalars())
|
||||
codes = {item.code for item in employees}
|
||||
|
||||
member_counts: dict[str, int] = defaultdict(int)
|
||||
workload: dict[str, int] = defaultdict(int)
|
||||
for code, count, total_workload in self.db.execute(
|
||||
select(
|
||||
ProjectMember.employee_code,
|
||||
func.count(func.distinct(ProjectMember.project_code)),
|
||||
func.coalesce(func.sum(ProjectMember.workload_percent), 0),
|
||||
)
|
||||
.where(
|
||||
ProjectMember.is_active.is_(True), ProjectMember.employee_code.in_(codes or {""})
|
||||
)
|
||||
.group_by(ProjectMember.employee_code)
|
||||
):
|
||||
member_counts[str(code)] = int(count)
|
||||
workload[str(code)] = int(total_workload or 0)
|
||||
|
||||
open_tasks: dict[str, int] = defaultdict(int)
|
||||
overdue_tasks: dict[str, int] = defaultdict(int)
|
||||
for code, task_status, due_date in self.db.execute(
|
||||
select(WorkTask.employee_code, WorkTask.status, WorkTask.due_date).where(
|
||||
WorkTask.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkTask.is_active.is_(True),
|
||||
WorkTask.employee_code.in_(codes or {""}),
|
||||
)
|
||||
):
|
||||
if task_status != StatusValue.COMPLETED:
|
||||
open_tasks[str(code)] += 1
|
||||
if due_date and due_date < date.today():
|
||||
overdue_tasks[str(code)] += 1
|
||||
|
||||
attendance_abnormal: dict[str, int] = defaultdict(int)
|
||||
attendance_seen: set[str] = set()
|
||||
for code, attendance_status in self.db.execute(
|
||||
select(AttendanceRecord.employee_id, AttendanceRecord.status).where(
|
||||
AttendanceRecord.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
AttendanceRecord.attendance_scope == "company",
|
||||
AttendanceRecord.is_active.is_(True),
|
||||
AttendanceRecord.work_date >= start,
|
||||
AttendanceRecord.work_date <= end,
|
||||
AttendanceRecord.employee_id.in_(codes or {""}),
|
||||
)
|
||||
):
|
||||
attendance_seen.add(str(code))
|
||||
if attendance_status in ATTENDANCE_ABNORMAL_STATUSES:
|
||||
attendance_abnormal[str(code)] += 1
|
||||
|
||||
report_counts: dict[str, int] = defaultdict(int)
|
||||
for code, count in self.db.execute(
|
||||
select(WorkReport.employee_code, func.count())
|
||||
.where(
|
||||
WorkReport.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
WorkReport.is_active.is_(True),
|
||||
WorkReport.is_draft.is_(False),
|
||||
WorkReport.period_end >= start,
|
||||
WorkReport.period_end <= end,
|
||||
WorkReport.employee_code.in_(codes or {""}),
|
||||
)
|
||||
.group_by(WorkReport.employee_code)
|
||||
):
|
||||
report_counts[str(code)] = int(count)
|
||||
|
||||
items = []
|
||||
for employee in employees:
|
||||
item = {
|
||||
"employee_code": employee.code,
|
||||
"name": employee.name,
|
||||
"department": employee.department_name,
|
||||
"title": employee.title,
|
||||
"employment_status": employee.employment_status,
|
||||
"project_count": member_counts[employee.code],
|
||||
"planned_workload_percent": workload[employee.code],
|
||||
"open_tasks": open_tasks[employee.code],
|
||||
"overdue_tasks": overdue_tasks[employee.code],
|
||||
"attendance_records": 1 if employee.code in attendance_seen else 0,
|
||||
"attendance_abnormal": attendance_abnormal[employee.code],
|
||||
"submitted_reports": report_counts[employee.code],
|
||||
"attendance_covered": bool(employee.ding_user_id),
|
||||
}
|
||||
item["needs_attention"] = bool(
|
||||
item["overdue_tasks"]
|
||||
or item["attendance_abnormal"]
|
||||
or item["planned_workload_percent"] > 100
|
||||
)
|
||||
items.append(item)
|
||||
attention = [item for item in items if item["needs_attention"]]
|
||||
attention.sort(
|
||||
key=lambda item: (
|
||||
item["overdue_tasks"],
|
||||
item["attendance_abnormal"],
|
||||
item["planned_workload_percent"],
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
metrics = {
|
||||
"total": len(employees),
|
||||
"active": sum(1 for item in employees if item.is_active),
|
||||
"inactive": sum(1 for item in employees if not item.is_active),
|
||||
"attendance_mapped": sum(1 for item in employees if item.ding_user_id),
|
||||
"attention_total": len(attention),
|
||||
"by_department": dict(
|
||||
Counter(item.department_name or "未设置部门" for item in employees)
|
||||
),
|
||||
"period_start": start.isoformat(),
|
||||
"period_end": end.isoformat(),
|
||||
}
|
||||
lines = [
|
||||
f"- 人员总数:{metrics['total']},在职:{metrics['active']},离职/失效:{metrics['inactive']}",
|
||||
f"- 考勤映射覆盖:{metrics['attendance_mapped']}/{metrics['total']}",
|
||||
f"- 需关注人员:{metrics['attention_total']}",
|
||||
]
|
||||
for item in attention[:10]:
|
||||
lines.append(
|
||||
f" - {item['name']}({item['department'] or '未设置部门'}):"
|
||||
f"逾期任务 {item['overdue_tasks']},考勤异常 {item['attendance_abnormal']},"
|
||||
f"计划负荷 {item['planned_workload_percent']}%"
|
||||
)
|
||||
return {
|
||||
ReportResponseKey.TITLE: ReportTitle.PERSONNEL_LIFECYCLE,
|
||||
ReportResponseKey.PERIOD_START: start.isoformat(),
|
||||
ReportResponseKey.PERIOD_END: end.isoformat(),
|
||||
ReportResponseKey.METRICS: metrics,
|
||||
"items": items,
|
||||
"attention": attention[:10],
|
||||
ReportResponseKey.LINES: lines,
|
||||
ReportResponseKey.CONTENT: "\n".join(lines),
|
||||
}
|
||||
|
||||
def management_lifecycle_report(
|
||||
self,
|
||||
report_type: str,
|
||||
actor: str = ActorValue.SCHEDULER,
|
||||
include_ai: bool = True,
|
||||
reference_date: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
start, end = self._management_period(report_type, reference_date)
|
||||
projects = self.source_project_lifecycle_summary()
|
||||
personnel = self.personnel_lifecycle_report(period_start=start, period_end=end)
|
||||
finance = (
|
||||
self.project_finance_needs_report(as_of=end, include_ai=False, actor=actor)
|
||||
if get_settings().finance_needs_enabled
|
||||
else None
|
||||
)
|
||||
title = (
|
||||
ReportTitle.LIFECYCLE_DAILY
|
||||
if report_type == ReportType.DAILY
|
||||
else ReportTitle.LIFECYCLE_WEEKLY
|
||||
)
|
||||
lines = [
|
||||
f"- 统计周期:{start.isoformat()} 至 {end.isoformat()}",
|
||||
f"- 项目:总数 {projects['total']},未归档 {projects['unarchived']},"
|
||||
f"已归档 {projects['archived']},阶段未知 {projects['unknown_stage']}",
|
||||
f"- 项目成员覆盖:{projects['member_coverage']['covered_projects']}/{projects['total']}",
|
||||
f"- 里程碑:总数 {projects['milestones']['total']},逾期 {projects['milestones']['overdue']}",
|
||||
f"- 项目关联任务:{projects['linked_tasks']},逾期 {projects['overdue_linked_tasks']},"
|
||||
f"待协助/超期事项:{projects['open_project_events']}",
|
||||
*personnel[ReportResponseKey.LINES],
|
||||
]
|
||||
if finance:
|
||||
lines.extend(finance[ReportResponseKey.LINES])
|
||||
else:
|
||||
lines.append(
|
||||
"- 项目资金需求:功能未启用;采购、费用、资金、供应商首期未接入,不计为零。"
|
||||
)
|
||||
report = {
|
||||
ReportResponseKey.TITLE: title,
|
||||
ReportResponseKey.REPORT_TYPE: report_type,
|
||||
ReportResponseKey.PERIOD_START: start.isoformat(),
|
||||
ReportResponseKey.PERIOD_END: end.isoformat(),
|
||||
ReportResponseKey.METRICS: {
|
||||
"projects": projects,
|
||||
"personnel": personnel[ReportResponseKey.METRICS],
|
||||
"finance": finance["summary"] if finance else {"status": "未启用"},
|
||||
},
|
||||
"attention": {
|
||||
"projects": projects["attention"],
|
||||
"personnel": personnel["attention"],
|
||||
"finance": finance["attention"] if finance else [],
|
||||
},
|
||||
ReportResponseKey.LINES: lines,
|
||||
"chart_data": {
|
||||
"period": f"{start.isoformat()} - {end.isoformat()}",
|
||||
"projects": {
|
||||
"total": projects["total"],
|
||||
"unarchived": projects["unarchived"],
|
||||
"archived": projects["archived"],
|
||||
},
|
||||
"risks": {
|
||||
"overdue_milestones": projects["milestones"]["overdue"],
|
||||
"overdue_tasks": projects["overdue_linked_tasks"],
|
||||
"open_events": projects["open_project_events"],
|
||||
},
|
||||
"people": {
|
||||
"active": personnel[ReportResponseKey.METRICS]["active"],
|
||||
"attention": personnel[ReportResponseKey.METRICS]["attention_total"],
|
||||
"attendance_mapped": personnel[ReportResponseKey.METRICS][
|
||||
"attendance_mapped"
|
||||
],
|
||||
},
|
||||
"finance": finance["finance_chart_data"] if finance else None,
|
||||
},
|
||||
"finance": finance,
|
||||
"finance_chart_data": finance["finance_chart_data"] if finance else None,
|
||||
}
|
||||
ai_analysis = self._management_ai_analysis(report, actor) if include_ai else None
|
||||
report["ai_analysis"] = ai_analysis
|
||||
if ai_analysis and ai_analysis.get(AIResponseKey.OK):
|
||||
lines.append("- AI 管理分析:")
|
||||
answer = str(ai_analysis.get(AIResponseKey.ANSWER) or "")[:3000]
|
||||
lines.extend(f" {line}" for line in answer.splitlines() if line.strip())
|
||||
report[ReportResponseKey.CONTENT] = "\n".join(lines)
|
||||
return report
|
||||
|
||||
def _management_ai_analysis(self, report: dict[str, Any], actor: str) -> dict[str, Any]:
|
||||
from app.core.config import get_settings
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.ai_agent.skills import AISkillId
|
||||
|
||||
attention = dict(report["attention"])
|
||||
attention["finance"] = [
|
||||
{
|
||||
"project_code": item["project_code"],
|
||||
"display_code": item["display_code"],
|
||||
"project_name": item["project_name"],
|
||||
"stage": item["stage"],
|
||||
"overdue_receivable": item["overdue_receivable"],
|
||||
"pending_outflow": item["pending_outflow"],
|
||||
"funding_need_30d": item["funding_need"]["30"],
|
||||
"delivery_risk": item["delivery_risk"],
|
||||
}
|
||||
for item in attention.get("finance", [])[:10]
|
||||
]
|
||||
context = {
|
||||
"period_start": report[ReportResponseKey.PERIOD_START],
|
||||
"period_end": report[ReportResponseKey.PERIOD_END],
|
||||
"metrics": report[ReportResponseKey.METRICS],
|
||||
"attention": attention,
|
||||
}
|
||||
last_error: Exception | None = None
|
||||
max_attempts = max(1, min(get_settings().ai_analysis_max_attempts, 5))
|
||||
for _ in range(max_attempts):
|
||||
try:
|
||||
result = AIService(self.db).run_skill(
|
||||
AISkillId.PROJECT_LIFECYCLE_ANALYSIS,
|
||||
context=context,
|
||||
actor=actor,
|
||||
)
|
||||
if result.get(AIResponseKey.PROVIDER) == AIProviderName.NOOP:
|
||||
return {
|
||||
AIResponseKey.OK: False,
|
||||
AIResponseKey.ERROR: "AI provider is not configured",
|
||||
"attempts": 1,
|
||||
}
|
||||
return {AIResponseKey.OK: True, **result}
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
self.db.rollback()
|
||||
return {
|
||||
AIResponseKey.OK: False,
|
||||
AIResponseKey.ERROR: str(last_error) if last_error else "AI analysis failed",
|
||||
AIResponseKey.TYPE: type(last_error).__name__ if last_error else "AIUnavailable",
|
||||
"attempts": max_attempts,
|
||||
}
|
||||
|
||||
def _latest_completed_attendance_date(self) -> date:
|
||||
latest = self.db.execute(
|
||||
select(func.max(AttendanceRecord.work_date)).where(
|
||||
AttendanceRecord.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
AttendanceRecord.attendance_scope == "company",
|
||||
AttendanceRecord.work_date < date.today(),
|
||||
)
|
||||
).scalar()
|
||||
return latest or (date.today() - timedelta(days=1))
|
||||
|
||||
def _management_period(
|
||||
self, report_type: str, reference_date: date | None
|
||||
) -> tuple[date, date]:
|
||||
reference = reference_date or date.today()
|
||||
if report_type == ReportType.DAILY:
|
||||
target = self._latest_completed_attendance_date()
|
||||
return target, target
|
||||
end = reference - timedelta(days=reference.weekday() + 1)
|
||||
return end - timedelta(days=6), end
|
||||
|
||||
def _count_source_rows(self, model: type, *conditions: Any) -> int:
|
||||
return int(
|
||||
self.db.execute(select(func.count()).select_from(model).where(*conditions)).scalar()
|
||||
or 0
|
||||
)
|
||||
@@ -3,6 +3,7 @@ from typing import Any
|
||||
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.config import get_settings
|
||||
from app.modules.reports.constants import (
|
||||
LifecycleFilterKey,
|
||||
LifecycleResponseKey,
|
||||
@@ -63,6 +64,17 @@ class ReportLifecycleReportMixin:
|
||||
risk_stats,
|
||||
include_global_risk,
|
||||
)
|
||||
finance = (
|
||||
self.project_finance_needs_report(
|
||||
project_code=project_code,
|
||||
owner=owner,
|
||||
as_of=period_end,
|
||||
include_ai=False,
|
||||
actor=actor,
|
||||
)
|
||||
if get_settings().finance_needs_enabled
|
||||
else None
|
||||
)
|
||||
|
||||
metrics = {
|
||||
LifecycleSection.HEALTH: health,
|
||||
@@ -74,12 +86,15 @@ class ReportLifecycleReportMixin:
|
||||
LifecycleSection.SUPPLIERS: supplier_stats,
|
||||
LifecycleSection.ATTENDANCE: attendance_stats,
|
||||
LifecycleSection.RISKS: risk_stats,
|
||||
LifecycleSection.FINANCE: finance["summary"] if finance else {"status": "未启用"},
|
||||
}
|
||||
lines = self._lifecycle_lines(
|
||||
filters[LifecycleFilterKey.LABELS],
|
||||
metrics,
|
||||
recommendations,
|
||||
)
|
||||
if finance:
|
||||
lines.extend(finance[LifecycleResponseKey.LINES])
|
||||
report = {
|
||||
LifecycleResponseKey.TITLE: ReportTitle.PROJECT_LIFECYCLE,
|
||||
LifecycleResponseKey.FILTERS: filters[LifecycleFilterKey.LABELS],
|
||||
@@ -88,6 +103,9 @@ class ReportLifecycleReportMixin:
|
||||
LifecycleResponseKey.RECOMMENDATIONS: recommendations,
|
||||
LifecycleResponseKey.LINES: lines,
|
||||
LifecycleResponseKey.CONTENT: "\n".join(lines),
|
||||
"source_lifecycle": self.source_project_lifecycle_summary(project_code, owner),
|
||||
"finance": finance,
|
||||
"finance_chart_data": finance["finance_chart_data"] if finance else None,
|
||||
}
|
||||
if include_ai:
|
||||
report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor)
|
||||
|
||||
@@ -26,7 +26,16 @@ class ReportPushRunMixin:
|
||||
receive_id_type: str,
|
||||
actor: str,
|
||||
status: str = ReportPushStatus.PENDING,
|
||||
idempotency_key: str | None = None,
|
||||
) -> ReportPushRun:
|
||||
if idempotency_key:
|
||||
existing = self.db.execute(
|
||||
select(ReportPushRun).where(
|
||||
ReportPushRun.idempotency_key == idempotency_key
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
record = ReportPushRun(
|
||||
code=_next_code("PUSH"),
|
||||
report_type=report_type,
|
||||
@@ -36,6 +45,7 @@ class ReportPushRunMixin:
|
||||
status=status,
|
||||
actor=actor,
|
||||
queued_at=utc_now(),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
|
||||
@@ -3,7 +3,9 @@ from sqlalchemy.orm import Session
|
||||
from app.modules.reports.services.common import ReportQueryMixin
|
||||
from app.modules.reports.services.delivery import ReportDeliveryMixin
|
||||
from app.modules.reports.services.enterprise import ReportEnterpriseAnalyticsMixin
|
||||
from app.modules.reports.services.finance_needs import FinanceNeedsReportMixin
|
||||
from app.modules.reports.services.lifecycle import ReportLifecycleMixin
|
||||
from app.modules.reports.services.intasect_lifecycle import IntasectLifecycleReportMixin
|
||||
from app.modules.reports.services.push_runs import ReportPushRunMixin
|
||||
from app.modules.reports.services.summaries import ReportSummaryMixin
|
||||
from app.modules.reports.services.work_reports import ReportWorkReportMixin
|
||||
@@ -12,6 +14,8 @@ from app.modules.risk.services import RiskService
|
||||
|
||||
class ReportService(
|
||||
ReportDeliveryMixin,
|
||||
FinanceNeedsReportMixin,
|
||||
IntasectLifecycleReportMixin,
|
||||
ReportWorkReportMixin,
|
||||
ReportEnterpriseAnalyticsMixin,
|
||||
ReportLifecycleMixin,
|
||||
|
||||
@@ -7,6 +7,7 @@ class WorkflowType(StrEnum):
|
||||
LEGACY_SYNC_MONITOR = "legacy_sync_monitor"
|
||||
ENTERPRISE_ANALYTICS = "enterprise_analytics"
|
||||
AI_MEMORY_CAPTURE = "ai_memory_capture"
|
||||
LIFECYCLE_REPORT = "lifecycle_report"
|
||||
|
||||
|
||||
class WorkflowStatus(StrEnum):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
@@ -79,7 +80,10 @@ class WorkflowService:
|
||||
|
||||
self.db.add(
|
||||
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,
|
||||
action=action,
|
||||
actor=actor,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from app.tasks.app import celery_app
|
||||
from app.tasks import events as _events # 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 risk as _risk # noqa: F401
|
||||
|
||||
|
||||
33
app/tasks/lifecycle.py
Normal file
33
app/tasks/lifecycle.py
Normal 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()
|
||||
@@ -4,17 +4,23 @@ from app.modules.audit.models import AuditLog
|
||||
from app.modules.business.models import (
|
||||
AttendanceRecord,
|
||||
Expense,
|
||||
Employee,
|
||||
FundAccount,
|
||||
LegacySyncRun,
|
||||
PerformanceMetric,
|
||||
Policy,
|
||||
Procurement,
|
||||
Project,
|
||||
ProjectCashFlow,
|
||||
ProjectContract,
|
||||
ProjectMember,
|
||||
ProjectMilestone,
|
||||
ReportPushRun,
|
||||
RiskEvent,
|
||||
RiskEventAction,
|
||||
Standard,
|
||||
Supplier,
|
||||
SourceSyncCursor,
|
||||
WorkReport,
|
||||
WorkTask,
|
||||
)
|
||||
@@ -25,8 +31,13 @@ from app.modules.workflows.models import WorkflowAction, WorkflowInstance
|
||||
|
||||
_MODELS = [
|
||||
AuditLog,
|
||||
Employee,
|
||||
FeishuEventReceipt,
|
||||
Project,
|
||||
ProjectCashFlow,
|
||||
ProjectContract,
|
||||
ProjectMember,
|
||||
ProjectMilestone,
|
||||
WorkTask,
|
||||
Procurement,
|
||||
Expense,
|
||||
@@ -46,6 +57,7 @@ _MODELS = [
|
||||
WorkflowAction,
|
||||
AIMemoryEntry,
|
||||
SystemHeartbeat,
|
||||
SourceSyncCursor,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@ dependencies:
|
||||
- celery==5.4.0
|
||||
- cryptography==44.0.0
|
||||
- pandas==2.2.3
|
||||
- pillow==11.0.0
|
||||
- pytest==8.3.4
|
||||
- ruff==0.8.4
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -49,8 +49,35 @@ from app.modules.events.constants import (
|
||||
)
|
||||
from app.modules.events.services import EventService
|
||||
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.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 (
|
||||
HeartbeatComponent,
|
||||
ObservabilityKey,
|
||||
@@ -66,6 +93,13 @@ from app.modules.reports.constants import (
|
||||
ReportTitle,
|
||||
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.workflows.constants import WorkflowStatus, WorkflowType
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -1052,3 +1186,757 @@ def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None:
|
||||
finally:
|
||||
monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False)
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user