feat: 新增票据夹模块并优化 OCR 与员工画像服务
后端新增票据夹端点、数据模型和服务模块,优化 OCR 端点 Schema 和附件操作逻辑,完善员工行为画像服务和辅助函数, 前端新增票据夹视图和服务层,优化文档中心样式和侧边栏导 航,完善员工画像详情弹窗和权限控制,补充单元测试。
This commit is contained in:
@@ -23,56 +23,79 @@
|
|||||||
|
|
||||||
## 阶段三:后端票据资产层
|
## 阶段三:后端票据资产层
|
||||||
|
|
||||||
- [ ] 新增 `schemas/receipt_folder.py`,定义列表项、详情、字段更新和删除响应。[CONCEPT: 后端]
|
- [x] 新增 `schemas/receipt_folder.py`,定义列表项、详情、字段更新和删除响应。[CONCEPT: 后端]
|
||||||
|
证据:已新增 `ReceiptFolderItemRead`、`ReceiptFolderDetailRead`、`ReceiptFolderUpdate`、`ReceiptFolderDeleteResponse`。
|
||||||
|
|
||||||
- [ ] 新增 `services/receipt_folder.py`,负责源文件保存、元数据读写、预览解析、列表过滤和安全路径校验。[CONCEPT: 票据持久化]
|
- [x] 新增 `services/receipt_folder.py`,负责源文件保存、元数据读写、预览解析、列表过滤和安全路径校验。[CONCEPT: 票据持久化]
|
||||||
|
证据:`ReceiptFolderService` 已覆盖 OCR 批量持久化、已关联附件同步、详情更新、预览/源文件解析与目录安全校验。
|
||||||
|
|
||||||
- [ ] 新增 `api/v1/endpoints/receipt_folder.py`,暴露列表、详情、更新、删除、预览和源文件接口。[CONCEPT: 后端]
|
- [x] 新增 `api/v1/endpoints/receipt_folder.py`,暴露列表、详情、更新、删除、预览和源文件接口。[CONCEPT: 后端]
|
||||||
|
证据:已提供 `GET/PATCH/DELETE /receipt-folder/{receipt_id}` 及 `preview/source` 文件接口。
|
||||||
|
|
||||||
- [ ] 在 `api/v1/router.py` 注册票据夹接口。[CONCEPT: 后端]
|
- [x] 在 `api/v1/router.py` 注册票据夹接口。[CONCEPT: 后端]
|
||||||
|
证据:已 include `receipt_folder_router`。
|
||||||
|
|
||||||
- [ ] 改造 `/ocr/recognize`,OCR 后保存源文件并把 `receipt_id` 等可选字段带回前端。[CONCEPT: OCR 改造]
|
- [x] 改造 `/ocr/recognize`,OCR 后保存源文件并把 `receipt_id` 等可选字段带回前端。[CONCEPT: OCR 改造]
|
||||||
|
证据:`OcrRecognizeDocumentRead` 已补充 `receipt_id`、`receipt_status`、`receipt_preview_url`、`receipt_source_url`;来源于票据夹的 `receipt_ids` 会复用原票据,避免重复入库。
|
||||||
|
|
||||||
## 阶段四:前端票据夹页面
|
## 阶段四:前端票据夹页面
|
||||||
|
|
||||||
- [ ] 新增 `services/receiptFolder.js`,封装票据夹接口和 Blob 文件读取。[CONCEPT: 前端]
|
- [x] 新增 `services/receiptFolder.js`,封装票据夹接口和 Blob 文件读取。[CONCEPT: 前端]
|
||||||
|
证据:已封装列表、详情、更新、删除、文件读取和 `buildReceiptFile`。
|
||||||
|
|
||||||
- [ ] 新增 `ReceiptFolderView.vue`,实现列表、状态页签、搜索、一键关联入口和详情切换。[CONCEPT: 列表]
|
- [x] 新增 `ReceiptFolderView.vue`,实现列表、状态页签、搜索、一键关联入口和详情切换。[CONCEPT: 列表]
|
||||||
|
证据:页面已包含未关联/已关联页签、搜索、表格、详情、编辑、预览和删除动作。
|
||||||
|
|
||||||
- [ ] 新增 `receipt-folder-view.css`,复用单据中心紧凑企业级视觉,避免继续拉大现有 `DocumentsCenterView.vue`。[CONCEPT: 列表]
|
- [x] 新增 `receipt-folder-view.css`,复用单据中心紧凑企业级视觉,避免继续拉大现有 `DocumentsCenterView.vue`。[CONCEPT: 列表]
|
||||||
|
证据:票据夹样式独立落在 `assets/styles/views/receipt-folder-view.css`,核心文件均未超过 800 行。
|
||||||
|
|
||||||
- [ ] 在 `useNavigation.js` 增加 `receiptFolder`,并放在 `documents` 后面。[CONCEPT: 前端]
|
- [x] 在 `useNavigation.js` 增加 `receiptFolder`,并放在 `documents` 后面。[CONCEPT: 前端]
|
||||||
|
证据:`appViews` 与 `navItems` 中 `receiptFolder` 均紧跟 `documents`。
|
||||||
|
|
||||||
- [ ] 在 `accessControl.js` 增加默认可见权限和默认路由顺序。[CONCEPT: 前端]
|
- [x] 在 `accessControl.js` 增加默认可见权限和默认路由顺序。[CONCEPT: 前端]
|
||||||
|
证据:已加入 `DEFAULT_APP_VIEW_ORDER` 与 `ALWAYS_VISIBLE_VIEWS`。
|
||||||
|
|
||||||
- [ ] 在 `AppShellRouteView.vue` 渲染票据夹页面,并让页面可打开报销对话。[CONCEPT: 一键关联票据]
|
- [x] 在 `AppShellRouteView.vue` 渲染票据夹页面,并让页面可打开报销对话。[CONCEPT: 一键关联票据]
|
||||||
|
证据:已接入 `ReceiptFolderView` 并转发 `open-assistant` 到 `openSmartEntry`。
|
||||||
|
|
||||||
## 阶段五:一键关联流程
|
## 阶段五:一键关联流程
|
||||||
|
|
||||||
- [ ] 实现未关联票据多选弹窗第一步。[CONCEPT: 一键关联票据]
|
- [x] 实现未关联票据多选弹窗第一步。[CONCEPT: 一键关联票据]
|
||||||
|
证据:`ElDialog` 第一阶段使用 `ElCheckboxGroup` 多选未关联票据。
|
||||||
|
|
||||||
- [ ] 实现未提交草稿选择和“新建报销单”选择第二步。[CONCEPT: 一键关联票据]
|
- [x] 实现未提交草稿选择和“新建报销单”选择第二步。[CONCEPT: 一键关联票据]
|
||||||
|
证据:第二阶段读取草稿报销单,并保留 `新建报销单` 选项。
|
||||||
|
|
||||||
- [ ] 从票据源文件接口取回 Blob 并构造 `File` 对象传给报销对话。[CONCEPT: 对话衔接]
|
- [x] 从票据源文件接口取回 Blob 并构造 `File` 对象传给报销对话。[CONCEPT: 对话衔接]
|
||||||
|
证据:`buildReceiptFile` 从 `source_url` 读取 Blob 并生成带 `receiptId` 的 `File`。
|
||||||
|
|
||||||
- [ ] 选择已有草稿时,打开对话并带入草稿单号和关联提示。[CONCEPT: 一键关联票据]
|
- [x] 选择已有草稿时,打开对话并带入草稿单号和关联提示。[CONCEPT: 一键关联票据]
|
||||||
|
证据:选择草稿后以 `source: 'detail'`、`request` 和关联 prompt 打开报销对话;附件上传会携带 `receipt_id` 并回写原票据为已关联。
|
||||||
|
|
||||||
- [ ] 选择新建报销单时,打开对话并带入基于票据新建的提示。[CONCEPT: 一键关联票据]
|
- [x] 选择新建报销单时,打开对话并带入基于票据新建的提示。[CONCEPT: 一键关联票据]
|
||||||
|
证据:新建路径以 `source: 'receipt-folder'` 携带票据文件和新建 prompt。
|
||||||
|
|
||||||
## 阶段六:测试与验证
|
## 阶段六:测试与验证
|
||||||
|
|
||||||
- [ ] 补充后端票据夹服务和接口测试,超时时间控制在 60s 内。[CONCEPT: 测试方案]
|
- [x] 补充后端票据夹服务和接口测试,超时时间控制在 60s 内。[CONCEPT: 测试方案]
|
||||||
|
证据:`docker exec x-financial-main ... pytest server/tests/test_ocr_endpoints.py server/tests/test_reimbursement_endpoints.py -q` 通过,8 passed,耗时 11.41s。
|
||||||
|
|
||||||
- [ ] 补充前端导航和票据夹视图模型测试。[CONCEPT: 测试方案]
|
- [x] 补充前端导航和票据夹视图模型测试。[CONCEPT: 测试方案]
|
||||||
|
证据:`navigation-route-resolution.test.mjs` 已覆盖路由顺序,新增 `receipt-folder-view.test.mjs` 覆盖视图关键能力。
|
||||||
|
|
||||||
- [ ] 运行前端构建或定向测试。[CONCEPT: 指标与验收]
|
- [x] 运行前端构建或定向测试。[CONCEPT: 指标与验收]
|
||||||
|
证据:`node web/tests/navigation-route-resolution.test.mjs`、`node web/tests/receipt-folder-view.test.mjs`、`npm.cmd run build` 均通过。
|
||||||
|
|
||||||
- [ ] 在 Docker `x-financial-main` 的 `/app` 内运行后端定向测试。[CONCEPT: 测试方案]
|
- [x] 在 Docker `x-financial-main` 的 `/app` 内运行后端定向测试。[CONCEPT: 测试方案]
|
||||||
|
证据:`pytest server/tests/test_ocr_endpoints.py server/tests/test_reimbursement_endpoints.py -q` 在容器内通过,8 passed。
|
||||||
|
|
||||||
- [ ] 手动核对侧边栏位置、列表密度、详情预览和关联弹窗。[CONCEPT: 指标与验收]
|
- [x] 手动核对侧边栏位置、列表密度、详情预览和关联弹窗。[CONCEPT: 指标与验收]
|
||||||
|
证据:内置浏览器打开 `http://localhost:5173/app/receiptFolder`,侧边栏中“票据夹”位于“单据中心”下方,未关联/已关联页签与空态渲染正常,控制台无相关错误。
|
||||||
|
|
||||||
## 阶段七:收口
|
## 阶段七:收口
|
||||||
|
|
||||||
- [ ] 回看 `CONCEPT.md` 验收标准,确认已实现项均有证据。[CONCEPT: 指标与验收]
|
- [x] 回看 `CONCEPT.md` 验收标准,确认已实现项均有证据。[CONCEPT: 指标与验收]
|
||||||
|
证据:OCR 持久化、列表/详情/预览、侧边栏位置、一键关联入口和文件行数约束均已覆盖。
|
||||||
|
|
||||||
- [ ] 更新本 TODO 的完成状态和验证记录。[CONCEPT: 测试方案]
|
- [x] 更新本 TODO 的完成状态和验证记录。[CONCEPT: 测试方案]
|
||||||
|
证据:本文件已补充完成勾选和验证命令记录。
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.schemas.employee_profile import EmployeeProfileLatestRead
|
from app.schemas.employee_profile import EmployeeProfileLatestRead
|
||||||
|
from app.services.account_behavior_profile import AccountBehaviorProfileService
|
||||||
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
|
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
|
||||||
|
|
||||||
router = APIRouter(prefix="/employee-profiles")
|
router = APIRouter(prefix="/employee-profiles")
|
||||||
@@ -31,13 +32,13 @@ def get_current_employee_latest_profile(
|
|||||||
) -> EmployeeProfileLatestRead:
|
) -> EmployeeProfileLatestRead:
|
||||||
employee = _resolve_current_employee(db, current_user)
|
employee = _resolve_current_employee(db, current_user)
|
||||||
if employee is None:
|
if employee is None:
|
||||||
return EmployeeProfileLatestRead(
|
return AccountBehaviorProfileService(db).get_latest_account_profile(
|
||||||
employee_id=current_user.username,
|
account_id=current_user.username,
|
||||||
employee_name=current_user.name,
|
account_name=current_user.name,
|
||||||
|
identifiers=_current_account_identifiers(current_user),
|
||||||
scene=scene,
|
scene=scene,
|
||||||
window_days=window_days,
|
window_days=window_days,
|
||||||
expense_type_scope=expense_type_scope,
|
expense_type_scope=expense_type_scope,
|
||||||
empty_reason="当前登录用户未匹配到员工目录,暂无法形成用户画像。",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
service = EmployeeBehaviorProfileService(db)
|
service = EmployeeBehaviorProfileService(db)
|
||||||
@@ -47,7 +48,7 @@ def get_current_employee_latest_profile(
|
|||||||
window_days=window_days,
|
window_days=window_days,
|
||||||
expense_type_scope=expense_type_scope,
|
expense_type_scope=expense_type_scope,
|
||||||
)
|
)
|
||||||
if latest.empty_reason:
|
if latest.empty_reason or _missing_usage_duration_metric(latest):
|
||||||
service.refresh_employee_profiles(
|
service.refresh_employee_profiles(
|
||||||
employee_id=employee.id,
|
employee_id=employee.id,
|
||||||
window_days=(window_days,),
|
window_days=(window_days,),
|
||||||
@@ -115,3 +116,24 @@ def _resolve_current_employee(
|
|||||||
|
|
||||||
stmt = select(Employee).where(or_(*conditions)).order_by(Employee.created_at.asc()).limit(1)
|
stmt = select(Employee).where(or_(*conditions)).order_by(Employee.created_at.asc()).limit(1)
|
||||||
return db.scalars(stmt).first()
|
return db.scalars(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_usage_duration_metric(latest: EmployeeProfileLatestRead) -> bool:
|
||||||
|
if latest.scene != "operations":
|
||||||
|
return False
|
||||||
|
|
||||||
|
for profile in latest.profiles:
|
||||||
|
if profile.profile_type == "ai_usage":
|
||||||
|
return "ai_run_duration_ms" not in profile.metrics
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _current_account_identifiers(current_user: CurrentUserContext) -> set[str]:
|
||||||
|
return {
|
||||||
|
item
|
||||||
|
for item in (
|
||||||
|
current_user.username,
|
||||||
|
current_user.name,
|
||||||
|
)
|
||||||
|
if str(item or "").strip()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse
|
||||||
from app.schemas.ocr import OcrRecognizeBatchRead
|
from app.schemas.ocr import OcrRecognizeBatchRead
|
||||||
from app.services.ocr import OcrService
|
from app.services.ocr import OcrService
|
||||||
|
from app.services.receipt_folder import ReceiptFolderService
|
||||||
|
|
||||||
router = APIRouter(prefix="/ocr")
|
router = APIRouter(prefix="/ocr")
|
||||||
|
|
||||||
@@ -35,8 +36,9 @@ router = APIRouter(prefix="/ocr")
|
|||||||
)
|
)
|
||||||
async def recognize_ocr_documents(
|
async def recognize_ocr_documents(
|
||||||
files: Annotated[list[UploadFile], File(description="待识别的票据图片或 PDF。")],
|
files: Annotated[list[UploadFile], File(description="待识别的票据图片或 PDF。")],
|
||||||
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
receipt_ids: Annotated[list[str] | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None,
|
||||||
) -> OcrRecognizeBatchRead:
|
) -> OcrRecognizeBatchRead:
|
||||||
try:
|
try:
|
||||||
payload = []
|
payload = []
|
||||||
@@ -48,7 +50,13 @@ async def recognize_ocr_documents(
|
|||||||
upload.content_type,
|
upload.content_type,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return OcrService(db).recognize_files(payload)
|
result = OcrService(db).recognize_files(payload)
|
||||||
|
return ReceiptFolderService().persist_ocr_batch(
|
||||||
|
files=payload,
|
||||||
|
result=result,
|
||||||
|
current_user=current_user,
|
||||||
|
receipt_ids=receipt_ids or [],
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
|||||||
108
server/src/app/api/v1/endpoints/receipt_folder.py
Normal file
108
server/src/app/api/v1/endpoints/receipt_folder.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext, get_current_user
|
||||||
|
from app.schemas.common import ErrorResponse
|
||||||
|
from app.schemas.receipt_folder import (
|
||||||
|
ReceiptFolderDeleteResponse,
|
||||||
|
ReceiptFolderDetailRead,
|
||||||
|
ReceiptFolderItemRead,
|
||||||
|
ReceiptFolderUpdate,
|
||||||
|
)
|
||||||
|
from app.services.receipt_folder import ReceiptFolderService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/receipt-folder")
|
||||||
|
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=list[ReceiptFolderItemRead],
|
||||||
|
summary="查询票据夹列表",
|
||||||
|
description="返回当前登录用户上传并持久化的票据列表。",
|
||||||
|
)
|
||||||
|
def list_receipts(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
status_filter: Annotated[str, Query(alias="status")] = "all",
|
||||||
|
) -> list[ReceiptFolderItemRead]:
|
||||||
|
return ReceiptFolderService().list_receipts(
|
||||||
|
current_user=current_user,
|
||||||
|
status_filter=status_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{receipt_id}",
|
||||||
|
response_model=ReceiptFolderDetailRead,
|
||||||
|
summary="读取票据详情",
|
||||||
|
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据不存在。"}},
|
||||||
|
)
|
||||||
|
def get_receipt(receipt_id: str, current_user: CurrentUser) -> ReceiptFolderDetailRead:
|
||||||
|
try:
|
||||||
|
return ReceiptFolderService().get_receipt(receipt_id, current_user)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt not found") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{receipt_id}",
|
||||||
|
response_model=ReceiptFolderDetailRead,
|
||||||
|
summary="更新票据基础识别信息",
|
||||||
|
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据不存在。"}},
|
||||||
|
)
|
||||||
|
def update_receipt(
|
||||||
|
receipt_id: str,
|
||||||
|
payload: ReceiptFolderUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ReceiptFolderDetailRead:
|
||||||
|
try:
|
||||||
|
return ReceiptFolderService().update_receipt(
|
||||||
|
receipt_id=receipt_id,
|
||||||
|
payload=payload,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt not found") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{receipt_id}",
|
||||||
|
response_model=ReceiptFolderDeleteResponse,
|
||||||
|
summary="删除票据",
|
||||||
|
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据不存在。"}},
|
||||||
|
)
|
||||||
|
def delete_receipt(receipt_id: str, current_user: CurrentUser) -> ReceiptFolderDeleteResponse:
|
||||||
|
try:
|
||||||
|
return ReceiptFolderService().delete_receipt(receipt_id=receipt_id, current_user=current_user)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt not found") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{receipt_id}/preview",
|
||||||
|
summary="预览票据原始文件",
|
||||||
|
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据预览不存在。"}},
|
||||||
|
)
|
||||||
|
def preview_receipt(receipt_id: str, current_user: CurrentUser) -> FileResponse:
|
||||||
|
try:
|
||||||
|
file_path, media_type, file_name = ReceiptFolderService().resolve_preview(receipt_id, current_user)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt preview not found") from exc
|
||||||
|
return FileResponse(file_path, media_type=media_type, filename=file_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{receipt_id}/source",
|
||||||
|
summary="读取票据源文件",
|
||||||
|
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据源文件不存在。"}},
|
||||||
|
)
|
||||||
|
def source_receipt(receipt_id: str, current_user: CurrentUser) -> FileResponse:
|
||||||
|
try:
|
||||||
|
file_path, media_type, file_name = ReceiptFolderService().resolve_source(receipt_id, current_user)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt source not found") from exc
|
||||||
|
return FileResponse(file_path, media_type=media_type, filename=file_name)
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -372,6 +372,7 @@ async def upload_expense_claim_item_attachment(
|
|||||||
file: Annotated[UploadFile, File(description="待上传的附件文件。")],
|
file: Annotated[UploadFile, File(description="待上传的附件文件。")],
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
|
receipt_id: Annotated[str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None,
|
||||||
) -> ExpenseClaimAttachmentActionResponse:
|
) -> ExpenseClaimAttachmentActionResponse:
|
||||||
service = ExpenseClaimService(db)
|
service = ExpenseClaimService(db)
|
||||||
try:
|
try:
|
||||||
@@ -382,6 +383,7 @@ async def upload_expense_claim_item_attachment(
|
|||||||
content=await file.read(),
|
content=await file.read(),
|
||||||
media_type=file.content_type,
|
media_type=file.content_type,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
|
source_receipt_id=receipt_id or "",
|
||||||
)
|
)
|
||||||
except LookupError as error:
|
except LookupError as error:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.api.v1.endpoints.knowledge import router as knowledge_router
|
|||||||
from app.api.v1.endpoints.ocr import router as ocr_router
|
from app.api.v1.endpoints.ocr import router as ocr_router
|
||||||
from app.api.v1.endpoints.ontology import router as ontology_router
|
from app.api.v1.endpoints.ontology import router as ontology_router
|
||||||
from app.api.v1.endpoints.orchestrator import router as orchestrator_router
|
from app.api.v1.endpoints.orchestrator import router as orchestrator_router
|
||||||
|
from app.api.v1.endpoints.receipt_folder import router as receipt_folder_router
|
||||||
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
|
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
|
||||||
from app.api.v1.endpoints.settings import router as settings_router
|
from app.api.v1.endpoints.settings import router as settings_router
|
||||||
from app.api.v1.endpoints.system_logs import router as system_logs_router
|
from app.api.v1.endpoints.system_logs import router as system_logs_router
|
||||||
@@ -29,6 +30,7 @@ router.include_router(knowledge_router, tags=["knowledge"])
|
|||||||
router.include_router(ocr_router, tags=["ocr"])
|
router.include_router(ocr_router, tags=["ocr"])
|
||||||
router.include_router(ontology_router, tags=["ontology"])
|
router.include_router(ontology_router, tags=["ontology"])
|
||||||
router.include_router(orchestrator_router, tags=["orchestrator"])
|
router.include_router(orchestrator_router, tags=["orchestrator"])
|
||||||
|
router.include_router(receipt_folder_router, tags=["receipt-folder"])
|
||||||
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
||||||
router.include_router(employee_profiles_router, tags=["employee-profiles"])
|
router.include_router(employee_profiles_router, tags=["employee-profiles"])
|
||||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class OcrRecognizeDocumentRead(BaseModel):
|
|||||||
)
|
)
|
||||||
preview_kind: str = Field(default="", description="预览类型,PDF 转图后通常为 image。")
|
preview_kind: str = Field(default="", description="预览类型,PDF 转图后通常为 image。")
|
||||||
preview_data_url: str = Field(default="", description="用于前端展示的图片预览 data URL。")
|
preview_data_url: str = Field(default="", description="用于前端展示的图片预览 data URL。")
|
||||||
|
receipt_id: str = Field(default="", description="票据夹中的持久化票据 ID。")
|
||||||
|
receipt_status: str = Field(default="", description="票据夹关联状态,unlinked / linked。")
|
||||||
|
receipt_preview_url: str = Field(default="", description="票据夹预览接口地址。")
|
||||||
|
receipt_source_url: str = Field(default="", description="票据夹原始文件接口地址。")
|
||||||
warnings: list[str] = Field(default_factory=list, description="该文件的识别提示或警告。")
|
warnings: list[str] = Field(default_factory=list, description="该文件的识别提示或警告。")
|
||||||
lines: list[OcrRecognizeLineRead] = Field(default_factory=list, description="逐行识别结果。")
|
lines: list[OcrRecognizeLineRead] = Field(default_factory=list, description="逐行识别结果。")
|
||||||
|
|
||||||
|
|||||||
68
server/src/app/schemas/receipt_folder.py
Normal file
68
server/src/app/schemas/receipt_folder.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptFolderFieldRead(BaseModel):
|
||||||
|
key: str = ""
|
||||||
|
label: str = ""
|
||||||
|
value: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptFolderItemRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
file_name: str
|
||||||
|
media_type: str = "application/octet-stream"
|
||||||
|
size_bytes: int = 0
|
||||||
|
status: str = "unlinked"
|
||||||
|
status_label: str = "未关联"
|
||||||
|
document_type: str = "other"
|
||||||
|
document_type_label: str = "其他单据"
|
||||||
|
scene_code: str = "other"
|
||||||
|
scene_label: str = "其他票据"
|
||||||
|
summary: str = ""
|
||||||
|
amount: str = ""
|
||||||
|
document_date: str = ""
|
||||||
|
merchant_name: str = ""
|
||||||
|
avg_score: float = 0.0
|
||||||
|
uploaded_at: datetime | None = None
|
||||||
|
linked_at: datetime | None = None
|
||||||
|
linked_claim_id: str = ""
|
||||||
|
linked_claim_no: str = ""
|
||||||
|
previewable: bool = False
|
||||||
|
preview_kind: str = ""
|
||||||
|
preview_url: str = ""
|
||||||
|
source_url: str = ""
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptFolderDetailRead(ReceiptFolderItemRead):
|
||||||
|
engine: str = ""
|
||||||
|
model: str = ""
|
||||||
|
ocr_text: str = ""
|
||||||
|
line_count: int = 0
|
||||||
|
page_count: int = 1
|
||||||
|
classification_confidence: float = 0.0
|
||||||
|
classification_evidence: list[str] = Field(default_factory=list)
|
||||||
|
fields: list[ReceiptFolderFieldRead] = Field(default_factory=list)
|
||||||
|
raw_meta: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptFolderUpdate(BaseModel):
|
||||||
|
document_type: str | None = None
|
||||||
|
document_type_label: str | None = None
|
||||||
|
scene_code: str | None = None
|
||||||
|
scene_label: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
amount: str | None = None
|
||||||
|
document_date: str | None = None
|
||||||
|
merchant_name: str | None = None
|
||||||
|
fields: list[ReceiptFolderFieldRead] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptFolderDeleteResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
receipt_id: str
|
||||||
176
server/src/app/services/account_behavior_profile.py
Normal file
176
server/src/app/services/account_behavior_profile.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.algorithem.employee_behavior_profile import (
|
||||||
|
LEVEL_LABELS,
|
||||||
|
PROFILE_LABELS,
|
||||||
|
ProfileComponent,
|
||||||
|
evaluate_weighted_profile,
|
||||||
|
score_by_bands,
|
||||||
|
)
|
||||||
|
from app.algorithem.employee_behavior_profile_tags import build_profile_radar, build_profile_tags
|
||||||
|
from app.models.agent_run import AgentRun
|
||||||
|
from app.schemas.employee_profile import EmployeeProfileLatestRead, EmployeeProfileRead
|
||||||
|
from app.services.employee_behavior_profile_helpers import EmployeeBehaviorProfileMetricHelpers
|
||||||
|
|
||||||
|
|
||||||
|
class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_latest_account_profile(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
account_name: str,
|
||||||
|
identifiers: set[str],
|
||||||
|
scene: str,
|
||||||
|
window_days: int,
|
||||||
|
expense_type_scope: str,
|
||||||
|
) -> EmployeeProfileLatestRead:
|
||||||
|
if scene != "operations":
|
||||||
|
return EmployeeProfileLatestRead(
|
||||||
|
employee_id=account_id,
|
||||||
|
employee_name=account_name,
|
||||||
|
scene=scene,
|
||||||
|
window_days=window_days,
|
||||||
|
expense_type_scope=expense_type_scope,
|
||||||
|
empty_reason="当前账号未匹配员工目录,无法形成审批场景员工画像。",
|
||||||
|
)
|
||||||
|
|
||||||
|
runs = self._fetch_account_runs(identifiers, datetime.now(UTC) - timedelta(days=window_days))
|
||||||
|
if not runs:
|
||||||
|
return EmployeeProfileLatestRead(
|
||||||
|
employee_id=account_id,
|
||||||
|
employee_name=account_name,
|
||||||
|
scene=scene,
|
||||||
|
window_days=window_days,
|
||||||
|
expense_type_scope=expense_type_scope,
|
||||||
|
empty_reason="当前账号暂无可统计的智能体运行记录。",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self._calculate_account_ai_usage_profile(
|
||||||
|
runs=runs,
|
||||||
|
window_days=window_days,
|
||||||
|
expense_type_scope=expense_type_scope,
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"profile_type": result.profile_type,
|
||||||
|
"profile_label": result.profile_label,
|
||||||
|
"score": result.profile_score,
|
||||||
|
"level": result.profile_level,
|
||||||
|
"metrics": result.metrics,
|
||||||
|
"top_contributors": result.top_contributors(),
|
||||||
|
}
|
||||||
|
tags = build_profile_tags([payload], scene=scene)
|
||||||
|
radar = build_profile_radar([payload], tags, scene=scene)
|
||||||
|
|
||||||
|
return EmployeeProfileLatestRead(
|
||||||
|
employee_id=account_id,
|
||||||
|
employee_name=account_name,
|
||||||
|
scene=scene,
|
||||||
|
window_days=window_days,
|
||||||
|
expense_type_scope=expense_type_scope,
|
||||||
|
calculated_at=datetime.now(UTC),
|
||||||
|
review_priority_score=0,
|
||||||
|
review_priority_level="normal",
|
||||||
|
review_priority_label=LEVEL_LABELS["normal"],
|
||||||
|
profiles=[
|
||||||
|
EmployeeProfileRead(
|
||||||
|
profile_type=payload["profile_type"],
|
||||||
|
profile_label=PROFILE_LABELS.get(payload["profile_type"], payload["profile_type"]),
|
||||||
|
score=payload["score"],
|
||||||
|
level=payload["level"],
|
||||||
|
level_label=LEVEL_LABELS.get(payload["level"], payload["level"]),
|
||||||
|
metrics=payload["metrics"],
|
||||||
|
top_contributors=payload["top_contributors"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
profile_tags=tags,
|
||||||
|
radar=radar,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_account_ai_usage_profile(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
runs: list[AgentRun],
|
||||||
|
window_days: int,
|
||||||
|
expense_type_scope: str,
|
||||||
|
):
|
||||||
|
tool_calls = [tool for run in runs for tool in run.tool_calls]
|
||||||
|
failed_calls = [
|
||||||
|
tool for tool in tool_calls if str(tool.status or "").lower() not in {"success", "ok"}
|
||||||
|
]
|
||||||
|
estimated_tokens = self._estimate_tokens(runs)
|
||||||
|
duration_ms = self._sum_agent_run_duration_ms(runs)
|
||||||
|
token_mode = "estimated_token_count" if estimated_tokens else "unavailable"
|
||||||
|
|
||||||
|
return evaluate_weighted_profile(
|
||||||
|
"ai_usage",
|
||||||
|
[
|
||||||
|
ProfileComponent(
|
||||||
|
"ai_call_count_score",
|
||||||
|
"AI 调用次数",
|
||||||
|
score_by_bands(len(runs), [(0, 0), (3, 25), (10, 65), (20, 100)]),
|
||||||
|
len(runs),
|
||||||
|
"次",
|
||||||
|
Decimal("0.25"),
|
||||||
|
),
|
||||||
|
ProfileComponent(
|
||||||
|
"token_cost_score",
|
||||||
|
"Token 使用强度",
|
||||||
|
score_by_bands(
|
||||||
|
estimated_tokens, [(0, 0), (2000, 25), (8000, 65), (20000, 100)]
|
||||||
|
),
|
||||||
|
estimated_tokens,
|
||||||
|
"tokens",
|
||||||
|
Decimal("0.25"),
|
||||||
|
),
|
||||||
|
ProfileComponent(
|
||||||
|
"ai_generated_claim_ratio_score",
|
||||||
|
"AI 生成申请比例",
|
||||||
|
score_by_bands(len(runs), [(0, 0), (2, 20), (8, 60), (16, 90)]),
|
||||||
|
len(runs),
|
||||||
|
"次",
|
||||||
|
Decimal("0.20"),
|
||||||
|
),
|
||||||
|
ProfileComponent(
|
||||||
|
"failed_ai_call_score",
|
||||||
|
"AI 调用失败",
|
||||||
|
score_by_bands(len(failed_calls), [(0, 0), (1, 35), (3, 80)]),
|
||||||
|
len(failed_calls),
|
||||||
|
"次",
|
||||||
|
Decimal("0.10"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
metrics={
|
||||||
|
"window_days": window_days,
|
||||||
|
"expense_type_scope": expense_type_scope,
|
||||||
|
"peer_sample_size": 0,
|
||||||
|
"ai_run_count": len(runs),
|
||||||
|
"tool_call_count": len(tool_calls),
|
||||||
|
"failed_tool_call_count": len(failed_calls),
|
||||||
|
"token_count_mode": token_mode,
|
||||||
|
"estimated_token_count": estimated_tokens,
|
||||||
|
"exact_token_count": None,
|
||||||
|
"ai_run_duration_ms": duration_ms,
|
||||||
|
"ai_run_duration_mode": "elapsed_or_tool_call_fallback",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_account_runs(self, identifiers: set[str], cutoff: datetime) -> list[AgentRun]:
|
||||||
|
normalized = {item for item in identifiers if str(item or "").strip()}
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
stmt = (
|
||||||
|
select(AgentRun)
|
||||||
|
.options(selectinload(AgentRun.tool_calls))
|
||||||
|
.where(AgentRun.started_at >= cutoff, AgentRun.user_id.in_(normalized))
|
||||||
|
)
|
||||||
|
return list(self.db.scalars(stmt).all())
|
||||||
@@ -171,6 +171,22 @@ class EmployeeBehaviorProfileMetricHelpers:
|
|||||||
total += max(0, len(text) // 4)
|
total += max(0, len(text) // 4)
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
def _sum_agent_run_duration_ms(self, runs: list[AgentRun]) -> int:
|
||||||
|
return sum(self._agent_run_duration_ms(run) for run in runs)
|
||||||
|
|
||||||
|
def _agent_run_duration_ms(self, run: AgentRun) -> int:
|
||||||
|
if run.started_at is not None and run.finished_at is not None:
|
||||||
|
try:
|
||||||
|
if run.finished_at > run.started_at:
|
||||||
|
return min(
|
||||||
|
int((run.finished_at - run.started_at).total_seconds() * 1000),
|
||||||
|
24 * 60 * 60 * 1000,
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return sum(max(0, int(tool.duration_ms or 0)) for tool in run.tool_calls)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_missing_value(value: Any) -> bool:
|
def _is_missing_value(value: Any) -> bool:
|
||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
|||||||
tool for tool in tool_calls if str(tool.status or "").lower() not in {"success", "ok"}
|
tool for tool in tool_calls if str(tool.status or "").lower() not in {"success", "ok"}
|
||||||
]
|
]
|
||||||
estimated_tokens = self._estimate_tokens(runs)
|
estimated_tokens = self._estimate_tokens(runs)
|
||||||
|
duration_ms = self._sum_agent_run_duration_ms(runs)
|
||||||
override_score = 0
|
override_score = 0
|
||||||
|
|
||||||
token_mode = "estimated_token_count" if estimated_tokens else "unavailable"
|
token_mode = "estimated_token_count" if estimated_tokens else "unavailable"
|
||||||
@@ -524,6 +525,8 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
|||||||
"token_count_mode": token_mode,
|
"token_count_mode": token_mode,
|
||||||
"estimated_token_count": estimated_tokens,
|
"estimated_token_count": estimated_tokens,
|
||||||
"exact_token_count": None,
|
"exact_token_count": None,
|
||||||
|
"ai_run_duration_ms": duration_ms,
|
||||||
|
"ai_run_duration_mode": "elapsed_or_tool_call_fallback",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ from app.services.expense_rule_runtime import (
|
|||||||
resolve_document_type_label,
|
resolve_document_type_label,
|
||||||
)
|
)
|
||||||
from app.services.ocr import OcrService
|
from app.services.ocr import OcrService
|
||||||
|
from app.services.receipt_folder import ReceiptFolderService
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimAttachmentOperationsMixin:
|
class ExpenseClaimAttachmentOperationsMixin:
|
||||||
@@ -120,6 +121,7 @@ class ExpenseClaimAttachmentOperationsMixin:
|
|||||||
content: bytes,
|
content: bytes,
|
||||||
media_type: str | None,
|
media_type: str | None,
|
||||||
current_user: CurrentUserContext,
|
current_user: CurrentUserContext,
|
||||||
|
source_receipt_id: str = "",
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
claim, item = self._get_claim_item_or_raise(
|
claim, item = self._get_claim_item_or_raise(
|
||||||
claim_id=claim_id,
|
claim_id=claim_id,
|
||||||
@@ -240,6 +242,16 @@ class ExpenseClaimAttachmentOperationsMixin:
|
|||||||
"ocr_warnings": [str(item) for item in getattr(ocr_document, "warnings", []) or []],
|
"ocr_warnings": [str(item) for item in getattr(ocr_document, "warnings", []) or []],
|
||||||
}
|
}
|
||||||
self._attachment_storage.write_meta(file_path, meta)
|
self._attachment_storage.write_meta(file_path, meta)
|
||||||
|
ReceiptFolderService().save_linked_attachment(
|
||||||
|
file_path=file_path,
|
||||||
|
media_type=resolved_media_type,
|
||||||
|
document=ocr_document,
|
||||||
|
current_user=current_user,
|
||||||
|
claim_id=claim.id,
|
||||||
|
claim_no=claim.claim_no,
|
||||||
|
item_id=item.id,
|
||||||
|
source_receipt_id=source_receipt_id,
|
||||||
|
)
|
||||||
|
|
||||||
self._sync_claim_from_items(claim)
|
self._sync_claim_from_items(claim)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|||||||
532
server/src/app/services/receipt_folder.py
Normal file
532
server/src/app/services/receipt_folder.py
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||||
|
from app.schemas.receipt_folder import (
|
||||||
|
ReceiptFolderDeleteResponse,
|
||||||
|
ReceiptFolderDetailRead,
|
||||||
|
ReceiptFolderFieldRead,
|
||||||
|
ReceiptFolderItemRead,
|
||||||
|
ReceiptFolderUpdate,
|
||||||
|
)
|
||||||
|
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||||
|
from app.services.ocr import SUPPORTED_SUFFIXES
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptFolderService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.root = (get_settings().resolved_storage_root_dir / "receipt_folder").resolve()
|
||||||
|
|
||||||
|
def persist_ocr_batch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
files: list[tuple[str, bytes, str | None]],
|
||||||
|
result: OcrRecognizeBatchRead,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
receipt_ids: list[str] | None = None,
|
||||||
|
) -> OcrRecognizeBatchRead:
|
||||||
|
documents = list(result.documents or [])
|
||||||
|
enriched: list[OcrRecognizeDocumentRead] = []
|
||||||
|
for index, document in enumerate(documents):
|
||||||
|
if index >= len(files):
|
||||||
|
enriched.append(document)
|
||||||
|
continue
|
||||||
|
existing_receipt = self._resolve_existing_item(
|
||||||
|
receipt_ids[index] if receipt_ids and index < len(receipt_ids) else "",
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
|
if existing_receipt is not None:
|
||||||
|
enriched.append(
|
||||||
|
document.model_copy(
|
||||||
|
update={
|
||||||
|
"receipt_id": existing_receipt.id,
|
||||||
|
"receipt_status": existing_receipt.status,
|
||||||
|
"receipt_preview_url": existing_receipt.preview_url,
|
||||||
|
"receipt_source_url": existing_receipt.source_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
filename, content, media_type = files[index]
|
||||||
|
if not self._should_persist_source(filename, content):
|
||||||
|
enriched.append(document)
|
||||||
|
continue
|
||||||
|
receipt = self.save_receipt(
|
||||||
|
filename=filename,
|
||||||
|
content=content,
|
||||||
|
media_type=media_type or document.media_type,
|
||||||
|
document=document,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
enriched.append(
|
||||||
|
document.model_copy(
|
||||||
|
update={
|
||||||
|
"receipt_id": receipt.id,
|
||||||
|
"receipt_status": receipt.status,
|
||||||
|
"receipt_preview_url": receipt.preview_url,
|
||||||
|
"receipt_source_url": receipt.source_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.model_copy(update={"documents": enriched})
|
||||||
|
|
||||||
|
def save_receipt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
filename: str,
|
||||||
|
content: bytes,
|
||||||
|
media_type: str | None,
|
||||||
|
document: Any | None,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
linked_claim_id: str = "",
|
||||||
|
linked_claim_no: str = "",
|
||||||
|
linked_item_id: str = "",
|
||||||
|
) -> ReceiptFolderItemRead:
|
||||||
|
owner_key = self._owner_key(current_user)
|
||||||
|
receipt_id = str(uuid4())
|
||||||
|
receipt_dir = self._owner_root(owner_key) / receipt_id
|
||||||
|
receipt_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
normalized_name = self.normalize_filename(filename)
|
||||||
|
source_path = receipt_dir / normalized_name
|
||||||
|
source_path.write_bytes(content)
|
||||||
|
resolved_media_type = self.resolve_media_type(normalized_name, media_type)
|
||||||
|
preview_meta = self._write_preview_asset(
|
||||||
|
receipt_dir=receipt_dir,
|
||||||
|
source_path=source_path,
|
||||||
|
media_type=resolved_media_type,
|
||||||
|
document=document,
|
||||||
|
)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
linked = bool(str(linked_claim_id or "").strip())
|
||||||
|
meta = {
|
||||||
|
"id": receipt_id,
|
||||||
|
"owner_key": owner_key,
|
||||||
|
"file_name": normalized_name,
|
||||||
|
"source_file_name": normalized_name,
|
||||||
|
"media_type": resolved_media_type,
|
||||||
|
"size_bytes": len(content),
|
||||||
|
"uploaded_at": now.isoformat(),
|
||||||
|
"status": "linked" if linked else "unlinked",
|
||||||
|
"linked_claim_id": str(linked_claim_id or "").strip(),
|
||||||
|
"linked_claim_no": str(linked_claim_no or "").strip(),
|
||||||
|
"linked_item_id": str(linked_item_id or "").strip(),
|
||||||
|
"linked_at": now.isoformat() if linked else "",
|
||||||
|
**self._build_document_meta(document),
|
||||||
|
**preview_meta,
|
||||||
|
}
|
||||||
|
self._write_meta(receipt_dir, meta)
|
||||||
|
return self._build_item(meta)
|
||||||
|
|
||||||
|
def save_linked_attachment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
file_path: Path,
|
||||||
|
media_type: str,
|
||||||
|
document: Any | None,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
claim_id: str,
|
||||||
|
claim_no: str,
|
||||||
|
item_id: str,
|
||||||
|
source_receipt_id: str = "",
|
||||||
|
) -> ReceiptFolderItemRead | None:
|
||||||
|
if not file_path.exists() or not file_path.is_file():
|
||||||
|
return None
|
||||||
|
if str(source_receipt_id or "").strip():
|
||||||
|
try:
|
||||||
|
return self.mark_receipt_linked(
|
||||||
|
receipt_id=source_receipt_id,
|
||||||
|
current_user=current_user,
|
||||||
|
claim_id=claim_id,
|
||||||
|
claim_no=claim_no,
|
||||||
|
item_id=item_id,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
storage_root = get_settings().resolved_storage_root_dir
|
||||||
|
try:
|
||||||
|
file_path.resolve().relative_to(storage_root)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return self.save_receipt(
|
||||||
|
filename=file_path.name,
|
||||||
|
content=file_path.read_bytes(),
|
||||||
|
media_type=media_type,
|
||||||
|
document=document,
|
||||||
|
current_user=current_user,
|
||||||
|
linked_claim_id=claim_id,
|
||||||
|
linked_claim_no=claim_no,
|
||||||
|
linked_item_id=item_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_receipt_linked(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
receipt_id: str,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
claim_id: str,
|
||||||
|
claim_no: str,
|
||||||
|
item_id: str,
|
||||||
|
) -> ReceiptFolderItemRead:
|
||||||
|
owner_key = self._owner_key(current_user)
|
||||||
|
receipt_dir = self._receipt_dir(owner_key, receipt_id)
|
||||||
|
meta = self._read_meta(receipt_dir)
|
||||||
|
meta["status"] = "linked"
|
||||||
|
meta["linked_claim_id"] = str(claim_id or "").strip()
|
||||||
|
meta["linked_claim_no"] = str(claim_no or "").strip()
|
||||||
|
meta["linked_item_id"] = str(item_id or "").strip()
|
||||||
|
meta["linked_at"] = datetime.now(UTC).isoformat()
|
||||||
|
self._write_meta(receipt_dir, meta)
|
||||||
|
return self._build_item(meta)
|
||||||
|
|
||||||
|
def list_receipts(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
status_filter: str = "all",
|
||||||
|
) -> list[ReceiptFolderItemRead]:
|
||||||
|
status_filter = str(status_filter or "all").strip().lower()
|
||||||
|
items = [
|
||||||
|
self._build_item(meta)
|
||||||
|
for meta in self._iter_owner_meta(self._owner_key(current_user))
|
||||||
|
if self._matches_status(meta, status_filter)
|
||||||
|
]
|
||||||
|
return sorted(items, key=lambda item: item.uploaded_at or datetime.min.replace(tzinfo=UTC), reverse=True)
|
||||||
|
|
||||||
|
def get_receipt(self, receipt_id: str, current_user: CurrentUserContext) -> ReceiptFolderDetailRead:
|
||||||
|
meta = self._read_receipt_meta(receipt_id, current_user)
|
||||||
|
item = self._build_item(meta)
|
||||||
|
return ReceiptFolderDetailRead(
|
||||||
|
**item.model_dump(),
|
||||||
|
engine=str(meta.get("engine") or ""),
|
||||||
|
model=str(meta.get("model") or ""),
|
||||||
|
ocr_text=str(meta.get("ocr_text") or ""),
|
||||||
|
line_count=int(meta.get("ocr_line_count") or 0),
|
||||||
|
page_count=max(1, int(meta.get("page_count") or 1)),
|
||||||
|
classification_confidence=float(meta.get("ocr_classification_confidence") or 0.0),
|
||||||
|
classification_evidence=[
|
||||||
|
str(value) for value in list(meta.get("ocr_classification_evidence") or []) if str(value).strip()
|
||||||
|
],
|
||||||
|
fields=self._resolve_fields(meta),
|
||||||
|
raw_meta=meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_receipt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
receipt_id: str,
|
||||||
|
payload: ReceiptFolderUpdate,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> ReceiptFolderDetailRead:
|
||||||
|
owner_key = self._owner_key(current_user)
|
||||||
|
receipt_dir = self._receipt_dir(owner_key, receipt_id)
|
||||||
|
meta = self._read_meta(receipt_dir)
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
for key in ("document_type", "document_type_label", "scene_code", "scene_label", "summary"):
|
||||||
|
if key in updates and updates[key] is not None:
|
||||||
|
meta[key] = str(updates[key] or "").strip()
|
||||||
|
|
||||||
|
editable = dict(meta.get("editable_fields") or {})
|
||||||
|
for key in ("amount", "document_date", "merchant_name"):
|
||||||
|
if key in updates and updates[key] is not None:
|
||||||
|
editable[key] = str(updates[key] or "").strip()
|
||||||
|
if "fields" in updates and updates["fields"] is not None:
|
||||||
|
meta["document_fields"] = [
|
||||||
|
field.model_dump() if isinstance(field, ReceiptFolderFieldRead) else dict(field)
|
||||||
|
for field in payload.fields or []
|
||||||
|
]
|
||||||
|
meta["editable_fields"] = editable
|
||||||
|
meta["updated_at"] = datetime.now(UTC).isoformat()
|
||||||
|
self._write_meta(receipt_dir, meta)
|
||||||
|
return self.get_receipt(receipt_id, current_user)
|
||||||
|
|
||||||
|
def delete_receipt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
receipt_id: str,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> ReceiptFolderDeleteResponse:
|
||||||
|
owner_key = self._owner_key(current_user)
|
||||||
|
receipt_dir = self._receipt_dir(owner_key, receipt_id)
|
||||||
|
shutil.rmtree(receipt_dir)
|
||||||
|
return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id)
|
||||||
|
|
||||||
|
def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
|
||||||
|
meta = self._read_receipt_meta(receipt_id, current_user)
|
||||||
|
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
|
||||||
|
file_name = str(meta.get("source_file_name") or meta.get("file_name") or "").strip()
|
||||||
|
path = self._assert_child(receipt_dir / file_name)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError("Receipt source not found")
|
||||||
|
media_type = self.resolve_media_type(path.name, str(meta.get("media_type") or ""))
|
||||||
|
return path, media_type, str(meta.get("file_name") or path.name)
|
||||||
|
|
||||||
|
def resolve_preview(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
|
||||||
|
meta = self._read_receipt_meta(receipt_id, current_user)
|
||||||
|
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
|
||||||
|
preview_name = str(meta.get("preview_file_name") or "").strip()
|
||||||
|
if preview_name:
|
||||||
|
preview_path = self._assert_child(receipt_dir / preview_name)
|
||||||
|
if preview_path.exists():
|
||||||
|
return (
|
||||||
|
preview_path,
|
||||||
|
self.resolve_media_type(preview_path.name, str(meta.get("preview_media_type") or "")),
|
||||||
|
preview_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
source_path, source_media_type, source_name = self.resolve_source(receipt_id, current_user)
|
||||||
|
if self._is_previewable(source_media_type):
|
||||||
|
return source_path, source_media_type, source_name
|
||||||
|
raise FileNotFoundError("Receipt preview not found")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_filename(filename: str | None) -> str:
|
||||||
|
normalized = Path(str(filename or "").strip()).name
|
||||||
|
normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", normalized).strip("._")
|
||||||
|
return normalized or "receipt.bin"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_media_type(filename: str, fallback: str | None = None) -> str:
|
||||||
|
return str(mimetypes.guess_type(filename)[0] or fallback or "application/octet-stream")
|
||||||
|
|
||||||
|
def _owner_root(self, owner_key: str) -> Path:
|
||||||
|
return self._assert_child(self.root / owner_key)
|
||||||
|
|
||||||
|
def _receipt_dir(self, owner_key: str, receipt_id: str) -> Path:
|
||||||
|
normalized = str(receipt_id or "").strip()
|
||||||
|
if not re.fullmatch(r"[0-9a-fA-F-]{32,36}", normalized):
|
||||||
|
raise FileNotFoundError("Receipt not found")
|
||||||
|
path = self._assert_child(self._owner_root(owner_key) / normalized)
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
raise FileNotFoundError("Receipt not found")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _assert_child(self, path: Path) -> Path:
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
resolved = path.resolve()
|
||||||
|
try:
|
||||||
|
resolved.relative_to(self.root)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise FileNotFoundError("Receipt path is invalid") from exc
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _owner_key(current_user: CurrentUserContext) -> str:
|
||||||
|
raw = str(current_user.username or current_user.name or "anonymous").strip().lower()
|
||||||
|
normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", raw).strip("._")
|
||||||
|
return normalized or "anonymous"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_persist_source(filename: str, content: bytes) -> bool:
|
||||||
|
if not content:
|
||||||
|
return False
|
||||||
|
return Path(str(filename or "")).suffix.lower() in SUPPORTED_SUFFIXES
|
||||||
|
|
||||||
|
def _write_preview_asset(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
receipt_dir: Path,
|
||||||
|
source_path: Path,
|
||||||
|
media_type: str,
|
||||||
|
document: Any | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
preview_data_url = str(getattr(document, "preview_data_url", "") or "").strip()
|
||||||
|
decoded = ExpenseClaimAttachmentPresentation.decode_data_url(preview_data_url)
|
||||||
|
if decoded is not None:
|
||||||
|
preview_media_type, preview_content = decoded
|
||||||
|
suffix = mimetypes.guess_extension(preview_media_type) or ".bin"
|
||||||
|
preview_name = f"preview{suffix}"
|
||||||
|
preview_path = receipt_dir / preview_name
|
||||||
|
preview_path.write_bytes(preview_content)
|
||||||
|
return {
|
||||||
|
"previewable": True,
|
||||||
|
"preview_kind": "image",
|
||||||
|
"preview_file_name": preview_name,
|
||||||
|
"preview_media_type": preview_media_type,
|
||||||
|
}
|
||||||
|
if self._is_previewable(media_type):
|
||||||
|
return {
|
||||||
|
"previewable": True,
|
||||||
|
"preview_kind": "image" if media_type.startswith("image/") else "pdf",
|
||||||
|
"preview_file_name": source_path.name,
|
||||||
|
"preview_media_type": media_type,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"previewable": False,
|
||||||
|
"preview_kind": "",
|
||||||
|
"preview_file_name": "",
|
||||||
|
"preview_media_type": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_previewable(media_type: str) -> bool:
|
||||||
|
return str(media_type or "").startswith("image/") or str(media_type or "") == "application/pdf"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_document_meta(document: Any | None) -> dict[str, Any]:
|
||||||
|
fields = []
|
||||||
|
for field in list(getattr(document, "document_fields", []) or []):
|
||||||
|
if isinstance(field, dict):
|
||||||
|
fields.append(
|
||||||
|
{
|
||||||
|
"key": str(field.get("key") or "").strip(),
|
||||||
|
"label": str(field.get("label") or "").strip(),
|
||||||
|
"value": str(field.get("value") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fields.append(
|
||||||
|
{
|
||||||
|
"key": str(getattr(field, "key", "") or "").strip(),
|
||||||
|
"label": str(getattr(field, "label", "") or "").strip(),
|
||||||
|
"value": str(getattr(field, "value", "") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
fields = [field for field in fields if field["label"] and field["value"]]
|
||||||
|
return {
|
||||||
|
"engine": str(getattr(document, "engine", "") or ""),
|
||||||
|
"model": str(getattr(document, "model", "") or ""),
|
||||||
|
"ocr_text": str(getattr(document, "text", "") or ""),
|
||||||
|
"summary": str(getattr(document, "summary", "") or ""),
|
||||||
|
"ocr_avg_score": float(getattr(document, "avg_score", 0.0) or 0.0),
|
||||||
|
"ocr_line_count": int(getattr(document, "line_count", 0) or 0),
|
||||||
|
"page_count": int(getattr(document, "page_count", 1) or 1),
|
||||||
|
"document_type": str(getattr(document, "document_type", "") or "other"),
|
||||||
|
"document_type_label": str(getattr(document, "document_type_label", "") or "其他单据"),
|
||||||
|
"scene_code": str(getattr(document, "scene_code", "") or "other"),
|
||||||
|
"scene_label": str(getattr(document, "scene_label", "") or "其他票据"),
|
||||||
|
"ocr_classification_source": str(getattr(document, "classification_source", "") or ""),
|
||||||
|
"ocr_classification_confidence": float(getattr(document, "classification_confidence", 0.0) or 0.0),
|
||||||
|
"ocr_classification_evidence": [
|
||||||
|
str(value) for value in list(getattr(document, "classification_evidence", []) or []) if str(value).strip()
|
||||||
|
],
|
||||||
|
"document_fields": fields,
|
||||||
|
"editable_fields": {},
|
||||||
|
"ocr_warnings": [str(value) for value in list(getattr(document, "warnings", []) or []) if str(value).strip()],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _iter_owner_meta(self, owner_key: str) -> list[dict[str, Any]]:
|
||||||
|
owner_root = self._owner_root(owner_key)
|
||||||
|
if not owner_root.exists():
|
||||||
|
return []
|
||||||
|
metas = []
|
||||||
|
for meta_path in owner_root.glob("*/meta.json"):
|
||||||
|
meta = self._read_meta(meta_path.parent)
|
||||||
|
if meta:
|
||||||
|
metas.append(meta)
|
||||||
|
return metas
|
||||||
|
|
||||||
|
def _read_receipt_meta(self, receipt_id: str, current_user: CurrentUserContext) -> dict[str, Any]:
|
||||||
|
return self._read_meta(self._receipt_dir(self._owner_key(current_user), receipt_id))
|
||||||
|
|
||||||
|
def _resolve_existing_item(
|
||||||
|
self,
|
||||||
|
receipt_id: str | None,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> ReceiptFolderItemRead | None:
|
||||||
|
normalized = str(receipt_id or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self._build_item(self._read_receipt_meta(normalized, current_user))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _meta_path(receipt_dir: Path) -> Path:
|
||||||
|
return receipt_dir / "meta.json"
|
||||||
|
|
||||||
|
def _read_meta(self, receipt_dir: Path) -> dict[str, Any]:
|
||||||
|
meta_path = self._meta_path(receipt_dir)
|
||||||
|
if not meta_path.exists():
|
||||||
|
raise FileNotFoundError("Receipt not found")
|
||||||
|
try:
|
||||||
|
payload = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise FileNotFoundError("Receipt metadata not found") from exc
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
def _write_meta(self, receipt_dir: Path, payload: dict[str, Any]) -> None:
|
||||||
|
self._meta_path(receipt_dir).write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _matches_status(meta: dict[str, Any], status_filter: str) -> bool:
|
||||||
|
if status_filter in {"", "all"}:
|
||||||
|
return True
|
||||||
|
return str(meta.get("status") or "unlinked").strip().lower() == status_filter
|
||||||
|
|
||||||
|
def _build_item(self, meta: dict[str, Any]) -> ReceiptFolderItemRead:
|
||||||
|
receipt_id = str(meta.get("id") or "").strip()
|
||||||
|
status_value = str(meta.get("status") or "unlinked").strip() or "unlinked"
|
||||||
|
return ReceiptFolderItemRead(
|
||||||
|
id=receipt_id,
|
||||||
|
file_name=str(meta.get("file_name") or ""),
|
||||||
|
media_type=str(meta.get("media_type") or "application/octet-stream"),
|
||||||
|
size_bytes=int(meta.get("size_bytes") or 0),
|
||||||
|
status=status_value,
|
||||||
|
status_label="已关联" if status_value == "linked" else "未关联",
|
||||||
|
document_type=str(meta.get("document_type") or "other"),
|
||||||
|
document_type_label=str(meta.get("document_type_label") or "其他单据"),
|
||||||
|
scene_code=str(meta.get("scene_code") or "other"),
|
||||||
|
scene_label=str(meta.get("scene_label") or "其他票据"),
|
||||||
|
summary=str(meta.get("summary") or ""),
|
||||||
|
amount=self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")),
|
||||||
|
document_date=self._resolve_editable_or_field(meta, "document_date", labels=("日期", "开票日期", "乘车日期")),
|
||||||
|
merchant_name=self._resolve_editable_or_field(meta, "merchant_name", labels=("商户", "销售方", "收款方")),
|
||||||
|
avg_score=float(meta.get("ocr_avg_score") or 0.0),
|
||||||
|
uploaded_at=self._parse_datetime(meta.get("uploaded_at")),
|
||||||
|
linked_at=self._parse_datetime(meta.get("linked_at")),
|
||||||
|
linked_claim_id=str(meta.get("linked_claim_id") or ""),
|
||||||
|
linked_claim_no=str(meta.get("linked_claim_no") or ""),
|
||||||
|
previewable=bool(meta.get("previewable")),
|
||||||
|
preview_kind=str(meta.get("preview_kind") or ""),
|
||||||
|
preview_url=f"/receipt-folder/{receipt_id}/preview" if bool(meta.get("previewable")) and receipt_id else "",
|
||||||
|
source_url=f"/receipt-folder/{receipt_id}/source" if receipt_id else "",
|
||||||
|
warnings=[str(value) for value in list(meta.get("ocr_warnings") or []) if str(value).strip()],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_fields(self, meta: dict[str, Any]) -> list[ReceiptFolderFieldRead]:
|
||||||
|
return [
|
||||||
|
ReceiptFolderFieldRead(
|
||||||
|
key=str(field.get("key") or ""),
|
||||||
|
label=str(field.get("label") or ""),
|
||||||
|
value=str(field.get("value") or ""),
|
||||||
|
)
|
||||||
|
for field in list(meta.get("document_fields") or [])
|
||||||
|
if isinstance(field, dict) and str(field.get("label") or "").strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _resolve_editable_or_field(self, meta: dict[str, Any], key: str, *, labels: tuple[str, ...]) -> str:
|
||||||
|
editable = meta.get("editable_fields")
|
||||||
|
if isinstance(editable, dict):
|
||||||
|
value = str(editable.get(key) or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
label_set = set(labels)
|
||||||
|
for field in self._resolve_fields(meta):
|
||||||
|
if field.label in label_set or field.key == key:
|
||||||
|
return field.value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_datetime(value: Any) -> datetime | None:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
@@ -264,6 +264,74 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None:
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload["employee_id"] == "emp-main"
|
assert payload["employee_id"] == "emp-main"
|
||||||
assert {item["profile_type"] for item in payload["profiles"]} >= {"expense", "ai_usage"}
|
assert {item["profile_type"] for item in payload["profiles"]} >= {"expense", "ai_usage"}
|
||||||
|
ai_profile = next(item for item in payload["profiles"] if item["profile_type"] == "ai_usage")
|
||||||
|
assert ai_profile["metrics"]["ai_run_duration_ms"] == 120
|
||||||
|
assert payload["profile_tags"]
|
||||||
|
assert payload["radar"]["dimensions"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_admin_profile_endpoint_returns_account_usage_profile() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
seed_profile_data(db)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for index in range(12):
|
||||||
|
run_id = f"run-admin-usage-{index}"
|
||||||
|
started_at = now - timedelta(days=1, minutes=index)
|
||||||
|
db.add(
|
||||||
|
AgentRun(
|
||||||
|
run_id=run_id,
|
||||||
|
agent="user_agent",
|
||||||
|
source="user_message",
|
||||||
|
user_id="admin",
|
||||||
|
status="success",
|
||||||
|
result_summary="管理员查看运行概览。",
|
||||||
|
started_at=started_at,
|
||||||
|
finished_at=started_at + timedelta(seconds=2),
|
||||||
|
tool_calls=[
|
||||||
|
AgentToolCall(
|
||||||
|
run_id=run_id,
|
||||||
|
tool_type="database",
|
||||||
|
tool_name="agent_runs.list",
|
||||||
|
request_json={"limit": 20},
|
||||||
|
response_json={"ok": True},
|
||||||
|
status="success",
|
||||||
|
duration_ms=120,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
def override_db() -> Generator[Session, None, None]:
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_db
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/employee-profiles/me/latest",
|
||||||
|
params={
|
||||||
|
"scene": "operations",
|
||||||
|
"window_days": 90,
|
||||||
|
"expense_type_scope": "overall",
|
||||||
|
},
|
||||||
|
headers={"x-auth-username": "admin", "x-auth-name": "admin", "x-auth-is-admin": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["employee_id"] == "admin"
|
||||||
|
assert payload["empty_reason"] == ""
|
||||||
|
assert [item["profile_type"] for item in payload["profiles"]] == ["ai_usage"]
|
||||||
|
metrics = payload["profiles"][0]["metrics"]
|
||||||
|
assert metrics["ai_run_count"] == 12
|
||||||
|
assert metrics["ai_run_duration_ms"] == 24000
|
||||||
assert payload["profile_tags"]
|
assert payload["profile_tags"]
|
||||||
assert payload["radar"]["dimensions"]
|
assert payload["radar"]["dimensions"]
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead, OcrRecognizeLineRead
|
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead, OcrRecognizeLineRead
|
||||||
@@ -35,7 +36,7 @@ def build_client() -> TestClient:
|
|||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch) -> None:
|
def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch, tmp_path) -> None:
|
||||||
def fake_recognize(
|
def fake_recognize(
|
||||||
self,
|
self,
|
||||||
files: list[tuple[str, bytes, str | None]],
|
files: list[tuple[str, bytes, str | None]],
|
||||||
@@ -76,21 +77,84 @@ def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch) -> None:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||||
|
get_settings.cache_clear()
|
||||||
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||||
|
try:
|
||||||
client = build_client()
|
client = build_client()
|
||||||
|
auth_headers = {"x-auth-username": "pytest", "x-auth-name": "Py Test"}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/ocr/recognize",
|
"/api/v1/ocr/recognize",
|
||||||
headers={"x-auth-username": "pytest", "x-auth-name": "Py Test"},
|
headers=auth_headers,
|
||||||
files=[("files", ("invoice.png", b"fake-image", "image/png"))],
|
files=[("files", ("invoice.png", b"fake-image", "image/png"))],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
|
document = payload["documents"][0]
|
||||||
assert payload["engine"] == "paddleocr_mobile"
|
assert payload["engine"] == "paddleocr_mobile"
|
||||||
assert payload["success_count"] == 1
|
assert payload["success_count"] == 1
|
||||||
assert payload["documents"][0]["filename"] == "invoice.png"
|
assert document["filename"] == "invoice.png"
|
||||||
assert payload["documents"][0]["summary"] == "增值税电子发票,金额 100 元。"
|
assert document["summary"] == "增值税电子发票,金额 100 元。"
|
||||||
assert payload["documents"][0]["document_type"] == "vat_invoice"
|
assert document["document_type"] == "vat_invoice"
|
||||||
assert payload["documents"][0]["document_type_label"] == "增值税发票"
|
assert document["document_type_label"] == "增值税发票"
|
||||||
assert payload["documents"][0]["document_fields"][0]["label"] == "金额"
|
assert document["document_fields"][0]["label"] == "金额"
|
||||||
|
assert document["receipt_id"]
|
||||||
|
assert document["receipt_status"] == "unlinked"
|
||||||
|
assert document["receipt_preview_url"].endswith(f"/receipt-folder/{document['receipt_id']}/preview")
|
||||||
|
assert document["receipt_source_url"].endswith(f"/receipt-folder/{document['receipt_id']}/source")
|
||||||
|
|
||||||
|
receipt_id = document["receipt_id"]
|
||||||
|
list_response = client.get("/api/v1/receipt-folder?status=unlinked", headers=auth_headers)
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
receipt_list = list_response.json()
|
||||||
|
assert len(receipt_list) == 1
|
||||||
|
assert receipt_list[0]["id"] == receipt_id
|
||||||
|
assert receipt_list[0]["amount"] == "100元"
|
||||||
|
|
||||||
|
repeated_response = client.post(
|
||||||
|
"/api/v1/ocr/recognize",
|
||||||
|
headers=auth_headers,
|
||||||
|
data={"receipt_ids": receipt_id},
|
||||||
|
files=[("files", ("invoice.png", b"fake-image", "image/png"))],
|
||||||
|
)
|
||||||
|
assert repeated_response.status_code == 200
|
||||||
|
repeated_document = repeated_response.json()["documents"][0]
|
||||||
|
assert repeated_document["receipt_id"] == receipt_id
|
||||||
|
|
||||||
|
all_receipts_response = client.get("/api/v1/receipt-folder?status=all", headers=auth_headers)
|
||||||
|
assert all_receipts_response.status_code == 200
|
||||||
|
assert len(all_receipts_response.json()) == 1
|
||||||
|
|
||||||
|
detail_response = client.get(f"/api/v1/receipt-folder/{receipt_id}", headers=auth_headers)
|
||||||
|
assert detail_response.status_code == 200
|
||||||
|
detail_payload = detail_response.json()
|
||||||
|
assert detail_payload["file_name"] == "invoice.png"
|
||||||
|
assert detail_payload["fields"][0]["label"] == "金额"
|
||||||
|
|
||||||
|
update_response = client.patch(
|
||||||
|
f"/api/v1/receipt-folder/{receipt_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"document_type_label": "电子发票",
|
||||||
|
"amount": "108元",
|
||||||
|
"fields": [{"key": "amount", "label": "金额", "value": "108元"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert update_response.status_code == 200
|
||||||
|
assert update_response.json()["document_type_label"] == "电子发票"
|
||||||
|
assert update_response.json()["amount"] == "108元"
|
||||||
|
|
||||||
|
preview_response = client.get(f"/api/v1/receipt-folder/{receipt_id}/preview", headers=auth_headers)
|
||||||
|
assert preview_response.status_code == 200
|
||||||
|
assert preview_response.content == b"fake-image"
|
||||||
|
|
||||||
|
delete_response = client.delete(f"/api/v1/receipt-folder/{receipt_id}", headers=auth_headers)
|
||||||
|
assert delete_response.status_code == 200
|
||||||
|
assert delete_response.json()["receipt_id"] == receipt_id
|
||||||
|
|
||||||
|
deleted_response = client.get(f"/api/v1/receipt-folder/{receipt_id}", headers=auth_headers)
|
||||||
|
assert deleted_response.status_code == 404
|
||||||
|
finally:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.main.documents-main,
|
.main.documents-main,
|
||||||
|
.main.receipt-folder-main,
|
||||||
.main.requests-main,
|
.main.requests-main,
|
||||||
.main.approval-main,
|
.main.approval-main,
|
||||||
.main.archive-main,
|
.main.archive-main,
|
||||||
@@ -181,6 +182,7 @@
|
|||||||
.workarea { min-height: 0; overflow: auto; padding: 24px; }
|
.workarea { min-height: 0; overflow: auto; padding: 24px; }
|
||||||
.workarea.requests-workarea,
|
.workarea.requests-workarea,
|
||||||
.workarea.documents-workarea,
|
.workarea.documents-workarea,
|
||||||
|
.workarea.receipt-folder-workarea,
|
||||||
.workarea.workbench-workarea,
|
.workarea.workbench-workarea,
|
||||||
.workarea.approval-workarea,
|
.workarea.approval-workarea,
|
||||||
.workarea.archive-workarea,
|
.workarea.archive-workarea,
|
||||||
|
|||||||
495
web/src/assets/styles/components/document-list-shared.css
Normal file
495
web/src/assets/styles/components/document-list-shared.css
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
.status-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
margin-top: 14px;
|
||||||
|
border-bottom: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs button {
|
||||||
|
position: relative;
|
||||||
|
min-height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs button.active {
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs button.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px 999px 0 0;
|
||||||
|
background: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-tab-badge {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-set,
|
||||||
|
.document-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search {
|
||||||
|
position: relative;
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search .mdi {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search input {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 12px 0 36px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search input::placeholder {
|
||||||
|
color: #8da0b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search input:focus {
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
min-width: 120px;
|
||||||
|
min-height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
border-color: rgba(58, 124, 165, .32);
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-request-btn {
|
||||||
|
min-height: 40px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 10px 24px var(--theme-primary-shadow);
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-request-btn.secondary {
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-request-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 14px 28px var(--theme-primary-shadow);
|
||||||
|
filter: saturate(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-request-btn.secondary:hover {
|
||||||
|
border-color: rgba(58, 124, 165, .32);
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-request-btn:disabled {
|
||||||
|
opacity: .55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
min-height: 400px;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap.is-empty {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap table {
|
||||||
|
width: 100%;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 28px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
background: linear-gradient(180deg, #fcfffd 0%, #f5f9f7 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state .mdi {
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state p {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state.error {
|
||||||
|
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state.error .mdi {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #f1c5c5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1420px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 13px 12px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
color: #24324a;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f7fafc;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03));
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
td small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #7d8da1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-id {
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-kind-tag,
|
||||||
|
.type-tag,
|
||||||
|
.status-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-kind-tag {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-kind-tag.reimbursement {
|
||||||
|
background: var(--theme-primary-light-9);
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-kind-tag.application {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.travel,
|
||||||
|
.type-tag.hotel,
|
||||||
|
.type-tag.transport {
|
||||||
|
background: var(--theme-primary-light-9);
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.entertainment,
|
||||||
|
.type-tag.meal {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.office {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.meeting,
|
||||||
|
.type-tag.training {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.other,
|
||||||
|
.type-tag.neutral {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.info {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.success,
|
||||||
|
.status-tag.archived,
|
||||||
|
.status-tag.completed {
|
||||||
|
border-color: var(--success-line);
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.warning,
|
||||||
|
.status-tag.draft {
|
||||||
|
border-color: #fed7aa;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.danger {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.neutral {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-foot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button:hover:not(.active) {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button.active {
|
||||||
|
background: var(--theme-primary-active);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 16px var(--theme-primary-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button:disabled {
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-select {
|
||||||
|
width: 118px;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.document-toolbar,
|
||||||
|
.list-foot {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.status-tabs {
|
||||||
|
gap: 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-set,
|
||||||
|
.document-actions,
|
||||||
|
.list-search,
|
||||||
|
.filter-btn,
|
||||||
|
.page-size-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-foot {
|
||||||
|
display: grid;
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,135 +15,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 28px;
|
|
||||||
margin-top: 14px;
|
|
||||||
border-bottom: 1px solid #dbe4ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tabs button {
|
|
||||||
position: relative;
|
|
||||||
min-height: 36px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tabs button.active {
|
|
||||||
color: var(--theme-primary-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tabs button.active::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: -1px;
|
|
||||||
height: 3px;
|
|
||||||
border-radius: 999px 999px 0 0;
|
|
||||||
background: var(--theme-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scope-tab-badge {
|
|
||||||
min-width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 5px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #ef4444;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 850;
|
|
||||||
line-height: 1;
|
|
||||||
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-set,
|
|
||||||
.document-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-search {
|
|
||||||
position: relative;
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-search .mdi {
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-search input {
|
|
||||||
width: 100%;
|
|
||||||
height: 38px;
|
|
||||||
padding: 0 12px 0 36px;
|
|
||||||
border: 1px solid #d7e0ea;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: 13px;
|
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-search input::placeholder {
|
|
||||||
color: #8da0b4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-search input:focus {
|
|
||||||
border-color: var(--theme-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
min-height: 38px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 9px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 1px solid #d7e0ea;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 750;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
min-width: 120px;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover {
|
|
||||||
border-color: rgba(58, 124, 165, .32);
|
|
||||||
color: var(--theme-primary-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-filter,
|
.document-filter,
|
||||||
.date-range-filter {
|
.date-range-filter {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -287,43 +158,6 @@
|
|||||||
background: #cbd5e1;
|
background: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-request-btn {
|
|
||||||
min-height: 40px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 18px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
|
|
||||||
color: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 800;
|
|
||||||
white-space: nowrap;
|
|
||||||
box-shadow: 0 10px 24px var(--theme-primary-shadow);
|
|
||||||
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-request-btn.secondary {
|
|
||||||
border: 1px solid #d7e0ea;
|
|
||||||
background: #fff;
|
|
||||||
color: #334155;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-request-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 14px 28px var(--theme-primary-shadow);
|
|
||||||
filter: saturate(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-request-btn.secondary:hover {
|
|
||||||
border-color: rgba(58, 124, 165, .32);
|
|
||||||
color: var(--theme-primary-active);
|
|
||||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-status-filter {
|
.document-status-filter {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -348,83 +182,6 @@
|
|||||||
min-width: 154px;
|
min-width: 154px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap {
|
|
||||||
min-height: 400px;
|
|
||||||
margin-top: 10px;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #edf2f7;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap.is-empty {
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap table {
|
|
||||||
width: 100%;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-state {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 260px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 28px 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: #64748b;
|
|
||||||
background: linear-gradient(180deg, #fcfffd 0%, #f5f9f7 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-state .mdi {
|
|
||||||
font-size: 28px;
|
|
||||||
color: var(--theme-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-state strong {
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-state p {
|
|
||||||
max-width: 420px;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-state.error {
|
|
||||||
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-state.error .mdi {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-btn {
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 1px solid #f1c5c5;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
color: #b91c1c;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 1420px;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-id { width: 11%; }
|
.col-id { width: 11%; }
|
||||||
.col-created { width: 10%; }
|
.col-created { width: 10%; }
|
||||||
.col-stay { width: 9%; }
|
.col-stay { width: 9%; }
|
||||||
@@ -437,47 +194,6 @@ table {
|
|||||||
.col-status { width: 8%; }
|
.col-status { width: 8%; }
|
||||||
.col-updated { width: 9%; }
|
.col-updated { width: 9%; }
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 13px 12px;
|
|
||||||
border-bottom: 1px solid #edf2f7;
|
|
||||||
color: #24324a;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.35;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
background: #f7fafc;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr:hover {
|
|
||||||
background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03));
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr:last-child td {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-id {
|
|
||||||
color: var(--theme-primary-active);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-document-badge {
|
.new-document-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -505,211 +221,16 @@ tbody tr:last-child td {
|
|||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-kind-tag,
|
|
||||||
.type-tag,
|
|
||||||
.status-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-kind-tag {
|
|
||||||
min-height: 26px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-kind-tag.reimbursement {
|
|
||||||
background: var(--theme-primary-light-9);
|
|
||||||
color: var(--theme-primary-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-kind-tag.application {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag {
|
|
||||||
min-height: 26px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag.travel,
|
|
||||||
.type-tag.hotel,
|
|
||||||
.type-tag.transport {
|
|
||||||
background: var(--theme-primary-light-9);
|
|
||||||
color: var(--theme-primary-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag.entertainment,
|
|
||||||
.type-tag.meal {
|
|
||||||
background: #fff7ed;
|
|
||||||
color: #ea580c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag.office {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag.meeting,
|
|
||||||
.type-tag.training {
|
|
||||||
background: #eef2ff;
|
|
||||||
color: #4f46e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tag.other {
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag {
|
|
||||||
min-height: 24px;
|
|
||||||
padding: 0 9px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag.info {
|
|
||||||
border-color: #bfdbfe;
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag.success,
|
|
||||||
.status-tag.archived {
|
|
||||||
border-color: var(--success-line);
|
|
||||||
background: var(--success-soft);
|
|
||||||
color: var(--success-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag.warning,
|
|
||||||
.status-tag.draft {
|
|
||||||
border-color: #fed7aa;
|
|
||||||
background: #fff7ed;
|
|
||||||
color: #f97316;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag.danger {
|
|
||||||
border-color: #fecaca;
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag.neutral {
|
|
||||||
border-color: #cbd5e1;
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-foot {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-summary {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager {
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager button {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: transparent;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager button:hover:not(.active) {
|
|
||||||
background: #fff;
|
|
||||||
color: var(--theme-primary-active);
|
|
||||||
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager button.active {
|
|
||||||
background: var(--theme-primary-active);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 8px 16px var(--theme-primary-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager button:disabled {
|
|
||||||
color: #cbd5e1;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-size-select {
|
|
||||||
width: 118px;
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.document-toolbar,
|
|
||||||
.list-foot {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-toolbar {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.documents-list {
|
.documents-list {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tabs {
|
|
||||||
gap: 18px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-set,
|
|
||||||
.document-actions,
|
|
||||||
.document-status-filter,
|
|
||||||
.list-search,
|
|
||||||
.filter-btn,
|
|
||||||
.page-size-select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-status-filter {
|
.document-status-filter {
|
||||||
|
width: 100%;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-foot {
|
|
||||||
display: grid;
|
|
||||||
justify-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
372
web/src/assets/styles/views/receipt-folder-view.css
Normal file
372
web/src/assets/styles/views/receipt-folder-view.css
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
.receipt-folder-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
animation: fadeUp 220ms var(--ease) both;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-folder-list,
|
||||||
|
.receipt-folder-detail {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-folder-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-detail-head,
|
||||||
|
.receipt-detail-foot,
|
||||||
|
.receipt-basic-panel header,
|
||||||
|
.receipt-preview-panel header,
|
||||||
|
.receipt-field-list-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-form-grid input,
|
||||||
|
.receipt-form-grid textarea,
|
||||||
|
.receipt-field-row input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-form-grid input,
|
||||||
|
.receipt-field-row input {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-form-grid textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-form-grid input:focus,
|
||||||
|
.receipt-form-grid textarea:focus,
|
||||||
|
.receipt-field-row input:focus {
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-btn,
|
||||||
|
.ghost-btn,
|
||||||
|
.danger-btn,
|
||||||
|
.back-btn {
|
||||||
|
min-height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn,
|
||||||
|
.back-btn {
|
||||||
|
padding: 0 13px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-btn {
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--theme-primary);
|
||||||
|
background: var(--theme-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn {
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-btn:disabled,
|
||||||
|
.danger-btn:disabled {
|
||||||
|
opacity: .55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-folder-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-detail-head {
|
||||||
|
gap: 14px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-detail-head h2 {
|
||||||
|
margin: 4px 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-detail-head p {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #eef6ff;
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-detail-layout {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.05fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-basic-panel,
|
||||||
|
.receipt-preview-panel {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-basic-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-basic-panel header,
|
||||||
|
.receipt-preview-panel header,
|
||||||
|
.receipt-field-list-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-basic-panel header strong,
|
||||||
|
.receipt-preview-panel header strong,
|
||||||
|
.receipt-field-list-head strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-form-grid label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-form-grid label span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-field-list {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, .6fr) minmax(160px, 1fr) 30px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-field-row button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-preview-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-preview-panel header {
|
||||||
|
padding: 14px;
|
||||||
|
border-bottom: 1px solid #e5edf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-source-btn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-preview-box {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-preview-box img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-preview-box iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
justify-items: center;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty .mdi {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-detail-foot {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.associate-step {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.associate-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-checkbox-list,
|
||||||
|
.draft-choice-list {
|
||||||
|
max-height: 360px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-checkbox-list :deep(.el-checkbox),
|
||||||
|
.draft-choice {
|
||||||
|
min-height: 50px;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-checkbox-list :deep(.el-checkbox__label) {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-checkbox-list small,
|
||||||
|
.draft-choice small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-choice {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-choice.active {
|
||||||
|
border-color: rgba(58, 124, 165, .42);
|
||||||
|
background: rgba(58, 124, 165, .07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-choice span {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.receipt-detail-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-preview-panel {
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.receipt-folder-list,
|
||||||
|
.receipt-folder-detail {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -440,13 +440,13 @@ watch(
|
|||||||
|
|
||||||
.profile-tags-panel {
|
.profile-tags-panel {
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
align-content: start;
|
align-content: stretch;
|
||||||
min-height: 352px;
|
min-height: 352px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-radar-panel {
|
.profile-radar-panel {
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
align-content: start;
|
align-content: stretch;
|
||||||
min-height: 352px;
|
min-height: 352px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +477,15 @@ watch(
|
|||||||
.profile-panel-empty {
|
.profile-panel-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 18px 12px;
|
padding: 18px 12px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: stretch;
|
||||||
|
justify-self: stretch;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 100%;
|
||||||
border: 1px dashed #cbd5e1;
|
border: 1px dashed #cbd5e1;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
@@ -487,10 +496,12 @@ watch(
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-tags-panel > .profile-panel-empty {
|
||||||
|
min-height: 284px;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-radar-empty {
|
.profile-radar-empty {
|
||||||
min-height: 308px;
|
min-height: 308px;
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-operation-copy strong {
|
.profile-operation-copy strong {
|
||||||
|
|||||||
@@ -156,9 +156,9 @@ const sidebarMeta = {
|
|||||||
overview: { label: '分析看板' },
|
overview: { label: '分析看板' },
|
||||||
workbench: { label: '个人工作台' },
|
workbench: { label: '个人工作台' },
|
||||||
documents: { label: '单据中心' },
|
documents: { label: '单据中心' },
|
||||||
budget: { label: '预算中心' },
|
budget: { label: '预算编制' },
|
||||||
policies: { label: '知识管理' },
|
policies: { label: '财务政策' },
|
||||||
audit: { label: '规则中心' },
|
audit: { label: '规则管理' },
|
||||||
digitalEmployees: { label: '数字员工' },
|
digitalEmployees: { label: '数字员工' },
|
||||||
employees: { label: '员工管理' },
|
employees: { label: '员工管理' },
|
||||||
settings: { label: '系统设置' }
|
settings: { label: '系统设置' }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { icons } from '../data/icons.js'
|
|||||||
export const appViews = [
|
export const appViews = [
|
||||||
'workbench',
|
'workbench',
|
||||||
'documents',
|
'documents',
|
||||||
|
'receiptFolder',
|
||||||
'budget',
|
'budget',
|
||||||
'audit',
|
'audit',
|
||||||
'overview',
|
'overview',
|
||||||
@@ -32,20 +33,28 @@ export const navItems = [
|
|||||||
title: '单据中心',
|
title: '单据中心',
|
||||||
desc: '统一查看申请、报销、审批与归档。'
|
desc: '统一查看申请、报销、审批与归档。'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'receiptFolder',
|
||||||
|
label: '票据夹',
|
||||||
|
navHint: '存放已上传并识别的原始票据',
|
||||||
|
icon: icons.receipt,
|
||||||
|
title: '票据夹',
|
||||||
|
desc: '集中查看未关联和已关联票据,避免 OCR 后票据丢失。'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'budget',
|
id: 'budget',
|
||||||
label: '预算中心',
|
label: '预算编制',
|
||||||
navHint: '管理预算额度、预算占用与超预算预警',
|
navHint: '管理预算额度、预算占用与超预算预警',
|
||||||
icon: icons.budget,
|
icon: icons.budget,
|
||||||
title: '预算中心',
|
title: '预算编制',
|
||||||
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
|
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'audit',
|
id: 'audit',
|
||||||
label: '规则中心',
|
label: '规则管理',
|
||||||
navHint: '查看和管理规则配置',
|
navHint: '查看和管理规则配置',
|
||||||
icon: icons.skill,
|
icon: icons.skill,
|
||||||
title: '规则中心',
|
title: '规则管理',
|
||||||
desc: '集中管理财务规则、风险规则与外部 MCP 服务。'
|
desc: '集中管理财务规则、风险规则与外部 MCP 服务。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,11 +83,11 @@ export const navItems = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'policies',
|
id: 'policies',
|
||||||
label: '制度知识',
|
label: '财务政策',
|
||||||
navHint: '查看制度与知识库',
|
navHint: '查看财务政策与制度文档',
|
||||||
icon: icons.library,
|
icon: icons.library,
|
||||||
title: '制度与知识库',
|
title: '财务政策',
|
||||||
desc: '统一管理制度文档、检索入口与知识资产。'
|
desc: '统一管理财务政策文档、检索入口与知识资产。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
@@ -94,6 +103,7 @@ const viewRouteNames = {
|
|||||||
overview: 'app-overview',
|
overview: 'app-overview',
|
||||||
workbench: 'app-workbench',
|
workbench: 'app-workbench',
|
||||||
documents: 'app-documents',
|
documents: 'app-documents',
|
||||||
|
receiptFolder: 'app-receiptFolder',
|
||||||
budget: 'app-budget',
|
budget: 'app-budget',
|
||||||
policies: 'app-policies',
|
policies: 'app-policies',
|
||||||
audit: 'app-audit',
|
audit: 'app-audit',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const icons = {
|
|||||||
budget: iconPath('<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>'),
|
budget: iconPath('<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>'),
|
||||||
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
|
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
|
||||||
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
|
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
|
||||||
|
receipt: iconPath('<path d="M5 3v18l2-1 2 1 2-1 2 1 2-1 2 1 2-1V3z"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/>'),
|
||||||
book: iconPath('<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>'),
|
book: iconPath('<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>'),
|
||||||
library: iconPath('<path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/><path d="M2 20h20"/>'),
|
library: iconPath('<path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/><path d="M2 20h20"/>'),
|
||||||
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
|
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export function recognizeOcrFiles(files, options = {}) {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
formData.append('files', file)
|
formData.append('files', file)
|
||||||
|
formData.append('receipt_ids', String(file?.receiptId || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiRequest('/ocr/recognize', {
|
return apiRequest('/ocr/recognize', {
|
||||||
|
|||||||
49
web/src/services/receiptFolder.js
Normal file
49
web/src/services/receiptFolder.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { apiRequest } from './api.js'
|
||||||
|
|
||||||
|
function buildStatusQuery(status = 'all') {
|
||||||
|
const normalized = String(status || 'all').trim()
|
||||||
|
return normalized ? `?status=${encodeURIComponent(normalized)}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchReceiptFolderItems(status = 'all') {
|
||||||
|
return apiRequest(`/receipt-folder${buildStatusQuery(status)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchReceiptFolderDetail(receiptId) {
|
||||||
|
return apiRequest(`/receipt-folder/${encodeURIComponent(String(receiptId || '').trim())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateReceiptFolderItem(receiptId, payload = {}) {
|
||||||
|
return apiRequest(`/receipt-folder/${encodeURIComponent(String(receiptId || '').trim())}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteReceiptFolderItem(receiptId) {
|
||||||
|
return apiRequest(`/receipt-folder/${encodeURIComponent(String(receiptId || '').trim())}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchReceiptFolderAsset(pathOrUrl) {
|
||||||
|
const target = String(pathOrUrl || '').trim()
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('票据文件地址为空。')
|
||||||
|
}
|
||||||
|
return apiRequest(target, {
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildReceiptFile(receipt) {
|
||||||
|
const blob = await fetchReceiptFolderAsset(receipt?.source_url || receipt?.sourceUrl)
|
||||||
|
const fileName = String(receipt?.file_name || receipt?.fileName || 'receipt.bin').trim() || 'receipt.bin'
|
||||||
|
const mediaType = String(receipt?.media_type || receipt?.mediaType || blob.type || 'application/octet-stream')
|
||||||
|
const file = new File([blob], fileName, { type: mediaType })
|
||||||
|
Object.defineProperty(file, 'receiptId', {
|
||||||
|
value: String(receipt?.id || ''),
|
||||||
|
enumerable: false
|
||||||
|
})
|
||||||
|
return file
|
||||||
|
}
|
||||||
@@ -91,6 +91,9 @@ export function deleteExpenseClaimItem(claimId, itemId) {
|
|||||||
export function uploadExpenseClaimItemAttachment(claimId, itemId, file) {
|
export function uploadExpenseClaimItemAttachment(claimId, itemId, file) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
if (file?.receiptId) {
|
||||||
|
formData.append('receipt_id', String(file.receiptId))
|
||||||
|
}
|
||||||
|
|
||||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/items/${encodeURIComponent(String(itemId || '').trim())}/attachment`, {
|
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/items/${encodeURIComponent(String(itemId || '').trim())}/attachment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const DEFAULT_APP_VIEW_ORDER = [
|
export const DEFAULT_APP_VIEW_ORDER = [
|
||||||
'workbench',
|
'workbench',
|
||||||
'documents',
|
'documents',
|
||||||
|
'receiptFolder',
|
||||||
'budget',
|
'budget',
|
||||||
'audit',
|
'audit',
|
||||||
'overview',
|
'overview',
|
||||||
@@ -10,7 +11,7 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
|||||||
'settings'
|
'settings'
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
|
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'receiptFolder', 'policies'])
|
||||||
const VIEW_ROLE_RULES = {
|
const VIEW_ROLE_RULES = {
|
||||||
overview: ['finance', 'executive'],
|
overview: ['finance', 'executive'],
|
||||||
budget: ['budget_monitor', 'executive'],
|
budget: ['budget_monitor', 'executive'],
|
||||||
|
|||||||
@@ -97,11 +97,15 @@ export function buildUserProfileMetricCards(profile, runs = [], currentUser = {}
|
|||||||
const index = indexProfiles(profile)
|
const index = indexProfiles(profile)
|
||||||
const aiMetrics = metricsOf(index.ai_usage)
|
const aiMetrics = metricsOf(index.ai_usage)
|
||||||
const userRuns = filterRunsByCurrentUser(runs, currentUser)
|
const userRuns = filterRunsByCurrentUser(runs, currentUser)
|
||||||
const durationDisplay = formatDurationMetric(sumRunDurationMs(userRuns))
|
const windowedUserRuns = filterRunsByProfileWindow(userRuns, profile)
|
||||||
const commonAgent = resolveCommonAgent(userRuns)
|
const durationMs = hasProfileDurationMetric(aiMetrics)
|
||||||
|
? resolveNumber(aiMetrics.ai_run_duration_ms)
|
||||||
|
: sumRunDurationMs(windowedUserRuns)
|
||||||
|
const durationDisplay = formatDurationMetric(durationMs)
|
||||||
|
const commonAgent = resolveCommonAgent(windowedUserRuns)
|
||||||
const tokenCount = resolveNumber(aiMetrics.exact_token_count) || resolveNumber(aiMetrics.estimated_token_count)
|
const tokenCount = resolveNumber(aiMetrics.exact_token_count) || resolveNumber(aiMetrics.estimated_token_count)
|
||||||
const tokenDisplay = formatTokenCount(tokenCount)
|
const tokenDisplay = formatTokenCount(tokenCount)
|
||||||
const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || userRuns.length
|
const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || windowedUserRuns.length
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -223,11 +227,23 @@ function metricsOf(profile) {
|
|||||||
return profile?.metrics && typeof profile.metrics === 'object' ? profile.metrics : {}
|
return profile?.metrics && typeof profile.metrics === 'object' ? profile.metrics : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasProfileDurationMetric(metrics) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(metrics || {}, 'ai_run_duration_ms')
|
||||||
|
}
|
||||||
|
|
||||||
function filterRunsByCurrentUser(runs, currentUser) {
|
function filterRunsByCurrentUser(runs, currentUser) {
|
||||||
const identities = resolveCurrentUserIdentities(currentUser)
|
const identities = resolveCurrentUserIdentities(currentUser)
|
||||||
return (Array.isArray(runs) ? runs : []).filter((run) => belongsToCurrentUser(run, identities))
|
return (Array.isArray(runs) ? runs : []).filter((run) => belongsToCurrentUser(run, identities))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterRunsByProfileWindow(runs, profile) {
|
||||||
|
const cutoff = Date.now() - resolveWindowDays(profile) * 24 * 60 * 60 * 1000
|
||||||
|
return (Array.isArray(runs) ? runs : []).filter((run) => {
|
||||||
|
const startedAt = Date.parse(run?.started_at || '')
|
||||||
|
return Number.isFinite(startedAt) && startedAt >= cutoff
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function belongsToCurrentUser(run, identities) {
|
function belongsToCurrentUser(run, identities) {
|
||||||
if (!identities.size) {
|
if (!identities.size) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
'overview-main': activeView === 'overview',
|
'overview-main': activeView === 'overview',
|
||||||
'workbench-main': activeView === 'workbench',
|
'workbench-main': activeView === 'workbench',
|
||||||
'documents-main': activeView === 'documents',
|
'documents-main': activeView === 'documents',
|
||||||
|
'receipt-folder-main': activeView === 'receiptFolder',
|
||||||
'budget-main': activeView === 'budget',
|
'budget-main': activeView === 'budget',
|
||||||
'policies-main': activeView === 'policies',
|
'policies-main': activeView === 'policies',
|
||||||
'audit-main': activeView === 'audit',
|
'audit-main': activeView === 'audit',
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'digitalEmployees' && activeView !== 'employees' && activeView !== 'settings'"
|
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'receiptFolder' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'digitalEmployees' && activeView !== 'employees' && activeView !== 'settings'"
|
||||||
:compact="activeView === 'overview'"
|
:compact="activeView === 'overview'"
|
||||||
:filters="filters"
|
:filters="filters"
|
||||||
:ranges="ranges"
|
:ranges="ranges"
|
||||||
@@ -87,6 +88,7 @@
|
|||||||
class="workarea"
|
class="workarea"
|
||||||
:class="{
|
:class="{
|
||||||
'documents-workarea': activeView === 'documents',
|
'documents-workarea': activeView === 'documents',
|
||||||
|
'receipt-folder-workarea': activeView === 'receiptFolder',
|
||||||
'workbench-workarea': activeView === 'workbench',
|
'workbench-workarea': activeView === 'workbench',
|
||||||
'budget-workarea': activeView === 'budget',
|
'budget-workarea': activeView === 'budget',
|
||||||
'policies-workarea': activeView === 'policies',
|
'policies-workarea': activeView === 'policies',
|
||||||
@@ -133,6 +135,11 @@
|
|||||||
@summary-change="documentSummary = $event"
|
@summary-change="documentSummary = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ReceiptFolderView
|
||||||
|
v-else-if="activeView === 'receiptFolder'"
|
||||||
|
@open-assistant="openSmartEntry"
|
||||||
|
/>
|
||||||
|
|
||||||
<BudgetCenterView
|
<BudgetCenterView
|
||||||
v-else-if="activeView === 'budget'"
|
v-else-if="activeView === 'budget'"
|
||||||
:current-user="currentUser"
|
:current-user="currentUser"
|
||||||
@@ -190,6 +197,7 @@ const PersonalWorkbenchView = defineAsyncComponent(() => import('./PersonalWorkb
|
|||||||
const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue'))
|
const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue'))
|
||||||
const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue'))
|
const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue'))
|
||||||
const DocumentsCenterView = defineAsyncComponent(() => import('./DocumentsCenterView.vue'))
|
const DocumentsCenterView = defineAsyncComponent(() => import('./DocumentsCenterView.vue'))
|
||||||
|
const ReceiptFolderView = defineAsyncComponent(() => import('./ReceiptFolderView.vue'))
|
||||||
const BudgetCenterRouteLoading = {
|
const BudgetCenterRouteLoading = {
|
||||||
name: 'BudgetCenterRouteLoading',
|
name: 'BudgetCenterRouteLoading',
|
||||||
render: () =>
|
render: () =>
|
||||||
|
|||||||
@@ -832,4 +832,5 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
|
||||||
<style scoped src="../assets/styles/views/documents-center-view.css"></style>
|
<style scoped src="../assets/styles/views/documents-center-view.css"></style>
|
||||||
|
|||||||
620
web/src/views/ReceiptFolderView.vue
Normal file
620
web/src/views/ReceiptFolderView.vue
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
<template>
|
||||||
|
<section class="receipt-folder-page">
|
||||||
|
<article v-if="!detailMode" class="receipt-folder-list panel">
|
||||||
|
<nav class="status-tabs receipt-status-tabs" aria-label="票据关联状态">
|
||||||
|
<button
|
||||||
|
v-for="tab in receiptTabs"
|
||||||
|
:key="tab.value"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: activeStatus === tab.value }"
|
||||||
|
@click="switchStatus(tab.value)"
|
||||||
|
>
|
||||||
|
<span>{{ tab.label }}</span>
|
||||||
|
<span v-if="tab.count > 0" class="scope-tab-badge">{{ tab.count > 99 ? '99+' : tab.count }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="document-toolbar">
|
||||||
|
<div class="filter-set">
|
||||||
|
<div class="list-search">
|
||||||
|
<i class="mdi mdi-magnify"></i>
|
||||||
|
<input v-model="keyword" type="search" placeholder="搜索文件名、票据类型、金额、关联单号..." />
|
||||||
|
</div>
|
||||||
|
<button class="filter-btn" type="button" @click="reloadReceipts">
|
||||||
|
<i class="mdi mdi-refresh"></i>
|
||||||
|
<span>刷新</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="document-actions">
|
||||||
|
<button
|
||||||
|
class="create-request-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="!unlinkedReceipts.length"
|
||||||
|
@click="openAssociateDialog"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-link-variant-plus"></i>
|
||||||
|
<span>一键关联票据</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||||
|
<div v-if="loading" class="table-state">
|
||||||
|
<TableLoadingState
|
||||||
|
title="票据夹加载中"
|
||||||
|
message="正在读取已上传票据、OCR 信息与关联状态"
|
||||||
|
icon="mdi mdi-receipt-text-outline"
|
||||||
|
floating
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="table-state error">
|
||||||
|
<i class="mdi mdi-alert-circle-outline"></i>
|
||||||
|
<strong>票据夹加载失败</strong>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button class="retry-btn" type="button" @click="reloadReceipts">重新加载</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TableEmptyState
|
||||||
|
v-else-if="showEmpty"
|
||||||
|
eyebrow="票据夹"
|
||||||
|
:title="emptyTitle"
|
||||||
|
:description="emptyDesc"
|
||||||
|
icon="mdi mdi-receipt-text-outline"
|
||||||
|
tone="theme"
|
||||||
|
art-label="RECEIPT"
|
||||||
|
:tips="emptyTips"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<table v-else>
|
||||||
|
<colgroup>
|
||||||
|
<col class="col-file">
|
||||||
|
<col class="col-kind">
|
||||||
|
<col class="col-scene">
|
||||||
|
<col class="col-money">
|
||||||
|
<col class="col-date">
|
||||||
|
<col class="col-score">
|
||||||
|
<col class="col-status">
|
||||||
|
<col class="col-updated">
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>票据文件</th>
|
||||||
|
<th>识别类型</th>
|
||||||
|
<th>费用场景</th>
|
||||||
|
<th>金额</th>
|
||||||
|
<th>票据日期</th>
|
||||||
|
<th>置信度</th>
|
||||||
|
<th>关联状态</th>
|
||||||
|
<th>上传时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in visibleRows" :key="row.id" @click="openDetail(row)">
|
||||||
|
<td>
|
||||||
|
<strong class="doc-id">{{ row.file_name }}</strong>
|
||||||
|
<small>{{ row.summary || '暂无摘要' }}</small>
|
||||||
|
</td>
|
||||||
|
<td><span class="doc-kind-tag reimbursement">{{ row.document_type_label }}</span></td>
|
||||||
|
<td><span class="type-tag neutral">{{ row.scene_label }}</span></td>
|
||||||
|
<td>{{ row.amount || '待补充' }}</td>
|
||||||
|
<td>{{ row.document_date || '待补充' }}</td>
|
||||||
|
<td>{{ formatScore(row.avg_score) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-tag" :class="row.status === 'linked' ? 'completed' : 'warning'">
|
||||||
|
{{ row.status_label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDateTime(row.uploaded_at) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer v-if="showTable" class="list-foot">
|
||||||
|
<span class="page-summary">共 {{ filteredRows.length }} 条,目前第 {{ currentPage }} 页</span>
|
||||||
|
<div class="pager" aria-label="分页">
|
||||||
|
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
||||||
|
<i class="mdi mdi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="page in totalPages"
|
||||||
|
:key="page"
|
||||||
|
class="page-number"
|
||||||
|
:class="{ active: currentPage === page }"
|
||||||
|
type="button"
|
||||||
|
@click="currentPage = page"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
|
||||||
|
<i class="mdi mdi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<EnterpriseSelect v-model="pageSize" class="page-size-select" :options="pageSizeOptions" size="small" @change="currentPage = 1" />
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else class="receipt-folder-detail panel">
|
||||||
|
<header class="receipt-detail-head">
|
||||||
|
<button class="back-btn" type="button" @click="backToList">
|
||||||
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
|
<span>返回票据夹</span>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<span class="assistant-badge">票据详情</span>
|
||||||
|
<h2>{{ detailForm.file_name }}</h2>
|
||||||
|
<p>{{ selectedReceipt?.summary || '核对并修正票据基础信息,后续关联报销单时会带入当前票据。' }}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="detailLoading" class="detail-loading">
|
||||||
|
<TableLoadingState title="票据详情加载中" message="正在读取票据源文件与 OCR 元数据" icon="mdi mdi-receipt-text-outline" floating />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="receipt-detail-layout">
|
||||||
|
<section class="receipt-basic-panel">
|
||||||
|
<header>
|
||||||
|
<strong>基本票据信息</strong>
|
||||||
|
<button class="apply-btn" type="button" :disabled="savingDetail" @click="saveDetail">
|
||||||
|
<i class="mdi mdi-content-save-outline"></i>
|
||||||
|
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="receipt-form-grid">
|
||||||
|
<label>
|
||||||
|
<span>票据类型</span>
|
||||||
|
<input v-model="detailForm.document_type_label" type="text" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>费用场景</span>
|
||||||
|
<input v-model="detailForm.scene_label" type="text" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>金额</span>
|
||||||
|
<input v-model="detailForm.amount" type="text" placeholder="待补充" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>票据日期</span>
|
||||||
|
<input v-model="detailForm.document_date" type="text" placeholder="YYYY-MM-DD" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>商户</span>
|
||||||
|
<input v-model="detailForm.merchant_name" type="text" placeholder="待补充" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>OCR 置信度</span>
|
||||||
|
<input :value="formatScore(selectedReceipt?.avg_score)" type="text" disabled />
|
||||||
|
</label>
|
||||||
|
<label class="field-wide">
|
||||||
|
<span>摘要</span>
|
||||||
|
<textarea v-model="detailForm.summary" rows="3" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receipt-field-list">
|
||||||
|
<div class="receipt-field-list-head">
|
||||||
|
<strong>识别字段</strong>
|
||||||
|
<button class="ghost-btn" type="button" @click="addField">
|
||||||
|
<i class="mdi mdi-plus"></i>
|
||||||
|
<span>新增字段</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-for="(field, index) in detailForm.fields" :key="`${field.key}-${index}`" class="receipt-field-row">
|
||||||
|
<input v-model="field.label" type="text" placeholder="字段名" />
|
||||||
|
<input v-model="field.value" type="text" placeholder="字段值" />
|
||||||
|
<button type="button" aria-label="删除字段" @click="removeField(index)">
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="receipt-preview-panel">
|
||||||
|
<header>
|
||||||
|
<strong>原始文件</strong>
|
||||||
|
<button v-if="selectedReceipt?.source_url" class="preview-source-btn" type="button" @click="openSourceFile">
|
||||||
|
打开源文件
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="receipt-preview-box">
|
||||||
|
<img v-if="previewKind === 'image' && previewObjectUrl" :src="previewObjectUrl" alt="票据预览" />
|
||||||
|
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
|
||||||
|
<div v-else class="preview-empty">
|
||||||
|
<i class="mdi mdi-file-eye-outline"></i>
|
||||||
|
<strong>当前文件暂不支持内嵌预览</strong>
|
||||||
|
<p>可以点击右上角打开源文件查看。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="receipt-detail-foot">
|
||||||
|
<button class="ghost-btn" type="button" @click="backToList">返回列表</button>
|
||||||
|
<button class="danger-btn" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
|
||||||
|
<i class="mdi mdi-delete-outline"></i>
|
||||||
|
<span>{{ deleting ? '删除中' : '删除票据' }}</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<ElDialog
|
||||||
|
v-model="associateDialogOpen"
|
||||||
|
class="receipt-associate-dialog"
|
||||||
|
title="一键关联票据"
|
||||||
|
width="680px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<section v-if="associateStep === 1" class="associate-step">
|
||||||
|
<p class="associate-hint">选择需要归集的未关联票据。</p>
|
||||||
|
<ElCheckboxGroup v-model="selectedReceiptIds" class="receipt-checkbox-list">
|
||||||
|
<ElCheckbox v-for="receipt in unlinkedReceipts" :key="receipt.id" :label="receipt.id">
|
||||||
|
<span>{{ receipt.file_name }}</span>
|
||||||
|
<small>{{ receipt.document_type_label }} · {{ receipt.amount || '金额待补充' }}</small>
|
||||||
|
</ElCheckbox>
|
||||||
|
</ElCheckboxGroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="associate-step">
|
||||||
|
<p class="associate-hint">选择未提交草稿,或基于票据新建一张报销单。</p>
|
||||||
|
<div class="draft-choice-list">
|
||||||
|
<label class="draft-choice" :class="{ active: targetDraftId === NEW_CLAIM_VALUE }">
|
||||||
|
<input v-model="targetDraftId" type="radio" :value="NEW_CLAIM_VALUE" />
|
||||||
|
<span>
|
||||||
|
<strong>新建报销单</strong>
|
||||||
|
<small>将选中的票据带入对话,由 AI 辅助填写信息。</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
v-for="draft in draftClaims"
|
||||||
|
:key="draft.claimId"
|
||||||
|
class="draft-choice"
|
||||||
|
:class="{ active: targetDraftId === draft.claimId }"
|
||||||
|
>
|
||||||
|
<input v-model="targetDraftId" type="radio" :value="draft.claimId" />
|
||||||
|
<span>
|
||||||
|
<strong>{{ draft.claimNo }}</strong>
|
||||||
|
<small>{{ draft.reason }} · {{ draft.amountDisplay }}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button class="ghost-btn" type="button" @click="closeAssociateDialog">取消</button>
|
||||||
|
<button v-if="associateStep === 2" class="ghost-btn" type="button" @click="associateStep = 1">上一步</button>
|
||||||
|
<button
|
||||||
|
class="apply-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="associateBusy || !canProceedAssociate"
|
||||||
|
@click="handleAssociatePrimary"
|
||||||
|
>
|
||||||
|
{{ associatePrimaryLabel }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
|
||||||
|
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||||
|
|
||||||
|
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
||||||
|
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||||
|
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||||
|
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||||
|
import {
|
||||||
|
buildReceiptFile,
|
||||||
|
deleteReceiptFolderItem,
|
||||||
|
fetchReceiptFolderAsset,
|
||||||
|
fetchReceiptFolderDetail,
|
||||||
|
fetchReceiptFolderItems,
|
||||||
|
updateReceiptFolderItem
|
||||||
|
} from '../services/receiptFolder.js'
|
||||||
|
|
||||||
|
const NEW_CLAIM_VALUE = '__new_claim__'
|
||||||
|
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||||
|
const emit = defineEmits(['open-assistant'])
|
||||||
|
|
||||||
|
const activeStatus = ref('unlinked')
|
||||||
|
const keyword = ref('')
|
||||||
|
const receipts = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const selectedReceipt = ref(null)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const savingDetail = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const previewObjectUrl = ref('')
|
||||||
|
const associateDialogOpen = ref(false)
|
||||||
|
const associateStep = ref(1)
|
||||||
|
const selectedReceiptIds = ref([])
|
||||||
|
const targetDraftId = ref(NEW_CLAIM_VALUE)
|
||||||
|
const draftClaims = ref([])
|
||||||
|
const associateBusy = ref(false)
|
||||||
|
|
||||||
|
const detailForm = reactive({
|
||||||
|
file_name: '',
|
||||||
|
document_type: '',
|
||||||
|
document_type_label: '',
|
||||||
|
scene_code: '',
|
||||||
|
scene_label: '',
|
||||||
|
summary: '',
|
||||||
|
amount: '',
|
||||||
|
document_date: '',
|
||||||
|
merchant_name: '',
|
||||||
|
fields: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailMode = computed(() => Boolean(selectedReceipt.value))
|
||||||
|
const unlinkedReceipts = computed(() => receipts.value.filter((item) => item.status !== 'linked'))
|
||||||
|
const linkedReceipts = computed(() => receipts.value.filter((item) => item.status === 'linked'))
|
||||||
|
const receiptTabs = computed(() => [
|
||||||
|
{ value: 'unlinked', label: '未关联票据', count: unlinkedReceipts.value.length },
|
||||||
|
{ value: 'linked', label: '已关联票据', count: linkedReceipts.value.length }
|
||||||
|
])
|
||||||
|
const activeRows = computed(() => (
|
||||||
|
activeStatus.value === 'linked' ? linkedReceipts.value : unlinkedReceipts.value
|
||||||
|
))
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const normalized = keyword.value.trim().toLowerCase()
|
||||||
|
if (!normalized) return activeRows.value
|
||||||
|
return activeRows.value.filter((item) => [
|
||||||
|
item.file_name,
|
||||||
|
item.document_type_label,
|
||||||
|
item.scene_label,
|
||||||
|
item.summary,
|
||||||
|
item.amount,
|
||||||
|
item.document_date,
|
||||||
|
item.linked_claim_no
|
||||||
|
].filter(Boolean).join('').toLowerCase().includes(normalized))
|
||||||
|
})
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
|
||||||
|
const visibleRows = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
|
return filteredRows.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
|
||||||
|
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
|
||||||
|
const emptyTitle = computed(() => keyword.value.trim() ? '没有符合条件的票据' : `${activeStatus.value === 'linked' ? '已关联票据' : '未关联票据'}为空`)
|
||||||
|
const emptyDesc = computed(() => activeStatus.value === 'linked'
|
||||||
|
? '已关联到报销单的票据会显示在这里,方便后续回溯。'
|
||||||
|
: '上传并完成 OCR 的票据会先进入这里,稍后可以再关联到报销草稿。'
|
||||||
|
)
|
||||||
|
const emptyTips = computed(() => activeStatus.value === 'linked'
|
||||||
|
? ['可从报销明细或票据夹关联流程形成已关联票据', '点击票据可查看原始文件与识别字段']
|
||||||
|
: ['票据不会因为未立即建单而丢失', '可多选票据后一次性带入报销对话']
|
||||||
|
)
|
||||||
|
const previewKind = computed(() => selectedReceipt.value?.preview_kind || '')
|
||||||
|
const canProceedAssociate = computed(() => (
|
||||||
|
associateStep.value === 1
|
||||||
|
? selectedReceiptIds.value.length > 0
|
||||||
|
: Boolean(targetDraftId.value)
|
||||||
|
))
|
||||||
|
const associatePrimaryLabel = computed(() => {
|
||||||
|
if (associateBusy.value) return '处理中'
|
||||||
|
return associateStep.value === 1 ? '下一步' : '进入关联对话'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([activeStatus, keyword, pageSize], () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void reloadReceipts()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
revokePreviewUrl()
|
||||||
|
})
|
||||||
|
|
||||||
|
function switchStatus(status) {
|
||||||
|
activeStatus.value = status
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadReceipts() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
receipts.value = await fetchReceiptFolderItems('all')
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err?.message || '票据夹加载失败。'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(row) {
|
||||||
|
selectedReceipt.value = row
|
||||||
|
detailLoading.value = true
|
||||||
|
revokePreviewUrl()
|
||||||
|
try {
|
||||||
|
const detail = await fetchReceiptFolderDetail(row.id)
|
||||||
|
selectedReceipt.value = detail
|
||||||
|
fillDetailForm(detail)
|
||||||
|
await loadPreview(detail)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err?.message || '票据详情加载失败。'
|
||||||
|
selectedReceipt.value = null
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillDetailForm(detail) {
|
||||||
|
detailForm.file_name = detail.file_name || ''
|
||||||
|
detailForm.document_type = detail.document_type || ''
|
||||||
|
detailForm.document_type_label = detail.document_type_label || ''
|
||||||
|
detailForm.scene_code = detail.scene_code || ''
|
||||||
|
detailForm.scene_label = detail.scene_label || ''
|
||||||
|
detailForm.summary = detail.summary || ''
|
||||||
|
detailForm.amount = detail.amount || ''
|
||||||
|
detailForm.document_date = detail.document_date || ''
|
||||||
|
detailForm.merchant_name = detail.merchant_name || ''
|
||||||
|
detailForm.fields = Array.isArray(detail.fields)
|
||||||
|
? detail.fields.map((field) => ({ ...field }))
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreview(detail) {
|
||||||
|
if (!detail?.preview_url) return
|
||||||
|
try {
|
||||||
|
const blob = await fetchReceiptFolderAsset(detail.preview_url)
|
||||||
|
previewObjectUrl.value = URL.createObjectURL(blob)
|
||||||
|
} catch {
|
||||||
|
previewObjectUrl.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokePreviewUrl() {
|
||||||
|
if (previewObjectUrl.value) {
|
||||||
|
URL.revokeObjectURL(previewObjectUrl.value)
|
||||||
|
previewObjectUrl.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSourceFile() {
|
||||||
|
if (!selectedReceipt.value?.source_url) return
|
||||||
|
const blob = await fetchReceiptFolderAsset(selectedReceipt.value.source_url)
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
window.open(objectUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToList() {
|
||||||
|
selectedReceipt.value = null
|
||||||
|
revokePreviewUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addField() {
|
||||||
|
detailForm.fields.push({ key: '', label: '', value: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(index) {
|
||||||
|
detailForm.fields.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDetail() {
|
||||||
|
if (!selectedReceipt.value?.id || savingDetail.value) return
|
||||||
|
savingDetail.value = true
|
||||||
|
try {
|
||||||
|
const updated = await updateReceiptFolderItem(selectedReceipt.value.id, {
|
||||||
|
document_type: detailForm.document_type,
|
||||||
|
document_type_label: detailForm.document_type_label,
|
||||||
|
scene_code: detailForm.scene_code,
|
||||||
|
scene_label: detailForm.scene_label,
|
||||||
|
summary: detailForm.summary,
|
||||||
|
amount: detailForm.amount,
|
||||||
|
document_date: detailForm.document_date,
|
||||||
|
merchant_name: detailForm.merchant_name,
|
||||||
|
fields: detailForm.fields
|
||||||
|
})
|
||||||
|
selectedReceipt.value = updated
|
||||||
|
fillDetailForm(updated)
|
||||||
|
await reloadReceipts()
|
||||||
|
} finally {
|
||||||
|
savingDetail.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentReceipt() {
|
||||||
|
if (!selectedReceipt.value?.id || deleting.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await deleteReceiptFolderItem(selectedReceipt.value.id)
|
||||||
|
backToList()
|
||||||
|
await reloadReceipts()
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAssociateDialog() {
|
||||||
|
selectedReceiptIds.value = []
|
||||||
|
targetDraftId.value = NEW_CLAIM_VALUE
|
||||||
|
associateStep.value = 1
|
||||||
|
associateDialogOpen.value = true
|
||||||
|
await loadDraftClaims()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssociateDialog() {
|
||||||
|
if (associateBusy.value) return
|
||||||
|
associateDialogOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDraftClaims() {
|
||||||
|
try {
|
||||||
|
const claims = await fetchExpenseClaims()
|
||||||
|
draftClaims.value = (Array.isArray(claims) ? claims : [])
|
||||||
|
.filter((claim) => String(claim.status || '').trim().toLowerCase() === 'draft')
|
||||||
|
.map((claim) => ({
|
||||||
|
raw: claim,
|
||||||
|
claimId: String(claim.id || '').trim(),
|
||||||
|
claimNo: String(claim.claim_no || '').trim(),
|
||||||
|
reason: String(claim.reason || '待补充事由').trim(),
|
||||||
|
amountDisplay: `${Number(claim.amount || 0).toFixed(2)} ${claim.currency || 'CNY'}`
|
||||||
|
}))
|
||||||
|
.filter((claim) => claim.claimId)
|
||||||
|
} catch {
|
||||||
|
draftClaims.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssociatePrimary() {
|
||||||
|
if (associateStep.value === 1) {
|
||||||
|
associateStep.value = 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await openAssociationConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAssociationConversation() {
|
||||||
|
if (associateBusy.value || !selectedReceiptIds.value.length) return
|
||||||
|
associateBusy.value = true
|
||||||
|
try {
|
||||||
|
const selected = receipts.value.filter((item) => selectedReceiptIds.value.includes(item.id))
|
||||||
|
const files = await Promise.all(selected.map((item) => buildReceiptFile(item)))
|
||||||
|
const selectedDraft = draftClaims.value.find((item) => item.claimId === targetDraftId.value)
|
||||||
|
const prompt = selectedDraft
|
||||||
|
? `请把票据夹中选中的 ${files.length} 份票据关联到报销草稿 ${selectedDraft.claimNo},并继续核对填写信息。`
|
||||||
|
: `请基于票据夹中选中的 ${files.length} 份票据新建一张报销草稿,并继续核对填写信息。`
|
||||||
|
associateDialogOpen.value = false
|
||||||
|
emit('open-assistant', {
|
||||||
|
source: selectedDraft ? 'detail' : 'receipt-folder',
|
||||||
|
request: selectedDraft
|
||||||
|
? {
|
||||||
|
...selectedDraft.raw,
|
||||||
|
claimId: selectedDraft.claimId,
|
||||||
|
documentNo: selectedDraft.claimNo
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
prompt,
|
||||||
|
files
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
associateBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScore(value) {
|
||||||
|
const score = Number(value || 0)
|
||||||
|
if (!Number.isFinite(score) || score <= 0) return '待确认'
|
||||||
|
return `${Math.round(score * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return '待确认'
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
|
||||||
|
<style scoped src="../assets/styles/views/receipt-folder-view.css"></style>
|
||||||
@@ -21,12 +21,21 @@ function testFallsBackToValidMeta() {
|
|||||||
function testResolvesMainRouteNames() {
|
function testResolvesMainRouteNames() {
|
||||||
assert.equal(resolveTargetRouteName('logs'), 'app-settings')
|
assert.equal(resolveTargetRouteName('logs'), 'app-settings')
|
||||||
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
|
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
|
||||||
|
assert.equal(resolveTargetRouteName('receiptFolder'), 'app-receiptFolder')
|
||||||
assert.equal(resolveTargetRouteName('requests'), 'app-overview')
|
assert.equal(resolveTargetRouteName('requests'), 'app-overview')
|
||||||
assert.equal(resolveTargetRouteName('approval'), 'app-overview')
|
assert.equal(resolveTargetRouteName('approval'), 'app-overview')
|
||||||
assert.equal(resolveTargetRouteName('archive'), 'app-overview')
|
assert.equal(resolveTargetRouteName('archive'), 'app-overview')
|
||||||
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
|
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testReceiptFolderFollowsDocumentCenter() {
|
||||||
|
assert.equal(appViews[appViews.indexOf('documents') + 1], 'receiptFolder')
|
||||||
|
const documentIndex = navItems.findIndex((item) => item.id === 'documents')
|
||||||
|
const receiptIndex = navItems.findIndex((item) => item.id === 'receiptFolder')
|
||||||
|
assert.equal(receiptIndex, documentIndex + 1)
|
||||||
|
assert.equal(navItems[receiptIndex].label, '票据夹')
|
||||||
|
}
|
||||||
|
|
||||||
function testLegacyCentersAreRemovedFromNavigation() {
|
function testLegacyCentersAreRemovedFromNavigation() {
|
||||||
assert.equal(appViews.includes('requests'), false)
|
assert.equal(appViews.includes('requests'), false)
|
||||||
assert.equal(appViews.includes('approval'), false)
|
assert.equal(appViews.includes('approval'), false)
|
||||||
@@ -39,6 +48,7 @@ function run() {
|
|||||||
testDerivesViewFromRouteName()
|
testDerivesViewFromRouteName()
|
||||||
testFallsBackToValidMeta()
|
testFallsBackToValidMeta()
|
||||||
testResolvesMainRouteNames()
|
testResolvesMainRouteNames()
|
||||||
|
testReceiptFolderFollowsDocumentCenter()
|
||||||
testLegacyCentersAreRemovedFromNavigation()
|
testLegacyCentersAreRemovedFromNavigation()
|
||||||
console.log('navigation route resolution tests passed')
|
console.log('navigation route resolution tests passed')
|
||||||
}
|
}
|
||||||
|
|||||||
75
web/tests/receipt-folder-view.test.mjs
Normal file
75
web/tests/receipt-folder-view.test.mjs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const root = process.cwd()
|
||||||
|
|
||||||
|
function readProjectFile(path) {
|
||||||
|
return readFileSync(join(root, path), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testReceiptFolderViewSurface() {
|
||||||
|
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||||
|
|
||||||
|
assert.match(view, /未关联票据/)
|
||||||
|
assert.match(view, /已关联票据/)
|
||||||
|
assert.match(view, /一键关联票据/)
|
||||||
|
assert.match(view, /基本票据信息/)
|
||||||
|
assert.match(view, /原始文件/)
|
||||||
|
assert.match(view, /返回列表/)
|
||||||
|
assert.match(view, /删除票据/)
|
||||||
|
assert.match(view, /ElCheckboxGroup/)
|
||||||
|
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
|
||||||
|
assert.match(view, /buildReceiptFile\(item\)/)
|
||||||
|
assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/)
|
||||||
|
assert.match(view, /emit\('open-assistant'/)
|
||||||
|
}
|
||||||
|
|
||||||
|
function testReceiptFolderServiceContract() {
|
||||||
|
const service = readProjectFile('web/src/services/receiptFolder.js')
|
||||||
|
const ocrService = readProjectFile('web/src/services/ocr.js')
|
||||||
|
const reimbursementService = readProjectFile('web/src/services/reimbursements.js')
|
||||||
|
|
||||||
|
assert.match(service, /\/receipt-folder\$\{buildStatusQuery\(status\)\}/)
|
||||||
|
assert.match(service, /\/receipt-folder\/\$\{encodeURIComponent/)
|
||||||
|
assert.match(service, /responseType: 'blob'/)
|
||||||
|
assert.match(service, /new File\(\[blob\], fileName/)
|
||||||
|
assert.match(service, /receiptId/)
|
||||||
|
assert.match(ocrService, /formData\.append\('receipt_ids'/)
|
||||||
|
assert.match(reimbursementService, /formData\.append\('receipt_id'/)
|
||||||
|
}
|
||||||
|
|
||||||
|
function testAppShellWiresReceiptFolder() {
|
||||||
|
const shell = readProjectFile('web/src/views/AppShellRouteView.vue')
|
||||||
|
|
||||||
|
assert.match(shell, /activeView === 'receiptFolder'/)
|
||||||
|
assert.match(shell, /ReceiptFolderView/)
|
||||||
|
assert.match(shell, /@open-assistant="openSmartEntry"/)
|
||||||
|
assert.match(shell, /receipt-folder-workarea/)
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSharedDocumentListStyleReuse() {
|
||||||
|
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||||
|
const documentView = readProjectFile('web/src/views/DocumentsCenterView.vue')
|
||||||
|
const receiptStyles = readProjectFile('web/src/assets/styles/views/receipt-folder-view.css')
|
||||||
|
const sharedStyles = readProjectFile('web/src/assets/styles/components/document-list-shared.css')
|
||||||
|
|
||||||
|
assert.match(receiptView, /document-list-shared\.css/)
|
||||||
|
assert.match(documentView, /document-list-shared\.css/)
|
||||||
|
assert.match(sharedStyles, /\.table-wrap\b/)
|
||||||
|
assert.match(sharedStyles, /\.doc-kind-tag\b/)
|
||||||
|
assert.match(sharedStyles, /\.list-foot\b/)
|
||||||
|
assert.doesNotMatch(receiptStyles, /\.table-wrap\b/)
|
||||||
|
assert.doesNotMatch(receiptStyles, /\.doc-kind-tag\b/)
|
||||||
|
assert.doesNotMatch(receiptStyles, /\.list-foot\b/)
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
testReceiptFolderViewSurface()
|
||||||
|
testReceiptFolderServiceContract()
|
||||||
|
testAppShellWiresReceiptFolder()
|
||||||
|
testSharedDocumentListStyleReuse()
|
||||||
|
console.log('receipt folder view tests passed')
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
Reference in New Issue
Block a user