From 4c59941ec613a25b7d015ca654742dbe81c05959 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Fri, 29 May 2026 14:51:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=A5=A8=E6=8D=AE?= =?UTF-8?q?=E5=A4=B9=E6=A8=A1=E5=9D=97=E5=B9=B6=E4=BC=98=E5=8C=96=20OCR=20?= =?UTF-8?q?=E4=B8=8E=E5=91=98=E5=B7=A5=E7=94=BB=E5=83=8F=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增票据夹端点、数据模型和服务模块,优化 OCR 端点 Schema 和附件操作逻辑,完善员工行为画像服务和辅助函数, 前端新增票据夹视图和服务层,优化文档中心样式和侧边栏导 航,完善员工画像详情弹窗和权限控制,补充单元测试。 --- document/development/receipt-folder/TODO.md | 69 +- .../app/api/v1/endpoints/employee_profiles.py | 32 +- server/src/app/api/v1/endpoints/ocr.py | 14 +- .../app/api/v1/endpoints/receipt_folder.py | 108 +++ .../app/api/v1/endpoints/reimbursements.py | 4 +- server/src/app/api/v1/router.py | 2 + server/src/app/schemas/ocr.py | 4 + server/src/app/schemas/receipt_folder.py | 68 ++ .../app/services/account_behavior_profile.py | 176 +++++ .../employee_behavior_profile_helpers.py | 16 + .../employee_behavior_profile_service.py | 3 + .../expense_claim_attachment_operations.py | 12 + server/src/app/services/receipt_folder.py | 532 +++++++++++++++ .../test_employee_behavior_profile_service.py | 68 ++ server/tests/test_ocr_endpoints.py | 96 ++- web/src/assets/styles/app.css | 2 + .../components/document-list-shared.css | 495 ++++++++++++++ .../styles/views/documents-center-view.css | 481 +------------- .../styles/views/receipt-folder-view.css | 372 +++++++++++ .../business/ExpenseProfileDetailModal.vue | 19 +- web/src/components/layout/SidebarRail.vue | 6 +- web/src/composables/useNavigation.js | 26 +- web/src/data/icons.js | 1 + web/src/services/ocr.js | 1 + web/src/services/receiptFolder.js | 49 ++ web/src/services/reimbursements.js | 3 + web/src/utils/accessControl.js | 9 +- web/src/utils/employeeProfileViewModel.js | 22 +- web/src/views/AppShellRouteView.vue | 10 +- web/src/views/DocumentsCenterView.vue | 1 + web/src/views/ReceiptFolderView.vue | 620 ++++++++++++++++++ .../navigation-route-resolution.test.mjs | 10 + web/tests/receipt-folder-view.test.mjs | 75 +++ 33 files changed, 2855 insertions(+), 551 deletions(-) create mode 100644 server/src/app/api/v1/endpoints/receipt_folder.py create mode 100644 server/src/app/schemas/receipt_folder.py create mode 100644 server/src/app/services/account_behavior_profile.py create mode 100644 server/src/app/services/receipt_folder.py create mode 100644 web/src/assets/styles/components/document-list-shared.css create mode 100644 web/src/assets/styles/views/receipt-folder-view.css create mode 100644 web/src/services/receiptFolder.js create mode 100644 web/src/views/ReceiptFolderView.vue create mode 100644 web/tests/receipt-folder-view.test.mjs diff --git a/document/development/receipt-folder/TODO.md b/document/development/receipt-folder/TODO.md index d9d21d1..a6dc7c9 100644 --- a/document/development/receipt-folder/TODO.md +++ b/document/development/receipt-folder/TODO.md @@ -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: 测试方案] + 证据:本文件已补充完成勾选和验证命令记录。 diff --git a/server/src/app/api/v1/endpoints/employee_profiles.py b/server/src/app/api/v1/endpoints/employee_profiles.py index 3d8f0d0..42c46f5 100644 --- a/server/src/app/api/v1/endpoints/employee_profiles.py +++ b/server/src/app/api/v1/endpoints/employee_profiles.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext, get_current_user, get_db from app.models.employee import Employee from app.schemas.employee_profile import EmployeeProfileLatestRead +from app.services.account_behavior_profile import AccountBehaviorProfileService from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService router = APIRouter(prefix="/employee-profiles") @@ -31,13 +32,13 @@ def get_current_employee_latest_profile( ) -> EmployeeProfileLatestRead: employee = _resolve_current_employee(db, current_user) if employee is None: - return EmployeeProfileLatestRead( - employee_id=current_user.username, - employee_name=current_user.name, + return AccountBehaviorProfileService(db).get_latest_account_profile( + account_id=current_user.username, + account_name=current_user.name, + identifiers=_current_account_identifiers(current_user), scene=scene, window_days=window_days, expense_type_scope=expense_type_scope, - empty_reason="当前登录用户未匹配到员工目录,暂无法形成用户画像。", ) service = EmployeeBehaviorProfileService(db) @@ -47,7 +48,7 @@ def get_current_employee_latest_profile( window_days=window_days, expense_type_scope=expense_type_scope, ) - if latest.empty_reason: + if latest.empty_reason or _missing_usage_duration_metric(latest): service.refresh_employee_profiles( employee_id=employee.id, 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) 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() + } diff --git a/server/src/app/api/v1/endpoints/ocr.py b/server/src/app/api/v1/endpoints/ocr.py index 6f016d9..ae2b327 100644 --- a/server/src/app/api/v1/endpoints/ocr.py +++ b/server/src/app/api/v1/endpoints/ocr.py @@ -2,13 +2,14 @@ from __future__ import annotations 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 app.api.deps import CurrentUserContext, get_current_user, get_db from app.schemas.common import ErrorResponse from app.schemas.ocr import OcrRecognizeBatchRead from app.services.ocr import OcrService +from app.services.receipt_folder import ReceiptFolderService router = APIRouter(prefix="/ocr") @@ -35,8 +36,9 @@ router = APIRouter(prefix="/ocr") ) async def recognize_ocr_documents( 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)], + receipt_ids: Annotated[list[str] | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None, ) -> OcrRecognizeBatchRead: try: payload = [] @@ -48,7 +50,13 @@ async def recognize_ocr_documents( 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: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc except RuntimeError as exc: diff --git a/server/src/app/api/v1/endpoints/receipt_folder.py b/server/src/app/api/v1/endpoints/receipt_folder.py new file mode 100644 index 0000000..fd5ee7b --- /dev/null +++ b/server/src/app/api/v1/endpoints/receipt_folder.py @@ -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) diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 6f52629..e440807 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -2,7 +2,7 @@ from __future__ import annotations 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 sqlalchemy.orm import Session @@ -372,6 +372,7 @@ async def upload_expense_claim_item_attachment( file: Annotated[UploadFile, File(description="待上传的附件文件。")], db: DbSession, current_user: CurrentUser, + receipt_id: Annotated[str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None, ) -> ExpenseClaimAttachmentActionResponse: service = ExpenseClaimService(db) try: @@ -382,6 +383,7 @@ async def upload_expense_claim_item_attachment( content=await file.read(), media_type=file.content_type, current_user=current_user, + source_receipt_id=receipt_id or "", ) except LookupError as error: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 3ca4cde..b5ce6c2 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -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.ontology import router as ontology_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.settings import router as settings_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(ontology_router, tags=["ontology"]) 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(employee_profiles_router, tags=["employee-profiles"]) router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) diff --git a/server/src/app/schemas/ocr.py b/server/src/app/schemas/ocr.py index 998288b..43a6d13 100644 --- a/server/src/app/schemas/ocr.py +++ b/server/src/app/schemas/ocr.py @@ -39,6 +39,10 @@ class OcrRecognizeDocumentRead(BaseModel): ) preview_kind: str = Field(default="", description="预览类型,PDF 转图后通常为 image。") 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="该文件的识别提示或警告。") lines: list[OcrRecognizeLineRead] = Field(default_factory=list, description="逐行识别结果。") diff --git a/server/src/app/schemas/receipt_folder.py b/server/src/app/schemas/receipt_folder.py new file mode 100644 index 0000000..032bdd2 --- /dev/null +++ b/server/src/app/schemas/receipt_folder.py @@ -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 diff --git a/server/src/app/services/account_behavior_profile.py b/server/src/app/services/account_behavior_profile.py new file mode 100644 index 0000000..3f7a145 --- /dev/null +++ b/server/src/app/services/account_behavior_profile.py @@ -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()) diff --git a/server/src/app/services/employee_behavior_profile_helpers.py b/server/src/app/services/employee_behavior_profile_helpers.py index 82fe453..51819a7 100644 --- a/server/src/app/services/employee_behavior_profile_helpers.py +++ b/server/src/app/services/employee_behavior_profile_helpers.py @@ -171,6 +171,22 @@ class EmployeeBehaviorProfileMetricHelpers: total += max(0, len(text) // 4) 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 def _is_missing_value(value: Any) -> bool: text = str(value or "").strip() diff --git a/server/src/app/services/employee_behavior_profile_service.py b/server/src/app/services/employee_behavior_profile_service.py index d984a3c..0343dc0 100644 --- a/server/src/app/services/employee_behavior_profile_service.py +++ b/server/src/app/services/employee_behavior_profile_service.py @@ -466,6 +466,7 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): 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) override_score = 0 token_mode = "estimated_token_count" if estimated_tokens else "unavailable" @@ -524,6 +525,8 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): "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", }, ) diff --git a/server/src/app/services/expense_claim_attachment_operations.py b/server/src/app/services/expense_claim_attachment_operations.py index 7eba990..89b9cd8 100644 --- a/server/src/app/services/expense_claim_attachment_operations.py +++ b/server/src/app/services/expense_claim_attachment_operations.py @@ -108,6 +108,7 @@ from app.services.expense_rule_runtime import ( resolve_document_type_label, ) from app.services.ocr import OcrService +from app.services.receipt_folder import ReceiptFolderService class ExpenseClaimAttachmentOperationsMixin: @@ -120,6 +121,7 @@ class ExpenseClaimAttachmentOperationsMixin: content: bytes, media_type: str | None, current_user: CurrentUserContext, + source_receipt_id: str = "", ) -> dict[str, Any] | None: claim, item = self._get_claim_item_or_raise( claim_id=claim_id, @@ -240,6 +242,16 @@ class ExpenseClaimAttachmentOperationsMixin: "ocr_warnings": [str(item) for item in getattr(ocr_document, "warnings", []) or []], } 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.db.commit() diff --git a/server/src/app/services/receipt_folder.py b/server/src/app/services/receipt_folder.py new file mode 100644 index 0000000..84456c8 --- /dev/null +++ b/server/src/app/services/receipt_folder.py @@ -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 diff --git a/server/tests/test_employee_behavior_profile_service.py b/server/tests/test_employee_behavior_profile_service.py index 699bc71..a6f6085 100644 --- a/server/tests/test_employee_behavior_profile_service.py +++ b/server/tests/test_employee_behavior_profile_service.py @@ -264,6 +264,74 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None: payload = response.json() assert payload["employee_id"] == "emp-main" 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["radar"]["dimensions"] diff --git a/server/tests/test_ocr_endpoints.py b/server/tests/test_ocr_endpoints.py index 14b9c82..9df72a8 100644 --- a/server/tests/test_ocr_endpoints.py +++ b/server/tests/test_ocr_endpoints.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.api.deps import get_db +from app.core.config import get_settings from app.db.base import Base from app.main import create_app from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead, OcrRecognizeLineRead @@ -35,7 +36,7 @@ def build_client() -> TestClient: 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( self, 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) - client = build_client() + try: + client = build_client() + auth_headers = {"x-auth-username": "pytest", "x-auth-name": "Py Test"} - response = client.post( - "/api/v1/ocr/recognize", - headers={"x-auth-username": "pytest", "x-auth-name": "Py Test"}, - files=[("files", ("invoice.png", b"fake-image", "image/png"))], - ) + response = client.post( + "/api/v1/ocr/recognize", + headers=auth_headers, + files=[("files", ("invoice.png", b"fake-image", "image/png"))], + ) - assert response.status_code == 200 - payload = response.json() - assert payload["engine"] == "paddleocr_mobile" - assert payload["success_count"] == 1 - assert payload["documents"][0]["filename"] == "invoice.png" - assert payload["documents"][0]["summary"] == "增值税电子发票,金额 100 元。" - assert payload["documents"][0]["document_type"] == "vat_invoice" - assert payload["documents"][0]["document_type_label"] == "增值税发票" - assert payload["documents"][0]["document_fields"][0]["label"] == "金额" + assert response.status_code == 200 + payload = response.json() + document = payload["documents"][0] + assert payload["engine"] == "paddleocr_mobile" + assert payload["success_count"] == 1 + assert document["filename"] == "invoice.png" + assert document["summary"] == "增值税电子发票,金额 100 元。" + assert document["document_type"] == "vat_invoice" + assert document["document_type_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() diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index 2faa116..4a5de55 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -163,6 +163,7 @@ overflow: hidden; } .main.documents-main, +.main.receipt-folder-main, .main.requests-main, .main.approval-main, .main.archive-main, @@ -181,6 +182,7 @@ .workarea { min-height: 0; overflow: auto; padding: 24px; } .workarea.requests-workarea, .workarea.documents-workarea, +.workarea.receipt-folder-workarea, .workarea.workbench-workarea, .workarea.approval-workarea, .workarea.archive-workarea, diff --git a/web/src/assets/styles/components/document-list-shared.css b/web/src/assets/styles/components/document-list-shared.css new file mode 100644 index 0000000..26eb1d7 --- /dev/null +++ b/web/src/assets/styles/components/document-list-shared.css @@ -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; + } +} diff --git a/web/src/assets/styles/views/documents-center-view.css b/web/src/assets/styles/views/documents-center-view.css index 1354182..ad797e3 100644 --- a/web/src/assets/styles/views/documents-center-view.css +++ b/web/src/assets/styles/views/documents-center-view.css @@ -15,135 +15,6 @@ 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, .date-range-filter { position: relative; @@ -287,43 +158,6 @@ 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 { display: inline-flex; align-items: center; @@ -348,83 +182,6 @@ 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-created { width: 10%; } .col-stay { width: 9%; } @@ -437,47 +194,6 @@ table { .col-status { width: 8%; } .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 { display: inline-flex; align-items: center; @@ -505,211 +221,16 @@ tbody tr:last-child td { 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) { .documents-list { 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 { + width: 100%; align-items: stretch; flex-direction: column; gap: 6px; } - .list-foot { - display: grid; - justify-items: stretch; - } } diff --git a/web/src/assets/styles/views/receipt-folder-view.css b/web/src/assets/styles/views/receipt-folder-view.css new file mode 100644 index 0000000..78a9b37 --- /dev/null +++ b/web/src/assets/styles/views/receipt-folder-view.css @@ -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; + } +} diff --git a/web/src/components/business/ExpenseProfileDetailModal.vue b/web/src/components/business/ExpenseProfileDetailModal.vue index 38d6d02..481a2d4 100644 --- a/web/src/components/business/ExpenseProfileDetailModal.vue +++ b/web/src/components/business/ExpenseProfileDetailModal.vue @@ -440,13 +440,13 @@ watch( .profile-tags-panel { grid-template-rows: auto minmax(0, 1fr); - align-content: start; + align-content: stretch; min-height: 352px; } .profile-radar-panel { grid-template-rows: auto minmax(0, 1fr) auto; - align-content: start; + align-content: stretch; min-height: 352px; } @@ -477,6 +477,15 @@ watch( .profile-panel-empty { margin: 0; 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-radius: 4px; background: #f8fafc; @@ -487,10 +496,12 @@ watch( text-align: center; } +.profile-tags-panel > .profile-panel-empty { + min-height: 284px; +} + .profile-radar-empty { min-height: 308px; - display: grid; - place-items: center; } .profile-operation-copy strong { diff --git a/web/src/components/layout/SidebarRail.vue b/web/src/components/layout/SidebarRail.vue index 320d028..cbf3982 100644 --- a/web/src/components/layout/SidebarRail.vue +++ b/web/src/components/layout/SidebarRail.vue @@ -156,9 +156,9 @@ const sidebarMeta = { overview: { label: '分析看板' }, workbench: { label: '个人工作台' }, documents: { label: '单据中心' }, - budget: { label: '预算中心' }, - policies: { label: '知识管理' }, - audit: { label: '规则中心' }, + budget: { label: '预算编制' }, + policies: { label: '财务政策' }, + audit: { label: '规则管理' }, digitalEmployees: { label: '数字员工' }, employees: { label: '员工管理' }, settings: { label: '系统设置' } diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js index 702229c..8f30547 100644 --- a/web/src/composables/useNavigation.js +++ b/web/src/composables/useNavigation.js @@ -6,6 +6,7 @@ import { icons } from '../data/icons.js' export const appViews = [ 'workbench', 'documents', + 'receiptFolder', 'budget', 'audit', 'overview', @@ -32,20 +33,28 @@ export const navItems = [ title: '单据中心', desc: '统一查看申请、报销、审批与归档。' }, + { + id: 'receiptFolder', + label: '票据夹', + navHint: '存放已上传并识别的原始票据', + icon: icons.receipt, + title: '票据夹', + desc: '集中查看未关联和已关联票据,避免 OCR 后票据丢失。' + }, { id: 'budget', - label: '预算中心', + label: '预算编制', navHint: '管理预算额度、预算占用与超预算预警', icon: icons.budget, - title: '预算中心', + title: '预算编制', desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。' }, { id: 'audit', - label: '规则中心', + label: '规则管理', navHint: '查看和管理规则配置', icon: icons.skill, - title: '规则中心', + title: '规则管理', desc: '集中管理财务规则、风险规则与外部 MCP 服务。' }, { @@ -74,11 +83,11 @@ export const navItems = [ }, { id: 'policies', - label: '制度知识', - navHint: '查看制度与知识库', + label: '财务政策', + navHint: '查看财务政策与制度文档', icon: icons.library, - title: '制度与知识库', - desc: '统一管理制度文档、检索入口与知识资产。' + title: '财务政策', + desc: '统一管理财务政策文档、检索入口与知识资产。' }, { id: 'settings', @@ -94,6 +103,7 @@ const viewRouteNames = { overview: 'app-overview', workbench: 'app-workbench', documents: 'app-documents', + receiptFolder: 'app-receiptFolder', budget: 'app-budget', policies: 'app-policies', audit: 'app-audit', diff --git a/web/src/data/icons.js b/web/src/data/icons.js index e901a0e..9cff749 100644 --- a/web/src/data/icons.js +++ b/web/src/data/icons.js @@ -9,6 +9,7 @@ export const icons = { budget: iconPath(''), archive: iconPath(''), file: iconPath(''), + receipt: iconPath(''), book: iconPath(''), library: iconPath(''), skill: iconPath(''), diff --git a/web/src/services/ocr.js b/web/src/services/ocr.js index 34ee8cd..613155b 100644 --- a/web/src/services/ocr.js +++ b/web/src/services/ocr.js @@ -4,6 +4,7 @@ export function recognizeOcrFiles(files, options = {}) { const formData = new FormData() for (const file of files) { formData.append('files', file) + formData.append('receipt_ids', String(file?.receiptId || '')) } return apiRequest('/ocr/recognize', { diff --git a/web/src/services/receiptFolder.js b/web/src/services/receiptFolder.js new file mode 100644 index 0000000..3e92cd4 --- /dev/null +++ b/web/src/services/receiptFolder.js @@ -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 +} diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js index e3aca1a..496b9a1 100644 --- a/web/src/services/reimbursements.js +++ b/web/src/services/reimbursements.js @@ -91,6 +91,9 @@ export function deleteExpenseClaimItem(claimId, itemId) { export function uploadExpenseClaimItemAttachment(claimId, itemId, file) { const formData = new FormData() 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`, { method: 'POST', diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 359a33b..7905ffb 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -1,7 +1,8 @@ export const DEFAULT_APP_VIEW_ORDER = [ - 'workbench', - 'documents', - 'budget', + 'workbench', + 'documents', + 'receiptFolder', + 'budget', 'audit', 'overview', 'policies', @@ -10,7 +11,7 @@ export const DEFAULT_APP_VIEW_ORDER = [ 'settings' ] -const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies']) +const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'receiptFolder', 'policies']) const VIEW_ROLE_RULES = { overview: ['finance', 'executive'], budget: ['budget_monitor', 'executive'], diff --git a/web/src/utils/employeeProfileViewModel.js b/web/src/utils/employeeProfileViewModel.js index 8202ba4..ccb186c 100644 --- a/web/src/utils/employeeProfileViewModel.js +++ b/web/src/utils/employeeProfileViewModel.js @@ -97,11 +97,15 @@ export function buildUserProfileMetricCards(profile, runs = [], currentUser = {} const index = indexProfiles(profile) const aiMetrics = metricsOf(index.ai_usage) const userRuns = filterRunsByCurrentUser(runs, currentUser) - const durationDisplay = formatDurationMetric(sumRunDurationMs(userRuns)) - const commonAgent = resolveCommonAgent(userRuns) + const windowedUserRuns = filterRunsByProfileWindow(userRuns, profile) + 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 tokenDisplay = formatTokenCount(tokenCount) - const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || userRuns.length + const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || windowedUserRuns.length return [ { @@ -223,11 +227,23 @@ function metricsOf(profile) { 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) { const identities = resolveCurrentUserIdentities(currentUser) 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) { if (!identities.size) { return false diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index d78a9d5..876353b 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -40,6 +40,7 @@ 'overview-main': activeView === 'overview', 'workbench-main': activeView === 'workbench', 'documents-main': activeView === 'documents', + 'receipt-folder-main': activeView === 'receiptFolder', 'budget-main': activeView === 'budget', 'policies-main': activeView === 'policies', 'audit-main': activeView === 'audit', @@ -75,7 +76,7 @@ /> + + import('./PersonalWorkb const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue')) const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue')) const DocumentsCenterView = defineAsyncComponent(() => import('./DocumentsCenterView.vue')) +const ReceiptFolderView = defineAsyncComponent(() => import('./ReceiptFolderView.vue')) const BudgetCenterRouteLoading = { name: 'BudgetCenterRouteLoading', render: () => diff --git a/web/src/views/DocumentsCenterView.vue b/web/src/views/DocumentsCenterView.vue index 5a4f61b..4af7d9f 100644 --- a/web/src/views/DocumentsCenterView.vue +++ b/web/src/views/DocumentsCenterView.vue @@ -832,4 +832,5 @@ onMounted(() => { }) + diff --git a/web/src/views/ReceiptFolderView.vue b/web/src/views/ReceiptFolderView.vue new file mode 100644 index 0000000..8aa3eb2 --- /dev/null +++ b/web/src/views/ReceiptFolderView.vue @@ -0,0 +1,620 @@ +