```
feat(ai_agent): 新增openclaw_hermes混合AI适配器 新增OpenClawHermesAdapter适配器,结合Hermes记忆功能和OpenClaw执行能力, 实现AI问答流程中的记忆召回、执行操作和记忆存储的完整闭环。 同时更新NoopAdapter提示信息,添加新的模型提供商选项。 feat(business): 新增考勤、工作报告和风险事件业务模型 新增AttendanceRecord、WorkReport、RiskEvent和LegacySyncRun四个业务模型, 扩展业务领域注册表,支持考勤管理、工作报告生成和风险事件跟踪等核心业务功能。 feat(reports): 实现考勤汇总和工作日报周报生成功能 新增attendance_summary方法用于统计每日考勤情况, 新增generate_work_report方法用于生成日/周经营报告, 包含任务完成情况、待处理事项和风险指标等综合信息。 feat(risk): 扩展风险管理API端点和供应商风险检测 新增供应商风险查询端点和风险事件管理端点, 提供风险事件列表查询和自动生成功能, 增强供应商风险评估能力。 feat(feishu): 添加考勤查询命令和风险摘要增强 集成考勤汇总查询功能到飞书命令系统, 在风险摘要中添加供应商风险和开放风险事件统计, 丰富日常经营管理信息展示。 refactor(service): 优化业务服务数据验证和类型转换 重构_model_payload函数实现数据验证和类型转换, 添加列值类型强制转换逻辑,提高API数据处理的准确性和安全性。 build(deps): 添加postgresql数据库驱动依赖 在Dockerfile中添加psycopg[binary]==3.2.3依赖包, 支持PostgreSQL数据库连接和操作。 chore(config): 更新.gitignore文件排除备份和迁移目录 在.gitignore中添加AGENTS.md.bak-*和migration/目录排除规则, 避免备份文件和本地迁移工作区被提交到版本控制系统。 ```
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,3 +9,7 @@ __pycache__/
|
|||||||
/read.md
|
/read.md
|
||||||
/README.md
|
/README.md
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
|
/AGENTS.md.bak-*
|
||||||
|
|
||||||
|
# Local migration workspace
|
||||||
|
/migration/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ RUN pip install --no-cache-dir \
|
|||||||
"uvicorn[standard]==0.34.0" \
|
"uvicorn[standard]==0.34.0" \
|
||||||
sqlalchemy==2.0.36 \
|
sqlalchemy==2.0.36 \
|
||||||
pymysql==1.1.1 \
|
pymysql==1.1.1 \
|
||||||
|
"psycopg[binary]==3.2.3" \
|
||||||
pydantic-settings==2.7.1 \
|
pydantic-settings==2.7.1 \
|
||||||
python-dotenv==1.0.1 \
|
python-dotenv==1.0.1 \
|
||||||
httpx==0.28.1 \
|
httpx==0.28.1 \
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class NoopAdapter(AIAdapter):
|
|||||||
return {
|
return {
|
||||||
"answer": (
|
"answer": (
|
||||||
"AI provider is not configured yet. This is a deterministic placeholder. "
|
"AI provider is not configured yet. This is a deterministic placeholder. "
|
||||||
"Set MODEL_PROVIDER to openclaw, hermes, or direct_llm after credentials are ready."
|
"Set MODEL_PROVIDER to openclaw_hermes, openclaw, hermes, or direct_llm "
|
||||||
|
"after credentials are ready."
|
||||||
),
|
),
|
||||||
"raw": {"prompt": prompt, "context": context or {}},
|
"raw": {"prompt": prompt, "context": context or {}},
|
||||||
}
|
}
|
||||||
@@ -76,6 +77,78 @@ class HermesAdapter(AIAdapter):
|
|||||||
return {"answer": data.get("answer") or data.get("content") or str(data), "raw": data}
|
return {"answer": data.get("answer") or data.get("content") or str(data), "raw": data}
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawHermesAdapter(AIAdapter):
|
||||||
|
"""Compose Hermes memory with OpenClaw execution."""
|
||||||
|
|
||||||
|
provider_name = "openclaw_hermes"
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.openclaw = OpenClawAdapter(settings)
|
||||||
|
self.hermes = HermesAdapter(settings)
|
||||||
|
|
||||||
|
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
base_context = context or {}
|
||||||
|
recall = self._recall_memory(prompt, base_context)
|
||||||
|
openclaw_context = {
|
||||||
|
**base_context,
|
||||||
|
"agent_pipeline": self.provider_name,
|
||||||
|
"hermes_memory": recall["answer"],
|
||||||
|
}
|
||||||
|
openclaw_result = self.openclaw.ask(prompt, openclaw_context)
|
||||||
|
remember = self._remember_interaction(prompt, base_context, openclaw_result["answer"])
|
||||||
|
return {
|
||||||
|
"answer": openclaw_result["answer"],
|
||||||
|
"raw": {
|
||||||
|
"pipeline": "hermes_recall -> openclaw_answer -> hermes_remember",
|
||||||
|
"hermes_recall": recall,
|
||||||
|
"openclaw": openclaw_result.get("raw", {}),
|
||||||
|
"hermes_remember": remember,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _recall_memory(self, prompt: str, context: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
recall_prompt = (
|
||||||
|
"Retrieve concise long-term memory, preferences, prior decisions, and relevant "
|
||||||
|
"business context for this request. Return only information useful to answer it."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = self.hermes.ask(
|
||||||
|
recall_prompt,
|
||||||
|
{
|
||||||
|
"mode": "memory_recall",
|
||||||
|
"user_prompt": prompt,
|
||||||
|
"request_context": context,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc: # Hermes memory should not block OpenClaw execution.
|
||||||
|
return {"answer": "", "raw": {}, "error": _error_detail(exc)}
|
||||||
|
return {"answer": result["answer"], "raw": result.get("raw", {})}
|
||||||
|
|
||||||
|
def _remember_interaction(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
context: dict[str, Any],
|
||||||
|
answer: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
remember_prompt = (
|
||||||
|
"Store durable lessons from this interaction for future company management "
|
||||||
|
"assistance. Ignore transient details and do not store secrets."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = self.hermes.ask(
|
||||||
|
remember_prompt,
|
||||||
|
{
|
||||||
|
"mode": "memory_write",
|
||||||
|
"user_prompt": prompt,
|
||||||
|
"request_context": context,
|
||||||
|
"assistant_answer": answer,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "raw": {}, "error": _error_detail(exc)}
|
||||||
|
return {"ok": True, "raw": result.get("raw", {}), "answer": result["answer"]}
|
||||||
|
|
||||||
|
|
||||||
class DirectLLMAdapter(AIAdapter):
|
class DirectLLMAdapter(AIAdapter):
|
||||||
"""Adapter for OpenAI-compatible chat completions APIs."""
|
"""Adapter for OpenAI-compatible chat completions APIs."""
|
||||||
|
|
||||||
@@ -118,6 +191,14 @@ def get_adapter() -> AIAdapter:
|
|||||||
return OpenClawAdapter(settings)
|
return OpenClawAdapter(settings)
|
||||||
if provider == "hermes":
|
if provider == "hermes":
|
||||||
return HermesAdapter(settings)
|
return HermesAdapter(settings)
|
||||||
|
if provider in {"openclaw_hermes", "openclaw-hermes", "hybrid"}:
|
||||||
|
return OpenClawHermesAdapter(settings)
|
||||||
if provider == "direct_llm":
|
if provider == "direct_llm":
|
||||||
return DirectLLMAdapter(settings)
|
return DirectLLMAdapter(settings)
|
||||||
return NoopAdapter()
|
return NoopAdapter()
|
||||||
|
|
||||||
|
|
||||||
|
def _error_detail(exc: Exception) -> Any:
|
||||||
|
if isinstance(exc, HTTPException):
|
||||||
|
return exc.detail
|
||||||
|
return {"type": type(exc).__name__, "message": str(exc)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, Integer, Numeric, String, Text
|
from sqlalchemy import JSON, Date, DateTime, Integer, Numeric, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -157,3 +157,78 @@ class Supplier(Base, TimestampMixin):
|
|||||||
price_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
price_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
||||||
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
|
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
|
||||||
blacklist_status: Mapped[str] = mapped_column(String(32), default="normal", index=True)
|
blacklist_status: Mapped[str] = mapped_column(String(32), default="normal", index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AttendanceRecord(Base, TimestampMixin):
|
||||||
|
__tablename__ = "attendance_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
employee_name: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
employee_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
work_date: Mapped[date] = mapped_column(Date, index=True)
|
||||||
|
check_in_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
check_out_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(64), default="正常", index=True)
|
||||||
|
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
source_system: Mapped[str] = mapped_column(String(64), default="internal")
|
||||||
|
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkReport(Base, TimestampMixin):
|
||||||
|
__tablename__ = "work_reports"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
report_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
reporter: Mapped[str] = mapped_column(String(128), default="system", index=True)
|
||||||
|
department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
period_start: Mapped[date] = mapped_column(Date, index=True)
|
||||||
|
period_end: Mapped[date] = mapped_column(Date, index=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
metrics: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
risk_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(64), default="已生成", index=True)
|
||||||
|
source_system: Mapped[str] = mapped_column(String(64), default="internal")
|
||||||
|
|
||||||
|
|
||||||
|
class RiskEvent(Base, TimestampMixin):
|
||||||
|
__tablename__ = "risk_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
risk_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
risk_level: Mapped[str] = mapped_column(String(32), default="medium", index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="open", index=True)
|
||||||
|
source_domain: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
source_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
detected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
mitigation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
evidence: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LegacySyncRun(Base, TimestampMixin):
|
||||||
|
__tablename__ = "legacy_sync_runs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
domain: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
source_table: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="running", index=True)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
updated_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
skipped_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -13,9 +13,25 @@ DOMAIN_MODELS: dict[str, type[DeclarativeMeta]] = {
|
|||||||
"standards": models.Standard,
|
"standards": models.Standard,
|
||||||
"performance-metrics": models.PerformanceMetric,
|
"performance-metrics": models.PerformanceMetric,
|
||||||
"suppliers": models.Supplier,
|
"suppliers": models.Supplier,
|
||||||
|
"attendance-records": models.AttendanceRecord,
|
||||||
|
"work-reports": models.WorkReport,
|
||||||
|
"risk-events": models.RiskEvent,
|
||||||
|
"legacy-sync-runs": models.LegacySyncRun,
|
||||||
}
|
}
|
||||||
|
|
||||||
LOW_RISK_DOMAINS = {"projects", "tasks", "procurements", "expenses", "policies", "standards"}
|
LOW_RISK_DOMAINS = {
|
||||||
|
"projects",
|
||||||
|
"tasks",
|
||||||
|
"procurements",
|
||||||
|
"expenses",
|
||||||
|
"policies",
|
||||||
|
"standards",
|
||||||
|
"suppliers",
|
||||||
|
"attendance-records",
|
||||||
|
"work-reports",
|
||||||
|
"risk-events",
|
||||||
|
"legacy-sync-runs",
|
||||||
|
}
|
||||||
HIGH_RISK_DOMAINS = {"fund-accounts", "performance-metrics"}
|
HIGH_RISK_DOMAINS = {"fund-accounts", "performance-metrics"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import Date as SQLDate
|
||||||
|
from sqlalchemy import DateTime as SQLDateTime
|
||||||
|
from sqlalchemy import Numeric as SQLNumeric
|
||||||
from sqlalchemy import Select, func, select
|
from sqlalchemy import Select, func, select
|
||||||
|
from sqlalchemy.sql.schema import Column
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
@@ -27,6 +31,39 @@ def serialize_model(record: Any) -> dict[str, Any]:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_column_value(column: Column, value: Any) -> Any:
|
||||||
|
"""Coerce API JSON values into the Python type expected by a SQLAlchemy column."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(column.type, SQLDateTime) and isinstance(value, str):
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
if isinstance(column.type, SQLDate) and isinstance(value, str):
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
if isinstance(column.type, SQLNumeric) and not isinstance(value, Decimal):
|
||||||
|
return Decimal(str(value))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Filter unknown keys and coerce values according to model column types."""
|
||||||
|
|
||||||
|
columns = {column.name: column for column in model.__table__.columns if column.name != "id"}
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
column = columns.get(key)
|
||||||
|
if column is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload[key] = _coerce_column_value(column, value)
|
||||||
|
except (ValueError, TypeError, InvalidOperation) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid value for field '{key}'",
|
||||||
|
) from exc
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
class BusinessService:
|
class BusinessService:
|
||||||
"""Manage generic CRUD operations across registered business domains."""
|
"""Manage generic CRUD operations across registered business domains."""
|
||||||
|
|
||||||
@@ -65,8 +102,7 @@ class BusinessService:
|
|||||||
actor: str = "api",
|
actor: str = "api",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
model = get_domain_model(domain)
|
model = get_domain_model(domain)
|
||||||
allowed = {column.name for column in model.__table__.columns if column.name != "id"}
|
payload = _model_payload(model, data)
|
||||||
payload = {key: value for key, value in data.items() if key in allowed}
|
|
||||||
record = model(**payload)
|
record = model(**payload)
|
||||||
self.db.add(record)
|
self.db.add(record)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
@@ -116,9 +152,7 @@ class BusinessService:
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Record not found",
|
detail="Record not found",
|
||||||
)
|
)
|
||||||
allowed = {column.name for column in model.__table__.columns if column.name != "id"}
|
for key, value in _model_payload(model, data).items():
|
||||||
for key, value in data.items():
|
|
||||||
if key in allowed:
|
|
||||||
setattr(record, key, value)
|
setattr(record, key, value)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(record)
|
self.db.refresh(record)
|
||||||
|
|||||||
@@ -108,6 +108,25 @@ class FeishuCommandService:
|
|||||||
result["provider_response"] = provider_response
|
result["provider_response"] = provider_response
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if any(keyword in command_text for keyword in ["打卡", "考勤", "attendance"]):
|
||||||
|
report = ReportService(self.db).attendance_summary()
|
||||||
|
result = {
|
||||||
|
"command": "attendance_summary",
|
||||||
|
"reply_type": "card",
|
||||||
|
"title": report["title"],
|
||||||
|
"content": report["content"],
|
||||||
|
"lines": report["lines"],
|
||||||
|
}
|
||||||
|
if auto_reply:
|
||||||
|
provider_response = self._send_card_if_configured(
|
||||||
|
chat_id,
|
||||||
|
report["title"],
|
||||||
|
report["lines"],
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
result["provider_response"] = provider_response
|
||||||
|
return result
|
||||||
|
|
||||||
if any(keyword in command_text for keyword in ["风险", "预警", "risk"]):
|
if any(keyword in command_text for keyword in ["风险", "预警", "risk"]):
|
||||||
summary = RiskService(self.db).summary()
|
summary = RiskService(self.db).summary()
|
||||||
lines = [
|
lines = [
|
||||||
@@ -117,6 +136,8 @@ class FeishuCommandService:
|
|||||||
f"- 延期项目:{len(summary['delayed_projects'])}",
|
f"- 延期项目:{len(summary['delayed_projects'])}",
|
||||||
f"- 超预算项目:{len(summary['over_budget_projects'])}",
|
f"- 超预算项目:{len(summary['over_budget_projects'])}",
|
||||||
f"- 资金风险账户:{len(summary['fund_risks'])}",
|
f"- 资金风险账户:{len(summary['fund_risks'])}",
|
||||||
|
f"- 供应商风险:{len(summary['supplier_risks'])}",
|
||||||
|
f"- 打开风险事件:{len(summary['open_events'])}",
|
||||||
]
|
]
|
||||||
result = {
|
result = {
|
||||||
"command": "risk_summary",
|
"command": "risk_summary",
|
||||||
|
|||||||
@@ -51,4 +51,5 @@ class LegacyProjectSyncResult(BaseModel):
|
|||||||
created: int
|
created: int
|
||||||
updated: int
|
updated: int
|
||||||
skipped: int
|
skipped: int
|
||||||
|
sync_run_code: str | None = None
|
||||||
items: list[dict[str, Any]]
|
items: list[dict[str, Any]]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.core.config import get_settings
|
|||||||
from app.core.database import legacy_engine
|
from app.core.database import legacy_engine
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
from app.modules.audit.service import AuditService
|
from app.modules.audit.service import AuditService
|
||||||
from app.modules.business.models import Project
|
from app.modules.business.models import LegacySyncRun, Project
|
||||||
from app.modules.business.service import serialize_model
|
from app.modules.business.service import serialize_model
|
||||||
|
|
||||||
FORBIDDEN_SQL_TOKENS = {
|
FORBIDDEN_SQL_TOKENS = {
|
||||||
@@ -256,6 +256,22 @@ class LegacyMySQLService:
|
|||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
sync_run = LegacySyncRun(
|
||||||
|
code=f"SYNC-PROJECTS-{datetime.utcnow():%Y%m%d%H%M%S%f}",
|
||||||
|
domain="projects",
|
||||||
|
source_table="LEGACY_PROJECT_QUERY",
|
||||||
|
status="dry_run" if dry_run else "success",
|
||||||
|
finished_at=datetime.utcnow(),
|
||||||
|
created_count=created,
|
||||||
|
updated_count=updated,
|
||||||
|
skipped_count=skipped,
|
||||||
|
note="Project sync from readonly legacy MySQL",
|
||||||
|
)
|
||||||
|
self.db.add(sync_run)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(sync_run)
|
||||||
|
result["sync_run_code"] = sync_run.code
|
||||||
|
|
||||||
AuditService(self.db).log(
|
AuditService(self.db).log(
|
||||||
AuditLogCreate(
|
AuditLogCreate(
|
||||||
actor=actor,
|
actor=actor,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_api_key
|
from app.core.security import require_api_key
|
||||||
from app.modules.reports.schemas import PushReportRequest, ReportResponse
|
from app.modules.reports.schemas import (
|
||||||
|
PushReportRequest,
|
||||||
|
ReportResponse,
|
||||||
|
WorkReportGenerateRequest,
|
||||||
|
)
|
||||||
from app.modules.reports.service import ReportService
|
from app.modules.reports.service import ReportService
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||||
@@ -19,6 +25,31 @@ def project_weekly(db: Session = Depends(get_db)) -> dict:
|
|||||||
return ReportService(db).project_weekly()
|
return ReportService(db).project_weekly()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attendance-summary")
|
||||||
|
def attendance_summary(
|
||||||
|
work_date: date | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return ReportService(db).attendance_summary(work_date)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/work-reports/generate")
|
||||||
|
def generate_work_report(
|
||||||
|
payload: WorkReportGenerateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return ReportService(db).generate_work_report(
|
||||||
|
report_type=payload.report_type,
|
||||||
|
reporter=payload.reporter,
|
||||||
|
department=payload.department,
|
||||||
|
project_code=payload.project_code,
|
||||||
|
period_start=payload.period_start,
|
||||||
|
period_end=payload.period_end,
|
||||||
|
persist=payload.persist,
|
||||||
|
actor=payload.actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/daily-brief/push")
|
@router.post("/daily-brief/push")
|
||||||
def push_daily_brief(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
|
def push_daily_brief(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
report = ReportService(db).daily_brief()
|
report = ReportService(db).daily_brief()
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from datetime import date
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -11,3 +14,14 @@ class PushReportRequest(BaseModel):
|
|||||||
receive_id: str | None = None
|
receive_id: str | None = None
|
||||||
receive_id_type: str = "chat_id"
|
receive_id_type: str = "chat_id"
|
||||||
actor: str = "system"
|
actor: str = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkReportGenerateRequest(BaseModel):
|
||||||
|
report_type: Literal["daily", "weekly"] = "daily"
|
||||||
|
reporter: str = "system"
|
||||||
|
department: str | None = None
|
||||||
|
project_code: str | None = None
|
||||||
|
period_start: date | None = None
|
||||||
|
period_end: date | None = None
|
||||||
|
persist: bool = True
|
||||||
|
actor: str = "api"
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
|
from datetime import date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.modules.business.models import Expense, FundAccount, Procurement, Project, WorkTask
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
|
from app.modules.audit.service import AuditService
|
||||||
|
from app.modules.business.models import (
|
||||||
|
AttendanceRecord,
|
||||||
|
Expense,
|
||||||
|
FundAccount,
|
||||||
|
Procurement,
|
||||||
|
Project,
|
||||||
|
RiskEvent,
|
||||||
|
WorkReport,
|
||||||
|
WorkTask,
|
||||||
|
)
|
||||||
|
from app.modules.business.service import serialize_model
|
||||||
from app.modules.feishu.service import FeishuService
|
from app.modules.feishu.service import FeishuService
|
||||||
from app.modules.risk.service import RiskService
|
from app.modules.risk.service import RiskService
|
||||||
|
|
||||||
|
DONE_STATUSES = {"完成", "已完成", "关闭", "done", "completed", "closed"}
|
||||||
|
PENDING_APPROVAL_STATUSES = {"草稿", "审批中", "待审批", "pending"}
|
||||||
|
PROJECT_CLOSED_STATUSES = {"验收", "已完成", "复盘", "归档", "关闭", "closed"}
|
||||||
|
|
||||||
|
|
||||||
def _money(value: Decimal | int | float | None) -> str:
|
def _money(value: Decimal | int | float | None) -> str:
|
||||||
"""Format a numeric value as a two-decimal money string."""
|
"""Format a numeric value as a two-decimal money string."""
|
||||||
@@ -15,6 +33,26 @@ def _money(value: Decimal | int | float | None) -> str:
|
|||||||
return f"{amount:,.2f}"
|
return f"{amount:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value: Any) -> Any:
|
||||||
|
"""Convert nested report payloads into JSON-storable values."""
|
||||||
|
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, (datetime, date)):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_json_safe(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _json_safe(item) for key, item in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _next_code(prefix: str) -> str:
|
||||||
|
"""Build a compact unique code for generated report records."""
|
||||||
|
|
||||||
|
return f"{prefix}-{datetime.utcnow():%Y%m%d%H%M%S%f}"
|
||||||
|
|
||||||
|
|
||||||
class ReportService:
|
class ReportService:
|
||||||
"""Build operational reports and push them through Feishu."""
|
"""Build operational reports and push them through Feishu."""
|
||||||
|
|
||||||
@@ -22,61 +60,59 @@ class ReportService:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.risks = RiskService(db)
|
self.risks = RiskService(db)
|
||||||
|
|
||||||
|
def _count(self, model: type, *conditions: Any) -> int:
|
||||||
|
stmt = select(func.count()).select_from(model)
|
||||||
|
for condition in conditions:
|
||||||
|
stmt = stmt.where(condition)
|
||||||
|
return int(self.db.execute(stmt).scalar() or 0)
|
||||||
|
|
||||||
def daily_brief(self) -> dict:
|
def daily_brief(self) -> dict:
|
||||||
project_count = int(
|
project_count = self._count(Project)
|
||||||
self.db.execute(select(func.count()).select_from(Project)).scalar() or 0
|
task_count = self._count(WorkTask)
|
||||||
|
procurement_pending = self._count(
|
||||||
|
Procurement,
|
||||||
|
Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES),
|
||||||
)
|
)
|
||||||
task_count = int(self.db.execute(select(func.count()).select_from(WorkTask)).scalar() or 0)
|
expense_pending = self._count(
|
||||||
procurement_pending = int(
|
Expense,
|
||||||
self.db.execute(
|
Expense.approval_status.in_(PENDING_APPROVAL_STATUSES),
|
||||||
select(func.count())
|
|
||||||
.select_from(Procurement)
|
|
||||||
.where(Procurement.approval_status.in_(["草稿", "审批中", "待审批"]))
|
|
||||||
).scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
expense_pending = int(
|
|
||||||
self.db.execute(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Expense)
|
|
||||||
.where(Expense.approval_status.in_(["草稿", "审批中", "待审批"]))
|
|
||||||
).scalar()
|
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
fund_total = (
|
fund_total = (
|
||||||
self.db.execute(select(func.sum(FundAccount.current_balance))).scalar()
|
self.db.execute(select(func.sum(FundAccount.current_balance))).scalar()
|
||||||
or Decimal("0")
|
or Decimal("0")
|
||||||
)
|
)
|
||||||
risk_summary = self.risks.summary()
|
risk_summary = self.risks.summary()
|
||||||
|
attendance = self.attendance_summary()
|
||||||
lines = [
|
lines = [
|
||||||
f"- 项目总数:{project_count}",
|
f"- 项目总数:{project_count}",
|
||||||
f"- 任务总数:{task_count}",
|
f"- 任务总数:{task_count}",
|
||||||
f"- 待处理采购:{procurement_pending}",
|
f"- 待处理采购:{procurement_pending}",
|
||||||
f"- 待处理费用:{expense_pending}",
|
f"- 待处理费用:{expense_pending}",
|
||||||
f"- 当前账户总余额:{_money(fund_total)}",
|
f"- 当前账户总余额:{_money(fund_total)}",
|
||||||
|
f"- 今日打卡记录:{attendance['total']},异常:{attendance['abnormal_total']}",
|
||||||
f"- 逾期任务:{len(risk_summary['overdue_tasks'])}",
|
f"- 逾期任务:{len(risk_summary['overdue_tasks'])}",
|
||||||
f"- 延期项目:{len(risk_summary['delayed_projects'])}",
|
f"- 延期项目:{len(risk_summary['delayed_projects'])}",
|
||||||
f"- 超预算项目:{len(risk_summary['over_budget_projects'])}",
|
f"- 超预算项目:{len(risk_summary['over_budget_projects'])}",
|
||||||
f"- 资金风险账户:{len(risk_summary['fund_risks'])}",
|
f"- 资金风险账户:{len(risk_summary['fund_risks'])}",
|
||||||
|
f"- 供应商风险:{len(risk_summary['supplier_risks'])}",
|
||||||
|
f"- 打开风险事件:{len(risk_summary['open_events'])}",
|
||||||
f"- 综合风险等级:{risk_summary['risk_level']}",
|
f"- 综合风险等级:{risk_summary['risk_level']}",
|
||||||
]
|
]
|
||||||
return {"title": "每日经营晨报", "lines": lines, "content": "\n".join(lines)}
|
return {"title": "每日经营晨报", "lines": lines, "content": "\n".join(lines)}
|
||||||
|
|
||||||
def project_weekly(self) -> dict:
|
def project_weekly(self) -> dict:
|
||||||
active = int(
|
active = self._count(
|
||||||
self.db.execute(
|
Project,
|
||||||
select(func.count())
|
Project.status.notin_(PROJECT_CLOSED_STATUSES),
|
||||||
.select_from(Project)
|
|
||||||
.where(Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭"]))
|
|
||||||
).scalar()
|
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
delayed = self.risks.delayed_projects()
|
delayed = self.risks.delayed_projects()
|
||||||
over_budget = self.risks.over_budget_projects()
|
over_budget = self.risks.over_budget_projects()
|
||||||
|
open_risks = self.risks.list_events(status_filter="open")
|
||||||
lines = [
|
lines = [
|
||||||
f"- 活跃项目:{active}",
|
f"- 活跃项目:{active}",
|
||||||
f"- 延期项目:{len(delayed)}",
|
f"- 延期项目:{len(delayed)}",
|
||||||
f"- 超预算项目:{len(over_budget)}",
|
f"- 超预算项目:{len(over_budget)}",
|
||||||
|
f"- 打开风险事件:{len(open_risks)}",
|
||||||
"- 需要管理层关注:",
|
"- 需要管理层关注:",
|
||||||
]
|
]
|
||||||
for item in delayed[:10]:
|
for item in delayed[:10]:
|
||||||
@@ -85,6 +121,184 @@ class ReportService:
|
|||||||
lines.append(f" - 超预算:{item.get('code')} {item.get('name')}")
|
lines.append(f" - 超预算:{item.get('code')} {item.get('name')}")
|
||||||
return {"title": "项目周报", "lines": lines, "content": "\n".join(lines)}
|
return {"title": "项目周报", "lines": lines, "content": "\n".join(lines)}
|
||||||
|
|
||||||
|
def attendance_summary(self, work_date: date | None = None) -> dict[str, Any]:
|
||||||
|
"""Summarize attendance records for one business day."""
|
||||||
|
|
||||||
|
target_date = work_date or date.today()
|
||||||
|
rows = self.db.execute(
|
||||||
|
select(AttendanceRecord.status, func.count())
|
||||||
|
.where(AttendanceRecord.work_date == target_date)
|
||||||
|
.group_by(AttendanceRecord.status)
|
||||||
|
).all()
|
||||||
|
status_counts = {str(status): int(count) for status, count in rows}
|
||||||
|
abnormal_total = sum(
|
||||||
|
count
|
||||||
|
for status, count in status_counts.items()
|
||||||
|
if status in {"迟到", "早退", "缺卡", "旷工", "异常"}
|
||||||
|
)
|
||||||
|
total = sum(status_counts.values())
|
||||||
|
lines = [
|
||||||
|
f"- 日期:{target_date.isoformat()}",
|
||||||
|
f"- 打卡记录:{total}",
|
||||||
|
f"- 异常记录:{abnormal_total}",
|
||||||
|
]
|
||||||
|
for status, count in sorted(status_counts.items()):
|
||||||
|
lines.append(f"- {status}:{count}")
|
||||||
|
return {
|
||||||
|
"title": "打卡汇总",
|
||||||
|
"work_date": target_date.isoformat(),
|
||||||
|
"total": total,
|
||||||
|
"abnormal_total": abnormal_total,
|
||||||
|
"status_counts": status_counts,
|
||||||
|
"lines": lines,
|
||||||
|
"content": "\n".join(lines),
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_work_report(
|
||||||
|
self,
|
||||||
|
report_type: str = "daily",
|
||||||
|
reporter: str = "system",
|
||||||
|
department: str | None = None,
|
||||||
|
project_code: str | None = None,
|
||||||
|
period_start: date | None = None,
|
||||||
|
period_end: date | None = None,
|
||||||
|
persist: bool = True,
|
||||||
|
actor: str = "api",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Generate a daily or weekly operating report, optionally persisting it."""
|
||||||
|
|
||||||
|
start, end = self._resolve_period(report_type, period_start, period_end)
|
||||||
|
metrics = self._report_metrics(start, end, project_code, department)
|
||||||
|
risk_summary = _json_safe(self.risks.summary())
|
||||||
|
title = "经营日报" if report_type == "daily" else "经营周报"
|
||||||
|
lines = self._work_report_lines(title, start, end, metrics, risk_summary)
|
||||||
|
report = {
|
||||||
|
"title": title,
|
||||||
|
"report_type": report_type,
|
||||||
|
"period_start": start.isoformat(),
|
||||||
|
"period_end": end.isoformat(),
|
||||||
|
"lines": lines,
|
||||||
|
"content": "\n".join(lines),
|
||||||
|
"metrics": metrics,
|
||||||
|
"risk_summary": risk_summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
record_data = None
|
||||||
|
if persist:
|
||||||
|
record = WorkReport(
|
||||||
|
code=_next_code(f"REPORT-{report_type.upper()}"),
|
||||||
|
report_type=report_type,
|
||||||
|
title=title,
|
||||||
|
reporter=reporter,
|
||||||
|
department=department,
|
||||||
|
project_code=project_code,
|
||||||
|
period_start=start,
|
||||||
|
period_end=end,
|
||||||
|
content=report["content"],
|
||||||
|
metrics=metrics,
|
||||||
|
risk_summary=risk_summary,
|
||||||
|
)
|
||||||
|
self.db.add(record)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
record_data = serialize_model(record)
|
||||||
|
AuditService(self.db).log(
|
||||||
|
AuditLogCreate(
|
||||||
|
actor=actor,
|
||||||
|
source="reports",
|
||||||
|
action=f"generate_{report_type}_report",
|
||||||
|
target_type="work-reports",
|
||||||
|
target_id=str(record.id),
|
||||||
|
response_payload=record_data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"report": report, "data": record_data}
|
||||||
|
|
||||||
|
def _resolve_period(
|
||||||
|
self,
|
||||||
|
report_type: str,
|
||||||
|
period_start: date | None,
|
||||||
|
period_end: date | None,
|
||||||
|
) -> tuple[date, date]:
|
||||||
|
today = date.today()
|
||||||
|
if report_type == "daily":
|
||||||
|
start = period_start or period_end or today
|
||||||
|
return start, period_end or start
|
||||||
|
end = period_end or today
|
||||||
|
start = period_start or end - timedelta(days=6)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
def _report_metrics(
|
||||||
|
self,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
project_code: str | None,
|
||||||
|
department: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
task_filters = [
|
||||||
|
WorkTask.due_date.is_not(None),
|
||||||
|
WorkTask.due_date >= start,
|
||||||
|
WorkTask.due_date <= end,
|
||||||
|
]
|
||||||
|
procurement_filters = [Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES)]
|
||||||
|
expense_filters = [Expense.approval_status.in_(PENDING_APPROVAL_STATUSES)]
|
||||||
|
attendance_filters = [
|
||||||
|
AttendanceRecord.work_date >= start,
|
||||||
|
AttendanceRecord.work_date <= end,
|
||||||
|
]
|
||||||
|
if project_code:
|
||||||
|
task_filters.append(WorkTask.project_code == project_code)
|
||||||
|
procurement_filters.append(Procurement.project_code == project_code)
|
||||||
|
expense_filters.append(Expense.project_code == project_code)
|
||||||
|
attendance_filters.append(AttendanceRecord.project_code == project_code)
|
||||||
|
if department:
|
||||||
|
expense_filters.append(Expense.department == department)
|
||||||
|
attendance_filters.append(AttendanceRecord.department == department)
|
||||||
|
|
||||||
|
completed_tasks = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *task_filters)
|
||||||
|
overdue_tasks = self._count(
|
||||||
|
WorkTask,
|
||||||
|
WorkTask.due_date < date.today(),
|
||||||
|
WorkTask.status.notin_(DONE_STATUSES),
|
||||||
|
*task_filters,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"projects_total": self._count(Project),
|
||||||
|
"active_projects": self._count(
|
||||||
|
Project,
|
||||||
|
Project.status.notin_(PROJECT_CLOSED_STATUSES),
|
||||||
|
),
|
||||||
|
"tasks_total": self._count(WorkTask, *task_filters),
|
||||||
|
"tasks_completed": completed_tasks,
|
||||||
|
"tasks_overdue": overdue_tasks,
|
||||||
|
"procurements_pending": self._count(Procurement, *procurement_filters),
|
||||||
|
"expenses_pending": self._count(Expense, *expense_filters),
|
||||||
|
"attendance_total": self._count(AttendanceRecord, *attendance_filters),
|
||||||
|
"open_risk_events": self._count(RiskEvent, RiskEvent.status == "open"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _work_report_lines(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metrics: dict[str, Any],
|
||||||
|
risk_summary: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
return [
|
||||||
|
f"- 报告:{title}",
|
||||||
|
f"- 周期:{start.isoformat()} 至 {end.isoformat()}",
|
||||||
|
f"- 项目:总数 {metrics['projects_total']},活跃 {metrics['active_projects']}",
|
||||||
|
f"- 任务:总数 {metrics['tasks_total']},完成 {metrics['tasks_completed']}",
|
||||||
|
f"- 逾期任务:{metrics['tasks_overdue']}",
|
||||||
|
f"- 待处理采购:{metrics['procurements_pending']}",
|
||||||
|
f"- 待处理费用:{metrics['expenses_pending']}",
|
||||||
|
f"- 打卡记录:{metrics['attendance_total']}",
|
||||||
|
f"- 打开风险事件:{metrics['open_risk_events']}",
|
||||||
|
f"- 综合风险等级:{risk_summary['risk_level']}",
|
||||||
|
]
|
||||||
|
|
||||||
def push_report(
|
def push_report(
|
||||||
self,
|
self,
|
||||||
report: dict,
|
report: dict,
|
||||||
|
|||||||
@@ -31,3 +31,22 @@ def over_budget_projects(db: Session = Depends(get_db)) -> dict:
|
|||||||
@router.get("/funds")
|
@router.get("/funds")
|
||||||
def fund_risks(db: Session = Depends(get_db)) -> dict:
|
def fund_risks(db: Session = Depends(get_db)) -> dict:
|
||||||
return {"items": RiskService(db).fund_risks()}
|
return {"items": RiskService(db).fund_risks()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/suppliers")
|
||||||
|
def supplier_risks(db: Session = Depends(get_db)) -> dict:
|
||||||
|
return {"items": RiskService(db).supplier_risks()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
def risk_events(
|
||||||
|
limit: int = 100,
|
||||||
|
status: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return {"items": RiskService(db).list_events(limit=limit, status_filter=status)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/generate")
|
||||||
|
def generate_risk_events(actor: str = "api", db: Session = Depends(get_db)) -> dict:
|
||||||
|
return RiskService(db).generate_events(actor=actor)
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.modules.business.models import FundAccount, Project, WorkTask
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
|
from app.modules.audit.service import AuditService
|
||||||
|
from app.modules.business.models import FundAccount, Project, RiskEvent, Supplier, WorkTask
|
||||||
from app.modules.business.service import serialize_model
|
from app.modules.business.service import serialize_model
|
||||||
|
|
||||||
|
DONE_STATUSES = {"完成", "已完成", "关闭", "done", "completed", "closed"}
|
||||||
|
CLOSED_RISK_STATUSES = {"closed", "resolved"}
|
||||||
|
|
||||||
|
|
||||||
class RiskService:
|
class RiskService:
|
||||||
"""Evaluate rule-based business risk signals from internal ledgers."""
|
"""Evaluate rule-based business risk signals from internal ledgers."""
|
||||||
@@ -19,7 +24,7 @@ class RiskService:
|
|||||||
stmt = select(WorkTask).where(
|
stmt = select(WorkTask).where(
|
||||||
WorkTask.due_date.is_not(None),
|
WorkTask.due_date.is_not(None),
|
||||||
WorkTask.due_date < date.today(),
|
WorkTask.due_date < date.today(),
|
||||||
WorkTask.status.notin_(["完成", "已完成", "关闭"]),
|
WorkTask.status.notin_(DONE_STATUSES),
|
||||||
)
|
)
|
||||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
@@ -27,7 +32,7 @@ class RiskService:
|
|||||||
stmt = select(Project).where(
|
stmt = select(Project).where(
|
||||||
Project.due_date.is_not(None),
|
Project.due_date.is_not(None),
|
||||||
Project.due_date < date.today(),
|
Project.due_date < date.today(),
|
||||||
Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭"]),
|
Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭", "closed"]),
|
||||||
)
|
)
|
||||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
@@ -42,16 +47,42 @@ class RiskService:
|
|||||||
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
|
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
|
||||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
|
def supplier_risks(self) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(Supplier).where(
|
||||||
|
(Supplier.blacklist_status != "normal")
|
||||||
|
| Supplier.risk_level.in_(["medium", "high"])
|
||||||
|
)
|
||||||
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
|
def list_events(
|
||||||
|
self,
|
||||||
|
limit: int = 100,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(min(limit, 500))
|
||||||
|
if status_filter:
|
||||||
|
stmt = (
|
||||||
|
select(RiskEvent)
|
||||||
|
.where(RiskEvent.status == status_filter)
|
||||||
|
.order_by(RiskEvent.id.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
)
|
||||||
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
def summary(self) -> dict[str, Any]:
|
def summary(self) -> dict[str, Any]:
|
||||||
overdue_tasks = self.overdue_tasks()
|
overdue_tasks = self.overdue_tasks()
|
||||||
delayed_projects = self.delayed_projects()
|
delayed_projects = self.delayed_projects()
|
||||||
over_budget_projects = self.over_budget_projects()
|
over_budget_projects = self.over_budget_projects()
|
||||||
fund_risks = self.fund_risks()
|
fund_risks = self.fund_risks()
|
||||||
|
supplier_risks = self.supplier_risks()
|
||||||
|
open_events = self.list_events(status_filter="open")
|
||||||
risk_score = (
|
risk_score = (
|
||||||
len(overdue_tasks) * 1
|
len(overdue_tasks) * 1
|
||||||
+ len(delayed_projects) * 3
|
+ len(delayed_projects) * 3
|
||||||
+ len(over_budget_projects) * 4
|
+ len(over_budget_projects) * 4
|
||||||
+ len(fund_risks) * 5
|
+ len(fund_risks) * 5
|
||||||
|
+ len(supplier_risks) * 3
|
||||||
|
+ len(open_events) * 2
|
||||||
)
|
)
|
||||||
if risk_score >= 15:
|
if risk_score >= 15:
|
||||||
level = "high"
|
level = "high"
|
||||||
@@ -66,4 +97,195 @@ class RiskService:
|
|||||||
"delayed_projects": delayed_projects,
|
"delayed_projects": delayed_projects,
|
||||||
"over_budget_projects": over_budget_projects,
|
"over_budget_projects": over_budget_projects,
|
||||||
"fund_risks": fund_risks,
|
"fund_risks": fund_risks,
|
||||||
|
"supplier_risks": supplier_risks,
|
||||||
|
"open_events": open_events,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def generate_events(self, actor: str = "api") -> dict[str, Any]:
|
||||||
|
"""Generate or refresh risk-event ledger entries from current signals."""
|
||||||
|
|
||||||
|
payloads = self._build_event_payloads()
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for payload in payloads:
|
||||||
|
record = self.db.execute(
|
||||||
|
select(RiskEvent).where(RiskEvent.code == payload["code"])
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
record = RiskEvent(**payload)
|
||||||
|
self.db.add(record)
|
||||||
|
self.db.flush()
|
||||||
|
created += 1
|
||||||
|
action = "created"
|
||||||
|
elif record.status in CLOSED_RISK_STATUSES:
|
||||||
|
skipped += 1
|
||||||
|
items.append({"action": "skipped", "risk_event": serialize_model(record)})
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
for key, value in payload.items():
|
||||||
|
if key != "code":
|
||||||
|
setattr(record, key, value)
|
||||||
|
updated += 1
|
||||||
|
action = "updated"
|
||||||
|
items.append({"action": action, "risk_event": serialize_model(record)})
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
AuditService(self.db).log(
|
||||||
|
AuditLogCreate(
|
||||||
|
actor=actor,
|
||||||
|
source="risk",
|
||||||
|
action="generate_events",
|
||||||
|
target_type="risk-events",
|
||||||
|
risk_level="medium",
|
||||||
|
response_payload={
|
||||||
|
"created": created,
|
||||||
|
"updated": updated,
|
||||||
|
"skipped": skipped,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {"created": created, "updated": updated, "skipped": skipped, "items": items}
|
||||||
|
|
||||||
|
def _build_event_payloads(self) -> list[dict[str, Any]]:
|
||||||
|
payloads: list[dict[str, Any]] = []
|
||||||
|
payloads.extend(self._overdue_task_payloads())
|
||||||
|
payloads.extend(self._delayed_project_payloads())
|
||||||
|
payloads.extend(self._over_budget_project_payloads())
|
||||||
|
payloads.extend(self._fund_risk_payloads())
|
||||||
|
payloads.extend(self._supplier_risk_payloads())
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
def _overdue_task_payloads(self) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(WorkTask).where(
|
||||||
|
WorkTask.due_date.is_not(None),
|
||||||
|
WorkTask.due_date < date.today(),
|
||||||
|
WorkTask.status.notin_(DONE_STATUSES),
|
||||||
|
)
|
||||||
|
payloads = []
|
||||||
|
for task in self.db.execute(stmt).scalars():
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"code": f"RISK-TASK-OVERDUE-{task.id}",
|
||||||
|
"title": f"任务逾期:{task.title}",
|
||||||
|
"risk_type": "overdue_task",
|
||||||
|
"risk_level": "medium",
|
||||||
|
"status": "open",
|
||||||
|
"source_domain": "tasks",
|
||||||
|
"source_record_id": str(task.id),
|
||||||
|
"project_code": task.project_code,
|
||||||
|
"owner": task.owner,
|
||||||
|
"due_date": task.due_date,
|
||||||
|
"detected_at": datetime.utcnow(),
|
||||||
|
"description": "任务已超过截止日期且未完成。",
|
||||||
|
"mitigation": "请负责人更新进度、明确阻塞项并给出新的完成时间。",
|
||||||
|
"evidence": serialize_model(task),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
def _delayed_project_payloads(self) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(Project).where(
|
||||||
|
Project.due_date.is_not(None),
|
||||||
|
Project.due_date < date.today(),
|
||||||
|
Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭", "closed"]),
|
||||||
|
)
|
||||||
|
payloads = []
|
||||||
|
for project in self.db.execute(stmt).scalars():
|
||||||
|
level = "high" if project.progress_percent < 80 else "medium"
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"code": f"RISK-PROJECT-DELAY-{project.id}",
|
||||||
|
"title": f"项目延期:{project.name}",
|
||||||
|
"risk_type": "delayed_project",
|
||||||
|
"risk_level": level,
|
||||||
|
"status": "open",
|
||||||
|
"source_domain": "projects",
|
||||||
|
"source_record_id": str(project.id),
|
||||||
|
"project_code": project.code,
|
||||||
|
"owner": project.owner,
|
||||||
|
"due_date": project.due_date,
|
||||||
|
"detected_at": datetime.utcnow(),
|
||||||
|
"description": "项目已超过计划截止日期且未进入完成状态。",
|
||||||
|
"mitigation": "请项目负责人提交延期原因、资源需求和纠偏计划。",
|
||||||
|
"evidence": serialize_model(project),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
def _over_budget_project_payloads(self) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(Project).where(
|
||||||
|
Project.budget_amount > 0,
|
||||||
|
Project.actual_amount > Project.budget_amount,
|
||||||
|
)
|
||||||
|
payloads = []
|
||||||
|
for project in self.db.execute(stmt).scalars():
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"code": f"RISK-PROJECT-BUDGET-{project.id}",
|
||||||
|
"title": f"项目超预算:{project.name}",
|
||||||
|
"risk_type": "over_budget_project",
|
||||||
|
"risk_level": "high",
|
||||||
|
"status": "open",
|
||||||
|
"source_domain": "projects",
|
||||||
|
"source_record_id": str(project.id),
|
||||||
|
"project_code": project.code,
|
||||||
|
"owner": project.owner,
|
||||||
|
"due_date": project.due_date,
|
||||||
|
"detected_at": datetime.utcnow(),
|
||||||
|
"description": "项目实际成本已超过预算。",
|
||||||
|
"mitigation": "请复核预算科目、冻结非必要采购并补充审批依据。",
|
||||||
|
"evidence": serialize_model(project),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
def _fund_risk_payloads(self) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
|
||||||
|
payloads = []
|
||||||
|
for account in self.db.execute(stmt).scalars():
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"code": f"RISK-FUND-{account.id}",
|
||||||
|
"title": f"资金低于安全线:{account.name}",
|
||||||
|
"risk_type": "fund_safety_line",
|
||||||
|
"risk_level": "high",
|
||||||
|
"status": "open",
|
||||||
|
"source_domain": "fund-accounts",
|
||||||
|
"source_record_id": str(account.id),
|
||||||
|
"owner": None,
|
||||||
|
"detected_at": datetime.utcnow(),
|
||||||
|
"description": "账户当前余额低于设置的安全线。",
|
||||||
|
"mitigation": "请财务确认收付款计划,并优先处理关键项目资金安排。",
|
||||||
|
"evidence": serialize_model(account),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
def _supplier_risk_payloads(self) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(Supplier).where(
|
||||||
|
(Supplier.blacklist_status != "normal")
|
||||||
|
| Supplier.risk_level.in_(["medium", "high"])
|
||||||
|
)
|
||||||
|
payloads = []
|
||||||
|
for supplier in self.db.execute(stmt).scalars():
|
||||||
|
level = "high" if supplier.blacklist_status != "normal" else supplier.risk_level
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"code": f"RISK-SUPPLIER-{supplier.id}",
|
||||||
|
"title": f"供应商风险:{supplier.name}",
|
||||||
|
"risk_type": "supplier_risk",
|
||||||
|
"risk_level": level,
|
||||||
|
"status": "open",
|
||||||
|
"source_domain": "suppliers",
|
||||||
|
"source_record_id": str(supplier.id),
|
||||||
|
"owner": supplier.contact,
|
||||||
|
"detected_at": datetime.utcnow(),
|
||||||
|
"description": "供应商风险等级或黑名单状态需要关注。",
|
||||||
|
"mitigation": "请采购负责人复核供应商准入、履约和替代方案。",
|
||||||
|
"evidence": serialize_model(supplier),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payloads
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ from app.core.database import Base, engine
|
|||||||
from app.modules.approvals.models import ApprovalRequest
|
from app.modules.approvals.models import ApprovalRequest
|
||||||
from app.modules.audit.models import AuditLog
|
from app.modules.audit.models import AuditLog
|
||||||
from app.modules.business.models import (
|
from app.modules.business.models import (
|
||||||
|
AttendanceRecord,
|
||||||
Expense,
|
Expense,
|
||||||
FundAccount,
|
FundAccount,
|
||||||
|
LegacySyncRun,
|
||||||
PerformanceMetric,
|
PerformanceMetric,
|
||||||
Policy,
|
Policy,
|
||||||
Procurement,
|
Procurement,
|
||||||
Project,
|
Project,
|
||||||
|
RiskEvent,
|
||||||
Standard,
|
Standard,
|
||||||
Supplier,
|
Supplier,
|
||||||
|
WorkReport,
|
||||||
WorkTask,
|
WorkTask,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +29,10 @@ _MODELS = [
|
|||||||
Standard,
|
Standard,
|
||||||
PerformanceMetric,
|
PerformanceMetric,
|
||||||
Supplier,
|
Supplier,
|
||||||
|
AttendanceRecord,
|
||||||
|
WorkReport,
|
||||||
|
RiskEvent,
|
||||||
|
LegacySyncRun,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -4,37 +4,13 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: mysql+pymysql://company_ai:company_ai_password@mysql:3306/company_ai?charset=utf8mb4
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
ports:
|
ports:
|
||||||
- "8010:8010"
|
- "8010:8010"
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql
|
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
mysql:
|
|
||||||
image: mysql:8.4
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: password
|
|
||||||
MYSQL_DATABASE: company_ai
|
|
||||||
MYSQL_USER: company_ai
|
|
||||||
MYSQL_PASSWORD: company_ai_password
|
|
||||||
ports:
|
|
||||||
- "3307:3306"
|
|
||||||
volumes:
|
|
||||||
- mysql_data:/var/lib/mysql
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
|
|
||||||
adminer:
|
|
||||||
image: adminer:4
|
|
||||||
ports:
|
|
||||||
- "8088:8080"
|
|
||||||
depends_on:
|
|
||||||
- mysql
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mysql_data:
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ dependencies:
|
|||||||
- uvicorn[standard]==0.34.0
|
- uvicorn[standard]==0.34.0
|
||||||
- sqlalchemy==2.0.36
|
- sqlalchemy==2.0.36
|
||||||
- pymysql==1.1.1
|
- pymysql==1.1.1
|
||||||
|
- psycopg[binary]==3.2.3
|
||||||
- pydantic-settings==2.7.1
|
- pydantic-settings==2.7.1
|
||||||
- python-dotenv==1.0.1
|
- python-dotenv==1.0.1
|
||||||
- httpx==0.28.1
|
- httpx==0.28.1
|
||||||
|
|||||||
72
tests/test_ai_adapters.py
Normal file
72
tests/test_ai_adapters.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.modules.ai_agent import adapters
|
||||||
|
|
||||||
|
|
||||||
|
class DummyResponse:
|
||||||
|
def __init__(self, data: dict[str, Any], status_code: int = 200):
|
||||||
|
self._data = data
|
||||||
|
self.status_code = status_code
|
||||||
|
self.text = str(data)
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
|
class DummyClient:
|
||||||
|
calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def __init__(self, timeout: int):
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def __enter__(self) -> "DummyClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
json: dict[str, Any],
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> DummyResponse:
|
||||||
|
self.calls.append({"url": url, "json": json, "headers": headers})
|
||||||
|
if url.endswith("/api/v1/agent/ask"):
|
||||||
|
assert json["context"]["hermes_memory"] == "remembered project preference"
|
||||||
|
return DummyResponse({"answer": "openclaw final answer", "tool_calls": 1})
|
||||||
|
mode = json["context"]["mode"]
|
||||||
|
if mode == "memory_recall":
|
||||||
|
return DummyResponse({"answer": "remembered project preference"})
|
||||||
|
if mode == "memory_write":
|
||||||
|
return DummyResponse({"answer": "stored"})
|
||||||
|
raise AssertionError(f"unexpected Hermes mode: {mode}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_openclaw_hermes_adapter_runs_recall_answer_and_remember(monkeypatch) -> None:
|
||||||
|
DummyClient.calls = []
|
||||||
|
monkeypatch.setattr(adapters.httpx, "Client", DummyClient)
|
||||||
|
settings = Settings(
|
||||||
|
model_provider="openclaw_hermes",
|
||||||
|
openclaw_base_url="http://openclaw.local",
|
||||||
|
openclaw_api_key="openclaw-key",
|
||||||
|
hermes_base_url="http://hermes.local",
|
||||||
|
hermes_api_key="hermes-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = adapters.OpenClawHermesAdapter(settings).ask(
|
||||||
|
"summarize project risk",
|
||||||
|
{"project_code": "P-001"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["answer"] == "openclaw final answer"
|
||||||
|
assert result["raw"]["pipeline"] == "hermes_recall -> openclaw_answer -> hermes_remember"
|
||||||
|
assert [call["url"] for call in DummyClient.calls] == [
|
||||||
|
"http://hermes.local/api/v1/ask",
|
||||||
|
"http://openclaw.local/api/v1/agent/ask",
|
||||||
|
"http://hermes.local/api/v1/ask",
|
||||||
|
]
|
||||||
|
assert DummyClient.calls[0]["headers"]["Authorization"] == "Bearer hermes-key"
|
||||||
|
assert DummyClient.calls[1]["headers"]["Authorization"] == "Bearer openclaw-key"
|
||||||
|
assert DummyClient.calls[1]["json"]["context"]["agent_pipeline"] == "openclaw_hermes"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||||
@@ -154,6 +155,71 @@ def test_approval_gate_for_high_risk_update() -> None:
|
|||||||
assert update_response.json()["data"]["current_balance"] == 100.0
|
assert update_response.json()["data"]["current_balance"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_ledgers_reports_and_risk_events() -> None:
|
||||||
|
domains_response = client.get("/api/v1/business/domains", headers=headers)
|
||||||
|
assert domains_response.status_code == 200
|
||||||
|
domains = domains_response.json()["domains"]
|
||||||
|
assert "attendance-records" in domains
|
||||||
|
assert "work-reports" in domains
|
||||||
|
assert "risk-events" in domains
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
attendance_response = client.post(
|
||||||
|
"/api/v1/business/attendance-records",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"actor": "pytest",
|
||||||
|
"data": {
|
||||||
|
"code": "ATT-SMOKE-001",
|
||||||
|
"employee_name": "Tester",
|
||||||
|
"department": "QA",
|
||||||
|
"work_date": today.isoformat(),
|
||||||
|
"status": "正常",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert attendance_response.status_code == 200
|
||||||
|
|
||||||
|
task_response = client.post(
|
||||||
|
"/api/v1/business/tasks",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"actor": "pytest",
|
||||||
|
"data": {
|
||||||
|
"code": "TASK-RISK-001",
|
||||||
|
"title": "Overdue smoke task",
|
||||||
|
"owner": "tester",
|
||||||
|
"status": "待办",
|
||||||
|
"due_date": (today - timedelta(days=1)).isoformat(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert task_response.status_code == 200
|
||||||
|
|
||||||
|
attendance_summary = client.get("/api/v1/reports/attendance-summary", headers=headers)
|
||||||
|
assert attendance_summary.status_code == 200
|
||||||
|
assert attendance_summary.json()["total"] >= 1
|
||||||
|
|
||||||
|
report_response = client.post(
|
||||||
|
"/api/v1/reports/work-reports/generate",
|
||||||
|
headers=headers,
|
||||||
|
json={"report_type": "daily", "reporter": "pytest", "actor": "pytest"},
|
||||||
|
)
|
||||||
|
assert report_response.status_code == 200
|
||||||
|
assert report_response.json()["data"]["report_type"] == "daily"
|
||||||
|
|
||||||
|
risk_response = client.post(
|
||||||
|
"/api/v1/risks/events/generate?actor=pytest",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert risk_response.status_code == 200
|
||||||
|
assert risk_response.json()["created"] >= 1
|
||||||
|
|
||||||
|
events_response = client.get("/api/v1/risks/events?status=open", headers=headers)
|
||||||
|
assert events_response.status_code == 200
|
||||||
|
assert any(item["risk_type"] == "overdue_task" for item in events_response.json()["items"])
|
||||||
|
|
||||||
|
|
||||||
def test_ai_noop_provider() -> None:
|
def test_ai_noop_provider() -> None:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/ai/ask",
|
"/api/v1/ai/ask",
|
||||||
|
|||||||
Reference in New Issue
Block a user