diff --git a/document/development/knowledge-answers/lightweight-knowledge-ingestion-design.html b/document/development/knowledge-answers/lightweight-knowledge-ingestion-design.html new file mode 100644 index 0000000..e45a4fa --- /dev/null +++ b/document/development/knowledge-answers/lightweight-knowledge-ingestion-design.html @@ -0,0 +1,896 @@ + + + + + + X-Financial 轻量知识库归集与问答优化开发文档 + + + +
+ + +
+
+
+ 开发文档 · 先定边界再实现 +

X-Financial 轻量知识库归集与问答优化方案

+

+ 本方案不把 X-Financial 改造成专业知识库平台,而是在现有 + LightRAGHermesAgentRun + 和知识库 UI 上补齐最薄弱的归集、分块、召回和证据回答能力。 + Yuxi 只作为成熟设计参考,借鉴其统一解析、分块预设和评估思想。 +

+
+ 保留现有 LightRAG + 轻量 Parser + 条款级分块 + 混合召回 + 证据化回答 +
+
+ +
+
+
核心目标
+
准、快、可解释
+
+
+
改造范围
+
归集与召回链路,不重做平台
+
+
+
并发预期
+
5-10 用户查询可降级、有上限
+
+
+
+ +
+
+
+

定位与边界

+

+ 知识库在 X-Financial 中是业务辅助能力,不是独立知识管理产品。 + 因此实现必须克制:不引入重型多租户平台,不替换现有业务数据模型, + 不把知识库 UI 做成复杂后台,只补齐影响问答质量的关键薄层。 +

+
+ 轻量优先 +
+ +
+
+

要解决的问题

+
    +
  • Word、PDF、Excel 等文件进入 RAG 前缺少统一结构。
  • +
  • 制度类文档如果按普通 chunk 切分,条款容易被切散。
  • +
  • 问答质量依赖向量召回,缺少关键词、标题、条款补召回。
  • +
  • 效果优化缺少固定评测集,容易靠体感判断。
  • +
+
+
+

保留的现有能力

+
    +
  • KnowledgeService 继续负责文件库和状态入口。
  • +
  • KnowledgeRagService 继续封装 LightRAG 查询和入库。
  • +
  • KnowledgeIndexTaskManager 继续承接 Hermes 增量任务。
  • +
  • 前端知识管理继续保持简单文件夹与文件列表形态。
  • +
+
+
+

明确不做

+
    +
  • 不整体引入 Yuxi 平台。
  • +
  • 不把存储改成 Milvus + Neo4j。
  • +
  • 不一次性接入全量 OCR 引擎。
  • +
  • 不新增复杂多租户知识库后台。
  • +
+
+
+
+ +
+
+
+

轻量架构

+

+ 新增能力只放在 LightRAG 前后两侧:前侧负责把文件变成稳定 Markdown 和业务友好 chunk, + 后侧负责混合召回、证据重排和可靠回答。LightRAG 仍是主召回核心。 +

+
+ 薄层增强 +
+ +
原始文件 + ├── docx / pdf / xlsx / pptx / csv / txt + ↓ +轻量 Parser + ├── 统一 Markdown + ├── 表格上下文 + └── 页码 / sheet / 条款路径 + ↓ +Chunk Preset + ├── laws:制度条款 + ├── qa:常见问答 + └── table:表格行组 + ↓ +现有 LightRAG / Qdrant + ↓ +混合召回 + ├── LightRAG 语义召回 + ├── 标题与条款关键词召回 + └── 轻量重排 top 3-5 + ↓ +证据化回答 + ├── 命中证据 + ├── 直接结论 + └── 缺失信息说明
+
+ +
+
+
+

Yuxi 借鉴点

+

+ Yuxi 的价值不在于整套平台,而在于成熟的归集分层思想: + 文件先解析成 Markdown,再按场景分块,再索引,再评估。 + 这些思想可以小规模落地到现有服务内。 +

+
+ 借鉴而非搬运 +
+ +
+
+

统一 Parser

+

学习 Yuxi 把多格式文件统一转 Markdown 的入口设计,但只实现 X-Financial 当前需要的格式。

+
+
+

分块 Preset

+

借鉴 RAGFlow-like preset。先做 lawsqatable 三类。

+
+
+

两阶段状态

+

内部区分解析和索引。UI 仍可显示简单归纳状态,后台记录真实失败点。

+
+
+

轻量评测

+

不做评估平台,只维护 JSON 用例和脚本,持续检查召回与回答质量。

+
+
+ + +
+ +
+
+
+

模块设计

+

+ 新增模块必须小而清楚,避免把逻辑继续堆进单个 Service。 + 单个核心文件控制在 800 行以内,优先按解析、分块、召回、评测拆分。 +

+
+ 职责拆分 +
+ +
+
+
knowledge_parser.py
+
+ 负责把 docx、pdf、xlsx、csv、txt 等文件转成 Markdown。 + 输出正文、标题路径、页码、sheet、表头、解析告警。 +
+
+
+
knowledge_chunking.py
+
+ 根据文件夹、文件类型和文档特征选择分块策略。 + 第一批只实现制度、问答、表格三类。 +
+
+
+
knowledge_retrieval.py
+
+ 在 LightRAG 命中结果外补充关键词、条款标题和文件名召回。 + 最终输出小而准的证据块。 +
+
+
+
knowledge_eval.py
+
+ 读取轻量评测用例,检查 expected 文件、关键词、证据和答案约束。 + 用于每次调整参数后的回归验证。 +
+
+
+
+ +
+
+
+

召回与回答策略

+

+ 目标不是让模型更会猜,而是让系统给模型更可靠的证据。 + 制度问题优先命中条款,表格问题保留表头与行上下文,回答必须暴露依据和缺失信息。 +

+
+ 证据优先 +
+ +
+
+

召回层

+
    +
  • LightRAG 继续提供语义召回。
  • +
  • 条款号、标题、文件名、关键词做补召回。
  • +
  • 召回候选数量有上限,避免并发下无限扩张。
  • +
+
+
+

重排层

+
    +
  • 优先保留含问题关键词、标题路径和条款语义的块。
  • +
  • 制度类按条款完整度加权。
  • +
  • 最终给回答链路 3-5 条高质量证据。
  • +
+
+
+

回答层

+
    +
  • 能直接基于证据回答时,不强制二次模型整理。
  • +
  • 模型只做压缩表达,不凭空补事实。
  • +
  • 证据不足时明确说明缺什么。
  • +
+
+
+
+ +
+
+
+

实施路线

+

+ 分四步小步交付。每一步都能单独验证,不把解析、索引、召回和评测揉成一次大改。 +

+
+ 渐进落地 +
+ +
+
+
P0 / 文档落地
+
+ 先明确轻量边界 + 完成本文档,确认不做重平台、不替换存储、不一次性引入复杂 OCR。 +
+
+
+
P1 / 统一解析
+
+ 补齐文件归集质量 + 新增 Parser,把 Word、PDF、Excel、CSV、TXT 稳定转为 Markdown,并保存解析产物供索引复用。 +
+
+
+
P2 / 场景分块
+
+ 提升制度与表格命中率 + 实现 laws、qa、table 三类分块。制度按章、节、条、款保留完整语义,表格保留 sheet、表头和行上下文。 +
+
+
+
P3 / 混合召回
+
+ 减少答偏和漏召回 + 在 LightRAG 命中外补充关键词、条款标题、文件名召回,输出可控数量的证据块。 +
+
+
+
P4 / 轻量评测
+
+ 把效果优化变成可回归 + 建设 30-50 条远光软件制度风格问答用例,覆盖报销、差旅、发票、预算、税务等高频问题。 +
+
+
+
+ +
+
+
+

验收标准

+

+ 验收不只看页面状态,而要看文件是否真实入库、召回是否命中文档依据、 + 回答是否引用证据,以及并发访问时是否能稳定降级。 +

+
+ 真实验证 +
+ +
+
Word、PDF、Excel、CSV、TXT 文件能生成可读 Markdown,且解析产物可复用。
+
制度类文件能按章、节、条、款形成相对完整的证据块。
+
Excel 表格问答能保留 sheet、表头、关键列和业务行上下文。
+
Hermes 增量任务能区分解析失败、索引失败和归纳失败。
+
常见制度问答优先返回证据化直接答案,模型超时时仍有可读降级答案。
+
5-10 个用户同时访问时,查询候选数、重排数、模型调用数都有明确上限。
+
轻量评测集覆盖至少 30 条问题,并记录命中文件、关键词和答案约束。
+
不引入 Yuxi 平台级依赖,不改变现有知识库 UI 的主体交互。
+
+ +

+ 后续实现时,优先在现有定向测试基础上补充 Parser、Chunking、Retrieval 和 Knowledge Eval 的小测试。 + 后端验证优先在 Docker 容器 x-financial-main 中运行。 +

