feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -615,7 +615,7 @@ class ExpenseClaimAttachmentAnalysisMixin:
|
||||
severity = "high"
|
||||
label = "高风险"
|
||||
headline = "AI提示:住宿金额超出报销标准"
|
||||
summary = "当前住宿票据金额超过规则中心差旅住宿标准,强行提交前需补充超标原因。"
|
||||
summary = "当前住宿票据金额超过规则中心差旅住宿标准,已作为风险项保留在单据中;如需按特殊情况提交,请补充超标原因。"
|
||||
elif (
|
||||
line_count == 0
|
||||
or not compact_text
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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("|", "\\|")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
@@ -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": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
@@ -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": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
server/storage/knowledge/制度政策/远光软件公司内部控制基本规范.pdf
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司内部控制基本规范.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/制度政策/远光软件公司合同管理制度.docx
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司合同管理制度.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/制度政策/远光软件公司财务管理制度总则.docx
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司财务管理制度总则.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/制度政策/远光软件公司资产管理制度.pdf
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司资产管理制度.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/制度政策/远光软件公司采购管理办法.xlsx
Normal file
BIN
server/storage/knowledge/制度政策/远光软件公司采购管理办法.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/发票管理/远光软件公司发票审核标准.xlsx
Normal file
BIN
server/storage/knowledge/发票管理/远光软件公司发票审核标准.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/发票管理/远光软件公司发票管理规范.docx
Normal file
BIN
server/storage/knowledge/发票管理/远光软件公司发票管理规范.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/发票管理/远光软件公司增值税发票操作指南.pdf
Normal file
BIN
server/storage/knowledge/发票管理/远光软件公司增值税发票操作指南.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/发票管理/远光软件公司电子发票管理办法.docx
Normal file
BIN
server/storage/knowledge/发票管理/远光软件公司电子发票管理办法.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/培训资料/远光软件报销流程培训手册.pdf
Normal file
BIN
server/storage/knowledge/培训资料/远光软件报销流程培训手册.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/培训资料/远光软件新员工财务培训课件.pdf
Normal file
BIN
server/storage/knowledge/培训资料/远光软件新员工财务培训课件.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/培训资料/远光软件财务制度培训手册.docx
Normal file
BIN
server/storage/knowledge/培训资料/远光软件财务制度培训手册.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/培训资料/远光软件财务培训课程安排.xlsx
Normal file
BIN
server/storage/knowledge/培训资料/远光软件财务培训课程安排.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/差旅规范/远光软件公司差旅费管理办法.docx
Normal file
BIN
server/storage/knowledge/差旅规范/远光软件公司差旅费管理办法.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/差旅规范/远光软件出差审批流程说明.pdf
Normal file
BIN
server/storage/knowledge/差旅规范/远光软件出差审批流程说明.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/差旅规范/远光软件国际出差管理规定.docx
Normal file
BIN
server/storage/knowledge/差旅规范/远光软件国际出差管理规定.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/差旅规范/远光软件差旅费标准速查表.xlsx
Normal file
BIN
server/storage/knowledge/差旅规范/远光软件差旅费标准速查表.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/常见问答/远光软件报销问题处理指引.xlsx
Normal file
BIN
server/storage/knowledge/常见问答/远光软件报销问题处理指引.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/常见问答/远光软件财务制度问答汇总.pdf
Normal file
BIN
server/storage/knowledge/常见问答/远光软件财务制度问答汇总.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/常见问答/远光软件财务报销常见问题解答.docx
Normal file
BIN
server/storage/knowledge/常见问答/远光软件财务报销常见问题解答.docx
Normal file
Binary file not shown.
Binary file not shown.
BIN
server/storage/knowledge/税务合规/远光软件企业所得税汇算清缴操作手册.pdf
Normal file
BIN
server/storage/knowledge/税务合规/远光软件企业所得税汇算清缴操作手册.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/税务合规/远光软件公司税务管理制度.docx
Normal file
BIN
server/storage/knowledge/税务合规/远光软件公司税务管理制度.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/税务合规/远光软件研发费用加计扣除管理办法.xlsx
Normal file
BIN
server/storage/knowledge/税务合规/远光软件研发费用加计扣除管理办法.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/税务合规/远光软件软件产品增值税即征即退操作指南.pdf
Normal file
BIN
server/storage/knowledge/税务合规/远光软件软件产品增值税即征即退操作指南.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务SLA标准.xlsx
Normal file
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务SLA标准.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务中心运营管理办法.docx
Normal file
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务中心运营管理办法.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务操作手册.pdf
Normal file
BIN
server/storage/knowledge/财务共享/远光软件财务共享服务操作手册.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx
Normal file
BIN
server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx
Normal file
BIN
server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx
Normal file
BIN
server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf
Normal file
BIN
server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/预算管理/远光软件公司预算管理制度.docx
Normal file
BIN
server/storage/knowledge/预算管理/远光软件公司预算管理制度.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/预算管理/远光软件年度预算编制指南.pdf
Normal file
BIN
server/storage/knowledge/预算管理/远光软件年度预算编制指南.pdf
Normal file
Binary file not shown.
BIN
server/storage/knowledge/预算管理/远光软件预算执行分析报告模板.docx
Normal file
BIN
server/storage/knowledge/预算管理/远光软件预算执行分析报告模板.docx
Normal file
Binary file not shown.
BIN
server/storage/knowledge/预算管理/远光软件预算编制模板.xlsx
Normal file
BIN
server/storage/knowledge/预算管理/远光软件预算编制模板.xlsx
Normal file
Binary file not shown.
@@ -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:
|
||||
|
||||
96
server/tests/test_knowledge_document_extractors.py
Normal file
96
server/tests/test_knowledge_document_extractors.py
Normal file
@@ -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 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:sp><p:txBody><a:p><a:r><a:t>差旅报销培训</a:t></a:r></a:p></p:txBody></p:sp>
|
||||
<p:sp><p:txBody><a:p><a:r><a:t>发票、审批、预算三项要素必须齐全</a:t></a:r></a:p></p:txBody></p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
</p:sld>
|
||||
"""
|
||||
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"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets>
|
||||
<sheet name="{sheet_name}" sheetId="1" r:id="rId1"/>
|
||||
</sheets>
|
||||
</workbook>
|
||||
"""
|
||||
rels_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
|
||||
Target="worksheets/sheet1.xml"/>
|
||||
</Relationships>
|
||||
"""
|
||||
row_xml = "\n".join(
|
||||
f'<row r="{row_index}">'
|
||||
+ "".join(
|
||||
f'<c r="{chr(65 + column_index)}{row_index}" t="inlineStr"><is><t>{cell}</t></is></c>'
|
||||
for column_index, cell in enumerate(row)
|
||||
)
|
||||
+ "</row>"
|
||||
for row_index, row in enumerate(rows, start=1)
|
||||
)
|
||||
sheet_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData>
|
||||
{row_xml}
|
||||
</sheetData>
|
||||
</worksheet>
|
||||
"""
|
||||
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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user