Files
JiuContinent 9cf7c44393 ```
feat: 添加生命周期报告和AI规则管理功能

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

1144 lines
46 KiB
Python

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