+
+ + +
+
+ + diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index bb616ae..b5f69e0 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -97,6 +97,16 @@ def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> li return ExpenseClaimService(db).list_approval_claims(current_user) +@router.get( + "/claims/archives", + response_model=list[ExpenseClaimRead], + summary="查询归档中心报销单列表", + description="返回公司已归档入账的报销单据,供财务与审计角色集中查阅。", +) +def list_archived_expense_claims(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]: + return ExpenseClaimService(db).list_archived_claims(current_user) + + @router.get( "/claims/{claim_id}", response_model=ExpenseClaimRead, diff --git a/server/src/app/schemas/user_agent.py b/server/src/app/schemas/user_agent.py index a7badc7..f11b5dc 100644 --- a/server/src/app/schemas/user_agent.py +++ b/server/src/app/schemas/user_agent.py @@ -58,10 +58,12 @@ class UserAgentExpenseQueryRecord(BaseModel): occurred_at: str = Field(default="", description="业务发生日期。") reason: str = Field(default="", description="事由。") location: str = Field(default="", description="地点。") + risk_flags: list[dict[str, Any]] = Field(default_factory=list, description="该单据当前风险项。") class UserAgentQueryPayload(BaseModel): result_type: str = Field(default="expense_claim_list", description="结构化查询结果类型。") + title: str = Field(default="", description="查询结果标题。") scope_label: str = Field(default="报销单", description="当前查询范围名。") recent_window_applied: bool = Field(default=False, description="是否应用了近 10 日窗口。") window_days: int | None = Field(default=None, ge=1, description="近 N 日窗口天数。") @@ -69,6 +71,7 @@ class UserAgentQueryPayload(BaseModel): window_end_date: str | None = Field(default=None, description="近 N 日窗口结束日期。") record_count: int = Field(default=0, ge=0, description="当前展示范围内的单据数。") preview_count: int = Field(default=0, ge=0, description="当前返回的单据数。") + preview_limit: int = Field(default=5, ge=1, description="默认展示条数上限。") older_record_count: int = Field(default=0, ge=0, description="超出近 10 日窗口的单据数。") has_more_in_window: bool = Field(default=False, description="当前展示范围内是否还有更多单据未返回。") total_amount: float = Field(default=0.0, ge=0.0, description="当前展示范围内金额合计。") @@ -122,6 +125,7 @@ class UserAgentReviewDocumentCard(BaseModel): avg_score: float = Field(default=0.0, ge=0.0, le=1.0, description="OCR 平均得分。") preview_kind: str = Field(default="", description="票据预览类型,例如 image。") preview_data_url: str = Field(default="", description="票据预览图片 data URL。") + preview_url: str = Field(default="", description="票据预览图片地址。") warnings: list[str] = Field(default_factory=list, description="该票据的识别提示。") fields: list[UserAgentReviewDocumentField] = Field( default_factory=list, diff --git a/server/src/app/services/agent_conversations.py b/server/src/app/services/agent_conversations.py index 7af40f8..814e0ff 100644 --- a/server/src/app/services/agent_conversations.py +++ b/server/src/app/services/agent_conversations.py @@ -93,6 +93,12 @@ class AgentConversationService: if existing_session_type != incoming_session_type: normalized_id = "" conversation = None + if conversation is not None and self._has_draft_claim_scope_conflict( + conversation, + incoming_draft_claim_id, + ): + normalized_id = "" + conversation = None if conversation is None: conversation = AgentConversation( @@ -241,6 +247,10 @@ class AgentConversationService: history_limit: int = 8, ) -> dict[str, Any]: merged = dict(context_json or {}) + incoming_draft_claim_id = self._resolve_draft_claim_id(merged) + if self._has_draft_claim_scope_conflict(conversation, incoming_draft_claim_id): + return merged + state_json = dict(conversation.state_json or {}) should_hydrate_review_flow = self._should_hydrate_review_flow_context( context_json=merged, @@ -641,6 +651,26 @@ class AgentConversationService: ).strip() return "" + @staticmethod + def _resolve_conversation_draft_claim_id(conversation: AgentConversation) -> str: + state_json = dict(conversation.state_json or {}) + return str( + conversation.draft_claim_id + or state_json.get("draft_claim_id") + or "" + ).strip() + + @staticmethod + def _has_draft_claim_scope_conflict( + conversation: AgentConversation, + incoming_draft_claim_id: str | None, + ) -> bool: + incoming_claim_id = str(incoming_draft_claim_id or "").strip() + if not incoming_claim_id: + return False + existing_claim_id = AgentConversationService._resolve_conversation_draft_claim_id(conversation) + return bool(existing_claim_id and existing_claim_id != incoming_claim_id) + @staticmethod def _merge_state_json( current_state: dict[str, Any] | None, diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index 5f7b113..20e1377 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -13,8 +13,10 @@ from app.models.organization import OrganizationUnit PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"} +ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive", "auditor"} APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} CLAIM_DELETE_ROLE_CODES = {"executive"} +ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid") class ExpenseClaimAccessPolicy: @@ -27,6 +29,30 @@ class ExpenseClaimAccessPolicy: return True return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & PRIVILEGED_CLAIM_ROLE_CODES) + @staticmethod + def has_archive_center_access(current_user: CurrentUserContext) -> bool: + if current_user.is_admin: + return True + return bool(ExpenseClaimAccessPolicy.normalize_role_codes(current_user) & ARCHIVE_CENTER_ROLE_CODES) + + @staticmethod + def build_archived_claim_condition() -> Any: + normalized_status = func.lower(func.coalesce(ExpenseClaim.status, "")) + stage = func.coalesce(ExpenseClaim.approval_stage, "") + return or_( + stage == "归档入账", + stage == "completed", + and_( + normalized_status.in_(ARCHIVED_CLAIM_STATUSES), + or_( + stage == "", + stage.is_(None), + stage == "归档入账", + stage == "completed", + ), + ), + ) + @staticmethod def has_claim_delete_access(current_user: CurrentUserContext) -> bool: if current_user.is_admin: @@ -374,7 +400,16 @@ class ExpenseClaimAccessPolicy: include_approval_scope: bool = False, ) -> Any: if self.has_privileged_claim_access(current_user): - return stmt + owned_conditions = self.build_personal_claim_conditions(current_user) + archived_condition = self.build_archived_claim_condition() + if owned_conditions: + return stmt.where( + or_( + ~archived_condition, + and_(archived_condition, or_(*owned_conditions)), + ) + ) + return stmt.where(~archived_condition) conditions = self.build_personal_claim_conditions(current_user) @@ -386,6 +421,12 @@ class ExpenseClaimAccessPolicy: return stmt.where(or_(*conditions)) + def apply_archived_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any: + if not self.has_archive_center_access(current_user): + return stmt.where(ExpenseClaim.id == "__no_visible_claim__") + + return stmt.where(self.build_archived_claim_condition()) + @staticmethod def resolve_claim_manager_name(claim: ExpenseClaim) -> str: if claim.employee is not None: diff --git a/server/src/app/services/expense_claim_attachment_analysis.py b/server/src/app/services/expense_claim_attachment_analysis.py index 282f17f..216fad8 100644 --- a/server/src/app/services/expense_claim_attachment_analysis.py +++ b/server/src/app/services/expense_claim_attachment_analysis.py @@ -615,7 +615,7 @@ class ExpenseClaimAttachmentAnalysisMixin: severity = "high" label = "高风险" headline = "AI提示:住宿金额超出报销标准" - summary = "当前住宿票据金额超过规则中心差旅住宿标准,强行提交前需补充超标原因。" + summary = "当前住宿票据金额超过规则中心差旅住宿标准,已作为风险项保留在单据中;如需按特殊情况提交,请补充超标原因。" elif ( line_count == 0 or not compact_text diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 68bcaf4..338e80f 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -169,6 +169,19 @@ class ExpenseClaimService( stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) return list(self.db.scalars(stmt).all()) + def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: + stmt = ( + select(ExpenseClaim) + .options( + selectinload(ExpenseClaim.items), + selectinload(ExpenseClaim.employee).selectinload(Employee.manager), + selectinload(ExpenseClaim.employee).selectinload(Employee.roles), + ) + .order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) + ) + stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user) + return list(self.db.scalars(stmt).all()) + def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None: stmt = ( select(ExpenseClaim) diff --git a/server/src/app/services/knowledge.py b/server/src/app/services/knowledge.py index 8dc4a93..48cc4a9 100644 --- a/server/src/app/services/knowledge.py +++ b/server/src/app/services/knowledge.py @@ -106,7 +106,7 @@ class KnowledgeService: KnowledgeFolderRead( name=folder_name, count=sum(1 for item in documents if item.folder == folder_name), - icon="mdi mdi-folder-open" if folder_name == "差旅规范" else "mdi mdi-folder", + icon="mdi mdi-folder", ) for folder_name in FIXED_KNOWLEDGE_FOLDERS ] diff --git a/server/src/app/services/knowledge_document_extractors.py b/server/src/app/services/knowledge_document_extractors.py index 9b57cf1..8c1fcd7 100644 --- a/server/src/app/services/knowledge_document_extractors.py +++ b/server/src/app/services/knowledge_document_extractors.py @@ -10,6 +10,12 @@ from zipfile import BadZipFile, ZipFile from app.services.knowledge_constants import IMAGE_EXTENSIONS, TEXT_EXTENSIONS from app.services.knowledge_file_utils import extract_extension +MAX_EXTRACTED_XLSX_SHEETS = 12 +MAX_EXTRACTED_XLSX_ROWS_PER_SHEET = 300 +MAX_EXTRACTED_XLSX_COLUMNS = 40 +MAX_EXTRACTED_PPTX_SLIDES = 80 + + def _read_text_preview(file_path: Path) -> str: encodings = ("utf-8", "utf-8-sig", "gbk") for encoding in encodings: @@ -19,6 +25,7 @@ def _read_text_preview(file_path: Path) -> str: continue return "当前文本文件编码暂不支持在线解析。" + def _extract_docx_text(file_path: Path) -> str: try: with ZipFile(file_path) as archive: @@ -30,6 +37,7 @@ def _extract_docx_text(file_path: Path) -> str: texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text] return "\n".join(texts) + def _extract_document_text_from_path( *, file_path: Path, @@ -41,6 +49,20 @@ def _extract_document_text_from_path( return _normalize_extracted_text(_read_text_preview(file_path)) if extension == "docx": return _normalize_extracted_text(_extract_docx_text(file_path)) + if extension == "xlsx": + return _normalize_extracted_text( + _build_xlsx_markdown( + original_name=original_name, + sheets=_extract_xlsx_sheets(file_path), + ) + ) + if extension == "pptx": + return _normalize_extracted_text( + _build_pptx_markdown( + original_name=original_name, + slides=_extract_pptx_slides(file_path), + ) + ) if extension == "pdf": text = _normalize_extracted_text(_extract_pdf_text(file_path)) if text: @@ -62,11 +84,13 @@ def _extract_document_text_from_path( ) return "" + def _normalize_extracted_text(text: str) -> str: normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n") normalized = re.sub(r"\n{3,}", "\n\n", normalized) return normalized.strip() + def _extract_pdf_text(file_path: Path) -> str: pdftotext_bin = shutil.which("pdftotext") if not pdftotext_bin: @@ -83,6 +107,7 @@ def _extract_pdf_text(file_path: Path) -> str: return "" return str(completed.stdout or "") + def _extract_text_with_ocr( *, file_path: Path, @@ -92,9 +117,7 @@ def _extract_text_with_ocr( try: from app.services.ocr import OcrService - result = OcrService().recognize_files( - [(original_name, file_path.read_bytes(), mime_type)] - ) + result = OcrService().recognize_files([(original_name, file_path.read_bytes(), mime_type)]) except Exception: return "" @@ -108,6 +131,7 @@ def _extract_text_with_ocr( parts.append(summary) return "\n\n".join(part for part in parts if part) + def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]: try: with ZipFile(file_path) as archive: @@ -182,8 +206,13 @@ def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]: value_node = next((item for item in cell if item.tag.endswith("}v")), None) if cell_type == "inlineStr": - text_node = next((item for item in cell.iter() if item.tag.endswith("}t")), None) - row_values.append((text_node.text or "").strip() if text_node is not None else "") + text_node = next( + (item for item in cell.iter() if item.tag.endswith("}t")), + None, + ) + row_values.append( + (text_node.text or "").strip() if text_node is not None else "" + ) continue if value_node is None or value_node.text is None: @@ -193,7 +222,9 @@ def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]: raw_value = value_node.text.strip() if cell_type == "s" and raw_value.isdigit(): index = int(raw_value) - row_values.append(shared_strings[index] if index < len(shared_strings) else raw_value) + row_values.append( + shared_strings[index] if index < len(shared_strings) else raw_value + ) else: row_values.append(raw_value) if row_values: @@ -205,6 +236,7 @@ def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]: except (BadZipFile, ElementTree.ParseError, KeyError, ValueError): return [] + def _extract_pptx_slides(file_path: Path) -> list[list[str]]: try: with ZipFile(file_path) as archive: @@ -216,8 +248,91 @@ def _extract_pptx_slides(file_path: Path) -> list[list[str]]: slides: list[list[str]] = [] for slide_name in slide_names: root = ElementTree.fromstring(archive.read(slide_name)) - texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text] + texts = [ + node.text.strip() + for node in root.iter() + if node.tag.endswith("}t") and node.text + ] slides.append(texts) return slides except (BadZipFile, ElementTree.ParseError, KeyError): return [] + + +def _build_xlsx_markdown( + *, + original_name: str, + sheets: list[tuple[str, list[list[str]]]], +) -> str: + if not sheets: + return "" + + parts = [f"# Excel 工作簿:{original_name}"] + for sheet_index, (sheet_name, rows) in enumerate(sheets[:MAX_EXTRACTED_XLSX_SHEETS], start=1): + visible_rows = [ + [_escape_markdown_cell(cell) for cell in row[:MAX_EXTRACTED_XLSX_COLUMNS]] + for row in rows[:MAX_EXTRACTED_XLSX_ROWS_PER_SHEET] + if any(str(cell or "").strip() for cell in row) + ] + if not visible_rows: + continue + + column_count = max(len(row) for row in visible_rows) + normalized_rows = [row + [""] * (column_count - len(row)) for row in visible_rows] + header = [ + cell or f"列{column_index + 1}" for column_index, cell in enumerate(normalized_rows[0]) + ] + + parts.append(f"## 工作表 {sheet_index}:{sheet_name}") + parts.append(_format_markdown_table(header, normalized_rows[1:])) + parts.append("### 行级检索线索") + for row_number, row in enumerate(normalized_rows[1:], start=2): + pairs = [ + f"{header[column_index]}={value}" for column_index, value in enumerate(row) if value + ] + if pairs: + parts.append(f"- {sheet_name} 第 {row_number} 行:" + ";".join(pairs)) + + if len(rows) > MAX_EXTRACTED_XLSX_ROWS_PER_SHEET: + parts.append( + f"- {sheet_name} 还有 {len(rows) - MAX_EXTRACTED_XLSX_ROWS_PER_SHEET} 行未展开。" + ) + + return "\n\n".join(part for part in parts if part).strip() + + +def _build_pptx_markdown( + *, + original_name: str, + slides: list[list[str]], +) -> str: + if not slides: + return "" + + parts = [f"# PowerPoint 演示文稿:{original_name}"] + for slide_index, slide_lines in enumerate(slides[:MAX_EXTRACTED_PPTX_SLIDES], start=1): + lines = [line.strip() for line in slide_lines if str(line or "").strip()] + if not lines: + continue + parts.append(f"## 幻灯片 {slide_index}") + parts.extend(f"- {line}" for line in lines) + if len(slides) > MAX_EXTRACTED_PPTX_SLIDES: + parts.append(f"- 还有 {len(slides) - MAX_EXTRACTED_PPTX_SLIDES} 页未展开。") + return "\n\n".join(part for part in parts if part).strip() + + +def _format_markdown_table(header: list[str], rows: list[list[str]]) -> str: + table_rows = [header] + rows + separator = ["---"] * len(header) + lines = [ + "| " + " | ".join(table_rows[0]) + " |", + "| " + " | ".join(separator) + " |", + ] + lines.extend("| " + " | ".join(row) + " |" for row in table_rows[1:]) + return "\n".join(lines) + + +def _escape_markdown_cell(value: str) -> str: + text = str(value or "").replace("\r\n", " ").replace("\r", " ").replace("\n", " ") + text = re.sub(r"\s+", " ", text).strip() + return text.replace("|", "\\|") diff --git a/server/src/app/services/knowledge_preview.py b/server/src/app/services/knowledge_preview.py index 834c526..ef30b3b 100644 --- a/server/src/app/services/knowledge_preview.py +++ b/server/src/app/services/knowledge_preview.py @@ -16,6 +16,7 @@ from app.services.knowledge_document_extractors import ( ) from app.services.knowledge_file_utils import extract_extension, format_size + def build_preview( entry: dict[str, Any], *, @@ -52,7 +53,9 @@ def build_preview( subtitle="当前格式暂不支持在线解析预览。", stats=[ KnowledgePreviewStatRead(label="文件格式", value=extension.upper() or "FILE"), - KnowledgePreviewStatRead(label="文件大小", value=format_size(entry["size_bytes"])), + KnowledgePreviewStatRead( + label="文件大小", value=format_size(entry["size_bytes"]) + ), KnowledgePreviewStatRead(label="建议操作", value="下载后查看"), ], blocks=[ @@ -68,9 +71,8 @@ def build_preview( ], ) -def _build_text_preview_page( - entry: dict[str, Any], text: str -) -> KnowledgePreviewPageRead: + +def _build_text_preview_page(entry: dict[str, Any], text: str) -> KnowledgePreviewPageRead: lines = [line.strip() for line in text.splitlines() if line.strip()] if not lines: lines = ["文件内容为空,或当前文档未提取到可展示文本。"] @@ -92,10 +94,9 @@ def _build_text_preview_page( blocks=blocks, ) -def _build_xlsx_preview_pages( - entry: dict[str, Any], file_path -) -> list[KnowledgePreviewPageRead]: - sheets = self._extract_xlsx_sheets(file_path) + +def _build_xlsx_preview_pages(entry: dict[str, Any], file_path) -> list[KnowledgePreviewPageRead]: + sheets = _extract_xlsx_sheets(file_path) if not sheets: sheets = [("Sheet 1", [["未提取到表格内容。"]])] @@ -118,7 +119,9 @@ def _build_xlsx_preview_pages( stats=[ KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)), KnowledgePreviewStatRead(label="预览行数", value=str(len(visible_rows))), - KnowledgePreviewStatRead(label="文件大小", value=format_size(entry["size_bytes"])), + KnowledgePreviewStatRead( + label="文件大小", value=format_size(entry["size_bytes"]) + ), ], blocks=blocks, ) @@ -126,10 +129,9 @@ def _build_xlsx_preview_pages( return preview_pages -def _build_pptx_preview_pages( - entry: dict[str, Any], file_path -) -> list[KnowledgePreviewPageRead]: - slides = self._extract_pptx_slides(file_path) + +def _build_pptx_preview_pages(entry: dict[str, Any], file_path) -> list[KnowledgePreviewPageRead]: + slides = _extract_pptx_slides(file_path) if not slides: slides = [["未提取到幻灯片文本。"]] @@ -154,4 +156,3 @@ def _build_pptx_preview_pages( ) return pages - diff --git a/server/src/app/services/ontology_detection.py b/server/src/app/services/ontology_detection.py index 72749b9..3073ad9 100644 --- a/server/src/app/services/ontology_detection.py +++ b/server/src/app/services/ontology_detection.py @@ -114,6 +114,20 @@ class OntologyDetectionMixin: return "query", 0.24 if any(keyword in compact_query for keyword in DRAFT_KEYWORDS): return "draft", 0.26 + if scenario == "expense" and "报销" in compact_query and any( + item.type == "expense_type" + and str(item.normalized_value or item.value or "").strip() + for item in entities + ) and not any( + keyword in compact_query + for keyword in ( + *QUERY_KEYWORDS, + *COMPARE_KEYWORDS, + *EXPLAIN_KEYWORDS, + *RISK_KEYWORDS, + ) + ): + return "draft", 0.25 if scenario == "expense" and self._is_generic_expense_prompt(compact_query): return "draft", 0.24 if any(keyword in compact_query for keyword in COMPARE_KEYWORDS): @@ -220,7 +234,11 @@ class OntologyDetectionMixin: has_expense_signal = any( keyword in compact_query for keyword in EXPENSE_NARRATIVE_KEYWORDS ) or "expense_type" in entity_types - has_context_signal = bool(time_range.start_date) or "amount" in entity_types + has_context_signal = ( + bool(time_range.start_date) + or "amount" in entity_types + or ("报销" in compact_query and "expense_type" in entity_types) + ) return has_expense_signal and has_context_signal diff --git a/server/src/app/services/ontology_extraction.py b/server/src/app/services/ontology_extraction.py index 8789fa6..c117c31 100644 --- a/server/src/app/services/ontology_extraction.py +++ b/server/src/app/services/ontology_extraction.py @@ -186,7 +186,21 @@ class OntologyExtractionMixin: if any( keyword in query - for keyword in ("打车", "网约车", "出租车", "车费", "乘车", "用车", "叫车", "车资", "停车费", "过路费") + for keyword in ( + "打车", + "网约车", + "出租车", + "出租车票", + "车费", + "乘车", + "用车", + "叫车", + "车资", + "的士", + "的士票", + "停车费", + "过路费", + ) ): upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9)) diff --git a/server/src/app/services/ontology_rules.py b/server/src/app/services/ontology_rules.py index 1cc1956..e0067e0 100644 --- a/server/src/app/services/ontology_rules.py +++ b/server/src/app/services/ontology_rules.py @@ -137,11 +137,14 @@ EXPENSE_TYPE_KEYWORDS = { "打车": "transport", "网约车": "transport", "出租车": "transport", + "出租车票": "transport", "乘车": "transport", "乘车费": "transport", "用车": "transport", "叫车": "transport", "车资": "transport", + "的士": "transport", + "的士票": "transport", "停车费": "transport", "餐费": "meal", "用餐": "meal", @@ -180,6 +183,9 @@ EXPENSE_NARRATIVE_KEYWORDS = ( "用车", "叫车", "车资", + "的士", + "的士票", + "出租车票", "餐费", "吃饭", "用餐", @@ -232,6 +238,9 @@ STATUS_KEYWORDS = { "已审批": "approved", "已通过": "approved", "已审核": "approved", + "归档": "archived", + "已归档": "archived", + "入账": "archived", "已入账": "paid", "已付款": "paid", "未付款": "unpaid", diff --git a/server/src/app/services/orchestrator_expense_query.py b/server/src/app/services/orchestrator_expense_query.py index d8b39c4..3e59126 100644 --- a/server/src/app/services/orchestrator_expense_query.py +++ b/server/src/app/services/orchestrator_expense_query.py @@ -17,14 +17,50 @@ from app.schemas.ontology import OntologyParseResult PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"} SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请") EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10 -EXPENSE_QUERY_PREVIEW_LIMIT = 20 +EXPENSE_QUERY_PREVIEW_LIMIT = 5 EXPENSE_STATUS_LABELS = { + "archived": "归档", "draft": "草稿", + "supplement": "待补充", + "returned": "已退回", "submitted": "已提交", "review": "审核中", "approved": "已通过", "rejected": "已驳回", - "paid": "已付款", + "paid": "归档", +} +EXPENSE_QUERY_STATUS_KEYWORDS = ( + (("归档", "已归档", "入账", "已入账", "已付款"), ("archived",)), + (("审批通过", "审核通过", "已通过", "已审核"), ("approved",)), + (("审批中", "审核中", "进行中", "流程中"), ("submitted", "review")), + (("已提交", "提交了"), ("submitted",)), + (("草稿", "待报销", "待提交"), ("draft",)), + (("待补充", "待完善", "退回", "已退回"), ("supplement", "returned")), + (("驳回", "已驳回", "拒绝"), ("rejected",)), +) +EXPENSE_STATUS_ALIASES = { + "归档": "archived", + "已归档": "archived", + "入账": "archived", + "已入账": "archived", + "已付款": "archived", + "已通过": "approved", + "审批通过": "approved", + "审核通过": "approved", + "已审核": "approved", + "审批中": "review", + "审核中": "review", + "进行中": "review", + "已提交": "submitted", + "草稿": "draft", + "待报销": "draft", + "待提交": "draft", + "待补充": "supplement", + "待完善": "supplement", + "已退回": "returned", + "退回": "returned", + "驳回": "rejected", + "已驳回": "rejected", } EXPENSE_STATUS_GROUP_LABELS = { "draft": "草稿", @@ -33,6 +69,13 @@ EXPENSE_STATUS_GROUP_LABELS = { "other": "其他状态", } EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other") +EXPENSE_RISK_LEVEL_LABELS = { + "high": "高风险", + "medium": "中风险", + "warning": "中风险", + "low": "低风险", + "info": "低风险", +} EXPENSE_TYPE_LABELS = { "travel": "差旅费", "hotel": "住宿费", @@ -95,7 +138,7 @@ class OrchestratorDatabaseQueryBuilder: total_count = int(self.db.scalar(count_stmt) or 0) total_amount = float(self.db.scalar(amount_stmt) or 0) - recent_window_applied = self._should_limit_expense_query_to_recent_window(ontology) + recent_window_applied = self._should_limit_expense_query_to_recent_window(ontology, message) display_count = total_count display_amount = total_amount older_record_count = 0 @@ -146,12 +189,14 @@ class OrchestratorDatabaseQueryBuilder: "record_count": display_count, "total_amount": round(display_amount, 2), "scope_label": scope_label, + "title": f"最近 {len(preview_claims)} 条{scope_label}" if preview_claims else f"{scope_label}筛选结果", "scoped_to_current_user": scoped_to_current_user, "recent_window_applied": recent_window_applied, "window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None, "window_start_date": window_start_date, "window_end_date": window_end_date, "preview_count": len(preview_claims), + "preview_limit": EXPENSE_QUERY_PREVIEW_LIMIT, "older_record_count": older_record_count, "records": [ self._build_expense_query_record(claim) @@ -199,6 +244,7 @@ class OrchestratorDatabaseQueryBuilder: @staticmethod def _should_limit_expense_query_to_recent_window( ontology: OntologyParseResult, + message: str = "", ) -> bool: has_explicit_claim_no = any( item.type == "expense_claim" @@ -208,7 +254,12 @@ class OrchestratorDatabaseQueryBuilder: has_explicit_time_range = bool( ontology.time_range.start_date or ontology.time_range.end_date ) - return not has_explicit_claim_no and not has_explicit_time_range + compact_message = str(message or "").replace(" ", "") + asks_recent_window = any( + keyword in compact_message + for keyword in ("近", "最近", "本周", "上周", "过去", "前几天", "这几天") + ) + return asks_recent_window and not has_explicit_claim_no and not has_explicit_time_range @staticmethod def _resolve_reference_now(context_json: dict[str, Any]) -> datetime: @@ -294,6 +345,12 @@ class OrchestratorDatabaseQueryBuilder: ) -> dict[str, Any]: status_group, status_group_label = self._resolve_expense_status_group(claim.status) document_datetime = self._resolve_expense_query_document_datetime(claim) + approval_stage = str(claim.approval_stage or "").strip() + status_label = ( + "已归档" + if "归档" in approval_stage + else EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中") + ) return { "claim_id": claim.id, "claim_no": claim.claim_no, @@ -302,16 +359,63 @@ class OrchestratorDatabaseQueryBuilder: "expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"), "amount": round(float(claim.amount), 2), "status": claim.status, - "status_label": EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中"), + "status_label": status_label, "status_group": status_group, "status_group_label": status_group_label, - "approval_stage": claim.approval_stage, + "approval_stage": approval_stage, "document_date": document_datetime.date().isoformat() if document_datetime else "", "occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "", "reason": claim.reason, "location": claim.location, + "risk_flags": self._normalize_expense_query_risk_flags(claim.risk_flags_json), } + @staticmethod + def _normalize_expense_query_risk_flags(raw_flags: Any) -> list[dict[str, str]]: + if not isinstance(raw_flags, list): + return [] + + normalized_flags: list[dict[str, str]] = [] + for index, raw_flag in enumerate(raw_flags, start=1): + if isinstance(raw_flag, dict): + raw_level = str(raw_flag.get("severity") or raw_flag.get("level") or "").strip().lower() + level = raw_level if raw_level in EXPENSE_RISK_LEVEL_LABELS else "medium" + summary = str( + raw_flag.get("message") + or raw_flag.get("summary") + or raw_flag.get("title") + or raw_flag.get("label") + or "" + ).strip() + detail = ";".join( + str(point or "").strip() + for point in list(raw_flag.get("points") or []) + if str(point or "").strip() + ) + title = str(raw_flag.get("label") or EXPENSE_RISK_LEVEL_LABELS[level]).strip() + else: + raw_text = str(raw_flag or "").strip() + if not raw_text: + continue + level = "high" if any(keyword in raw_text for keyword in ("高风险", "超标", "重复", "异常")) else "medium" + summary = raw_text + detail = raw_text + title = EXPENSE_RISK_LEVEL_LABELS[level] + + if not summary: + continue + normalized_flags.append( + { + "key": f"risk-{index}", + "level": level, + "level_label": EXPENSE_RISK_LEVEL_LABELS.get(level, "中风险"), + "title": title or EXPENSE_RISK_LEVEL_LABELS.get(level, "中风险"), + "summary": summary, + "detail": detail or summary, + } + ) + return normalized_flags + def _build_expense_query_scope( self, *, @@ -344,12 +448,13 @@ class OrchestratorDatabaseQueryBuilder: ) project_values = self._collect_expense_query_filter_values(ontology, "project") location_values = self._collect_expense_query_filter_values(ontology, "location") - status_values = list( - dict.fromkeys( + status_values = self._resolve_expense_query_status_values( + [ str(item.value).strip() for item in ontology.constraints if item.field == "status" and item.operator == "=" and str(item.value).strip() - ) + ], + message, ) amount_constraints = [ item @@ -363,8 +468,16 @@ class OrchestratorDatabaseQueryBuilder: conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos)) if expense_types: conditions.append(ExpenseClaim.expense_type.in_(expense_types)) - if status_values: - conditions.append(ExpenseClaim.status.in_(status_values)) + direct_status_values = [status for status in status_values if status != "archived"] + if "archived" in status_values: + conditions.append( + or_( + ExpenseClaim.approval_stage.ilike("%归档%"), + ExpenseClaim.status.in_(["approved", "paid"]), + ) + ) + if direct_status_values: + conditions.append(ExpenseClaim.status.in_(direct_status_values)) if project_values: project_conditions = [] for value in project_values: @@ -438,7 +551,49 @@ class OrchestratorDatabaseQueryBuilder: else: scope_label = "全部报销单" - return conditions, scope_label, scoped_to_current_user + return conditions, self._compose_expense_scope_label(scope_label, status_values), scoped_to_current_user + + @staticmethod + def _resolve_expense_query_status_values( + raw_values: list[str], + message: str, + ) -> list[str]: + values: list[str] = [] + for raw_value in raw_values: + normalized = str(raw_value or "").strip() + if not normalized: + continue + values.append(EXPENSE_STATUS_ALIASES.get(normalized, normalized)) + + compact_message = str(message or "").replace(" ", "") + for keywords, statuses in EXPENSE_QUERY_STATUS_KEYWORDS: + if any(keyword in compact_message for keyword in keywords): + values.extend(statuses) + + return [ + status + for status in dict.fromkeys(values) + if status in EXPENSE_STATUS_LABELS + ] + + @staticmethod + def _compose_expense_scope_label(scope_label: str, status_values: list[str]) -> str: + normalized_scope = str(scope_label or "").strip() or "报销单" + if not status_values: + return normalized_scope + + status_labels = [ + EXPENSE_STATUS_LABELS.get(status, status) + for status in status_values + if status in EXPENSE_STATUS_LABELS + ] + if not status_labels: + return normalized_scope + + status_text = "或".join(dict.fromkeys(status_labels)) + if "报销单" in normalized_scope: + return normalized_scope.replace("报销单", f"{status_text}报销单") + return f"{normalized_scope}({status_text})" @staticmethod def _collect_expense_query_filter_values( diff --git a/server/src/app/services/user_agent_response.py b/server/src/app/services/user_agent_response.py index 7381fc0..ff9a0b6 100644 --- a/server/src/app/services/user_agent_response.py +++ b/server/src/app/services/user_agent_response.py @@ -365,25 +365,13 @@ class UserAgentResponseMixin: ) return f"{window_prefix}没有查到{query_payload.scope_label}。你可以补充时间范围、单号或状态继续筛选。" - group_lines = [ - f"{item.label} {item.count} 笔" - for item in query_payload.status_groups - if item.count > 0 - ] answer_parts = [ - f"我先为你列出{window_prefix}的{query_payload.scope_label}," - f"共 {query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f} 元。" + f"已按你的筛选条件查询{query_payload.scope_label}。", + f"下面先列出最近 {query_payload.preview_count} 条记录,点击任一单据即可查看详情。", + f"本次共命中 {query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f} 元。", ] - if group_lines: - answer_parts.append(f"其中包括:{'、'.join(group_lines)}。") hint_parts: list[str] = [] - if query_payload.has_more_in_window and query_payload.preview_count < query_payload.record_count: - hint_parts.append( - f"下方先展示最近 {query_payload.preview_count} 笔,你可以直接点击单据查看详情。" - ) - elif query_payload.records: - hint_parts.append("下方已列出本次命中的真实单据,可直接点击查看详情。") if query_payload.older_record_count > 0 and query_payload.window_days: hint_parts.append( @@ -448,6 +436,11 @@ class UserAgentResponseMixin: occurred_at=str(item.get("occurred_at") or "").strip(), reason=str(item.get("reason") or "").strip(), location=str(item.get("location") or "").strip(), + risk_flags=[ + flag + for flag in list(item.get("risk_flags") or []) + if isinstance(flag, dict) + ], ) ) @@ -466,6 +459,7 @@ class UserAgentResponseMixin: return UserAgentQueryPayload( result_type="expense_claim_list", scope_label=str(payload.tool_payload.get("scope_label") or self._resolve_subject(payload)).strip() or "报销单", + title=str(payload.tool_payload.get("title") or "").strip(), recent_window_applied=bool(payload.tool_payload.get("recent_window_applied")), window_days=( int(payload.tool_payload["window_days"]) @@ -480,6 +474,7 @@ class UserAgentResponseMixin: ), record_count=max(0, int(payload.tool_payload.get("record_count") or 0)), preview_count=max(0, int(payload.tool_payload.get("preview_count") or len(records))), + preview_limit=max(1, int(payload.tool_payload.get("preview_limit") or 5)), older_record_count=max(0, int(payload.tool_payload.get("older_record_count") or 0)), has_more_in_window=bool(payload.tool_payload.get("has_more_in_window") or payload.tool_payload.get("has_more")), total_amount=round(float(payload.tool_payload.get("total_amount") or 0), 2), @@ -670,18 +665,7 @@ class UserAgentResponseMixin: ] if payload.ontology.intent in {"query", "compare"}: - return [ - UserAgentSuggestedAction( - label="查看明细", - action_type="open_detail", - description="继续查看命中记录和过滤条件。", - ), - UserAgentSuggestedAction( - label="生成处理意见", - action_type="create_draft", - description="把当前查询结果整理成可确认草稿。", - ), - ] + return [] if payload.ontology.intent == "risk_check": return [ diff --git a/server/src/app/services/user_agent_review_core.py b/server/src/app/services/user_agent_review_core.py index ee71b66..f7019f9 100644 --- a/server/src/app/services/user_agent_review_core.py +++ b/server/src/app/services/user_agent_review_core.py @@ -322,6 +322,7 @@ class UserAgentReviewCoreMixin: avg_score=float(item.get("avg_score") or 0.0), preview_kind=str(item.get("preview_kind") or "").strip(), preview_data_url=str(item.get("preview_data_url") or "").strip(), + preview_url=str(item.get("preview_url") or "").strip(), warnings=[str(warning) for warning in item.get("warnings", []) if str(warning).strip()], fields=[ UserAgentReviewDocumentField( @@ -411,16 +412,26 @@ class UserAgentReviewCoreMixin: ) -> list[UserAgentReviewRiskBrief]: briefs: list[UserAgentReviewRiskBrief] = [] for reason in self._resolve_submission_blocked_reasons(payload): + needs_exception_explanation = self._is_submission_exception_explanation_reason(reason) briefs.append( UserAgentReviewRiskBrief( title="提交风险提示", level=self._resolve_submission_blocked_risk_level(reason), content=reason, detail=( - "该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明," - "否则审批人无法判断成本归属、业务真实性或票据有效性。" + "该项不是票据归集阻断条件,系统会保留费用明细并在详情中标记高风险;" + "继续提交前需要补充特殊情况说明,便于审批人判断例外原因。" + if needs_exception_explanation + else ( + "该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明," + "否则审批人无法判断成本归属、业务真实性或票据有效性。" + ) + ), + suggestion=( + "请在附加说明中写清超标或例外原因;确认业务真实后可继续提交给审批人重点复核。" + if needs_exception_explanation + else "按提示补齐对应信息;如果业务场景本身合理,请补充说明或佐证附件后再提交。" ), - suggestion="按提示补齐对应信息;如果业务场景本身合理,请补充说明或佐证附件后再提交。", ) ) @@ -514,6 +525,16 @@ class UserAgentReviewCoreMixin: return "high" if any(keyword in normalized for keyword in amount_keywords) else "warning" + @staticmethod + def _is_submission_exception_explanation_reason(reason: str) -> bool: + normalized = re.sub(r"\s+", "", str(reason or "")) + if not normalized: + return False + has_over_standard = any(keyword in normalized for keyword in ("超标", "超出", "超标准", "差标")) + has_explanation = any(keyword in normalized for keyword in ("说明", "原因", "例外", "特殊")) + return has_over_standard and has_explanation + + @staticmethod def _filter_deprecated_review_risk_briefs( briefs: list[UserAgentReviewRiskBrief], diff --git a/server/src/app/services/user_agent_review_messages.py b/server/src/app/services/user_agent_review_messages.py index 0f5bb80..13ed700 100644 --- a/server/src/app/services/user_agent_review_messages.py +++ b/server/src/app/services/user_agent_review_messages.py @@ -183,9 +183,9 @@ class UserAgentReviewMessageMixin: if draft_payload is not None and draft_payload.claim_no: return ( f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}。" - "后续您可以继续补充缺失项,或修改识别结果后再继续提交。" + "后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。" ) - return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。" + return "已按您当前确认的信息保存为草稿。后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。" if review_action == "link_to_existing_draft": document_count = self._resolve_review_document_count(payload) followup_copy = self._build_review_action_followup_copy(review_payload) @@ -214,6 +214,12 @@ class UserAgentReviewMessageMixin: reason_lines = "\n".join( f"{index}. {reason}" for index, reason in enumerate(reasons, start=1) ) + if all(self._is_submission_exception_explanation_reason(reason) for reason in reasons): + return ( + "检测到当前单据存在需要说明的超标风险,但票据和费用明细会继续保留在单据中。\n" + f"{reason_lines}\n" + "如果确有特殊情况,请先在附加说明中补充原因;补充后可以继续提交给审批人重点复核。" + ) return ( "AI预审暂未通过,所以还没有提交到审批人。\n" f"{reason_lines}\n" @@ -253,6 +259,12 @@ class UserAgentReviewMessageMixin: blocked_reasons = self._resolve_submission_blocked_reasons(payload) if blocked_reasons: reason_text = ";".join(dict.fromkeys(reason.strip("。;;") for reason in blocked_reasons if reason)) + if all(self._is_submission_exception_explanation_reason(reason) for reason in blocked_reasons): + return ( + f"检测到当前单据存在需要说明的超标风险:{reason_text}。" + "票据会先正常归集到单据中,并在费用明细前标记风险;" + "如确有特殊情况,请在附加说明中补充原因后继续提交审批。" + ) return ( f"AI预审未通过:{reason_text}。" "请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。" @@ -670,4 +682,3 @@ class UserAgentReviewMessageMixin: if not claim_groups: return False return True - diff --git a/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.pdf b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.pdf differ diff --git a/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.pdf.meta.json b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.pdf.meta.json new file mode 100644 index 0000000..fbfb425 --- /dev/null +++ b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.pdf.meta.json @@ -0,0 +1,88 @@ +{ + "file_name": "2月20_武汉-上海.pdf", + "storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-05-22T05:00:39.043901+00:00", + "previewable": true, + "preview_kind": "image", + "preview_storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.preview.png", + "preview_media_type": "image/png", + "preview_file_name": "2月20_武汉-上海.preview.png", + "analysis": { + "severity": "pass", + "label": "AI提示符合条件", + "headline": "AI提示:附件符合基础校验条件", + "summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。", + "points": [ + "票据类型:已识别为火车/高铁票。", + "附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。", + "金额字段:已识别到与当前明细接近的金额 354.00 元。" + ], + "rule_basis": [], + "suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。" + }, + "document_info": { + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + } + ] + }, + "requirement_check": { + "matches": true, + "current_expense_type": "train_ticket", + "current_expense_type_label": "火车票", + "allowed_scene_labels": [], + "allowed_document_type_labels": [], + "recognized_scene_code": "travel", + "recognized_scene_label": "差旅票据", + "recognized_document_type": "train_ticket", + "recognized_document_type_label": "火车/高铁票", + "mismatch_severity": "high", + "rule_code": "rule.expense.scene_submission_standard", + "rule_name": "报销场景提交与附件标准", + "message": "当前费用项目为火车票,已识别为火车/高铁票。" + }, + "ocr_status": "recognized", + "ocr_error": "", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "ocr_summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "ocr_warnings": [] +} \ No newline at end of file diff --git a/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.preview.png b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/0df374d2-0eeb-4c54-be94-53589dbf0d65/2月20_武汉-上海.preview.png differ diff --git a/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.jpg b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.jpg new file mode 100644 index 0000000..a625452 Binary files /dev/null and b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.jpg differ diff --git a/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.jpg.meta.json b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.jpg.meta.json new file mode 100644 index 0000000..ab3d1f0 --- /dev/null +++ b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.jpg.meta.json @@ -0,0 +1,79 @@ +{ + "file_name": "酒店3.jpg", + "storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.jpg", + "media_type": "image/jpeg", + "size_bytes": 153582, + "uploaded_at": "2026-05-22T06:05:17.947049+00:00", + "previewable": true, + "preview_kind": "image", + "preview_storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.preview.jpg", + "preview_media_type": "image/jpeg", + "preview_file_name": "酒店3.preview.jpg", + "analysis": { + "severity": "pass", + "label": "AI提示符合条件", + "headline": "AI提示:附件符合基础校验条件", + "summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。", + "points": [ + "票据类型:已识别为酒店住宿票据。", + "附件类型要求:当前费用项目为住宿票,已识别为酒店住宿票据。", + "金额字段:已识别到与当前明细接近的金额 1086.00 元。" + ], + "rule_basis": [ + "依据《公司差旅费报销规则》(v1.0.17),住宿费按员工职级、出差城市和每晚金额进行差标核算。" + ], + "suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。" + }, + "document_info": { + "document_type": "hotel_invoice", + "document_type_label": "酒店住宿票据", + "scene_code": "hotel", + "scene_label": "住宿票据", + "fields": [ + { + "key": "amount", + "label": "金额", + "value": "1086元" + }, + { + "key": "date", + "label": "日期", + "value": "2026-02-20" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "上海喜来登酒店" + } + ] + }, + "requirement_check": { + "matches": true, + "current_expense_type": "hotel_ticket", + "current_expense_type_label": "住宿票", + "allowed_scene_labels": [], + "allowed_document_type_labels": [], + "recognized_scene_code": "hotel", + "recognized_scene_label": "住宿票据", + "recognized_document_type": "hotel_invoice", + "recognized_document_type_label": "酒店住宿票据", + "mismatch_severity": "high", + "rule_code": "rule.expense.scene_submission_standard", + "rule_name": "报销场景提交与附件标准", + "message": "当前费用项目为住宿票,已识别为酒店住宿票据。" + }, + "ocr_status": "recognized", + "ocr_error": "", + "ocr_text": "上海喜来登酒店(样例)\n住宿费用单\n单据编号:SH-SAMPLE-20260223-001\n开单期:2026年223\n宾客姓名:曹笑\n住期:2026年220\n离店期:2026年223\n住晚数:3晚\n房型:豪华床房\n房号:1808\n项目\n日期\n数量\n单价\n金额\n备注\n住宿费\n2026-02-20至2026-02-22\n3晚\n¥362/晚\n¥1086\n豪华大床房\n金额大写:壹仟零捌拾陆元整\n合计:¥1086\n备注:\n1.如有疑问,请致电前台:021-28958888。\n2.退房时间为中午12:00,超时退房将按酒店规定收取相关费用。\n3.感谢您的下榻,期待您的再次光临!\n酒店地址:上海市浦东新区银城中路88号 邮编:200120\n样例票据|仅供系统测试|无效凭证", + "ocr_summary": "上海喜来登酒店(样例);住宿费用单;单据编号:SH-SAMPLE-20260223-001", + "ocr_avg_score": 0.988790222009023, + "ocr_line_count": 30, + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.71, + "ocr_classification_evidence": [ + "住宿", + "离店", + "酒店" + ], + "ocr_warnings": [] +} \ No newline at end of file diff --git a/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.preview.jpg b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.preview.jpg new file mode 100644 index 0000000..a625452 Binary files /dev/null and b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/12aab9cc-fa31-4cde-8935-16ef24fb94a4/酒店3.preview.jpg differ diff --git a/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.pdf b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.pdf new file mode 100644 index 0000000..d516ecb Binary files /dev/null and b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.pdf differ diff --git a/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.pdf.meta.json b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.pdf.meta.json new file mode 100644 index 0000000..ecceb77 --- /dev/null +++ b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.pdf.meta.json @@ -0,0 +1,88 @@ +{ + "file_name": "2月23_上海-武汉.pdf", + "storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.pdf", + "media_type": "application/pdf", + "size_bytes": 24940, + "uploaded_at": "2026-05-22T05:01:02.605504+00:00", + "previewable": true, + "preview_kind": "image", + "preview_storage_key": "635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.preview.png", + "preview_media_type": "image/png", + "preview_file_name": "2月23_上海-武汉.preview.png", + "analysis": { + "severity": "pass", + "label": "AI提示符合条件", + "headline": "AI提示:附件符合基础校验条件", + "summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。", + "points": [ + "票据类型:已识别为火车/高铁票。", + "附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。", + "金额字段:已识别到与当前明细接近的金额 354.00 元。" + ], + "rule_basis": [], + "suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。" + }, + "document_info": { + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-23 13:54" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26319166100006175398" + }, + { + "key": "route", + "label": "行程", + "value": "上海-武汉" + } + ] + }, + "requirement_check": { + "matches": true, + "current_expense_type": "train_ticket", + "current_expense_type_label": "火车票", + "allowed_scene_labels": [], + "allowed_document_type_labels": [], + "recognized_scene_code": "travel", + "recognized_scene_label": "差旅票据", + "recognized_document_type": "train_ticket", + "recognized_document_type_label": "火车/高铁票", + "mismatch_severity": "high", + "rule_code": "rule.expense.scene_submission_standard", + "rule_name": "报销场景提交与附件标准", + "message": "当前费用项目为火车票,已识别为火车/高铁票。" + }, + "ocr_status": "recognized", + "ocr_error": "", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "ocr_summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9620026834309101, + "ocr_line_count": 24, + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "ocr_warnings": [] +} \ No newline at end of file diff --git a/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.preview.png b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.preview.png new file mode 100644 index 0000000..099413e Binary files /dev/null and b/server/storage/expense_claims/635e6e0f-d4ae-4913-a046-c2340643ea1c/2014d004-4ef9-4655-8ef0-8f3ad64df2f7/2月23_上海-武汉.preview.png differ diff --git a/server/storage/knowledge/.index.json b/server/storage/knowledge/.index.json index 9fce3d0..d95d3d7 100644 --- a/server/storage/knowledge/.index.json +++ b/server/storage/knowledge/.index.json @@ -14,8 +14,8 @@ "updated_at": "2026-05-17T09:28:28.999515+00:00", "uploaded_by": "admin", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00", + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:04:12.388160+00:00", "ingest_completed_at": "2026-05-17T10:01:33.272539+00:00", "ingest_document_name": "远光《公司支出管理办法(2024)》.pdf", "ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00", @@ -23,25 +23,739 @@ "ingest_agent_run_id": "run_3a0b0ecb941b4c8e" }, { - "id": "a8f8465df08e455ebe133351721d49f8", - "folder": "报销制度", - "original_name": "无单需求文档0506.docx", - "stored_name": "a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx", + "id": "c7601043d9944ef2bcf4d3f67ed253f7", + "folder": "财务知识库", + "original_name": "远光软件会计科目使用说明.xlsx", + "stored_name": "远光软件会计科目使用说明.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "extension": "xlsx", + "size_bytes": 6336, + "sha256": "", + "created_at": "2026-05-22T07:00:22.328877+00:00", + "updated_at": "2026-05-22T07:00:22.328877+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.851719+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "b0277cd76034437997fbf5219662725a", + "folder": "财务知识库", + "original_name": "远光软件财务基础知识手册.docx", + "stored_name": "远光软件财务基础知识手册.docx", "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "extension": "docx", - "size_bytes": 454307, - "sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9", - "created_at": "2026-05-17T13:00:09.485818+00:00", - "updated_at": "2026-05-17T13:00:09.485818+00:00", - "uploaded_by": "admin", + "size_bytes": 36653, + "sha256": "", + "created_at": "2026-05-22T07:00:22.011016+00:00", + "updated_at": "2026-05-22T07:00:22.011016+00:00", + "uploaded_by": "系统导入", "version_number": 1, - "ingest_status": 3, - "ingest_status_updated_at": "2026-05-21T15:56:58.286585+00:00", - "ingest_completed_at": "2026-05-21T15:56:58.286585+00:00", - "ingest_document_name": "无单需求文档0506.docx", - "ingest_document_updated_at": "2026-05-17T13:00:09.485818+00:00", - "ingest_document_sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9", - "ingest_agent_run_id": "run_9f4f60cf545c470f" + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.861469+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "23f56f159a3e4bc3b2338056544120dd", + "folder": "财务知识库", + "original_name": "远光软件财务术语解释手册.docx", + "stored_name": "远光软件财务术语解释手册.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36490, + "sha256": "", + "created_at": "2026-05-22T07:00:22.352133+00:00", + "updated_at": "2026-05-22T07:00:22.352133+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.870777+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "09fbcae74d3b41e498a47e05b45262cb", + "folder": "财务知识库", + "original_name": "远光软件高新技术企业税收优惠政策汇总.pdf", + "stored_name": "远光软件高新技术企业税收优惠政策汇总.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 37682, + "sha256": "", + "created_at": "2026-05-22T07:00:22.304623+00:00", + "updated_at": "2026-05-22T07:00:22.304623+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.879239+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "5fb3c63fbfe244a280cf3316a20150cd", + "folder": "制度政策", + "original_name": "远光软件公司内部控制基本规范.pdf", + "stored_name": "远光软件公司内部控制基本规范.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 49972, + "sha256": "", + "created_at": "2026-05-22T07:00:18.153373+00:00", + "updated_at": "2026-05-22T07:00:18.153373+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.893729+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "f4ae48231a974240bbaf6c9f3bfd4160", + "folder": "制度政策", + "original_name": "远光软件公司合同管理制度.docx", + "stored_name": "远光软件公司合同管理制度.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36764, + "sha256": "", + "created_at": "2026-05-22T07:00:18.190399+00:00", + "updated_at": "2026-05-22T07:00:18.190399+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.902022+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "b1d08d6a9dc6404aba9098f3b7287353", + "folder": "制度政策", + "original_name": "远光软件公司财务管理制度总则.docx", + "stored_name": "远光软件公司财务管理制度总则.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36865, + "sha256": "", + "created_at": "2026-05-22T07:00:17.798679+00:00", + "updated_at": "2026-05-22T07:00:17.798679+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.907591+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "c87fc4aabe524c6c81862c20aabe434c", + "folder": "制度政策", + "original_name": "远光软件公司资产管理制度.pdf", + "stored_name": "远光软件公司资产管理制度.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 44978, + "sha256": "", + "created_at": "2026-05-22T07:00:18.531598+00:00", + "updated_at": "2026-05-22T07:00:18.531598+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.913293+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "13181df0179a4bacb12a2f65e3772d9b", + "folder": "制度政策", + "original_name": "远光软件公司采购管理办法.xlsx", + "stored_name": "远光软件公司采购管理办法.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "extension": "xlsx", + "size_bytes": 7011, + "sha256": "", + "created_at": "2026-05-22T07:00:18.221073+00:00", + "updated_at": "2026-05-22T07:00:18.221073+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.918790+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "396588b0cdd04c86a61ae0b9bd04e06c", + "folder": "差旅规范", + "original_name": "远光软件公司差旅费管理办法.docx", + "stored_name": "远光软件公司差旅费管理办法.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 37028, + "sha256": "", + "created_at": "2026-05-22T07:00:19.734422+00:00", + "updated_at": "2026-05-22T07:00:19.734422+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.933936+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "fe5f834f94244b77bb62171d580ecee3", + "folder": "差旅规范", + "original_name": "远光软件出差审批流程说明.pdf", + "stored_name": "远光软件出差审批流程说明.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 39208, + "sha256": "", + "created_at": "2026-05-22T07:00:20.095824+00:00", + "updated_at": "2026-05-22T07:00:20.095824+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.939406+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "be3fca61e2be421896405082c93cf86c", + "folder": "差旅规范", + "original_name": "远光软件国际出差管理规定.docx", + "stored_name": "远光软件国际出差管理规定.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36502, + "sha256": "", + "created_at": "2026-05-22T07:00:20.128471+00:00", + "updated_at": "2026-05-22T07:00:20.128471+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.945004+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "c4421b3049b244a8a92cc53d502e530f", + "folder": "差旅规范", + "original_name": "远光软件差旅费标准速查表.xlsx", + "stored_name": "远光软件差旅费标准速查表.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "extension": "xlsx", + "size_bytes": 6199, + "sha256": "", + "created_at": "2026-05-22T07:00:19.759954+00:00", + "updated_at": "2026-05-22T07:00:19.759954+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.950298+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "e13cc0a8d6474b6caeeedc49c4304558", + "folder": "发票管理", + "original_name": "远光软件公司发票审核标准.xlsx", + "stored_name": "远光软件公司发票审核标准.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "extension": "xlsx", + "size_bytes": 7235, + "sha256": "", + "created_at": "2026-05-22T07:00:18.922298+00:00", + "updated_at": "2026-05-22T07:00:18.922298+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.958758+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "7170abfdde6f4e6abad2fc987564c2cf", + "folder": "发票管理", + "original_name": "远光软件公司发票管理规范.docx", + "stored_name": "远光软件公司发票管理规范.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36870, + "sha256": "", + "created_at": "2026-05-22T07:00:18.560177+00:00", + "updated_at": "2026-05-22T07:00:18.560177+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.963796+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "dd0d7b32e832446e8ce9caa06c442685", + "folder": "发票管理", + "original_name": "远光软件公司增值税发票操作指南.pdf", + "stored_name": "远光软件公司增值税发票操作指南.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 45772, + "sha256": "", + "created_at": "2026-05-22T07:00:18.888128+00:00", + "updated_at": "2026-05-22T07:00:18.888128+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.968988+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "f268a54ee05e4dfca33fd86bcc077216", + "folder": "发票管理", + "original_name": "远光软件公司电子发票管理办法.docx", + "stored_name": "远光软件公司电子发票管理办法.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36403, + "sha256": "", + "created_at": "2026-05-22T07:00:18.953110+00:00", + "updated_at": "2026-05-22T07:00:18.953110+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.974057+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "f3f74cb65a9a4a16933368218c5e25de", + "folder": "税务合规", + "original_name": "远光软件企业所得税汇算清缴操作手册.pdf", + "stored_name": "远光软件企业所得税汇算清缴操作手册.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 41933, + "sha256": "", + "created_at": "2026-05-22T07:00:21.585718+00:00", + "updated_at": "2026-05-22T07:00:21.585718+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.983136+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "56721ca1904b437486a609b85e3d9362", + "folder": "税务合规", + "original_name": "远光软件公司税务管理制度.docx", + "stored_name": "远光软件公司税务管理制度.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36753, + "sha256": "", + "created_at": "2026-05-22T07:00:20.881351+00:00", + "updated_at": "2026-05-22T07:00:20.881351+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.988449+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "2460661167ef456699ab259321db4156", + "folder": "税务合规", + "original_name": "远光软件研发费用加计扣除管理办法.xlsx", + "stored_name": "远光软件研发费用加计扣除管理办法.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "extension": "xlsx", + "size_bytes": 6027, + "sha256": "", + "created_at": "2026-05-22T07:00:21.606227+00:00", + "updated_at": "2026-05-22T07:00:21.606227+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.993925+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "e30f54ea32704fbd9701cc931b447a06", + "folder": "税务合规", + "original_name": "远光软件软件产品增值税即征即退操作指南.pdf", + "stored_name": "远光软件软件产品增值税即征即退操作指南.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 41919, + "sha256": "", + "created_at": "2026-05-22T07:00:21.202633+00:00", + "updated_at": "2026-05-22T07:00:21.202633+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:57.999215+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "2d1cd10154e84cb38640dce31f33b529", + "folder": "预算管理", + "original_name": "远光软件公司预算管理制度.docx", + "stored_name": "远光软件公司预算管理制度.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36667, + "sha256": "", + "created_at": "2026-05-22T07:00:22.379307+00:00", + "updated_at": "2026-05-22T07:00:22.379307+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.007947+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "229b3a79fef14360ba3cbd0a55e5e20c", + "folder": "预算管理", + "original_name": "远光软件年度预算编制指南.pdf", + "stored_name": "远光软件年度预算编制指南.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 44848, + "sha256": "", + "created_at": "2026-05-22T07:00:22.760169+00:00", + "updated_at": "2026-05-22T07:00:22.760169+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.013550+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "a40da5544dea4efcade070274b84a54e", + "folder": "预算管理", + "original_name": "远光软件预算执行分析报告模板.docx", + "stored_name": "远光软件预算执行分析报告模板.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36502, + "sha256": "", + "created_at": "2026-05-22T07:00:22.848272+00:00", + "updated_at": "2026-05-22T07:00:22.848272+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.019078+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "dcd982e40ce94105824e59ecbbae75cb", + "folder": "预算管理", + "original_name": "远光软件预算编制模板.xlsx", + "stored_name": "远光软件预算编制模板.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "extension": "xlsx", + "size_bytes": 7819, + "sha256": "", + "created_at": "2026-05-22T07:00:22.803708+00:00", + "updated_at": "2026-05-22T07:00:22.803708+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.024507+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "79cb9276398b4216ba17d5623aadf75f", + "folder": "财务共享", + "original_name": "远光软件财务共享服务SLA标准.xlsx", + "stored_name": "远光软件财务共享服务SLA标准.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "extension": "xlsx", + "size_bytes": 6007, + "sha256": "", + "created_at": "2026-05-22T07:00:21.971983+00:00", + "updated_at": "2026-05-22T07:00:21.971983+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.037116+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "f841ca416b5d404994a7c4a310e35569", + "folder": "财务共享", + "original_name": "远光软件财务共享服务中心运营管理办法.docx", + "stored_name": "远光软件财务共享服务中心运营管理办法.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36572, + "sha256": "", + "created_at": "2026-05-22T07:00:21.634300+00:00", + "updated_at": "2026-05-22T07:00:21.634300+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.045292+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "d1ad784de58a4c4a802a0b9fbce29f62", + "folder": "财务共享", + "original_name": "远光软件财务共享服务操作手册.pdf", + "stored_name": "远光软件财务共享服务操作手册.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 38186, + "sha256": "", + "created_at": "2026-05-22T07:00:21.945868+00:00", + "updated_at": "2026-05-22T07:00:21.945868+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.053890+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "ce50d015861f4633a634a2eae416fa2e", + "folder": "培训资料", + "original_name": "远光软件报销流程培训手册.pdf", + "stored_name": "远光软件报销流程培训手册.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 47218, + "sha256": "", + "created_at": "2026-05-22T07:00:19.662743+00:00", + "updated_at": "2026-05-22T07:00:19.662743+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.066031+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "56a0e13b705e49468d46629f3b5f691a", + "folder": "培训资料", + "original_name": "远光软件新员工财务培训课件.pdf", + "stored_name": "远光软件新员工财务培训课件.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 50347, + "sha256": "", + "created_at": "2026-05-22T07:00:19.323921+00:00", + "updated_at": "2026-05-22T07:00:19.323921+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.073977+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "22ef5d13bb5e4307a8097628eaa3d398", + "folder": "培训资料", + "original_name": "远光软件财务制度培训手册.docx", + "stored_name": "远光软件财务制度培训手册.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36802, + "sha256": "", + "created_at": "2026-05-22T07:00:18.988700+00:00", + "updated_at": "2026-05-22T07:00:18.988700+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.082287+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "78d1a28f1c934f46b762fb1466d4be32", + "folder": "培训资料", + "original_name": "远光软件财务培训课程安排.xlsx", + "stored_name": "远光软件财务培训课程安排.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "extension": "xlsx", + "size_bytes": 6187, + "sha256": "", + "created_at": "2026-05-22T07:00:19.686485+00:00", + "updated_at": "2026-05-22T07:00:19.686485+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.089670+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "91fbf156593a4dcc956780962195ffd7", + "folder": "常见问答", + "original_name": "远光软件报销问题处理指引.xlsx", + "stored_name": "远光软件报销问题处理指引.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "extension": "xlsx", + "size_bytes": 6096, + "sha256": "", + "created_at": "2026-05-22T07:00:20.476077+00:00", + "updated_at": "2026-05-22T07:00:20.476077+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.101732+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "a24793b7f7de4749a7c531d1713a4a2b", + "folder": "常见问答", + "original_name": "远光软件财务制度问答汇总.pdf", + "stored_name": "远光软件财务制度问答汇总.pdf", + "mime_type": "application/pdf", + "extension": "pdf", + "size_bytes": 47165, + "sha256": "", + "created_at": "2026-05-22T07:00:20.453567+00:00", + "updated_at": "2026-05-22T07:00:20.453567+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.109771+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" + }, + { + "id": "3acd9c2df63b4a438c7eab876269b25d", + "folder": "常见问答", + "original_name": "远光软件财务报销常见问题解答.docx", + "stored_name": "远光软件财务报销常见问题解答.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "extension": "docx", + "size_bytes": 36671, + "sha256": "", + "created_at": "2026-05-22T07:00:20.158497+00:00", + "updated_at": "2026-05-22T07:00:20.158497+00:00", + "uploaded_by": "系统导入", + "version_number": 1, + "ingest_status": 1, + "ingest_status_updated_at": "2026-05-22T07:03:58.117797+00:00", + "ingest_completed_at": "", + "ingest_document_name": "", + "ingest_document_updated_at": "", + "ingest_document_sha256": "", + "ingest_agent_run_id": "" } ] } \ No newline at end of file diff --git a/server/storage/knowledge/制度政策/远光软件公司内部控制基本规范.pdf b/server/storage/knowledge/制度政策/远光软件公司内部控制基本规范.pdf new file mode 100644 index 0000000..10be215 Binary files /dev/null and b/server/storage/knowledge/制度政策/远光软件公司内部控制基本规范.pdf differ diff --git a/server/storage/knowledge/制度政策/远光软件公司合同管理制度.docx b/server/storage/knowledge/制度政策/远光软件公司合同管理制度.docx new file mode 100644 index 0000000..6594702 Binary files /dev/null and b/server/storage/knowledge/制度政策/远光软件公司合同管理制度.docx differ diff --git a/server/storage/knowledge/制度政策/远光软件公司财务管理制度总则.docx b/server/storage/knowledge/制度政策/远光软件公司财务管理制度总则.docx new file mode 100644 index 0000000..ab545dc Binary files /dev/null and b/server/storage/knowledge/制度政策/远光软件公司财务管理制度总则.docx differ diff --git a/server/storage/knowledge/制度政策/远光软件公司资产管理制度.pdf b/server/storage/knowledge/制度政策/远光软件公司资产管理制度.pdf new file mode 100644 index 0000000..d5ddb47 Binary files /dev/null and b/server/storage/knowledge/制度政策/远光软件公司资产管理制度.pdf differ diff --git a/server/storage/knowledge/制度政策/远光软件公司采购管理办法.xlsx b/server/storage/knowledge/制度政策/远光软件公司采购管理办法.xlsx new file mode 100644 index 0000000..0667cdf Binary files /dev/null and b/server/storage/knowledge/制度政策/远光软件公司采购管理办法.xlsx differ diff --git a/server/storage/knowledge/发票管理/远光软件公司发票审核标准.xlsx b/server/storage/knowledge/发票管理/远光软件公司发票审核标准.xlsx new file mode 100644 index 0000000..2e8c8fd Binary files /dev/null and b/server/storage/knowledge/发票管理/远光软件公司发票审核标准.xlsx differ diff --git a/server/storage/knowledge/发票管理/远光软件公司发票管理规范.docx b/server/storage/knowledge/发票管理/远光软件公司发票管理规范.docx new file mode 100644 index 0000000..1265bc2 Binary files /dev/null and b/server/storage/knowledge/发票管理/远光软件公司发票管理规范.docx differ diff --git a/server/storage/knowledge/发票管理/远光软件公司增值税发票操作指南.pdf b/server/storage/knowledge/发票管理/远光软件公司增值税发票操作指南.pdf new file mode 100644 index 0000000..5b12fe4 Binary files /dev/null and b/server/storage/knowledge/发票管理/远光软件公司增值税发票操作指南.pdf differ diff --git a/server/storage/knowledge/发票管理/远光软件公司电子发票管理办法.docx b/server/storage/knowledge/发票管理/远光软件公司电子发票管理办法.docx new file mode 100644 index 0000000..7e883bf Binary files /dev/null and b/server/storage/knowledge/发票管理/远光软件公司电子发票管理办法.docx differ diff --git a/server/storage/knowledge/培训资料/远光软件报销流程培训手册.pdf b/server/storage/knowledge/培训资料/远光软件报销流程培训手册.pdf new file mode 100644 index 0000000..95536ff Binary files /dev/null and b/server/storage/knowledge/培训资料/远光软件报销流程培训手册.pdf differ diff --git a/server/storage/knowledge/培训资料/远光软件新员工财务培训课件.pdf b/server/storage/knowledge/培训资料/远光软件新员工财务培训课件.pdf new file mode 100644 index 0000000..6d443f1 Binary files /dev/null and b/server/storage/knowledge/培训资料/远光软件新员工财务培训课件.pdf differ diff --git a/server/storage/knowledge/培训资料/远光软件财务制度培训手册.docx b/server/storage/knowledge/培训资料/远光软件财务制度培训手册.docx new file mode 100644 index 0000000..cd840a5 Binary files /dev/null and b/server/storage/knowledge/培训资料/远光软件财务制度培训手册.docx differ diff --git a/server/storage/knowledge/培训资料/远光软件财务培训课程安排.xlsx b/server/storage/knowledge/培训资料/远光软件财务培训课程安排.xlsx new file mode 100644 index 0000000..2eceff6 Binary files /dev/null and b/server/storage/knowledge/培训资料/远光软件财务培训课程安排.xlsx differ diff --git a/server/storage/knowledge/差旅规范/远光软件公司差旅费管理办法.docx b/server/storage/knowledge/差旅规范/远光软件公司差旅费管理办法.docx new file mode 100644 index 0000000..89bf07b Binary files /dev/null and b/server/storage/knowledge/差旅规范/远光软件公司差旅费管理办法.docx differ diff --git a/server/storage/knowledge/差旅规范/远光软件出差审批流程说明.pdf b/server/storage/knowledge/差旅规范/远光软件出差审批流程说明.pdf new file mode 100644 index 0000000..c3ce2a4 Binary files /dev/null and b/server/storage/knowledge/差旅规范/远光软件出差审批流程说明.pdf differ diff --git a/server/storage/knowledge/差旅规范/远光软件国际出差管理规定.docx b/server/storage/knowledge/差旅规范/远光软件国际出差管理规定.docx new file mode 100644 index 0000000..d01589f Binary files /dev/null and b/server/storage/knowledge/差旅规范/远光软件国际出差管理规定.docx differ diff --git a/server/storage/knowledge/差旅规范/远光软件差旅费标准速查表.xlsx b/server/storage/knowledge/差旅规范/远光软件差旅费标准速查表.xlsx new file mode 100644 index 0000000..c6509d1 Binary files /dev/null and b/server/storage/knowledge/差旅规范/远光软件差旅费标准速查表.xlsx differ diff --git a/server/storage/knowledge/常见问答/远光软件报销问题处理指引.xlsx b/server/storage/knowledge/常见问答/远光软件报销问题处理指引.xlsx new file mode 100644 index 0000000..39a5278 Binary files /dev/null and b/server/storage/knowledge/常见问答/远光软件报销问题处理指引.xlsx differ diff --git a/server/storage/knowledge/常见问答/远光软件财务制度问答汇总.pdf b/server/storage/knowledge/常见问答/远光软件财务制度问答汇总.pdf new file mode 100644 index 0000000..6987bd3 Binary files /dev/null and b/server/storage/knowledge/常见问答/远光软件财务制度问答汇总.pdf differ diff --git a/server/storage/knowledge/常见问答/远光软件财务报销常见问题解答.docx b/server/storage/knowledge/常见问答/远光软件财务报销常见问题解答.docx new file mode 100644 index 0000000..b4e5fe7 Binary files /dev/null and b/server/storage/knowledge/常见问答/远光软件财务报销常见问题解答.docx differ diff --git a/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx b/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx deleted file mode 100644 index 5e4b1d6..0000000 Binary files a/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx and /dev/null differ diff --git a/server/storage/knowledge/税务合规/远光软件企业所得税汇算清缴操作手册.pdf b/server/storage/knowledge/税务合规/远光软件企业所得税汇算清缴操作手册.pdf new file mode 100644 index 0000000..5fa4fe4 Binary files /dev/null and b/server/storage/knowledge/税务合规/远光软件企业所得税汇算清缴操作手册.pdf differ diff --git a/server/storage/knowledge/税务合规/远光软件公司税务管理制度.docx b/server/storage/knowledge/税务合规/远光软件公司税务管理制度.docx new file mode 100644 index 0000000..ff0744d Binary files /dev/null and b/server/storage/knowledge/税务合规/远光软件公司税务管理制度.docx differ diff --git a/server/storage/knowledge/税务合规/远光软件研发费用加计扣除管理办法.xlsx b/server/storage/knowledge/税务合规/远光软件研发费用加计扣除管理办法.xlsx new file mode 100644 index 0000000..f619faf Binary files /dev/null and b/server/storage/knowledge/税务合规/远光软件研发费用加计扣除管理办法.xlsx differ diff --git a/server/storage/knowledge/税务合规/远光软件软件产品增值税即征即退操作指南.pdf b/server/storage/knowledge/税务合规/远光软件软件产品增值税即征即退操作指南.pdf new file mode 100644 index 0000000..bb84adb Binary files /dev/null and b/server/storage/knowledge/税务合规/远光软件软件产品增值税即征即退操作指南.pdf differ diff --git a/server/storage/knowledge/财务共享/远光软件财务共享服务SLA标准.xlsx b/server/storage/knowledge/财务共享/远光软件财务共享服务SLA标准.xlsx new file mode 100644 index 0000000..9bc5e81 Binary files /dev/null and b/server/storage/knowledge/财务共享/远光软件财务共享服务SLA标准.xlsx differ diff --git a/server/storage/knowledge/财务共享/远光软件财务共享服务中心运营管理办法.docx b/server/storage/knowledge/财务共享/远光软件财务共享服务中心运营管理办法.docx new file mode 100644 index 0000000..6b23e1d Binary files /dev/null and b/server/storage/knowledge/财务共享/远光软件财务共享服务中心运营管理办法.docx differ diff --git a/server/storage/knowledge/财务共享/远光软件财务共享服务操作手册.pdf b/server/storage/knowledge/财务共享/远光软件财务共享服务操作手册.pdf new file mode 100644 index 0000000..d54543c Binary files /dev/null and b/server/storage/knowledge/财务共享/远光软件财务共享服务操作手册.pdf differ diff --git a/server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx b/server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx new file mode 100644 index 0000000..7385952 Binary files /dev/null and b/server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx differ diff --git a/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx b/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx new file mode 100644 index 0000000..2f4f75b Binary files /dev/null and b/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx differ diff --git a/server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx b/server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx new file mode 100644 index 0000000..ad5ecac Binary files /dev/null and b/server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx differ diff --git a/server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf b/server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf new file mode 100644 index 0000000..e8fad30 Binary files /dev/null and b/server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf differ diff --git a/server/storage/knowledge/预算管理/远光软件公司预算管理制度.docx b/server/storage/knowledge/预算管理/远光软件公司预算管理制度.docx new file mode 100644 index 0000000..bbd837e Binary files /dev/null and b/server/storage/knowledge/预算管理/远光软件公司预算管理制度.docx differ diff --git a/server/storage/knowledge/预算管理/远光软件年度预算编制指南.pdf b/server/storage/knowledge/预算管理/远光软件年度预算编制指南.pdf new file mode 100644 index 0000000..f1e6ae1 Binary files /dev/null and b/server/storage/knowledge/预算管理/远光软件年度预算编制指南.pdf differ diff --git a/server/storage/knowledge/预算管理/远光软件预算执行分析报告模板.docx b/server/storage/knowledge/预算管理/远光软件预算执行分析报告模板.docx new file mode 100644 index 0000000..b55989c Binary files /dev/null and b/server/storage/knowledge/预算管理/远光软件预算执行分析报告模板.docx differ diff --git a/server/storage/knowledge/预算管理/远光软件预算编制模板.xlsx b/server/storage/knowledge/预算管理/远光软件预算编制模板.xlsx new file mode 100644 index 0000000..09611c4 Binary files /dev/null and b/server/storage/knowledge/预算管理/远光软件预算编制模板.xlsx differ diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index dd32022..d705825 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -1516,8 +1516,21 @@ def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tm analysis = uploaded_meta["analysis"] assert analysis["severity"] == "high" assert analysis["headline"] == "AI提示:住宿金额超出报销标准" + assert "保留在单据中" in analysis["summary"] + assert "特殊情况" in analysis["summary"] assert any("住宿标准" in point and "800.00 元" in point for point in analysis["points"]) assert any("住宿费按员工职级" in basis for basis in analysis["rule_basis"]) + db.refresh(claim) + hotel_item = next(item for item in claim.items if str(item.invoice_id or "").strip()) + assert hotel_item.item_amount == Decimal("800.00") + assert claim.invoice_count == 1 + assert any( + isinstance(flag, dict) + and str(flag.get("source") or "").strip() == "attachment_analysis" + and flag.get("item_id") == hotel_item.id + and str(flag.get("severity") or "").strip() == "high" + for flag in list(claim.risk_flags_json or []) + ) def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None: @@ -2433,8 +2446,8 @@ def test_list_claims_allows_finance_to_view_all_records() -> None: claims = ExpenseClaimService(db).list_claims(current_user) - assert len(claims) == 2 - assert {claim.claim_no for claim in claims} == {"EXP-FIN-101", "EXP-FIN-102"} + assert len(claims) == 1 + assert claims[0].claim_no == "EXP-FIN-101" def test_list_claims_allows_executive_to_view_all_records() -> None: @@ -2488,8 +2501,134 @@ def test_list_claims_allows_executive_to_view_all_records() -> None: claims = ExpenseClaimService(db).list_claims(current_user) - assert len(claims) == 2 - assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"} + assert len(claims) == 1 + assert claims[0].claim_no == "EXP-EXE-101" + + +def test_list_claims_keeps_own_archived_claim_for_finance_applicant() -> None: + current_user = CurrentUserContext( + username="finance@example.com", + name="财务", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + db.add( + ExpenseClaim( + claim_no="EXP-FIN-OWN-ARCH", + employee_name="财务", + department_name="财务部", + project_code="PRJ-FIN", + expense_type="meal", + reason="本人报销", + location="上海", + amount=Decimal("88.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC), + status="approved", + approval_stage="归档入账", + risk_flags_json=[], + ) + ) + db.commit() + + claims = ExpenseClaimService(db).list_claims(current_user) + + assert len(claims) == 1 + assert claims[0].claim_no == "EXP-FIN-OWN-ARCH" + + +def test_list_archived_claims_returns_company_archived_records_for_finance() -> None: + current_user = CurrentUserContext( + username="finance@example.com", + name="财务", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + db.add_all( + [ + ExpenseClaim( + claim_no="EXP-ARCH-101", + employee_name="甲", + department_name="A部", + project_code="PRJ-A", + expense_type="travel", + reason="A 报销", + location="上海", + amount=Decimal("120.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC), + status="approved", + approval_stage="归档入账", + risk_flags_json=[], + ), + ExpenseClaim( + claim_no="EXP-ARCH-102", + employee_name="乙", + department_name="B部", + project_code="PRJ-B", + expense_type="meal", + reason="B 报销", + location="杭州", + amount=Decimal("300.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC), + status="submitted", + approval_stage="财务审批", + risk_flags_json=[], + ), + ] + ) + db.commit() + + claims = ExpenseClaimService(db).list_archived_claims(current_user) + + assert len(claims) == 1 + assert claims[0].claim_no == "EXP-ARCH-101" + + +def test_list_archived_claims_is_empty_for_regular_employee() -> None: + current_user = CurrentUserContext( + username="zhangsan@example.com", + name="张三", + role_codes=["employee"], + is_admin=False, + ) + + with build_session() as db: + db.add( + ExpenseClaim( + claim_no="EXP-ARCH-EMP", + employee_name="张三", + department_name="研发部", + project_code="PRJ-EMP", + expense_type="travel", + reason="本人报销", + location="北京", + amount=Decimal("200.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC), + status="approved", + approval_stage="归档入账", + risk_flags_json=[], + ) + ) + db.commit() + + claims = ExpenseClaimService(db).list_archived_claims(current_user) + + assert claims == [] def test_finance_can_return_but_cannot_delete_submitted_claim() -> None: diff --git a/server/tests/test_knowledge_document_extractors.py b/server/tests/test_knowledge_document_extractors.py new file mode 100644 index 0000000..341448e --- /dev/null +++ b/server/tests/test_knowledge_document_extractors.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from zipfile import ZipFile + +from app.services.knowledge_document_extractors import _extract_document_text_from_path + + +def test_extract_xlsx_document_text_builds_markdown_with_row_clues(tmp_path) -> None: + file_path = tmp_path / "company-expense-rules.xlsx" + _write_minimal_xlsx( + file_path, + sheet_name="报销标准", + rows=[ + ["费用类型", "标准", "说明"], + ["住宿费", "500", "超标准需事前审批"], + ["交通费", "据实", "保留发票"], + ], + ) + + text = _extract_document_text_from_path( + file_path=file_path, + original_name="公司支出管理办法.xlsx", + mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + assert "# Excel 工作簿:公司支出管理办法.xlsx" in text + assert "## 工作表 1:报销标准" in text + assert "| 费用类型 | 标准 | 说明 |" in text + assert "费用类型=住宿费;标准=500;说明=超标准需事前审批" in text + assert "费用类型=交通费;标准=据实;说明=保留发票" in text + + +def test_extract_pptx_document_text_builds_markdown_slides(tmp_path) -> None: + file_path = tmp_path / "training.pptx" + slide_xml = """ + + + + 差旅报销培训 + 发票、审批、预算三项要素必须齐全 + + + +""" + with ZipFile(file_path, "w") as archive: + archive.writestr("ppt/slides/slide1.xml", slide_xml) + + text = _extract_document_text_from_path( + file_path=file_path, + original_name="报销培训.pptx", + mime_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", + ) + + assert "# PowerPoint 演示文稿:报销培训.pptx" in text + assert "## 幻灯片 1" in text + assert "- 差旅报销培训" in text + assert "- 发票、审批、预算三项要素必须齐全" in text + + +def _write_minimal_xlsx(file_path, *, sheet_name: str, rows: list[list[str]]) -> None: + workbook_xml = f""" + + + + + +""" + rels_xml = """ + + + +""" + row_xml = "\n".join( + f'' + + "".join( + f'{cell}' + for column_index, cell in enumerate(row) + ) + + "" + for row_index, row in enumerate(rows, start=1) + ) + sheet_xml = f""" + + + {row_xml} + + +""" + with ZipFile(file_path, "w") as archive: + archive.writestr("xl/workbook.xml", workbook_xml) + archive.writestr("xl/_rels/workbook.xml.rels", rels_xml) + archive.writestr("xl/worksheets/sheet1.xml", sheet_xml) diff --git a/server/tests/test_knowledge_service.py b/server/tests/test_knowledge_service.py index fe7b5c5..7141e83 100644 --- a/server/tests/test_knowledge_service.py +++ b/server/tests/test_knowledge_service.py @@ -28,6 +28,15 @@ def build_session() -> Session: return session_factory() +def test_list_library_returns_closed_folder_icons_by_default(tmp_path) -> None: + service = KnowledgeService(storage_root=tmp_path) + + library = service.list_library() + + assert library.folders + assert {folder.icon for folder in library.folders} == {"mdi mdi-folder"} + + def test_reconcile_document_ingest_status_keeps_failed_when_linked_run_failed( tmp_path, monkeypatch, diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index df55553..854231a 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -534,6 +534,28 @@ def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type() ) +def test_semantic_ontology_service_maps_taxi_ticket_reimbursement_to_transport_draft() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="送客户去机场,报销的士票", + user_id="pytest", + ) + ) + + assert result.scenario == "expense" + assert result.intent == "draft" + assert any( + item.type == "expense_type" and item.normalized_value == "transport" + for item in result.entities + ) + assert not any( + item.type == "expense_type" and item.normalized_value == "entertainment" + for item in result.entities + ) + + def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None: session_factory = build_session_factory() with session_factory() as db: diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index 5aefd3e..d8bcc4b 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -228,6 +228,60 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro assert continued_context["review_form_values"]["expense_type"] == "差旅费" +def test_conversation_scope_creates_new_session_for_different_claim() -> None: + session_factory = build_session_factory() + with session_factory() as db: + service = AgentConversationService(db) + old_conversation = service.get_or_create_conversation( + conversation_id="conv-old-claim-scope", + user_id="emp-scope@example.com", + source="user_message", + context_json={ + "session_type": "expense", + "draft_claim_id": "claim-old", + "attachment_names": ["old-hotel.pdf"], + "attachment_count": 1, + "review_form_values": { + "expense_type": "住宿票", + "merchant_name": "旧酒店", + }, + }, + ) + service.append_message( + conversation_id=old_conversation.conversation_id, + role="user", + content="继续补充旧酒店发票", + ) + + scoped_conversation = service.get_or_create_conversation( + conversation_id=old_conversation.conversation_id, + user_id="emp-scope@example.com", + source="user_message", + context_json={ + "session_type": "expense", + "draft_claim_id": "claim-current", + }, + ) + conflict_context = service.hydrate_context_json( + conversation=old_conversation, + context_json={"draft_claim_id": "claim-current"}, + message="继续补充当前单据的火车票", + ) + scoped_context = service.hydrate_context_json( + conversation=scoped_conversation, + context_json={"draft_claim_id": "claim-current"}, + message="继续补充当前单据的火车票", + ) + + db.refresh(old_conversation) + assert scoped_conversation.conversation_id != old_conversation.conversation_id + assert scoped_conversation.draft_claim_id == "claim-current" + assert old_conversation.draft_claim_id == "claim-old" + assert conflict_context == {"draft_claim_id": "claim-current"} + assert scoped_context["draft_claim_id"] == "claim-current" + assert scoped_context["conversation_history"] == [] + + def test_orchestrator_history_query_filters_location_time_and_returns_real_amount( monkeypatch, ) -> None: @@ -322,6 +376,89 @@ def test_orchestrator_history_query_filters_location_time_and_returns_real_amoun assert "321.45" in response.result["answer"] +def test_orchestrator_archive_query_filters_archived_claims_and_limits_preview( + monkeypatch, +) -> None: + monkeypatch.setattr( + "app.services.runtime_chat.RuntimeChatService.complete", + lambda *_args, **_kwargs: None, + ) + session_factory = build_session_factory() + with session_factory() as db: + employee = Employee( + id="emp-archive-query", + employee_no="E9021", + name="归档员工", + email="archive-query@example.com", + ) + claims = [] + for index in range(6): + claims.append( + ExpenseClaim( + id=f"claim-archive-query-{index}", + claim_no=f"EXP-ARCHIVE-{index + 1:03d}", + employee=employee, + employee_id=employee.id, + employee_name="归档员工", + department_name="交付部", + expense_type="travel", + reason=f"归档差旅 {index + 1}", + location="上海", + amount=Decimal("100.00") + Decimal(index), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 2, index + 1, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 2, index + 2, 10, 0, tzinfo=UTC), + status="approved", + approval_stage="归档入账", + ) + ) + draft_claim = ExpenseClaim( + id="claim-archive-query-draft", + claim_no="EXP-ARCHIVE-DRAFT", + employee=employee, + employee_id=employee.id, + employee_name="归档员工", + department_name="交付部", + expense_type="travel", + reason="未归档草稿", + location="上海", + amount=Decimal("999.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 3, 1, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + ) + db.add_all([employee, *claims, draft_claim]) + db.commit() + + response = OrchestratorService(db).run( + OrchestratorRequest( + source="user_message", + user_id="archive-query@example.com", + message="帮我查询一下我的归档的单据有哪些?", + context_json={ + "client_now_iso": "2026-05-21T04:00:00.000Z", + "client_timezone_offset_minutes": -480, + }, + ) + ) + + query_payload = response.result["query_payload"] + assert response.status == "succeeded" + assert response.trace_summary.intent == "query" + assert query_payload["record_count"] == 6 + assert query_payload["preview_count"] == 5 + assert query_payload["preview_limit"] == 5 + assert query_payload["title"] == "最近 5 条你的归档报销单" + assert all(record["status"] == "approved" for record in query_payload["records"]) + assert "EXP-ARCHIVE-DRAFT" not in [record["claim_no"] for record in query_payload["records"]] + assert response.result["suggested_actions"] == [] + assert "下面先列出最近 5 条记录" in response.result["answer"] + + def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action( monkeypatch, ) -> None: diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index 17b6e5c..a6f4b08 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -700,6 +700,37 @@ def test_user_agent_guides_riding_fare_as_transport_expense() -> None: assert "“交通费”" in response.review_payload.intent_summary +def test_user_agent_keeps_taxi_ticket_for_customer_dropoff_as_transport_expense() -> None: + session_factory = build_session_factory() + with session_factory() as db: + message = "送客户去机场,报销的士票" + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=message, + user_id="pytest", + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message=message, + ontology=ontology, + tool_payload={"draft_only": True}, + ) + ) + + assert ontology.intent == "draft" + assert response.review_payload is not None + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["expense_type"].value == "交通费" + assert slot_map["expense_type"].normalized_value == "transport" + assert slot_map["reason"].value == "送客户去机场,报销的士票" + assert "业务招待费" not in response.review_payload.intent_summary + assert "客户名称" not in response.review_payload.missing_slots + assert "参与人员" not in response.review_payload.missing_slots + + def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_context() -> None: session_factory = build_session_factory() with session_factory() as db: @@ -1060,6 +1091,40 @@ def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None: ) +def test_user_agent_save_draft_answer_guides_followup_to_existing_draft() -> None: + session_factory = build_session_factory() + with session_factory() as db: + context_json = {"review_action": "save_draft"} + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="请按当前识别信息保存报销草稿", + user_id="pytest", + context_json=context_json, + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="请按当前识别信息保存报销草稿", + ontology=ontology, + context_json=context_json, + tool_payload={ + "claim_id": "claim-1", + "claim_no": "BX202605220001", + "status": "draft", + "approval_stage": "待提交", + }, + ) + ) + + assert response.draft_payload is not None + assert response.draft_payload.claim_no == "BX202605220001" + assert "已按您当前确认的信息保存为草稿 BX202605220001" in response.answer + assert "请关联这张草稿" in response.answer + assert "继续保存草稿" not in response.answer + + def test_user_agent_returns_submitted_draft_payload_for_review_next_step() -> None: session_factory = build_session_factory() with session_factory() as db: @@ -2022,6 +2087,8 @@ def test_user_agent_filters_deprecated_review_risk_briefs() -> None: def test_user_agent_submission_blocked_risk_level_only_marks_amount_reasons_high() -> None: assert UserAgentService._resolve_submission_blocked_risk_level("住宿金额超出当前职级差标") == "high" assert UserAgentService._resolve_submission_blocked_risk_level("缺少直属领导或参与人员信息") == "warning" + assert UserAgentService._is_submission_exception_explanation_reason("住宿金额超出当前职级差标,且未补充超标说明。") + assert not UserAgentService._is_submission_exception_explanation_reason("缺少直属领导或参与人员信息") def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -> None: @@ -2066,11 +2133,13 @@ def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() - assert response.review_payload is not None assert response.answer == response.review_payload.body_message - assert response.answer.startswith("AI预审未通过:住宿金额超出当前职级差标") - assert "整改后再继续提交" in response.answer + assert response.answer.startswith("检测到当前单据存在需要说明的超标风险") + assert "票据会先正常归集到单据中" in response.answer + assert "附加说明" in response.answer assert response.review_payload.can_proceed is False blocked_brief = next(item for item in response.review_payload.risk_briefs if item.title == "提交风险提示") assert blocked_brief.level == "high" + assert "不是票据归集阻断条件" in blocked_brief.detail assert not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs) diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index 710f6e5..6a0246f 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -96,6 +96,7 @@ } .main.requests-main, .main.approval-main, +.main.archive-main, .main.policies-main, .main.audit-main, .main.logs-main, @@ -114,6 +115,7 @@ .workarea { min-height: 0; overflow: auto; padding: 24px; } .workarea.requests-workarea, .workarea.approval-workarea, +.workarea.archive-workarea, .workarea.policies-workarea, .workarea.audit-workarea, .workarea.logs-workarea, diff --git a/web/src/assets/styles/views/archive-center-view.css b/web/src/assets/styles/views/archive-center-view.css new file mode 100644 index 0000000..fe3a10f --- /dev/null +++ b/web/src/assets/styles/views/archive-center-view.css @@ -0,0 +1,54 @@ +.archive-page .status-tag.archived { + color: #0f766e; + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(16, 185, 129, 0.22); +} + +.archive-page .risk-tag.none { + background: #f1f5f9; + color: #64748b; +} + +.archive-dropdown-filter { + position: relative; +} + +.archive-dropdown-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 12; + min-width: 148px; + max-height: 280px; + padding: 6px; + border: 1px solid #d7e0ea; + border-radius: 10px; + background: #fff; + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12); + overflow-y: auto; +} + +.archive-dropdown-option { + display: block; + width: 100%; + min-height: 36px; + padding: 0 12px; + border: 0; + border-radius: 8px; + background: transparent; + color: #334155; + font-size: 13px; + font-weight: 600; + text-align: left; + cursor: pointer; +} + +.archive-dropdown-option:hover, +.archive-dropdown-option.active { + background: rgba(16, 185, 129, 0.1); + color: #047857; +} + +.archive-page .hint { + color: #475569; +} diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view-part3.css b/web/src/assets/styles/views/travel-reimbursement-create-view-part3.css index e7d50c5..1b3256c 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view-part3.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view-part3.css @@ -1089,23 +1089,3 @@ gap: 12px; flex-wrap: wrap; } - -.review-upload-decision-modal { - display: grid; - gap: 18px; -} - -.review-upload-decision-copy { - display: grid; - gap: 10px; -} - -.review-upload-decision-actions { - justify-content: stretch; -} - -.review-upload-decision-actions .primary-dialog-btn, -.review-upload-decision-actions .secondary-dialog-btn { - flex: 1 1 168px; -} - diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css index cb19200..de9be64 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css @@ -453,10 +453,6 @@ justify-content: stretch; } - .review-upload-decision-actions { - width: 100%; - } - .primary-dialog-btn, .secondary-dialog-btn, .danger-dialog-btn { diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css index 62fe91e..de8bd8a 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css @@ -740,6 +740,38 @@ color: #475569; } +.message-answer-markdown :deep(.markdown-attachment-card) { + margin: 10px 0 12px; + padding: 12px 14px; + border: 1px solid #dbe4ee; + border-left: 4px solid #2563eb; + border-radius: 8px; + background: #f8fafc; + color: #334155; +} + +.message-answer-markdown :deep(.markdown-attachment-card + .markdown-attachment-card) { + margin-top: 12px; +} + +.message-answer-markdown :deep(.markdown-attachment-card p) { + margin: 0; +} + +.message-answer-markdown :deep(.markdown-attachment-card p:first-child) { + color: #0f172a; + font-weight: 820; +} + +.message-answer-markdown :deep(.markdown-attachment-card ul) { + margin-top: 8px; + padding-left: 18px; +} + +.message-answer-markdown :deep(.markdown-attachment-card li + li) { + margin-top: 4px; +} + .message-answer-markdown :deep(code) { padding: 2px 6px; border-radius: 6px; @@ -766,6 +798,22 @@ text-decoration: underline; } +.message-answer-markdown :deep(.markdown-action-paragraph) { + margin-top: 34px; + color: #475569; +} + +.message-answer-markdown :deep(.markdown-action-link) { + color: #2563eb; + font-weight: 850; + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; +} + +.message-answer-markdown :deep(.markdown-action-link:hover) { + color: #1d4ed8; +} + .message-answer-markdown :deep(.markdown-table-wrap) { width: 100%; max-width: 100%; @@ -1237,6 +1285,71 @@ font-weight: 700; } +.expense-query-risk-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +.expense-query-risk-chip { + max-width: 100%; + min-height: 24px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 8px; + border: 1px solid #fecaca; + border-radius: 999px; + background: #fff7ed; + color: #9a3412; + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.expense-query-risk-chip span, +.expense-query-risk-chip em { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.expense-query-risk-chip span { + max-width: 86px; + color: #7c2d12; +} + +.expense-query-risk-chip strong { + flex-shrink: 0; + font-weight: 850; +} + +.expense-query-risk-chip em { + max-width: 120px; + font-style: normal; +} + +.expense-query-risk-chip.high { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.expense-query-risk-chip.medium, +.expense-query-risk-chip.warning { + border-color: #fed7aa; + background: #fff7ed; + color: #c2410c; +} + +.expense-query-risk-chip.low, +.expense-query-risk-chip.info { + border-color: #bfdbfe; + background: #eff6ff; + color: #1d4ed8; +} + .expense-query-pager { display: flex; align-items: center; @@ -1513,4 +1626,3 @@ font-size: 13px; font-weight: 900; } - diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index b28423b..5405c2d 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -606,54 +606,6 @@ gap: 8px; } -.detail-note-tag-list, -.risk-card-tag-list { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.risk-note-tag { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 850; - line-height: 1; -} - -.risk-note-tag.high { - background: #fef2f2; - color: #dc2626; -} - -.risk-note-tag.medium { - background: #fff7ed; - color: #c2410c; -} - -.risk-note-tag.low { - background: #eff6ff; - color: #2563eb; -} - -.risk-note-tag.hotel { - background: #fdf2f8; - color: #be185d; -} - -.risk-note-tag.traffic { - background: #ecfeff; - color: #0e7490; -} - -.risk-note-tag.neutral { - background: #f1f5f9; - color: #475569; -} - .leader-approval-card { border-color: rgba(5, 150, 105, .18); background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%); diff --git a/web/src/components/layout/SidebarRail.vue b/web/src/components/layout/SidebarRail.vue index 674188e..08c3062 100644 --- a/web/src/components/layout/SidebarRail.vue +++ b/web/src/components/layout/SidebarRail.vue @@ -79,8 +79,9 @@ const { const sidebarMeta = { overview: { label: '财务总览' }, workbench: { label: '个人工作台' }, - requests: { label: '个人报销' }, + requests: { label: '报销中心' }, approval: { label: '审批中心' }, + archive: { label: '归档中心' }, policies: { label: '知识管理' }, audit: { label: '任务规则中心' }, logs: { label: '日志管理' }, diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js index 70fbeae..3707001 100644 --- a/web/src/composables/useAppShell.js +++ b/web/src/composables/useAppShell.js @@ -6,92 +6,29 @@ import { useNavigation, navItems } from './useNavigation.js' import { useRequests } from './useRequests.js' import { useSystemState } from './useSystemState.js' import { useToast } from './useToast.js' -import { fetchLatestConversation } from '../services/orchestrator.js' -import { normalizeRequestForUi } from '../utils/requestViewModel.js' -import { buildWorkbenchSummary } from '../utils/workbenchSummary.js' - -const SESSION_TYPE_EXPENSE = 'expense' - -function isPlaceholderValue(value) { - const text = String(value || '').trim() - if (!text) { - return true - } - - return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, '')) -} - -function hasMissingAttachment(request) { - const expenseItems = Array.isArray(request?.expenseItems) ? request.expenseItems : [] - - if (expenseItems.length) { - return expenseItems.some((item) => !String(item?.invoiceId || item?.invoice_id || '').trim()) - } - - const attachmentSummary = String(request?.attachmentSummary || '').trim() - const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim() - return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue) -} - -function hasPendingInfo(request) { - if (!request) { - return false - } - - if (request.approvalKey === 'draft' || request.approvalKey === 'supplement') { - return true - } - - if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) { - return true - } - - return [ - request.profileDepartment, - request.profilePosition, - request.profileGrade, - request.profileManager, - request.reason, - request.occurredDisplay - ].some(isPlaceholderValue) -} - -function resolveDetailAlertTone(request) { - if (request?.approvalKey === 'completed') return 'success' - if (request?.approvalKey === 'rejected') return 'danger' - return 'warning' -} - -function buildDetailAlerts(request) { - if (!request) { - return [] - } - - const alerts = [] - const nodeLabel = String(request.node || request.approval || '').trim() - - if (nodeLabel) { - alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) }) - } - - if (hasMissingAttachment(request)) { - alerts.push({ label: '缺少票据', tone: 'warning' }) - } - - if (hasPendingInfo(request)) { - alerts.push({ label: '待补信息', tone: 'warning' }) - } - - return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3) -} - -export function useAppShell() { +import { fetchLatestConversation } from '../services/orchestrator.js' +import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' +import { buildDetailAlerts } from '../utils/detailAlerts.js' +import { normalizeRequestForUi } from '../utils/requestViewModel.js' +import { buildWorkbenchSummary } from '../utils/workbenchSummary.js' + +const SESSION_TYPE_EXPENSE = 'expense' + +export function useAppShell() { const route = useRoute() const router = useRouter() const smartEntryOpen = ref(false) - const smartEntryContext = ref({ prompt: '', source: 'requests', request: null, files: [], conversation: null }) + const smartEntryContext = ref({ + prompt: '', + source: 'requests', + request: null, + files: [], + conversation: null, + scope: null + }) const smartEntrySessionId = ref(0) + const smartEntryInvalidatedDraftClaimId = ref('') const { activeView, currentView, setView } = useNavigation() const { @@ -208,25 +145,56 @@ export function useAppShell() { setView(view) } - function openTravelCreate() { - smartEntryOpen.value = true - smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [], conversation: null } - smartEntrySessionId.value += 1 - } + function openTravelCreate() { + smartEntryOpen.value = true + smartEntryContext.value = { + prompt: '', + source: 'topbar', + request: null, + files: [], + conversation: null, + scope: null + } + smartEntrySessionId.value += 1 + } - function resolveCurrentUserId() { - const user = currentUser.value || {} - return String(user.username || user.name || 'anonymous').trim() || 'anonymous' - } - - async function resolveSmartEntryConversation(payload = {}) { - if (payload.conversation) { - return payload.conversation - } - - if (!payload.restoreLatestConversation) { - return null - } + function resolveCurrentUserId() { + const user = currentUser.value || {} + return String(user.username || user.name || 'anonymous').trim() || 'anonymous' + } + + function resolveSmartEntryClaimScope(payload = {}) { + const request = payload.request && typeof payload.request === 'object' ? payload.request : null + const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null + const claimId = String( + payloadScope?.claimId || + payloadScope?.claim_id || + request?.claimId || + request?.claim_id || + '' + ).trim() + if (!claimId) { + return null + } + return { type: 'claim', claimId } + } + + function isDetailClaimScopedPayload(payload = {}) { + return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload)) + } + + async function resolveSmartEntryConversation(payload = {}) { + if (payload.conversation) { + return payload.conversation + } + + if (isDetailClaimScopedPayload(payload)) { + return null + } + + if (!payload.restoreLatestConversation) { + return null + } try { const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, { @@ -240,17 +208,19 @@ export function useAppShell() { } } - async function openSmartEntry(payload = {}) { - const conversation = await resolveSmartEntryConversation(payload) - smartEntryOpen.value = true - - smartEntryContext.value = { - prompt: payload.prompt ?? '', - source: payload.source ?? 'workbench', - request: payload.request ?? selectedRequest.value, - files: Array.isArray(payload.files) ? payload.files : [], - conversation - } + async function openSmartEntry(payload = {}) { + const conversation = await resolveSmartEntryConversation(payload) + const scope = resolveSmartEntryClaimScope(payload) + smartEntryOpen.value = true + + smartEntryContext.value = { + prompt: payload.prompt ?? '', + source: payload.source ?? 'workbench', + request: payload.request ?? selectedRequest.value, + files: Array.isArray(payload.files) ? payload.files : [], + conversation, + scope + } smartEntrySessionId.value += 1 } @@ -262,15 +232,15 @@ export function useAppShell() { const claimNo = String(payload.claimNo || payload.claim_no || '').trim() const status = String(payload.status || payload.claimStatus || '').trim() const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim() - smartEntryOpen.value = false await reloadRequests() - void refreshApprovalInbox() if (status === 'submitted') { + smartEntryOpen.value = false + void refreshApprovalInbox() toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`) - } else { - toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`) + router.push({ name: 'app-requests' }) + return } - router.push({ name: 'app-requests' }) + toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`) } function openRequestDetail(request) { @@ -289,7 +259,13 @@ export function useAppShell() { void refreshApprovalInbox() } - async function handleRequestDeleted() { + async function handleRequestDeleted(payload = {}) { + const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim() + if (deletedClaimId) { + clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE) + smartEntryInvalidatedDraftClaimId.value = deletedClaimId + } + await reloadRequests() void refreshApprovalInbox() router.push({ name: 'app-requests' }) @@ -327,6 +303,7 @@ export function useAppShell() { selectedRequest, setView, smartEntryContext, + smartEntryInvalidatedDraftClaimId, smartEntryOpen, smartEntrySessionId, detailAlerts, diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js index 1df69c7..52678b5 100644 --- a/web/src/composables/useNavigation.js +++ b/web/src/composables/useNavigation.js @@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router' import { icons } from '../data/icons.js' -export const appViews = ['overview', 'workbench', 'requests', 'approval', 'policies', 'audit', 'logs', 'employees', 'settings'] +export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'logs', 'employees', 'settings'] export const navItems = [ { @@ -24,10 +24,10 @@ export const navItems = [ }, { id: 'requests', - label: '个人报销', - navHint: '查看和管理个人报销', + label: '报销中心', + navHint: '查看和管理报销单据', icon: icons.list, - title: '个人报销', + title: '报销中心', desc: '集中查看草稿、审批进度、票据状态与风险提示。' }, { @@ -38,6 +38,14 @@ export const navItems = [ title: '审批中心', desc: '按优先级处理待审批事项,控制时效与风险。' }, + { + id: 'archive', + label: '归档中心', + navHint: '查阅公司已归档财务数据', + icon: icons.archive, + title: '归档中心', + desc: '集中保存公司已归档入账的报销单据,形成完整财务归档库。' + }, { id: 'policies', label: '制度知识', @@ -85,6 +93,7 @@ const viewRouteNames = { workbench: 'app-workbench', requests: 'app-requests', approval: 'app-approval', + archive: 'app-archive', policies: 'app-policies', audit: 'app-audit', logs: 'app-logs', diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index f6e26a0..b9a9ac8 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -1,11 +1,14 @@ import { computed, reactive, ref } from 'vue' import { fetchExpenseClaims } from '../services/reimbursements.js' +import { filterActionableRiskFlags } from '../utils/riskFlags.js' const EXPENSE_TYPE_LABELS = { travel: '差旅费', train_ticket: '火车票', flight_ticket: '机票', + ship_ticket: '轮船票', + ferry_ticket: '轮船票', hotel_ticket: '住宿票', ride_ticket: '乘车', travel_allowance: '出差补贴', @@ -31,6 +34,8 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket']) +const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket']) +const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket']) const REIMBURSEMENT_PROGRESS_LABELS = [ '创建单据', @@ -135,6 +140,17 @@ function resolveLocationDisplay(location, typeCode) { return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填' } +function resolveExpenseDescriptionDetail(itemType, itemLocation) { + const normalizedType = normalizeExpenseType(itemType) + if (ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) { + return '起始地-目的地' + } + if (HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) { + return '目的地酒店' + } + return resolveLocationDisplay(itemLocation, normalizedType) +} + function resolveExpenseItemViewId(item, index, claim) { return String(item?.id || `${claim?.id || 'claim'}-item-${index}`) } @@ -273,7 +289,7 @@ function buildRiskSummary(riskFlags) { return '无' } - const items = riskFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean) + const items = filterActionableRiskFlags(riskFlags).map((item) => stringifyRiskFlag(item)).filter(Boolean) return items.length ? items.join(';') : '无' } @@ -602,7 +618,7 @@ function buildExpenseItems(claim, riskSummary) { name: itemTypeLabel, category: itemTypeLabel, desc: itemReason || '待补充', - detail: resolveLocationDisplay(itemLocation, itemType), + detail: resolveExpenseDescriptionDetail(itemType, itemLocation), amount: itemAmountDisplay, status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充', tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad', @@ -654,6 +670,7 @@ export function mapExpenseClaimToRequest(claim) { applyTime: formatDateTime(applyDateTime) || '待补充', submittedAt: applyDateTime || '', createdAt: claim?.created_at || '', + updatedAt: claim?.updated_at || '', amount: parseNumber(claim?.amount), riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [], invoiceCount, diff --git a/web/src/data/icons.js b/web/src/data/icons.js index 4b2f46a..42c9e16 100644 --- a/web/src/data/icons.js +++ b/web/src/data/icons.js @@ -5,6 +5,7 @@ export const icons = { workspace: iconPath(''), list: iconPath(''), approval: iconPath(''), + archive: iconPath(''), file: iconPath(''), skill: iconPath(''), users: iconPath(''), diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js index 5fbfece..0c9837f 100644 --- a/web/src/services/reimbursements.js +++ b/web/src/services/reimbursements.js @@ -8,6 +8,10 @@ export function fetchApprovalExpenseClaims() { return apiRequest('/reimbursements/claims/approvals') } +export function fetchArchivedExpenseClaims() { + return apiRequest('/reimbursements/claims/archives') +} + export function fetchExpenseClaimDetail(claimId) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`) } diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 5d3325e..d866917 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -1,75 +1,77 @@ -export const DEFAULT_APP_VIEW_ORDER = [ - 'overview', - 'workbench', - 'requests', - 'approval', - 'policies', - 'audit', - 'logs', - 'employees', - 'settings' -] - -const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies']) -const VIEW_ROLE_RULES = { - overview: ['finance', 'executive'], - approval: ['approver', 'finance', 'executive'], - audit: ['auditor', 'finance'], - logs: ['manager'], - employees: ['manager'], - settings: ['manager'] -} -const CLAIM_MANAGER_ROLE_CODES = new Set(['executive']) -const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver']) -const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver']) +export const DEFAULT_APP_VIEW_ORDER = [ + 'overview', + 'workbench', + 'requests', + 'approval', + 'archive', + 'policies', + 'audit', + 'logs', + 'employees', + 'settings' +] + +const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies']) +const VIEW_ROLE_RULES = { + overview: ['finance', 'executive'], + approval: ['approver', 'finance', 'executive'], + archive: ['finance', 'executive', 'auditor'], + audit: ['auditor', 'finance'], + logs: ['manager'], + employees: ['manager'], + settings: ['manager'] +} +const CLAIM_MANAGER_ROLE_CODES = new Set(['executive']) +const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver']) +const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver']) function normalizedRoleCodes(user) { if (!user) { return [] } - return Array.isArray(user.roleCodes) - ? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) - : [] -} + return Array.isArray(user.roleCodes) + ? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) + : [] +} -export function isManagerUser(user) { - return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') -} - -export function isFinanceUser(user) { - return normalizedRoleCodes(user).includes('finance') -} - -export function isExecutiveUser(user) { - return normalizedRoleCodes(user).includes('executive') -} - -export function canManageExpenseClaims(user) { - if (Boolean(user?.isAdmin)) { - return true - } - - return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode)) -} - -export function canReturnExpenseClaims(user) { - if (Boolean(user?.isAdmin)) { - return true - } - - return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode)) -} - -export function canApproveLeaderExpenseClaims(user) { - if (Boolean(user?.isAdmin)) { - return true - } - - return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode)) -} - -export function canAccessAppView(user, viewId) { +export function isManagerUser(user) { + return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') +} + +export function isFinanceUser(user) { + return normalizedRoleCodes(user).includes('finance') +} + +export function isExecutiveUser(user) { + return normalizedRoleCodes(user).includes('executive') +} + +export function canManageExpenseClaims(user) { + if (Boolean(user?.isAdmin)) { + return true + } + + return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode)) +} + +export function canReturnExpenseClaims(user) { + if (Boolean(user?.isAdmin)) { + return true + } + + return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode)) +} + +export function canApproveLeaderExpenseClaims(user) { + if (Boolean(user?.isAdmin)) { + return true + } + + return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode)) +} + +export function canAccessAppView(user, viewId) { if (!viewId || !user) { return false } diff --git a/web/src/utils/archiveCenterListFilters.js b/web/src/utils/archiveCenterListFilters.js new file mode 100644 index 0000000..177392e --- /dev/null +++ b/web/src/utils/archiveCenterListFilters.js @@ -0,0 +1,216 @@ +import { + isActionableRiskFlag, + isRiskSummaryWithRisk, + normalizeRiskFlagTone +} from './riskFlags.js' + +export const ARCHIVE_FILTER_ALL = 'all' + +export function countClaimRisks(riskFlags, riskSummary) { + let count = 0 + + for (const flag of Array.isArray(riskFlags) ? riskFlags : []) { + if (!isActionableRiskFlag(flag)) { + continue + } + + if (!flag || typeof flag !== 'object') { + count += 1 + continue + } + + const points = Array.isArray(flag.points) + ? flag.points.map((point) => String(point || '').trim()).filter(Boolean) + : [] + + if (points.length) { + count += points.length + continue + } + + const message = String( + flag.message || flag.reason || flag.summary || flag.label || flag.description || flag.title || '' + ).trim() + + if (message) { + count += 1 + } + } + + if (!count && isRiskSummaryWithRisk(riskSummary)) { + return 1 + } + + return count +} + +export function resolveArchiveRiskTone(riskFlags, riskSummary) { + let tone = 'low' + + for (const flag of Array.isArray(riskFlags) ? riskFlags : []) { + if (!isActionableRiskFlag(flag)) { + continue + } + + const flagTone = normalizeRiskFlagTone(flag) + if (flagTone === 'high') { + return 'high' + } + if (flagTone === 'medium') { + tone = 'medium' + } + } + + if (tone === 'low' && isRiskSummaryWithRisk(riskSummary)) { + return 'medium' + } + + return tone +} + +export function formatArchiveRiskCountLabel(riskCount) { + const count = Math.max(0, Number(riskCount) || 0) + return `${count}条` +} + +export function extractArchiveMonth(...values) { + for (const value of values) { + const text = String(value || '').trim() + if (!text) { + continue + } + + const parsedDate = new Date(text) + if (!Number.isNaN(parsedDate.getTime())) { + const year = parsedDate.getFullYear() + const month = String(parsedDate.getMonth() + 1).padStart(2, '0') + return `${year}-${month}` + } + + const matched = text.match(/(\d{4})-(\d{2})/) + if (matched) { + return `${matched[1]}-${matched[2]}` + } + } + + return '' +} + +export function formatArchiveMonthLabel(monthKey) { + const normalized = String(monthKey || '').trim() + const matched = normalized.match(/^(\d{4})-(\d{2})$/) + if (!matched) { + return normalized || '未知月份' + } + + return `${matched[1]}年${matched[2]}月` +} + +export function buildTypeFilterOptions(rows) { + const typeMap = new Map() + + for (const row of rows) { + const value = String(row?.typeCode || 'other').trim() || 'other' + if (!typeMap.has(value)) { + typeMap.set(value, String(row?.type || row?.typeLabel || value).trim() || value) + } + } + + return [ + { value: ARCHIVE_FILTER_ALL, label: '全部类型' }, + ...Array.from(typeMap.entries()) + .sort((left, right) => left[1].localeCompare(right[1], 'zh-CN')) + .map(([value, label]) => ({ value, label })) + ] +} + +export function buildDepartmentFilterOptions(rows) { + const departments = new Set() + + for (const row of rows) { + const department = String(row?.department || row?.dept || '').trim() + if (department) { + departments.add(department) + } + } + + return [ + { value: ARCHIVE_FILTER_ALL, label: '全部部门' }, + ...Array.from(departments) + .sort((left, right) => left.localeCompare(right, 'zh-CN')) + .map((value) => ({ value, label: value })) + ] +} + +export function buildArchiveMonthFilterOptions(rows) { + const months = new Set() + + for (const row of rows) { + const month = String(row?.archiveMonth || '').trim() + if (month) { + months.add(month) + } + } + + return [ + { value: ARCHIVE_FILTER_ALL, label: '全部月份' }, + ...Array.from(months) + .sort((left, right) => right.localeCompare(left)) + .map((value) => ({ value, label: formatArchiveMonthLabel(value) })) + ] +} + +export function applyArchiveListFilters(rows, filters) { + let filteredRows = Array.isArray(rows) ? [...rows] : [] + + if (filters.tab && filters.tab !== '全部归档') { + filteredRows = filteredRows.filter((row) => row.archiveTab === filters.tab) + } + + if (filters.risk === 'has') { + filteredRows = filteredRows.filter((row) => row.hasRisk) + } else if (filters.risk === 'none') { + filteredRows = filteredRows.filter((row) => !row.hasRisk) + } else if (filters.risk && filters.risk !== ARCHIVE_FILTER_ALL) { + filteredRows = filteredRows.filter((row) => row.hasRisk && row.riskTone === filters.risk) + } + + if (filters.type && filters.type !== ARCHIVE_FILTER_ALL) { + filteredRows = filteredRows.filter((row) => String(row.typeCode || '').trim() === filters.type) + } + + if (filters.department && filters.department !== ARCHIVE_FILTER_ALL) { + filteredRows = filteredRows.filter((row) => String(row.department || '').trim() === filters.department) + } + + if (filters.archiveMonth && filters.archiveMonth !== ARCHIVE_FILTER_ALL) { + filteredRows = filteredRows.filter((row) => String(row.archiveMonth || '').trim() === filters.archiveMonth) + } + + const keyword = String(filters.keyword || '').trim().toLowerCase() + if (keyword) { + filteredRows = filteredRows.filter((row) => ( + String(row.id || '').toLowerCase().includes(keyword) + || String(row.applicant || '').toLowerCase().includes(keyword) + || String(row.department || '').toLowerCase().includes(keyword) + || String(row.type || '').toLowerCase().includes(keyword) + || String(row.amount || '').toLowerCase().includes(keyword) + || String(row.risk || '').toLowerCase().includes(keyword) + || String(row.riskCount ?? '').includes(keyword) + || String(row.archiveMonthLabel || '').toLowerCase().includes(keyword) + )) + } + + return filteredRows +} + +export function hasActiveArchiveListFilters(filters) { + return Boolean( + (filters.tab && filters.tab !== '全部归档') + || (filters.risk && filters.risk !== ARCHIVE_FILTER_ALL) + || (filters.type && filters.type !== ARCHIVE_FILTER_ALL) + || (filters.department && filters.department !== ARCHIVE_FILTER_ALL) + || (filters.archiveMonth && filters.archiveMonth !== ARCHIVE_FILTER_ALL) + || String(filters.keyword || '').trim() + ) +} diff --git a/web/src/utils/assistantSessionSnapshot.js b/web/src/utils/assistantSessionSnapshot.js index 0850469..40a225d 100644 --- a/web/src/utils/assistantSessionSnapshot.js +++ b/web/src/utils/assistantSessionSnapshot.js @@ -22,12 +22,12 @@ function getStorage() { return window.localStorage } -function emitSnapshotChange(sessionType) { +function emitSnapshotChange(sessionType, detail = {}) { if (typeof window === 'undefined') { return } window.dispatchEvent(new CustomEvent(ASSISTANT_SESSION_SNAPSHOT_EVENT, { - detail: { sessionType: normalizeSessionType(sessionType) } + detail: { sessionType: normalizeSessionType(sessionType), ...detail } })) } @@ -82,18 +82,39 @@ export function writeAssistantSessionSnapshot(userId, sessionType = 'expense', s export function clearAssistantSessionSnapshot(userId, sessionType = 'expense') { const storage = getStorage() if (!storage) { - return + return false } const normalizedSessionType = normalizeSessionType(sessionType) try { storage.removeItem(resolveAssistantSessionSnapshotKey(userId, normalizedSessionType)) - emitSnapshotChange(normalizedSessionType) + emitSnapshotChange(normalizedSessionType, { action: 'clear' }) + return true } catch (error) { console.warn('Failed to clear assistant session snapshot:', error) + return false } } export function hasAssistantSessionSnapshot(userId, sessionType = 'expense') { return Boolean(readAssistantSessionSnapshot(userId, sessionType)?.state) } + +function resolveSnapshotDraftClaimId(snapshot) { + const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : {} + return String(state.draftClaimId || state.draft_claim_id || '').trim() +} + +export function clearAssistantSessionSnapshotForDraftClaim(userId, claimId, sessionType = 'expense') { + const normalizedClaimId = String(claimId || '').trim() + if (!normalizedClaimId) { + return false + } + + const snapshot = readAssistantSessionSnapshot(userId, sessionType) + if (resolveSnapshotDraftClaimId(snapshot) !== normalizedClaimId) { + return false + } + + return clearAssistantSessionSnapshot(userId, sessionType) +} diff --git a/web/src/utils/detailAlerts.js b/web/src/utils/detailAlerts.js new file mode 100644 index 0000000..8e77447 --- /dev/null +++ b/web/src/utils/detailAlerts.js @@ -0,0 +1,114 @@ +const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) +const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment']) + +function isPlaceholderValue(value) { + const text = String(value || '').trim() + if (!text) { + return true + } + + return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, '')) +} + +function normalizeExpenseType(value) { + return String(value || '').trim() || 'other' +} + +function isSystemGeneratedExpenseItem(item) { + const itemType = normalizeExpenseType(item?.itemType || item?.item_type) + return Boolean(item?.isSystemGenerated || item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) +} + +function hasPositiveAmount(value) { + const amount = Number(value) + return Number.isFinite(amount) && amount > 0 +} + +function getExpenseItems(request) { + return Array.isArray(request?.expenseItems) ? request.expenseItems : [] +} + +export function hasMissingAttachment(request) { + const expenseItems = getExpenseItems(request) + + if (expenseItems.length) { + return expenseItems.some((item) => { + if (isSystemGeneratedExpenseItem(item)) { + return false + } + return !String(item?.invoiceId || item?.invoice_id || '').trim() + }) + } + + const attachmentSummary = String(request?.attachmentSummary || '').trim() + const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim() + return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue) +} + +export function hasPendingInfo(request) { + if (!request) { + return false + } + + const expenseItems = getExpenseItems(request).filter((item) => !isSystemGeneratedExpenseItem(item)) + const hasItemValue = (resolver) => expenseItems.some((item) => !isPlaceholderValue(resolver(item))) + const hasItemAmount = expenseItems.some((item) => hasPositiveAmount(item?.itemAmount || item?.item_amount)) + const requestType = normalizeExpenseType(request.typeCode || request.expense_type) + const locationRequired = LOCATION_REQUIRED_EXPENSE_TYPES.has(requestType) + + if (!hasPositiveAmount(request.amountValue) && !hasItemAmount) { + return true + } + + if (isPlaceholderValue(request.typeLabel) && !hasItemValue((item) => item?.itemType || item?.item_type)) { + return true + } + + if (isPlaceholderValue(request.reason) && !hasItemValue((item) => item?.itemReason || item?.item_reason || item?.desc)) { + return true + } + + if (isPlaceholderValue(request.occurredDisplay) && !hasItemValue((item) => item?.itemDate || item?.item_date || item?.time)) { + return true + } + + if ( + locationRequired + && isPlaceholderValue(request.location) + && isPlaceholderValue(request.city) + && !hasItemValue((item) => item?.itemLocation || item?.item_location) + ) { + return true + } + + return false +} + +function resolveDetailAlertTone(request) { + if (request?.approvalKey === 'completed') return 'success' + if (request?.approvalKey === 'rejected') return 'danger' + return 'warning' +} + +export function buildDetailAlerts(request) { + if (!request) { + return [] + } + + const alerts = [] + const nodeLabel = String(request.node || request.approval || '').trim() + + if (nodeLabel) { + alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) }) + } + + if (hasMissingAttachment(request)) { + alerts.push({ label: '缺少票据', tone: 'warning' }) + } + + if (hasPendingInfo(request)) { + alerts.push({ label: '待补信息', tone: 'warning' }) + } + + return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3) +} diff --git a/web/src/utils/expenseClaimArchive.js b/web/src/utils/expenseClaimArchive.js new file mode 100644 index 0000000..a12ada8 --- /dev/null +++ b/web/src/utils/expenseClaimArchive.js @@ -0,0 +1,14 @@ +export function isArchivedExpenseClaim(claim) { + const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim() + const status = String(claim?.status || '').trim().toLowerCase() + + if (stage === '归档入账' || stage === 'completed' || stage.includes('归档')) { + return true + } + + if (!['approved', 'completed', 'paid'].includes(status)) { + return false + } + + return !stage || stage === '归档入账' || stage === 'completed' +} diff --git a/web/src/utils/markdown.js b/web/src/utils/markdown.js index 0a44083..e3232d1 100644 --- a/web/src/utils/markdown.js +++ b/web/src/utils/markdown.js @@ -8,6 +8,76 @@ const markdown = new MarkdownIt({ const defaultTableOpen = markdown.renderer.rules.table_open const defaultTableClose = markdown.renderer.rules.table_close +const defaultParagraphOpen = markdown.renderer.rules.paragraph_open +const defaultLinkOpen = markdown.renderer.rules.link_open +const defaultBlockquoteOpen = markdown.renderer.rules.blockquote_open + +const ACTION_LINK_CLASS_BY_HREF = { + '#confirm-attachment-association': 'markdown-action-link-confirm' +} + +function resolveActionLinkClass(href) { + const normalizedHref = String(href || '').trim() + return ACTION_LINK_CLASS_BY_HREF[normalizedHref] || '' +} + +function inlineTokenHasActionLink(token) { + const children = Array.isArray(token?.children) ? token.children : [] + return children.some((child) => ( + child?.type === 'link_open' && resolveActionLinkClass(child.attrGet?.('href')) + )) +} + +function resolveInlineTokenPlainText(token) { + const children = Array.isArray(token?.children) ? token.children : [] + const childText = children + .filter((child) => ['text', 'code_inline'].includes(String(child?.type || ''))) + .map((child) => String(child?.content || '')) + .join('') + .trim() + return childText || String(token?.content || '').replace(/[*_`]+/g, '').trim() +} + +function blockquoteHasAttachmentHeading(tokens, idx) { + for (let i = idx + 1; i < tokens.length; i += 1) { + const token = tokens[i] + if (token?.type === 'blockquote_close') { + return false + } + if (token?.type === 'inline') { + return /^附件\s*\d+\s*[::]/.test(resolveInlineTokenPlainText(token)) + } + } + return false +} + +markdown.renderer.rules.paragraph_open = (tokens, idx, options, env, self) => { + if (inlineTokenHasActionLink(tokens[idx + 1])) { + tokens[idx].attrJoin('class', 'markdown-action-paragraph') + } + return defaultParagraphOpen + ? defaultParagraphOpen(tokens, idx, options, env, self) + : self.renderToken(tokens, idx, options) +} + +markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => { + const actionClass = resolveActionLinkClass(tokens[idx].attrGet('href')) + if (actionClass) { + tokens[idx].attrJoin('class', `markdown-action-link ${actionClass}`) + } + return defaultLinkOpen + ? defaultLinkOpen(tokens, idx, options, env, self) + : self.renderToken(tokens, idx, options) +} + +markdown.renderer.rules.blockquote_open = (tokens, idx, options, env, self) => { + if (blockquoteHasAttachmentHeading(tokens, idx)) { + tokens[idx].attrJoin('class', 'markdown-attachment-card') + } + return defaultBlockquoteOpen + ? defaultBlockquoteOpen(tokens, idx, options, env, self) + : self.renderToken(tokens, idx, options) +} markdown.renderer.rules.table_open = (tokens, idx, options, env, self) => ( `
${defaultTableOpen ? defaultTableOpen(tokens, idx, options, env, self) : ''}` diff --git a/web/src/utils/riskFlags.js b/web/src/utils/riskFlags.js new file mode 100644 index 0000000..fc1317c --- /dev/null +++ b/web/src/utils/riskFlags.js @@ -0,0 +1,107 @@ +const NO_RISK_SUMMARY_VALUES = new Set(['无', '暂无异常', '无异常', '暂无风险']) +const NON_RISK_SOURCES = new Set([ + 'manual_approval', + 'finance_approval', + 'approval', + 'approval_log', + 'expense_claim_approval', + 'expense_claim_finance_approval' +]) +const NON_RISK_EVENTS = new Set([ + 'expense_claim_approval', + 'expense_claim_finance_approval' +]) +const NON_RISK_TONES = new Set(['info', 'pass', 'success', 'approved', 'ok', 'none']) +const RISK_SOURCES = new Set([ + 'attachment_analysis', + 'submission_review', + 'manual_return', + 'platform_risk', + 'policy_review', + 'scene_policy_review' +]) + +function normalizeText(value) { + return String(value || '').trim() +} + +function normalizeKey(value) { + return normalizeText(value).toLowerCase() +} + +function isApprovalOnlyText(value) { + const text = normalizeText(value) + if (!text) { + return true + } + + return ( + /^(同意|通过|审批通过|审核通过|已同意|无意见)$/.test(text) + || /已审批通过/.test(text) + || /已完成财务审核/.test(text) + || /进入归档入账/.test(text) + || /流转至/.test(text) + ) +} + +export function normalizeRiskFlagTone(flag) { + if (!flag || typeof flag !== 'object') { + return normalizeText(flag) ? 'medium' : 'none' + } + + const tone = normalizeKey(flag.severity || flag.tone || flag.level || flag.riskTone || flag.risk_tone) + if (['high', 'medium', 'low'].includes(tone)) { + return tone + } + if (NON_RISK_TONES.has(tone)) { + return 'none' + } + + const source = normalizeKey(flag.source) + if (source === 'manual_return') { + return 'medium' + } + if (RISK_SOURCES.has(source)) { + return 'medium' + } + + const riskText = normalizeText(flag.message || flag.reason || flag.summary || flag.label || flag.description || flag.title) + if (riskText && !isApprovalOnlyText(riskText)) { + return 'medium' + } + + return 'none' +} + +export function isActionableRiskFlag(flag) { + if (!flag || typeof flag !== 'object') { + const text = normalizeText(flag) + return Boolean(text && !isApprovalOnlyText(text)) + } + + const source = normalizeKey(flag.source) + const eventType = normalizeKey(flag.event_type || flag.eventType) + if (NON_RISK_SOURCES.has(source) || NON_RISK_EVENTS.has(eventType)) { + return false + } + + const tone = normalizeRiskFlagTone(flag) + if (tone === 'high' || tone === 'medium' || tone === 'low') { + return true + } + + return false +} + +export function filterActionableRiskFlags(riskFlags) { + return (Array.isArray(riskFlags) ? riskFlags : []).filter((flag) => isActionableRiskFlag(flag)) +} + +export function isRiskSummaryWithRisk(riskSummary) { + const summary = normalizeText(riskSummary) + if (!summary || NO_RISK_SUMMARY_VALUES.has(summary) || isApprovalOnlyText(summary)) { + return false + } + + return true +} diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 7ef71b3..8fb95d0 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -17,6 +17,7 @@ 'workbench-main': activeView === 'workbench', 'requests-main': activeView === 'requests', 'approval-main': activeView === 'approval', + 'archive-main': activeView === 'archive', 'policies-main': activeView === 'policies', 'audit-main': activeView === 'audit', 'audit-detail-main': activeView === 'audit' && auditDetailOpen, @@ -49,7 +50,7 @@ /> + @@ -122,6 +125,7 @@ :initial-conversation="smartEntryContext.conversation" :entry-source="smartEntryContext.source" :request-context="smartEntryContext.request" + :invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId" @close="closeSmartEntry" @draft-saved="handleDraftSaved" /> @@ -140,6 +144,7 @@ import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue' import TravelRequestDetailView from './TravelRequestDetailView.vue' import RequestsView from './RequestsView.vue' import ApprovalCenterView from './ApprovalCenterView.vue' +import ArchiveCenterView from './ArchiveCenterView.vue' import PoliciesView from './PoliciesView.vue' import AuditView from './AuditView.vue' import LogsView from './LogsView.vue' @@ -187,6 +192,7 @@ const { search, selectedRequest, smartEntryContext, + smartEntryInvalidatedDraftClaimId, smartEntryOpen, smartEntrySessionId, toast, diff --git a/web/src/views/ArchiveCenterView.vue b/web/src/views/ArchiveCenterView.vue new file mode 100644 index 0000000..0b72f71 --- /dev/null +++ b/web/src/views/ArchiveCenterView.vue @@ -0,0 +1,141 @@ + + + + + + + diff --git a/web/src/views/PoliciesView.vue b/web/src/views/PoliciesView.vue index 5ecf369..508b72d 100644 --- a/web/src/views/PoliciesView.vue +++ b/web/src/views/PoliciesView.vue @@ -24,7 +24,7 @@ :class="{ active: activeFolder === folder.name }" @click="activeFolder = folder.name" > - + {{ folder.name }} {{ folder.count }} diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index 105aca7..649b7bb 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -1,6 +1,6 @@