diff --git a/Dockerfile b/Dockerfile index 7033efe..bce049c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/alembic/versions/202607120001_intasect_lifecycle_foundation.py b/alembic/versions/202607120001_intasect_lifecycle_foundation.py new file mode 100644 index 0000000..23c3938 --- /dev/null +++ b/alembic/versions/202607120001_intasect_lifecycle_foundation.py @@ -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) diff --git a/alembic/versions/202607120002_project_finance_needs.py b/alembic/versions/202607120002_project_finance_needs.py new file mode 100644 index 0000000..3c4ceac --- /dev/null +++ b/alembic/versions/202607120002_project_finance_needs.py @@ -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") diff --git a/app/core/background/scheduler.py b/app/core/background/scheduler.py index c6412c7..9c4a016 100644 --- a/app/core/background/scheduler.py +++ b/app/core/background/scheduler.py @@ -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", diff --git a/app/core/background/task_queue/__init__.py b/app/core/background/task_queue/__init__.py index ca0ae98..d3d4470 100644 --- a/app/core/background/task_queue/__init__.py +++ b/app/core/background/task_queue/__init__.py @@ -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", ] diff --git a/app/core/background/task_queue/constants.py b/app/core/background/task_queue/constants.py index 119836c..5952789 100644 --- a/app/core/background/task_queue/constants.py +++ b/app/core/background/task_queue/constants.py @@ -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" diff --git a/app/core/background/task_queue/lifecycle.py b/app/core/background/task_queue/lifecycle.py new file mode 100644 index 0000000..279862b --- /dev/null +++ b/app/core/background/task_queue/lifecycle.py @@ -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() diff --git a/app/core/config/settings.py b/app/core/config/settings.py index 7d53907..3cf23c2 100644 --- a/app/core/config/settings.py +++ b/app/core/config/settings.py @@ -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", diff --git a/app/modules/ai_agent/constants.py b/app/modules/ai_agent/constants.py index 5ffe134..d0e6e87 100644 --- a/app/modules/ai_agent/constants.py +++ b/app/modules/ai_agent/constants.py @@ -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): diff --git a/app/modules/ai_agent/service.py b/app/modules/ai_agent/service.py index f6bbd76..5c9b669 100644 --- a/app/modules/ai_agent/service.py +++ b/app/modules/ai_agent/service.py @@ -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: diff --git a/app/modules/ai_agent/skills.py b/app/modules/ai_agent/skills.py index f713b5a..c571df3 100644 --- a/app/modules/ai_agent/skills.py +++ b/app/modules/ai_agent/skills.py @@ -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, diff --git a/app/modules/ai_memory/constants.py b/app/modules/ai_memory/constants.py index 042d6fc..2b2ca2f 100644 --- a/app/modules/ai_memory/constants.py +++ b/app/modules/ai_memory/constants.py @@ -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 diff --git a/app/modules/ai_memory/routes.py b/app/modules/ai_memory/routes.py index ae121bb..1cbd0d3 100644 --- a/app/modules/ai_memory/routes.py +++ b/app/modules/ai_memory/routes.py @@ -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, + ) + } diff --git a/app/modules/ai_memory/schemas.py b/app/modules/ai_memory/schemas.py index 9baf64f..fde5084 100644 --- a/app/modules/ai_memory/schemas.py +++ b/app/modules/ai_memory/schemas.py @@ -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 diff --git a/app/modules/ai_memory/service.py b/app/modules/ai_memory/service.py index a6b6237..53063a2 100644 --- a/app/modules/ai_memory/service.py +++ b/app/modules/ai_memory/service.py @@ -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, diff --git a/app/modules/audit/constants.py b/app/modules/audit/constants.py index 84930fd..b92a0f6 100644 --- a/app/modules/audit/constants.py +++ b/app/modules/audit/constants.py @@ -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" diff --git a/app/modules/business/constants.py b/app/modules/business/constants.py index 3370717..1a1f504 100644 --- a/app/modules/business/constants.py +++ b/app/modules/business/constants.py @@ -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" diff --git a/app/modules/business/models/__init__.py b/app/modules/business/models/__init__.py index ebef96c..9478a1e 100644 --- a/app/modules/business/models/__init__.py +++ b/app/modules/business/models/__init__.py @@ -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", ] diff --git a/app/modules/business/models/attendance.py b/app/modules/business/models/attendance.py index e6b965a..88cd4e3 100644 --- a/app/modules/business/models/attendance.py +++ b/app/modules/business/models/attendance.py @@ -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) diff --git a/app/modules/business/models/lifecycle.py b/app/modules/business/models/lifecycle.py new file mode 100644 index 0000000..e6231b5 --- /dev/null +++ b/app/modules/business/models/lifecycle.py @@ -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) diff --git a/app/modules/business/models/projects.py b/app/modules/business/models/projects.py index 04bf1ec..680a069 100644 --- a/app/modules/business/models/projects.py +++ b/app/modules/business/models/projects.py @@ -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) diff --git a/app/modules/business/models/reports.py b/app/modules/business/models/reports.py index a405df0..e206c7f 100644 --- a/app/modules/business/models/reports.py +++ b/app/modules/business/models/reports.py @@ -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) diff --git a/app/modules/feishu/client.py b/app/modules/feishu/client.py index 5f0c6e3..5b47c42 100644 --- a/app/modules/feishu/client.py +++ b/app/modules/feishu/client.py @@ -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], diff --git a/app/modules/feishu/commands.py b/app/modules/feishu/commands.py index 8c338ff..0ae696e 100644 --- a/app/modules/feishu/commands.py +++ b/app/modules/feishu/commands.py @@ -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, diff --git a/app/modules/feishu/constants.py b/app/modules/feishu/constants.py index fdac48f..55a1172 100644 --- a/app/modules/feishu/constants.py +++ b/app/modules/feishu/constants.py @@ -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" diff --git a/app/modules/feishu/service.py b/app/modules/feishu/service.py index b93b41c..bc47444 100644 --- a/app/modules/feishu/service.py +++ b/app/modules/feishu/service.py @@ -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, } diff --git a/app/modules/legacy_mysql/intasect.py b/app/modules/legacy_mysql/intasect.py new file mode 100644 index 0000000..48e241c --- /dev/null +++ b/app/modules/legacy_mysql/intasect.py @@ -0,0 +1,1143 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import Engine, Select, select, text, update +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.database import legacy_engine +from app.core.utils.time import utc_now +from app.modules.business.constants import ( + CashFlowDirection, + CashFlowType, + DataQualityStatus, + RiskLevel, + SourceSystem, + StatusValue, +) +from app.modules.business.models import ( + AttendanceRecord, + Employee, + Project, + ProjectCashFlow, + ProjectContract, + ProjectMember, + ProjectMilestone, + RiskEvent, + SourceSyncCursor, + WorkReport, + WorkTask, +) +from app.modules.legacy_mysql.constants import LegacyQueryError + + +INTASECT_SOURCE = "intasect" +DEFAULT_BATCH_SIZE = 500 +EPOCH = datetime(1970, 1, 1) + + +PROJECT_SQL = """ +SELECT CAST(p.id AS CHAR) AS source_key, p.id AS source_id, + p.business_code, p.pro_sn, p.name, p.mgr_deptid, p.mgr_deptname, + p.mgr_user_id, p.mgr_user_name, p.project_stage, + COALESCE(stage_dict.dict_label, p.project_stage) AS stage_label, + p.archive_flag, p.create_time AS source_created_at, + COALESCE(p.update_time, p.create_time) AS source_updated_at, + p.contract_date, p.project_done_date, p.project_information, + p.contract_money, p.project_invest_amount +FROM t_project p +LEFT JOIN sys_dict_data stage_dict + ON stage_dict.dict_type = 'project_stage' AND stage_dict.dict_value = p.project_stage +WHERE p.del_flag = '0' AND p.id > :after_key +ORDER BY p.id +LIMIT :limit +""" + +EMPLOYEE_SQL = """ +SELECT CAST(u.user_id AS CHAR) AS source_key, u.user_id AS source_id, + u.user_name AS employee_name, u.dept_id, d.dept_name, + u.status AS employment_status, u.ding_id, du.title, + du.hired_date, u.create_time AS source_created_at, + COALESCE(u.update_time, u.create_time, du.update_time, du.create_time) AS source_updated_at +FROM sys_user u +LEFT JOIN sys_dept d ON d.dept_id = u.dept_id +LEFT JOIN ding_user du ON du.userid = u.ding_id +WHERE u.del_flag = '0' AND u.user_id > :after_key +ORDER BY u.user_id +LIMIT :limit +""" + +PROJECT_MEMBER_SQL = """ +SELECT CAST(pu.id AS CHAR) AS source_key, pu.id AS source_id, + pu.project_id, pu.user_id, post.post_name AS role_name, + pu.leader, pu.resident, pu.work_mode, pu.work_time, pu.worknum_rate, + pu.create_time AS source_created_at, pu.create_time AS source_updated_at +FROM t_project_user pu +JOIN t_project p ON p.id = pu.project_id AND p.del_flag = '0' +JOIN sys_user u ON u.user_id = pu.user_id AND u.del_flag = '0' +LEFT JOIN sys_post post ON post.post_id = pu.post_id +WHERE pu.id > :after_key +ORDER BY pu.id +LIMIT :limit +""" + +TASK_SQL = """ +SELECT CAST(t.id AS CHAR) AS source_key, t.id AS source_id, + t.task_name, t.task_project_id, p.del_flag AS project_del_flag, + t.task_assignee, assignee.user_name AS assignee_name, + t.task_status, t.task_level, t.task_start_time, t.task_end_time, + t.task_done_time, t.task_content, t.task_pause, + t.create_time AS source_created_at, + COALESCE(t.update_time, t.create_time) AS source_updated_at +FROM dt_task t +LEFT JOIN t_project p ON p.id = t.task_project_id +LEFT JOIN sys_user assignee ON CAST(assignee.user_id AS CHAR) = t.task_assignee +WHERE t.id > :after_key +ORDER BY t.id +LIMIT :limit +""" + +MILESTONE_SQL = """ +SELECT CONCAT(LPAD(ps.project_id, 20, '0'), ':', ps.stage_id, ':', COALESCE(ps.sub_proid, '')) AS source_key, + ps.project_id, ps.stage_id, COALESCE(ls.stage_name, ps.stage_id) AS stage_name, + MIN(ps.plan_start) AS plan_start, MAX(ps.plan_end) AS plan_end, + MAX(ps.is_end) AS is_end, MAX(ps.is_overtime) AS is_overtime, + COALESCE(activity.activity_total, 0) AS activity_total, + COALESCE(activity.activity_completed, 0) AS activity_completed, + MAX(COALESCE(ps.update_time, ps.create_time)) AS source_updated_at +FROM t_project_stone ps +JOIN t_project p ON p.id = ps.project_id AND p.del_flag = '0' +LEFT JOIN t_lcb_stage ls ON ls.stage_id = ps.stage_id +LEFT JOIN ( + SELECT project_id, stage_id, COALESCE(sub_proid, '') AS sub_proid, + COUNT(*) AS activity_total, + SUM(finish_flag = 'Y') AS activity_completed + FROM t_project_activity + GROUP BY project_id, stage_id, COALESCE(sub_proid, '') +) activity ON activity.project_id = ps.project_id + AND activity.stage_id = ps.stage_id + AND activity.sub_proid = COALESCE(ps.sub_proid, '') +WHERE CONCAT(LPAD(ps.project_id, 20, '0'), ':', ps.stage_id, ':', COALESCE(ps.sub_proid, '')) > :after_key +GROUP BY ps.project_id, ps.stage_id, COALESCE(ps.sub_proid, ''), + COALESCE(ls.stage_name, ps.stage_id), + activity.activity_total, activity.activity_completed +ORDER BY source_key +LIMIT :limit +""" + +PROJECT_EVENT_SQL = """ +SELECT e.id AS source_key, e.id AS source_id, e.project_id, + e.project_status, e.status AS event_status, e.need_help, + e.plan_date, e.end_date, e.event, e.event_description, + COALESCE(e.update_time, e.create_time) AS source_updated_at +FROM t_project_event e +JOIN t_project p ON p.id = e.project_id AND p.del_flag = '0' +WHERE e.id > :after_key AND (e.status = 'YCQ' OR e.need_help = 'Y') +ORDER BY e.id +LIMIT :limit +""" + +DING_ATTENDANCE_SQL = """ +SELECT changed.source_key, a.userid, a.work_date, u.user_id, u.user_name, d.dept_name, + MIN(CASE WHEN a.check_type = 'OnDuty' THEN a.user_check_time END) AS check_in_at, + MAX(CASE WHEN a.check_type = 'OffDuty' THEN a.user_check_time END) AS check_out_at, + GROUP_CONCAT(DISTINCT a.time_result ORDER BY a.time_result) AS time_results, + GROUP_CONCAT(DISTINCT a.location_result ORDER BY a.location_result) AS location_results, + changed.source_updated_at +FROM ding_user_attendance a +JOIN ( + SELECT attendance_source.userid, attendance_source.work_date, + CONCAT(attendance_source.userid, ':', DATE_FORMAT(attendance_source.work_date, '%Y-%m-%d')) AS source_key, + MAX(COALESCE(attendance_source.update_time, attendance_source.create_time, attendance_source.user_check_time)) AS source_updated_at + FROM ding_user_attendance attendance_source + JOIN sys_user changed_user + ON changed_user.ding_id = attendance_source.userid AND changed_user.del_flag = '0' + WHERE attendance_source.work_date IS NOT NULL + GROUP BY attendance_source.userid, attendance_source.work_date + HAVING source_updated_at > :watermark_at + OR (source_updated_at = :watermark_at AND source_key > :after_key) + ORDER BY source_updated_at, source_key + LIMIT :limit +) changed ON changed.userid = a.userid AND changed.work_date = a.work_date +JOIN sys_user u ON u.ding_id = a.userid AND u.del_flag = '0' +LEFT JOIN sys_dept d ON d.dept_id = u.dept_id +GROUP BY changed.source_key, changed.source_updated_at, + a.userid, a.work_date, u.user_id, u.user_name, d.dept_name +ORDER BY changed.source_updated_at, changed.source_key +""" + +DING_ATTENDANCE_FULL_SQL = """ +SELECT CONCAT(a.userid, ':', DATE_FORMAT(a.work_date, '%Y-%m-%d')) AS source_key, + a.userid, a.work_date, u.user_id, u.user_name, d.dept_name, + MIN(CASE WHEN a.check_type = 'OnDuty' THEN a.user_check_time END) AS check_in_at, + MAX(CASE WHEN a.check_type = 'OffDuty' THEN a.user_check_time END) AS check_out_at, + GROUP_CONCAT(DISTINCT a.time_result ORDER BY a.time_result) AS time_results, + GROUP_CONCAT(DISTINCT a.location_result ORDER BY a.location_result) AS location_results, + MAX(COALESCE(a.update_time, a.create_time, a.user_check_time)) AS source_updated_at +FROM ding_user_attendance a +JOIN sys_user u ON u.ding_id = a.userid AND u.del_flag = '0' +LEFT JOIN sys_dept d ON d.dept_id = u.dept_id +WHERE a.work_date IS NOT NULL + AND (a.userid > :after_userid + OR (a.userid = :after_userid AND a.work_date > :after_date)) +GROUP BY a.userid, a.work_date, u.user_id, u.user_name, d.dept_name +ORDER BY a.userid, a.work_date +LIMIT :limit +""" + +PROJECT_ATTENDANCE_SQL = """ +SELECT CAST(s.id AS CHAR) AS source_key, s.id AS source_id, + s.project_id, s.user_id, u.user_name, d.dept_name, + DATE(COALESCE(s.in_time, s.create_time)) AS work_date, + s.in_time AS check_in_at, s.out_time AS check_out_at, + s.is_delay, COALESCE(s.create_time, s.in_time, s.out_time) AS source_updated_at +FROM t_project_usersign s +JOIN t_project p ON p.id = s.project_id AND p.del_flag = '0' +JOIN sys_user u ON u.user_id = s.user_id AND u.del_flag = '0' +LEFT JOIN sys_dept d ON d.dept_id = u.dept_id +WHERE (COALESCE(s.create_time, s.in_time, s.out_time) > :watermark_at + OR (COALESCE(s.create_time, s.in_time, s.out_time) = :watermark_at AND s.id > :after_key)) +ORDER BY source_updated_at, s.id +LIMIT :limit +""" + +PROJECT_ATTENDANCE_FULL_SQL = """ +SELECT CAST(s.id AS CHAR) AS source_key, s.id AS source_id, + s.project_id, s.user_id, u.user_name, d.dept_name, + DATE(COALESCE(s.in_time, s.create_time)) AS work_date, + s.in_time AS check_in_at, s.out_time AS check_out_at, + s.is_delay, COALESCE(s.create_time, s.in_time, s.out_time) AS source_updated_at +FROM t_project_usersign s +JOIN t_project p ON p.id = s.project_id AND p.del_flag = '0' +JOIN sys_user u ON u.user_id = s.user_id AND u.del_flag = '0' +LEFT JOIN sys_dept d ON d.dept_id = u.dept_id +WHERE s.id > :after_key +ORDER BY s.id +LIMIT :limit +""" + +WORK_REPORT_SQL = """ +SELECT r.id AS source_key, r.id AS source_id, r.user_id, u.user_name, + d.dept_name, r.date AS period_end, r.begin_date AS period_start, + r.type AS report_type, r.late, r.draft, + r.create_time AS source_created_at, + COALESCE(r.update_time, r.create_time) AS source_updated_at +FROM t_daily r +JOIN sys_user u ON u.user_id = r.user_id AND u.del_flag = '0' +LEFT JOIN sys_dept d ON d.dept_id = u.dept_id +WHERE (COALESCE(r.update_time, r.create_time) > :watermark_at + OR (COALESCE(r.update_time, r.create_time) = :watermark_at AND r.id > :after_key)) +ORDER BY source_updated_at, r.id +LIMIT :limit +""" + +WORK_REPORT_FULL_SQL = """ +SELECT r.id AS source_key, r.id AS source_id, r.user_id, u.user_name, + d.dept_name, r.date AS period_end, r.begin_date AS period_start, + r.type AS report_type, r.late, r.draft, + r.create_time AS source_created_at, + COALESCE(r.update_time, r.create_time) AS source_updated_at +FROM t_daily r +JOIN sys_user u ON u.user_id = r.user_id AND u.del_flag = '0' +LEFT JOIN sys_dept d ON d.dept_id = u.dept_id +WHERE r.id > :after_key +ORDER BY r.id +LIMIT :limit +""" + +CONTRACT_SQL = """ +SELECT CAST(c.id AS CHAR) AS source_key, c.id AS source_id, + c.project_id, p.id AS linked_project_id, p.del_flag AS project_del_flag, + c.type AS contract_type_code, + COALESCE(contract_type.dict_label, c.type) AS contract_type_label, + c.contract_amount, c.contract_date, c.date_start, c.date_end, + c.pay_way AS invoice_type_code, c.create_time AS source_created_at +FROM t_contract c +LEFT JOIN t_project p ON p.id = c.project_id +LEFT JOIN sys_dict_data contract_type + ON contract_type.dict_type = 'contract_type' AND contract_type.dict_value = c.type +WHERE c.id > :after_key +ORDER BY c.id +LIMIT :limit +""" + +CONTRACT_RECEIVABLE_SQL = """ +SELECT CAST(cp.id AS CHAR) AS source_key, cp.id AS source_id, + cp.contract_id AS source_contract_id, c.id AS linked_contract_id, + c.project_id, p.id AS linked_project_id, p.del_flag AS project_del_flag, + cp.pay_node AS category_code, + COALESCE(pay_node.dict_label, cp.pay_node) AS category_label, + cp.pay_num AS planned_amount, cp.real_num AS actual_amount, + COALESCE(cp.plan_date, cp.pay_date) AS planned_date, cp.real_date AS actual_date, + cp.status AS payment_status, cp.fapiao AS invoice_status, + cp.create_time AS source_created_at +FROM t_contract_pay cp +LEFT JOIN t_contract c ON c.id = cp.contract_id +LEFT JOIN t_project p ON p.id = c.project_id +LEFT JOIN sys_dict_data pay_node + ON pay_node.dict_type = 'pay_node' AND pay_node.dict_value = cp.pay_node +WHERE cp.id > :after_key +ORDER BY cp.id +LIMIT :limit +""" + +PROJECT_FUND_SQL = """ +SELECT CAST(f.id AS CHAR) AS source_key, f.id AS source_id, + f.project_id, p.id AS linked_project_id, p.del_flag AS project_del_flag, + f.cost_class, cost_class.dict_label AS cost_class_label, + f.cost_type AS category_code, + COALESCE(cost_type.dict_label, f.cost_type) AS category_label, + f.cost_amount AS planned_amount, f.approval_status, f.confirm_status, + f.approval_time, f.trade_time, + f.create_time AS source_created_at, + COALESCE(f.update_time, f.create_time) AS source_updated_at +FROM dt_bid_fund_detail f +LEFT JOIN t_project p ON p.id = f.project_id +LEFT JOIN sys_dict_data cost_class + ON cost_class.dict_type = 'pms_cost_class' AND cost_class.dict_value = f.cost_class +LEFT JOIN sys_dict_data cost_type + ON cost_type.dict_type = 'pms_cost_type' AND cost_type.dict_value = f.cost_type +WHERE f.id > :after_key +ORDER BY f.id +LIMIT :limit +""" + + +CORE_FULL_DATASETS = ( + "projects", + "employees", + "project_members", + "tasks", + "milestones", + "project_events", +) +FINANCE_DATASETS = ("contracts", "contract_receivables", "project_funds") +FULL_DATASETS = (*CORE_FULL_DATASETS, *FINANCE_DATASETS) +INCREMENTAL_DATASETS = ("company_attendance", "project_attendance", "work_reports") +DATASET_SQL = { + "projects": PROJECT_SQL, + "employees": EMPLOYEE_SQL, + "project_members": PROJECT_MEMBER_SQL, + "tasks": TASK_SQL, + "milestones": MILESTONE_SQL, + "project_events": PROJECT_EVENT_SQL, + "company_attendance": DING_ATTENDANCE_SQL, + "project_attendance": PROJECT_ATTENDANCE_SQL, + "work_reports": WORK_REPORT_SQL, + "contracts": CONTRACT_SQL, + "contract_receivables": CONTRACT_RECEIVABLE_SQL, + "project_funds": PROJECT_FUND_SQL, +} + + +class IntasectSourceRepository: + def __init__(self, engine: Engine | None = None): + self.engine = engine or legacy_engine + if self.engine is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=LegacyQueryError.DATABASE_NOT_CONFIGURED, + ) + + def fetch_page( + self, + dataset: str, + after_key: str, + watermark_at: datetime, + limit: int = DEFAULT_BATCH_SIZE, + ) -> list[dict[str, Any]]: + full_sql = { + "company_attendance": DING_ATTENDANCE_FULL_SQL, + "project_attendance": PROJECT_ATTENDANCE_FULL_SQL, + "work_reports": WORK_REPORT_FULL_SQL, + } + sql = ( + full_sql.get(dataset, DATASET_SQL[dataset]) + if watermark_at == EPOCH + else DATASET_SQL[dataset] + ) + after_userid = "" + after_date = date(1900, 1, 1) + if dataset == "company_attendance" and after_key: + after_userid, raw_date = after_key.rsplit(":", 1) + after_date = date.fromisoformat(raw_date) + params: dict[str, Any] = { + "after_key": int(after_key or 0) + if dataset + in { + "projects", + "contracts", + "contract_receivables", + "project_funds", + "employees", + "project_members", + "tasks", + "project_attendance", + } + else after_key, + "watermark_at": watermark_at, + "after_userid": after_userid, + "after_date": after_date, + "limit": min(max(int(limit), 1), DEFAULT_BATCH_SIZE), + } + with self.engine.connect() as connection: + return [dict(row) for row in connection.execute(text(sql), params).mappings()] + + +class IntasectSyncService: + def __init__(self, db: Session, source: IntasectSourceRepository | None = None): + self.db = db + self.source = source + + def sync_all( + self, + run_code: str, + force_full: bool = False, + batch_size: int = DEFAULT_BATCH_SIZE, + ) -> dict[str, Any]: + results: dict[str, Any] = {} + full_datasets = list(CORE_FULL_DATASETS) + if get_settings().finance_needs_enabled: + full_datasets[1:1] = FINANCE_DATASETS + for dataset in (*full_datasets, *INCREMENTAL_DATASETS): + results[dataset] = self.sync_dataset(dataset, run_code, force_full, batch_size) + return results + + def sync_dataset( + self, + dataset: str, + run_code: str, + force_full: bool = False, + batch_size: int = DEFAULT_BATCH_SIZE, + ) -> dict[str, Any]: + cursor = self._cursor(dataset) + if self.source is None: + self.source = IntasectSourceRepository() + incremental = ( + dataset in INCREMENTAL_DATASETS and cursor.watermark_at is not None and not force_full + ) + watermark_at = cursor.watermark_at if incremental and cursor.watermark_at else EPOCH + after_key = cursor.watermark_key if incremental and cursor.watermark_key else "" + seen_at = utc_now() + cursor.status = StatusValue.RUNNING + cursor.last_run_code = run_code + cursor.error_message = None + self.db.commit() + + processed = 0 + latest_source_updated: datetime | None = None + latest_source_key = "" + try: + while True: + rows = self.source.fetch_page(dataset, after_key, watermark_at, batch_size) + if not rows: + break + self._apply_rows(dataset, rows, seen_at) + processed += len(rows) + last = rows[-1] + after_key = str(last["source_key"]) + last_updated = _as_datetime(last.get("source_updated_at")) + if incremental and last_updated: + watermark_at = last_updated + for row in rows: + row_updated = _as_datetime(row.get("source_updated_at")) + row_key = str(row["source_key"]) + if row_updated and ( + latest_source_updated is None + or row_updated > latest_source_updated + or (row_updated == latest_source_updated and row_key > latest_source_key) + ): + latest_source_updated = row_updated + latest_source_key = row_key + self.db.commit() + if len(rows) < batch_size: + break + if dataset in FULL_DATASETS: + self._deactivate_missing(dataset, seen_at) + cursor.status = StatusValue.COMPLETED + cursor.processed_count = processed + if dataset in INCREMENTAL_DATASETS and latest_source_updated: + cursor.watermark_at = latest_source_updated + cursor.watermark_key = latest_source_key + else: + cursor.watermark_at = seen_at + cursor.watermark_key = after_key + cursor.last_success_at = utc_now() + self.db.commit() + except Exception as exc: + self.db.rollback() + cursor = self._cursor(dataset) + cursor.status = "failed" + cursor.last_run_code = run_code + cursor.processed_count = processed + cursor.error_message = str(exc)[:2000] + self.db.commit() + raise + return { + "dataset": dataset, + "processed": processed, + "incremental": incremental, + "watermark_at": cursor.watermark_at.isoformat() if cursor.watermark_at else None, + "watermark_key": cursor.watermark_key, + } + + def _cursor(self, dataset: str) -> SourceSyncCursor: + record = self.db.execute( + select(SourceSyncCursor).where(SourceSyncCursor.dataset == dataset) + ).scalar_one_or_none() + if record is None: + record = SourceSyncCursor(dataset=dataset) + self.db.add(record) + self.db.commit() + self.db.refresh(record) + return record + + def _apply_rows(self, dataset: str, rows: list[dict[str, Any]], seen_at: datetime) -> None: + handlers = { + "projects": self._upsert_projects, + "contracts": self._upsert_contracts, + "contract_receivables": self._upsert_contract_receivables, + "project_funds": self._upsert_project_funds, + "employees": self._upsert_employees, + "project_members": self._upsert_members, + "tasks": self._upsert_tasks, + "milestones": self._upsert_milestones, + "project_events": self._upsert_events, + "company_attendance": self._upsert_company_attendance, + "project_attendance": self._upsert_project_attendance, + "work_reports": self._upsert_work_reports, + } + handlers[dataset](rows, seen_at) + + def _existing(self, model: type, external_ids: list[str]) -> dict[str, Any]: + statement: Select = select(model).where( + model.source_system == SourceSystem.LEGACY_MYSQL, + model.external_id.in_(external_ids), + ) + return {str(item.external_id): item for item in self.db.execute(statement).scalars()} + + def _upsert_projects(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + existing = self._existing(Project, [str(row["source_id"]) for row in rows]) + for row in rows: + external_id = str(row["source_id"]) + record = existing.get(external_id) + payload = _project_payload(row, seen_at) + if record is None: + self.db.add(Project(**payload)) + else: + _assign(record, payload) + + def _upsert_employees(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + existing = self._existing(Employee, [str(row["source_id"]) for row in rows]) + for row in rows: + external_id = str(row["source_id"]) + record = existing.get(external_id) + payload = _employee_payload(row, seen_at) + if record is None: + self.db.add(Employee(**payload)) + else: + _assign(record, payload) + + def _upsert_contracts(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + existing = self._existing(ProjectContract, [str(row["source_id"]) for row in rows]) + for row in rows: + external_id = str(row["source_id"]) + record = existing.get(external_id) + payload = _contract_payload(row, seen_at) + if record is None: + self.db.add(ProjectContract(**payload)) + else: + _assign(record, payload) + + def _upsert_contract_receivables( + self, rows: list[dict[str, Any]], seen_at: datetime + ) -> None: + ids = [f"receivable:{row['source_id']}" for row in rows] + existing = self._existing(ProjectCashFlow, ids) + for row in rows: + external_id = f"receivable:{row['source_id']}" + record = existing.get(external_id) + payload = _contract_receivable_payload(row, seen_at) + if record is None: + self.db.add(ProjectCashFlow(**payload)) + else: + _assign(record, payload) + + def _upsert_project_funds(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + ids = [f"fund:{row['source_id']}" for row in rows] + existing = self._existing(ProjectCashFlow, ids) + for row in rows: + external_id = f"fund:{row['source_id']}" + record = existing.get(external_id) + payload = _project_fund_payload(row, seen_at) + if record is None: + self.db.add(ProjectCashFlow(**payload)) + else: + _assign(record, payload) + + def _upsert_members(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + existing = self._existing(ProjectMember, [str(row["source_id"]) for row in rows]) + for row in rows: + external_id = str(row["source_id"]) + record = existing.get(external_id) + payload = _member_payload(row, seen_at) + if record is None: + self.db.add(ProjectMember(**payload)) + else: + _assign(record, payload) + + def _upsert_tasks(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + existing = self._existing(WorkTask, [str(row["source_id"]) for row in rows]) + for row in rows: + external_id = str(row["source_id"]) + record = existing.get(external_id) + payload = _task_payload(row, seen_at) + if record is None: + self.db.add(WorkTask(**payload)) + else: + _assign(record, payload) + + def _upsert_milestones(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + ids = [str(row["source_key"]) for row in rows] + existing = self._existing(ProjectMilestone, ids) + for row in rows: + external_id = str(row["source_key"]) + record = existing.get(external_id) + payload = _milestone_payload(row, seen_at) + if record is None: + self.db.add(ProjectMilestone(**payload)) + else: + _assign(record, payload) + + def _upsert_events(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + codes = [f"INTASECT-EVENT-{row['source_id']}" for row in rows] + existing = { + item.code: item + for item in self.db.execute( + select(RiskEvent).where(RiskEvent.code.in_(codes)) + ).scalars() + } + for row in rows: + code = f"INTASECT-EVENT-{row['source_id']}" + record = existing.get(code) + payload = _event_payload(row, seen_at) + if record is None: + self.db.add(RiskEvent(**payload)) + else: + _assign(record, payload) + + def _upsert_company_attendance(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + ids = [f"ding:{row['source_key']}" for row in rows] + existing = self._existing(AttendanceRecord, ids) + for row in rows: + external_id = f"ding:{row['source_key']}" + record = existing.get(external_id) + payload = _company_attendance_payload(row, seen_at) + if record is None: + self.db.add(AttendanceRecord(**payload)) + else: + _assign(record, payload) + + def _upsert_project_attendance(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + ids = [f"project:{row['source_id']}" for row in rows] + existing = self._existing(AttendanceRecord, ids) + for row in rows: + external_id = f"project:{row['source_id']}" + record = existing.get(external_id) + payload = _project_attendance_payload(row, seen_at) + if record is None: + self.db.add(AttendanceRecord(**payload)) + else: + _assign(record, payload) + + def _upsert_work_reports(self, rows: list[dict[str, Any]], seen_at: datetime) -> None: + ids = [str(row["source_id"]) for row in rows] + existing = self._existing(WorkReport, ids) + for row in rows: + external_id = str(row["source_id"]) + record = existing.get(external_id) + payload = _work_report_payload(row, seen_at) + if record is None: + self.db.add(WorkReport(**payload)) + else: + _assign(record, payload) + + def _deactivate_missing(self, dataset: str, seen_at: datetime) -> None: + if dataset == "project_events": + self.db.execute( + update(RiskEvent) + .where( + RiskEvent.source_domain == "intasect_project_event", + RiskEvent.updated_at < seen_at, + ) + .values(status=StatusValue.RESOLVED) + ) + self.db.commit() + return + targets: dict[str, tuple[type, list[Any]]] = { + "projects": (Project, []), + "contracts": (ProjectContract, []), + "contract_receivables": ( + ProjectCashFlow, + [ProjectCashFlow.flow_type == CashFlowType.CONTRACT_RECEIVABLE], + ), + "project_funds": ( + ProjectCashFlow, + [ProjectCashFlow.flow_type == CashFlowType.PROJECT_FUND], + ), + "employees": (Employee, []), + "project_members": (ProjectMember, []), + "tasks": (WorkTask, []), + "milestones": (ProjectMilestone, []), + } + target = targets.get(dataset) + if target is None: + return + model, extra_conditions = target + conditions = [ + model.source_system == SourceSystem.LEGACY_MYSQL, + (model.last_seen_at.is_(None) | (model.last_seen_at < seen_at)), + *extra_conditions, + ] + self.db.execute(update(model).where(*conditions).values(is_active=False)) + self.db.commit() + + +def _project_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = str(row["source_id"]) + archived = str(row.get("archive_flag") or "0") == "2" + stage = _text(row.get("project_stage")) + completed = archived or stage == "XMWJ" + progress_by_stage = {"JD25": 25, "JD50": 50, "JD75": 75, "XMWJ": 100} + return { + "code": f"INTASECT-PROJECT-{external_id}", + "display_code": _text(row.get("business_code")) or _text(row.get("pro_sn")), + "name": _text(row.get("name")) or f"项目 {external_id}", + "owner": _text(row.get("mgr_user_name")), + "owner_employee_code": ( + f"INTASECT-EMPLOYEE-{row['mgr_user_id']}" if row.get("mgr_user_id") else None + ), + "department_code": _text(row.get("mgr_deptid")), + "department_name": _text(row.get("mgr_deptname")), + "status": StatusValue.COMPLETED if completed else StatusValue.RUNNING, + "progress_percent": progress_by_stage.get(stage or "", 0), + "source_contract_amount": _scaled_amount(row.get("contract_money"), Decimal("10000")), + "source_project_investment_amount": _scaled_amount( + row.get("project_invest_amount"), Decimal("10000") + ), + "start_date": _as_date(row.get("contract_date")), + "due_date": None, + "description": _text(row.get("project_information")), + "source_stage": stage, + "source_stage_label": _text(row.get("stage_label")) or stage, + "source_archived": archived, + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "source_created_at": _as_datetime(row.get("source_created_at")), + "source_updated_at": _as_datetime(row.get("source_updated_at")), + "last_seen_at": seen_at, + "is_active": True, + } + + +def _contract_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = str(row["source_id"]) + quality = _project_link_quality(row) + project_id = row.get("project_id") + return { + "code": f"INTASECT-CONTRACT-{external_id}", + "project_code": f"INTASECT-PROJECT-{project_id}" if project_id is not None else None, + "contract_type_code": _text(row.get("contract_type_code")), + "contract_type_label": _text(row.get("contract_type_label")), + "amount": _as_decimal(row.get("contract_amount")) or Decimal("0"), + "signed_date": _as_date(row.get("contract_date")), + "start_date": _as_date(row.get("date_start")), + "end_date": _as_date(row.get("date_end")), + "invoice_type_code": _text(row.get("invoice_type_code")), + "data_quality_status": quality, + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "source_created_at": _as_datetime(row.get("source_created_at")), + "last_seen_at": seen_at, + "is_active": True, + } + + +def _contract_receivable_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = f"receivable:{row['source_id']}" + actual = _as_decimal(row.get("actual_amount")) + payment_status = _text(row.get("payment_status")) + if row.get("linked_contract_id") is None: + quality = DataQualityStatus.ORPHAN_CONTRACT + else: + quality = _project_link_quality(row) + if quality == DataQualityStatus.VALID and payment_status == "Y" and not actual: + quality = DataQualityStatus.PAID_AMOUNT_MISSING + elif quality == DataQualityStatus.VALID and payment_status == "N" and actual and actual > 0: + quality = DataQualityStatus.STATUS_AMOUNT_MISMATCH + project_id = row.get("project_id") + contract_id = row.get("source_contract_id") + return { + "code": f"INTASECT-RECEIVABLE-{row['source_id']}", + "project_code": f"INTASECT-PROJECT-{project_id}" if project_id is not None else None, + "contract_code": ( + f"INTASECT-CONTRACT-{contract_id}" if row.get("linked_contract_id") is not None else None + ), + "flow_type": CashFlowType.CONTRACT_RECEIVABLE, + "direction": CashFlowDirection.INFLOW, + "category_code": _text(row.get("category_code")), + "category_label": _text(row.get("category_label")), + "planned_amount": _as_decimal(row.get("planned_amount")) or Decimal("0"), + "actual_amount": actual, + "planned_date": _as_date(row.get("planned_date")), + "actual_date": _as_date(row.get("actual_date")), + "payment_status": payment_status, + "invoice_status": _text(row.get("invoice_status")), + "approval_status": None, + "confirmation_status": None, + "data_quality_status": quality, + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "source_created_at": _as_datetime(row.get("source_created_at")), + "source_updated_at": None, + "last_seen_at": seen_at, + "is_active": True, + } + + +def _project_fund_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = f"fund:{row['source_id']}" + amount = _as_decimal(row.get("planned_amount")) or Decimal("0") + confirmed = _text(row.get("approval_status")) == "1" and _text( + row.get("confirm_status") + ) == "Y" + project_id = row.get("project_id") + return { + "code": f"INTASECT-FUND-{row['source_id']}", + "project_code": f"INTASECT-PROJECT-{project_id}" if project_id is not None else None, + "contract_code": None, + "flow_type": CashFlowType.PROJECT_FUND, + "direction": ( + CashFlowDirection.INFLOW + if _text(row.get("cost_class")) == "1" + else CashFlowDirection.OUTFLOW + ), + "category_code": _text(row.get("category_code")), + "category_label": _text(row.get("category_label")), + "planned_amount": amount, + "actual_amount": amount if confirmed else None, + "planned_date": None, + "actual_date": _as_date(row.get("trade_time")) if confirmed else None, + "payment_status": None, + "invoice_status": None, + "approval_status": _text(row.get("approval_status")), + "confirmation_status": _text(row.get("confirm_status")), + "data_quality_status": _project_link_quality(row), + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "source_created_at": _as_datetime(row.get("source_created_at")), + "source_updated_at": _as_datetime(row.get("source_updated_at")), + "last_seen_at": seen_at, + "is_active": True, + } + + +def _employee_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = str(row["source_id"]) + active = str(row.get("employment_status") or "0") == "0" + hired_at = None + if row.get("hired_date"): + try: + hired_at = datetime.fromtimestamp(int(row["hired_date"]) / 1000) + except (TypeError, ValueError, OSError): + hired_at = None + return { + "code": f"INTASECT-EMPLOYEE-{external_id}", + "name": _text(row.get("employee_name")) or f"员工 {external_id}", + "department_code": _text(row.get("dept_id")), + "department_name": _text(row.get("dept_name")), + "title": _text(row.get("title")), + "employment_status": "在职" if active else "离职", + "hired_at": hired_at, + "ding_user_id": _text(row.get("ding_id")), + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "source_created_at": _as_datetime(row.get("source_created_at")), + "source_updated_at": _as_datetime(row.get("source_updated_at")), + "last_seen_at": seen_at, + "is_active": active, + } + + +def _member_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = str(row["source_id"]) + return { + "code": f"INTASECT-MEMBER-{external_id}", + "project_code": f"INTASECT-PROJECT-{row['project_id']}", + "employee_code": f"INTASECT-EMPLOYEE-{row['user_id']}", + "role_name": _text(row.get("role_name")), + "is_leader": str(row.get("leader") or "N").upper() == "Y", + "is_resident": str(row.get("resident") or "N").upper() == "Y", + "work_mode": _text(row.get("work_mode")), + "planned_days": _as_int(row.get("work_time")), + "workload_percent": _as_int(row.get("worknum_rate")), + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "source_created_at": _as_datetime(row.get("source_created_at")), + "last_seen_at": seen_at, + "is_active": True, + } + + +def _task_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = str(row["source_id"]) + completed = str(row.get("task_status")) == "1" + project_id = row.get("task_project_id") if str(row.get("project_del_flag")) == "0" else None + employee_id = _numeric_text(row.get("task_assignee")) + return { + "code": f"INTASECT-TASK-{external_id}", + "title": _text(row.get("task_name")) or f"任务 {external_id}", + "project_code": f"INTASECT-PROJECT-{project_id}" if project_id else None, + "owner": _text(row.get("assignee_name")), + "employee_code": f"INTASECT-EMPLOYEE-{employee_id}" if employee_id else None, + "status": StatusValue.COMPLETED if completed else StatusValue.RUNNING, + "priority": _task_priority(row.get("task_level")), + "due_date": _as_date(row.get("task_end_time")), + "completed_at": _as_datetime(row.get("task_done_time")), + "blocker": "任务已挂起" if _as_int(row.get("task_pause")) else None, + "description": _text(row.get("task_content")), + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "source_created_at": _as_datetime(row.get("source_created_at")), + "source_updated_at": _as_datetime(row.get("source_updated_at")), + "last_seen_at": seen_at, + "is_active": True, + } + + +def _milestone_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = str(row["source_key"]) + ended = str(row.get("is_end") or "0") == "1" + plan_end = _as_date(row.get("plan_end")) + overdue = str(row.get("is_overtime") or "0") == "1" or ( + bool(plan_end) + and plan_end < date.today() + and not ended + and str(row.get("is_end") or "0") != "2" + ) + return { + "code": f"INTASECT-MILESTONE-{external_id}", + "project_code": f"INTASECT-PROJECT-{row['project_id']}", + "source_stage_id": str(row["stage_id"]), + "stage_name": _text(row.get("stage_name")) or str(row["stage_id"]), + "plan_start": _as_date(row.get("plan_start")), + "plan_end": plan_end, + "status": StatusValue.COMPLETED if ended else StatusValue.RUNNING, + "is_overdue": overdue, + "activity_total": _as_int(row.get("activity_total")) or 0, + "activity_completed": _as_int(row.get("activity_completed")) or 0, + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "source_updated_at": _as_datetime(row.get("source_updated_at")), + "last_seen_at": seen_at, + "is_active": True, + } + + +def _event_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + source_id = str(row["source_id"]) + need_help = str(row.get("need_help") or "N").upper() == "Y" + event_status = str(row.get("event_status") or "") + return { + "code": f"INTASECT-EVENT-{source_id}", + "title": _text(row.get("event")) + or _text(row.get("event_description")) + or f"项目重大事项 {source_id}", + "risk_type": "project_event", + "risk_level": RiskLevel.HIGH if need_help else RiskLevel.MEDIUM, + "status": StatusValue.RESOLVED if event_status == "YWJ" else StatusValue.OPEN, + "source_domain": "intasect_project_event", + "source_record_id": source_id, + "project_code": f"INTASECT-PROJECT-{row['project_id']}", + "due_date": _as_date(row.get("plan_date")), + "description": _text(row.get("event_description")), + "evidence": { + "source_status": event_status, + "project_stage": _text(row.get("project_status")), + "need_help": need_help, + "source_updated_at": ( + _as_datetime(row.get("source_updated_at")).isoformat() + if _as_datetime(row.get("source_updated_at")) + else None + ), + "last_seen_at": seen_at.isoformat(), + }, + } + + +def _company_attendance_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = f"ding:{row['source_key']}" + source_status = _text(row.get("time_results")) + return { + "code": f"INTASECT-ATTENDANCE-{row['user_id']}-{row['work_date']}", + "employee_name": _text(row.get("user_name")) or f"员工 {row['user_id']}", + "employee_id": f"INTASECT-EMPLOYEE-{row['user_id']}", + "department": _text(row.get("dept_name")), + "work_date": _as_date(row.get("work_date")), + "check_in_at": _as_datetime(row.get("check_in_at")), + "check_out_at": _as_datetime(row.get("check_out_at")), + "status": _attendance_status(source_status), + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "attendance_scope": "company", + "source_status": source_status, + "source_location_status": _text(row.get("location_results")), + "source_updated_at": _as_datetime(row.get("source_updated_at")), + "last_seen_at": seen_at, + "is_active": True, + } + + +def _project_attendance_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + source_id = str(row["source_id"]) + delayed = str(row.get("is_delay") or "N").upper() == "Y" + return { + "code": f"INTASECT-PROJECT-SIGN-{source_id}", + "employee_name": _text(row.get("user_name")) or f"员工 {row['user_id']}", + "employee_id": f"INTASECT-EMPLOYEE-{row['user_id']}", + "department": _text(row.get("dept_name")), + "project_code": f"INTASECT-PROJECT-{row['project_id']}", + "work_date": _as_date(row.get("work_date")), + "check_in_at": _as_datetime(row.get("check_in_at")), + "check_out_at": _as_datetime(row.get("check_out_at")), + "status": StatusValue.LATE if delayed else StatusValue.NORMAL_CN, + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": f"project:{source_id}", + "attendance_scope": "project", + "source_status": "delayed" if delayed else "normal", + "source_updated_at": _as_datetime(row.get("source_updated_at")), + "last_seen_at": seen_at, + "is_active": True, + } + + +def _work_report_payload(row: dict[str, Any], seen_at: datetime) -> dict[str, Any]: + external_id = str(row["source_id"]) + period_end = _as_date(row.get("period_end")) or date.today() + period_start = _as_date(row.get("period_start")) or period_end + report_type = _text(row.get("report_type")) or "daily" + return { + "code": f"INTASECT-WORK-REPORT-{external_id}", + "report_type": report_type, + "title": f"{report_type}-{period_end.isoformat()}", + "reporter": _text(row.get("user_name")) or f"员工 {row['user_id']}", + "employee_code": f"INTASECT-EMPLOYEE-{row['user_id']}", + "department": _text(row.get("dept_name")), + "period_start": period_start, + "period_end": period_end, + "content": "", + "status": StatusValue.GENERATED, + "source_system": SourceSystem.LEGACY_MYSQL, + "external_id": external_id, + "is_late": str(row.get("late") or "N").upper() == "Y", + "is_draft": str(row.get("draft") or "N").upper() != "N", + "source_updated_at": _as_datetime(row.get("source_updated_at")), + "last_seen_at": seen_at, + "is_active": True, + } + + +def _attendance_status(source_status: str | None) -> str: + statuses = set((source_status or "").split(",")) + if "Absenteeism" in statuses: + return StatusValue.ABSENT + if "NotSigned" in statuses: + return StatusValue.MISSING_PUNCH + if "Late" in statuses: + return StatusValue.LATE + if "Early" in statuses: + return StatusValue.LEAVE_EARLY + return StatusValue.NORMAL_CN + + +def _task_priority(value: Any) -> str: + return {"0": "P1", "1": "P2", "2": "P3"}.get(str(value), "P2") + + +def _assign(record: Any, payload: dict[str, Any]) -> None: + for key, value in payload.items(): + setattr(record, key, value) + + +def _as_datetime(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime.combine(value, datetime.min.time()) + if value: + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + return None + + +def _as_date(value: Any) -> date | None: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if value: + text_value = str(value).strip()[:10] + try: + return date.fromisoformat(text_value) + except ValueError: + return None + return None + + +def _as_int(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _as_decimal(value: Any) -> Decimal | None: + if value is None or value == "": + return None + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _scaled_amount(value: Any, multiplier: Decimal) -> Decimal | None: + amount = _as_decimal(value) + return amount * multiplier if amount is not None else None + + +def _project_link_quality(row: dict[str, Any]) -> DataQualityStatus: + if row.get("linked_project_id") is None: + return DataQualityStatus.ORPHAN_PROJECT + if str(row.get("project_del_flag") or "0") != "0": + return DataQualityStatus.DELETED_PROJECT + return DataQualityStatus.VALID + + +def _numeric_text(value: Any) -> str | None: + text_value = _text(value) + return text_value if text_value and text_value.isdigit() else None + + +def _text(value: Any) -> str | None: + if value is None: + return None + text_value = str(value).strip() + return text_value or None diff --git a/app/modules/reports/chart.py b/app/modules/reports/chart.py new file mode 100644 index 0000000..ca76eb2 --- /dev/null +++ b/app/modules/reports/chart.py @@ -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) diff --git a/app/modules/reports/constants.py b/app/modules/reports/constants.py index 42d06f3..cb0e830 100644 --- a/app/modules/reports/constants.py +++ b/app/modules/reports/constants.py @@ -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" diff --git a/app/modules/reports/lifecycle_pipeline.py b/app/modules/reports/lifecycle_pipeline.py new file mode 100644 index 0000000..053b9a4 --- /dev/null +++ b/app/modules/reports/lifecycle_pipeline.py @@ -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() diff --git a/app/modules/reports/routes.py b/app/modules/reports/routes.py index 8c08b6a..2880447 100644 --- a/app/modules/reports/routes.py +++ b/app/modules/reports/routes.py @@ -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, diff --git a/app/modules/reports/schemas.py b/app/modules/reports/schemas.py index 6038404..bfe8ccd 100644 --- a/app/modules/reports/schemas.py +++ b/app/modules/reports/schemas.py @@ -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 diff --git a/app/modules/reports/services/delivery.py b/app/modules/reports/services/delivery.py index 439f001..1875097 100644 --- a/app/modules/reports/services/delivery.py +++ b/app/modules/reports/services/delivery.py @@ -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, diff --git a/app/modules/reports/services/finance_needs.py b/app/modules/reports/services/finance_needs.py new file mode 100644 index 0000000..39865be --- /dev/null +++ b/app/modules/reports/services/finance_needs.py @@ -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}" diff --git a/app/modules/reports/services/intasect_lifecycle.py b/app/modules/reports/services/intasect_lifecycle.py new file mode 100644 index 0000000..772a6e0 --- /dev/null +++ b/app/modules/reports/services/intasect_lifecycle.py @@ -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 + ) diff --git a/app/modules/reports/services/lifecycle/report.py b/app/modules/reports/services/lifecycle/report.py index 92c889b..c1e78da 100644 --- a/app/modules/reports/services/lifecycle/report.py +++ b/app/modules/reports/services/lifecycle/report.py @@ -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) diff --git a/app/modules/reports/services/push_runs.py b/app/modules/reports/services/push_runs.py index 7a30374..7731641 100644 --- a/app/modules/reports/services/push_runs.py +++ b/app/modules/reports/services/push_runs.py @@ -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() diff --git a/app/modules/reports/services/service.py b/app/modules/reports/services/service.py index 0d854c7..ceb282b 100644 --- a/app/modules/reports/services/service.py +++ b/app/modules/reports/services/service.py @@ -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, diff --git a/app/modules/workflows/constants.py b/app/modules/workflows/constants.py index f386dfe..de7539f 100644 --- a/app/modules/workflows/constants.py +++ b/app/modules/workflows/constants.py @@ -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): diff --git a/app/modules/workflows/service.py b/app/modules/workflows/service.py index 1227cc0..6c6f8b1 100644 --- a/app/modules/workflows/service.py +++ b/app/modules/workflows/service.py @@ -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, diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py index 682ed49..275510d 100644 --- a/app/tasks/__init__.py +++ b/app/tasks/__init__.py @@ -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 diff --git a/app/tasks/lifecycle.py b/app/tasks/lifecycle.py new file mode 100644 index 0000000..4b2af92 --- /dev/null +++ b/app/tasks/lifecycle.py @@ -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() diff --git a/app/tools/init_db.py b/app/tools/init_db.py index 7203264..d6f5d43 100644 --- a/app/tools/init_db.py +++ b/app/tools/init_db.py @@ -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, ] diff --git a/environment.yml b/environment.yml index 34f1b59..63f0d95 100644 --- a/environment.yml +++ b/environment.yml @@ -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 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index e7ced67..993ba21 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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")