764 lines
33 KiB
Python
764 lines
33 KiB
Python
"""产品推介材料业务编排服务。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import mimetypes
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.schemas.promotion_material import (
|
||
PromotionDeliveryRequest,
|
||
PromotionGenerationRequest,
|
||
PromotionInputsUpdate,
|
||
PromotionReviewRequest,
|
||
PromotionTaskCreate,
|
||
)
|
||
from app.core.config import get_settings
|
||
from app.core.contracts import RequestContext
|
||
from app.core.errors import GenericResourceNotFoundError, ValidationAgentError
|
||
from app.core.promotion_material_contracts import MANDATORY_RISK_DISCLOSURE
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.promotion_material import (
|
||
PromotionAttachment,
|
||
PromotionComplianceCheck,
|
||
PromotionDeliveryRecord,
|
||
PromotionInputSnapshot,
|
||
PromotionMaterialTask,
|
||
PromotionMaterialVersion,
|
||
PromotionReviewRecord,
|
||
)
|
||
from app.service.api_transaction_service import ApiTransactionService
|
||
from app.service.authorization_service import AuthorizationService
|
||
from app.service.promotion_compliance import (
|
||
ComplianceFinding,
|
||
PromotionComplianceChecker,
|
||
)
|
||
from app.service.promotion_pdf_adapter import PromotionPdfAdapter
|
||
from app.service.promotion_performance import (
|
||
create_performance_chart,
|
||
file_hash,
|
||
parse_performance_file,
|
||
)
|
||
from app.service.promotion_poster_renderer import PromotionPosterRenderer
|
||
from app.service.promotion_renderer import PromotionPptxRenderer
|
||
|
||
|
||
class PromotionMaterialService:
|
||
def __init__(self, session: AsyncSession | None = None) -> None:
|
||
self.session = session
|
||
settings = get_settings()
|
||
self.storage_root = Path(settings.promotion_material_storage_dir).resolve()
|
||
self.max_photo_size = settings.promotion_max_photo_size_bytes
|
||
self.max_performance_size = settings.promotion_max_performance_file_size_bytes
|
||
self.compliance = PromotionComplianceChecker()
|
||
self.renderer = PromotionPptxRenderer()
|
||
self.poster_renderer = PromotionPosterRenderer()
|
||
self.pdf_adapter = PromotionPdfAdapter(
|
||
enabled=settings.promotion_pdf_enabled,
|
||
converter_path=settings.promotion_pdf_converter_path,
|
||
)
|
||
|
||
async def create_task(
|
||
self, payload: PromotionTaskCreate, context: RequestContext, key: str | None
|
||
) -> dict[str, Any]:
|
||
await self._require(context, "promotion:write")
|
||
if key is None:
|
||
raise ValidationAgentError("必须提供 Idempotency-Key")
|
||
body = payload.model_dump(mode="json")
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
"promotion-material:create-task",
|
||
key,
|
||
body,
|
||
lambda session: self._create_task_tx(session, payload, context),
|
||
)
|
||
|
||
async def update_inputs(
|
||
self,
|
||
task_no: str,
|
||
payload: PromotionInputsUpdate,
|
||
context: RequestContext,
|
||
key: str | None,
|
||
) -> dict[str, Any]:
|
||
await self._require(context, "promotion:write")
|
||
if key is None:
|
||
raise ValidationAgentError("必须提供 Idempotency-Key")
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"promotion-material:update-inputs:{task_no}",
|
||
key,
|
||
payload.model_dump(mode="json"),
|
||
lambda session: self._update_inputs_tx(session, task_no, payload, context),
|
||
)
|
||
|
||
async def add_attachment(
|
||
self,
|
||
task_no: str,
|
||
attachment_type: str,
|
||
filename: str,
|
||
media_type: str,
|
||
payload: bytes,
|
||
context: RequestContext,
|
||
key: str | None,
|
||
) -> dict[str, Any]:
|
||
await self._require(context, "promotion:write")
|
||
if key is None:
|
||
raise ValidationAgentError("必须提供 Idempotency-Key")
|
||
if not payload:
|
||
raise ValidationAgentError("上传文件不能为空")
|
||
body = {"attachment_type": attachment_type, "filename": filename,
|
||
"media_type": media_type, "sha256": file_hash(payload)}
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"promotion-material:add-attachment:{task_no}",
|
||
key,
|
||
body,
|
||
lambda session: self._add_attachment_tx(
|
||
session, task_no, attachment_type, filename, media_type, payload, context
|
||
),
|
||
)
|
||
|
||
async def generate(
|
||
self,
|
||
task_no: str,
|
||
payload: PromotionGenerationRequest,
|
||
context: RequestContext,
|
||
key: str | None,
|
||
) -> dict[str, Any]:
|
||
await self._require(context, "promotion:write")
|
||
if key is None:
|
||
raise ValidationAgentError("必须提供 Idempotency-Key")
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"promotion-material:generate:{task_no}",
|
||
key,
|
||
payload.model_dump(mode="json"),
|
||
lambda session: self._generate_tx(session, task_no, payload, context),
|
||
)
|
||
|
||
async def compliance_checks(
|
||
self, task_no: str, context: RequestContext
|
||
) -> dict[str, Any]:
|
||
await self._require(context, "promotion:read")
|
||
session = self._session()
|
||
rows = (await session.execute(select(PromotionComplianceCheck).where(
|
||
PromotionComplianceCheck.task_no == task_no
|
||
).order_by(PromotionComplianceCheck.id))).scalars().all()
|
||
return {"code": 0, "message": "ok", "data": {
|
||
"task_no": task_no,
|
||
"findings": [self._compliance_dict(row) for row in rows],
|
||
}}
|
||
|
||
async def get_task(self, task_no: str, context: RequestContext) -> dict[str, Any]:
|
||
await self._require(context, "promotion:read")
|
||
session = self._session()
|
||
task = await session.scalar(select(PromotionMaterialTask).where(
|
||
PromotionMaterialTask.task_no == task_no
|
||
))
|
||
if task is None:
|
||
raise GenericResourceNotFoundError("推介材料任务不存在")
|
||
is_advisor = "advisor" in context.roles
|
||
if is_advisor:
|
||
delivery = await session.scalar(select(PromotionDeliveryRecord).where(
|
||
PromotionDeliveryRecord.task_no == task_no,
|
||
PromotionDeliveryRecord.advisor_id == int(context.user_id),
|
||
PromotionDeliveryRecord.status == "sent",
|
||
))
|
||
if delivery is None:
|
||
raise GenericResourceNotFoundError("该材料尚未发送给当前投顾")
|
||
version = await session.scalar(select(PromotionMaterialVersion).where(
|
||
PromotionMaterialVersion.task_no == task_no,
|
||
PromotionMaterialVersion.status.in_(("approved", "sent")),
|
||
).order_by(PromotionMaterialVersion.version_no.desc()))
|
||
if version is None:
|
||
return {"code": 0, "message": "ok", "data": {
|
||
"task_no": task_no,
|
||
"product_name": task.product_name,
|
||
"status": task.status,
|
||
"material_version": None,
|
||
}}
|
||
return {"code": 0, "message": "ok", "data": {
|
||
"task_no": task_no,
|
||
"product_name": task.product_name,
|
||
"status": task.status,
|
||
"material_version": {
|
||
"id": version.id,
|
||
"version_no": version.version_no,
|
||
"style_code": version.style_code,
|
||
"status": version.status,
|
||
"pptx_path": version.pptx_path,
|
||
"pdf_path": version.pdf_path,
|
||
"poster_path": version.poster_path,
|
||
"chart_paths": version.chart_paths,
|
||
},
|
||
}}
|
||
|
||
async def review(
|
||
self,
|
||
task_no: str,
|
||
payload: PromotionReviewRequest,
|
||
context: RequestContext,
|
||
key: str | None,
|
||
) -> dict[str, Any]:
|
||
await self._require(context, "promotion:review")
|
||
if key is None:
|
||
raise ValidationAgentError("必须提供 Idempotency-Key")
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"promotion-material:review:{task_no}",
|
||
key,
|
||
payload.model_dump(mode="json"),
|
||
lambda session: self._review_tx(session, task_no, payload, context),
|
||
)
|
||
|
||
async def deliver(
|
||
self,
|
||
task_no: str,
|
||
payload: PromotionDeliveryRequest,
|
||
context: RequestContext,
|
||
key: str | None,
|
||
) -> dict[str, Any]:
|
||
await self._require(context, "promotion:deliver")
|
||
if key is None:
|
||
raise ValidationAgentError("必须提供 Idempotency-Key")
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"promotion-material:deliver:{task_no}",
|
||
key,
|
||
payload.model_dump(mode="json"),
|
||
lambda session: self._deliver_tx(session, task_no, payload, context),
|
||
)
|
||
|
||
async def _create_task_tx(
|
||
self, session: AsyncSession, payload: PromotionTaskCreate, context: RequestContext
|
||
) -> dict[str, Any]:
|
||
task_no = await self._next_task_no(session)
|
||
now = self._now()
|
||
session.add(PromotionMaterialTask(
|
||
task_no=task_no,
|
||
product_code=payload.product_code,
|
||
product_name=payload.product_name,
|
||
material_title=payload.material_title,
|
||
style_code=payload.style_code,
|
||
output_formats=payload.output_formats,
|
||
template_version="promotion-v1",
|
||
status="draft",
|
||
created_by=int(context.user_id),
|
||
created_at=now,
|
||
updated_at=now,
|
||
))
|
||
self._audit(session, context, "promotion.task_created", {"task_no": task_no})
|
||
return {"code": 0, "message": "ok", "data": {"task_no": task_no, "status": "draft"}}
|
||
|
||
async def _update_inputs_tx(
|
||
self, session: AsyncSession, task_no: str, payload: PromotionInputsUpdate,
|
||
context: RequestContext,
|
||
) -> dict[str, Any]:
|
||
task = await self._task(session, task_no)
|
||
latest = await session.scalar(select(func.max(PromotionInputSnapshot.version_no)).where(
|
||
PromotionInputSnapshot.task_no == task_no
|
||
))
|
||
version_no = int(latest or 0) + 1
|
||
now = self._now()
|
||
values = payload.model_dump(mode="json")
|
||
session.add(PromotionInputSnapshot(
|
||
task_no=task_no,
|
||
version_no=version_no,
|
||
product_info=values["product_info"],
|
||
manager_info=values["manager_info"],
|
||
team_info=values["team_info"],
|
||
strategy_info=values["strategy_info"],
|
||
fee_structure=values["fee_structure"],
|
||
performance_info=values["performance_info"],
|
||
risk_disclosure=values["risk_disclosure"],
|
||
source_notes=values["source_notes"],
|
||
created_by=int(context.user_id),
|
||
created_at=now,
|
||
))
|
||
task.status = "input_ready"
|
||
task.updated_at = now
|
||
self._audit(session, context, "promotion.inputs_updated", {
|
||
"task_no": task_no, "input_version": version_no,
|
||
})
|
||
return {"code": 0, "message": "ok", "data": {
|
||
"task_no": task_no, "input_version": version_no, "status": task.status,
|
||
}}
|
||
|
||
async def _add_attachment_tx(
|
||
self,
|
||
session: AsyncSession,
|
||
task_no: str,
|
||
attachment_type: str,
|
||
filename: str,
|
||
media_type: str,
|
||
payload: bytes,
|
||
context: RequestContext,
|
||
) -> dict[str, Any]:
|
||
task = await self._task(session, task_no)
|
||
self._validate_attachment(
|
||
attachment_type,
|
||
filename,
|
||
media_type,
|
||
len(payload),
|
||
max_photo_size=self.max_photo_size,
|
||
max_performance_size=self.max_performance_size,
|
||
)
|
||
digest = file_hash(payload)
|
||
duplicate = await session.scalar(select(PromotionAttachment).where(
|
||
PromotionAttachment.task_no == task_no,
|
||
PromotionAttachment.file_hash == digest,
|
||
))
|
||
if duplicate is not None:
|
||
return {"code": 0, "message": "ok", "data": {
|
||
"attachment_id": duplicate.id, "duplicate": True,
|
||
}}
|
||
extension = Path(filename).suffix.lower()
|
||
destination = self.storage_root / task_no / attachment_type / f"{digest}{extension}"
|
||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||
destination.write_bytes(payload)
|
||
parsed_metadata: dict[str, Any] = {}
|
||
try:
|
||
if attachment_type == "manager_photo":
|
||
from PIL import Image
|
||
|
||
with Image.open(destination) as image:
|
||
parsed_metadata = {"width": image.width, "height": image.height,
|
||
"format": image.format}
|
||
elif attachment_type == "performance_data":
|
||
parsed_metadata = parse_performance_file(destination)
|
||
except Exception as exc:
|
||
destination.unlink(missing_ok=True)
|
||
raise ValidationAgentError("附件内容无法解析或校验失败") from exc
|
||
now = self._now()
|
||
attachment = PromotionAttachment(
|
||
task_no=task_no,
|
||
attachment_type=attachment_type,
|
||
filename=Path(filename).name,
|
||
media_type=(
|
||
media_type
|
||
or mimetypes.guess_type(filename)[0]
|
||
or "application/octet-stream"
|
||
),
|
||
file_hash=digest,
|
||
size_bytes=len(payload),
|
||
file_path=str(destination),
|
||
parsed_metadata=parsed_metadata,
|
||
uploaded_by=int(context.user_id),
|
||
created_at=now,
|
||
)
|
||
session.add(attachment)
|
||
task.updated_at = now
|
||
await session.flush()
|
||
self._audit(session, context, "promotion.attachment_uploaded", {
|
||
"task_no": task_no, "attachment_id": attachment.id,
|
||
"attachment_type": attachment_type, "sha256": digest,
|
||
})
|
||
return {"code": 0, "message": "ok", "data": {
|
||
"attachment_id": attachment.id, "attachment_type": attachment_type,
|
||
"sha256": digest, "size_bytes": len(payload),
|
||
}}
|
||
|
||
async def _generate_tx(
|
||
self,
|
||
session: AsyncSession,
|
||
task_no: str,
|
||
payload: PromotionGenerationRequest,
|
||
context: RequestContext,
|
||
) -> dict[str, Any]:
|
||
task = await self._task(session, task_no)
|
||
snapshot = await session.scalar(select(PromotionInputSnapshot).where(
|
||
PromotionInputSnapshot.task_no == task_no
|
||
).order_by(PromotionInputSnapshot.version_no.desc()))
|
||
if snapshot is None:
|
||
return {"code": 422, "message": "请先补充结构化资料", "data": {}}
|
||
inputs = self._snapshot_dict(snapshot)
|
||
attachments = (await session.execute(select(PromotionAttachment).where(
|
||
PromotionAttachment.task_no == task_no
|
||
).order_by(PromotionAttachment.id))).scalars().all()
|
||
performance_attachment = next(
|
||
(item for item in attachments if item.attachment_type == "performance_data"),
|
||
None,
|
||
)
|
||
inputs["performance_info"] = {
|
||
**inputs["performance_info"],
|
||
"performance_attachment_id": performance_attachment.id
|
||
if performance_attachment is not None
|
||
else None,
|
||
}
|
||
input_findings = self.compliance.check_inputs(inputs)
|
||
if input_findings:
|
||
self._save_findings(session, task_no, None, input_findings)
|
||
task.status = "compliance_failed"
|
||
task.updated_at = self._now()
|
||
return {"code": 422, "message": "输入资料未通过合规校验", "data": {
|
||
"task_no": task_no,
|
||
"findings": [self._finding_dict(item) for item in input_findings],
|
||
}}
|
||
task.status = "generating"
|
||
task.updated_at = self._now()
|
||
version_no = int(await session.scalar(select(func.max(
|
||
PromotionMaterialVersion.version_no
|
||
)).where(PromotionMaterialVersion.task_no == task_no)) or 0) + 1
|
||
draft = self._build_draft(task, inputs)
|
||
pptx_path = self.storage_root / task_no / "materials" / f"v{version_no}.pptx"
|
||
poster_path = self.storage_root / task_no / "materials" / f"v{version_no}-poster.png"
|
||
photo_path = next(
|
||
(
|
||
item.file_path
|
||
for item in attachments
|
||
if item.attachment_type == "manager_photo"
|
||
),
|
||
None,
|
||
)
|
||
try:
|
||
chart_paths = self._build_charts(task_no, version_no, list(attachments), inputs)
|
||
self.renderer.render(draft, pptx_path, photo_path=photo_path,
|
||
chart_paths=chart_paths)
|
||
requested_formats = payload.output_formats or task.output_formats
|
||
if "poster" in requested_formats:
|
||
self.poster_renderer.render(
|
||
draft,
|
||
poster_path,
|
||
photo_path=photo_path,
|
||
chart_paths=chart_paths,
|
||
)
|
||
pdf_path: str | None = None
|
||
if "pdf" in requested_formats:
|
||
target_pdf = self.storage_root / task_no / "materials" / f"v{version_no}.pdf"
|
||
pdf_path = self.pdf_adapter.convert(pptx_path, target_pdf)
|
||
if pdf_path is None:
|
||
raise RuntimeError("PDF 转换未启用或转换器路径未配置")
|
||
except (RuntimeError, OSError, ValueError) as exc:
|
||
task.status = "failed"
|
||
task.updated_at = self._now()
|
||
return {"code": 503, "message": str(exc), "data": {"task_no": task_no}}
|
||
version = PromotionMaterialVersion(
|
||
task_no=task_no,
|
||
input_snapshot_id=snapshot.id,
|
||
version_no=version_no,
|
||
style_code=task.style_code,
|
||
template_version=task.template_version,
|
||
prompt_version="deterministic-v1",
|
||
draft_json=draft,
|
||
pptx_path=str(pptx_path),
|
||
pdf_path=pdf_path,
|
||
poster_path=str(poster_path) if "poster" in requested_formats else None,
|
||
chart_paths=chart_paths,
|
||
status="generated",
|
||
generated_by=int(context.user_id),
|
||
created_at=self._now(),
|
||
)
|
||
session.add(version)
|
||
await session.flush()
|
||
findings = self.compliance.check_draft(draft, inputs)
|
||
if not findings:
|
||
findings = [ComplianceFinding(
|
||
"overall.pass", "确定性合规校验通过", "generated_text", "pass",
|
||
None, "可提交人工审核",
|
||
)]
|
||
self._save_findings(session, task_no, version.id, findings)
|
||
blocking = any(item.severity == "block" for item in findings)
|
||
version.status = "compliance_failed" if blocking else "pending_review"
|
||
task.status = version.status
|
||
task.updated_at = self._now()
|
||
self._audit(session, context, "promotion.material_generated", {
|
||
"task_no": task_no, "material_version_id": version.id,
|
||
"status": task.status,
|
||
})
|
||
return {"code": 0 if not blocking else 422,
|
||
"message": "ok" if not blocking else "生成内容未通过合规校验",
|
||
"data": {
|
||
"task_no": task_no, "material_version_id": version.id,
|
||
"status": task.status, "pptx_path": str(pptx_path),
|
||
"pdf_path": pdf_path,
|
||
"poster_path": (
|
||
str(poster_path) if "poster" in requested_formats else None
|
||
),
|
||
"chart_paths": chart_paths,
|
||
"findings": [self._finding_dict(item) for item in findings],
|
||
}}
|
||
|
||
async def _review_tx(
|
||
self, session: AsyncSession, task_no: str, payload: PromotionReviewRequest,
|
||
context: RequestContext,
|
||
) -> dict[str, Any]:
|
||
task = await self._task(session, task_no)
|
||
version = await session.scalar(select(PromotionMaterialVersion).where(
|
||
PromotionMaterialVersion.id == payload.material_version_id,
|
||
PromotionMaterialVersion.task_no == task_no,
|
||
).with_for_update())
|
||
if version is None:
|
||
raise GenericResourceNotFoundError("材料版本不存在")
|
||
if version.status != "pending_review":
|
||
return {"code": 422, "message": "当前材料版本不在待审核状态", "data": {}}
|
||
checks = (await session.execute(select(PromotionComplianceCheck).where(
|
||
PromotionComplianceCheck.material_version_id == version.id,
|
||
PromotionComplianceCheck.severity == "block",
|
||
))).scalars().all()
|
||
if checks:
|
||
return {"code": 422, "message": "存在阻断级合规问题", "data": {}}
|
||
now = self._now()
|
||
session.add(PromotionReviewRecord(
|
||
task_no=task_no, material_version_id=version.id, reviewer_id=int(context.user_id),
|
||
decision=payload.decision, comment=payload.comment, created_at=now,
|
||
))
|
||
version.status = "approved" if payload.decision == "approved" else "rejected"
|
||
task.status = (
|
||
"approved"
|
||
if payload.decision == "approved"
|
||
else "draft" if payload.decision == "revision_requested" else "rejected"
|
||
)
|
||
task.updated_at = now
|
||
self._audit(session, context, "promotion.material_reviewed", {
|
||
"task_no": task_no, "material_version_id": version.id,
|
||
"decision": payload.decision,
|
||
})
|
||
return {"code": 0, "message": "ok", "data": {
|
||
"task_no": task_no, "material_version_id": version.id, "status": task.status,
|
||
}}
|
||
|
||
async def _deliver_tx(
|
||
self, session: AsyncSession, task_no: str, payload: PromotionDeliveryRequest,
|
||
context: RequestContext,
|
||
) -> dict[str, Any]:
|
||
task = await self._task(session, task_no)
|
||
version = await session.scalar(select(PromotionMaterialVersion).where(
|
||
PromotionMaterialVersion.id == payload.material_version_id,
|
||
PromotionMaterialVersion.task_no == task_no,
|
||
).with_for_update())
|
||
if version is None:
|
||
raise GenericResourceNotFoundError("材料版本不存在")
|
||
if version.status != "approved":
|
||
return {"code": 422, "message": "只有审核通过的材料可以发送", "data": {}}
|
||
if payload.delivery_channel != "internal_record":
|
||
return {"code": 422, "message": "首版只支持内部发送记录,未接入外部投顾端", "data": {}}
|
||
now = self._now()
|
||
for advisor_id in payload.advisor_ids:
|
||
existing = await session.scalar(select(PromotionDeliveryRecord).where(
|
||
PromotionDeliveryRecord.material_version_id == version.id,
|
||
PromotionDeliveryRecord.advisor_id == advisor_id,
|
||
))
|
||
if existing is None:
|
||
session.add(PromotionDeliveryRecord(
|
||
task_no=task_no, material_version_id=version.id, advisor_id=advisor_id,
|
||
delivery_channel=payload.delivery_channel, status="sent",
|
||
created_by=int(context.user_id), created_at=now, sent_at=now,
|
||
))
|
||
else:
|
||
existing.status = "sent"
|
||
existing.sent_at = now
|
||
version.status = "sent"
|
||
task.status = "sent"
|
||
task.updated_at = now
|
||
self._audit(session, context, "promotion.material_delivered", {
|
||
"task_no": task_no, "material_version_id": version.id,
|
||
"advisor_ids": payload.advisor_ids,
|
||
})
|
||
return {"code": 0, "message": "ok", "data": {
|
||
"task_no": task_no, "material_version_id": version.id,
|
||
"advisor_ids": payload.advisor_ids, "status": "sent",
|
||
}}
|
||
|
||
@staticmethod
|
||
def _build_draft(task: PromotionMaterialTask, inputs: dict[str, Any]) -> dict[str, Any]:
|
||
product = inputs["product_info"]
|
||
manager = inputs["manager_info"]
|
||
team = inputs["team_info"]
|
||
strategy = inputs["strategy_info"]
|
||
fees = inputs["fee_structure"]
|
||
performance = inputs["performance_info"]
|
||
risk = inputs["risk_disclosure"]
|
||
fee_text = ";".join(f"{key}: {value}" for key, value in fees.items() if value)
|
||
performance_text = (
|
||
f"数据截至 {performance.get('as_of_date') or '未填写'}。"
|
||
f"区间 {performance.get('history_months') or '未填写'} 个月。"
|
||
f"产品收益 {performance.get('product_return') or '未提供'}。"
|
||
f"最大回撤 {performance.get('max_drawdown') or '未提供'},"
|
||
f"波动率 {performance.get('volatility') or '未提供'},"
|
||
f"夏普比率 {performance.get('sharpe_ratio') or '未提供'}。"
|
||
)
|
||
return {
|
||
"title": task.material_title,
|
||
"subtitle": f"{task.product_name} 产品推介材料",
|
||
"style_code": task.style_code,
|
||
"chapters": [
|
||
{"title": task.material_title, "body": "仅供合规审核和专业投顾使用"},
|
||
{"title": "产品基本信息", "body": (
|
||
f"基金类型:{product['fund_type']}\n运作方式:{product['operation_mode']}\n"
|
||
f"产品状态:{product.get('product_status', '')}\n"
|
||
f"投资目标:{product['investment_objective']}\n"
|
||
f"业绩比较基准:{product.get('benchmark') or '未提供'}"
|
||
)},
|
||
{"title": "管理人及投研团队", "body": (
|
||
f"管理人:{manager['management_company']}\n登记编码:{manager['registration_code']}\n"
|
||
f"基金经理:{manager['manager_name']}\n"
|
||
f"从业年限:{manager.get('employment_years') or '未提供'}\n"
|
||
"实际投资管理经验:"
|
||
f"{manager.get('investment_management_experience') or '未提供'}\n"
|
||
f"团队建设:{team['team_description']}\n"
|
||
f"投研能力:{team.get('research_capability') or '未提供'}"
|
||
)},
|
||
{"title": "投资范围、策略与限制", "body": (
|
||
f"投资范围:{strategy['investment_scope']}\n策略:{strategy['strategy']}\n"
|
||
f"投资限制:{strategy['restrictions']}\n"
|
||
f"工具属性:{strategy.get('index_tool_attribute') or '不适用'}"
|
||
)},
|
||
{"title": "费用结构", "body": fee_text or "未提供费用结构"},
|
||
{"title": "基金经理历史业绩", "body": (
|
||
"以下内容为基金经理代表产品历史业绩,不代表本基金历史业绩。\n"
|
||
+ performance_text
|
||
), "chart_index": 0},
|
||
{"title": "产品、基准与代表产品曲线", "body": (
|
||
"曲线由上传的结构化业绩数据生成,具体口径以来源文件和审核结果为准。"
|
||
), "chart_index": 0},
|
||
{"title": "收益与风险指标", "body": (
|
||
f"{performance_text}\n"
|
||
"最大回撤反映历史区间内的最大跌幅,波动率反映收益变化幅度,"
|
||
"夏普比率用于辅助观察单位风险对应的超额收益。"
|
||
)},
|
||
{"title": "风险揭示", "body": (
|
||
MANDATORY_RISK_DISCLOSURE + "。\n"
|
||
+ "\n".join(f"特别风险:{item}" for item in risk.get("special_risks", []))
|
||
+ (
|
||
f"\n补充说明:{risk['additional_notes']}"
|
||
if risk.get("additional_notes")
|
||
else ""
|
||
)
|
||
)},
|
||
{"title": "资料来源与版本", "body": (
|
||
"本材料基于运营人员提交的结构化资料、附件和固定模板生成。"
|
||
"生成后须经人工审核,未经审核不得对外使用。"
|
||
)},
|
||
],
|
||
}
|
||
|
||
def _build_charts(
|
||
self,
|
||
task_no: str,
|
||
version_no: int,
|
||
attachments: list[PromotionAttachment],
|
||
inputs: dict[str, Any],
|
||
) -> list[str]:
|
||
performance_attachment = next(
|
||
(item for item in attachments if item.attachment_type == "performance_data"), None
|
||
)
|
||
if performance_attachment is None:
|
||
return []
|
||
data = parse_performance_file(performance_attachment.file_path)
|
||
destination = self.storage_root / task_no / "charts" / f"v{version_no}-performance.png"
|
||
return [create_performance_chart(
|
||
data, destination, title=f"{inputs['product_info'].get('fund_type', '')} 业绩曲线"
|
||
)]
|
||
|
||
async def _task(self, session: AsyncSession, task_no: str) -> PromotionMaterialTask:
|
||
task = await session.scalar(select(PromotionMaterialTask).where(
|
||
PromotionMaterialTask.task_no == task_no
|
||
).with_for_update())
|
||
if task is None:
|
||
raise GenericResourceNotFoundError("推介材料任务不存在")
|
||
return task
|
||
|
||
def _session(self) -> AsyncSession:
|
||
if self.session is None:
|
||
raise RuntimeError("查询服务缺少数据库会话")
|
||
return self.session
|
||
|
||
@staticmethod
|
||
async def _require(context: RequestContext, permission: str) -> None:
|
||
await AuthorizationService.require(context, permission)
|
||
|
||
async def _next_task_no(self, session: AsyncSession) -> str:
|
||
today = datetime.now().strftime("%Y%m%d")
|
||
count = await session.scalar(select(func.count()).select_from(PromotionMaterialTask).where(
|
||
PromotionMaterialTask.task_no.like(f"PM-{today}-%")
|
||
))
|
||
return f"PM-{today}-{int(count or 0) + 1:04d}"
|
||
|
||
@staticmethod
|
||
def _snapshot_dict(snapshot: PromotionInputSnapshot) -> dict[str, Any]:
|
||
return {
|
||
"product_info": snapshot.product_info,
|
||
"manager_info": snapshot.manager_info,
|
||
"team_info": snapshot.team_info,
|
||
"strategy_info": snapshot.strategy_info,
|
||
"fee_structure": snapshot.fee_structure,
|
||
"performance_info": snapshot.performance_info,
|
||
"risk_disclosure": snapshot.risk_disclosure,
|
||
"source_notes": snapshot.source_notes,
|
||
}
|
||
|
||
@staticmethod
|
||
def _validate_attachment(
|
||
attachment_type: str,
|
||
filename: str,
|
||
media_type: str,
|
||
size: int,
|
||
*,
|
||
max_photo_size: int,
|
||
max_performance_size: int,
|
||
) -> None:
|
||
limits = {
|
||
"manager_photo": (max_photo_size, {".jpg", ".jpeg", ".png", ".webp"}),
|
||
"performance_data": (max_performance_size, {".csv", ".xlsx", ".xlsm"}),
|
||
"source_evidence": (30 * 1024 * 1024, {".pdf", ".docx", ".xlsx", ".csv"}),
|
||
"template_file": (50 * 1024 * 1024, {".pptx"}),
|
||
}
|
||
if attachment_type not in limits:
|
||
raise ValidationAgentError("不支持的附件类型")
|
||
maximum, extensions = limits[attachment_type]
|
||
if size > maximum:
|
||
raise ValidationAgentError("附件超过大小限制")
|
||
if Path(filename).suffix.lower() not in extensions:
|
||
raise ValidationAgentError("附件扩展名与附件类型不匹配")
|
||
if attachment_type == "manager_photo" and not media_type.startswith("image/"):
|
||
raise ValidationAgentError("基金经理头像必须是图片")
|
||
|
||
@staticmethod
|
||
def _finding_dict(item: ComplianceFinding) -> dict[str, Any]:
|
||
return {
|
||
"rule_code": item.rule_code, "rule_name": item.rule_name, "scope": item.scope,
|
||
"severity": item.severity, "hit_text": item.hit_text, "suggestion": item.suggestion,
|
||
}
|
||
|
||
@staticmethod
|
||
def _compliance_dict(row: PromotionComplianceCheck) -> dict[str, Any]:
|
||
return {
|
||
"id": row.id, "material_version_id": row.material_version_id,
|
||
"rule_code": row.rule_code, "rule_name": row.rule_name, "scope": row.scope,
|
||
"severity": row.severity, "hit_text": row.hit_text, "suggestion": row.suggestion,
|
||
}
|
||
|
||
def _save_findings(
|
||
self,
|
||
session: AsyncSession,
|
||
task_no: str,
|
||
version_id: int | None,
|
||
findings: list[ComplianceFinding],
|
||
) -> None:
|
||
now = self._now()
|
||
for item in findings:
|
||
session.add(PromotionComplianceCheck(
|
||
task_no=task_no, material_version_id=version_id,
|
||
rule_code=item.rule_code, rule_name=item.rule_name, scope=item.scope,
|
||
severity=item.severity, hit_text=item.hit_text, suggestion=item.suggestion,
|
||
created_at=now,
|
||
))
|
||
|
||
@staticmethod
|
||
def _audit(session: AsyncSession, context: RequestContext, action: str,
|
||
detail: dict[str, Any]) -> None:
|
||
session.add(InteractionAudit(
|
||
actor_type="user", actor_id=int(context.user_id), target_customer_id=None,
|
||
session_id=None, portal=context.portal, action_type=action,
|
||
detail={**detail, "trace_id": context.trace_id},
|
||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
))
|
||
|
||
@staticmethod
|
||
def _now() -> datetime:
|
||
return datetime.now(UTC).replace(tzinfo=None)
|