Files
group_fqcd_jr/app/service/offsite_fund_service.py

711 lines
33 KiB
Python

"""场外基金申购赎回业务编排服务。"""
import asyncio
from collections.abc import Sequence
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Literal, cast
from zoneinfo import ZoneInfo
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.offsite_fund_contracts import (
DocumentType,
OffsiteDocumentSummary,
OperationDecision,
ReceiveRecognizedMailRequest,
RecognizedAttachment,
)
from app.model.audit import InteractionAudit
from app.model.offsite_fund import (
OffsiteExecutionPlanTask,
OffsiteFundAttachment,
OffsiteFundDocument,
OffsiteFundMail,
OffsiteNotification,
OffsiteQueryRecord,
OffsiteRuleResult,
)
from app.service.offsite_fund_rules import (
OffsiteFundRuleEngine,
decimal_from,
normalize_amount_yuan,
parse_application_date,
)
from app.service.offsite_nl2sql_adapter import OffsiteNl2SqlAdapter
from app.service.offsite_smtp_adapter import (
OffsiteMailReplyRequest,
OffsiteSmtpSender,
SmtpAttachment,
SmtpSendResult,
)
SHANGHAI = ZoneInfo("Asia/Shanghai")
class OffsiteFundService:
def __init__(
self, session: AsyncSession, smtp_sender: OffsiteSmtpSender | None = None
) -> None:
self.session = session
self.rules = OffsiteFundRuleEngine()
self.nl2sql = OffsiteNl2SqlAdapter()
self.smtp_sender = smtp_sender or OffsiteSmtpSender(get_settings())
async def receive_recognized_mail(
self, payload: ReceiveRecognizedMailRequest, context: RequestContext
) -> dict[str, object]:
denied = self._permission_error(context, ("offsite:write",))
if denied is not None:
return denied
settings = get_settings()
if payload.sender not in settings.offsite_allowed_senders:
return {"code": 403, "message": "发件人不在场外业务白名单", "data": {}}
business = [item for item in payload.attachments if item.document_type != "other"]
if not business:
return {"code": 0, "message": "ok", "data": {"business": False}}
async with self.session.begin():
existing = await self.session.scalar(select(OffsiteFundMail).where(
OffsiteFundMail.imap_uid == payload.imap_uid,
OffsiteFundMail.message_id == payload.message_id,
))
if existing is not None:
return {"code": 0, "message": "ok", "data": {"mail_id": existing.mail_id}}
mail_id = await self._next_mail_id()
now = datetime.now(UTC).replace(tzinfo=None)
mail = OffsiteFundMail(
mail_id=mail_id, imap_uid=payload.imap_uid, message_id=payload.message_id,
received_date=datetime.now(SHANGHAI).date(), sender=payload.sender,
return_path=payload.return_path, auth_result=payload.auth_result,
original_eml_path=payload.eml_path, status="recognized",
created_at=now, updated_at=now,
)
self.session.add(mail)
summaries: list[OffsiteDocumentSummary] = []
for index, item in enumerate(payload.attachments, start=1):
attachment_id = f"{mail_id}-A{index:02d}"
self.session.add(OffsiteFundAttachment(
attachment_id=attachment_id, mail_id=mail_id, filename=item.filename,
file_hash=item.file_hash, media_type=item.media_type,
size_bytes=item.size_bytes, document_type=item.document_type,
original_file_path=item.original_file_path, ocr_text=item.ocr_text,
extracted_fields=item.extracted_fields,
field_confidence={
key: str(value) for key, value in item.field_confidence.items()
},
page_evidence=item.page_evidence, status="recognized", created_at=now,
))
if item.document_type == "other":
continue
if item.document_type == "summary":
summaries.append(await self._create_document(
mail_id, f"{attachment_id}-S", attachment_id, item, "subscription", now))
summaries.append(await self._create_document(
mail_id, f"{attachment_id}-R", attachment_id, item, "redemption", now))
else:
summaries.append(await self._create_document(
mail_id, attachment_id, attachment_id, item, item.document_type, now))
self._add_audit(context, "offsite.mail_recognized", {
"mail_id": mail_id,
"imap_uid": payload.imap_uid,
"message_id": payload.message_id,
"business_attachment_count": len(business),
})
return {"code": 0, "message": "ok", "data": {
"mail_id": mail_id,
"documents": [item.model_dump(mode="json") for item in summaries],
}}
async def confirm_document(
self, task_id: str, decision: OperationDecision, operator_id: str,
context: RequestContext,
) -> dict[str, object]:
denied = self._permission_error(context, ("offsite:confirm", "offsite:write"))
if denied is not None:
return denied
async with self.session.begin():
document = await self.session.scalar(select(OffsiteFundDocument).where(
OffsiteFundDocument.task_id == task_id).with_for_update())
if document is None:
return {"code": 404, "message": "单据不存在", "data": {}}
document.operator_decision = decision
document.status = "operator_confirmed"
document.updated_at = datetime.now(UTC).replace(tzinfo=None)
self._add_audit(context, "offsite.document_confirmed", {
"task_id": task_id,
"operator_id": operator_id,
"decision": decision,
})
return {"code": 0, "message": "ok", "data": {"task_id": task_id, "decision": decision}}
async def recalculate_statistics(
self, fund_code: str, application_date: str, context: RequestContext
) -> dict[str, object]:
denied = self._permission_error(context, ("offsite:read", "offsite:write"))
if denied is not None:
return denied
target_date = parse_application_date(application_date)
if target_date is None:
return {"code": 422, "message": "申请日期格式不正确", "data": {}}
rows = (await self.session.execute(select(OffsiteFundDocument).where(
OffsiteFundDocument.fund_code == fund_code,
OffsiteFundDocument.application_date == target_date,
OffsiteFundDocument.operator_decision == "确认正常",
))).scalars().all()
subscription_total = sum(
((row.subscription_amount_yuan or Decimal("0")) for row in rows),
Decimal("0"),
)
redemption_total = sum(
((row.redemption_shares or Decimal("0")) for row in rows),
Decimal("0"),
)
agency_breakdown = self._agency_breakdown(rows)
redemption_amount_yuan: Decimal | None = None
net_flow_amount_yuan: Decimal | None = subscription_total if redemption_total == 0 else None
return {"code": 0, "message": "ok", "data": {
"fund_code": fund_code, "application_date": target_date.isoformat(),
"fund_name": next((row.fund_name for row in rows if row.fund_name), None),
"subscription_amount_yuan": str(subscription_total),
"subscription_count": sum(1 for row in rows if row.document_type == "subscription"),
"redemption_shares": str(redemption_total),
"redemption_count": sum(1 for row in rows if row.document_type == "redemption"),
"latest_nav": None,
"redemption_amount_yuan": (
str(redemption_amount_yuan) if redemption_amount_yuan is not None else None
),
"net_flow_amount_yuan": (
str(net_flow_amount_yuan) if net_flow_amount_yuan is not None else None
),
"agency_breakdown": agency_breakdown,
}}
async def trigger_agent_nl2sql(
self, task_id: str, operator_id: str, manual_confirmed: bool,
context: RequestContext,
) -> dict[str, object]:
denied = self._permission_error(
context, ("offsite:nl2sql", "offsite:write", "financial:nl2sql:read")
)
if denied is not None:
return denied
if not manual_confirmed:
return {"code": 422, "message": "必须传递人工已确认原始文件内容状态", "data": {}}
now = datetime.now(UTC).replace(tzinfo=None)
records: list[dict[str, object]] = []
async with self.session.begin():
document = await self.session.scalar(select(OffsiteFundDocument).where(
OffsiteFundDocument.task_id == task_id))
if document is None:
return {"code": 404, "message": "单据不存在", "data": {}}
questions = self._nl2sql_questions(document)
if not questions:
return {"code": 422, "message": "查询条件不足", "data": {}}
for rule_code, question in questions:
result = self.nl2sql.query(question, context)
self.session.add(OffsiteQueryRecord(
task_id=task_id, rule_code=rule_code,
natural_language_request=question, script_path=self.nl2sql.script_path,
result_summary=result, status=str(result.get("status", "error")),
error_message=(
result.get("message") if isinstance(result.get("message"), str) else None
),
created_at=now,
))
await self._update_query_plan_status(task_id, rule_code, result, now)
records.append({"rule_code": rule_code, "status": result.get("status")})
self._add_audit(context, "offsite.nl2sql_triggered", {
"task_id": task_id,
"operator_id": operator_id,
"query_count": len(records),
})
return {"code": 0, "message": "ok", "data": {"task_id": task_id, "queries": records}}
async def create_notification(
self, task_id: str, notification_type: str, operator_id: str,
context: RequestContext,
) -> dict[str, object]:
denied = self._permission_error(context, ("offsite:notify", "offsite:write"))
if denied is not None:
return denied
settings = get_settings()
receiver = {
"risk": settings.offsite_risk_receiver_id,
"settlement": settings.offsite_settlement_receiver_id,
"mail_return": settings.offsite_mail_return_receiver,
}[notification_type]
draft = f"{task_id} 待发送{notification_type}通知"
now = datetime.now(UTC).replace(tzinfo=None)
async with self.session.begin():
document = await self.session.scalar(select(OffsiteFundDocument).where(
OffsiteFundDocument.task_id == task_id))
if document is None:
return {"code": 404, "message": "单据不存在", "data": {}}
validation = self._validate_notification(document, notification_type)
if validation is not None:
return validation
rule_results = (await self.session.execute(select(OffsiteRuleResult).where(
OffsiteRuleResult.task_id == task_id
))).scalars().all()
payload = self._notification_payload(document, rule_results, operator_id)
notice = OffsiteNotification(
notification_type=notification_type, business_key=task_id,
receiver_id=receiver, operator_id=operator_id, agent_draft=draft,
final_content=draft, payload=payload, status="待发送",
created_at=now, updated_at=now,
)
self.session.add(notice)
await self.session.flush()
notice_id = notice.id
self._add_audit(context, "offsite.notification_created", {
"task_id": task_id,
"notification_type": notification_type,
"operator_id": operator_id,
"receiver_id": receiver,
})
return {"code": 0, "message": "ok", "data": {"notification_id": str(notice_id)}}
async def send_notification(
self,
notification_id: int,
operator_id: str,
operator_confirmed: bool,
final_content: str | None,
context: RequestContext,
) -> dict[str, object]:
denied = self._permission_error(context, ("offsite:notify", "offsite:write"))
if denied is not None:
return denied
if not operator_confirmed:
return {"code": 422, "message": "邮件发送前必须完成运营确认", "data": {}}
prepared = await self._prepare_notification_send(
notification_id, operator_id, final_content, context
)
if isinstance(prepared, dict):
return prepared
notice, mail, attachment, receiver = prepared
try:
request = self._build_mail_reply_request(
notice, mail, attachment, receiver, operator_id, final_content
)
result = await asyncio.to_thread(self.smtp_sender.send_reply, request)
except OSError as exc:
result = SmtpSendResult(
status="发送失败",
dry_run=False,
provider_message_id=None,
failure_reason=type(exc).__name__,
retry_count=notice.retry_count + 1,
request_summary={"notification_id": str(notification_id)},
)
await self._finish_notification(notification_id, result, context)
return {
"code": 0,
"message": "ok",
"data": {
"notification_id": str(notification_id),
"status": result.status,
"dry_run": result.dry_run,
"provider_message_id": result.provider_message_id,
"failure_reason": result.failure_reason,
"retry_count": result.retry_count,
},
}
async def _prepare_notification_send(
self,
notification_id: int,
operator_id: str,
final_content: str | None,
context: RequestContext,
) -> tuple[
OffsiteNotification,
OffsiteFundMail,
OffsiteFundAttachment,
str,
] | dict[str, object]:
settings = get_settings()
async with self.session.begin():
notice = await self.session.scalar(select(OffsiteNotification).where(
OffsiteNotification.id == notification_id
).with_for_update())
if notice is None:
return {"code": 404, "message": "通知不存在", "data": {}}
if notice.status == "发送成功":
return {
"code": 0,
"message": "ok",
"data": {
"notification_id": str(notification_id),
"status": notice.status,
"provider_message_id": notice.provider_message_id,
},
}
if notice.status == "发送中":
return {"code": 409, "message": "通知正在发送中", "data": {}}
if notice.retry_count > 0 and notice.retry_count >= settings.offsite_max_retry_count:
return {"code": 422, "message": "通知已达到最大重试次数", "data": {}}
document = await self.session.scalar(select(OffsiteFundDocument).where(
OffsiteFundDocument.task_id == notice.business_key))
if document is None:
return {"code": 404, "message": "通知关联的单据或邮件不存在", "data": {}}
mail = await self.session.scalar(select(OffsiteFundMail).where(
OffsiteFundMail.mail_id == document.mail_id))
if mail is None:
return {"code": 404, "message": "通知关联的单据或邮件不存在", "data": {}}
if notice.notification_type == "mail_return":
receiver = settings.offsite_mail_return_receiver
else:
receiver = notice.receiver_id if "@" in notice.receiver_id else ""
if not receiver:
return {"code": 422, "message": "通知对象未配置可发送的邮箱地址", "data": {}}
attachment = await self.session.scalar(select(OffsiteFundAttachment).where(
OffsiteFundAttachment.attachment_id == document.attachment_id))
if attachment is None:
return {"code": 404, "message": "通知关联的原始附件不存在", "data": {}}
if final_content is not None and final_content != notice.final_content:
self._add_audit(context, "offsite.notification_content_changed", {
"notification_id": str(notification_id),
"operator_id": operator_id,
"changed": True,
})
notice.final_content = final_content
notice.operator_id = operator_id
notice.status = "发送中"
return notice, mail, attachment, receiver
def _build_mail_reply_request(
self,
notice: OffsiteNotification,
mail: OffsiteFundMail,
attachment: OffsiteFundAttachment,
receiver: str,
operator_id: str,
final_content: str | None,
) -> OffsiteMailReplyRequest:
path = Path(attachment.original_file_path)
payload = path.read_bytes()
return OffsiteMailReplyRequest(
to_address=receiver,
subject="场外基金申购赎回处理结果",
body=final_content if final_content is not None else notice.final_content,
operator_id=operator_id,
operator_confirmed=True,
reply_to_message_id=mail.message_id,
attachments=(
SmtpAttachment(
filename=attachment.filename,
media_type=attachment.media_type,
payload=payload,
),
),
retry_count=notice.retry_count,
)
async def _finish_notification(
self, notification_id: int, result: SmtpSendResult, context: RequestContext
) -> None:
now = datetime.now(UTC).replace(tzinfo=None)
async with self.session.begin():
notice = await self.session.scalar(select(OffsiteNotification).where(
OffsiteNotification.id == notification_id
).with_for_update())
if notice is None:
return
notice.status = result.status
notice.provider_message_id = result.provider_message_id
notice.failure_reason = result.failure_reason
notice.retry_count = result.retry_count
notice.sent_at = now if result.status == "发送成功" else None
notice.updated_at = now
if result.status == "发送成功" and notice.notification_type == "mail_return":
document = await self.session.scalar(select(OffsiteFundDocument).where(
OffsiteFundDocument.task_id == notice.business_key))
if document is not None:
mail = await self.session.scalar(select(OffsiteFundMail).where(
OffsiteFundMail.mail_id == document.mail_id).with_for_update())
if mail is not None:
mail.status = "normal_return_sent"
self._add_audit(context, "offsite.notification_send_finished", {
"notification_id": str(notification_id),
"status": result.status,
"dry_run": result.dry_run,
"retry_count": result.retry_count,
})
async def _next_mail_id(self) -> str:
today = datetime.now(SHANGHAI).date()
prefix = today.strftime("%Y%m%d")
current = await self.session.scalar(select(func.count()).select_from(OffsiteFundMail).where(
OffsiteFundMail.received_date == today))
return f"{prefix}-{int(current or 0) + 1:03d}"
async def _create_document(
self,
mail_id: str,
task_id: str,
attachment_id: str,
item: RecognizedAttachment,
document_type: Literal["subscription", "redemption"],
now: datetime,
) -> OffsiteDocumentSummary:
fields = item.extracted_fields
raw_date = fields.get("申请日期")
amount_yuan = normalize_amount_yuan(fields.get("申购金额"), fields.get("金额单位"))
redemption_shares = decimal_from(fields.get("赎回份额"))
status = self._recognition_status(item, document_type)
document = OffsiteFundDocument(
task_id=task_id, mail_id=mail_id, attachment_id=attachment_id,
document_type=document_type, fund_code=self._text(fields.get("基金代码")),
fund_name=self._text(fields.get("基金名称")),
account_identifier=self._text(fields.get("账户标识")),
investor_name=self._text(fields.get("投资者名称") or fields.get("客户标识")),
application_no=self._text(fields.get("申请编号")),
application_date=parse_application_date(raw_date),
raw_application_date=self._text(raw_date), agency=self._text(fields.get("代销机构")),
subscription_amount_yuan=amount_yuan, redemption_shares=redemption_shares,
status=status, created_at=now, updated_at=now,
)
self.session.add(document)
self._append_plan_tasks(task_id, document_type, fields, now)
decisions = self.rules.check_subscription(
amount_yuan=amount_yuan, nav=decimal_from(fields.get("最新净值")),
total_fund_shares=decimal_from(fields.get("基金最新总份额")),
before_holding_shares=decimal_from(fields.get("申请前持有份额")),
) if document_type == "subscription" else self.rules.check_redemption(
redemption_shares=redemption_shares,
total_fund_shares=decimal_from(fields.get("基金最新总份额")),
available_quantity=decimal_from(fields.get("当前最新可用份额")),
)
results: dict[str, Literal["正常", "异常", "无法判断"]] = {}
for decision in decisions:
results[decision.rule_code] = decision.result
self.session.add(OffsiteRuleResult(
task_id=task_id, rule_code=decision.rule_code, rule_name=decision.rule_name,
result=decision.result, document_value=decision.document_value,
database_value=decision.database_value, calculation=decision.calculation,
created_at=now,
))
return OffsiteDocumentSummary(
task_id=task_id, document_type=cast(DocumentType, document_type), status=status,
rule_results=results)
def _append_plan_tasks(
self, task_id: str, document_type: str, fields: dict[str, object], now: datetime
) -> None:
rules: tuple[tuple[str, str], ...] = (
("subscription_minimum_amount", "申购最低金额"),
("subscription_holding_ratio", "申购后单一投资者持有比例"),
("subscription_single_share_limit", "申购单笔份额上限"),
)
if document_type == "redemption":
rules = (("redemption_large_ratio", "赎回巨额比例"),
("redemption_available_quantity", "账户可用份额"))
for rule_code, title in rules:
for stage in ("查询", "计算", "核对"):
if rule_code == "subscription_minimum_amount" and stage == "查询":
status = "不适用"
else:
status = "已完成" if stage != "查询" else "待执行"
self.session.add(OffsiteExecutionPlanTask(
task_id=task_id, stage=stage, rule_code=rule_code, title=title,
depends_on=[], input_json=fields, output_json=None,
status=status,
error_message=None, created_at=now, updated_at=now,
))
@staticmethod
def _nl2sql_questions(document: OffsiteFundDocument) -> list[tuple[str, str]]:
if not document.fund_code:
return []
base = f"基金代码为{document.fund_code}"
if document.account_identifier:
base += f",账户标识为{document.account_identifier}"
if document.document_type == "subscription":
return [
(
"subscription_holding_ratio",
base + ",查询基金最新总份额、最新净值和申请前持有份额",
),
("subscription_single_share_limit", base + ",查询基金最新总份额和最新净值"),
]
return [
("redemption_large_ratio", base + ",查询产品最新总份额"),
("redemption_available_quantity", base + ",查询账户当前最新可用份额"),
]
@staticmethod
def _text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
async def _update_query_plan_status(
self, task_id: str, rule_code: str, result: dict[str, object], now: datetime
) -> None:
task = await self.session.scalar(select(OffsiteExecutionPlanTask).where(
OffsiteExecutionPlanTask.task_id == task_id,
OffsiteExecutionPlanTask.rule_code == rule_code,
OffsiteExecutionPlanTask.stage == "查询",
).with_for_update())
if task is None:
return
status = str(result.get("status", "error"))
task.status = "已完成" if status in {"ready", "success"} else "查询失败"
if status == "need_confirmation":
task.status = "无法判断"
task.output_json = result
message = result.get("message")
task.error_message = message if isinstance(message, str) else None
task.updated_at = now
@staticmethod
def _permission_error(
context: RequestContext, permissions: tuple[str, ...]
) -> dict[str, object] | None:
roles = {"operator", "risk_operator", "admin", "super_admin"}
if not roles.intersection(context.roles):
return {"code": 403, "message": "当前角色不能操作场外基金流程", "data": {}}
if not set(permissions).intersection(context.permissions):
return {"code": 403, "message": "缺少场外基金操作权限", "data": {}}
return None
def _add_audit(
self, context: RequestContext, action_type: str, detail: dict[str, object]
) -> None:
self.session.add(InteractionAudit(
actor_type="user",
actor_id=int(context.user_id) if context.user_id.isdigit() else None,
target_customer_id=None,
session_id=None,
portal=context.portal,
action_type=action_type,
detail={**detail, "trace_id": context.trace_id},
created_at=datetime.now(UTC).replace(tzinfo=None),
))
@staticmethod
def _recognition_status(
item: RecognizedAttachment, document_type: Literal["subscription", "redemption"]
) -> str:
fields = item.extracted_fields
required = ["基金代码", "基金名称", "账户标识", "申请编号", "申请日期", "代销机构"]
if not (fields.get("投资者名称") or fields.get("客户标识")):
return "recognition_exception"
if document_type == "subscription":
required.extend(["申购金额", "金额单位"])
else:
required.append("赎回份额")
if any(not str(fields.get(name) or "").strip() for name in required):
return "recognition_exception"
if not item.field_confidence:
return "recognition_review"
confidence_values = [
value for value in (decimal_from(value) for value in item.field_confidence.values())
if value is not None
]
if not confidence_values:
return "recognition_review"
min_confidence = min(confidence_values)
if min_confidence < Decimal("0.80"):
return "recognition_exception"
if min_confidence < Decimal("0.95"):
return "recognition_review"
return "planned"
@staticmethod
def _agency_breakdown(rows: Sequence[OffsiteFundDocument]) -> list[dict[str, object]]:
grouped: dict[str, dict[str, object]] = {}
for row in rows:
agency = row.agency or "未识别代销机构"
item = grouped.setdefault(agency, {
"agency": agency,
"subscription_amount_yuan": Decimal("0"),
"subscription_count": 0,
"redemption_shares": Decimal("0"),
"redemption_count": 0,
})
if row.document_type == "subscription":
item["subscription_amount_yuan"] = cast(
Decimal, item["subscription_amount_yuan"]
) + (row.subscription_amount_yuan or Decimal("0"))
subscription_count = item["subscription_count"]
item["subscription_count"] = (
subscription_count + 1 if isinstance(subscription_count, int) else 1
)
elif row.document_type == "redemption":
item["redemption_shares"] = cast(
Decimal, item["redemption_shares"]
) + (row.redemption_shares or Decimal("0"))
redemption_count = item["redemption_count"]
item["redemption_count"] = (
redemption_count + 1 if isinstance(redemption_count, int) else 1
)
return [
{
**item,
"subscription_amount_yuan": str(item["subscription_amount_yuan"]),
"redemption_shares": str(item["redemption_shares"]),
}
for item in grouped.values()
]
@staticmethod
def _validate_notification(
document: OffsiteFundDocument, notification_type: str
) -> dict[str, object] | None:
if notification_type == "risk" and document.operator_decision != "确认异常":
return {"code": 422, "message": "风控通知只允许发送已确认异常单据", "data": {}}
if notification_type == "settlement" and document.operator_decision != "确认正常":
return {"code": 422, "message": "资金清算通知只允许发送已确认正常单据", "data": {}}
if notification_type == "mail_return" and document.operator_decision == "未处理":
return {"code": 422, "message": "邮件返回前必须先完成人工确认", "data": {}}
return None
@staticmethod
def _notification_payload(
document: OffsiteFundDocument,
rule_results: Sequence[OffsiteRuleResult],
operator_id: str,
) -> dict[str, object]:
anomalies = [
{
"rule_code": result.rule_code,
"rule_name": result.rule_name,
"result": result.result,
"document_value": result.document_value,
"database_value": result.database_value,
"calculation": result.calculation,
}
for result in rule_results if result.result == "异常"
]
return {
"task_id": document.task_id,
"mail_id": document.mail_id,
"attachment_id": document.attachment_id,
"document_type": document.document_type,
"fund_code": document.fund_code,
"fund_name": document.fund_name,
"account_identifier": OffsiteFundService._mask_account(document.account_identifier),
"application_no": document.application_no,
"application_date": (
document.application_date.isoformat() if document.application_date else None
),
"agency": document.agency,
"operator_id": operator_id,
"operator_decision": document.operator_decision,
"confirmed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(timespec="seconds"),
"anomalies": anomalies,
}
@staticmethod
def _mask_account(value: str | None) -> str | None:
if value is None or len(value) <= 4:
return value
return f"{value[:2]}***{value[-2:]}"