diff --git a/.gitignore b/.gitignore index 11500b6..bab2e68 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ __pycache__/ server/.venv/ server/.venv-ocr312 server/.secrets/ +server/logs/ +server/storage/expense_claims/ +server/storage/finance_reports/ +test-results/ +.codex-remote-attachments/ +tmp-*.png diff --git a/docker-compose.yml b/docker-compose.yml index 608dec7..4b047e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,8 @@ services: - > apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends - python3 python3-pip python3-venv fontconfig fonts-noto-cjk fonts-noto-cjk-extra && + python3 python3-pip python3-venv fontconfig && + if ! fc-match 'Noto Sans CJK SC' | grep -qi 'Noto'; then if ! timeout "${CJK_FONT_INSTALL_TIMEOUT_SECONDS:-45}" sh -lc 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra'; then printf '%s\n' '[WARN] CJK font installation timed out or failed; continuing startup without blocking the app.'; fi; fi && printf '%s\n' '' '' diff --git a/document/development/expense-control-demo-data/01_finance-rules.md b/document/development/expense-control-demo-data/01_finance-rules.md index e5ade37..e41f38a 100644 --- a/document/development/expense-control-demo-data/01_finance-rules.md +++ b/document/development/expense-control-demo-data/01_finance-rules.md @@ -1,5 +1,11 @@ # 财务规则表补齐开发记录 +## 2026-06-05 口径调整 + +用户明确要求业务招待费超过 500 元、大额办公用品以及金额超过 2000 元的费用申请审批要求进入财务规则中心。因此新增《公司费用申请审批规则》作为申请前置和审批阈值的财务规则依据;风险规则负责引用该财务规则并执行命中判断。 + +本次调整不恢复旧的单项《业务招待费报销规则》或《办公用品费报销规则》,而是使用统一规则表维护申请审批阈值,避免规则中心再次出现多个口径型规则表。 + ## 目标 财务规则中心只维护真正具备制度标准、且需要按职级/职务或明确人均标准执行的规则表。没有实际金额分档的费用类型,不在财务规则中心单独生成 Excel 表;其额度控制进入预算中心,申请前置和材料完整性进入风险规则。 diff --git a/document/development/expense-control-demo-data/02_risk-rules.md b/document/development/expense-control-demo-data/02_risk-rules.md index bfff5b1..fe4644d 100644 --- a/document/development/expense-control-demo-data/02_risk-rules.md +++ b/document/development/expense-control-demo-data/02_risk-rules.md @@ -1,5 +1,9 @@ # 风险规则补齐开发记录 +## 2026-06-05 口径调整 + +业务招待费超过 500 元、办公用品超过 2000 元、通用费用超过 2000 元的申请前置要求,制度依据统一改为财务规则《公司费用申请审批规则》。风险规则继续承担执行判断,但 `finance_rule_code` 统一指向 `expense.preapproval.policy`。 + ## 目标 补齐预算、申请前置、报销偏差、费用标准、材料完整性类风险规则,让后续 demo 数据可以形成“预算-申请-报销-风控”的闭环。 diff --git a/document/development/小财管家/CONCEPT.md b/document/development/小财管家/CONCEPT.md index d0909d5..223670c 100644 --- a/document/development/小财管家/CONCEPT.md +++ b/document/development/小财管家/CONCEPT.md @@ -150,6 +150,27 @@ - 申请任务完成后,再把剩余报销任务作为后续任务引导到报销助手。 - 附件归集不作为第一屏主动作抢占申请流程;当进入报销任务时,相关附件随报销上下文带入。 +小财管家必须维护运行时任务上下文,而不是把每次用户输入都当成新的独立意图。上下文至少包含: + +- 当前任务:正在处理的申请或报销任务。 +- 剩余任务:已拆解但尚未处理的任务队列。 +- 已完成任务:已经形成申请单、报销草稿、附件归集或提交动作的任务。 +- 等待动作:当前正在等待用户补字段、确认核对表、确认提交审批或继续下一项。 +- 最近结构化结果:当前申请核对表、报销核对结果、附件归集建议等。 + +用户输入“确认”“无误”“可以提交”等文本时,小财管家必须先匹配当前等待动作;如果当前等待的是申请单提交确认,就提交当前申请单;如果当前等待的是继续下一项,就进入剩余任务队列中的下一项;如果当前核对表仍有缺字段,则提示补字段。只有没有可匹配上下文时,才重新进入任务规划。 + +上述匹配不应主要依赖前端关键词规则。第一版应新增“小财管家运行时决策智能体”,由后端 function calling 接收 `runtime_state` 和用户当前输入,返回结构化 `next_action`: + +- `submit_current_application`:确认当前申请核对表并提交至审批。 +- `continue_next_task`:当前任务已完成,继续剩余任务队列中的下一项。 +- `fill_current_slot`:用户补充了当前等待字段。 +- `ask_user`:当前信息不足,需要继续追问。 +- `plan_new_tasks`:当前没有可匹配上下文,重新进入任务规划。 +- `cancel_current_action` / `no_op`:取消或不执行当前动作。 + +前端只执行模型返回的结构化动作,并做安全校验:例如申请核对表必须 `readyToSubmit` 才能提交,已提交消息必须标记避免重复提交,缺字段时必须追问。本地规则只允许作为模型失败后的保守兜底,不作为主判断来源。 + 可以自动执行的动作: - 任务拆解。 @@ -161,7 +182,7 @@ ### 4. 流式过程摘要 -前端展示的是“意图识别智能体”的过程摘要,不是模型内部推理链;过程摘要必须独立于最终回复正文展示。 +前端展示的是“意图识别智能体”的过程摘要,不是模型内部推理链;过程摘要必须独立于最终回复正文展示。过程摘要必须围绕业务理解展开,例如用户说了什么、被拆成哪些申请/报销任务、已识别哪些业务要素、还缺少哪些关键条件、为什么需要向用户追问。不能只展示“接收确认、协调能力、准备输出”等系统执行日志。 示例: @@ -188,7 +209,7 @@ 计划完成后的最终正文也必须流式输出。前端不能把完整正文一次性替换到消息气泡里,而应进入 `typing` 状态按字符逐步追加正文;正文输出完成后,再把状态改为“等待用户确认”并展示确认按钮。 -用户确认当前步骤后,小财管家隐式委派给申请能力或报销能力时,也必须保持同一套流式体验:先在系统气泡上方的小财管家思考折叠气泡中逐字展示“接收确认、协调能力、等待结果”等过程摘要;拿到申请核对表或报销核对结果后,再逐字输出正文。结构化表格、核对卡片、确认按钮可以在正文输出完成后一次性展示,但正文不能一次性替换进消息气泡。 +用户确认当前步骤后,小财管家隐式委派给申请能力或报销能力时,也必须保持同一套流式体验:先在系统气泡上方的小财管家思考折叠气泡中逐字展示当前业务任务、已识别信息、待补充条件和下一步动作;拿到申请核对表或报销核对结果后,再逐字输出正文。结构化表格、核对卡片、确认按钮可以在正文输出完成后一次性展示,但正文不能一次性替换进消息气泡。 小财管家委派期间不得打开右侧单助手执行流程面板,也不得把“申请助手 / 报销助手”的执行步骤显示成独立助手思考框。用户可见身份保持“小财管家”,具体调用哪个能力只作为小财管家自己的过程摘要,不切换为“财务助手”或单独助手会话。 @@ -208,6 +229,12 @@ 后续步骤如果需要展示“还需要补充”,必须是结构化列表,每个待补充项独立成行,包含字段业务名称和填写说明;不得把多个待补充项拼接成一行连续文本。 +当后续步骤发现关键条件缺失时,小财管家不能只展示“模型复核不稳定”或“下方表格待补充”。它必须把缺口转成下一轮对话问题,并优先给出可直接选择的业务选项。例如差旅申请缺少 `transport_mode` 时,用户界面展示为“请问你打算怎么出行?火车、飞机或轮船”,不得先展示申请核对表,也不得默认补成火车;用户选择后再生成申请核对表、写回出行方式、重新测算费用,并继续判断是否可以提交申请。这是“思考 -> 行动 -> 再思考 -> 再行动”循环的一部分。 + +用户补齐关键字段也不是终态动作。以“出行方式”为例,用户选择火车后,小财管家必须先进入下一轮业务思考,基于已识别的时间、地点、事由和出行方式模拟查询交通票据或票价口径,完成系统预估金额测算,再流式输出正文并展示申请核对表;不能在用户点击选项后直接把旧核对表补字段后闪现出来。 + +费用申请核对表阶段不得把系统档案字段或非阻塞归档字段当作用户待补充项。`employee_no`、`employee_name`、`department_name` 应从当前登录用户档案和组织上下文读取;`attachments` 在差旅申请阶段不阻塞核对表生成,可在后续报销、归档或审批材料补充阶段处理;`amount` 在申请阶段由系统规则估算。字段决策模型即使返回这些字段为缺失,服务端也必须过滤,不能向用户展示“附件/凭证和员工编号为合规必需字段”这类错误追问。 + 任务卡片和正文不得直接暴露本体字段名,例如 `transport_mode`、`amount`、`attachments`。本体字段只允许作为内部结构化数据进入后端、助手委派和持久化链路;用户界面必须翻译为业务中文,并提供可理解的填写说明: - `transport_mode` 展示为“出行方式”,说明可填写高铁、飞机、自驾、出租车等。 @@ -311,6 +338,13 @@ 规则置信度评分仅用于模型不可用或模型返回结构不可用时的兜底路径。 +任务拆解之后还需要第二层“任务字段决策智能体”。这一步不能由前端关键词或固定 required 字段直接决定,而要把当前任务类型、用户原话、上游任务拆解结果、canonical ontology fields、已抽取字段、缺失字段、附件和申请/报销上下文交给模型,通过 function calling 返回下一步动作: + +- `ask_user`:当前信息不足,必须先把缺口转成业务问题和可选项。 +- `render_preview`:当前信息足够生成可核对结果,但提交、入库、绑定附件前仍需用户确认。 + +字段决策规则只能作为模型不可用或结构化结果非法时的兜底,兜底结果必须标记为 `rule_fallback`,不能伪装成智能体判断。字段名必须来自 ontology registry;UI 只展示中文业务名称,不展示 canonical 字段名。 + 任务置信度: $$ diff --git a/document/development/小财管家/TODO.md b/document/development/小财管家/TODO.md index ba3147b..f6ac99c 100644 --- a/document/development/小财管家/TODO.md +++ b/document/development/小财管家/TODO.md @@ -44,6 +44,14 @@ - [x] 支持小财管家隐藏委派申请/报销能力,执行后保留小财管家会话并继续引导剩余任务。[CONCEPT: 执行流] 证据:`sessionTypeOverride` 和 `stewardContinuation` 已接入前端提交流程。 - [x] 支持小财管家确认后的隐式委派继续流式输出,正文完成后再展示申请核对表、报销核对卡片和确认按钮。[CONCEPT: 流式过程摘要] 证据:`useTravelReimbursementSubmitComposer.js` 新增 `typeStewardDelegatedMessage`,申请预览与 orchestrator 结果均先流式思考、再逐字正文、最后挂载结构化 payload;`npm.cmd --prefix web run build` 成功。 - [x] 小财管家委派申请/报销能力期间不打开右侧单助手执行流程面板,用户可见身份保持“小财管家”。[CONCEPT: 流式过程摘要] 证据:`stewardDelegated` 分支跳过 flow step 与 review panel scope,并在最终消息设置 `assistantName: '小财管家'`;`stewardPlanModel.js` 助手标签兜底不再显示“财务助手”。 +- [x] 小财管家在后续步骤发现关键缺口时,主动转成可回答的问题和选项,而不是只展示待补充表格。[CONCEPT: 用户可见结果展示] 证据:`useTravelReimbursementSubmitComposer.js` 在申请核对缺少“出行方式”时只输出主动追问和火车/飞机/轮船选项,不提前挂载 `applicationPreview`;`stewardPlanModel.js` 的内部 `carry_text` 不再把“高铁、飞机”等示例写进缺字段提示,避免本地抽取误当成用户已选择;`TravelReimbursementCreateView.js` 在用户选择后不再直接补旧表格,而是重新进入小财管家的委派流;`web/tests/expense-application-fast-preview.test.mjs` 覆盖该回归。 +- [x] 用户补齐出行方式后,小财管家必须先思考、模拟查询票据和测算金额,再展示申请核对表。[CONCEPT: 用户可见结果展示] 证据:`stewardFieldCompletionModel.js` 将补齐字段后的当前任务、本体字段和旧预览重组为续跑输入;`TravelReimbursementCreateView.js` 的 `continueStewardApplicationFieldCompletion` 调用 `submitComposerInternal` 触发流式思考、申请复核和费用测算,不再调用 `commitApplicationPreviewEditor` 直接闪现表格。 +- [x] 防止残留预算上下文抢占小财管家的申请续跑链路。[CONCEPT: 执行流] 证据:`budgetAssistantReportModel.js` 不再因存在 `initialBudgetContext` 就无条件进入预算编制报告;`useTravelReimbursementSubmitComposer.js` 对 `stewardDelegated` 显式跳过预算编制分支;`expense-application-fast-preview.test.mjs` 覆盖“申请续跑 + 残留预算上下文”不得进入预算编制。 +- [x] 支持用户直接输入“确认/无误/可以提交”命中当前申请核对表提交动作,而不是重新规划。[CONCEPT: 用户确认] 证据:`TravelReimbursementCreateView.js` 通过 `handleStewardRuntimeDecision` 优先请求运行时决策智能体;模型返回 `submit_current_application` 后复用 `confirmApplicationSubmit`;本地 `handleApplicationSubmitConfirmationText` 仅作为模型不可用时的兜底;提交成功后标记 `applicationSubmitConfirmed`,避免后续重复提交;测试 `text confirmation submits pending application preview before replanning steward task` 覆盖该优先级。 +- [x] 增加小财管家运行时决策智能体,把“确认、继续下一项、补字段、重新规划”的上下文判断迁到后端 function calling。[CONCEPT: 用户确认] 证据:`POST /steward/runtime-decisions` 调用 `StewardRuntimeDecisionAgent`,通过 `submit_steward_runtime_decision` 返回 `submit_current_application`、`continue_next_task`、`fill_current_slot`、`plan_new_tasks` 等动作;前端 `handleStewardRuntimeDecision` 先提交 `runtime_state`,再执行模型返回的结构化动作,本地规则仅兜底。 +- [x] 增加第二层任务字段决策智能体,动态判断当前任务应追问用户还是展示核对结果。[CONCEPT: 算法与公式] 证据:`POST /steward/slot-decisions` 调用 `StewardSlotDecisionAgent`,通过 `submit_steward_slot_decision` function calling 输出 `ask_user` / `render_preview`、canonical 缺失字段、问题和选项;前端 `useTravelReimbursementSubmitComposer.js` 在小财管家委派申请时消费该决策。 +- [x] 防止字段决策模型把申请阶段非阻塞字段误判为用户必填项。[CONCEPT: 用户可见结果展示] 证据:`StewardSlotDecisionAgent` 过滤 `amount`、`attachments`、`employee_no`、`department_name`、`employee_name`,模型误返 `ask_user` 且过滤后无缺口时改为 `render_preview`;前端 `APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS` 同步过滤兜底缺口和选项;测试覆盖附件/员工编号误判。 +- [x] 小财管家思考气泡必须体现业务意图和关键缺口,不能退化为系统执行日志。[CONCEPT: 流式过程摘要] 证据:`steward_planner.py` 将差旅申请缺少“出行方式”纳入计划缺口并追加业务缺口思考事件;`useTravelReimbursementSubmitComposer.js` 和 `TravelReimbursementCreateView.js` 的确认后思考改为读取任务摘要、已识别信息和待补充项。 - [x] 确认申请任务后,将任务摘要分派到现有申请助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=application` 和 `auto_submit=true`。 - [x] 确认报销任务后,将任务摘要和附件带入现有报销助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=expense`、`carry_files=true` 和 `auto_submit=true`。 - [x] 附件归集建议确认后,将选中附件作为报销助手上下文继续处理。[CONCEPT: 附件归集] 证据:附件归集确认动作携带归集附件名称和排除附件名称。 diff --git a/document/development/申请单关联归档状态/CONCEPT.md b/document/development/申请单关联归档状态/CONCEPT.md new file mode 100644 index 0000000..845b8c5 --- /dev/null +++ b/document/development/申请单关联归档状态/CONCEPT.md @@ -0,0 +1,88 @@ +# 申请单关联归档状态概念文档 + +## 功能一句话 + +申请单审批完成后先进入关联单据状态,只有关联的报销单完成付款归档后,申请单才同步归档。 + +## 背景与问题 + +当前费用申请单审批完成后,部分列表和进度展示会把申请单视为归档;但业务上申请单只是完成了事前审批,还需要等待后续报销单关联、报销审批、付款完成后,申请单生命周期才真正闭环。 + +这会导致用户看到报销单仍在处理、申请单却已归档,或者报销单已完成但申请单还停留在进行中的割裂状态。 + +## 目标 + +1. 申请单审批完成不直接进入归档中心。 +2. 申请单进度在归档前增加“关联单据状态”节点。 +3. 已有关联报销单但未付款完成时,该节点显示“关联中”。 +4. 没有关联报销单时,该节点显示“未关联”。 +5. 关联报销单付款完成后,申请单同步进入“申请归档”。 + +## 非目标 + +1. 不新增数据库表。 +2. 不改变报销单本身的审批、付款权限。 +3. 不改变申请单审批通过自动生成报销草稿的现有能力。 + +## 用户与场景 + +涉及角色: + +- 申请人:查看申请单是否已经关联后续报销单。 +- 审批人:审批申请单后不再误以为该申请已经归档。 +- 财务人员:付款完成报销单时,同步闭环关联申请单。 + +关键场景: + +1. 申请单审批通过,但未生成或未关联报销单:显示“关联单据状态 / 未关联”。 +2. 申请单审批通过,并已生成报销草稿或报销单仍在流程中:显示“关联单据状态 / 关联中”。 +3. 关联报销单已付款:报销单进入已付款,申请单进入“申请归档”。 + +## 方案设计 + +后端: + +- 申请单 `approved + 审批完成` 不再被归档查询命中。 +- 申请单只有 `approved + 申请归档` 才属于归档。 +- 报销单付款完成时,从 `application_handoff` 或 `application_link` 风险事件中读取关联申请单。 +- 找到关联申请单后,追加同步归档事件,并将申请单阶段置为“申请归档”。 + +前端: + +- 申请单进度增加“关联单据状态”和“已归档”节点。 +- 审批完成但未归档的申请单,当前节点停留在“关联单据状态”。 +- 根据申请单自身的 `generated_draft_claim_no` 或报销单侧关联事件显示“关联中 / 未关联”。 +- 只有“申请归档”阶段才展示归档完成。 + +## 算法与公式 + +当前功能不涉及显式数学公式。 + +关联状态判断: + +```text +has_linked_reimbursement = exists(application.generated_draft_claim_no) + or exists(reimbursement.risk_flags.application_claim_id/no == application.id/no) + +application_archived = application.status in {approved, completed} + and application.approval_stage == "申请归档" +``` + +## 测试方案 + +1. 后端状态测试:审批完成申请单不归档,申请归档才归档。 +2. 后端付款测试:关联报销单付款后,申请单同步进入“申请归档”。 +3. 前端进度测试:审批完成申请单显示“关联单据状态”和“已归档”。 +4. 前端归档判断测试:`审批完成` 申请单不算归档,`申请归档` 才算归档。 + +## 验收标准 + +1. 单据中心普通视图仍能看到审批完成但未归档的申请单。 +2. 归档中心不会提前出现仅审批完成的申请单。 +3. 申请单进度在审批完成后能看到“关联单据状态”。 +4. 报销单付款完成后,关联申请单同步显示为归档。 + +## 风险与开放问题 + +- 旧数据中可能存在已经把申请单审批完成当作归档的数据,本次按新业务规则修正展示与查询口径。 +- 如果历史申请单缺少关联报销事件,只能展示“未关联”,不做自动猜测。 diff --git a/document/development/申请单关联归档状态/TODO.md b/document/development/申请单关联归档状态/TODO.md new file mode 100644 index 0000000..aa3d72f --- /dev/null +++ b/document/development/申请单关联归档状态/TODO.md @@ -0,0 +1,8 @@ +# 申请单关联归档状态开发 TODO + +- [x] 梳理申请单审批完成、报销单关联、报销单付款、归档查询的现有链路。[CONCEPT: 背景与问题] 证据:已确认 `expense_claim_status_registry.py`、`expense_claim_access_policy.py`、`expense_claim_approval_flow.py`、`useRequests.js` 的当前行为。 +- [x] 调整后端归档查询口径:申请单 `审批完成` 不再视为归档,仅 `申请归档` 才归档。[CONCEPT: 方案设计] 证据:`ExpenseClaimAccessPolicy.build_archived_claim_condition()` 仅将 `APPLICATION_ARCHIVE_STAGE` 视为申请归档。 +- [x] 调整报销单付款完成逻辑:根据关联事件同步推进申请单到 `申请归档`。[CONCEPT: 方案设计] 证据:`mark_claim_paid()` 调用 `_archive_linked_applications_after_reimbursement_paid()`,新增付款同步测试通过。 +- [x] 调整前端申请单进度:增加 `关联单据状态` 与 `已归档` 节点,并显示 `关联中/未关联`。[CONCEPT: 方案设计] 证据:`useRequests.js` 新增申请单进度节点和关联状态计算。 +- [x] 补充前后端回归测试,覆盖未关联、关联中、已归档三类申请单状态。[CONCEPT: 测试方案] 证据:`requestProgressSteps.test.mjs`、`document-center-archived-scope.test.mjs`、`expense-claim-archive.test.mjs`、`test_expense_claim_service.py` 已覆盖。 +- [x] 在容器或前端定向测试中完成验证,并记录命令结果。[CONCEPT: 验收标准] 证据:前端 Node 定向测试、容器内 py_compile、状态/路由/归档/付款同步 pytest、`npm.cmd --prefix web run build` 均通过。 diff --git a/document/development/费用申请审批财务规则/CONCEPT.md b/document/development/费用申请审批财务规则/CONCEPT.md new file mode 100644 index 0000000..0821a39 --- /dev/null +++ b/document/development/费用申请审批财务规则/CONCEPT.md @@ -0,0 +1,131 @@ +# 费用申请审批财务规则概念文档 + +## 功能一句话 + +在财务规则中心新增《公司费用申请审批规则》,统一维护业务招待、办公用品和通用大额费用的事前申请与审批阈值,并让报销风险规则引用该规则执行。 + +## 背景与问题 + +现有系统已经有“业务招待无申请”“办公采购无申请”“大额费用无申请”等风险规则,但制度依据主要以风险规则 JSON 的口径字段存在,财务规则中心缺少一张可被制度管理员查看、编辑和追溯的规则表。 + +用户明确要求: + +- 业务招待费超过 500 元需要申请。 +- 大额办公用品需要申请。 +- 金额超过 2000 元的费用都需要走审批。 +- 这些要求最好形成财务规则,而不是散落在代码或前端提示中。 + +## 目标与非目标 + +目标: + +- 新增一张财务规则资产《公司费用申请审批规则》。 +- 规则资产以 Excel 形式进入 `finance-rules` 规则库,并在规则中心按“财务规则”展示。 +- 风险规则引用统一的 `finance_rule_code`,不再使用零散口径 code。 +- 报销阶段按结构化金额规则判断,而不是只靠关键词命中。 +- 关联有效申请单后不触发“缺少申请”风险。 + +非目标: + +- 本轮不新增数据库字段。 +- 本轮不新增非本体业务字段。 +- 本轮不改造完整审批流节点,只补充申请前置与风险执行依据。 + +## 用户与场景 + +- 报销人:上传或录入业务招待、办公用品、大额费用报销时,系统自动识别是否缺少事前申请。 +- 直属领导和财务审核人:审核单据时能看到风险来自财务规则。 +- 财务制度管理员:能在规则中心看到并维护《公司费用申请审批规则》。 + +## 功能能力 + +### 财务规则表 + +规则表包含以下行: + +- 业务招待费:单次费用金额大于 500 元时,必须先提交费用申请单。 +- 办公用品费:单次或批量采购金额大于 2000 元时,必须先提交办公采购或费用申请单。 +- 通用大额费用:任意费用金额大于 2000 元时,必须进入审批流程。 + +### 风险规则执行 + +- `meal` 与 `entertainment` 都视为业务招待费。 +- `office` 视为办公用品费。 +- `all` 视为通用大额费用。 +- 报销阶段没有关联有效申请单时,超过阈值命中高风险。 +- 已有关联申请单时,不命中缺少申请风险。 + +## 方案设计 + +### 后端 + +- 在 `agent_asset_spreadsheet.py` 中新增费用申请审批规则 code 与文件名常量。 +- 在财务规则同步中新增该资产的 metadata、Excel 工作簿生成和版本快照。 +- 在初始化和补齐逻辑中创建该财务规则资产,确保老库和新库都能看到。 +- 将三条风险规则改为 `composite_rule_v1`,使用金额阈值和申请单存在性执行。 +- 在 `risk_rule_template_executor.py` 中补齐 `application.*` 字段解析,桥接现有 `application_link` / `application_handoff` / `application_detail` 风险上下文。 + +### 前端 + +本轮不新增前端页面。规则中心已有财务规则和 JSON 风险规则展示能力,后端资产同步后前端可直接展示。 + +### 数据与本体 + +本轮只使用现有本体字段: + +- `expense_type` +- `amount` +- `reason` +- `application_claim_id` +- `application_claim_no` +- `application_detail` + +不新增非本体字段。 + +## 算法与公式 + +业务招待费规则: + +$$ +hit = expenseType \in \{meal, entertainment\} \land amount > 500 \land \neg hasApplication +$$ + +办公用品规则: + +$$ +hit = expenseType = office \land amount > 2000 \land \neg hasApplication +$$ + +通用大额规则: + +$$ +hit = amount > 2000 \land \neg hasApplication +$$ + +其中: + +- `amount` 来自 `claim.amount`。 +- `hasApplication` 来自 `application.id`、`application.claim_no` 或等价申请单上下文。 + +## 测试方案 + +- 单元测试:验证 `application.*` 字段能从已有申请关联上下文解析。 +- 规则执行测试:超过 500 元业务招待费且无申请命中风险。 +- 规则执行测试:超过 2000 元办公用品费且无申请命中风险。 +- 规则执行测试:超过 2000 元通用费用且无申请命中风险。 +- 规则执行测试:已关联申请单的超额费用不命中缺少申请风险。 +- 资产测试:规则中心种子数据包含《公司费用申请审批规则》,且 `config_json.tag` 为“财务规则”。 + +## 指标与验收 + +- 财务规则中心能看到新增规则资产。 +- 新增资产 `finance_rule_code` 统一为 `expense.preapproval.policy`。 +- 三条风险规则均引用该财务规则 code。 +- 容器内后端定向测试通过。 +- 不新增非本体业务字段。 + +## 风险与开放问题 + +- “大额办公用品”的金额阈值按用户同句“大额/超过 2000 都需要审批”落为 2000 元。 +- 当前申请单上下文主要存在 `risk_flags_json` 的申请关联 flag 中,本轮先补执行器解析,不新增外键字段。 +- 后续如果要支持不同部门或不同职级阈值,可以在同一张财务规则表中扩展分档行。 diff --git a/document/development/费用申请审批财务规则/TODO.md b/document/development/费用申请审批财务规则/TODO.md new file mode 100644 index 0000000..f67788e --- /dev/null +++ b/document/development/费用申请审批财务规则/TODO.md @@ -0,0 +1,23 @@ +# 费用申请审批财务规则 TODO + +## 调研与契约 + +- [x] 盘点现有财务规则资产、风险规则 JSON 与规则同步链路。[CONCEPT: 背景与问题] 证据:确认现有 `finance-rules` 仅差旅和通信两张核心规则表,前置申请规则当前在 `risk-rules` 中。 +- [x] 明确本轮不新增非本体业务字段。[CONCEPT: 数据与本体] 证据:规则只使用 `expense_type`、`amount`、`reason` 和申请单上下文。 + +## 后端实现 + +- [x] 新增《公司费用申请审批规则》财务规则资产常量与 Excel 工作簿内容。[CONCEPT: 财务规则表] 证据:`COMPANY_PREAPPROVAL_RULE_CODE`、`COMPANY_PREAPPROVAL_RULE_FILENAME` 和 `_ensure_company_preapproval_rule_spreadsheet_seed()` 已实现。 +- [x] 初始化种子和老库补齐逻辑都能创建该财务规则资产。[CONCEPT: 方案设计] 证据:`agent_foundation_asset_seed.py` 和 `agent_foundation_asset_topup.py` 均接入该资产。 +- [x] 将大额费用、业务招待、办公用品三条前置申请风险规则改为结构化金额判断。[CONCEPT: 风险规则执行] 证据:三条 `risk.application.*without_preapproval.json` 已改为 `composite_rule_v1`。 +- [x] 补齐 `application.*` 字段解析,支持从现有关联申请上下文判断是否已有申请。[CONCEPT: 后端] 证据:`risk_rule_template_executor.py` 新增 `_resolve_application_values()`。 + +## 测试与验证 + +- [x] 新增执行器测试:申请单上下文存在时 `application.id` 可解析。[CONCEPT: 测试方案] 证据:`test_application_context_values_are_available_to_composite_rules` 通过。 +- [x] 新增风险规则执行测试:业务招待费超过 500 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 meal。 +- [x] 新增风险规则执行测试:办公用品超过 2000 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 office。 +- [x] 新增风险规则执行测试:通用费用超过 2000 元且无申请命中。[CONCEPT: 测试方案] 证据:`test_preapproval_amount_rules_hit_without_linked_application` 覆盖 software。 +- [x] 新增资产同步测试:财务规则中心包含新增规则资产。[CONCEPT: 指标与验收] 证据:`test_finance_rules_use_risk_rule_scenario_categories` 断言新增财务规则资产和规则文档。 +- [x] Docker `x-financial-main` 容器内定向测试通过。[CONCEPT: 指标与验收] 证据:新增与相邻回归共 15 个后端测试通过。 +- [x] 重启后端并验证运行时健康状态。[CONCEPT: 指标与验收] 证据:`x-financial-main` 已重启并进入 healthy;真实库可查到 `rule.expense.company_preapproval_requirement`。 diff --git a/server/rules/finance-rules/公司费用申请审批规则.xlsx b/server/rules/finance-rules/公司费用申请审批规则.xlsx new file mode 100644 index 0000000..eef68c1 Binary files /dev/null and b/server/rules/finance-rules/公司费用申请审批规则.xlsx differ diff --git a/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json b/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json index 441fb6f..648f529 100644 --- a/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json +++ b/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json @@ -1,17 +1,17 @@ { "schema_version": "2.0", "rule_code": "risk.application.large_expense_without_preapproval", - "name": "大额费用未事前申请", - "description": "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。", + "name": "?????????", + "description": "???????? 2000 ?????????????", "enabled": true, "requires_attachment": false, "risk_dimension": "expense_control_demo", - "risk_category": "申请前置", + "risk_category": "????", "ontology_signal": "application_required", "evaluator": "template_rule", - "template_key": "keyword_match_v1", - "finance_rule_code": "finance.preapproval.policy", - "finance_rule_sheet": "费用申请前置规则", + "template_key": "composite_rule_v1", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "????????", "business_stage": [ "reimbursement" ], @@ -34,68 +34,75 @@ "fields": [ { "key": "claim.amount", - "label": "报销金额", + "label": "????", "type": "number", "source": "claim" }, { "key": "claim.expense_type", - "label": "费用类型", + "label": "????", "type": "enum", "source": "claim" }, { "key": "claim.department_name", - "label": "部门", + "label": "??", "type": "text", "source": "claim" }, { "key": "claim.reason", - "label": "事由", + "label": "??", "type": "text", "source": "claim" }, { "key": "item.item_reason", - "label": "明细说明", + "label": "????", "type": "text", "source": "item" }, { "key": "application.id", - "label": "申请单", + "label": "???ID", + "type": "text", + "source": "application" + }, + { + "key": "application.claim_no", + "label": "????", "type": "text", "source": "application" }, { "key": "application.status", - "label": "申请状态", + "label": "????", "type": "enum", "source": "application" }, { "key": "application.approved_amount", - "label": "申请审批金额", + "label": "??????", "type": "number", "source": "application" }, { "key": "application.expense_type", - "label": "申请费用类型", + "label": "??????", "type": "enum", "source": "application" }, { "key": "application.department_name", - "label": "申请部门", + "label": "????", "type": "text", "source": "application" } ] }, "params": { - "template_key": "keyword_match_v1", + "template_key": "composite_rule_v1", + "semantic_type": "preapproval_required_amount_threshold", "field_keys": [ "claim.amount", "claim.expense_type", @@ -103,31 +110,89 @@ "claim.reason", "item.item_reason", "application.id", + "application.claim_no", "application.status", "application.approved_amount", "application.expense_type", "application.department_name" ], - "search_fields": [ - "claim.reason", - "item.item_reason", - "claim.expense_type" + "conditions": [ + { + "id": "amount_exceeds_preapproval_threshold", + "operator": "numeric_compare", + "left_fields": [ + "claim.amount" + ], + "threshold": 2000, + "compare": "gt" + }, + { + "id": "application_present", + "operator": "exists_any", + "fields": [ + "application.id", + "application.claim_no" + ] + }, + { + "id": "not_specific_preapproval_type", + "operator": "not_contains_any", + "fields": [ + "claim.expense_type" + ], + "keywords": [ + "meal", + "entertainment", + "office", + "????", + "??", + "????", + "??" + ] + } ], - "keywords": [ - "大额费用", - "未申请", - "先申请后报销" - ], - "condition_summary": "金额达到大额阈值且缺少已通过申请单时触发。", - "finance_rule_code": "finance.preapproval.policy", - "finance_rule_sheet": "费用申请前置规则", + "hit_logic": { + "all": [ + "amount_exceeds_preapproval_threshold", + { + "not": "application_present" + }, + "not_specific_preapproval_type" + ] + }, + "formula": "amount > threshold AND NOT hasApplication", + "condition_summary": "?????????????????? 2000 ????????????????", + "message_template": "?????? 2000 ?????????????????????????", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "????????", "business_stage": [ "reimbursement" ], "expense_types": [ "all" ], - "budget_required": true + "budget_required": true, + "threshold_amount": 2000, + "rule_ir": { + "facts": [ + { + "id": "A", + "label": "????", + "fields": [ + "claim.amount" + ] + }, + { + "id": "B", + "label": "???", + "fields": [ + "application.id", + "application.claim_no" + ] + } + ], + "hit_logic": "A > threshold AND NOT EXISTS(B)" + } }, "outcomes": { "pass": { @@ -141,16 +206,16 @@ } }, "metadata": { - "owner": "风控与审计部", + "owner": "??????", "stability": "platform", - "source_ref": "费用管控 Demo 风险规则库", - "created_at": "2026-05-31T00:10:41.805274+00:00", + "source_ref": "??????????", + "created_at": "2026-06-05T00:00:00+08:00", "created_by": "system", "risk_score": 86, "risk_level": "high", - "rule_title": "大额费用未事前申请", - "finance_rule_code": "finance.preapproval.policy", - "finance_rule_sheet": "费用申请前置规则", + "rule_title": "?????????", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "????????", "business_stage": [ "reimbursement" ], diff --git a/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json b/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json index a722db1..0045bdd 100644 --- a/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json +++ b/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json @@ -1,22 +1,23 @@ { "schema_version": "2.0", "rule_code": "risk.application.meal_high_value_without_preapproval", - "name": "大额业务招待未申请", - "description": "业务招待金额或人均金额超过制度阈值但未事前审批。", + "name": "??????????", + "description": "????????? 500 ?????????????", "enabled": true, "requires_attachment": false, "risk_dimension": "expense_control_demo", - "risk_category": "申请前置", + "risk_category": "????", "ontology_signal": "application_required", "evaluator": "template_rule", - "template_key": "keyword_match_v1", - "finance_rule_code": "expense.application.policy", - "finance_rule_sheet": "费用申请前置规则", + "template_key": "composite_rule_v1", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "????????", "business_stage": [ "reimbursement" ], "expense_types": [ - "meal" + "meal", + "entertainment" ], "budget_required": true, "applies_to": { @@ -24,7 +25,8 @@ "expense" ], "expense_types": [ - "meal" + "meal", + "entertainment" ], "business_stages": [ "reimbursement" @@ -34,74 +36,75 @@ "fields": [ { "key": "claim.amount", - "label": "报销金额", + "label": "????", "type": "number", "source": "claim" }, { "key": "claim.expense_type", - "label": "费用类型", + "label": "????", "type": "enum", "source": "claim" }, { "key": "claim.department_name", - "label": "部门", + "label": "??", "type": "text", "source": "claim" }, { "key": "claim.reason", - "label": "事由", + "label": "??", "type": "text", "source": "claim" }, { "key": "item.item_reason", - "label": "明细说明", + "label": "????", "type": "text", "source": "item" }, { "key": "application.id", - "label": "申请单", + "label": "???ID", + "type": "text", + "source": "application" + }, + { + "key": "application.claim_no", + "label": "????", "type": "text", "source": "application" }, { "key": "application.status", - "label": "申请状态", + "label": "????", "type": "enum", "source": "application" }, { "key": "application.approved_amount", - "label": "申请审批金额", + "label": "??????", "type": "number", "source": "application" }, { "key": "application.expense_type", - "label": "申请费用类型", + "label": "??????", "type": "enum", "source": "application" }, { "key": "application.department_name", - "label": "申请部门", + "label": "????", "type": "text", "source": "application" - }, - { - "key": "material.attendee_list_uploaded", - "label": "参与人清单已上传", - "type": "boolean", - "source": "material" } ] }, "params": { - "template_key": "keyword_match_v1", + "template_key": "composite_rule_v1", + "semantic_type": "preapproval_required_amount_threshold", "field_keys": [ "claim.amount", "claim.expense_type", @@ -109,32 +112,73 @@ "claim.reason", "item.item_reason", "application.id", + "application.claim_no", "application.status", "application.approved_amount", "application.expense_type", - "application.department_name", - "material.attendee_list_uploaded" + "application.department_name" ], - "search_fields": [ - "claim.reason", - "item.item_reason", - "claim.expense_type" + "conditions": [ + { + "id": "amount_exceeds_preapproval_threshold", + "operator": "numeric_compare", + "left_fields": [ + "claim.amount" + ], + "threshold": 500, + "compare": "gt" + }, + { + "id": "application_present", + "operator": "exists_any", + "fields": [ + "application.id", + "application.claim_no" + ] + } ], - "keywords": [ - "业务招待", - "人均超标", - "未申请" - ], - "condition_summary": "业务招待金额超过申请阈值且没有通过申请时触发。", - "finance_rule_code": "expense.application.policy", - "finance_rule_sheet": "费用申请前置规则", + "hit_logic": { + "all": [ + "amount_exceeds_preapproval_threshold", + { + "not": "application_present" + } + ] + }, + "formula": "amount > threshold AND NOT hasApplication", + "condition_summary": "??????????? 500 ????????????????", + "message_template": "??????? 500 ?????????????????????????", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "????????", "business_stage": [ "reimbursement" ], "expense_types": [ - "meal" + "meal", + "entertainment" ], - "budget_required": true + "budget_required": true, + "threshold_amount": 500, + "rule_ir": { + "facts": [ + { + "id": "A", + "label": "????", + "fields": [ + "claim.amount" + ] + }, + { + "id": "B", + "label": "???", + "fields": [ + "application.id", + "application.claim_no" + ] + } + ], + "hit_logic": "A > threshold AND NOT EXISTS(B)" + } }, "outcomes": { "pass": { @@ -144,29 +188,30 @@ "fail": { "severity": "high", "action": "manual_review", - "risk_score": 84 + "risk_score": 88 } }, "metadata": { - "owner": "风控与审计部", + "owner": "??????", "stability": "platform", - "source_ref": "费用管控 Demo 风险规则库", - "created_at": "2026-05-31T00:10:41.818641+00:00", + "source_ref": "??????????", + "created_at": "2026-06-05T00:00:00+08:00", "created_by": "system", - "risk_score": 84, + "risk_score": 88, "risk_level": "high", - "rule_title": "大额业务招待未申请", - "finance_rule_code": "expense.application.policy", - "finance_rule_sheet": "费用申请前置规则", + "rule_title": "??????????", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "????????", "business_stage": [ "reimbursement" ], "expense_types": [ - "meal" + "meal", + "entertainment" ], "budget_required": true }, "severity": "high", - "risk_score": 84, + "risk_score": 88, "risk_level": "high" } diff --git a/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json b/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json index a1ffad5..fd96f97 100644 --- a/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json +++ b/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json @@ -1,17 +1,17 @@ { "schema_version": "2.0", "rule_code": "risk.application.office_bulk_without_purchase", - "name": "办公用品大额采购未申请", - "description": "批量办公用品或设备采购达到阈值但未走采购申请。", + "name": "???????????", + "description": "???????????????? 2000 ???????????", "enabled": true, "requires_attachment": false, "risk_dimension": "expense_control_demo", - "risk_category": "申请前置", + "risk_category": "????", "ontology_signal": "application_required", "evaluator": "template_rule", - "template_key": "keyword_match_v1", - "finance_rule_code": "expense.application.policy", - "finance_rule_sheet": "费用申请前置规则", + "template_key": "composite_rule_v1", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "????????", "business_stage": [ "reimbursement" ], @@ -34,68 +34,75 @@ "fields": [ { "key": "claim.amount", - "label": "报销金额", + "label": "????", "type": "number", "source": "claim" }, { "key": "claim.expense_type", - "label": "费用类型", + "label": "????", "type": "enum", "source": "claim" }, { "key": "claim.department_name", - "label": "部门", + "label": "??", "type": "text", "source": "claim" }, { "key": "claim.reason", - "label": "事由", + "label": "??", "type": "text", "source": "claim" }, { "key": "item.item_reason", - "label": "明细说明", + "label": "????", "type": "text", "source": "item" }, { "key": "application.id", - "label": "申请单", + "label": "???ID", + "type": "text", + "source": "application" + }, + { + "key": "application.claim_no", + "label": "????", "type": "text", "source": "application" }, { "key": "application.status", - "label": "申请状态", + "label": "????", "type": "enum", "source": "application" }, { "key": "application.approved_amount", - "label": "申请审批金额", + "label": "??????", "type": "number", "source": "application" }, { "key": "application.expense_type", - "label": "申请费用类型", + "label": "??????", "type": "enum", "source": "application" }, { "key": "application.department_name", - "label": "申请部门", + "label": "????", "type": "text", "source": "application" } ] }, "params": { - "template_key": "keyword_match_v1", + "template_key": "composite_rule_v1", + "semantic_type": "preapproval_required_amount_threshold", "field_keys": [ "claim.amount", "claim.expense_type", @@ -103,31 +110,72 @@ "claim.reason", "item.item_reason", "application.id", + "application.claim_no", "application.status", "application.approved_amount", "application.expense_type", "application.department_name" ], - "search_fields": [ - "claim.reason", - "item.item_reason", - "claim.expense_type" + "conditions": [ + { + "id": "amount_exceeds_preapproval_threshold", + "operator": "numeric_compare", + "left_fields": [ + "claim.amount" + ], + "threshold": 2000, + "compare": "gt" + }, + { + "id": "application_present", + "operator": "exists_any", + "fields": [ + "application.id", + "application.claim_no" + ] + } ], - "keywords": [ - "办公采购", - "大额办公用品", - "采购申请" - ], - "condition_summary": "办公用品单次金额达到采购阈值且缺少采购申请时触发。", - "finance_rule_code": "expense.application.policy", - "finance_rule_sheet": "费用申请前置规则", + "hit_logic": { + "all": [ + "amount_exceeds_preapproval_threshold", + { + "not": "application_present" + } + ] + }, + "formula": "amount > threshold AND NOT hasApplication", + "condition_summary": "???????????????? 2000 ????????????????", + "message_template": "??????? 2000 ??????????????????????????????", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "????????", "business_stage": [ "reimbursement" ], "expense_types": [ "office" ], - "budget_required": true + "budget_required": true, + "threshold_amount": 2000, + "rule_ir": { + "facts": [ + { + "id": "A", + "label": "????", + "fields": [ + "claim.amount" + ] + }, + { + "id": "B", + "label": "???", + "fields": [ + "application.id", + "application.claim_no" + ] + } + ], + "hit_logic": "A > threshold AND NOT EXISTS(B)" + } }, "outcomes": { "pass": { @@ -135,22 +183,22 @@ "action": "continue" }, "fail": { - "severity": "medium", + "severity": "high", "action": "manual_review", - "risk_score": 78 + "risk_score": 84 } }, "metadata": { - "owner": "风控与审计部", + "owner": "??????", "stability": "platform", - "source_ref": "费用管控 Demo 风险规则库", - "created_at": "2026-05-31T00:10:41.811910+00:00", + "source_ref": "??????????", + "created_at": "2026-06-05T00:00:00+08:00", "created_by": "system", - "risk_score": 78, - "risk_level": "medium", - "rule_title": "办公用品大额采购未申请", - "finance_rule_code": "expense.application.policy", - "finance_rule_sheet": "费用申请前置规则", + "risk_score": 84, + "risk_level": "high", + "rule_title": "???????????", + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "????????", "business_stage": [ "reimbursement" ], @@ -159,7 +207,7 @@ ], "budget_required": true }, - "severity": "medium", - "risk_score": 78, - "risk_level": "medium" + "severity": "high", + "risk_score": 84, + "risk_level": "high" } diff --git a/server/scripts/bootstrap_paddleocr_mobile.sh b/server/scripts/bootstrap_paddleocr_mobile.sh index c5b5165..b4bab69 100644 --- a/server/scripts/bootstrap_paddleocr_mobile.sh +++ b/server/scripts/bootstrap_paddleocr_mobile.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312" PYTHON_BIN="${PYTHON_BIN:-python3.12}" +PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.3.1}" +PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}" if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then echo "python3.12 不存在,请先安装 Python 3.12。" >&2 @@ -15,6 +17,6 @@ apt-get install -y libgl1 libglib2.0-0 "${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}" "${OCR_VENV_DIR}/bin/pip" install --upgrade pip -"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==3.2.0" "paddleocr==3.5.0" +"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==${PADDLEPADDLE_VERSION}" "paddleocr==${PADDLEOCR_VERSION}" -echo "PaddleOCR mobile runtime 已安装到 ${OCR_VENV_DIR}" +echo "PaddleOCR mobile runtime ${PADDLEOCR_VERSION} / PaddlePaddle ${PADDLEPADDLE_VERSION} 已安装到 ${OCR_VENV_DIR}" diff --git a/server/scripts/paddle_ocr_worker.py b/server/scripts/paddle_ocr_worker.py index 217830c..0e045ad 100644 --- a/server/scripts/paddle_ocr_worker.py +++ b/server/scripts/paddle_ocr_worker.py @@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--lang", default="ch") parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det") parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec") + parser.add_argument("--enable-mkldnn", action="store_true") return parser.parse_args() @@ -106,6 +107,8 @@ def main() -> int: use_doc_unwarping=False, use_textline_orientation=False, lang=args.lang, + # PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference. + enable_mkldnn=args.enable_mkldnn, ) documents = [] diff --git a/server/server_start.sh b/server/server_start.sh index 6696113..a932ecc 100755 --- a/server/server_start.sh +++ b/server/server_start.sh @@ -188,6 +188,8 @@ if is_container; then fi SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}" +SERVER_WORKERS="${SERVER_WORKERS:-${WEB_CONCURRENCY:-1}}" +export SERVER_WORKERS needs_windows_python() { is_msys || is_wsl @@ -355,6 +357,12 @@ start_server() { exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" fi + if [ "$SERVER_WORKERS" -gt 1 ] 2>/dev/null; then + BACKGROUND_SCHEDULERS_ENABLED="${BACKGROUND_SCHEDULERS_ENABLED:-false}" + export BACKGROUND_SCHEDULERS_ENABLED + exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" --workers "$SERVER_WORKERS" + fi + exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" } diff --git a/server/src/app/api/v1/endpoints/ocr.py b/server/src/app/api/v1/endpoints/ocr.py index ae2b327..6e69b1c 100644 --- a/server/src/app/api/v1/endpoints/ocr.py +++ b/server/src/app/api/v1/endpoints/ocr.py @@ -4,6 +4,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from sqlalchemy.orm import Session +from starlette.concurrency import run_in_threadpool from app.api.deps import CurrentUserContext, get_current_user, get_db from app.schemas.common import ErrorResponse @@ -50,7 +51,7 @@ async def recognize_ocr_documents( upload.content_type, ) ) - result = OcrService(db).recognize_files(payload) + result = await run_in_threadpool(lambda: OcrService(db).recognize_files(payload)) return ReceiptFolderService().persist_ocr_batch( files=payload, result=result, diff --git a/server/src/app/api/v1/endpoints/steward.py b/server/src/app/api/v1/endpoints/steward.py index a0e5839..6d283d1 100644 --- a/server/src/app/api/v1/endpoints/steward.py +++ b/server/src/app/api/v1/endpoints/steward.py @@ -11,10 +11,20 @@ from sqlalchemy.orm import Session from app.api.deps import get_db from app.schemas.common import ErrorResponse -from app.schemas.steward import StewardPlanRequest, StewardPlanResponse, StewardThinkingEvent +from app.schemas.steward import ( + StewardPlanRequest, + StewardPlanResponse, + StewardRuntimeDecisionRequest, + StewardRuntimeDecisionResponse, + StewardSlotDecisionRequest, + StewardSlotDecisionResponse, + StewardThinkingEvent, +) from app.services.runtime_chat import RuntimeChatService from app.services.steward_intent_agent import StewardIntentAgent from app.services.steward_planner import StewardPlannerService +from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent +from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent router = APIRouter(prefix="/steward") DbSession = Annotated[Session, Depends(get_db)] @@ -39,6 +49,32 @@ def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPl raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc +@router.post( + "/slot-decisions", + response_model=StewardSlotDecisionResponse, + summary="判断小财管家当前任务字段缺口", + description="结合当前任务、本体字段和用户上下文,使用 function calling 判断下一步应先追问用户还是展示核对结果。", +) +def create_steward_slot_decision( + payload: StewardSlotDecisionRequest, + db: DbSession, +) -> StewardSlotDecisionResponse: + return StewardSlotDecisionAgent(RuntimeChatService(db)).decide(payload) + + +@router.post( + "/runtime-decisions", + response_model=StewardRuntimeDecisionResponse, + summary="判断小财管家运行时下一步动作", + description="结合任务队列、当前结构化结果和用户输入,使用 function calling 判断应提交当前单据、继续下一任务、补字段或重新规划。", +) +def create_steward_runtime_decision( + payload: StewardRuntimeDecisionRequest, + db: DbSession, +) -> StewardRuntimeDecisionResponse: + return StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(payload) + + @router.post( "/plans/stream", summary="流式生成小财管家任务计划", @@ -60,8 +96,8 @@ async def _iter_steward_plan_events( StewardThinkingEvent( event_id="intent_agent_stream_start", stage="stream_start", - title="意图识别智能体接管", - content="已收到任务描述,正在调用小财管家意图识别智能体拆解申请、报销和附件线索。", + title="读取用户输入", + content="我先判断这句话里是否同时包含申请、报销或附件归集事项,再决定处理顺序。", status="running", ).model_dump(mode="json"), ) @@ -75,7 +111,7 @@ async def _iter_steward_plan_events( for event in plan.thinking_events: yield _encode_stream_event("thinking", event.model_dump(mode="json")) - await asyncio.sleep(0.18) + await asyncio.sleep(0.6) yield _encode_stream_event("plan", plan.model_dump(mode="json")) diff --git a/server/src/app/core/config.py b/server/src/app/core/config.py index 581d669..6f5a08c 100644 --- a/server/src/app/core/config.py +++ b/server/src/app/core/config.py @@ -38,10 +38,16 @@ class Settings(BaseSettings): admin_email: str = Field(default="", alias="ADMIN_EMAIL") web_host: str = Field(default="0.0.0.0", alias="WEB_HOST") - web_port: int = Field(default=5173, alias="WEB_PORT") - app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST") - app_port: int = Field(default=8000, alias="SERVER_PORT") - api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX") + web_port: int = Field(default=5173, alias="WEB_PORT") + app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST") + app_port: int = Field(default=8000, alias="SERVER_PORT") + server_workers: int = Field(default=1, alias="SERVER_WORKERS") + web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY") + background_schedulers_enabled: bool = Field( + default=True, + alias="BACKGROUND_SCHEDULERS_ENABLED", + ) + api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX") postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST") postgres_port: int = Field(default=5432, alias="POSTGRES_PORT") @@ -49,8 +55,11 @@ class Settings(BaseSettings): postgres_user: str = Field(default="postgres", alias="POSTGRES_USER") postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD") - database_url: str | None = Field(default=None, alias="DATABASE_URL") - sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO") + database_url: str | None = Field(default=None, alias="DATABASE_URL") + sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO") + sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE") + sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW") + sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT") redis_url: str | None = Field(default=None, alias="REDIS_URL") cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS") @@ -70,6 +79,7 @@ class Settings(BaseSettings): ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN") ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS") ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB") + ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS") ocr_language: str = Field(default="ch", alias="OCR_LANGUAGE") seed_demo_financial_records: bool = Field( default=False, diff --git a/server/src/app/db/session.py b/server/src/app/db/session.py index ffed537..9370874 100644 --- a/server/src/app/db/session.py +++ b/server/src/app/db/session.py @@ -18,11 +18,20 @@ def configure_session_factory() -> None: if _engine is not None: _engine.dispose() - _engine = create_engine( - settings.resolved_database_url, - echo=settings.sqlalchemy_echo, - pool_pre_ping=True, - ) + engine_kwargs = { + "echo": settings.sqlalchemy_echo, + "pool_pre_ping": True, + } + if not settings.resolved_database_url.startswith("sqlite"): + engine_kwargs.update( + { + "pool_size": max(1, int(settings.sqlalchemy_pool_size or 10)), + "max_overflow": max(0, int(settings.sqlalchemy_max_overflow or 20)), + "pool_timeout": max(1, int(settings.sqlalchemy_pool_timeout or 30)), + } + ) + + _engine = create_engine(settings.resolved_database_url, **engine_kwargs) _session_factory = sessionmaker(bind=_engine, autoflush=False, autocommit=False) diff --git a/server/src/app/main.py b/server/src/app/main.py index d26cca7..ff09717 100644 --- a/server/src/app/main.py +++ b/server/src/app/main.py @@ -25,6 +25,23 @@ from app.services.knowledge_rag import shutdown_knowledge_rag_runtime from app.services.knowledge_scheduler import knowledge_index_scheduler +def _effective_server_workers(settings: object) -> int: + server_workers = getattr(settings, "server_workers", None) + web_concurrency = getattr(settings, "web_concurrency", None) + workers = web_concurrency if int(server_workers or 1) <= 1 and web_concurrency else server_workers + try: + return max(1, int(workers or 1)) + except (TypeError, ValueError): + return 1 + + +def _should_start_background_schedulers(settings: object) -> bool: + if not bool(getattr(settings, "background_schedulers_enabled", True)): + return False + + return _effective_server_workers(settings) <= 1 + + @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings = get_settings() @@ -34,11 +51,19 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: prepare_agent_foundation() prepare_knowledge_library() sync_repository_hermes_skills() - knowledge_index_scheduler.start() - finance_dashboard_scheduler.start() - employee_profile_scheduler.start() - digital_employee_reminder_scheduler.start() - finance_report_scheduler.start() + schedulers_started = _should_start_background_schedulers(settings) + if schedulers_started: + knowledge_index_scheduler.start() + finance_dashboard_scheduler.start() + employee_profile_scheduler.start() + digital_employee_reminder_scheduler.start() + finance_report_scheduler.start() + else: + logger.warning( + "Background schedulers skipped - workers=%s enabled=%s", + _effective_server_workers(settings), + settings.background_schedulers_enabled, + ) logger.info( "Server ready - host=%s port=%s prefix=%s", settings.app_host, @@ -46,11 +71,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings.api_v1_prefix, ) yield - finance_report_scheduler.shutdown() - digital_employee_reminder_scheduler.shutdown() - employee_profile_scheduler.shutdown() - finance_dashboard_scheduler.shutdown() - knowledge_index_scheduler.shutdown() + if schedulers_started: + finance_report_scheduler.shutdown() + digital_employee_reminder_scheduler.shutdown() + employee_profile_scheduler.shutdown() + finance_dashboard_scheduler.shutdown() + knowledge_index_scheduler.shutdown() knowledge_index_task_manager.shutdown() shutdown_knowledge_rag_runtime() diff --git a/server/src/app/repositories/agent_run.py b/server/src/app/repositories/agent_run.py index b1afdc7..509e40c 100644 --- a/server/src/app/repositories/agent_run.py +++ b/server/src/app/repositories/agent_run.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from sqlalchemy import select from sqlalchemy.orm import Session @@ -28,6 +30,74 @@ class AgentRunRepository: stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit) return list(self.db.scalars(stmt).all()) + def list_light( + self, + *, + agent: str | None = None, + status: str | None = None, + source: str | None = None, + limit: int = 20, + ) -> list[dict[str, Any]]: + stmt = select( + AgentRun.id.label("id"), + AgentRun.run_id.label("run_id"), + AgentRun.agent.label("agent"), + AgentRun.source.label("source"), + AgentRun.user_id.label("user_id"), + AgentRun.task_id.label("task_id"), + AgentRun.permission_level.label("permission_level"), + AgentRun.status.label("status"), + AgentRun.result_summary.label("result_summary"), + AgentRun.error_message.label("error_message"), + AgentRun.started_at.label("started_at"), + AgentRun.finished_at.label("finished_at"), + AgentRun.route_json["job_type"].as_string().label("route_job_type"), + AgentRun.route_json["task_type"].as_string().label("route_task_type"), + AgentRun.route_json["task_code"].as_string().label("route_task_code"), + AgentRun.route_json["task_name"].as_string().label("route_task_name"), + AgentRun.route_json["task_title"].as_string().label("route_task_title"), + AgentRun.route_json["asset_name"].as_string().label("route_asset_name"), + AgentRun.route_json["selected_agent"].as_string().label("route_selected_agent"), + AgentRun.route_json["phase"].as_string().label("route_phase"), + AgentRun.route_json["stage"].as_string().label("route_stage"), + AgentRun.route_json["report_type"].as_string().label("route_report_type"), + AgentRun.route_json["snapshot_key"].as_string().label("route_snapshot_key"), + AgentRun.route_json["folder"].as_string().label("route_folder"), + AgentRun.route_json["heartbeat_at"].as_string().label("route_heartbeat_at"), + AgentRun.route_json["progress"].label("route_progress"), + AgentRun.ontology_json["scenario"].as_string().label("ontology_scenario"), + AgentRun.ontology_json["intent"].as_string().label("ontology_intent"), + AgentRun.ontology_json["parse_strategy"].as_string().label("ontology_parse_strategy"), + ) + if agent: + stmt = stmt.where(AgentRun.agent == agent) + if status: + stmt = stmt.where(AgentRun.status == status) + if source: + stmt = stmt.where(AgentRun.source == source) + stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit) + return [dict(item) for item in self.db.execute(stmt).mappings().all()] + + def list_light_tool_calls(self, run_ids: list[str]) -> list[dict[str, Any]]: + if not run_ids: + return [] + + stmt = ( + select( + AgentToolCall.id.label("id"), + AgentToolCall.run_id.label("run_id"), + AgentToolCall.tool_type.label("tool_type"), + AgentToolCall.tool_name.label("tool_name"), + AgentToolCall.status.label("status"), + AgentToolCall.duration_ms.label("duration_ms"), + AgentToolCall.error_message.label("error_message"), + AgentToolCall.created_at.label("created_at"), + ) + .where(AgentToolCall.run_id.in_(run_ids)) + .order_by(AgentToolCall.created_at.asc()) + ) + return [dict(item) for item in self.db.execute(stmt).mappings().all()] + def get_by_run_id(self, run_id: str) -> AgentRun | None: stmt = select(AgentRun).where(AgentRun.run_id == run_id) return self.db.scalar(stmt) diff --git a/server/src/app/schemas/notification_state.py b/server/src/app/schemas/notification_state.py index 41a01c2..73867af 100644 --- a/server/src/app/schemas/notification_state.py +++ b/server/src/app/schemas/notification_state.py @@ -28,7 +28,7 @@ class NotificationStatePatch(BaseModel): class NotificationStateBatchPatch(BaseModel): - states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100) + states: list[NotificationStatePatch] = Field(default_factory=list, max_length=500) class NotificationStateRead(BaseModel): diff --git a/server/src/app/schemas/steward.py b/server/src/app/schemas/steward.py index f2ee810..1efb83c 100644 --- a/server/src/app/schemas/steward.py +++ b/server/src/app/schemas/steward.py @@ -8,6 +8,18 @@ from pydantic import BaseModel, Field StewardTaskType = Literal["expense_application", "reimbursement"] StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"] StewardPlanningSource = Literal["llm_function_call", "rule_fallback"] +StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"] +StewardSlotNextAction = Literal["ask_user", "render_preview"] +StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"] +StewardRuntimeNextAction = Literal[ + "plan_new_tasks", + "submit_current_application", + "continue_next_task", + "fill_current_slot", + "ask_user", + "cancel_current_action", + "no_op", +] StewardTaskStatus = Literal[ "planned", "needs_confirmation", @@ -88,3 +100,50 @@ class StewardPlanResponse(BaseModel): attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。") confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。") model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。") + + +class StewardSlotOption(BaseModel): + label: str = Field(description="用户可见选项文案。") + value: str = Field(description="写回本体字段的选项值。") + field_key: str = Field(description="对应 canonical ontology field。") + description: str = Field(default="", description="选项说明。") + + +class StewardSlotDecisionRequest(BaseModel): + task_type: StewardTaskType = Field(description="当前小财管家正在推进的任务类型。") + user_message: str = Field(description="用户原始话术或小财管家携带的任务上下文。") + ontology_fields: dict[str, str] = Field(default_factory=dict, description="当前已抽取的 canonical ontology 字段。") + missing_fields: list[str] = Field(default_factory=list, description="上游意图识别给出的 canonical 缺失字段。") + task_context: dict[str, Any] = Field(default_factory=dict, description="当前任务、附件、申请预览等上下文。") + + +class StewardSlotDecisionResponse(BaseModel): + decision_source: StewardSlotDecisionSource = Field(default="rule_fallback", description="字段决策来源。") + next_action: StewardSlotNextAction = Field(description="下一步应追问用户还是展示核对结果。") + required_fields: list[str] = Field(default_factory=list, description="模型认为当前业务需要的 canonical 字段。") + missing_fields: list[str] = Field(default_factory=list, description="当前仍缺失的 canonical 字段。") + question: str = Field(default="", description="需要追问时展示给用户的问题。") + options: list[StewardSlotOption] = Field(default_factory=list, description="可直接选择的补充选项。") + rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。") + model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。") + + +class StewardRuntimeDecisionRequest(BaseModel): + user_message: str = Field(description="用户当前输入。") + session_type: str = Field(default="steward", description="当前前端会话类型。") + runtime_state: dict[str, Any] = Field(default_factory=dict, description="小财管家运行时上下文。") + context_json: dict[str, Any] = Field(default_factory=dict, description="调用方补充上下文。") + + +class StewardRuntimeDecisionResponse(BaseModel): + decision_source: StewardRuntimeDecisionSource = Field(default="rule_fallback", description="运行时决策来源。") + next_action: StewardRuntimeNextAction = Field(description="小财管家下一步动作。") + target_task_id: str = Field(default="", description="关联的小财管家任务 ID。") + target_message_id: str = Field(default="", description="关联的前端消息 ID。") + field_key: str = Field(default="", description="补字段时对应 canonical ontology field。") + field_value: str = Field(default="", description="补字段时用户提供的字段值。") + confirmation_required: bool = Field(default=False, description="执行该动作前是否仍需要用户二次确认。") + question: str = Field(default="", description="需要追问用户时展示的问题。") + response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。") + rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。") + model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。") diff --git a/server/src/app/services/agent_asset_spreadsheet.py b/server/src/app/services/agent_asset_spreadsheet.py index fbbaa60..be8fc2b 100644 --- a/server/src/app/services/agent_asset_spreadsheet.py +++ b/server/src/app/services/agent_asset_spreadsheet.py @@ -24,6 +24,8 @@ COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimburs COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx" COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement" COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx" +COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement" +COMPANY_PREAPPROVAL_RULE_FILENAME = "公司费用申请审批规则.xlsx" FINANCE_RULES_LIBRARY = "finance-rules" RISK_RULES_LIBRARY = "risk-rules" RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY} diff --git a/server/src/app/services/agent_foundation_asset_seed.py b/server/src/app/services/agent_foundation_asset_seed.py index c67fcc7..76b0304 100644 --- a/server/src/app/services/agent_foundation_asset_seed.py +++ b/server/src/app/services/agent_foundation_asset_seed.py @@ -17,6 +17,7 @@ from app.core.logging import get_logger from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + COMPANY_PREAPPROVAL_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE, FINANCE_RULES_LIBRARY, AgentAssetSpreadsheetManager, @@ -26,6 +27,8 @@ from app.services.agent_foundation_constants import ( ATTACHMENT_RULE_RUNTIME_CONFIG, COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, COMPANY_COMMUNICATION_RULE_VERSION, + COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON, + COMPANY_PREAPPROVAL_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_VERSION, DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, @@ -301,6 +304,35 @@ class AgentFoundationAssetSeedMixin: "rule_template_label": "通信费报销 Excel 模板", }, ) + company_preapproval_rule = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code=COMPANY_PREAPPROVAL_RULE_CODE, + name="公司费用申请审批规则", + description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON), + owner="财务制度管理组", + reviewer="顾承宣", + status=AgentAssetStatus.ACTIVE.value, + current_version=COMPANY_PREAPPROVAL_RULE_VERSION, + published_version=COMPANY_PREAPPROVAL_RULE_VERSION, + working_version=COMPANY_PREAPPROVAL_RULE_VERSION, + config_json={ + "severity": "high", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], + "ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", + "expense_types": ["meal", "entertainment", "office", "all"], + "business_stage": ["expense_application", "reimbursement"], + "budget_required": True, + "rule_template_label": "费用申请审批 Excel 模板", + }, + ) skill_expense_asset = AgentAsset( asset_type=AgentAssetType.SKILL.value, code="skill.expense.summary_lookup", @@ -468,6 +500,7 @@ class AgentFoundationAssetSeedMixin: *platform_risk_assets, company_travel_rule, company_communication_rule, + company_preapproval_rule, skill_expense_asset, skill_ar_asset, invoice_mcp_asset, @@ -495,6 +528,11 @@ class AgentFoundationAssetSeedMixin: version=COMPANY_COMMUNICATION_RULE_VERSION, actor_name="系统初始化", ) + company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed( + company_preapproval_rule, + version=COMPANY_PREAPPROVAL_RULE_VERSION, + actor_name="系统初始化", + ) self._hide_deprecated_finance_rule_assets() @@ -581,6 +619,18 @@ class AgentFoundationAssetSeedMixin: change_note="初始化通信费报销 Excel 规则表。", created_by="系统初始化", ), + AgentAssetVersion( + asset=company_preapproval_rule, + version=COMPANY_PREAPPROVAL_RULE_VERSION, + content=AgentAssetSpreadsheetManager.build_version_markdown( + rule_name=company_preapproval_rule.name, + version=COMPANY_PREAPPROVAL_RULE_VERSION, + metadata=company_preapproval_rule_meta, + ), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化费用申请审批 Excel 规则表。", + created_by="系统初始化", + ), AgentAssetVersion( asset=skill_expense_asset, version="v1.0.0", diff --git a/server/src/app/services/agent_foundation_asset_topup.py b/server/src/app/services/agent_foundation_asset_topup.py index 7f03816..15f1a10 100644 --- a/server/src/app/services/agent_foundation_asset_topup.py +++ b/server/src/app/services/agent_foundation_asset_topup.py @@ -16,6 +16,7 @@ from app.models.agent_asset import AgentAsset from app.models.agent_run import AgentRun from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + COMPANY_PREAPPROVAL_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE, FINANCE_RULES_LIBRARY, AgentAssetSpreadsheetManager, @@ -25,6 +26,8 @@ from app.services.agent_foundation_constants import ( ATTACHMENT_RULE_RUNTIME_CONFIG, COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, COMPANY_COMMUNICATION_RULE_VERSION, + COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON, + COMPANY_PREAPPROVAL_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_VERSION, DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, @@ -115,6 +118,10 @@ class AgentFoundationAssetTopUpMixin: select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE) ) + company_preapproval_rule = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == COMPANY_PREAPPROVAL_RULE_CODE) + ) + if ATTACHMENT_RULE_ASSET_CODE not in existing_codes: attachment_rule = self._create_seed_asset( @@ -392,6 +399,36 @@ class AgentFoundationAssetTopUpMixin: }, ) + if COMPANY_PREAPPROVAL_RULE_CODE not in existing_codes: + + company_preapproval_rule = self._create_seed_asset( + asset_type=AgentAssetType.RULE.value, + code=COMPANY_PREAPPROVAL_RULE_CODE, + name="公司费用申请审批规则", + description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON), + owner="财务制度管理组", + reviewer="顾承宣", + status=AgentAssetStatus.ACTIVE.value, + current_version=COMPANY_PREAPPROVAL_RULE_VERSION, + config_json={ + "severity": "high", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], + "ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", + "expense_types": ["meal", "entertainment", "office", "all"], + "business_stage": ["expense_application", "reimbursement"], + "budget_required": True, + "rule_template_label": "费用申请审批 Excel 模板", + }, + ) + if company_travel_rule is not None: company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON) if not str(company_travel_rule.current_version or "").strip(): @@ -536,6 +573,77 @@ class AgentFoundationAssetTopUpMixin: reviewed_at=datetime.now(UTC), ) + if company_preapproval_rule is not None: + company_preapproval_rule.scenario_json = list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON) + if not str(company_preapproval_rule.current_version or "").strip(): + company_preapproval_rule.current_version = COMPANY_PREAPPROVAL_RULE_VERSION + if not str(company_preapproval_rule.working_version or "").strip(): + company_preapproval_rule.working_version = company_preapproval_rule.current_version + if not str(company_preapproval_rule.published_version or "").strip(): + company_preapproval_rule.published_version = company_preapproval_rule.current_version + if not str(company_preapproval_rule.status or "").strip(): + company_preapproval_rule.status = AgentAssetStatus.ACTIVE.value + + company_preapproval_rule.description = ( + "通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。" + ) + company_preapproval_rule.config_json = { + **(company_preapproval_rule.config_json or {}), + "severity": "high", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], + "ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], + "finance_rule_code": "expense.preapproval.policy", + "finance_rule_sheet": "费用申请审批规则", + "expense_types": ["meal", "entertainment", "office", "all"], + "business_stage": ["expense_application", "reimbursement"], + "budget_required": True, + "rule_template_label": "费用申请审批 Excel 模板", + } + company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed( + company_preapproval_rule, + version=str( + company_preapproval_rule.current_version + or COMPANY_PREAPPROVAL_RULE_VERSION + ), + actor_name="系统初始化", + ) + + self._ensure_asset_version( + company_preapproval_rule, + version=str( + company_preapproval_rule.current_version + or COMPANY_PREAPPROVAL_RULE_VERSION + ), + content=AgentAssetSpreadsheetManager.build_version_markdown( + rule_name=company_preapproval_rule.name, + version=str( + company_preapproval_rule.current_version + or COMPANY_PREAPPROVAL_RULE_VERSION + ), + metadata=company_preapproval_rule_meta, + ), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化费用申请审批 Excel 规则表。", + created_by="系统初始化", + ) + + if ( + str(company_preapproval_rule.current_version or "").strip() + == COMPANY_PREAPPROVAL_RULE_VERSION + ): + self._ensure_asset_review( + company_preapproval_rule, + version=COMPANY_PREAPPROVAL_RULE_VERSION, + reviewer="顾承宣", + review_status=AgentReviewStatus.APPROVED.value, + review_note="首版费用申请审批规则表已确认,可作为财务规则使用。", + reviewed_at=datetime.now(UTC), + ) + self._hide_deprecated_finance_rule_assets() if "skill.ar.aging_summary" not in existing_codes: diff --git a/server/src/app/services/agent_foundation_constants.py b/server/src/app/services/agent_foundation_constants.py index c308da9..2303010 100644 --- a/server/src/app/services/agent_foundation_constants.py +++ b/server/src/app/services/agent_foundation_constants.py @@ -82,10 +82,14 @@ COMPANY_TRAVEL_RULE_VERSION = "v1.0.0" COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0" +COMPANY_PREAPPROVAL_RULE_VERSION = "v1.0.0" + COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅费",) COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",) +COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON = ("费用申请",) + DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估") DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize" diff --git a/server/src/app/services/agent_foundation_spreadsheets.py b/server/src/app/services/agent_foundation_spreadsheets.py index 16d43a3..63c193f 100644 --- a/server/src/app/services/agent_foundation_spreadsheets.py +++ b/server/src/app/services/agent_foundation_spreadsheets.py @@ -12,6 +12,8 @@ from app.models.agent_asset import AgentAsset from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + COMPANY_PREAPPROVAL_RULE_CODE, + COMPANY_PREAPPROVAL_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, @@ -19,6 +21,7 @@ from app.services.agent_asset_spreadsheet import ( ) from app.services.agent_foundation_constants import ( COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, + COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_SCENARIO_JSON, ) from app.services.finance_rule_catalog import ( @@ -54,6 +57,14 @@ class AgentFoundationSpreadsheetMixin: expense_types=["communication"], ) ) + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_PREAPPROVAL_RULE_CODE, + scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0], + finance_rule_sheet="费用申请审批规则", + expense_types=["meal", "entertainment", "office", "all"], + ) + ) return synced_count def _ensure_core_finance_rule_asset( @@ -92,14 +103,19 @@ class AgentFoundationSpreadsheetMixin: asset.status = AgentAssetStatus.DISABLED.value asset.scenario_json = ["已废弃"] replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code) - deprecated_reason = ( - "交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。" - if replacement - else ( + if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE: + deprecated_reason = ( + "交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。" + ) + elif replacement == COMPANY_PREAPPROVAL_RULE_CODE: + deprecated_reason = ( + "申请审批阈值已并入公司费用申请审批规则,不再作为独立财务规则展示。" + ) + else: + deprecated_reason = ( "该费用类型没有独立职务金额分档,额度控制转入预算中心," "不再作为独立财务规则表展示。" ) - ) asset.config_json = { **(asset.config_json or {}), "enabled": False, @@ -258,6 +274,93 @@ class AgentFoundationSpreadsheetMixin: ) + def _ensure_company_preapproval_rule_spreadsheet_seed( + + self, + + asset: AgentAsset, + + *, + + version: str, + + actor_name: str, + + ): + + return self._ensure_finance_rule_spreadsheet_seed( + + asset, + + version=version, + + actor_name=actor_name, + + file_name=COMPANY_PREAPPROVAL_RULE_FILENAME, + + fallback_sheet_name="费用申请审批规则", + + workbook_sheets=[ + ( + "费用申请审批规则", + [ + [ + "费用类型代码", + "费用类型", + "触发条件", + "阈值金额", + "前置要求", + "审批要求", + "风险动作", + "备注", + ], + [ + "meal/entertainment", + "业务招待费", + "单次费用金额大于 500 元", + 500, + "必须先提交费用申请单,并说明客户、参与人和招待事由", + "申请单需按审批链完成审批后方可报销", + "报销阶段未关联已通过申请单时标记高风险", + "适配 meal 与 entertainment 两个本体费用类型", + ], + [ + "office", + "办公用品费", + "单次或批量采购金额大于 2000 元", + 2000, + "必须先提交办公采购或费用申请单", + "申请单需经直属领导审批;如触发预算管控则继续预算复核", + "报销阶段未关联已通过申请单时标记高风险", + "覆盖办公用品、办公耗材、低值易耗品等场景", + ], + [ + "all", + "通用大额费用", + "任意费用金额大于 2000 元", + 2000, + "必须进入费用申请和审批流程", + "至少完成直属领导审批;按预算和财务规则继续流转", + "报销阶段未关联已通过申请单时标记高风险", + "差旅、通信等已有专项规则时可同时适用专项规则", + ], + ], + ), + ( + "字段说明", + [ + ["字段", "说明"], + ["费用类型代码", "使用系统本体费用类型,不新增非本体字段"], + ["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"], + ["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"], + ["审批要求", "说明申请单进入审批链后的最低审批要求"], + ["风险动作", "说明报销阶段未满足规则时的系统处理"], + ], + ), + ], + + ) + @staticmethod def _read_or_build_company_travel_rule_file( diff --git a/server/src/app/services/agent_runs.py b/server/src/app/services/agent_runs.py index e7173ab..fcf3f1e 100644 --- a/server/src/app/services/agent_runs.py +++ b/server/src/app/services/agent_runs.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import uuid from datetime import UTC, datetime, timedelta from typing import Any @@ -24,6 +25,34 @@ logger = get_logger("app.services.agent_runs") KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30) KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"} +LIST_ROUTE_FIELDS = ( + ("route_job_type", "job_type"), + ("route_task_type", "task_type"), + ("route_task_code", "task_code"), + ("route_task_name", "task_name"), + ("route_task_title", "task_title"), + ("route_asset_name", "asset_name"), + ("route_selected_agent", "selected_agent"), + ("route_phase", "phase"), + ("route_stage", "stage"), + ("route_report_type", "report_type"), + ("route_snapshot_key", "snapshot_key"), + ("route_folder", "folder"), + ("route_heartbeat_at", "heartbeat_at"), +) +LIST_ONTOLOGY_FIELDS = ( + ("ontology_scenario", "scenario"), + ("ontology_intent", "intent"), + ("ontology_parse_strategy", "parse_strategy"), +) +LIST_PROGRESS_FIELDS = { + "percent", + "total_documents", + "completed_documents", + "failed_documents", + "skipped_documents", + "current_stage", +} class AgentRunService: @@ -41,8 +70,22 @@ class AgentRunService: ) -> list[AgentRunRead]: self._ensure_ready() self._reconcile_stale_knowledge_index_runs() - runs = self.repository.list(agent=agent, status=status, source=source, limit=limit) - return [self._serialize_run(item) for item in runs] + rows = self.repository.list_light( + agent=agent, + status=status, + source=source, + limit=limit, + ) + tool_calls_by_run_id = self._group_light_tool_calls( + self.repository.list_light_tool_calls([str(item["run_id"]) for item in rows]) + ) + return [ + self._serialize_run_list_item( + item, + tool_calls_by_run_id.get(str(item["run_id"]), []), + ) + for item in rows + ] def get_run(self, run_id: str) -> AgentRunRead | None: self._ensure_ready() @@ -435,3 +478,99 @@ class AgentRunService: if semantic_parse else None, ) + + def _serialize_run_list_item( + self, + row: dict[str, Any], + tool_calls: list[dict[str, Any]], + ) -> AgentRunRead: + return AgentRunRead( + id=str(row["id"]), + run_id=str(row["run_id"]), + agent=str(row["agent"]), + source=str(row["source"]), + user_id=row.get("user_id"), + task_id=row.get("task_id"), + ontology_json=self._build_list_ontology_json(row), + route_json=self._build_list_route_json(row), + permission_level=str(row["permission_level"]), + status=str(row["status"]), + result_summary=row.get("result_summary"), + error_message=row.get("error_message"), + started_at=row["started_at"], + finished_at=row.get("finished_at"), + tool_calls=[self._serialize_light_tool_call(item) for item in tool_calls], + semantic_parse=None, + ) + + def _build_list_route_json(self, row: dict[str, Any]) -> dict[str, Any]: + payload: dict[str, Any] = {} + for source_key, target_key in LIST_ROUTE_FIELDS: + self._set_if_present(payload, target_key, row.get(source_key)) + + progress = self._coerce_json_object(row.get("route_progress")) + compact_progress = { + key: value + for key, value in progress.items() + if key in LIST_PROGRESS_FIELDS and self._is_scalar_json_value(value) + } + if compact_progress: + payload["progress"] = compact_progress + return payload + + def _build_list_ontology_json(self, row: dict[str, Any]) -> dict[str, Any]: + payload: dict[str, Any] = {} + for source_key, target_key in LIST_ONTOLOGY_FIELDS: + self._set_if_present(payload, target_key, row.get(source_key)) + return payload + + def _serialize_light_tool_call(self, row: dict[str, Any]) -> AgentToolCallRead: + return AgentToolCallRead( + id=str(row["id"]), + run_id=str(row["run_id"]), + tool_type=str(row["tool_type"]), + tool_name=str(row["tool_name"]), + request_json={}, + response_json={}, + status=str(row["status"]), + duration_ms=int(row.get("duration_ms") or 0), + error_message=row.get("error_message"), + created_at=row["created_at"], + ) + + @staticmethod + def _group_light_tool_calls( + tool_calls: list[dict[str, Any]], + ) -> dict[str, list[dict[str, Any]]]: + grouped: dict[str, list[dict[str, Any]]] = {} + for tool_call in tool_calls: + grouped.setdefault(str(tool_call.get("run_id") or ""), []).append(tool_call) + return grouped + + @staticmethod + def _coerce_json_object(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str): + normalized = value.strip() + if normalized.startswith("{") and normalized.endswith("}"): + try: + loaded = json.loads(normalized) + except json.JSONDecodeError: + return {} + return loaded if isinstance(loaded, dict) else {} + return {} + + @staticmethod + def _set_if_present(payload: dict[str, Any], key: str, value: Any) -> None: + if value is None: + return + if isinstance(value, str) and not value.strip(): + return + if not AgentRunService._is_scalar_json_value(value): + return + payload[key] = value + + @staticmethod + def _is_scalar_json_value(value: Any) -> bool: + return value is None or isinstance(value, str | int | float | bool) diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index b49ccb9..4d82832 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -12,7 +12,7 @@ from app.models.financial_record import ExpenseClaim from app.models.organization import OrganizationUnit from app.models.role import Role from app.services.expense_claim_workflow_constants import ( - APPROVAL_DONE_STAGE, + APPLICATION_ARCHIVE_STAGE, ARCHIVE_ACCOUNTING_STAGE, BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, @@ -30,7 +30,7 @@ BUDGET_MONITOR_ROLE_CODE = "budget_monitor" BUDGET_MONITOR_APPROVAL_GRADE = "P8" CLAIM_DELETE_ROLE_CODES = {"executive"} ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid") -APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed") +APPLICATION_ARCHIVED_STAGES = (APPLICATION_ARCHIVE_STAGE,) ARCHIVED_REIMBURSEMENT_STAGES = ( ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE, @@ -67,24 +67,31 @@ class ExpenseClaimAccessPolicy: normalized_type == "application", normalized_type.like("%\\_application", escape="\\"), ) - return or_( - stage.in_(ARCHIVED_REIMBURSEMENT_STAGES), - stage == "completed", - and_( - application_condition, - normalized_status.in_(ARCHIVED_CLAIM_STATUSES), - stage.in_(APPLICATION_ARCHIVED_STAGES), - ), - and_( - normalized_status.in_(ARCHIVED_CLAIM_STATUSES), - or_( - stage == "", - stage.is_(None), - stage.in_(ARCHIVED_REIMBURSEMENT_STAGES), - stage == "completed", + reimbursement_condition = and_( + ~application_condition, + or_( + stage.in_(ARCHIVED_REIMBURSEMENT_STAGES), + stage == "completed", + and_( + normalized_status.in_(ARCHIVED_CLAIM_STATUSES), + or_( + stage == "", + stage.is_(None), + stage.in_(ARCHIVED_REIMBURSEMENT_STAGES), + stage == "completed", + ), ), ), ) + application_archive_condition = and_( + application_condition, + normalized_status.in_(ARCHIVED_CLAIM_STATUSES), + stage.in_(APPLICATION_ARCHIVED_STAGES), + ) + return or_( + reimbursement_condition, + application_archive_condition, + ) @staticmethod def has_claim_delete_access(current_user: CurrentUserContext) -> bool: @@ -96,8 +103,6 @@ class ExpenseClaimAccessPolicy: def is_archived_claim(claim: ExpenseClaim) -> bool: normalized_status = str(claim.status or "").strip().lower() stage = str(claim.approval_stage or "").strip() - if stage in set(ARCHIVED_REIMBURSEMENT_STAGES): - return True normalized_type = str(claim.expense_type or "").strip().lower() claim_no = str(claim.claim_no or "").strip().upper() is_application_claim = ( @@ -105,11 +110,9 @@ class ExpenseClaimAccessPolicy: or normalized_type == "application" or normalized_type.endswith("_application") ) - if ( - is_application_claim - and normalized_status in ARCHIVED_CLAIM_STATUSES - and stage in APPLICATION_ARCHIVED_STAGES - ): + if is_application_claim: + return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in APPLICATION_ARCHIVED_STAGES + if stage in set(ARCHIVED_REIMBURSEMENT_STAGES): return True return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", *ARCHIVED_REIMBURSEMENT_STAGES} diff --git a/server/src/app/services/expense_claim_application_handoff.py b/server/src/app/services/expense_claim_application_handoff.py index 6f90fe8..9d435dd 100644 --- a/server/src/app/services/expense_claim_application_handoff.py +++ b/server/src/app/services/expense_claim_application_handoff.py @@ -5,7 +5,11 @@ from datetime import UTC, datetime from decimal import Decimal from typing import Any +from sqlalchemy import or_, select + from app.models.financial_record import ExpenseClaim +from app.services.expense_claim_risk_stage import with_risk_business_stage +from app.services.expense_claim_workflow_constants import APPLICATION_ARCHIVE_STAGE APPLICATION_REIMBURSEMENT_TYPE_MAP = { @@ -15,6 +19,7 @@ APPLICATION_REIMBURSEMENT_TYPE_MAP = { "expense_application": "other", "application": "other", } +APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"} class ExpenseClaimApplicationHandoffMixin: @@ -130,3 +135,116 @@ class ExpenseClaimApplicationHandoffMixin: approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft" approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}。" return draft_claim + + @staticmethod + def _collect_application_references_from_reimbursement(claim: ExpenseClaim) -> tuple[set[str], set[str]]: + application_ids: set[str] = set() + application_nos: set[str] = set() + for flag in list(claim.risk_flags_json or []): + if not isinstance(flag, dict): + continue + source = str(flag.get("source") or "").strip() + has_application_reference = any( + str(flag.get(key) or "").strip() + for key in ( + "application_claim_id", + "applicationClaimId", + "application_claim_no", + "applicationClaimNo", + ) + ) + if source not in APPLICATION_LINK_FLAG_SOURCES and not has_application_reference: + continue + application_id = str(flag.get("application_claim_id") or flag.get("applicationClaimId") or "").strip() + application_no = str(flag.get("application_claim_no") or flag.get("applicationClaimNo") or "").strip() + if application_id: + application_ids.add(application_id) + if application_no: + application_nos.add(application_no) + return application_ids, application_nos + + def _find_linked_application_claims(self, reimbursement_claim: ExpenseClaim) -> list[ExpenseClaim]: + application_ids, application_nos = self._collect_application_references_from_reimbursement(reimbursement_claim) + conditions = [] + if application_ids: + conditions.append(ExpenseClaim.id.in_(application_ids)) + if application_nos: + conditions.append(ExpenseClaim.claim_no.in_(application_nos)) + if not conditions: + return [] + + claims = list(self.db.scalars(select(ExpenseClaim).where(or_(*conditions))).all()) + return [claim for claim in claims if self._is_expense_application_claim(claim)] + + def _archive_linked_applications_after_reimbursement_paid( + self, + *, + reimbursement_claim: ExpenseClaim, + payment_flag: dict[str, Any], + operator: str, + current_user: Any, + ) -> list[dict[str, str]]: + archived_applications: list[dict[str, str]] = [] + payment_event_id = str(payment_flag.get("payment_event_id") or "").strip() + for application_claim in self._find_linked_application_claims(reimbursement_claim): + previous_status = str(application_claim.status or "").strip() + previous_stage = str(application_claim.approval_stage or "").strip() + if previous_stage == APPLICATION_ARCHIVE_STAGE: + continue + + normalized_status = previous_status.lower() + if normalized_status not in {"approved", "completed"}: + continue + + before_json = self._serialize_claim(application_claim) + archive_flag = with_risk_business_stage( + { + "source": "application_archive_sync", + "event_type": "expense_application_archived_by_reimbursement", + "archive_event_id": str(uuid.uuid4()), + "severity": "info", + "label": "申请归档", + "message": ( + f"关联报销单 {reimbursement_claim.claim_no} 已完成付款," + "系统同步将申请单归档。" + ), + "operator": operator, + "operator_username": getattr(current_user, "username", ""), + "operator_role_codes": [ + str(item).strip().lower() + for item in getattr(current_user, "role_codes", []) + if str(item).strip() + ], + "application_claim_id": application_claim.id, + "application_claim_no": application_claim.claim_no, + "reimbursement_claim_id": reimbursement_claim.id, + "reimbursement_claim_no": reimbursement_claim.claim_no, + "payment_event_id": payment_event_id, + "previous_status": previous_status, + "previous_approval_stage": previous_stage, + "next_status": "approved", + "next_approval_stage": APPLICATION_ARCHIVE_STAGE, + "created_at": datetime.now(UTC).isoformat(), + }, + "expense_application", + ) + application_claim.status = "approved" + application_claim.approval_stage = APPLICATION_ARCHIVE_STAGE + application_claim.risk_flags_json = [*list(application_claim.risk_flags_json or []), archive_flag] + archived_applications.append( + { + "application_claim_id": application_claim.id, + "application_claim_no": str(application_claim.claim_no or "").strip(), + "next_approval_stage": APPLICATION_ARCHIVE_STAGE, + } + ) + self.audit_service.log_action( + actor=operator, + action="expense_application.archive_by_reimbursement", + resource_type="expense_claim", + resource_id=application_claim.id, + before_json=before_json, + after_json=self._serialize_claim(application_claim), + ) + + return archived_applications diff --git a/server/src/app/services/expense_claim_approval_flow.py b/server/src/app/services/expense_claim_approval_flow.py index 3359d14..8f3fd90 100644 --- a/server/src/app/services/expense_claim_approval_flow.py +++ b/server/src/app/services/expense_claim_approval_flow.py @@ -6,7 +6,7 @@ from typing import Any from app.api.deps import CurrentUserContext from app.services.expense_claim_workflow_constants import ( - APPROVAL_DONE_STAGE, + APPLICATION_LINK_STATUS_STAGE, BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE, @@ -62,7 +62,7 @@ class ExpenseClaimApprovalFlowMixin: if merged_budget_approval: label = "领导及预算审核通过" next_status = "approved" - next_stage = APPROVAL_DONE_STAGE + next_stage = APPLICATION_LINK_STATUS_STAGE default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。" elif requires_budget_review: next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) @@ -73,7 +73,7 @@ class ExpenseClaimApprovalFlowMixin: default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。" else: next_status = "approved" - next_stage = APPROVAL_DONE_STAGE + next_stage = APPLICATION_LINK_STATUS_STAGE default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。" else: if requires_budget_review: @@ -99,7 +99,7 @@ class ExpenseClaimApprovalFlowMixin: label = "预算管理者审核通过" if is_application_claim: next_status = "approved" - next_stage = APPROVAL_DONE_STAGE + next_stage = APPLICATION_LINK_STATUS_STAGE default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。" else: next_status = "submitted" @@ -186,7 +186,7 @@ class ExpenseClaimApprovalFlowMixin: claim.approval_stage = next_stage if claim.submitted_at is None: claim.submitted_at = datetime.now(UTC) - if is_application_claim and next_stage == APPROVAL_DONE_STAGE: + if is_application_claim and next_stage == APPLICATION_LINK_STATUS_STAGE: if previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion( claim, @@ -289,6 +289,15 @@ class ExpenseClaimApprovalFlowMixin: "reimbursement", ) + archived_applications = self._archive_linked_applications_after_reimbursement_paid( + reimbursement_claim=claim, + payment_flag=payment_flag, + operator=operator, + current_user=current_user, + ) + if archived_applications: + payment_flag["archived_application_claims"] = archived_applications + claim.status = PAYMENT_PAID_STATUS claim.approval_stage = PAYMENT_PAID_STAGE claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag] diff --git a/server/src/app/services/expense_claim_platform_risk_flag.py b/server/src/app/services/expense_claim_platform_risk_flag.py index 767c6db..7f62cec 100644 --- a/server/src/app/services/expense_claim_platform_risk_flag.py +++ b/server/src/app/services/expense_claim_platform_risk_flag.py @@ -63,6 +63,8 @@ def build_platform_risk_flag( "rule_type": "risk", "rule_code": str(manifest.get("rule_code") or "").strip(), "rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(), + "finance_rule_code": str(manifest.get("finance_rule_code") or "").strip(), + "finance_rule_sheet": str(manifest.get("finance_rule_sheet") or "").strip(), "severity": severity, "action": action, "label": label, diff --git a/server/src/app/services/expense_claim_status_registry.py b/server/src/app/services/expense_claim_status_registry.py index a4e47ed..9404ef2 100644 --- a/server/src/app/services/expense_claim_status_registry.py +++ b/server/src/app/services/expense_claim_status_registry.py @@ -5,6 +5,8 @@ from typing import Any from app.services.expense_claim_workflow_constants import ( APPROVAL_DONE_STAGE, + APPLICATION_ARCHIVE_STAGE, + APPLICATION_LINK_STATUS_STAGE, ARCHIVE_ACCOUNTING_STAGE, BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, @@ -73,6 +75,8 @@ CANONICAL_APPROVAL_STAGES = { BUDGET_MANAGER_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE, APPROVAL_DONE_STAGE, + APPLICATION_LINK_STATUS_STAGE, + APPLICATION_ARCHIVE_STAGE, ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PENDING_STAGE, PAYMENT_PAID_STAGE, @@ -214,8 +218,10 @@ def _approved_stage(raw_stage: str, is_application_claim: bool) -> str: stage = _normalize_stage_alias(raw_stage) lowered = str(raw_stage or "").strip().lower() if is_application_claim: - if not stage or lowered == "completed": - return APPROVAL_DONE_STAGE + if stage == APPLICATION_ARCHIVE_STAGE: + return APPLICATION_ARCHIVE_STAGE + if not stage or lowered == "completed" or stage == APPROVAL_DONE_STAGE: + return APPLICATION_LINK_STATUS_STAGE return stage if stage in {ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE}: return stage diff --git a/server/src/app/services/expense_claim_workflow_constants.py b/server/src/app/services/expense_claim_workflow_constants.py index 47a8f66..dc89ebd 100644 --- a/server/src/app/services/expense_claim_workflow_constants.py +++ b/server/src/app/services/expense_claim_workflow_constants.py @@ -2,6 +2,8 @@ DIRECT_MANAGER_APPROVAL_STAGE = "直属领导审批" BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批" FINANCE_APPROVAL_STAGE = "财务审批" APPROVAL_DONE_STAGE = "审批完成" +APPLICATION_LINK_STATUS_STAGE = "关联单据状态" +APPLICATION_ARCHIVE_STAGE = "申请归档" ARCHIVE_ACCOUNTING_STAGE = "归档入账" PAYMENT_PENDING_STATUS = "pending_payment" PAYMENT_PAID_STATUS = "paid" diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 6e697e9..84803c4 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -858,7 +858,7 @@ class ExpenseClaimService( self._release_budget_for_delete(claim, current_user) self._delete_claim_analysis_records(resource_id) self._attachment_storage.delete_claim_files(claim) - ReceiptFolderService().delete_receipts_for_claim(resource_id) + ReceiptFolderService().unlink_receipts_for_claim(resource_id) self.db.delete(claim) self.db.commit() @@ -1021,4 +1021,3 @@ class ExpenseClaimService( - diff --git a/server/src/app/services/finance_rule_catalog.py b/server/src/app/services/finance_rule_catalog.py index 9fd6c13..a1d6954 100644 --- a/server/src/app/services/finance_rule_catalog.py +++ b/server/src/app/services/finance_rule_catalog.py @@ -1,6 +1,9 @@ from __future__ import annotations -from app.services.agent_asset_spreadsheet import COMPANY_TRAVEL_EXPENSE_RULE_CODE +from app.services.agent_asset_spreadsheet import ( + COMPANY_PREAPPROVAL_RULE_CODE, + COMPANY_TRAVEL_EXPENSE_RULE_CODE, +) DEPRECATED_FINANCE_RULE_CODES = ( "rule.expense.company_transport_hotel_detail_reimbursement", @@ -17,4 +20,6 @@ DEPRECATED_FINANCE_RULE_REPLACEMENTS = { "rule.expense.company_transport_hotel_detail_reimbursement": ( COMPANY_TRAVEL_EXPENSE_RULE_CODE ), + "rule.expense.company_meal_expense_reimbursement": COMPANY_PREAPPROVAL_RULE_CODE, + "rule.expense.company_office_expense_reimbursement": COMPANY_PREAPPROVAL_RULE_CODE, } diff --git a/server/src/app/services/hermes_employee_profile_scanner.py b/server/src/app/services/hermes_employee_profile_scanner.py index f07f2c3..b0679e6 100644 --- a/server/src/app/services/hermes_employee_profile_scanner.py +++ b/server/src/app/services/hermes_employee_profile_scanner.py @@ -1,7 +1,5 @@ from __future__ import annotations -import json - from sqlalchemy import select from sqlalchemy.orm import Session from sqlalchemy.orm import selectinload @@ -26,10 +24,23 @@ class HermesEmployeeProfileScannerService: summary["baseline_summary"] = baseline_summary logger.info( "Hermes employee profile scan completed: %s", - json.dumps(summary, ensure_ascii=False), + self._build_log_summary(summary), ) return summary + def _build_log_summary(self, summary: dict) -> dict: + baseline_summary = self._as_dict(summary.get("baseline_summary")) + buckets = baseline_summary.get("buckets") + return { + "target_employee_count": self._to_int(summary.get("target_employee_count")), + "snapshot_count": self._to_int(summary.get("snapshot_count")), + "high_attention_employee_count": self._to_int( + summary.get("high_attention_employee_count") + ), + "window_days": summary.get("window_days") or [], + "baseline_bucket_count": len(buckets) if isinstance(buckets, list) else 0, + } + def _build_baseline_summary(self) -> dict: stmt = ( select(ExpenseClaim) @@ -42,3 +53,14 @@ class HermesEmployeeProfileScannerService: for claim in self.db.scalars(stmt).all() ] return ProfileBaselineUpdater().build_from_claims(claims).as_dict() + + @staticmethod + def _as_dict(value: object) -> dict: + return value if isinstance(value, dict) else {} + + @staticmethod + def _to_int(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 diff --git a/server/src/app/services/ocr.py b/server/src/app/services/ocr.py index e975c72..df20acf 100644 --- a/server/src/app/services/ocr.py +++ b/server/src/app/services/ocr.py @@ -1,10 +1,13 @@ from __future__ import annotations import base64 +import hashlib import json import re import shutil import subprocess +import threading +from collections import OrderedDict from dataclasses import dataclass, field from pathlib import Path from uuid import uuid4 @@ -17,6 +20,7 @@ from app.services.document_intelligence import DocumentIntelligenceService WORKER_JSON_PREFIX = "__OCR_JSON__=" SUPPORTED_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".pdf"} +OCR_RESULT_CACHE_LIMIT = 32 @dataclass(slots=True) @@ -50,6 +54,12 @@ class AggregatedOcrDocument: class OcrService: + _cache_lock = threading.Lock() + _result_cache: OrderedDict[str, OcrRecognizeDocumentRead] = OrderedDict() + _worker_semaphore_lock = threading.Lock() + _worker_semaphore: threading.Semaphore | None = None + _worker_semaphore_limit = 0 + def __init__(self, db: Session | None = None) -> None: self.settings = get_settings() self.document_intelligence_service = DocumentIntelligenceService(db) @@ -70,6 +80,7 @@ class OcrService: python_bin = self._resolve_python_bin() worker_path = self._resolve_worker_path() worker_payload: dict = {} + cache_keys_by_source: dict[str, str] = {} try: for filename, content, media_type in files: @@ -109,6 +120,16 @@ class OcrService: ) continue + cache_key = self._build_cache_key(content) + cached_document = self._read_cached_document( + cache_key, + filename=normalized_name, + media_type=resolved_media_type, + ) + if cached_document is not None: + documents.append(cached_document) + continue + temp_path = temp_root / f"{uuid4().hex}{suffix}" temp_path.write_bytes(content) cleanup_paths.append(temp_path) @@ -116,15 +137,16 @@ class OcrService: if suffix == ".pdf": try: text_layer = self._extract_pdf_text_layer(temp_path) - prepared_inputs.extend( - self._prepare_pdf_inputs( - pdf_path=temp_path, - filename=normalized_name, - media_type=resolved_media_type, - cleanup_paths=cleanup_paths, - text_layer=text_layer, - ) + pdf_inputs = self._prepare_pdf_inputs( + pdf_path=temp_path, + filename=normalized_name, + media_type=resolved_media_type, + cleanup_paths=cleanup_paths, + text_layer=text_layer, ) + prepared_inputs.extend(pdf_inputs) + for item in pdf_inputs: + cache_keys_by_source.setdefault(item.source_key, cache_key) except RuntimeError as exc: documents.append( OcrRecognizeDocumentRead( @@ -135,10 +157,11 @@ class OcrService: ) continue + source_key = uuid4().hex prepared_inputs.append( PreparedOcrInput( input_path=temp_path, - source_key=uuid4().hex, + source_key=source_key, filename=normalized_name, media_type=resolved_media_type, preview_kind="image" if resolved_media_type.startswith("image/") else "", @@ -149,6 +172,7 @@ class OcrService: ), ) ) + cache_keys_by_source[source_key] = cache_key if prepared_inputs: worker_payload = self._invoke_worker( @@ -156,11 +180,15 @@ class OcrService: worker_path=worker_path, input_paths=[item.input_path for item in prepared_inputs], ) - documents.extend( - self._build_documents( - worker_documents=worker_payload.get("documents", []), - prepared_inputs=prepared_inputs, - ) + recognized_documents = self._build_documents( + worker_documents=worker_payload.get("documents", []), + prepared_inputs=prepared_inputs, + ) + documents.extend(recognized_documents) + self._write_cached_documents( + recognized_documents, + prepared_inputs=prepared_inputs, + cache_keys_by_source=cache_keys_by_source, ) success_count = sum( @@ -215,6 +243,79 @@ class OcrService: raise RuntimeError(f"OCR worker 不存在:{worker_path}") return str(worker_path) + def _build_cache_key(self, content: bytes) -> str: + digest = hashlib.sha256(content).hexdigest() + return "|".join( + [ + self.settings.ocr_language, + self.settings.ocr_text_detection_model, + self.settings.ocr_text_recognition_model, + digest, + ] + ) + + @classmethod + def _read_cached_document( + cls, + cache_key: str, + *, + filename: str, + media_type: str, + ) -> OcrRecognizeDocumentRead | None: + if not cache_key: + return None + with cls._cache_lock: + cached = cls._result_cache.get(cache_key) + if cached is None: + return None + cls._result_cache.move_to_end(cache_key) + return cached.model_copy(update={"filename": filename, "media_type": media_type}) + + @classmethod + def _write_cached_documents( + cls, + documents: list[OcrRecognizeDocumentRead], + *, + prepared_inputs: list[PreparedOcrInput], + cache_keys_by_source: dict[str, str], + ) -> None: + if not documents or not cache_keys_by_source: + return + + source_order: list[str] = [] + seen_sources: set[str] = set() + for item in prepared_inputs: + if item.source_key in seen_sources: + continue + seen_sources.add(item.source_key) + source_order.append(item.source_key) + + with cls._cache_lock: + for source_key, document in zip(source_order, documents, strict=False): + cache_key = cache_keys_by_source.get(source_key, "") + if not cache_key: + continue + cls._result_cache[cache_key] = document.model_copy( + update={ + "receipt_id": "", + "receipt_status": "", + "receipt_preview_url": "", + "receipt_source_url": "", + } + ) + cls._result_cache.move_to_end(cache_key) + while len(cls._result_cache) > OCR_RESULT_CACHE_LIMIT: + cls._result_cache.popitem(last=False) + + @classmethod + def _resolve_worker_semaphore(cls, limit: int) -> threading.Semaphore: + normalized_limit = max(1, int(limit or 1)) + with cls._worker_semaphore_lock: + if cls._worker_semaphore is None or cls._worker_semaphore_limit != normalized_limit: + cls._worker_semaphore = threading.Semaphore(normalized_limit) + cls._worker_semaphore_limit = normalized_limit + return cls._worker_semaphore + def _invoke_worker( self, *, @@ -235,13 +336,15 @@ class OcrService: for path in input_paths: command.extend(["--input", str(path)]) - completed = subprocess.run( - command, - capture_output=True, - text=True, - timeout=self.settings.ocr_timeout_seconds, - check=False, - ) + semaphore = self._resolve_worker_semaphore(self.settings.ocr_max_concurrent_workers) + with semaphore: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=self.settings.ocr_timeout_seconds, + check=False, + ) if completed.returncode != 0: detail = (completed.stderr or completed.stdout or "").strip() raise RuntimeError(f"OCR 执行失败:{detail or 'worker 返回非 0 状态码。'}") diff --git a/server/src/app/services/receipt_folder.py b/server/src/app/services/receipt_folder.py index 2f0a481..32054e8 100644 --- a/server/src/app/services/receipt_folder.py +++ b/server/src/app/services/receipt_folder.py @@ -336,11 +336,11 @@ class ReceiptFolderService: shutil.rmtree(receipt_dir) return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id) - def delete_receipts_for_claim(self, claim_id: str) -> int: + def unlink_receipts_for_claim(self, claim_id: str) -> int: normalized_claim_id = str(claim_id or "").strip() if not normalized_claim_id: return 0 - deleted_count = 0 + unlinked_count = 0 self.root.mkdir(parents=True, exist_ok=True) for meta_path in list(self.root.glob("*/*/meta.json")): try: @@ -349,9 +349,18 @@ class ReceiptFolderService: continue if str(meta.get("linked_claim_id") or "").strip() != normalized_claim_id: continue - shutil.rmtree(meta_path.parent, ignore_errors=True) - deleted_count += 1 - return deleted_count + meta["status"] = "unlinked" + meta["linked_claim_id"] = "" + meta["linked_claim_no"] = "" + meta["linked_item_id"] = "" + meta["linked_at"] = "" + meta["updated_at"] = datetime.now(UTC).isoformat() + self._write_meta(meta_path.parent, meta) + unlinked_count += 1 + return unlinked_count + + def delete_receipts_for_claim(self, claim_id: str) -> int: + return self.unlink_receipts_for_claim(claim_id) def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]: meta = self._read_receipt_meta(receipt_id, current_user) diff --git a/server/src/app/services/risk_rule_template_executor.py b/server/src/app/services/risk_rule_template_executor.py index 8517fe7..07e228f 100644 --- a/server/src/app/services/risk_rule_template_executor.py +++ b/server/src/app/services/risk_rule_template_executor.py @@ -603,6 +603,8 @@ class RiskRuleTemplateExecutor: ) if normalized.startswith("attachment."): return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts) + if normalized.startswith("application."): + return self._resolve_application_values(normalized.removeprefix("application."), claim) if normalized.startswith("budget."): return self._resolve_budget_values(normalized.removeprefix("budget."), contexts) return [] @@ -714,6 +716,99 @@ class RiskRuleTemplateExecutor: values.append(budget_context.get(key)) return self._normalize_values(values) + def _resolve_application_values(self, field_key: str, claim: ExpenseClaim) -> list[str]: + values: list[Any] = [] + normalized_key = str(field_key or "").strip() + alias_map = { + "id": ( + "application_claim_id", + "applicationClaimId", + "application_id", + "applicationId", + "claim_id", + "claimId", + "id", + ), + "claim_no": ( + "application_claim_no", + "applicationClaimNo", + "application_no", + "applicationNo", + "claim_no", + "claimNo", + "no", + ), + "status": ("application_status", "applicationStatus", "status"), + "approved_amount": ( + "application_approved_amount", + "applicationApprovedAmount", + "approved_amount", + "approvedAmount", + "application_amount", + "applicationAmount", + "amount", + ), + "amount": ( + "application_amount", + "applicationAmount", + "approved_amount", + "approvedAmount", + "amount", + ), + "expense_type": ( + "application_expense_type", + "applicationExpenseType", + "expense_type", + "expenseType", + ), + "department_name": ( + "application_department_name", + "applicationDepartmentName", + "department_name", + "departmentName", + ), + "reason": ("application_reason", "applicationReason", "reason"), + } + lookup_keys = alias_map.get( + normalized_key, + (normalized_key, normalized_key.replace("_", ""), normalized_key.replace("_", "-")), + ) + for source in self._iter_application_contexts(claim): + for key in lookup_keys: + if key in source and source.get(key) not in (None, ""): + values.append(source.get(key)) + return self._normalize_values(values) + + @staticmethod + def _iter_application_contexts(claim: ExpenseClaim) -> list[dict[str, Any]]: + contexts: list[dict[str, Any]] = [] + application_sources = {"application_detail", "application_handoff", "application_link"} + nested_keys = ( + "application_detail", + "applicationDetail", + "review_form_values", + "reviewFormValues", + "expense_scene_selection", + "expenseSceneSelection", + ) + for flag in list(getattr(claim, "risk_flags_json", None) or []): + if not isinstance(flag, dict): + continue + source = str(flag.get("source") or "").strip() + has_application_anchor = ( + source in application_sources + or any(key in flag for key in ("application_claim_no", "applicationClaimNo")) + or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail")) + ) + if not has_application_anchor: + continue + contexts.append(flag) + for key in nested_keys: + nested = flag.get(key) + if isinstance(nested, dict): + contexts.append(nested) + return contexts + def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]: values: list[Any] = [] for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}: diff --git a/server/src/app/services/steward_model_plan_builder.py b/server/src/app/services/steward_model_plan_builder.py index 736c03b..1d6b939 100644 --- a/server/src/app/services/steward_model_plan_builder.py +++ b/server/src/app/services/steward_model_plan_builder.py @@ -229,10 +229,9 @@ class StewardModelPlanBuilder: StewardThinkingEvent( event_id="intent_agent_function_call", stage="llm_function_call", - title="意图识别智能体接管", + title="拆解财务事项", content=( - "已调用系统主模型的 submit_steward_intent_plan 工具," - "把用户话术转换为可校验的结构化财务任务计划。" + "我正在把这句话拆成可执行的财务事项,并检查每一项应该进入申请流程还是报销流程。" ), ) ] @@ -255,6 +254,10 @@ class StewardModelPlanBuilder: ) if len(events) == 1: events.extend(self.planner._build_thinking_events(tasks, attachment_groups, attachments)[1:]) + else: + gap_event = self.planner._build_business_gap_thinking_event(tasks) + if gap_event: + events.append(gap_event) return events def _sanitize_model_missing_fields( diff --git a/server/src/app/services/steward_planner.py b/server/src/app/services/steward_planner.py index ebcfda4..af4251e 100644 --- a/server/src/app/services/steward_planner.py +++ b/server/src/app/services/steward_planner.py @@ -52,6 +52,39 @@ REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报 MONTH_DAY_PATTERN = re.compile(r"(?P\d{1,2})\s*月\s*(?P\d{1,2})\s*(?:日|号)?") ISO_DATE_PATTERN = re.compile(r"(?P\d{4})[-/年](?P\d{1,2})[-/月](?P\d{1,2})(?:日)?") +BUSINESS_FIELD_LABELS = { + "expense_type": "费用类型", + "time_range": "时间", + "location": "地点", + "reason": "事由", + "amount": "金额", + "transport_mode": "出行方式", + "attachments": "附件/凭证", + "customer_name": "客户或项目对象", + "merchant_name": "商户/开票方", + "department_name": "所属部门", + "employee_name": "申请人", + "employee_no": "员工编号", +} + +EXPENSE_TYPE_LABELS = { + "travel": "差旅", + "transport": "交通费", + "entertainment": "业务招待费", + "office": "办公用品", + "meeting": "会议费", + "training": "培训费", + "other": "其他费用", +} + +TRANSPORT_MODE_LABELS = { + "train": "火车/高铁", + "flight": "飞机", + "taxi": "出租车/网约车", + "subway": "地铁", + "other": "其他交通方式", +} + @dataclass(frozen=True) class PlannedTaskDraft: @@ -372,6 +405,8 @@ class StewardPlannerService: required = ["expense_type", "time_range", "reason"] if task_type == "expense_application": required.append("location") + if fields.get("expense_type") in {"travel", "transport"}: + required.append("transport_mode") return [key for key in required if not str(fields.get(key) or "").strip()] @staticmethod @@ -543,10 +578,13 @@ class StewardPlannerService: StewardThinkingEvent( event_id="intent_ontology_mapping", stage="ontology_mapping", - title="映射业务本体字段", + title="核对业务要素", content=ontology_summary, ), ] + gap_event = self._build_business_gap_thinking_event(tasks) + if gap_event: + events.append(gap_event) if attachments: events.append( StewardThinkingEvent( @@ -580,23 +618,82 @@ class StewardPlannerService: if fields.get("location"): anchors.append(fields["location"]) if fields.get("expense_type"): - anchors.append(fields["expense_type"]) + anchors.append(StewardPlannerService._format_business_field_value("expense_type", fields["expense_type"])) anchor_text = "、".join(anchors) if anchors else "待补充关键字段" parts.append(f"{task_label}:{task.title}({anchor_text})") return ";".join(parts) @staticmethod def _summarize_ontology_coverage(tasks: list[StewardTask]) -> str: - canonical_keys = [] - missing_keys = [] + mapped_labels = [] + missing_labels = [] for task in tasks: - canonical_keys.extend(task.ontology_fields.keys()) - missing_keys.extend(task.missing_fields) - unique_keys = sorted({item for item in canonical_keys if item}) - unique_missing = sorted({item for item in missing_keys if item}) - mapped = "、".join(unique_keys) if unique_keys else "暂无稳定字段" - missing = ";缺失字段:" + "、".join(unique_missing) if unique_missing else "" - return f"已使用 canonical ontology fields:{mapped}{missing}。兼容字段只作为输入别名,不直接进入业务逻辑。" + mapped_labels.extend(StewardPlannerService._business_field_label(key) for key in task.ontology_fields.keys()) + missing_labels.extend(StewardPlannerService._business_field_label(key) for key in task.missing_fields) + mapped = "、".join(dict.fromkeys(label for label in mapped_labels if label)) or "暂无稳定业务要素" + missing = ";还缺少:" + "、".join(dict.fromkeys(label for label in missing_labels if label)) if missing_labels else "" + return f"已把用户输入归一为业务要素:{mapped}{missing}。后续执行仍会先让用户确认。" + + @staticmethod + def _build_business_gap_thinking_event(tasks: list[StewardTask]) -> StewardThinkingEvent | None: + gap_lines = [] + for task in tasks: + if not task.missing_fields: + continue + missing_labels = [ + StewardPlannerService._business_field_label(key) + for key in task.missing_fields + if key + ] + if not missing_labels: + continue + if task.task_type == "expense_application" and "transport_mode" in task.missing_fields: + gap_lines.append( + ( + f"{task.title}已识别到{StewardPlannerService._summarize_known_business_points(task)}," + "但用户没有说明出行方式;出行方式会影响交通费用测算,进入申请单核对后需要先追问火车、飞机或轮船。" + ) + ) + else: + gap_lines.append( + ( + f"{task.title}还缺少{'、'.join(dict.fromkeys(missing_labels))}," + "需要在对应步骤里继续向用户确认,不能直接执行入库或提交。" + ) + ) + if not gap_lines: + return None + return StewardThinkingEvent( + event_id="intent_business_gap_check", + stage="business_gap_check", + title="判断待补充信息", + content=";".join(gap_lines), + ) + + @staticmethod + def _summarize_known_business_points(task: StewardTask) -> str: + parts = [] + for key in ("time_range", "location", "reason", "expense_type"): + value = str(task.ontology_fields.get(key) or "").strip() + if value: + parts.append( + f"{StewardPlannerService._business_field_label(key)}为" + f"{StewardPlannerService._format_business_field_value(key, value)}" + ) + return "、".join(parts) or "部分业务要素" + + @staticmethod + def _business_field_label(key: str) -> str: + return BUSINESS_FIELD_LABELS.get(str(key or "").strip(), str(key or "").strip()) + + @staticmethod + def _format_business_field_value(key: str, value: str) -> str: + cleaned = str(value or "").strip() + if key == "expense_type": + return EXPENSE_TYPE_LABELS.get(cleaned, cleaned) + if key == "transport_mode": + return TRANSPORT_MODE_LABELS.get(cleaned, cleaned) + return cleaned @staticmethod def _summarize_attachment_correlation( diff --git a/server/src/app/services/steward_runtime_decision_agent.py b/server/src/app/services/steward_runtime_decision_agent.py new file mode 100644 index 0000000..bb810d3 --- /dev/null +++ b/server/src/app/services/steward_runtime_decision_agent.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import json +from typing import Any + +from app.schemas.steward import ( + StewardRuntimeDecisionRequest, + StewardRuntimeDecisionResponse, +) +from app.services.runtime_chat import RuntimeChatService + + +STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision" + +RUNTIME_NEXT_ACTIONS = { + "plan_new_tasks", + "submit_current_application", + "continue_next_task", + "fill_current_slot", + "ask_user", + "cancel_current_action", + "no_op", +} + + +class StewardRuntimeDecisionAgent: + """用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。""" + + def __init__(self, runtime_chat_service: RuntimeChatService) -> None: + self.runtime_chat_service = runtime_chat_service + + def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse: + normalized_request = self._normalize_request(request) + result = self.runtime_chat_service.complete_with_tool_call( + self._build_messages(normalized_request), + tools=[self._build_tool_schema()], + tool_choice={ + "type": "function", + "function": {"name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME}, + }, + max_tokens=1000, + temperature=0.05, + timeout_seconds=30, + max_attempts=1, + ) + traces = result.calls_as_dicts() + if result.tool_call is not None and result.tool_call.name == STEWARD_RUNTIME_DECISION_FUNCTION_NAME: + response = self._build_response_from_model_payload(result.tool_call.arguments, normalized_request, traces) + if response is not None: + return response + return self._build_rule_fallback(normalized_request, traces) + + @staticmethod + def _normalize_request(request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionRequest: + return StewardRuntimeDecisionRequest( + user_message=str(request.user_message or "").strip(), + session_type=str(request.session_type or "steward").strip() or "steward", + runtime_state=request.runtime_state if isinstance(request.runtime_state, dict) else {}, + context_json=request.context_json if isinstance(request.context_json, dict) else {}, + ) + + @staticmethod + def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]: + payload = { + "user_message": request.user_message, + "session_type": request.session_type, + "runtime_state": request.runtime_state, + "context_json": request.context_json, + } + return [ + { + "role": "system", + "content": ( + "你是 X-Financial 小财管家的运行时决策智能体。" + "你必须基于 runtime_state 判断用户当前输入对应哪个等待动作,不能把每次输入都当成全新任务。" + "runtime_state 会包含 current_task、remaining_tasks、completed_tasks、pending_application、" + "pending_steward_action、waiting_for、recent_structured_result 等上下文。" + "如果用户是在确认当前申请核对表无误,应返回 submit_current_application;" + "如果用户是在确认继续下一项,应返回 continue_next_task;" + "如果用户补充了当前等待字段,应返回 fill_current_slot;" + "如果当前结构化结果仍缺字段,应返回 ask_user;" + "只有当前没有可匹配上下文,且用户输入明显是新财务事项时,才返回 plan_new_tasks。" + "提交、入库、绑定、审批等高风险动作只返回结构化意图,实际执行由系统安全校验完成。" + "rationale 和 response_text 必须面向用户,不暴露内部推理链。" + ), + }, + {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}, + ] + + @staticmethod + def _build_tool_schema() -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME, + "description": "提交小财管家基于运行时上下文的下一步动作决策。", + "parameters": { + "type": "object", + "properties": { + "next_action": { + "type": "string", + "enum": sorted(RUNTIME_NEXT_ACTIONS), + }, + "target_task_id": {"type": "string"}, + "target_message_id": {"type": "string"}, + "field_key": {"type": "string"}, + "field_value": {"type": "string"}, + "confirmation_required": {"type": "boolean"}, + "question": {"type": "string"}, + "response_text": {"type": "string"}, + "rationale": {"type": "string"}, + }, + "required": [ + "next_action", + "target_task_id", + "target_message_id", + "field_key", + "field_value", + "confirmation_required", + "question", + "response_text", + "rationale", + ], + }, + }, + } + + def _build_response_from_model_payload( + self, + payload: dict[str, Any], + request: StewardRuntimeDecisionRequest, + traces: list[dict[str, Any]], + ) -> StewardRuntimeDecisionResponse | None: + next_action = str(payload.get("next_action") or "").strip() + if next_action not in RUNTIME_NEXT_ACTIONS: + return None + return StewardRuntimeDecisionResponse( + decision_source="llm_function_call", + next_action=next_action, # type: ignore[arg-type] + target_task_id=self._clean_text(payload.get("target_task_id")), + target_message_id=self._clean_text(payload.get("target_message_id")), + field_key=self._clean_text(payload.get("field_key")), + field_value=self._clean_text(payload.get("field_value")), + confirmation_required=bool(payload.get("confirmation_required")), + question=self._clean_text(payload.get("question")), + response_text=self._clean_text(payload.get("response_text")), + rationale=self._clean_text(payload.get("rationale")), + model_call_traces=traces, + ) + + def _build_rule_fallback( + self, + request: StewardRuntimeDecisionRequest, + traces: list[dict[str, Any]], + ) -> StewardRuntimeDecisionResponse: + state = request.runtime_state + pending_application = state.get("pending_application") if isinstance(state.get("pending_application"), dict) else {} + pending_steward_action = state.get("pending_steward_action") if isinstance(state.get("pending_steward_action"), dict) else {} + waiting_for = str(state.get("waiting_for") or "").strip() + message = request.user_message.replace(" ", "") + confirmation_text = message in {"确认", "确定", "无误", "确认提交", "可以提交", "提交", "没问题"} + if confirmation_text and pending_application.get("ready_to_submit"): + return StewardRuntimeDecisionResponse( + decision_source="rule_fallback", + next_action="submit_current_application", + target_message_id=str(pending_application.get("message_id") or ""), + target_task_id=str(pending_application.get("task_id") or ""), + rationale="模型运行时决策暂不可用,我先按当前待提交申请单上下文处理你的确认。", + model_call_traces=traces, + ) + if confirmation_text and pending_steward_action: + return StewardRuntimeDecisionResponse( + decision_source="rule_fallback", + next_action="continue_next_task", + target_message_id=str(pending_steward_action.get("message_id") or ""), + target_task_id=str(pending_steward_action.get("target_task_id") or ""), + rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。", + model_call_traces=traces, + ) + if waiting_for: + return StewardRuntimeDecisionResponse( + decision_source="rule_fallback", + next_action="ask_user", + question="我需要先确认当前等待事项,请补充或选择当前问题对应的信息。", + rationale="模型运行时决策暂不可用,当前仍存在等待用户补充的信息。", + model_call_traces=traces, + ) + return StewardRuntimeDecisionResponse( + decision_source="rule_fallback", + next_action="plan_new_tasks", + rationale="模型运行时决策暂不可用,当前没有可安全匹配的等待动作,回到任务规划。", + model_call_traces=traces, + ) + + @staticmethod + def _clean_text(value: Any) -> str: + return str(value or "").strip() diff --git a/server/src/app/services/steward_slot_decision_agent.py b/server/src/app/services/steward_slot_decision_agent.py new file mode 100644 index 0000000..bee1b48 --- /dev/null +++ b/server/src/app/services/steward_slot_decision_agent.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from app.schemas.steward import ( + StewardSlotDecisionRequest, + StewardSlotDecisionResponse, + StewardSlotOption, +) +from app.services.ontology_field_registry import normalize_ontology_form_values +from app.services.runtime_chat import RuntimeChatService +from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER, BUSINESS_CANONICAL_FIELDS + + +STEWARD_SLOT_DECISION_FUNCTION_NAME = "submit_steward_slot_decision" + + +FIELD_CATALOG: dict[str, dict[str, str]] = { + "expense_type": {"label": "费用类型", "description": "申请或报销所属费用场景,如差旅、交通、住宿、业务招待。"}, + "time_range": {"label": "时间", "description": "申请时为出差起止日期,报销时为费用发生日期。"}, + "location": {"label": "地点", "description": "出差目的地、费用发生地或业务活动地点。"}, + "reason": {"label": "事由", "description": "出差、报销或业务活动的业务原因。"}, + "amount": {"label": "金额", "description": "报销时为实际金额;申请时金额可由系统估算,不应默认要求用户填写。"}, + "transport_mode": {"label": "出行方式", "description": "差旅申请交通费用测算所需字段,由用户明确选择或表达。"}, + "attachments": {"label": "附件/凭证", "description": "发票、行程单、付款截图或其他证明材料。"}, + "customer_name": {"label": "客户或项目对象", "description": "业务招待、客户拜访或项目支撑涉及的对象。"}, + "merchant_name": {"label": "商户/开票方", "description": "报销票据上的商户或开票方。"}, + "department_name": {"label": "所属部门", "description": "申请人或费用归属部门。"}, + "employee_name": {"label": "申请人", "description": "发起申请或报销的员工。"}, + "employee_no": {"label": "员工编号", "description": "公司内部员工编号。"}, +} + +APPLICATION_NON_BLOCKING_FIELDS = {"amount", "attachments", "employee_no", "department_name", "employee_name"} + + +@dataclass(frozen=True, slots=True) +class StewardSlotDecisionAgentResult: + payload: dict[str, Any] + model_call_traces: list[dict[str, Any]] + + +class StewardSlotDecisionAgent: + """用大模型 function calling 判断当前任务缺什么,以及下一步是否应先追问。""" + + def __init__(self, runtime_chat_service: RuntimeChatService) -> None: + self.runtime_chat_service = runtime_chat_service + + def decide(self, request: StewardSlotDecisionRequest) -> StewardSlotDecisionResponse: + normalized_request = self._normalize_request(request) + result = self.runtime_chat_service.complete_with_tool_call( + self._build_messages(normalized_request), + tools=[self._build_tool_schema()], + tool_choice={ + "type": "function", + "function": {"name": STEWARD_SLOT_DECISION_FUNCTION_NAME}, + }, + max_tokens=1200, + temperature=0.05, + timeout_seconds=30, + max_attempts=1, + ) + if result.tool_call is not None and result.tool_call.name == STEWARD_SLOT_DECISION_FUNCTION_NAME: + response = self._build_response_from_model_payload( + result.tool_call.arguments, + normalized_request, + result.calls_as_dicts(), + ) + if response is not None: + return response + return self._build_rule_fallback(normalized_request, result.calls_as_dicts()) + + @staticmethod + def _normalize_request(request: StewardSlotDecisionRequest) -> StewardSlotDecisionRequest: + normalized_fields = { + key: value + for key, value in normalize_ontology_form_values(request.ontology_fields).items() + if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip() + } + missing_fields: list[str] = [] + for item in request.missing_fields: + key = str(item or "").strip() + if request.task_type == "expense_application" and key in APPLICATION_NON_BLOCKING_FIELDS: + continue + if key in BUSINESS_CANONICAL_FIELDS and key not in missing_fields and not normalized_fields.get(key): + missing_fields.append(key) + return StewardSlotDecisionRequest( + task_type=request.task_type, + user_message=str(request.user_message or "").strip(), + ontology_fields=normalized_fields, + missing_fields=missing_fields, + task_context=request.task_context if isinstance(request.task_context, dict) else {}, + ) + + @staticmethod + def _build_messages(request: StewardSlotDecisionRequest) -> list[dict[str, Any]]: + context_payload = { + "task_type": request.task_type, + "user_message": request.user_message, + "ontology_fields": request.ontology_fields, + "missing_fields_from_intent_agent": request.missing_fields, + "field_catalog": { + key: FIELD_CATALOG[key] + for key in BUSINESS_CANONICAL_FIELD_ORDER + if key in FIELD_CATALOG + }, + "task_context": request.task_context, + } + return [ + { + "role": "system", + "content": ( + "你是 X-Financial 小财管家的任务字段决策智能体。" + "你必须通过 function calling 返回下一步动作。" + "你的任务不是关键词匹配,而是结合用户意图、当前任务类型、canonical ontology 字段、" + "上游意图识别给出的缺失字段和字段目录,判断现在应先追问用户,还是可以展示核对结果。" + "所有 required_fields 和 missing_fields 只能使用 field_catalog 中的 canonical 字段。" + "如果字段是内部提示、示例、系统指令或可选项,不能当作用户已经提供。" + "费用申请场景中 amount 可由系统估算,不应作为用户必须手填字段。" + "费用申请生成核对表阶段,attachments 不阻塞生成,可在报销或归档阶段补充;" + "employee_no、department_name、employee_name 属于系统用户档案字段,必须从上下文读取,不能向用户追问。" + "差旅申请通常只有 transport_mode 这类会影响费用测算的字段才需要先追问。" + "如果缺失字段会影响后续测算、入库、附件归集或合规判断,应返回 ask_user;" + "如果信息足以生成可核对但未提交的结果,应返回 render_preview。" + "question 和 rationale 必须是面向用户的业务说明,不暴露内部推理链。" + ), + }, + { + "role": "user", + "content": json.dumps(context_payload, ensure_ascii=False), + }, + ] + + @staticmethod + def _build_tool_schema() -> dict[str, Any]: + canonical_fields = list(BUSINESS_CANONICAL_FIELD_ORDER) + return { + "type": "function", + "function": { + "name": STEWARD_SLOT_DECISION_FUNCTION_NAME, + "description": "提交小财管家当前任务的字段缺口和下一步动作决策。", + "parameters": { + "type": "object", + "properties": { + "next_action": { + "type": "string", + "enum": ["ask_user", "render_preview"], + }, + "required_fields": { + "type": "array", + "items": {"type": "string", "enum": canonical_fields}, + }, + "missing_fields": { + "type": "array", + "items": {"type": "string", "enum": canonical_fields}, + }, + "question": {"type": "string"}, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "value": {"type": "string"}, + "field_key": {"type": "string", "enum": canonical_fields}, + "description": {"type": "string"}, + }, + "required": ["label", "value", "field_key"], + }, + }, + "rationale": {"type": "string"}, + }, + "required": ["next_action", "required_fields", "missing_fields", "question", "options", "rationale"], + }, + }, + } + + def _build_response_from_model_payload( + self, + payload: dict[str, Any], + request: StewardSlotDecisionRequest, + traces: list[dict[str, Any]], + ) -> StewardSlotDecisionResponse | None: + next_action = str(payload.get("next_action") or "").strip() + if next_action not in {"ask_user", "render_preview"}: + return None + required_fields = self._sanitize_fields(payload.get("required_fields")) + missing_fields = self._sanitize_fields(payload.get("missing_fields")) + required_fields = self._filter_blocking_fields(required_fields, request.task_type) + missing_fields = self._filter_blocking_fields(missing_fields, request.task_type) + missing_fields = [ + key + for key in missing_fields + if key in required_fields or key in request.missing_fields + ] + if next_action == "ask_user" and not missing_fields: + missing_fields = list(request.missing_fields) + if next_action == "ask_user" and not missing_fields: + next_action = "render_preview" + options = [] + question = "" + rationale = "当前申请信息足以先生成核对结果;附件和员工编号不应作为用户补填项阻塞申请预览。" + else: + options = self._sanitize_options(payload.get("options"), missing_fields) + question = self._clean_text(payload.get("question")) + rationale = self._clean_text(payload.get("rationale")) + return StewardSlotDecisionResponse( + decision_source="llm_function_call", + next_action=next_action, # type: ignore[arg-type] + required_fields=required_fields, + missing_fields=missing_fields, + question=question, + options=options, + rationale=rationale, + model_call_traces=traces, + ) + + @staticmethod + def _filter_blocking_fields(fields: list[str], task_type: str) -> list[str]: + if task_type != "expense_application": + return fields + return [field for field in fields if field not in APPLICATION_NON_BLOCKING_FIELDS] + + @staticmethod + def _sanitize_fields(raw_fields: Any) -> list[str]: + fields: list[str] = [] + if not isinstance(raw_fields, list): + return fields + for item in raw_fields: + key = str(item or "").strip() + if key in BUSINESS_CANONICAL_FIELDS and key not in fields: + fields.append(key) + return fields + + def _sanitize_options(self, raw_options: Any, missing_fields: list[str]) -> list[StewardSlotOption]: + options: list[StewardSlotOption] = [] + if isinstance(raw_options, list): + for item in raw_options: + if not isinstance(item, dict): + continue + field_key = str(item.get("field_key") or "").strip() + label = self._clean_text(item.get("label")) + value = self._clean_text(item.get("value")) or label + if not field_key or field_key not in BUSINESS_CANONICAL_FIELDS or not label or not value: + continue + options.append( + StewardSlotOption( + field_key=field_key, + label=label, + value=value, + description=self._clean_text(item.get("description")), + ) + ) + if not options and missing_fields and missing_fields[0] == "transport_mode": + options = [ + StewardSlotOption(field_key="transport_mode", label="火车", value="火车", description="选择火车或高铁出行。"), + StewardSlotOption(field_key="transport_mode", label="飞机", value="飞机", description="选择飞机出行。"), + StewardSlotOption(field_key="transport_mode", label="轮船", value="轮船", description="选择轮船出行。"), + ] + return options[:6] + + def _build_rule_fallback( + self, + request: StewardSlotDecisionRequest, + traces: list[dict[str, Any]], + ) -> StewardSlotDecisionResponse: + missing_fields = list(request.missing_fields) + if missing_fields: + field = missing_fields[0] + return StewardSlotDecisionResponse( + decision_source="rule_fallback", + next_action="ask_user", + required_fields=list(dict.fromkeys([*request.ontology_fields.keys(), *missing_fields])), + missing_fields=missing_fields, + question=self._build_fallback_question(field), + options=self._sanitize_options([], [field]), + rationale="模型字段决策暂不可用,我先按上游意图识别给出的本体缺口向你确认。", + model_call_traces=traces, + ) + return StewardSlotDecisionResponse( + decision_source="rule_fallback", + next_action="render_preview", + required_fields=list(request.ontology_fields.keys()), + missing_fields=[], + question="", + options=[], + rationale="当前任务没有上游标记的关键字段缺口,可以先生成核对结果供你确认。", + model_call_traces=traces, + ) + + @staticmethod + def _build_fallback_question(field: str) -> str: + label = FIELD_CATALOG.get(field, {}).get("label") or field + if field == "transport_mode": + return "请问你这次打算怎么出行?可以选择火车、飞机或轮船。" + return f"当前还缺少{label},请先补充后我再继续处理。" + + @staticmethod + def _clean_text(value: Any) -> str: + return str(value or "").strip() diff --git a/server/src/app/services/system_dashboard.py b/server/src/app/services/system_dashboard.py index 41506e5..44a7c4d 100644 --- a/server/src/app/services/system_dashboard.py +++ b/server/src/app/services/system_dashboard.py @@ -1,11 +1,12 @@ from __future__ import annotations import json +from dataclasses import dataclass, field from datetime import UTC, date, datetime, timedelta from typing import Any from sqlalchemy import select -from sqlalchemy.orm import Session, selectinload +from sqlalchemy.orm import Session from app.db.base import Base from app.models.agent_feedback import AgentOperationFeedback @@ -17,6 +18,7 @@ SUCCESS_STATUSES = {"success", "succeeded", "ok", "done", "completed"} FAILED_STATUSES = {"failed", "failure", "error", "errored"} BLOCKED_STATUSES = {"blocked", "forbidden", "rejected"} RUNNING_STATUSES = {"running", "pending"} +TOKEN_ESTIMATE_FALLBACK_TOTAL = 600 TOOL_BUCKETS = [ { @@ -58,6 +60,32 @@ TOOL_BUCKETS = [ ] +@dataclass(slots=True) +class _DashboardToolCall: + id: str + run_id: str + tool_type: str | None + tool_name: str | None + status: str | None + duration_ms: int | None + error_message: str | None + created_at: datetime | None + input_tokens: int + output_tokens: int + total_tokens: int + + +@dataclass(slots=True) +class _DashboardRun: + run_id: str + agent: str | None + source: str | None + user_id: str | None + status: str | None + started_at: datetime + tool_calls: list[_DashboardToolCall] = field(default_factory=list) + + class SystemDashboardService: def __init__(self, db: Session) -> None: self.db = db @@ -116,16 +144,73 @@ class SystemDashboardService: def _ensure_storage_ready(self) -> None: Base.metadata.create_all(bind=self.db.get_bind()) - def _fetch_runs(self, start: datetime, *, before: datetime | None = None) -> list[AgentRun]: + def _fetch_runs(self, start: datetime, *, before: datetime | None = None) -> list[_DashboardRun]: stmt = ( - select(AgentRun) - .options(selectinload(AgentRun.tool_calls)) + select( + AgentRun.run_id.label("run_id"), + AgentRun.agent.label("agent"), + AgentRun.source.label("source"), + AgentRun.user_id.label("user_id"), + AgentRun.status.label("run_status"), + AgentRun.started_at.label("started_at"), + AgentToolCall.id.label("tool_id"), + AgentToolCall.run_id.label("tool_run_id"), + AgentToolCall.tool_type.label("tool_type"), + AgentToolCall.tool_name.label("tool_name"), + AgentToolCall.status.label("tool_status"), + AgentToolCall.duration_ms.label("duration_ms"), + AgentToolCall.error_message.label("tool_error_message"), + AgentToolCall.created_at.label("tool_created_at"), + AgentToolCall.request_json["input_tokens"].as_integer().label("request_input_tokens"), + AgentToolCall.request_json["prompt_tokens"].as_integer().label("request_prompt_tokens"), + AgentToolCall.request_json["total_tokens"].as_integer().label("request_total_tokens"), + AgentToolCall.response_json["input_tokens"].as_integer().label("response_input_tokens"), + AgentToolCall.response_json["output_tokens"].as_integer().label("response_output_tokens"), + AgentToolCall.response_json["completion_tokens"].as_integer().label("response_completion_tokens"), + AgentToolCall.response_json["total_tokens"].as_integer().label("response_total_tokens"), + ) + .outerjoin(AgentToolCall, AgentToolCall.run_id == AgentRun.run_id) .where(AgentRun.started_at >= start) - .order_by(AgentRun.started_at.asc()) + .order_by(AgentRun.started_at.asc(), AgentToolCall.created_at.asc()) ) if before is not None: stmt = stmt.where(AgentRun.started_at < before) - return list(self.db.scalars(stmt).all()) + + runs: dict[str, _DashboardRun] = {} + for row in self.db.execute(stmt).all(): + run = runs.get(row.run_id) + if run is None: + run = _DashboardRun( + run_id=row.run_id, + agent=row.agent, + source=row.source, + user_id=row.user_id, + status=row.run_status, + started_at=row.started_at, + ) + runs[row.run_id] = run + + if row.tool_id is None: + continue + + input_tokens, output_tokens, total_tokens = self._token_counts_from_row(row) + run.tool_calls.append( + _DashboardToolCall( + id=row.tool_id, + run_id=row.tool_run_id or row.run_id, + tool_type=row.tool_type, + tool_name=row.tool_name, + status=row.tool_status, + duration_ms=row.duration_ms, + error_message=row.tool_error_message, + created_at=row.tool_created_at, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + ) + ) + + return list(runs.values()) def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]: stmt = ( @@ -143,7 +228,11 @@ class SystemDashboardService: ) return list(self.db.scalars(stmt).all()) - def _agent_daily_ratio(self, labels: list[str], tool_calls: list[AgentToolCall]) -> dict[str, Any]: + def _agent_daily_ratio( + self, + labels: list[str], + tool_calls: list[_DashboardToolCall], + ) -> dict[str, Any]: counts = {bucket["key"]: [0 for _ in labels] for bucket in TOOL_BUCKETS} label_index = {label: index for index, label in enumerate(labels)} for tool in tool_calls: @@ -231,7 +320,7 @@ class SystemDashboardService: for index, (user_id, value) in enumerate(rows) ] - def _accuracy_comparison(self, tool_calls: list[AgentToolCall]) -> dict[str, Any]: + def _accuracy_comparison(self, tool_calls: list[_DashboardToolCall]) -> dict[str, Any]: correct = {bucket["name"]: 0 for bucket in TOOL_BUCKETS} wrong = {bucket["name"]: 0 for bucket in TOOL_BUCKETS} for tool in tool_calls: @@ -297,7 +386,7 @@ class SystemDashboardService: def _tool_detail_rows( self, - tool_calls: list[AgentToolCall], + tool_calls: list[_DashboardToolCall], records: list[dict[str, Any]], ) -> list[dict[str, Any]]: token_by_tool = {str(record["tool_id"]): int(record["total"]) for record in records} @@ -331,14 +420,15 @@ class SystemDashboardService: ) return rows - def _build_token_records(self, runs: list[AgentRun]) -> list[dict[str, Any]]: + def _build_token_records(self, runs: list[_DashboardRun]) -> list[dict[str, Any]]: records: list[dict[str, Any]] = [] for run in runs: for tool in run.tool_calls: - input_tokens, output_tokens = self._extract_tool_tokens(tool) - total = input_tokens + output_tokens + input_tokens = int(tool.input_tokens or 0) + output_tokens = int(tool.output_tokens or 0) + total = int(tool.total_tokens or input_tokens + output_tokens) if total <= 0: - total = self._estimate_tool_tokens(tool) + total = self._estimate_tool_tokens(tool) if hasattr(tool, "request_json") else 0 input_tokens = int(total * 0.62) output_tokens = total - input_tokens records.append( @@ -353,6 +443,42 @@ class SystemDashboardService: ) return records + def _token_counts_from_row(self, row: Any) -> tuple[int, int, int]: + input_tokens = self._first_positive_int( + row.request_input_tokens, + row.request_prompt_tokens, + row.response_input_tokens, + ) + output_tokens = self._first_positive_int( + row.response_output_tokens, + row.response_completion_tokens, + ) + total_tokens = self._first_positive_int( + row.request_total_tokens, + row.response_total_tokens, + ) + + if total_tokens and not input_tokens and not output_tokens: + input_tokens = int(total_tokens * 0.62) + output_tokens = total_tokens - input_tokens + + if input_tokens + output_tokens <= 0 and total_tokens <= 0: + total_tokens = TOKEN_ESTIMATE_FALLBACK_TOTAL + input_tokens = int(total_tokens * 0.62) + output_tokens = total_tokens - input_tokens + + if total_tokens <= 0: + total_tokens = input_tokens + output_tokens + + return input_tokens, output_tokens, total_tokens + + @staticmethod + def _first_positive_int(*values: Any) -> int: + for value in values: + if isinstance(value, (int, float)) and value > 0: + return int(value) + return 0 + def _extract_tool_tokens(self, tool: AgentToolCall) -> tuple[int, int]: payload = { "request": tool.request_json or {}, @@ -392,7 +518,7 @@ class SystemDashboardService: return found return 0 - def _tool_bucket(self, tool: AgentToolCall) -> dict[str, Any]: + def _tool_bucket(self, tool: AgentToolCall | _DashboardToolCall) -> dict[str, Any]: text = f"{tool.tool_type or ''} {tool.tool_name or ''}".lower() if self._is_failed(tool.status) and ("timeout" in text or tool.error_message): return TOOL_BUCKETS[-1] diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 79053b2..ae70cff 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -24,6 +24,7 @@ from app.services.document_numbering import ( ) from app.services.user_agent_application_dates import ( expand_application_time_with_days, + resolve_application_date_range, resolve_application_days_from_time_range, ) from app.services.user_agent_application_locations import normalize_application_location @@ -1143,8 +1144,19 @@ class UserAgentApplicationMixin: facts: dict[str, str], occurred_at: datetime, ) -> bool: + current_range = resolve_application_date_range(facts.get("time", "")) current_time = cls._normalize_application_time_identity(facts.get("time")) existing_detail = cls._extract_application_detail_from_claim(claim) + existing_range = resolve_application_date_range(existing_detail.get("time")) + if existing_range is None and claim.occurred_at is not None: + existing_day = claim.occurred_at.date() + existing_range = (existing_day, existing_day) + if current_range is None and occurred_at is not None: + current_day = occurred_at.date() + current_range = (current_day, current_day) + if current_range is not None and existing_range is not None: + return current_range[0] <= existing_range[1] and existing_range[0] <= current_range[1] + existing_time = cls._normalize_application_time_identity(existing_detail.get("time")) if current_time and existing_time: return current_time == existing_time diff --git a/server/src/app/services/user_agent_application_dates.py b/server/src/app/services/user_agent_application_dates.py index 5f44169..66165bf 100644 --- a/server/src/app/services/user_agent_application_dates.py +++ b/server/src/app/services/user_agent_application_dates.py @@ -45,7 +45,7 @@ def resolve_application_days_count(days_text: str) -> int: def resolve_application_days_from_time_range(time_text: str) -> int: matches = re.findall( - r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", + r"20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?", str(time_text or ""), ) if len(matches) < 2: @@ -57,10 +57,29 @@ def resolve_application_days_from_time_range(time_text: str) -> int: return (end_date - start_date).days + 1 +def resolve_application_date_range(time_text: str) -> tuple[date, date] | None: + matches = re.findall( + r"20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?", + str(time_text or ""), + ) + dates = [ + parsed + for parsed in (_parse_application_date(value) for value in matches) + if parsed is not None + ] + if not dates: + return None + start_date = dates[0] + end_date = dates[-1] if len(dates) > 1 else start_date + if end_date < start_date: + start_date, end_date = end_date, start_date + return start_date, end_date + + def _resolve_start_date(time_text: str, context_json: dict[str, Any]) -> date | None: if time_text: match = re.search( - r"(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", + r"(?P20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?)", time_text, ) if match: diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index c99dad2..2b10b59 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -36,10 +36,13 @@ from app.services import agent_foundation as agent_foundation_module from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + COMPANY_PREAPPROVAL_RULE_CODE, + COMPANY_PREAPPROVAL_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, ) +from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON from app.services.agent_assets import AgentAssetService from app.services.agent_runs import AgentRunService from app.services.audit import AuditLogService @@ -62,6 +65,7 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None: for file_name in ( COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + COMPANY_PREAPPROVAL_RULE_FILENAME, ): source_path = real_finance_rules / file_name if source_path.exists(): @@ -181,8 +185,10 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None: communication_rule = next( item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE ) + preapproval_rule = next(item for item in rules if item.code == COMPANY_PREAPPROVAL_RULE_CODE) travel_config = travel_rule.config_json or {} communication_config = communication_rule.config_json or {} + preapproval_config = preapproval_rule.config_json or {} assert travel_rule.scenario_json == ["差旅费"] assert travel_config["scenario_category"] == "差旅费" @@ -190,6 +196,12 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None: assert communication_rule.scenario_json == ["通信费"] assert communication_config["scenario_category"] == "通信费" assert communication_config["ai_review_category"] == "通信费" + assert preapproval_rule.scenario_json == list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON) + assert preapproval_config["tag"] == "财务规则" + assert preapproval_config["finance_rule_code"] == "expense.preapproval.policy" + assert preapproval_config["finance_rule_sheet"] == "费用申请审批规则" + assert preapproval_config["expense_types"] == ["meal", "entertainment", "office", "all"] + assert preapproval_config["rule_document"]["file_name"] == COMPANY_PREAPPROVAL_RULE_FILENAME def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None: diff --git a/server/tests/test_agent_runs_service.py b/server/tests/test_agent_runs_service.py index 857035e..61135b9 100644 --- a/server/tests/test_agent_runs_service.py +++ b/server/tests/test_agent_runs_service.py @@ -106,6 +106,68 @@ def test_agent_run_service_updates_existing_tool_call() -> None: assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"} +def test_agent_run_list_uses_lightweight_preview_and_detail_keeps_full_payload() -> None: + with build_session() as db: + service = AgentRunService(db) + run = service.create_run( + agent=AgentName.HERMES.value, + source=AgentRunSource.SCHEDULE.value, + status=AgentRunStatus.SUCCEEDED.value, + ontology_json={ + "scenario": "knowledge", + "intent": "sync", + "parse_strategy": "rule_fallback", + "model_invocation_summary": {"tokens": 999}, + }, + route_json={ + "job_type": "knowledge_index_sync", + "phase": "indexing", + "progress": { + "percent": 50, + "total_documents": 2, + "completed_documents": 1, + "documents": [{"id": "doc-1", "text": "x" * 2000}], + }, + "knowledge_ingest": {"documents": [{"id": "doc-1", "text": "x" * 2000}]}, + }, + ) + service.record_tool_call( + run_id=run.run_id, + tool_type=AgentToolType.LLM.value, + tool_name="lightrag.index_documents", + request_json={"prompt": "x" * 2000}, + response_json={"documents": [{"id": "doc-1", "text": "x" * 2000}]}, + status="succeeded", + duration_ms=123, + ) + + listed = next(item for item in service.list_runs(limit=20) if item.run_id == run.run_id) + detail = service.get_run(run.run_id) + + assert listed.ontology_json == { + "scenario": "knowledge", + "intent": "sync", + "parse_strategy": "rule_fallback", + } + assert listed.route_json["job_type"] == "knowledge_index_sync" + assert listed.route_json["phase"] == "indexing" + assert listed.route_json["progress"] == { + "percent": 50, + "total_documents": 2, + "completed_documents": 1, + } + assert "knowledge_ingest" not in listed.route_json + assert len(listed.tool_calls) == 1 + assert listed.tool_calls[0].tool_name == "lightrag.index_documents" + assert listed.tool_calls[0].request_json == {} + assert listed.tool_calls[0].response_json == {} + + assert detail is not None + assert "knowledge_ingest" in detail.route_json + assert detail.tool_calls[0].request_json["prompt"] + assert detail.tool_calls[0].response_json["documents"] + + def test_agent_run_service_summarizes_model_and_tool_failures() -> None: with build_session() as db: service = AgentRunService(db) diff --git a/server/tests/test_expense_claim_approval_routing.py b/server/tests/test_expense_claim_approval_routing.py index 88e1a4f..912daa6 100644 --- a/server/tests/test_expense_claim_approval_routing.py +++ b/server/tests/test_expense_claim_approval_routing.py @@ -16,7 +16,7 @@ from app.models.financial_record import ExpenseClaim from app.models.organization import OrganizationUnit from app.models.role import Role from app.services.expense_claim_workflow_constants import ( - APPROVAL_DONE_STAGE, + APPLICATION_LINK_STATUS_STAGE, BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE, @@ -147,7 +147,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None assert approved is not None assert approved.status == "approved" - assert approved.approval_stage == APPROVAL_DONE_STAGE + assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 assert any( isinstance(flag, dict) @@ -160,7 +160,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None assert any( isinstance(flag, dict) and flag.get("source") == "manual_approval" - and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE + and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE and flag.get("route_decision", {}).get("requires_budget_review") is False for flag in approved.risk_flags_json ) @@ -218,7 +218,7 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud assert approved is not None assert approved.status == "approved" - assert approved.approval_stage == APPROVAL_DONE_STAGE + assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE assert any( isinstance(flag, dict) and flag.get("source") == "approval_routing" @@ -285,7 +285,7 @@ def test_application_route_ignores_reimbursement_stage_current_risks() -> None: assert approved is not None assert approved.status == "approved" - assert approved.approval_stage == APPROVAL_DONE_STAGE + assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE route_flag = [ flag for flag in approved.risk_flags_json diff --git a/server/tests/test_expense_claim_platform_risk_stage.py b/server/tests/test_expense_claim_platform_risk_stage.py index ad7057d..c09d1b3 100644 --- a/server/tests/test_expense_claim_platform_risk_stage.py +++ b/server/tests/test_expense_claim_platform_risk_stage.py @@ -319,6 +319,44 @@ def test_expense_application_pre_review_runs_stage_rules(tmp_path, monkeypatch) assert ai_pre_review["business_stage"] == "expense_application" +def test_preapproval_amount_rules_run_from_rule_library() -> None: + with build_session() as db: + claim = _build_claim(claim_no="RE-PREAPPROVAL-MEAL", expense_type="meal") + claim.amount = Decimal("501.00") + + flags = ExpenseClaimService(db).evaluate_platform_risk_rules( + claim, + business_stage="reimbursement", + )["flags"] + + meal_flags = [ + flag + for flag in flags + if isinstance(flag, dict) + and flag.get("rule_code") == "risk.application.meal_high_value_without_preapproval" + ] + assert len(meal_flags) == 1 + assert meal_flags[0]["finance_rule_code"] == "expense.preapproval.policy" + assert "500" in meal_flags[0]["message"] + + claim.risk_flags_json = [ + { + "source": "application_link", + "application_claim_id": "application-preapproval-ok", + "application_claim_no": "AP-202606-OK", + } + ] + flags = ExpenseClaimService(db).evaluate_platform_risk_rules( + claim, + business_stage="reimbursement", + )["flags"] + assert all( + flag.get("rule_code") != "risk.application.meal_high_value_without_preapproval" + for flag in flags + if isinstance(flag, dict) + ) + + def test_reimbursement_item_sync_persists_rule_center_risk_preview( tmp_path, monkeypatch, diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 091395a..017dd1e 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.api.deps import CurrentUserContext +from app.core.config import get_settings from app.db.base import Base from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee import Employee @@ -31,11 +32,14 @@ from app.services.expense_claim_attachment_storage import ExpenseClaimAttachment from app.services.expense_claims import ExpenseClaimService from app.services.expense_claim_workflow_constants import ( APPROVAL_DONE_STAGE, + APPLICATION_ARCHIVE_STAGE, + APPLICATION_LINK_STATUS_STAGE, BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, ) from app.services.ontology import SemanticOntologyService from app.services.ocr import OcrService +from app.services.receipt_folder import ReceiptFolderService def build_claim(*, expense_type: str, location: str) -> ExpenseClaim: @@ -3907,6 +3911,23 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() -> approval_stage="审批完成", risk_flags_json=[], ), + ExpenseClaim( + claim_no="AP-20260525121000-ARCHIVED", + employee_name="戊", + department_name="E部", + project_code="PRJ-E", + expense_type="travel_application", + reason="E 申请", + location="广州", + amount=Decimal("600.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC), + status="approved", + approval_stage=APPLICATION_ARCHIVE_STAGE, + risk_flags_json=[], + ), ExpenseClaim( claim_no="AP-20260525123000-HGFEDCBA", employee_name="丁", @@ -3933,7 +3954,7 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() -> assert {claim.claim_no for claim in claims} == { "EXP-ARCH-101", "EXP-ARCH-PAID", - "AP-20260525120000-ABCDEFGH", + "AP-20260525121000-ARCHIVED", } @@ -4288,6 +4309,65 @@ def test_admin_can_delete_archived_claim() -> None: assert db.get(ExpenseClaim, claim_id) is None +def test_admin_delete_claim_unlinks_receipt_folder_items(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + try: + receipt_owner = CurrentUserContext( + username="emp-1", + name="Employee", + role_codes=[], + is_admin=False, + ) + admin_user = CurrentUserContext( + username="superadmin", + name="Admin", + role_codes=["manager"], + is_admin=True, + ) + + with build_session() as db: + claim = build_claim(expense_type="travel", location="Shanghai") + db.add(claim) + db.commit() + claim_id = claim.id + claim_no = claim.claim_no + item_id = claim.items[0].id + + receipt_service = ReceiptFolderService() + receipt = receipt_service.save_receipt( + filename="admin-delete-linked-receipt.pdf", + content=b"%PDF-1.4 linked", + media_type="application/pdf", + current_user=receipt_owner, + linked_claim_id=claim_id, + linked_claim_no=claim_no, + linked_item_id=item_id, + document=OcrRecognizeDocumentRead( + filename="admin-delete-linked-receipt.pdf", + media_type="application/pdf", + text="invoice number 123 amount 100", + document_type="vat_invoice", + document_type_label="invoice", + scene_code="other", + scene_label="receipt", + ), + ) + assert receipt.status == "linked" + + deleted = ExpenseClaimService(db).delete_claim(claim_id, admin_user) + + assert deleted is not None + assert db.get(ExpenseClaim, claim_id) is None + unlinked_receipt = receipt_service.get_receipt(receipt.id, receipt_owner) + assert unlinked_receipt.status == "unlinked" + assert unlinked_receipt.linked_claim_id == "" + assert unlinked_receipt.linked_claim_no == "" + assert unlinked_receipt.linked_at is None + finally: + get_settings.cache_clear() + + def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None: current_user = CurrentUserContext( username="manager-return@example.com", @@ -4842,7 +4922,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg assert approved is not None assert approved.status == "approved" - assert approved.approval_stage == "审批完成" + assert approved.approval_stage == "关联单据状态" archived_claims = ExpenseClaimService(db).list_archived_claims( CurrentUserContext( username="finance-archive@example.com", @@ -4851,7 +4931,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg is_admin=False, ) ) - assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims) + assert all(claim.claim_no != "APP-20260525-APPROVE" for claim in archived_claims) generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() assert generated_draft.status == "draft" assert generated_draft.approval_stage == "待提交" @@ -4891,7 +4971,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg and flag.get("opinion") == "预算额度可承接,同意。" and flag.get("previous_approval_stage") == "预算管理者审批" and flag.get("next_status") == "approved" - and flag.get("next_approval_stage") == "审批完成" + and flag.get("next_approval_stage") == "关联单据状态" and flag.get("generated_draft_claim_id") == generated_draft.id and flag.get("generated_draft_claim_no") == generated_draft.claim_no for flag in approved.risk_flags_json @@ -5002,7 +5082,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N assert approved is not None assert approved.status == "approved" - assert approved.approval_stage == APPROVAL_DONE_STAGE + assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None: @@ -5147,7 +5227,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud assert approved is not None assert approved.status == "approved" - assert approved.approval_stage == APPROVAL_DONE_STAGE + assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 assert not any( isinstance(flag, dict) @@ -5158,7 +5238,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud isinstance(flag, dict) and flag.get("source") == "manual_approval" and flag.get("next_status") == "approved" - and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE + and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE and flag.get("budget_approval_merged") is True and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" for flag in approved.risk_flags_json @@ -5235,7 +5315,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli assert approved is not None assert approved.status == "approved" - assert approved.approval_stage == "审批完成" + assert approved.approval_stage == "关联单据状态" assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 assert not any( isinstance(flag, dict) @@ -5250,7 +5330,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli and flag.get("opinion") == "业务必要且预算可承接,同意申请。" and flag.get("previous_approval_stage") == "直属领导审批" and flag.get("next_status") == "approved" - and flag.get("next_approval_stage") == "审批完成" + and flag.get("next_approval_stage") == "关联单据状态" and flag.get("budget_approval_merged") is True and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" for flag in approved.risk_flags_json @@ -5819,6 +5899,94 @@ def test_finance_can_mark_pending_payment_claim_as_paid() -> None: ) +def test_marking_linked_reimbursement_paid_archives_application_claim() -> None: + current_user = CurrentUserContext( + username="finance-pay-linked-application@example.com", + name="财务付款", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + application_claim = ExpenseClaim( + claim_no="AP-202606050001-ARCHIVE", + employee_name="张三", + department_name="交付部", + project_code="PRJ-APP", + expense_type="travel_application", + reason="支撑国网部署", + location="上海", + amount=Decimal("3000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 6, 5, 10, 0, tzinfo=UTC), + status="approved", + approval_stage=APPROVAL_DONE_STAGE, + risk_flags_json=[], + ) + db.add(application_claim) + db.flush() + + reimbursement_claim = ExpenseClaim( + claim_no="RE-202606050001-ARCHIVE", + employee_name="张三", + department_name="交付部", + project_code="PRJ-APP", + expense_type="travel", + reason="支撑国网部署报销", + location="上海", + amount=Decimal("3000.00"), + currency="CNY", + invoice_count=2, + occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 6, 6, 10, 0, tzinfo=UTC), + status="pending_payment", + approval_stage="待付款", + risk_flags_json=[ + { + "source": "application_handoff", + "event_type": "expense_application_to_reimbursement_draft", + "application_claim_id": application_claim.id, + "application_claim_no": application_claim.claim_no, + } + ], + ) + db.add(reimbursement_claim) + db.commit() + + archived_before = ExpenseClaimService(db).list_archived_claims(current_user) + assert all(claim.claim_no != application_claim.claim_no for claim in archived_before) + + paid = ExpenseClaimService(db).mark_claim_paid(reimbursement_claim.id, current_user) + + assert paid is not None + db.refresh(application_claim) + assert application_claim.status == "approved" + assert application_claim.approval_stage == APPLICATION_ARCHIVE_STAGE + assert any( + isinstance(flag, dict) + and flag.get("source") == "application_archive_sync" + and flag.get("event_type") == "expense_application_archived_by_reimbursement" + and flag.get("reimbursement_claim_no") == reimbursement_claim.claim_no + and flag.get("next_approval_stage") == APPLICATION_ARCHIVE_STAGE + for flag in application_claim.risk_flags_json + ) + assert any( + isinstance(flag, dict) + and flag.get("source") == "payment" + and any( + item.get("application_claim_no") == application_claim.claim_no + for item in flag.get("archived_application_claims", []) + if isinstance(item, dict) + ) + for flag in paid.risk_flags_json + ) + + archived_after = ExpenseClaimService(db).list_archived_claims(current_user) + assert any(claim.claim_no == application_claim.claim_no for claim in archived_after) + + def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None: current_user = CurrentUserContext( username="finance-returned@example.com", diff --git a/server/tests/test_expense_claim_status_registry.py b/server/tests/test_expense_claim_status_registry.py index ebcc6b2..04ac577 100644 --- a/server/tests/test_expense_claim_status_registry.py +++ b/server/tests/test_expense_claim_status_registry.py @@ -3,7 +3,8 @@ from app.services.expense_claim_status_registry import ( normalize_expense_claim_state, ) from app.services.expense_claim_workflow_constants import ( - APPROVAL_DONE_STAGE, + APPLICATION_ARCHIVE_STAGE, + APPLICATION_LINK_STATUS_STAGE, ARCHIVE_ACCOUNTING_STAGE, FINANCE_APPROVAL_STAGE, PAYMENT_PAID_STAGE, @@ -40,7 +41,19 @@ def test_normalize_reimbursement_archive_stage_differs_from_application_done() - ) assert reimbursement_state.approval_stage == ARCHIVE_ACCOUNTING_STAGE - assert application_state.approval_stage == APPROVAL_DONE_STAGE + assert application_state.approval_stage == APPLICATION_LINK_STATUS_STAGE + + +def test_normalize_application_archive_stage_is_distinct_from_approval_done() -> None: + state = normalize_expense_claim_state( + "approved", + APPLICATION_ARCHIVE_STAGE, + claim_no="AP-20260602-0002", + expense_type="travel_application", + ) + + assert state.status == "approved" + assert state.approval_stage == APPLICATION_ARCHIVE_STAGE def test_normalize_payment_stages_by_status() -> None: diff --git a/server/tests/test_notification_states.py b/server/tests/test_notification_states.py index ab63864..39d525c 100644 --- a/server/tests/test_notification_states.py +++ b/server/tests/test_notification_states.py @@ -117,3 +117,28 @@ def test_notification_state_endpoint_reads_and_updates_current_user_state() -> N assert payload["states"][0]["hidden_at"] is None assert payload["states"][0]["context_json"]["kind"] == "workbench" assert other_response.json()["states"] == [] + + +def test_notification_state_endpoint_accepts_document_center_bulk_read_state() -> None: + client = build_client() + headers = {"x-auth-username": "alice", "x-auth-name": "Alice"} + states = [ + { + "notification_id": f"document:owned:DOC-{index}", + "read": True, + "hidden": False, + "context_json": {"kind": "document", "target_type": "documents-center"}, + } + for index in range(150) + ] + + response = client.post( + "/api/v1/notification-states", + json={"states": states}, + headers=headers, + ) + + assert response.status_code == 200 + payload = response.json() + assert len(payload["states"]) == 150 + assert all(item["read_at"] for item in payload["states"]) diff --git a/server/tests/test_ocr_service.py b/server/tests/test_ocr_service.py index 0717b8d..e783e08 100644 --- a/server/tests/test_ocr_service.py +++ b/server/tests/test_ocr_service.py @@ -179,6 +179,64 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview( assert recognized.lines[1].page_index == 1 +def test_ocr_service_reuses_cached_document_for_same_content( + monkeypatch, + tmp_path: Path, +) -> None: + calls = {"count": 0} + + def fake_invoke_worker( + self, + *, + python_bin: str, + worker_path: str, + input_paths: list[Path], + ) -> dict: + calls["count"] += 1 + return { + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "documents": [ + { + "input_path": str(input_paths[0]), + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "text": "增值税电子发票 金额 20 元", + "summary": "增值税电子发票,金额 20 元。", + "avg_score": 0.97, + "line_count": 1, + "page_count": 1, + "warnings": [], + "lines": [ + { + "text": "增值税电子发票 金额 20 元", + "score": 0.97, + "box": [[1, 2], [10, 2], [10, 8], [1, 8]], + } + ], + } + ], + } + + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python") + monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py") + monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker) + OcrService._result_cache.clear() + get_settings.cache_clear() + try: + first = OcrService().recognize_files([("first.png", b"same-image", "image/png")]) + second = OcrService().recognize_files([("second.png", b"same-image", "image/png")]) + finally: + OcrService._result_cache.clear() + get_settings.cache_clear() + + assert calls["count"] == 1 + assert first.documents[0].filename == "first.png" + assert second.documents[0].filename == "second.png" + assert second.documents[0].summary == first.documents[0].summary + + def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy( monkeypatch, tmp_path: Path, diff --git a/server/tests/test_receipt_folder_service.py b/server/tests/test_receipt_folder_service.py index c45537a..cd34f8b 100644 --- a/server/tests/test_receipt_folder_service.py +++ b/server/tests/test_receipt_folder_service.py @@ -1,7 +1,5 @@ from __future__ import annotations -import pytest - from app.api.deps import CurrentUserContext from app.core.config import get_settings from app.schemas.ocr import OcrRecognizeDocumentRead @@ -71,7 +69,7 @@ def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monke get_settings.cache_clear() -def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkeypatch, tmp_path) -> None: +def test_receipt_folder_unlink_receipts_for_claim_marks_linked_receipts_unlinked(monkeypatch, tmp_path) -> None: monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) get_settings.cache_clear() try: @@ -101,9 +99,17 @@ def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkey ), ) - assert service.get_receipt(receipt.id, current_user).linked_claim_id == "claim-1" - assert service.delete_receipts_for_claim("claim-1") == 1 - with pytest.raises(FileNotFoundError): - service.get_receipt(receipt.id, current_user) + linked_detail = service.get_receipt(receipt.id, current_user) + assert linked_detail.status == "linked" + assert linked_detail.linked_claim_id == "claim-1" + assert linked_detail.linked_claim_no == "RE-001" + + assert service.unlink_receipts_for_claim("claim-1") == 1 + + unlinked_detail = service.get_receipt(receipt.id, current_user) + assert unlinked_detail.status == "unlinked" + assert unlinked_detail.linked_claim_id == "" + assert unlinked_detail.linked_claim_no == "" + assert unlinked_detail.linked_at is None finally: get_settings.cache_clear() diff --git a/server/tests/test_risk_rule_dsl_examples.py b/server/tests/test_risk_rule_dsl_examples.py index 9b1d8b2..d5559bb 100644 --- a/server/tests/test_risk_rule_dsl_examples.py +++ b/server/tests/test_risk_rule_dsl_examples.py @@ -1,10 +1,13 @@ from __future__ import annotations +import json from datetime import UTC, date, datetime from decimal import Decimal +from pathlib import Path import pytest +from app.core.config import SERVER_DIR from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.services.risk_rule_dsl_examples import ( get_risk_rule_dsl_example, @@ -166,6 +169,95 @@ def test_date_rule_uses_application_month_before_ticket_item_date() -> None: assert condition["outside_dates"] == ["2026-02-20"] +def test_application_context_values_are_available_to_composite_rules() -> None: + claim = _claim(amount=Decimal("3000.00")) + claim.risk_flags_json = [ + { + "source": "application_link", + "application_claim_id": "application-ctx-1", + "application_claim_no": "AP-202606-CTX", + "application_detail": { + "application_amount": "3000", + "application_expense_type": "office", + }, + } + ] + manifest = { + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "params": { + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "conditions": [ + { + "id": "application_present", + "operator": "exists_any", + "fields": ["application.id", "application.claim_no"], + } + ], + "hit_logic": "application_present", + "condition_summary": "application exists", + }, + } + + result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[]) + + assert result is not None + condition = result["evidence"]["conditions"][0] + assert condition["values"] == ["application-ctx-1", "AP-202606-CTX"] + + +@pytest.mark.parametrize( + ("file_name", "expense_type", "amount"), + [ + ("risk.application.meal_high_value_without_preapproval.json", "meal", Decimal("501.00")), + ("risk.application.office_bulk_without_purchase.json", "office", Decimal("2001.00")), + ("risk.application.large_expense_without_preapproval.json", "software", Decimal("2001.00")), + ], +) +def test_preapproval_amount_rules_hit_without_linked_application( + file_name: str, + expense_type: str, + amount: Decimal, +) -> None: + claim = _claim(amount=amount) + claim.expense_type = expense_type + manifest = _load_rule_manifest(file_name) + + result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[]) + + assert result is not None + assert result["evidence"]["condition_results"]["amount_exceeds_preapproval_threshold"] is True + assert result["evidence"]["condition_results"]["application_present"] is False + + +@pytest.mark.parametrize( + ("file_name", "expense_type", "amount"), + [ + ("risk.application.meal_high_value_without_preapproval.json", "entertainment", Decimal("800.00")), + ("risk.application.office_bulk_without_purchase.json", "office", Decimal("2600.00")), + ("risk.application.large_expense_without_preapproval.json", "software", Decimal("2600.00")), + ], +) +def test_preapproval_amount_rules_skip_when_application_is_linked( + file_name: str, + expense_type: str, + amount: Decimal, +) -> None: + claim = _claim(amount=amount) + claim.expense_type = expense_type + claim.risk_flags_json = [ + { + "source": "application_link", + "application_claim_id": "application-linked-ok", + "application_claim_no": "AP-202606-OK", + } + ] + manifest = _load_rule_manifest(file_name) + + result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[]) + + assert result is None + + def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim: claim = ExpenseClaim( claim_no="TEST-RISK-RULE-DSL", @@ -193,3 +285,8 @@ def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim: ) ] return claim + + +def _load_rule_manifest(file_name: str) -> dict: + path = Path(SERVER_DIR) / "rules" / "risk-rules" / file_name + return json.loads(path.read_text(encoding="utf-8")) diff --git a/server/tests/test_steward_planner.py b/server/tests/test_steward_planner.py index 315d61f..f6ff29d 100644 --- a/server/tests/test_steward_planner.py +++ b/server/tests/test_steward_planner.py @@ -93,6 +93,38 @@ class EntertainmentFunctionCallingIntentAgent: ) +class ApplicationFunctionCallingIntentAgent: + def detect(self, request, *, base_date, canonical_fields): + return StewardIntentAgentResult( + payload={ + "thinking_events": [ + { + "stage": "task_split", + "title": "识别出差申请", + "content": "模型识别到用户要发起北京出差申请,并且后续还有报销事项。", + } + ], + "tasks": [ + { + "task_type": "expense_application", + "title": "北京出差申请", + "summary": "明天前往北京出差3天,支撑国网仿生产部署。", + "confidence": 0.94, + "ontology_fields": { + "time_range": "明天", + "location": "北京", + "expense_type": "差旅", + "reason": "支撑国网仿生产部署", + }, + "missing_fields": [], + } + ], + "attachment_groups": [], + }, + model_call_traces=[], + ) + + def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None: payload = StewardPlanRequest( message="我要报销昨天客户现场沟通的交通费", @@ -136,6 +168,22 @@ def test_steward_planner_normalizes_llm_business_entertainment_expense_type() -> assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03" +def test_steward_planner_enforces_application_transport_gap_after_function_calling() -> None: + payload = StewardPlanRequest( + message="明天出差北京3天,支撑国网仿生产部署", + client_now_iso="2026-06-04T09:30:00+08:00", + ) + + result = StewardPlannerService(intent_agent=ApplicationFunctionCallingIntentAgent()).build_plan(payload) + + assert result.planning_source == "llm_function_call" + assert result.tasks[0].missing_fields == ["transport_mode"] + gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"] + assert gap_events + assert "没有说明出行方式" in gap_events[0].content + assert "火车、飞机或轮船" in gap_events[0].content + + def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None: payload = StewardPlanRequest( message="我要报销昨天的交通费", @@ -197,6 +245,10 @@ def test_steward_planner_treats_future_travel_without_apply_word_as_application( assert result.tasks[0].ontology_fields["location"] == "北京" assert result.tasks[0].ontology_fields["expense_type"] == "travel" assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署" + assert result.tasks[0].missing_fields == ["transport_mode"] + gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"] + assert gap_events + assert "没有说明出行方式" in gap_events[0].content assert result.tasks[1].assigned_agent == "reimbursement_assistant" assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03" assert result.tasks[1].ontology_fields["expense_type"] == "entertainment" diff --git a/server/tests/test_steward_runtime_decision_agent.py b/server/tests/test_steward_runtime_decision_agent.py new file mode 100644 index 0000000..f745531 --- /dev/null +++ b/server/tests/test_steward_runtime_decision_agent.py @@ -0,0 +1,96 @@ +from app.schemas.steward import StewardRuntimeDecisionRequest +from app.services.steward_runtime_decision_agent import ( + STEWARD_RUNTIME_DECISION_FUNCTION_NAME, + StewardRuntimeDecisionAgent, +) + + +class _FakeToolCall: + def __init__(self, name, arguments): + self.name = name + self.arguments = arguments + + +class _FakeRuntimeResult: + def __init__(self, tool_call=None): + self.tool_call = tool_call + + def calls_as_dicts(self): + return [{"tool": self.tool_call.name if self.tool_call else ""}] + + +class _FakeRuntime: + def __init__(self, payload): + self.payload = payload + self.last_messages = [] + self.last_tools = [] + self.last_tool_choice = None + + def complete_with_tool_call(self, messages, tools, tool_choice, **kwargs): + self.last_messages = messages + self.last_tools = tools + self.last_tool_choice = tool_choice + if self.payload is None: + return _FakeRuntimeResult() + return _FakeRuntimeResult(_FakeToolCall(STEWARD_RUNTIME_DECISION_FUNCTION_NAME, self.payload)) + + +def test_steward_runtime_decision_uses_function_calling_context(): + runtime = _FakeRuntime( + { + "next_action": "submit_current_application", + "target_task_id": "task-application-beijing", + "target_message_id": "msg-application-preview", + "field_key": "", + "field_value": "", + "confirmation_required": False, + "question": "", + "response_text": "", + "rationale": "用户确认当前申请核对表无误。", + } + ) + + result = StewardRuntimeDecisionAgent(runtime).decide( + StewardRuntimeDecisionRequest( + user_message="确认", + runtime_state={ + "waiting_for": "application_submit_confirmation", + "pending_application": { + "message_id": "msg-application-preview", + "task_id": "task-application-beijing", + "ready_to_submit": True, + }, + "remaining_tasks": [ + {"task_id": "task-reimbursement-meal", "task_type": "reimbursement"} + ], + }, + ) + ) + + assert result.decision_source == "llm_function_call" + assert result.next_action == "submit_current_application" + assert result.target_message_id == "msg-application-preview" + assert result.target_task_id == "task-application-beijing" + assert runtime.last_tool_choice["function"]["name"] == STEWARD_RUNTIME_DECISION_FUNCTION_NAME + assert "runtime_state" in runtime.last_messages[-1]["content"] + + +def test_steward_runtime_decision_fallback_keeps_current_context(): + runtime = _FakeRuntime(None) + + result = StewardRuntimeDecisionAgent(runtime).decide( + StewardRuntimeDecisionRequest( + user_message="确认", + runtime_state={ + "pending_steward_action": { + "message_id": "msg-next-task", + "target_task_id": "task-reimbursement-meal", + } + }, + ) + ) + + assert result.decision_source == "rule_fallback" + assert result.next_action == "continue_next_task" + assert result.target_message_id == "msg-next-task" + assert result.target_task_id == "task-reimbursement-meal" diff --git a/server/tests/test_steward_slot_decision_agent.py b/server/tests/test_steward_slot_decision_agent.py new file mode 100644 index 0000000..4c7e201 --- /dev/null +++ b/server/tests/test_steward_slot_decision_agent.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from app.schemas.steward import StewardSlotDecisionRequest +from app.services.runtime_chat import RuntimeChatCallTrace, RuntimeChatToolCall, RuntimeToolCallResult +from app.services.steward_slot_decision_agent import ( + STEWARD_SLOT_DECISION_FUNCTION_NAME, + StewardSlotDecisionAgent, +) + + +class FakeSlotRuntime: + def __init__(self, arguments=None): + self.arguments = arguments + self.messages = None + + def complete_with_tool_call(self, messages, **kwargs): + self.messages = messages + if self.arguments is None: + return RuntimeToolCallResult(tool_call=None, calls=[]) + return RuntimeToolCallResult( + tool_call=RuntimeChatToolCall( + name=STEWARD_SLOT_DECISION_FUNCTION_NAME, + arguments=self.arguments, + ), + calls=[ + RuntimeChatCallTrace( + slot="main", + provider="OpenAI Compatible", + model="fake", + attempt=1, + status="succeeded", + ) + ], + ) + + +def test_steward_slot_decision_uses_function_calling_result() -> None: + runtime = FakeSlotRuntime( + { + "next_action": "ask_user", + "required_fields": ["expense_type", "time_range", "location", "reason", "transport_mode"], + "missing_fields": ["transport_mode"], + "question": "请问你这次打算怎么出行?", + "options": [ + {"field_key": "transport_mode", "label": "飞机", "value": "飞机"}, + {"field_key": "transport_mode", "label": "火车", "value": "火车"}, + ], + "rationale": "出行方式会影响交通费用测算。", + } + ) + + result = StewardSlotDecisionAgent(runtime).decide( + StewardSlotDecisionRequest( + task_type="expense_application", + user_message="明天出差北京3天,支撑国网仿生产部署", + ontology_fields={ + "expense_type": "travel", + "time_range": "2026-06-05 至 2026-06-07", + "location": "北京", + "reason": "支撑国网仿生产部署", + }, + missing_fields=["transport_mode"], + ) + ) + + assert result.decision_source == "llm_function_call" + assert result.next_action == "ask_user" + assert result.missing_fields == ["transport_mode"] + assert [item.value for item in result.options] == ["飞机", "火车"] + assert "出行方式会影响" in result.rationale + + +def test_steward_slot_decision_falls_back_to_intent_missing_fields_only() -> None: + runtime = FakeSlotRuntime(arguments=None) + + result = StewardSlotDecisionAgent(runtime).decide( + StewardSlotDecisionRequest( + task_type="expense_application", + user_message="还需要补充:出行方式(例如高铁、飞机、自驾、出租车)", + ontology_fields={ + "expense_type": "travel", + "location": "北京", + "reason": "支撑国网仿生产部署", + }, + missing_fields=["transport_mode"], + ) + ) + + assert result.decision_source == "rule_fallback" + assert result.next_action == "ask_user" + assert result.missing_fields == ["transport_mode"] + assert [item.value for item in result.options] == ["火车", "飞机", "轮船"] + assert "高铁" not in result.required_fields + + +def test_steward_slot_decision_does_not_ask_user_for_application_profile_or_attachments() -> None: + runtime = FakeSlotRuntime( + { + "next_action": "ask_user", + "required_fields": [ + "expense_type", + "time_range", + "location", + "reason", + "amount", + "attachments", + "employee_no", + ], + "missing_fields": ["attachments", "employee_no"], + "question": "请补充附件和员工编号。", + "options": [], + "rationale": "附件/凭证和员工编号为合规必需字段。", + } + ) + + result = StewardSlotDecisionAgent(runtime).decide( + StewardSlotDecisionRequest( + task_type="expense_application", + user_message="明天出差北京3天,支撑国网仿生产部署", + ontology_fields={ + "expense_type": "travel", + "time_range": "2026-06-05 至 2026-06-07", + "location": "北京", + "reason": "支撑国网仿生产部署", + }, + missing_fields=["attachments", "employee_no"], + ) + ) + + assert result.decision_source == "llm_function_call" + assert result.next_action == "render_preview" + assert result.missing_fields == [] + assert "attachments" not in result.required_fields + assert "employee_no" not in result.required_fields + assert result.options == [] + assert "合规必需字段" not in result.rationale diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index a2db0e1..1332600 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -693,6 +693,66 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None: assert second_response.draft_payload is None +def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None: + session_factory = build_session_factory() + with session_factory() as db: + existing_claim = ExpenseClaim( + id="application-overlap-1", + claim_no="AP-202606050001-OVERLAP", + employee_name="pytest", + department_name="技术部", + expense_type="travel_application", + reason="支撑国网部署", + location="北京", + amount=Decimal("2700.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 6, 5, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[ + { + "source": "application_detail", + "business_stage": "expense_application", + "application_detail": { + "application_type": "差旅费用申请", + "time": "2026-06-05 至 2026-06-07", + "location": "北京", + "reason": "支撑国网部署", + }, + } + ], + ) + db.add(existing_claim) + db.commit() + + response = build_application_user_agent_response( + db, + "确认提交", + context_overrides={ + "manager_name": "向万红", + "application_preview": { + "fields": { + "applicationType": "差旅费用申请", + "time": "2026-06-06 至 2026-06-08", + "location": "北京", + "reason": "支撑国网仿生产部署", + "days": "3天", + "transportMode": "火车", + "amount": "2700元", + } + }, + }, + ) + + claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all() + assert len(claims) == 1 + assert "已存在申请单" in response.answer + assert "系统没有重复创建" in response.answer + assert existing_claim.claim_no in response.answer + assert response.draft_payload is None + + def test_user_agent_application_edit_resubmits_returned_application_claim() -> None: session_factory = build_session_factory() with session_factory() as db: diff --git a/web/src/assets/images/cap-analysis.png b/web/src/assets/images/cap-analysis.png new file mode 100644 index 0000000..2f17392 Binary files /dev/null and b/web/src/assets/images/cap-analysis.png differ diff --git a/web/src/assets/images/cap-approval.png b/web/src/assets/images/cap-approval.png new file mode 100644 index 0000000..8be9714 Binary files /dev/null and b/web/src/assets/images/cap-approval.png differ diff --git a/web/src/assets/images/cap-budget.png b/web/src/assets/images/cap-budget.png new file mode 100644 index 0000000..d81549e Binary files /dev/null and b/web/src/assets/images/cap-budget.png differ diff --git a/web/src/assets/images/cap-expense.png b/web/src/assets/images/cap-expense.png new file mode 100644 index 0000000..3fce6ac Binary files /dev/null and b/web/src/assets/images/cap-expense.png differ diff --git a/web/src/assets/images/cap-policy.png b/web/src/assets/images/cap-policy.png new file mode 100644 index 0000000..15d7872 Binary files /dev/null and b/web/src/assets/images/cap-policy.png differ diff --git a/web/src/assets/images/cap-reimb.png b/web/src/assets/images/cap-reimb.png new file mode 100644 index 0000000..f516eeb Binary files /dev/null and b/web/src/assets/images/cap-reimb.png differ diff --git a/web/src/assets/images/exp-cart.png b/web/src/assets/images/exp-cart.png new file mode 100644 index 0000000..ace489e Binary files /dev/null and b/web/src/assets/images/exp-cart.png differ diff --git a/web/src/assets/images/exp-dining.png b/web/src/assets/images/exp-dining.png new file mode 100644 index 0000000..f2d7e75 Binary files /dev/null and b/web/src/assets/images/exp-dining.png differ diff --git a/web/src/assets/images/exp-flight.png b/web/src/assets/images/exp-flight.png new file mode 100644 index 0000000..46dc7ac Binary files /dev/null and b/web/src/assets/images/exp-flight.png differ diff --git a/web/src/assets/images/exp-meeting.png b/web/src/assets/images/exp-meeting.png new file mode 100644 index 0000000..4f7c73e Binary files /dev/null and b/web/src/assets/images/exp-meeting.png differ diff --git a/web/src/assets/images/exp-megaphone.png b/web/src/assets/images/exp-megaphone.png new file mode 100644 index 0000000..471cddd Binary files /dev/null and b/web/src/assets/images/exp-megaphone.png differ diff --git a/web/src/assets/images/hero-financial-decor.svg b/web/src/assets/images/hero-financial-decor.svg index 66a2af7..dd65c4c 100644 --- a/web/src/assets/images/hero-financial-decor.svg +++ b/web/src/assets/images/hero-financial-decor.svg @@ -1,107 +1,142 @@ - - - + + + - - - + + + + - - - - - - - + + + - - - + + + + + - - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + - - - - - 支出分析 - - + + + + + + + - - - - - - + + + 支出分析 + + + + + + + 72% + + + + + + + + + + + + + + + + - + - - - - + + + + + + + - - 费用趋势 + + 费用趋势 - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +14% + + + + + + + + + + + - - - - - - - - - - - - - - - - - diff --git a/web/src/assets/images/workbench-hero-right-bg.png b/web/src/assets/images/workbench-hero-right-bg.png new file mode 100644 index 0000000..4440448 Binary files /dev/null and b/web/src/assets/images/workbench-hero-right-bg.png differ diff --git a/web/src/assets/styles/components/digital-employee-work-records-overrides.css b/web/src/assets/styles/components/digital-employee-work-records-overrides.css new file mode 100644 index 0000000..b4e8a30 --- /dev/null +++ b/web/src/assets/styles/components/digital-employee-work-records-overrides.css @@ -0,0 +1,271 @@ +/* Current DigitalEmployeeWorkRecords scoped styles. Kept last to override retired rules above. */ +.digital-work-records { + height: 100%; +} + +.digital-employee-list-panel, +.digital-work-records-list-stage { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.digital-work-records-table { + min-width: 1180px; + table-layout: fixed; +} + +.digital-work-records-table .col-time { width: 14%; } +.digital-work-records-table .col-module { width: 13%; } +.digital-work-records-table .col-source { width: 10%; } +.digital-work-records-table .col-status { width: 16%; } +.digital-work-records-table .col-summary { width: 31%; } +.digital-work-records-table .col-trace { width: 16%; } + +.work-record-row { + outline: none; +} + +.work-record-row:focus-visible { + box-shadow: inset 0 0 0 2px rgba(58, 124, 165, 0.28); +} + +.work-record-summary-cell { + text-align: left !important; +} + +.work-record-summary-cell strong, +.work-record-summary-cell span, +.work-record-summary-cell em { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.work-record-summary-cell strong { + color: #0f172a; + font-size: 13px; + font-weight: 800; +} + +.work-record-summary-cell span { + margin-top: 4px; + color: #64748b; + font-size: 13px; + line-height: 1.5; +} + +.work-record-summary-cell em { + margin-top: 6px; + color: #94a3b8; + font-size: 12px; + font-style: normal; +} + +.work-record-trace-cell { + color: #2563eb !important; +} + +:deep(.work-record-detail-page) { + flex: 1 1 0; + min-height: 0; + padding: 16px 18px 0; + background: transparent; +} + +:deep(.work-record-detail-page .detail-scroll) { + min-height: 0; + display: block; + padding-right: 4px; + overflow: auto; +} + +:deep(.work-record-detail-page .detail-scroll) > * + * { + margin-top: 14px; +} + +:deep(.work-record-detail-page .detail-grid) { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 14px; + align-items: start; + width: min(100%, 1160px); + margin: 0 auto; +} + +:deep(.work-record-detail-page .detail-main), +:deep(.work-record-detail-page .detail-side) { + display: grid; + align-content: start; + gap: 14px; + min-height: 0; + min-width: 0; +} + +:deep(.work-record-detail-page .detail-actions) { + margin-top: 10px; + padding: 10px 0 0; +} + +:deep(.work-record-detail-page .enterprise-detail-card) { + min-height: 0; + padding: 14px; + overflow: hidden; + border: 1px solid #dbe4ee; + border-radius: 4px; + background: #fff; + box-shadow: none; +} + +:deep(.work-record-detail-page .enterprise-detail-card .card-head) { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +:deep(.work-record-detail-page .enterprise-detail-card .card-head h3) { + margin: 0; + color: #111827; + font-size: 15px; + line-height: 1.35; + font-weight: 850; +} + +:deep(.work-record-detail-page .enterprise-detail-card .card-head p) { + margin: 4px 0 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +:deep(.work-record-detail-page .edit-badge) { + min-height: 26px; + padding: 0 9px; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22); + border-radius: 4px; + background: var(--theme-primary-soft); + color: var(--theme-primary-active); + font-size: 12px; + font-weight: 800; +} + +:deep(.work-record-summary-card .json-risk-meta-grid) { + padding: 0; +} + +:deep(.work-record-detail-page .json-risk-meta-item), +:deep(.work-record-detail-page .json-risk-description-text) { + border-radius: 4px; +} + +.work-record-summary-text { + margin: 0; + color: #334155; + font-size: 13px; + line-height: 1.7; +} + +.work-record-error-text { + margin: 12px 0 0; + padding: 10px 12px; + border: 1px solid #fecaca; + border-radius: 4px; + background: #fef2f2; + color: #b91c1c; + font-size: 13px; + line-height: 1.6; +} + +.work-record-tool-list { + display: grid; + gap: 8px; +} + +.work-record-tool-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid #dbe4ee; + border-radius: 4px; + background: #f8fafc; + transition: border-color 160ms ease, background 160ms ease; +} + +.work-record-tool-item:hover { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.26); + background: #f9fbff; +} + +.work-record-tool-item strong { + min-width: 0; + overflow: hidden; + color: #0f172a; + font-size: 13px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.work-record-tool-item span { + color: #64748b; + font-size: 12px; + white-space: nowrap; +} + +.work-record-inline-empty { + min-height: 92px; + display: grid; + place-items: center; + padding: 14px; + border: 1px dashed #cbd5e1; + border-radius: 4px; + background: #f8fafc; + color: #94a3b8; + font-size: 13px; + line-height: 1.6; + text-align: center; +} + +.work-record-code-block { + max-height: 360px; + margin: 0; + padding: 12px; + overflow: auto; + border: 1px solid #dbe4ee; + border-radius: 4px; + background: #111827; + color: #e5e7eb; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 1.55; +} + +.digital-work-records :deep(.toolbar-actions .picker-filter), +.digital-work-records :deep(.toolbar-actions .picker-trigger) { + min-width: 148px; +} + +.digital-refresh-now { + width: 40px; + min-width: 40px; + padding: 0; +} + +.digital-refresh-now .mdi { + font-size: 18px; +} + +@media (max-width: 1180px) { + .work-record-tool-item { + grid-template-columns: 1fr; + } +} diff --git a/web/src/assets/styles/components/document-list-shared.css b/web/src/assets/styles/components/document-list-shared.css index 3c132a0..530e3d2 100644 --- a/web/src/assets/styles/components/document-list-shared.css +++ b/web/src/assets/styles/components/document-list-shared.css @@ -137,6 +137,171 @@ color: var(--theme-primary-active); } +.document-filter, +.date-range-filter { + position: relative; +} + +.document-filter-menu, +.date-range-popover { + position: absolute; + z-index: 40; + border: 1px solid #d7e0ea; + border-radius: 4px; + background: #fff; + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12); + overflow: hidden; +} + +.document-filter-menu { + top: calc(100% + 8px); + left: 0; + min-width: 150px; + max-height: 280px; + padding: 6px; + overflow-y: auto; +} + +.document-filter-menu button { + display: block; + width: 100%; + min-height: 36px; + padding: 0 12px; + border: 0; + border-radius: 4px; + background: transparent; + color: #334155; + font-size: 13px; + font-weight: 650; + text-align: left; + white-space: nowrap; +} + +.document-filter-menu button:hover, +.document-filter-menu button.active { + background: rgba(58, 124, 165, 0.1); + color: var(--theme-primary-active); +} + +.date-range-trigger { + min-width: 150px; +} + +.date-range-label { + max-width: 104px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.date-range-popover { + top: calc(100% + 8px); + left: 0; + width: 320px; + display: grid; + gap: 14px; + padding: 16px; +} + +.date-range-popover header, +.date-range-popover footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.date-range-popover header strong { + color: #0f172a; + font-size: 15px; +} + +.date-range-popover header button { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 0; + border-radius: 4px; + background: transparent; + color: #64748b; +} + +.date-range-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.date-range-fields label { + display: grid; + gap: 6px; +} + +.date-range-fields span { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.date-range-fields input { + width: 100%; + height: 38px; + padding: 0 9px; + border: 1px solid #d7e0ea; + border-radius: 4px; + color: #0f172a; + font-size: 13px; +} + +.ghost-btn, +.apply-btn { + height: 36px; + padding: 0 14px; + border-radius: 4px; + font-size: 13px; + font-weight: 750; +} + +.ghost-btn { + border: 1px solid #d7e0ea; + background: #fff; + color: #334155; +} + +.apply-btn { + border: 0; + background: var(--theme-primary); + color: #fff; +} + +.apply-btn:disabled { + cursor: not-allowed; + background: #cbd5e1; +} + +.document-status-filter { + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + min-height: 38px; +} + +.status-dropdown-filter, +.status-filter-trigger, +.status-filter-menu { + min-width: 154px; +} + +.status-filter-trigger > .mdi:first-child { + color: var(--theme-primary); +} + +.clear-filter-btn { + justify-content: center; +} + .create-request-btn { min-height: 40px; display: inline-flex; diff --git a/web/src/assets/styles/components/personal-workbench-composer-date.css b/web/src/assets/styles/components/personal-workbench-composer-date.css index 201902a..82553ef 100644 --- a/web/src/assets/styles/components/personal-workbench-composer-date.css +++ b/web/src/assets/styles/components/personal-workbench-composer-date.css @@ -61,7 +61,7 @@ position: absolute; top: calc(100% + 8px); left: 18px; - z-index: 60; + z-index: 120; width: min(320px, calc(100% - 36px)); max-width: calc(100vw - 32px); display: grid; diff --git a/web/src/assets/styles/components/personal-workbench-glass.css b/web/src/assets/styles/components/personal-workbench-glass.css index 4169a8f..a6e1f92 100644 --- a/web/src/assets/styles/components/personal-workbench-glass.css +++ b/web/src/assets/styles/components/personal-workbench-glass.css @@ -14,38 +14,22 @@ } .capability-card { + position: relative; isolation: isolate; - border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16); - border-left: 3px solid color-mix(in srgb, var(--capability-color) 42%, rgba(255, 255, 255, 0.72)); - background: - var(--workbench-glass-base), - linear-gradient(135deg, color-mix(in srgb, var(--capability-soft) 46%, transparent) 0%, transparent 52%, color-mix(in srgb, var(--capability-color) 11%, transparent) 100%), - var(--workbench-glass-theme-tint); - background-color: rgba(255, 255, 255, 0.64); - box-shadow: - 0 10px 28px rgba(15, 23, 42, 0.055), - inset 0 1px 0 rgba(255, 255, 255, 0.84), - inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08); - backdrop-filter: var(--workbench-glass-blur); - -webkit-backdrop-filter: var(--workbench-glass-blur); + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(12px) saturate(150%); + -webkit-backdrop-filter: blur(12px) saturate(150%); + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28); + border-left: 3px solid color-mix(in srgb, var(--capability-color) 60%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.8)); + box-shadow: + 0 12px 28px rgba(15, 23, 42, 0.04), + inset 0 1px 0 rgba(255, 255, 255, 0.96); } .capability-card::before, .capability-card::after, .workbench-card::before, .workbench-card::after { - content: ""; - position: absolute; - inset: 0; - z-index: 0; - display: block; - pointer-events: none; -} - -.capability-card::before { - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.12), transparent 38%), - var(--workbench-capability-bg-image) 0 0 / var(--workbench-capability-tile-size) repeat; mix-blend-mode: soft-light; opacity: var(--workbench-glass-noise-opacity); } @@ -77,40 +61,18 @@ .workbench-card { position: relative; isolation: isolate; - border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14); - background: - linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.64) 55%, rgba(255, 255, 255, 0.72)), - var(--workbench-glass-theme-tint); - background-color: rgba(255, 255, 255, 0.66); - box-shadow: - 0 12px 30px rgba(15, 23, 42, 0.052), - inset 0 1px 0 rgba(255, 255, 255, 0.86), - inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07); - backdrop-filter: var(--workbench-glass-blur); - -webkit-backdrop-filter: var(--workbench-glass-blur); + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(12px) saturate(150%); + -webkit-backdrop-filter: blur(12px) saturate(150%); + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28); + box-shadow: + 0 12px 28px rgba(15, 23, 42, 0.04), + inset 0 1px 0 rgba(255, 255, 255, 0.96); } .workbench-card::before, .workbench-card::after { - border-radius: inherit; -} - -.workbench-card::before { - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 42%), - var(--workbench-panel-bg-image) 0 0 / var(--workbench-panel-tile-size) repeat; - mix-blend-mode: soft-light; - opacity: calc(var(--workbench-glass-noise-opacity) * 0.8); -} - -.workbench-card::after { - border: 1px solid rgba(255, 255, 255, 0.36); - background: var(--workbench-glass-highlight); - opacity: 0.56; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.58), - inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.055); - transition: opacity 180ms var(--ease); + display: none !important; } .workbench-card > * { @@ -138,15 +100,10 @@ .capability-card:hover, .workbench-card:hover { - box-shadow: - 0 16px 36px rgba(15, 23, 42, 0.075), - inset 0 1px 0 rgba(255, 255, 255, 0.9), - inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1); -} - -.capability-card:hover::after, -.workbench-card:hover::after { - opacity: 0.88; + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.4); + box-shadow: + 0 16px 36px rgba(15, 23, 42, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 1); } .capability-card:hover { diff --git a/web/src/assets/styles/components/personal-workbench-insights.css b/web/src/assets/styles/components/personal-workbench-insights.css index b282bcd..ad4e5a1 100644 --- a/web/src/assets/styles/components/personal-workbench-insights.css +++ b/web/src/assets/styles/components/personal-workbench-insights.css @@ -66,10 +66,12 @@ .insight-metric-list, .insight-profile-list { min-height: 0; - display: grid; - gap: 6px; - grid-auto-rows: minmax(0, 1fr); + display: flex; + flex-direction: column; + justify-content: space-evenly; + gap: 0; overflow: hidden; + height: 100%; } .insight-metric-row, @@ -94,6 +96,15 @@ transition: border-color 180ms var(--ease), background-color 180ms var(--ease); + animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both; +} + +.insight-metric-row { + animation-delay: calc(400ms + var(--item-index, 0) * 80ms); +} + +.insight-profile-card { + animation-delay: calc(500ms + var(--item-index, 0) * 80ms); } .insight-metric-row:hover, @@ -104,22 +115,6 @@ rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04); } -/* 局部改造:让费用统计内层的小卡片也变为低透明度透镜,形成双层液态玻璃(Double Glassmorphism)的极品手感 */ -.expense-stats-panel .insight-metric-row { - background: rgba(255, 255, 255, 0.05) !important; - border-color: rgba(255, 255, 255, 0.2) !important; - border-left-color: rgba(255, 255, 255, 0.6) !important; - backdrop-filter: blur(8px) saturate(120%); - -webkit-backdrop-filter: blur(8px) saturate(120%); - box-shadow: - 0 4px 12px rgba(0, 0, 0, 0.03), - inset 0 1px 0 rgba(255, 255, 255, 0.3) !important; -} - -.expense-stats-panel .insight-metric-row:hover { - background: rgba(255, 255, 255, 0.2) !important; - border-color: rgba(255, 255, 255, 0.4) !important; -} .insight-metric-icon, .insight-profile-icon { diff --git a/web/src/assets/styles/components/personal-workbench-responsive.css b/web/src/assets/styles/components/personal-workbench-responsive.css index 3a3e26a..4f50055 100644 --- a/web/src/assets/styles/components/personal-workbench-responsive.css +++ b/web/src/assets/styles/components/personal-workbench-responsive.css @@ -4,8 +4,8 @@ --hero-padding-top: 20px; --hero-padding-bottom: 20px; --hero-title-size: 28px; - --hero-copy-gap: 5px; - --hero-title-bottom-gap: 14px; + --hero-copy-gap: 16px; + --hero-title-bottom-gap: 10px; --composer-min-height: 108px; --composer-textarea-height: 48px; --composer-padding-block: 10px; @@ -15,7 +15,9 @@ } .assistant-hero { - --assistant-bg-position: 56% center; + --assistant-bg-position: right center; + --assistant-decor-width: clamp(760px, 66vw, 980px); + --assistant-decor-opacity: 0.86; padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px; } @@ -58,7 +60,9 @@ } .assistant-hero { - --assistant-bg-position: 58% center; + --assistant-bg-position: right center; + --assistant-decor-width: clamp(760px, 66vw, 980px); + --assistant-decor-opacity: 0.9; padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px; } @@ -83,7 +87,7 @@ } .capability-copy { - padding-left: 14px; + padding-left: 0; } .workbench-content-grid { @@ -110,13 +114,15 @@ } .assistant-hero { - --assistant-bg-position: 62% center; + --assistant-bg-position: right center; + --assistant-decor-width: clamp(620px, 74vw, 860px); + --assistant-decor-opacity: 0.62; --assistant-readability-mask: - linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.88) 58%, rgba(255, 255, 255, 0.44) 100%); + linear-gradient(90deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.5) 58%, rgba(255, 255, 255, 0.06) 100%); --assistant-theme-tint: - linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14) 100%); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); + linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.09) 100%); + backdrop-filter: blur(10px) saturate(1.12); + -webkit-backdrop-filter: blur(10px) saturate(1.12); } .assistant-copy { @@ -140,6 +146,125 @@ } } +@media (min-width: 961px) and (max-width: 1440px), + (min-width: 961px) and (max-height: 820px) { + .workbench { + --hero-padding-top: 14px; + --hero-padding-bottom: 14px; + --hero-title-size: 24px; + --hero-copy-gap: 14px; + --hero-title-bottom-gap: 8px; + --composer-min-height: 92px; + --composer-textarea-height: 38px; + --composer-padding-block: 8px; + --quick-prompts-gap-top: 5px; + --capability-row-height: 82px; + gap: 8px; + } + + .assistant-hero { + --assistant-decor-width: clamp(680px, 60vw, 880px); + --assistant-decor-opacity: 0.72; + padding: var(--hero-padding-top) 16px var(--hero-padding-bottom) 34px; + } + + .assistant-copy { + width: min(900px, 92%); + } + + .assistant-copy h1 { + margin-bottom: var(--hero-title-bottom-gap); + font-size: var(--hero-title-size); + line-height: 1.14; + } + + .assistant-composer { + min-height: var(--composer-min-height); + gap: 4px; + padding: var(--composer-padding-block) 14px 8px; + } + + .assistant-composer textarea { + height: var(--composer-textarea-height); + min-height: var(--composer-textarea-height); + max-height: var(--composer-textarea-height); + font-size: 14px; + line-height: 1.42; + } + + .composer-toolbar { + gap: 8px; + } + + .composer-icon-button, + .composer-send-button { + height: 30px; + } + + .composer-icon-button { + width: 30px; + font-size: 17px; + } + + .composer-send-button { + width: 46px; + font-size: 16px; + } + + .composer-count { + font-size: 12px; + } + + .quick-prompts { + gap: 8px; + margin-top: var(--quick-prompts-gap-top); + font-size: 12.5px; + } + + .quick-prompts button { + min-height: 24px; + padding: 0 10px; + font-size: 12px; + } + + .capability-grid { + gap: 10px; + } + + .capability-card { + grid-template-columns: 34px minmax(0, 1fr) 14px; + gap: 10px; + padding: 12px 12px 12px 16px; + } + + .capability-icon { + --workbench-list-icon-size: 34px; + --workbench-list-icon-art-size: 20px; + width: 34px; + height: 34px; + } + + .capability-copy { + gap: 2px; + } + + .capability-copy strong { + font-size: 13px; + line-height: 1.2; + } + + .capability-copy small { + font-size: 11px; + line-height: 1.22; + } + + .capability-arrow { + width: 14px; + min-width: 14px; + font-size: 16px; + } +} + @media (max-width: 760px) { .workbench { height: auto; @@ -156,15 +281,17 @@ .assistant-hero { min-height: auto; - --assistant-bg-position: 68% center; + --assistant-bg-position: right center; + --assistant-decor-width: min(620px, 118vw); + --assistant-decor-opacity: 0.36; --assistant-readability-mask: - linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0.88) 100%), - linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.72) 100%); + linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.76) 100%), + linear-gradient(90deg, rgba(255, 255, 255, 0.88) 0%, rgba(255, 255, 255, 0.52) 100%); --assistant-theme-tint: - linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08) 100%); + linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 100%); padding: 24px 18px 24px; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(9px) saturate(1.1); + -webkit-backdrop-filter: blur(9px) saturate(1.1); } .assistant-copy { @@ -311,7 +438,7 @@ } .capability-copy { - padding-left: 6px; + padding-left: 0; gap: 2px; } diff --git a/web/src/assets/styles/components/personal-workbench.css b/web/src/assets/styles/components/personal-workbench.css index 80af18a..b6459cf 100644 --- a/web/src/assets/styles/components/personal-workbench.css +++ b/web/src/assets/styles/components/personal-workbench.css @@ -57,31 +57,48 @@ .workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; } .assistant-hero { + --assistant-bg-position: right center; + --assistant-decor-width: clamp(860px, 62vw, 1180px); + --assistant-decor-opacity: 0.92; + --assistant-readability-mask: + linear-gradient(90deg, rgba(255, 255, 255, 0.74) 0%, rgba(255, 255, 255, 0.34) 46%, rgba(255, 255, 255, 0) 100%); + --assistant-theme-tint: + linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025) 54%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.075)); position: relative; z-index: 2; min-height: 0; - overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + overflow: visible; padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px; - border: 1px solid rgba(255, 255, 255, 0.9); - border-radius: 16px; - background: - linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%); - background-color: transparent; - backdrop-filter: blur(40px) saturate(200%); - -webkit-backdrop-filter: blur(40px) saturate(200%); - box-shadow: 0 16px 32px rgba(0, 0, 0, 0.04), inset 0 2px 4px rgba(255, 255, 255, 1); + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18); + border-radius: 4px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.54)), + var(--assistant-theme-tint); + background-color: rgba(247, 252, 255, 0.72); + backdrop-filter: blur(14px) saturate(1.18); + -webkit-backdrop-filter: blur(14px) saturate(1.18); + box-shadow: + 0 12px 28px rgba(15, 23, 42, 0.045), + inset 0 1px 0 rgba(255, 255, 255, 0.86), + inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07); isolation: isolate; + animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both; + animation-delay: 0ms; } .assistant-hero::after { content: ""; position: absolute; top: 0; - right: 100px; + right: 0; bottom: 0; - width: 50%; - min-width: 400px; - background: url("../../images/hero-financial-decor.svg") right center / auto 100% no-repeat; + width: 82%; + min-width: 760px; + background: url("../../images/workbench-hero-right-bg.png") var(--assistant-bg-position) / var(--assistant-decor-width) auto no-repeat; + opacity: var(--assistant-decor-opacity); pointer-events: none; z-index: 0; } @@ -92,8 +109,9 @@ inset: 0; border-radius: inherit; background: - linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%), - linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%); + var(--assistant-readability-mask), + linear-gradient(120deg, rgba(255, 255, 255, 0.36), transparent 22%, transparent 72%, rgba(255, 255, 255, 0.18)), + linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.05), transparent 58%); pointer-events: none; z-index: 1; } @@ -114,8 +132,23 @@ font-weight: 850; } -.assistant-copy h1 span { +.assistant-copy h1 span:not(.typing-cursor) { color: var(--workbench-primary-active); + display: inline-block; + animation: workbenchItemIn 400ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both; +} + +.typing-cursor { + display: inline-block; + color: var(--workbench-primary-active); + font-weight: 400; + margin-left: 2px; + animation: cursorBlink 0.9s step-end infinite; +} + +@keyframes cursorBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } } .assistant-copy p { @@ -127,29 +160,70 @@ font-weight: 600; } +.assistant-copy > * { + animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both; +} + +.assistant-copy > h1 { animation-delay: 80ms; } +.assistant-copy > p { animation-delay: 160ms; } +.assistant-copy > .assistant-composer { animation-delay: 240ms; } +.assistant-copy > .assistant-file-strip { animation-delay: 320ms; } +.assistant-copy > .quick-prompts { animation-delay: 320ms; } + .assistant-file-input { display: none; } .assistant-composer { position: relative; - z-index: 5; + z-index: 20; display: grid; gap: 6px; max-width: 920px; min-height: var(--composer-min-height); padding: var(--composer-padding-block) 18px 10px; - border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28); + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24); border-radius: 4px; - background: rgba(255, 255, 255, 0.96); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96); - backdrop-filter: blur(4px); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.74)), + linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.045), rgba(255, 255, 255, 0.18)); + box-shadow: + 0 10px 24px rgba(15, 23, 42, 0.045), + inset 0 1px 0 rgba(255, 255, 255, 0.9), + inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06); + backdrop-filter: blur(10px) saturate(1.14); + -webkit-backdrop-filter: blur(10px) saturate(1.14); + transition: + border-color 180ms var(--ease), + background 180ms var(--ease), + box-shadow 180ms var(--ease); +} + +.assistant-composer::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + border-radius: inherit; + background: + linear-gradient(110deg, rgba(255, 255, 255, 0.32), transparent 32%), + linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 42%); + pointer-events: none; +} + +.assistant-composer > * { + position: relative; + z-index: 1; } .assistant-composer:focus-within { - border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.85); + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.58); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.78)), + linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06), rgba(255, 255, 255, 0.22)); box-shadow: - 0 0 0 4px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14), - 0 16px 36px rgba(15, 23, 42, 0.06), - inset 0 1px 0 rgba(255, 255, 255, 0.96); + 0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11), + 0 14px 30px rgba(15, 23, 42, 0.055), + inset 0 1px 0 rgba(255, 255, 255, 0.94), + inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08); } .assistant-composer textarea { @@ -331,24 +405,28 @@ position: relative; isolation: isolate; display: grid; - grid-template-columns: 40px minmax(0, 1fr) 10px; + grid-template-columns: 40px minmax(0, 1fr) 18px; align-items: center; gap: 14px; min-height: 0; - padding: 17px 12px 17px 26px; + padding: 16px 18px 16px 22px; overflow: visible; + text-align: left; border: 1px solid rgba(255, 255, 255, 0.9); border-left: 3px solid color-mix(in srgb, var(--capability-color) 80%, rgba(255, 255, 255, 0.9)); - border-radius: 12px; - background: - linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%); - background-color: transparent; - backdrop-filter: blur(40px) saturate(200%); - -webkit-backdrop-filter: blur(40px) saturate(200%); - text-align: left; + min-width: 0; + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.15); + border-radius: 4px; box-shadow: - 0 16px 32px rgba(0, 0, 0, 0.04), - inset 0 2px 4px rgba(255, 255, 255, 1); + 0 8px 24px rgba(15, 23, 42, 0.03), + inset 0 1px 0 rgba(255, 255, 255, 1); + color: var(--workbench-ink); + text-decoration: none; + animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both; + animation-delay: var(--delay, 100ms); transition: border-color 180ms var(--ease), box-shadow 180ms var(--ease), @@ -356,16 +434,12 @@ transform 180ms var(--ease); } -.capability-card::before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - background: - linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%), - linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%); - pointer-events: none; - z-index: 0; +.capability-card:hover { + transform: translateY(-2px); + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.4); + box-shadow: + 0 16px 32px rgba(15, 23, 42, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 1); } .capability-card > * { @@ -373,13 +447,9 @@ z-index: 1; } -.capability-card::after { - display: none; -} - .capability-icon { --workbench-list-icon-size: 40px; - --workbench-list-icon-art-size: 23px; + --workbench-list-icon-art-size: 24px; width: 40px; height: 40px; color: var(--capability-color); @@ -388,8 +458,9 @@ .capability-copy { min-width: 0; display: grid; + justify-items: start; gap: 4px; - padding-left: 18px; + text-align: left; } .capability-copy strong { @@ -400,6 +471,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + text-align: left; } .capability-copy small { @@ -409,11 +481,19 @@ line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; + text-align: left; } .capability-arrow { + justify-self: end; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + min-width: 18px; color: color-mix(in srgb, var(--workbench-muted) 68%, #ffffff); font-size: 18px; + line-height: 1; } .capability-card--green { @@ -461,28 +541,16 @@ min-height: 0; height: 100%; padding: 12px 14px; - background: - linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%); - background-color: transparent; - backdrop-filter: blur(40px) saturate(200%); - -webkit-backdrop-filter: blur(40px) saturate(200%); - border: 1px solid rgba(255, 255, 255, 0.9); - border-radius: 16px; + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.15); + border-radius: 4px; box-shadow: - 0 16px 32px rgba(0, 0, 0, 0.04), - inset 0 2px 4px rgba(255, 255, 255, 1); -} - -.workbench-card::before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - background: - linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%), - linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%); - pointer-events: none; - z-index: 1; + 0 12px 28px rgba(15, 23, 42, 0.04), + inset 0 1px 0 rgba(255, 255, 255, 1); + animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both; + animation-delay: var(--delay, 200ms); } .workbench-card > * { @@ -534,6 +602,18 @@ font-weight: 850; } +.insight-metric-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 14px; + border-radius: 4px; + background: color-mix(in srgb, var(--insight-color) 4%, transparent); + transition: transform 180ms var(--ease), background-color 180ms var(--ease); + animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both; + animation-delay: calc(400ms + var(--item-index, 0) * 80ms); +} + .link-action { display: inline-flex; align-items: center; @@ -560,6 +640,11 @@ border-top: 0; } +.progress-row { + animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both; + animation-delay: calc(300ms + var(--item-index, 0) * 80ms); +} + .progress-identity, .progress-result { gap: 12px; @@ -716,40 +801,66 @@ display: inline-flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; - border-radius: 8px; - font-size: 20px; + width: 42px; + height: 42px; + border-radius: 12px; + font-size: 22px; flex-shrink: 0; + position: relative; + box-shadow: + 0 4px 10px rgba(0, 0, 0, 0.04), + inset 0 1px 0 rgba(255, 255, 255, 0.9), + inset 0 -1px 0 rgba(0, 0, 0, 0.03); +} + +.expense-type-icon::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 100%); + border-radius: inherit; + z-index: 0; +} + +.expense-type-icon i { + position: relative; + z-index: 1; + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.12)); } .expense-type-icon--blue { - background: color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff); + background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 3%, #ffffff) 100%); + border: 1px solid color-mix(in srgb, var(--workbench-primary, #3a7ca5) 20%, #ffffff); color: var(--workbench-primary, #3a7ca5); } .expense-type-icon--amber { - background: color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff); + background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 3%, #ffffff) 100%); + border: 1px solid color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 20%, #ffffff); color: var(--workbench-chart-amber, #b58b4c); } .expense-type-icon--emerald { - background: color-mix(in srgb, #10b981 12%, #ffffff); - color: #10b981; + background: linear-gradient(135deg, color-mix(in srgb, #0f8f68 12%, #ffffff) 0%, color-mix(in srgb, #0f8f68 3%, #ffffff) 100%); + border: 1px solid color-mix(in srgb, #0f8f68 20%, #ffffff); + color: #0f8f68; } .expense-type-icon--violet { - background: color-mix(in srgb, #8b5cf6 12%, #ffffff); - color: #8b5cf6; + background: linear-gradient(135deg, color-mix(in srgb, #6d5bd0 12%, #ffffff) 0%, color-mix(in srgb, #6d5bd0 3%, #ffffff) 100%); + border: 1px solid color-mix(in srgb, #6d5bd0 20%, #ffffff); + color: #6d5bd0; } .expense-type-icon--cyan { - background: color-mix(in srgb, #06b6d4 12%, #ffffff); - color: #06b6d4; + background: linear-gradient(135deg, color-mix(in srgb, #0788a2 12%, #ffffff) 0%, color-mix(in srgb, #0788a2 3%, #ffffff) 100%); + border: 1px solid color-mix(in srgb, #0788a2 20%, #ffffff); + color: #0788a2; } .expense-type-icon--muted { - background: var(--info-soft, #f1f5f9); + background: linear-gradient(135deg, var(--info-soft, #f1f5f9) 0%, #ffffff 100%); + border: 1px solid var(--workbench-line); color: var(--workbench-muted, #64748b); } @@ -877,3 +988,22 @@ border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24); color: var(--workbench-primary-active); } + +@keyframes workbenchItemIn { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .assistant-hero, + .capability-card, + .workbench-card { + animation: none !important; + } +} diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css index 279d507..394a86a 100644 --- a/web/src/assets/styles/components/top-bar.css +++ b/web/src/assets/styles/components/top-bar.css @@ -1179,6 +1179,136 @@ background: var(--theme-primary-active); } +@media (min-width: 961px) and (max-width: 1440px), + (min-width: 961px) and (max-height: 820px) { + .topbar { + gap: 16px; + padding: 12px 20px 14px; + } + + .topbar.chat-mode { + padding-bottom: 12px; + } + + .eyebrow { + margin-bottom: 5px; + padding: 2px 8px; + font-size: 10px; + letter-spacing: 0.8px; + } + + .topbar h1 { + font-size: 22px; + line-height: 1.16; + } + + .topbar p { + display: -webkit-box; + max-width: 640px; + margin-top: 3px; + overflow: hidden; + color: #64748b; + font-size: 12.5px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + } + + .top-actions { + gap: 10px; + } + + .range-shell { + height: 36px; + padding: 2px; + } + + .range-meta { + height: 30px; + gap: 6px; + padding: 0 10px; + font-size: 12px; + } + + .range-tabs button { + height: 30px; + min-width: 48px; + padding: 0 10px; + font-size: 12px; + } + + .custom-range-btn { + height: 36px; + gap: 6px; + padding: 0 11px; + font-size: 12px; + } + + .dashboard-switch-wrap { + width: 176px; + flex-basis: 176px; + height: 38px; + } + + .dashboard-switch-select :deep(.el-select__wrapper) { + height: 38px; + min-height: 38px; + } + + .topbar-toolset { + gap: 12px; + } + + .topbar-icon-btn { + width: 30px; + height: 30px; + font-size: 20px; + } + + .company-switcher { + height: 34px; + padding: 0 12px; + font-size: 12px; + } + + .kpi-chips { + gap: 8px; + } + + .kpi-chip { + gap: 1px 8px; + padding: 6px 12px; + } + + .chip-value { + font-size: 18px; + } + + .chip-value small { + font-size: 11px; + } + + .chip-label { + font-size: 11px; + } + + .chip-delta { + font-size: 10px; + } + + .detail-alert-pill { + min-height: 28px; + padding: 0 10px; + font-size: 11.5px; + } + + .create-top-btn { + height: 36px; + padding: 0 14px; + font-size: 13px; + } +} + @media (max-width: 1120px) { .range-combo { width: 100%; diff --git a/web/src/assets/styles/components/travel-reimbursement-message-application.css b/web/src/assets/styles/components/travel-reimbursement-message-application.css index c8d5855..244bda9 100644 --- a/web/src/assets/styles/components/travel-reimbursement-message-application.css +++ b/web/src/assets/styles/components/travel-reimbursement-message-application.css @@ -99,6 +99,13 @@ opacity: 0.58; } +.reimbursement-draft-pending-detail { + display: inline; + margin-left: 8px; + color: #94a3b8; + font-weight: 760; +} + .application-draft-preview .application-draft-head { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; diff --git a/web/src/assets/styles/components/travel-reimbursement-message-item.css b/web/src/assets/styles/components/travel-reimbursement-message-item.css index dfb2291..b223fde 100644 --- a/web/src/assets/styles/components/travel-reimbursement-message-item.css +++ b/web/src/assets/styles/components/travel-reimbursement-message-item.css @@ -60,7 +60,7 @@ max-width: min(100%, 760px); padding: 12px 14px; border: 1px solid #d8e4f0; - border-radius: 14px; + border-radius: 4px; background: #ffffff; color: #24324a; font-size: var(--wb-fs-bubble, 13px); @@ -182,11 +182,54 @@ max-width: min(100%, 1080px); } -.message-feedback-bubble { - grid-column: 2; - justify-self: start; - max-width: min(100%, 420px); +.message-action-toolbar { + display: inline-flex; + align-items: center; + gap: 4px; margin-top: -2px; + color: #728197; +} + +.message-action-btn { + width: 30px; + height: 28px; + display: inline-grid; + place-items: center; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; + transition: + color 160ms ease, + background 160ms ease, + border-color 160ms ease; +} + +.message-action-btn i { + font-size: 18px; + line-height: 1; +} + +.message-action-btn:hover, +.message-action-btn:focus-visible { + color: #245f90; + background: #eef6fb; + border-color: #c9ddea; + outline: none; +} + +.message-action-btn.active { + color: #1d6f9f; + background: #e8f4fb; + border-color: #bcd8e8; +} + +.message-action-btn:disabled { + cursor: not-allowed; + color: #b7c2cf; + background: transparent; + border-color: transparent; } .message-bubble-review-risk-low, @@ -482,7 +525,7 @@ margin: 10px 0 12px; overflow-x: auto; border: 1px solid #dbe4ee; - border-radius: 10px; + border-radius: 4px; background: #ffffff; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05); } @@ -566,7 +609,7 @@ align-items: center; gap: 6px; padding: 0 12px; - border-radius: 8px; + border-radius: 4px; font-size: var(--wb-fs-chip, 12px); font-weight: 750; } @@ -606,6 +649,44 @@ gap: 8px; } +.structured-card-reveal-enter-active { + transition: + opacity 220ms cubic-bezier(0.2, 0, 0, 1), + transform 240ms cubic-bezier(0.22, 1, 0.36, 1), + clip-path 240ms cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: top left; + will-change: opacity, transform, clip-path; +} + +.structured-card-reveal-leave-active { + transition: + opacity 140ms ease, + transform 140ms ease; + transform-origin: top left; +} + +.structured-card-reveal-enter-from { + opacity: 0; + transform: translateY(8px) scale(0.985); + clip-path: inset(0 0 14px 0); +} + +.structured-card-reveal-enter-to { + opacity: 1; + transform: translateY(0) scale(1); + clip-path: inset(0 0 0 0); +} + +.structured-card-reveal-leave-from { + opacity: 1; + transform: translateY(0); +} + +.structured-card-reveal-leave-to { + opacity: 0; + transform: translateY(4px); +} + .message-suggested-action-btn { height: 100%; min-height: 54px; @@ -614,16 +695,32 @@ align-items: center; gap: 10px; padding: 10px 12px; - border-radius: 10px; + border-radius: 4px; text-align: left; } +.structured-card-reveal-enter-active .message-suggested-action-btn { + animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(2) { + animation-delay: 45ms; +} + +.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(3) { + animation-delay: 90ms; +} + +.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(n + 4) { + animation-delay: 120ms; +} + .message-suggested-action-icon { width: 30px; height: 30px; display: grid; place-items: center; - border-radius: 8px; + border-radius: 4px; background: #eff6ff; color: var(--theme-primary, #3a7ca5); } @@ -651,7 +748,7 @@ margin-top: 12px; padding: 12px; border: 1px solid #e2e8f0; - border-radius: 12px; + border-radius: 4px; background: #f8fbff; } @@ -670,12 +767,17 @@ padding: 0; overflow: hidden; border: 1px solid #d7e4f2; - border-radius: 8px; + border-radius: 4px; background: #ffffff; color: #334155; font-size: var(--wb-fs-bubble, 13px); } +.application-preview-shell { + min-width: 0; + display: grid; +} + .application-preview-row { position: relative; display: grid; @@ -684,6 +786,30 @@ border-top: 1px solid #e6edf5; } +.structured-card-reveal-enter-active .application-preview-row { + animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.structured-card-reveal-enter-active .application-preview-row:nth-child(2) { + animation-delay: 35ms; +} + +.structured-card-reveal-enter-active .application-preview-row:nth-child(3) { + animation-delay: 70ms; +} + +.structured-card-reveal-enter-active .application-preview-row:nth-child(4) { + animation-delay: 105ms; +} + +.structured-card-reveal-enter-active .application-preview-row:nth-child(5) { + animation-delay: 140ms; +} + +.structured-card-reveal-enter-active .application-preview-row:nth-child(n + 6) { + animation-delay: 165ms; +} + .application-preview-row.editable { cursor: pointer; } @@ -786,7 +912,7 @@ height: 30px; padding: 0 9px; border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.48); - border-radius: 6px; + border-radius: 4px; background: #ffffff; color: #0f172a; font: inherit; @@ -809,7 +935,7 @@ display: inline-grid; place-items: center; border: 1px solid transparent; - border-radius: 6px; + border-radius: 4px; background: var(--theme-primary-soft, #eaf4fa); color: var(--theme-primary-active, #255b7d); cursor: pointer; @@ -903,7 +1029,7 @@ min-height: 22px; padding: 0 7px; border: 0; - border-radius: 6px; + border-radius: 4px; background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1); color: var(--theme-primary-active, #255b7d); font-weight: 880; @@ -914,6 +1040,17 @@ font-weight: 820; } +@keyframes structured-card-item-reveal { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .expense-query-record-list, .message-citation-list { display: grid; @@ -926,7 +1063,7 @@ grid-template-columns: minmax(0, 1fr) auto; gap: 10px; padding: 10px; - border-radius: 10px; + border-radius: 4px; text-align: left; } @@ -1054,7 +1191,7 @@ .message-bubble { max-width: 100%; - border-radius: 12px; + border-radius: 4px; } .steward-task-missing-list li { @@ -1062,3 +1199,15 @@ gap: 3px; } } + +@media (prefers-reduced-motion: reduce) { + .structured-card-reveal-enter-active, + .structured-card-reveal-leave-active { + transition: none; + } + + .structured-card-reveal-enter-active .application-preview-row, + .structured-card-reveal-enter-active .message-suggested-action-btn { + animation: none; + } +} diff --git a/web/src/assets/styles/detail-page-corners.css b/web/src/assets/styles/detail-page-corners.css index 352898a..83d403f 100644 --- a/web/src/assets/styles/detail-page-corners.css +++ b/web/src/assets/styles/detail-page-corners.css @@ -208,3 +208,22 @@ ) { border-radius: var(--enterprise-detail-radius); } + +.digital-work-records.digital-work-records .work-record-detail-page :is( + .detail-inline-state, + .detail-loading-state, + .detail-hero, + .enterprise-detail-card, + .json-risk-meta-item, + .json-risk-description-text, + .work-record-error-text, + .work-record-tool-item, + .work-record-inline-empty, + .work-record-code-block, + .edit-badge, + .back-action, + .minor-action, + .major-action +) { + border-radius: var(--enterprise-detail-radius); +} diff --git a/web/src/assets/styles/element-plus-theme.css b/web/src/assets/styles/element-plus-theme.css index 0413175..8f8b8fe 100644 --- a/web/src/assets/styles/element-plus-theme.css +++ b/web/src/assets/styles/element-plus-theme.css @@ -32,7 +32,7 @@ --el-text-color-primary: var(--ink); --el-text-color-regular: var(--text); --el-text-color-secondary: var(--muted); - --el-font-family: Inter, "SF Pro Text", "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif; + --el-font-family: var(--font-sans); --el-font-size-base: 14px; --el-box-shadow-light: 0 8px 22px rgba(15, 23, 42, 0.08); --el-box-shadow-lighter: 0 4px 14px rgba(15, 23, 42, 0.06); diff --git a/web/src/assets/styles/global.css b/web/src/assets/styles/global.css index 4992dfd..ee7539e 100644 --- a/web/src/assets/styles/global.css +++ b/web/src/assets/styles/global.css @@ -72,7 +72,8 @@ --desktop-stage-height: 100dvh; --desktop-viewport-width: 1440; --desktop-viewport-height: 900; - font-family: Inter, "SF Pro Display", "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif; + --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "PingFang SC", "Hiragino Sans GB", "Helvetica Neue", "Segoe UI", "Microsoft YaHei", Arial, sans-serif; + font-family: var(--font-sans); } * { box-sizing: border-box; } diff --git a/web/src/assets/styles/views/budget-center-view.css b/web/src/assets/styles/views/budget-center-view.css index 7bf397c..edc9256 100644 --- a/web/src/assets/styles/views/budget-center-view.css +++ b/web/src/assets/styles/views/budget-center-view.css @@ -41,20 +41,6 @@ align-items: flex-start; } -.budget-select-filter { - display: inline-flex; - align-items: center; - gap: 8px; - color: #64748b; - font-size: 13px; - font-weight: 750; - white-space: nowrap; -} - -.budget-select-filter .enterprise-select { - min-width: 118px; -} - .budget-primary-btn, .budget-ghost-btn { min-height: 38px; @@ -464,17 +450,11 @@ padding: 12px 12px 0; } - .budget-select-filter, - .budget-select-filter .enterprise-select, .budget-primary-btn, .budget-ghost-btn { width: 100%; } - .budget-select-filter { - justify-content: space-between; - } - .budget-scope-tabs { gap: 18px; flex-wrap: nowrap; diff --git a/web/src/assets/styles/views/documents-center-view.css b/web/src/assets/styles/views/documents-center-view.css index 083f483..2b4605b 100644 --- a/web/src/assets/styles/views/documents-center-view.css +++ b/web/src/assets/styles/views/documents-center-view.css @@ -15,173 +15,6 @@ overflow: hidden; } -.document-filter, -.date-range-filter { - position: relative; -} - -.document-filter-menu, -.date-range-popover { - position: absolute; - z-index: 40; - border: 1px solid #d7e0ea; - border-radius: 4px; - background: #fff; - box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12); - overflow: hidden; -} - -.document-filter-menu { - top: calc(100% + 8px); - left: 0; - min-width: 150px; - max-height: 280px; - padding: 6px; - overflow-y: auto; -} - -.document-filter-menu button { - display: block; - width: 100%; - min-height: 36px; - padding: 0 12px; - border: 0; - border-radius: 4px; - background: transparent; - color: #334155; - font-size: 13px; - font-weight: 650; - text-align: left; - white-space: nowrap; -} - -.document-filter-menu button:hover, -.document-filter-menu button.active { - background: rgba(58, 124, 165, 0.1); - color: var(--theme-primary-active); -} - -.date-range-trigger { - min-width: 150px; -} - -.date-range-label { - max-width: 104px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.date-range-popover { - top: calc(100% + 8px); - left: 0; - width: 320px; - display: grid; - gap: 14px; - padding: 16px; -} - -.date-range-popover header, -.date-range-popover footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.date-range-popover header strong { - color: #0f172a; - font-size: 15px; -} - -.date-range-popover header button { - width: 30px; - height: 30px; - display: grid; - place-items: center; - border: 0; - border-radius: 4px; - background: transparent; - color: #64748b; -} - -.date-range-fields { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.date-range-fields label { - display: grid; - gap: 6px; -} - -.date-range-fields span { - color: #64748b; - font-size: 12px; - font-weight: 700; -} - -.date-range-fields input { - width: 100%; - height: 38px; - padding: 0 9px; - border: 1px solid #d7e0ea; - border-radius: 4px; - color: #0f172a; - font-size: 13px; -} - -.ghost-btn, -.apply-btn { - height: 36px; - padding: 0 14px; - border-radius: 4px; - font-size: 13px; - font-weight: 750; -} - -.ghost-btn { - border: 1px solid #d7e0ea; - background: #fff; - color: #334155; -} - -.apply-btn { - border: 0; - background: var(--theme-primary); - color: #fff; -} - -.apply-btn:disabled { - cursor: not-allowed; - background: #cbd5e1; -} - -.document-status-filter { - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - min-height: 38px; -} - -.status-dropdown-filter { - min-width: 154px; -} - -.status-filter-trigger { - min-width: 154px; -} - -.status-filter-trigger > .mdi:first-child { - color: var(--theme-primary); -} - -.status-filter-menu { - min-width: 154px; -} - .col-id { width: 11%; } .col-created { width: 10%; } .col-stay { width: 9%; } diff --git a/web/src/assets/styles/views/logs-view.css b/web/src/assets/styles/views/logs-view.css index f7287ac..48777c8 100644 --- a/web/src/assets/styles/views/logs-view.css +++ b/web/src/assets/styles/views/logs-view.css @@ -40,10 +40,6 @@ width: 280px; } -.system-logs-list .document-filter { - position: relative; -} - .system-logs-list .status-dropdown-filter, .system-logs-list .status-filter-trigger, .system-logs-list .status-filter-menu { @@ -74,42 +70,6 @@ font-size: 18px; } -.system-logs-list .document-filter-menu { - position: absolute; - top: calc(100% + 8px); - left: 0; - z-index: 40; - min-width: 150px; - max-height: 280px; - padding: 6px; - overflow-y: auto; - border: 1px solid #d7e0ea; - border-radius: 4px; - background: #fff; - box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12); -} - -.system-logs-list .document-filter-menu button { - width: 100%; - min-height: 36px; - display: block; - padding: 0 12px; - border: 0; - border-radius: 4px; - background: transparent; - color: #334155; - font-size: 13px; - font-weight: 650; - text-align: left; - white-space: nowrap; -} - -.system-logs-list .document-filter-menu button:hover, -.system-logs-list .document-filter-menu button.active { - background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1); - color: var(--theme-primary-active); -} - .system-logs-list .system-log-table { min-width: 1260px; } diff --git a/web/src/assets/styles/views/policies-view.css b/web/src/assets/styles/views/policies-view.css index c5a9aa6..4f2c82d 100644 --- a/web/src/assets/styles/views/policies-view.css +++ b/web/src/assets/styles/views/policies-view.css @@ -114,44 +114,46 @@ border-right: 1px solid #edf2f7; padding-right: 12px; } - -.folder-tree { - min-height: 0; - display: grid; - align-content: start; - gap: 6px; - overflow-y: auto; -} - -.folder-tree button { - min-height: 34px; - display: grid; - grid-template-columns: 18px minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - padding: 0 9px; - border: 0; - border-radius: 7px; - background: transparent; - color: #334155; - font-size: 13px; - text-align: left; -} - + +.folder-tree { + min-height: 0; + display: grid; + align-content: start; + gap: 6px; + overflow-y: auto; +} + +.folder-tree button { + min-height: 34px; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 0 9px; + border: 0; + border-radius: 7px; + background: transparent; + color: #334155; + font-size: 13px; + text-align: left; + animation: listRowIn 460ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + animation-delay: var(--delay, 0ms); +} + .folder-tree button.active { background: var(--theme-primary-light-9); color: var(--theme-primary-active); font-weight: 850; } - + .folder-tree b { min-width: 24px; height: 20px; display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 999px; - background: #f1f5f9; + align-items: center; + justify-content: center; + border-radius: 999px; + background: #f1f5f9; color: #64748b; font-size: 11px; } @@ -189,7 +191,7 @@ color: #64748b; box-shadow: none; } - + .document-area { min-width: 0; min-height: 0; @@ -201,51 +203,51 @@ .document-area.read-only { grid-template-rows: minmax(0, 1fr) auto; } - -.upload-input { - display: none; -} - -.upload-zone { - min-height: 112px; - display: grid; - place-items: center; - align-content: center; - gap: 8px; - border: 1px dashed #93c5fd; - border-radius: 10px; - background: #f8fbff; - color: #334155; - text-align: center; - cursor: pointer; - transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease; -} - -.upload-zone:hover { - border-color: #60a5fa; - background: #f3f8ff; -} - -.upload-zone.disabled { - cursor: default; - border-color: #cbd5e1; - background: #f8fafc; -} - -.upload-zone.busy { - opacity: 0.72; -} - -.upload-zone i { - color: #2563eb; - font-size: 31px; -} - -.upload-zone strong { - font-size: 13px; - font-weight: 850; -} - + +.upload-input { + display: none; +} + +.upload-zone { + min-height: 112px; + display: grid; + place-items: center; + align-content: center; + gap: 8px; + border: 1px dashed #93c5fd; + border-radius: 10px; + background: #f8fbff; + color: #334155; + text-align: center; + cursor: pointer; + transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease; +} + +.upload-zone:hover { + border-color: #60a5fa; + background: #f3f8ff; +} + +.upload-zone.disabled { + cursor: default; + border-color: #cbd5e1; + background: #f8fafc; +} + +.upload-zone.busy { + opacity: 0.72; +} + +.upload-zone i { + color: #2563eb; + font-size: 31px; +} + +.upload-zone strong { + font-size: 13px; + font-weight: 850; +} + .upload-zone span { color: #64748b; font-size: 12px; @@ -255,24 +257,24 @@ min-height: 0; overflow: auto; } - + table { width: 100%; min-width: 780px; border-collapse: collapse; } - -th, -td { - padding: 12px 10px; - border-bottom: 1px solid #edf2f7; - color: #24324a; - font-size: 12px; - line-height: 1.35; - text-align: left; - vertical-align: middle; -} - + +th, +td { + padding: 12px 10px; + border-bottom: 1px solid #edf2f7; + color: #24324a; + font-size: 12px; + line-height: 1.35; + text-align: left; + vertical-align: middle; +} + th { background: #f7fafc; color: #64748b; @@ -289,59 +291,61 @@ th { .knowledge-document-table td:first-child { text-align: left; } - -.doc-row { - cursor: pointer; - transition: background 180ms ease, box-shadow 180ms ease; -} - -.doc-row:hover { - background: #f8fbff; -} - + +.doc-row { + cursor: pointer; + animation: listRowIn 460ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + animation-delay: var(--delay, 0ms); + transition: background 180ms ease, box-shadow 180ms ease; +} + +.doc-row:hover { + background: #f8fbff; +} + .doc-row.selected { background: linear-gradient(90deg, rgba(var(--theme-primary-rgb), 0.08), rgba(59, 130, 246, 0.04)); box-shadow: inset 3px 0 0 var(--theme-primary); } - -.file-name { - display: inline-flex; - align-items: center; - gap: 7px; - font-weight: 750; - white-space: nowrap; -} - -.file-name .pdf, -.viewer-filetype.pdf { color: #ef4444; } -.file-name .word, -.viewer-filetype.word { color: #2563eb; } + +.file-name { + display: inline-flex; + align-items: center; + gap: 7px; + font-weight: 750; + white-space: nowrap; +} + +.file-name .pdf, +.viewer-filetype.pdf { color: #ef4444; } +.file-name .word, +.viewer-filetype.word { color: #2563eb; } .file-name .excel, .viewer-filetype.excel { color: var(--success); } - -.doc-tag { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 7px; - border-radius: 6px; - background: #f1f5f9; - color: #64748b; - font-size: 11px; - font-weight: 750; -} - -.state-tag { - min-height: 22px; - display: inline-flex; - align-items: center; - padding: 0 8px; - border-radius: 6px; - font-size: 11px; - font-weight: 800; - white-space: nowrap; -} - + +.doc-tag { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + border-radius: 6px; + background: #f1f5f9; + color: #64748b; + font-size: 11px; + font-weight: 750; +} + +.state-tag { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + .state-tag.success { background: var(--success-soft); color: var(--success-hover); @@ -351,7 +355,7 @@ th { background: #e2e8f0; color: #475569; } - + .state-tag.warning { background: #ffedd5; color: #f97316; @@ -373,14 +377,14 @@ th { line-height: 1.4; white-space: nowrap; } - + .more-btn { width: 32px; height: 32px; display: grid; place-items: center; - border: 0; - border-radius: 8px; + border: 0; + border-radius: 8px; background: transparent; color: #2563eb; } @@ -417,43 +421,43 @@ th { gap: 4px; justify-content: center; } - + .empty-row { color: #64748b; text-align: center; } .list-foot { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 16px; - margin-top: 8px; -} - -.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; - padding: 0; - border: 0; - border-radius: 9px; - background: transparent; - color: #334155; - font-size: 14px; - font-weight: 800; - transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; -} - + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + margin-top: 8px; +} + +.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; + padding: 0; + border: 0; + border-radius: 9px; + background: transparent; + color: #334155; + font-size: 14px; + font-weight: 800; + transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; +} + .pager button:hover:not(.active) { background: #fff; color: var(--theme-primary-active); @@ -465,101 +469,101 @@ th { color: #fff; box-shadow: 0 8px 16px var(--theme-primary-shadow); } - -.list-foot .page-summary { - color: #64748b; - font-size: 14px; - font-weight: 650; -} - -.page-nav { - color: #64748b; -} - + +.list-foot .page-summary { + color: #64748b; + font-size: 14px; + font-weight: 650; +} + +.page-nav { + color: #64748b; +} + .page-size-select { width: 112px; justify-self: end; } - + .preview-panel { height: 100%; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); - padding: 20px 22px; - overflow: hidden; -} - -.preview-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; - padding-bottom: 16px; - border-bottom: 1px solid #edf2f7; -} - -.preview-copy { - min-width: 0; -} - -.preview-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.mini-action, -.icon-action, -.viewer-toolbar-actions button { - border: 1px solid #d7e0ea; - border-radius: 8px; - background: #fff; - color: #334155; -} - -.mini-action { - min-height: 34px; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0 12px; - font-size: 12px; - font-weight: 800; -} - -.icon-action { - width: 34px; - height: 34px; - display: grid; - place-items: center; -} - -.preview-summary-line { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - margin-top: 8px; - color: #64748b; - font-size: 13px; - line-height: 1.6; -} - -.preview-secondary-line { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - margin-top: 12px; - padding: 10px 12px; - border-radius: 10px; - background: #1e293b; - color: #e2e8f0; - font-size: 12px; - line-height: 1.5; -} - + padding: 20px 22px; + overflow: hidden; +} + +.preview-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding-bottom: 16px; + border-bottom: 1px solid #edf2f7; +} + +.preview-copy { + min-width: 0; +} + +.preview-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.mini-action, +.icon-action, +.viewer-toolbar-actions button { + border: 1px solid #d7e0ea; + border-radius: 8px; + background: #fff; + color: #334155; +} + +.mini-action { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 12px; + font-size: 12px; + font-weight: 800; +} + +.icon-action { + width: 34px; + height: 34px; + display: grid; + place-items: center; +} + +.preview-summary-line { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 8px; + color: #64748b; + font-size: 13px; + line-height: 1.6; +} + +.preview-secondary-line { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 12px; + padding: 10px 12px; + border-radius: 10px; + background: #1e293b; + color: #e2e8f0; + font-size: 12px; + line-height: 1.5; +} + .preview-viewer { min-height: 0; margin-top: 18px; @@ -585,6 +589,7 @@ th { min-height: 0; } + .preview-modal-panel { height: 100%; border-radius: 24px; @@ -1401,3 +1406,22 @@ th { grid-template-columns: 1fr; } } + +@keyframes listRowIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .folder-tree button, + .doc-row, + .page-sheet { + animation: none !important; + } +} diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css index 984f9ae..f07d3d4 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css @@ -365,8 +365,10 @@ } .dialog-panel { - flex: 1 1 auto; + flex: 1 1 0; + height: auto; min-height: 0; + max-height: 100%; } .insight-panel-shell { diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css index 9a0a4d7..0e7d789 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css @@ -619,10 +619,13 @@ .assistant-layout { min-height: 0; flex: 1; + height: 100%; + max-height: 100%; display: flex; padding: clamp(12px, 1.5vw, 16px); align-items: stretch; gap: clamp(12px, 1.5vw, 16px); + overflow: hidden; } .dialog-panel, @@ -641,8 +644,11 @@ .dialog-panel { flex: 1 1 auto; - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; + display: flex; + flex-direction: column; + height: 100%; + max-height: 100%; + min-height: 0; overflow: hidden; background: #ffffff; transition: @@ -671,6 +677,7 @@ } .dialog-toolbar { + flex: 0 0 auto; display: flex; align-items: center; gap: 10px; @@ -766,12 +773,15 @@ } .message-list { + flex: 1 1 0; min-height: 0; + max-height: 100%; display: grid; align-content: start; gap: 14px; padding: 18px; overflow-y: auto; + overscroll-behavior: contain; } .message-row.user .message-avatar { @@ -1918,6 +1928,13 @@ padding: 0 18px 18px; display: grid; gap: 12px; + position: sticky; + bottom: 0; + z-index: 20; + flex: 0 0 auto; + flex-shrink: 0; + background: #ffffff; + box-shadow: 0 -10px 22px rgba(248, 250, 252, 0.92); } .hidden-file-input { @@ -1994,3 +2011,37 @@ font-size: 13px; font-weight: 900; } + +.message-row-reveal-enter-active { + transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 350ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.message-row-reveal-enter-from { + opacity: 0; + transform: translateY(16px) scale(0.98); +} + +.message-row-reveal-enter-to { + opacity: 1; + transform: translateY(0) scale(1); +} + +.message-row-reveal-leave-active { + transition: opacity 200ms ease, transform 200ms ease; + position: absolute; + width: 100%; +} + +.message-row-reveal-leave-from { + opacity: 1; + transform: translateY(0) scale(1); +} + +.message-row-reveal-leave-to { + opacity: 0; + transform: translateY(-10px) scale(0.98); +} + +.message-row-reveal-move { + transition: transform 350ms cubic-bezier(0.2, 0.8, 0.2, 1); +} diff --git a/web/src/assets/workbench-icons/README.md b/web/src/assets/workbench-icons/README.md index 551b2b8..3d4b193 100644 --- a/web/src/assets/workbench-icons/README.md +++ b/web/src/assets/workbench-icons/README.md @@ -1,5 +1,7 @@ # Workbench Icons -Icons in this folder are sourced from [Heroicons](https://heroicons.com) (MIT License). +Icons in this folder are SVG assets used by the Personal Workbench todo, +progress, and assistant capability entries. -Used on the Personal Workbench todo and progress lists. +The assistant capability icons are custom line icons designed for the +X-Financial workbench visual system. diff --git a/web/src/assets/workbench-icons/outline-approval.svg b/web/src/assets/workbench-icons/outline-approval.svg index 7baa2bb..d3c9348 100644 --- a/web/src/assets/workbench-icons/outline-approval.svg +++ b/web/src/assets/workbench-icons/outline-approval.svg @@ -1,6 +1,7 @@ -
+
@@ -56,5 +49,4 @@ defineProps({ const emit = defineEmits(['toggle', 'close', 'select']) - - + diff --git a/web/src/components/audit/DigitalEmployeeRunProducts.vue b/web/src/components/audit/DigitalEmployeeRunProducts.vue index d6b850a..085efdc 100644 --- a/web/src/components/audit/DigitalEmployeeRunProducts.vue +++ b/web/src/components/audit/DigitalEmployeeRunProducts.vue @@ -1,12 +1,13 @@ - + diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue index e9ddd46..60927d6 100644 --- a/web/src/components/layout/TopBar.vue +++ b/web/src/components/layout/TopBar.vue @@ -364,8 +364,9 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js' import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js' import { useTopBarWorkbenchPopovers } from '../../composables/useTopBarWorkbenchPopovers.js' -import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js' -import EnterpriseSelect from '../shared/EnterpriseSelect.vue' +import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js' +import { resolveDocumentNotificationId } from '../../utils/documentCenterNewState.js' +import EnterpriseSelect from '../shared/EnterpriseSelect.vue' const props = defineProps({ currentView: { type: Object, required: true }, @@ -520,7 +521,7 @@ function resolveWorkbenchNotificationId(item, index) { const documentNotificationItems = computed(() => documentInboxNotificationRows.value .map((row) => { - const id = normalizeNotificationId(`document:${row.documentKey || row.claimId || row.documentNo}`) + const id = normalizeNotificationId(resolveDocumentNotificationId(row)) if (!id || isNotificationHidden(id)) { return null } diff --git a/web/src/components/shared/DocumentDropdownFilter.vue b/web/src/components/shared/DocumentDropdownFilter.vue new file mode 100644 index 0000000..a540c4c --- /dev/null +++ b/web/src/components/shared/DocumentDropdownFilter.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/web/src/components/shared/RiskRuleFlowDiagram.vue b/web/src/components/shared/RiskRuleFlowDiagram.vue index 6ad26e3..d2fc16c 100644 --- a/web/src/components/shared/RiskRuleFlowDiagram.vue +++ b/web/src/components/shared/RiskRuleFlowDiagram.vue @@ -70,7 +70,7 @@ const props = defineProps({ severityLabel: { type: String, default: '中风险' } }) -const FONT = "Helvetica, Arial, sans-serif" +const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', 'Segoe UI', Arial, sans-serif" const TEXT = '#0d0d0d' const MUTED = '#6e6e80' const NEUTRAL_LINE = '#cbd5e1' diff --git a/web/src/components/shared/WorkbenchListIcon.vue b/web/src/components/shared/WorkbenchListIcon.vue index 94ef01c..8084a0a 100644 --- a/web/src/components/shared/WorkbenchListIcon.vue +++ b/web/src/components/shared/WorkbenchListIcon.vue @@ -39,13 +39,15 @@ const iconStyle = computed(() => iconMeta.value.style) .workbench-list-icon__halo { position: absolute; - top: 8px; - bottom: 8px; - left: 0; - width: 3px; + inset: 6px auto 6px -2px; + width: 4px; border-radius: 2px; - background: color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff); - opacity: 0.72; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff), + color-mix(in srgb, var(--icon-color, var(--theme-primary)) 28%, #ffffff) + ); + opacity: 0.88; } .workbench-list-icon__panel { @@ -57,18 +59,20 @@ const iconStyle = computed(() => iconMeta.value.style) place-items: center; overflow: hidden; border-radius: 4px; - border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 22%, var(--line, #e2e8f0)); + border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 24%, var(--line, #e2e8f0)); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.44)), + radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0) 48%), + linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.52)), linear-gradient( 135deg, - color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 64%, #fff) 0%, - #fff 52%, - color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, var(--surface-soft, #f8fafc)) 100% + color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 72%, #fff) 0%, + #fff 46%, + color-mix(in srgb, var(--icon-color, var(--theme-primary)) 12%, var(--surface-soft, #f8fafc)) 100% ); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), - 0 1px 2px rgba(15, 23, 42, 0.045); + inset 0 -1px 0 color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, rgba(255, 255, 255, 0.9)), + 0 8px 18px rgba(15, 23, 42, 0.055); } .workbench-list-icon__shine { @@ -95,10 +99,37 @@ const iconStyle = computed(() => iconMeta.value.style) } .workbench-list-icon--outline .workbench-list-icon__art :deep(.workbench-heroicon) { - stroke-width: 1.65; + stroke-width: 1.55; +} + +.workbench-list-icon__art :deep(.icon-fill) { + fill: currentColor; + stroke: none; + opacity: 0.09; +} + +.workbench-list-icon__art :deep(.icon-accent) { + opacity: 0.36; +} + +.workbench-list-icon__art :deep(.icon-muted) { + opacity: 0.62; } .workbench-list-icon--solid .workbench-list-icon__art :deep(.workbench-heroicon path) { opacity: 0.96; } + +.workbench-list-icon__art :deep(.workbench-image-icon) { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; + height: 44px; + max-width: none; + object-fit: contain; + display: block; + filter: drop-shadow(0 4px 8px rgba(15, 23, 42, 0.15)); +} diff --git a/web/src/components/travel/TravelReimbursementMessageItem.vue b/web/src/components/travel/TravelReimbursementMessageItem.vue index 3beb1df..18c5738 100644 --- a/web/src/components/travel/TravelReimbursementMessageItem.vue +++ b/web/src/components/travel/TravelReimbursementMessageItem.vue @@ -148,104 +148,111 @@
-
-
- 字段 - 内容 -
+
- {{ row.label }} - - - - - -
-
- - - +
+ 字段 + 内容 +
+
+ {{ row.label }} + + + + + +
+ + + + + +
-
- -
+ + +
风险标签 @@ -481,20 +490,26 @@ @@ -602,14 +649,12 @@ diff --git a/web/src/views/BudgetCenterView.vue b/web/src/views/BudgetCenterView.vue index ff45c2e..93cd6e0 100644 --- a/web/src/views/BudgetCenterView.vue +++ b/web/src/views/BudgetCenterView.vue @@ -20,21 +20,36 @@ - - - - - - + + +
diff --git a/web/src/views/DocumentsCenterView.vue b/web/src/views/DocumentsCenterView.vue index 031beef..8583c5e 100644 --- a/web/src/views/DocumentsCenterView.vue +++ b/web/src/views/DocumentsCenterView.vue @@ -248,8 +248,25 @@ import TableEmptyState from '../components/shared/TableEmptyState.vue' import TableLoadingState from '../components/shared/TableLoadingState.vue' import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js' import { mapExpenseClaimToRequest } from '../composables/useRequests.js' -import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js' -import { countNewDocuments, isNewDocument, markDocumentViewed, markDocumentsViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js' +import { + REIMBURSEMENT_LIST_PREVIEW_PARAMS, + extractExpenseClaimItems, + fetchApprovalExpenseClaims, + fetchArchivedExpenseClaims +} from '../services/reimbursements.js' +import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js' +import { + buildDocumentViewedStatePatch, + buildDocumentsViewedStatePatches, + countNewDocuments, + isNewDocument, + markDocumentViewed, + markDocumentsViewed, + mergeNotificationStatesIntoViewedDocumentKeys, + readDocumentScope, + readViewedDocumentKeys, + writeDocumentScope +} from '../utils/documentCenterNewState.js' import { sortDocumentRowsByLatestTime } from '../utils/documentCenterSort.js' import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js' import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js' @@ -860,9 +877,36 @@ function changePageSize(size) { currentPage.value = 1 } +function applyRemoteViewedDocumentStates(states) { + viewedDocumentKeys.value = mergeNotificationStatesIntoViewedDocumentKeys(states, viewedDocumentKeys.value) +} + +async function loadRemoteViewedDocumentKeys() { + try { + applyRemoteViewedDocumentStates(await fetchNotificationStates()) + } catch { + // 接口不可用时保留本机已读缓存,避免影响单据中心主流程。 + } +} + +async function syncDocumentViewedPatches(patches) { + const normalizedPatches = (Array.isArray(patches) ? patches : [patches]).filter(Boolean) + if (!normalizedPatches.length) { + return + } + + try { + applyRemoteViewedDocumentStates(await patchNotificationStates(normalizedPatches)) + } catch { + // 本机状态已先落地;远端失败时等待下次操作或刷新重试。 + } +} + function openDocument(row) { writeDocumentScope(activeScopeTab.value, scopeTabs) + const viewedPatch = buildDocumentViewedStatePatch(row) viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value) + void syncDocumentViewedPatches([viewedPatch]) emit('open-document', row.rawRequest || row) } @@ -871,7 +915,9 @@ function markAllDocumentsRead() { return } + const viewedPatches = buildDocumentsViewedStatePatches(allReadableDocumentRows.value, viewedDocumentKeys.value) viewedDocumentKeys.value = markDocumentsViewed(allReadableDocumentRows.value, viewedDocumentKeys.value) + void syncDocumentViewedPatches(viewedPatches) } async function loadSupportingRows() { @@ -879,30 +925,26 @@ async function loadSupportingRows() { supportingError.value = '' const [approvalResult, archiveResult] = await Promise.allSettled([ - fetchApprovalExpenseClaims(), - fetchArchivedExpenseClaims() + fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS), + fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS) ]) if (approvalResult.status === 'fulfilled') { approvalRows.value = excludeArchivedDocumentRows( - Array.isArray(approvalResult.value) - ? approvalResult.value + extractExpenseClaimItems(approvalResult.value) .map((item) => mapExpenseClaimToRequest(item)) .map((item) => buildDocumentRow(item, { source: 'approval' })) .filter(Boolean) - : [] ) } else { approvalRows.value = [] } if (archiveResult.status === 'fulfilled') { - archiveRows.value = Array.isArray(archiveResult.value) - ? archiveResult.value - .map((item) => mapExpenseClaimToRequest(item)) - .map((item) => buildDocumentRow(item, { source: 'archive', archived: true })) - .filter(Boolean) - : [] + archiveRows.value = extractExpenseClaimItems(archiveResult.value) + .map((item) => mapExpenseClaimToRequest(item)) + .map((item) => buildDocumentRow(item, { source: 'archive', archived: true })) + .filter(Boolean) } else { archiveRows.value = [] supportingError.value = archiveResult.reason instanceof Error @@ -915,6 +957,7 @@ async function loadSupportingRows() { function reloadAll() { emit('reload') + void loadRemoteViewedDocumentKeys() void loadSupportingRows() } @@ -963,6 +1006,7 @@ watch(documentSummary, (summary) => { }, { immediate: true }) onMounted(() => { + void loadRemoteViewedDocumentKeys() void loadSupportingRows() }) @@ -970,6 +1014,7 @@ watch( () => props.refreshToken, (token, previousToken) => { if (token && token !== previousToken) { + void loadRemoteViewedDocumentKeys() void loadSupportingRows() } } diff --git a/web/src/views/EmployeeManagementView.vue b/web/src/views/EmployeeManagementView.vue index 9185079..e1df278 100644 --- a/web/src/views/EmployeeManagementView.vue +++ b/web/src/views/EmployeeManagementView.vue @@ -380,143 +380,38 @@ />
-
- - -
+ -
- - -
+ -
- - -
+
diff --git a/web/src/views/LogsView.vue b/web/src/views/LogsView.vue index 39cad12..55c82b0 100644 --- a/web/src/views/LogsView.vue +++ b/web/src/views/LogsView.vue @@ -211,4 +211,5 @@ import viewModel from './scripts/LogsView.js' export default viewModel + diff --git a/web/src/views/PoliciesView.vue b/web/src/views/PoliciesView.vue index 4c41138..520ea0c 100644 --- a/web/src/views/PoliciesView.vue +++ b/web/src/views/PoliciesView.vue @@ -23,48 +23,49 @@ {{ knowledgeSyncButtonLabel }}
- - -
- - -
-
- - - {{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }} - {{ uploadHint }} -
- -
+ +
+
+ + + {{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }} + {{ uploadHint }} +
+ +
- - - - + + + + - - - - + + + +
文件名称标签
文件名称标签 上传时间 版本 状态 归纳时间 上传人 操作
diff --git a/web/src/views/ReceiptFolderView.vue b/web/src/views/ReceiptFolderView.vue index f10c738..ce71836 100644 --- a/web/src/views/ReceiptFolderView.vue +++ b/web/src/views/ReceiptFolderView.vue @@ -20,9 +20,21 @@ - @@ -349,6 +361,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs' import { ElDialog } from 'element-plus/es/components/dialog/index.mjs' +import DocumentDropdownFilter from '../components/shared/DocumentDropdownFilter.vue' import EnterprisePagination from '../components/shared/EnterprisePagination.vue' import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue' import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue' @@ -365,6 +378,7 @@ import { } from '../services/receiptFolder.js' import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js' import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js' +import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js' const NEW_CLAIM_VALUE = '__new_claim__' const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size })) @@ -417,19 +431,17 @@ const activeRows = computed(() => { return receipts.value }) const showStatusColumn = computed(() => activeStatus.value !== 'linked') -const filteredRows = computed(() => { - const normalized = keyword.value.trim().toLowerCase() - if (!normalized) return activeRows.value - return activeRows.value.filter((item) => [ - item.file_name, - item.document_type_label, - item.scene_label, - item.summary, - item.amount, - item.document_date, - item.linked_claim_no - ].filter(Boolean).join('').toLowerCase().includes(normalized)) -}) +const { + filteredRows, + hasActiveReceiptFilters, + openReceiptFilterKey, + receiptFilterControls, + receiptFilters, + clearReceiptFilters, + resolveReceiptFilterLabel, + selectReceiptFilter, + toggleReceiptFilter +} = createReceiptFolderListFilterModel({ receipts, activeRows, keyword }) const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value))) const pageSummary = computed(() => `共 ${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`) const visibleRows = computed(() => { @@ -531,7 +543,15 @@ const associatePrimaryLabel = computed(() => { return associateStep.value === 1 ? '下一步' : '进入关联对话' }) -watch([activeStatus, keyword, pageSize], () => { +watch([ + activeStatus, + keyword, + pageSize, + () => receiptFilters.documentType, + () => receiptFilters.scene, + () => receiptFilters.month, + () => receiptFilters.quality +], () => { currentPage.value = 1 }) diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index bfc0152..800831a 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -90,14 +90,30 @@ -
+ +
+
+ +
+
+ 小财管家正在识别意图 +

我正在读取你的输入,准备拆解申请、报销和附件任务。

+
+
+ -
+
option.value === value)?.label || fallback +} + function resolveBudgetUpdatedAt(row) { return row?.updatedAt || row?.submittedAt || row?.archivedAt || '-' } @@ -99,8 +103,8 @@ export default { emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'], components: { BudgetTrendChart, + DocumentDropdownFilter, EnterprisePagination, - EnterpriseSelect, EnterpriseDetailCard, EnterpriseDetailPage, TableEmptyState, @@ -116,6 +120,7 @@ export default { const budgetLoading = ref(true) const budgetError = ref('') const selectedBudgetId = ref('') + const activeBudgetFilterKey = ref('') const filters = ref({ year: '2026', quarter: 'Q1', @@ -158,6 +163,9 @@ export default { () => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算' ) const statusOptions = computed(() => mapOptions(getBudgetStatusOptions(activeBudgetScope.value))) + const budgetYearFilterLabel = computed(() => resolveOptionLabel(yearOptions, filters.value.year, '年度')) + const budgetQuarterFilterLabel = computed(() => resolveOptionLabel(quarterOptions, filters.value.quarter, '季度')) + const budgetStatusFilterLabel = computed(() => resolveOptionLabel(statusOptions.value, filters.value.status, '状态')) const filteredBudgetRows = computed(() => activeScopeRows.value @@ -322,6 +330,15 @@ export default { budgetPage.value = 1 } + function toggleBudgetFilter(key) { + activeBudgetFilterKey.value = activeBudgetFilterKey.value === key ? '' : key + } + + function selectBudgetFilter(key, value) { + filters.value[key] = value + activeBudgetFilterKey.value = '' + } + function resolveScopedDepartments(options) { if (!isDepartmentBudgetMonitor.value) return options @@ -419,6 +436,7 @@ export default { BUDGET_SCOPE_ALL, BUDGET_SCOPE_ARCHIVE, BUDGET_SCOPE_REVIEW, + activeBudgetFilterKey, activeBudgetScope, budgetError, budgetKeyword, @@ -427,6 +445,9 @@ export default { budgetPageSize, budgetPageSizeOptions, budgetScopeTabs, + budgetQuarterFilterLabel, + budgetStatusFilterLabel, + budgetYearFilterLabel, backToList, canAuditBudgetDrafts, canEditBudget, @@ -447,8 +468,10 @@ export default { showEmpty, showTable, statusOptions, + selectBudgetFilter, totalBudgetPages, totalBudgetRows, + toggleBudgetFilter, visibleBudgetRows, yearOptions } diff --git a/web/src/views/scripts/EmployeeManagementView.js b/web/src/views/scripts/EmployeeManagementView.js index 7479fa4..326c9d0 100644 --- a/web/src/views/scripts/EmployeeManagementView.js +++ b/web/src/views/scripts/EmployeeManagementView.js @@ -1,6 +1,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' +import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.vue' import EnterprisePagination from '../../components/shared/EnterprisePagination.vue' import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import TableLoadingState from '../../components/shared/TableLoadingState.vue' @@ -449,10 +450,18 @@ function buildEmployeeSummary(employees) { } } +function mapSimpleFilterOptions(values, allLabel) { + return [ + { label: allLabel, value: '' }, + ...values.map((value) => ({ label: value, value })) + ] +} + export default { name: 'EmployeeManagementView', components: { ConfirmDialog, + DocumentDropdownFilter, EnterprisePagination, EnterpriseSelect, TableLoadingState, @@ -559,6 +568,12 @@ export default { ) ) ) + const departmentFilterOptions = computed(() => mapSimpleFilterOptions(departmentOptions.value, '全部部门')) + const gradeFilterOptions = computed(() => mapSimpleFilterOptions(gradeOptions.value, '全部职级')) + const roleDropdownOptions = computed(() => mapSimpleFilterOptions(roleFilterOptions.value, '全部角色')) + const departmentFilterLabel = computed(() => selectedDepartment.value || '组织部门') + const gradeFilterLabel = computed(() => selectedGrade.value || '职级') + const roleFilterLabel = computed(() => selectedRole.value || '系统角色') const managerOptions = computed(() => { const currentId = selectedEmployee.value?.id @@ -1440,6 +1455,12 @@ export default { selectedDepartment, selectedGrade, selectedRole, + departmentFilterLabel, + departmentFilterOptions, + gradeFilterLabel, + gradeFilterOptions, + roleDropdownOptions, + roleFilterLabel, activeFilterPopover, currentPage, pageSize, diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index 922696d..f642205 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -22,6 +22,10 @@ import { formatStewardOntologyFields } from './stewardPlanModel.js' import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js' +import { + buildStewardFieldCompletionContinuation, + buildStewardFieldCompletionRawText +} from './stewardFieldCompletionModel.js' import { buildOperationFeedbackPayload, normalizeOperationFeedbackContext @@ -30,7 +34,7 @@ import { recognizeOcrFiles } from '../../services/ocr.js' import { fetchAgentRunDetail } from '../../services/agentAssets.js' import { createOperationFeedback } from '../../services/operationFeedback.js' import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js' -import { fetchStewardPlan, fetchStewardPlanStream } from '../../services/steward.js' +import { fetchStewardPlan, fetchStewardPlanStream, fetchStewardRuntimeDecision } from '../../services/steward.js' import { renderMarkdown } from '../../utils/markdown.js' import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js' import { @@ -51,10 +55,12 @@ import { resolveSuggestedActionPrefill } from '../../utils/assistantSuggestedActionPrefill.js' import { + APPLICATION_TRANSPORT_MODE_OPTIONS, buildApplicationPreviewFooterMessage, buildApplicationPreviewSubmitText, buildLocalApplicationPreviewMessage, - normalizeApplicationPreview + normalizeApplicationPreview, + normalizeTransportModeOption } from '../../utils/expenseApplicationPreview.js' import { TRAVEL_PLANNING_ACTION_GENERATE, @@ -208,8 +214,19 @@ import { } from './travelReimbursementConversationModel.js' const STEWARD_ASSISTANT_NAME = '小财管家' -const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 18 -const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 14 +const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 10 +const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 8 +const STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4 +const STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5 +const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field' +const APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN = /^(确认|确定|确认提交|确定提交|提交|提交审批|确认审批|确认无误|核对无误|信息无误|无误|没问题|可以提交|确认进入审批|提交至审批流程|确认提交审批|同意提交)$/ +const APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN = /^(不|否|否定|取消|暂不|先不|不确认|不提交|再检查|再看看|等等|等一下)/ +const STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN = /^(继续|继续执行|下一步|继续下一步|开始下一步|处理下一项|继续处理|确认开始|确定开始|可以|好的|好|行)$/ +const STEWARD_RUNTIME_CANCEL_TEXT_PATTERN = /^(取消|暂不|先不|不用|不要|不继续|不处理|先等等|等一下|停止|终止|算了)$/ +const STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH = 12 +const STEWARD_RUNTIME_BUSINESS_HINT_PATTERN = /(申请|报销|出差|差旅|招待|交通费|住宿费|餐费|发票|票据|费用|预算|借款|付款|审批|审核)/ +const STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN = /(今天|明天|后天|昨天|前天|\d{1,2}月\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}|我要|帮我|需要|创建|填写|处理|去|前往)/ +const STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN = /(当前|这个|这一步|上面|上述|申请单|核对表|出行方式|交通方式|火车|高铁|动车|飞机|轮船|提交|审批|确认)/ const REVIEW_RISK_LEVEL_META = { high: { @@ -692,6 +709,19 @@ export default { const assistantHeaderDescription = computed(() => isStewardSession.value ? '统一财务任务编排入口' : '个人工作窗,一站式费控解决枢纽' ) + const hasStewardInitialAutoSubmitPayload = computed(() => ( + isStewardSession.value && + props.initialPromptAutoSubmit !== false && + ( + Boolean(String(props.initialPrompt || '').trim()) || + (Array.isArray(props.initialFiles) && props.initialFiles.length > 0) + ) + )) + const showStewardInitialRecognition = computed(() => ( + hasStewardInitialAutoSubmitPayload.value && + !messages.value.length && + (workbenchVisible.value || submitting.value) + )) const { flowRunId, flowSteps, @@ -1235,6 +1265,7 @@ export default { return [] } const accessibleModes = filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value) + .filter((mode) => mode.key !== SESSION_TYPE_STEWARD) const visibleModes = props.entrySource === 'budget' ? accessibleModes.filter((mode) => mode.key === SESSION_TYPE_BUDGET) : accessibleModes @@ -1444,7 +1475,7 @@ export default { function scrollToBottom() { const scrollOnce = () => { - const list = messageListRef.value + const list = messageListRef.value?.$el || messageListRef.value if (!list) { return false } @@ -1611,6 +1642,141 @@ export default { return true } + function buildApplicationPreviewFieldAppliedText(message, fieldLabel = '', value = '') { + const missingFields = resolveApplicationPreviewMissingFields(message) + const resolvedFieldLabel = String(fieldLabel || '补充项').trim() + const resolvedValue = String(value || '').trim() + if (missingFields.length) { + return [ + `已更新:**${resolvedFieldLabel}:${resolvedValue}**。`, + '', + `我重新检查了一遍,当前还需要补充:**${missingFields.join('、')}**。`, + '', + '请继续补齐下方核对表里的待补充项;补齐后我再继续推进申请提交。' + ].join('\n') + } + return [ + `已更新:**${resolvedFieldLabel}:${resolvedValue}**。`, + '', + '我已经重新同步下方申请核对表和费用测算。', + '', + '请继续核查表格内容;如果信息无误,点击确认进入审批环节。' + ].join('\n') + } + + function isStewardApplicationPreviewFieldCompletion(targetMessage, payload = {}) { + return Boolean( + payload.steward_delegated_field_completion || + String(targetMessage?.assistantName || '').trim() === STEWARD_ASSISTANT_NAME || + targetMessage?.stewardPlan + ) + } + + async function continueStewardApplicationFieldCompletion({ + targetMessage, + action, + sourcePreview, + fieldKey, + fieldLabel, + value + }) { + if (!lockSuggestedActionMessage(targetMessage, action)) { + return true + } + + const continuation = buildStewardFieldCompletionContinuation( + targetMessage?.stewardContinuation || null, + fieldKey, + value + ) + const userText = `选择${fieldLabel || '补充项'}:${value}` + const carryText = buildStewardFieldCompletionRawText({ + preview: sourcePreview, + fieldKey, + fieldLabel, + value, + continuation + }) + + if (!action?.suppressUserEcho) { + messages.value.push(createMessage('user', userText)) + } + persistSessionState() + nextTick(scrollToBottom) + + await submitComposerInternal({ + rawText: carryText, + userText, + pendingText: '小财管家正在根据补齐信息查询票据并测算费用...', + files: [], + skipScopeGuard: true, + skipApplicationModelReview: true, + skipStewardPlan: true, + skipUserMessage: true, + sessionTypeOverride: SESSION_TYPE_APPLICATION, + stewardContinuation: continuation + }) + return true + } + + async function applyApplicationPreviewFieldAction(message, action) { + const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + const fieldKey = String(payload.field_key || payload.fieldKey || '').trim() + const fieldLabel = String(payload.field_label || payload.fieldLabel || action?.label || '').trim() + let value = String(payload.value || action?.label || '').trim() + const targetMessage = messages.value.find((item) => String(item.id || '') === String(message?.id || '')) || message + const sourcePreview = targetMessage?.applicationPreview || + payload.applicationPreview || + payload.application_preview || + payload.preview || + null + if (!sourcePreview || !fieldKey || !value) { + return false + } + if (fieldKey === 'transportMode') { + value = normalizeTransportModeOption(value, '') + } + if (fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(value)) { + toast('请选择有效的出行方式。') + return true + } + if (isStewardApplicationPreviewFieldCompletion(targetMessage, payload)) { + return continueStewardApplicationFieldCompletion({ + targetMessage, + action, + sourcePreview, + fieldKey, + fieldLabel, + value + }) + } + if (!lockSuggestedActionMessage(targetMessage, action)) { + return true + } + + targetMessage.applicationPreview = normalizeApplicationPreview(sourcePreview) + messages.value.push(createMessage('user', `选择${fieldLabel || '补充项'}:${value}`)) + openApplicationPreviewEditor(targetMessage, fieldKey, targetMessage.applicationPreview?.fields?.[fieldKey] || '') + applicationPreviewEditor.value = { + ...applicationPreviewEditor.value, + draftValue: value + } + await commitApplicationPreviewEditor(targetMessage) + if (String(targetMessage.assistantName || '').trim() === STEWARD_ASSISTANT_NAME || targetMessage.stewardPlan) { + targetMessage.assistantName = STEWARD_ASSISTANT_NAME + targetMessage.text = buildApplicationPreviewFieldAppliedText(targetMessage, fieldLabel, value) + const nextMeta = Array.isArray(targetMessage.meta) ? targetMessage.meta : [] + targetMessage.meta = Array.from(new Set([ + STEWARD_ASSISTANT_NAME, + resolveApplicationPreviewMissingFields(targetMessage).length ? '等待补充' : '等待用户确认', + ...nextMeta.filter((item) => String(item || '').trim() && item !== STEWARD_ASSISTANT_NAME) + ])).slice(0, 4) + } + persistSessionState() + nextTick(scrollToBottom) + return true + } + function pushExpenseSceneSelectionPrompt(originalMessage) { const sourceText = String(originalMessage || '').trim() if (!sourceText) { @@ -1650,6 +1816,27 @@ export default { if (await handleGuidedSuggestedAction(message, action)) return if (await handleSceneSelectionApplicationGate(message, action)) return + if (actionType === APPLICATION_PREVIEW_FIELD_ACTION_SET) { + await applyApplicationPreviewFieldAction(message, action) + return + } + + if (actionType === 'open_application_detail') { + const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim() + if (!claimId) { + toast('当前没有可查看的申请单据。') + return + } + if (!lockSuggestedActionMessage(message, action)) return + await router.push({ + name: 'app-document-detail', + params: { requestId: claimId } + }) + emit('close') + return + } + if (actionType === 'open_receipt_folder') { if (!lockSuggestedActionMessage(message, action)) return await router.push({ name: 'app-receiptFolder' }) @@ -1716,12 +1903,15 @@ export default { : '小财管家正在调用报销助手整理报销核对结果...', files: carryFiles, skipScopeGuard: true, + skipApplicationModelReview: targetSessionType === SESSION_TYPE_APPLICATION, + skipStewardSlotDecision: targetSessionType === SESSION_TYPE_APPLICATION, skipStewardPlan: true, skipUserMessage: confirmedByText, sessionTypeOverride: targetSessionType, stewardContinuation: { planId: String(actionPayload.steward_plan_id || '').trim(), currentTaskId: String(actionPayload.steward_next_task_id || '').trim(), + currentTask: actionPayload.steward_current_task || null, remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks) ? actionPayload.steward_remaining_tasks : [] @@ -2050,6 +2240,11 @@ export default { ) } + function canOpenDraftDetail(message) { + const draftPayload = message?.draftPayload || {} + return Boolean(String(draftPayload.claim_id || draftPayload.claimId || '').trim()) + } + function resolveReimbursementDraftClaimNo(draftPayload) { return String( draftPayload?.claim_no @@ -2057,7 +2252,7 @@ export default { || draftPayload?.claim_id || draftPayload?.claimId || '' - ).trim() || '待生成' + ).trim() || '保存后生成' } function updateMessageOperationFeedback(message, patch = {}) { @@ -2077,20 +2272,112 @@ export default { )) } - function isOperationFeedbackVisible(message) { - const feedback = message?.operationFeedback || null + function shouldShowAssistantMessageActions(message) { return Boolean( - feedback?.context - && !feedback.dismissed + message?.role === 'assistant' + && ( + String(message.text || '').trim() + || message.applicationPreview + || message.reviewPayload + || message.queryPayload + || message.draftPayload + || message.budgetReport + ) + && !(message.stewardPlan?.streamStatus === 'streaming' && !String(message.text || '').trim()) ) } - function dismissOperationFeedbackForMessage(message) { - updateMessageOperationFeedback(message, { - dismissed: true, - error: '' - }) - persistSessionState() + function buildMessageActionText(message) { + const parts = [] + const text = String(message?.text || '').trim() + if (text) { + parts.push(text) + } + const applicationPreview = message?.applicationPreview + ? normalizeApplicationPreview(message.applicationPreview) + : null + if (applicationPreview?.fields) { + const previewLines = resolveApplicationPreviewRows({ applicationPreview }).map((row) => + `${row.label}:${row.value || '待补充'}` + ) + if (previewLines.length) { + parts.push(previewLines.join('\n')) + } + } + if (message?.draftPayload) { + const claimNo = resolveReimbursementDraftClaimNo(message.draftPayload) + if (claimNo) { + parts.push(`单据:${claimNo}`) + } + } + return parts.join('\n\n').trim() + } + + async function copyAssistantMessage(message) { + const text = buildMessageActionText(message) + if (!text) { + toast('当前消息没有可复制的内容。') + return + } + try { + if (globalThis.navigator?.clipboard?.writeText) { + await globalThis.navigator.clipboard.writeText(text) + } else { + const textarea = globalThis.document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', 'readonly') + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + globalThis.document.body.appendChild(textarea) + textarea.select() + globalThis.document.execCommand('copy') + globalThis.document.body.removeChild(textarea) + } + toast('已复制。') + } catch (error) { + console.warn('Failed to copy assistant message:', error) + toast('复制失败,请稍后重试。') + } + } + + function speakAssistantMessage(message) { + const text = buildMessageActionText(message) + if (!text) { + toast('当前消息没有可播报的内容。') + return + } + const speech = globalThis.speechSynthesis + if (!speech || typeof globalThis.SpeechSynthesisUtterance === 'undefined') { + toast('当前浏览器不支持语音播报。') + return + } + speech.cancel() + const utterance = new globalThis.SpeechSynthesisUtterance(text.slice(0, 1200)) + utterance.lang = 'zh-CN' + utterance.rate = 1 + speech.speak(utterance) + } + + function buildMessageOperationFeedbackContext(message) { + const existingContext = message?.operationFeedback?.context || null + if (existingContext) { + return existingContext + } + const messageId = String(message?.id || '').trim() + const assistantName = String(message?.assistantName || '').trim() + return normalizeOperationFeedbackContext({ + run_id: messageId.slice(0, 50) || null, + conversation_id: String(conversationId.value || '').trim(), + user_id: resolveCurrentUserId(), + selected_agent: assistantName || (message?.stewardPlan ? STEWARD_ASSISTANT_NAME : 'user_agent'), + source: 'assistant_message_action', + session_type: activeSessionType.value, + operation_type: message?.stewardPlan ? 'steward_message' : 'assistant_message', + operation_status: 'succeeded', + status: 'succeeded', + entry_source: props.entrySource, + result_summary: buildMessageActionText(message).slice(0, 500) + }, currentUser.value || {}) } async function submitOperationFeedbackForMessage(message, feedback = {}) { @@ -2100,7 +2387,7 @@ export default { return } - const context = message?.operationFeedback?.context || null + const context = buildMessageOperationFeedbackContext(message) if (!context) { return } @@ -2109,6 +2396,7 @@ export default { submitting: true, rating, reason: String(feedback.reason || '').trim(), + context, error: '' }) try { @@ -2132,6 +2420,13 @@ export default { } } + function isMessageFeedbackSelected(message, rating) { + return Boolean( + message?.operationFeedback?.submitted + && Number(message.operationFeedback.rating || 0) === Number(rating || 0) + ) + } + async function openApplicationDraftDetail(message) { const draftPayload = message?.draftPayload || {} const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim() @@ -2154,6 +2449,632 @@ export default { return Array.isArray(normalizedPreview.missingFields) ? normalizedPreview.missingFields : [] } + function isApplicationSubmitConfirmationText(value = '') { + const normalized = String(value || '') + .replace(/\s+/g, '') + .replace(/[,,。.!!??;;::]/g, '') + if (!normalized || APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN.test(normalized)) { + return false + } + return APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN.test(normalized) + } + + function normalizeStewardRuntimeInputText(value = '') { + return String(value || '') + .replace(/\s+/g, '') + .replace(/[,,。.!!??;;::]/g, '') + .trim() + } + + function isStewardRuntimeContinueText(value = '') { + const normalized = normalizeStewardRuntimeInputText(value) + return Boolean(normalized && STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN.test(normalized)) + } + + function isStewardRuntimeCancelText(value = '') { + const normalized = normalizeStewardRuntimeInputText(value) + return Boolean(normalized && STEWARD_RUNTIME_CANCEL_TEXT_PATTERN.test(normalized)) + } + + function resolveStewardRuntimeTransportAlias(value = '') { + const normalized = normalizeStewardRuntimeInputText(value) + if (!normalized) { + return '' + } + const matchedModes = [] + if (/火车|高铁|动车|列车|铁路/.test(normalized)) { + matchedModes.push('火车') + } + if (/飞机|机票|航班|航空/.test(normalized)) { + matchedModes.push('飞机') + } + if (/轮船|船票|客轮|渡轮|坐船/.test(normalized)) { + matchedModes.push('轮船') + } + return matchedModes.length === 1 ? matchedModes[0] : '' + } + + function shouldPlanNewStewardTasksLocally(rawText = '', runtimeState = {}) { + const text = String(rawText || '').trim() + const normalized = normalizeStewardRuntimeInputText(text) + if ( + normalized.length < STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH || + isApplicationSubmitConfirmationText(normalized) || + isStewardRuntimeContinueText(normalized) || + isStewardRuntimeCancelText(normalized) + ) { + return false + } + if (!STEWARD_RUNTIME_BUSINESS_HINT_PATTERN.test(text) || !STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN.test(text)) { + return false + } + const waitingFor = String(runtimeState?.waiting_for || '').trim() + if (waitingFor && STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN.test(text)) { + return false + } + return true + } + + function findLatestApplicationPreviewMessage() { + for (const message of [...messages.value].reverse()) { + if ( + message?.role !== 'assistant' || + !message.applicationPreview || + message.applicationSubmitConfirmed + ) { + continue + } + return message + } + return null + } + + function findPendingApplicationSubmitMessage() { + const message = findLatestApplicationPreviewMessage() + if (!message) { + return null + } + const normalizedPreview = normalizeApplicationPreview(message.applicationPreview) + if (normalizedPreview.readyToSubmit) { + message.applicationPreview = normalizedPreview + return message + } + return null + } + + function pushApplicationSubmitBlockedMessage(userText = '', message = null, options = {}) { + const normalizedPreview = normalizeApplicationPreview(message?.applicationPreview || {}) + const missingFields = Array.isArray(normalizedPreview.missingFields) + ? normalizedPreview.missingFields + : [] + if (userText && !options.userMessageAlreadyAdded) { + messages.value.push(createMessage('user', userText)) + } + messages.value.push(createMessage( + 'assistant', + [ + '我理解你是在确认当前申请单,但这张申请单还不能提交。', + '', + missingFields.length + ? `还需要先补充:**${missingFields.join('、')}**。` + : '请先把申请核对表中的待补充信息补齐。', + '', + '补齐后再输入“确认”,我会继续提交至审批流程。' + ].join('\n'), + [], + { + assistantName: String(message?.assistantName || '').trim() || undefined, + meta: ['等待补充'] + } + )) + composerDraft.value = '' + persistSessionState() + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + } + + async function handleApplicationSubmitConfirmationText(options = {}) { + const rawText = String(options.rawText ?? composerDraft.value ?? '').trim() + const files = Array.from(options.files ?? attachedFiles.value ?? []) + if (!isApplicationSubmitConfirmationText(rawText) || files.length) { + return false + } + const latestApplicationMessage = findLatestApplicationPreviewMessage() + if (!latestApplicationMessage) { + return false + } + const targetMessage = findPendingApplicationSubmitMessage() + if (!targetMessage) { + pushApplicationSubmitBlockedMessage(rawText, latestApplicationMessage) + return true + } + applicationSubmitConfirmDialog.value = { + open: true, + message: targetMessage + } + await confirmApplicationSubmit({ userText: rawText }) + return true + } + + function findPendingStewardSuggestedActionContext(decision = null) { + const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim() + const targetTaskId = String(decision?.target_task_id || decision?.targetTaskId || '').trim() + for (const message of [...messages.value].reverse()) { + if ( + message?.role !== 'assistant' || + message.suggestedActionsLocked || + !Array.isArray(message.suggestedActions) || + !message.suggestedActions.length + ) { + continue + } + if (targetMessageId && String(message.id || '') !== targetMessageId) { + continue + } + const action = message.suggestedActions.find((item) => { + if (String(item?.action_type || '').trim() === APPLICATION_PREVIEW_FIELD_ACTION_SET) { + return false + } + const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {} + return !targetTaskId || + String(payload.steward_next_task_id || payload.target_task_id || '').trim() === targetTaskId + }) || message.suggestedActions[0] + if (action) { + return { message, action } + } + } + return null + } + + function findPendingSlotSuggestedActionContext(decision = null) { + const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim() + const fieldValue = String(decision?.field_value || decision?.fieldValue || '').trim() + for (const message of [...messages.value].reverse()) { + if ( + message?.role !== 'assistant' || + message.suggestedActionsLocked || + !Array.isArray(message.suggestedActions) || + !message.suggestedActions.length + ) { + continue + } + const action = message.suggestedActions.find((item) => { + if (String(item?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) { + return false + } + const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {} + const payloadField = String(payload.field_key || payload.fieldKey || '').trim() + const payloadValue = String(payload.value || item?.label || '').trim() + return payloadField && (!fieldKey || payloadField === fieldKey) && (!fieldValue || payloadValue === fieldValue) + }) + if (action) { + return { message, action } + } + } + return null + } + + function findPendingSlotSuggestedActionContextByInput(rawText = '') { + const normalizedInput = normalizeStewardRuntimeInputText(rawText) + if (!normalizedInput) { + return null + } + const transportAlias = resolveStewardRuntimeTransportAlias(normalizedInput) + for (const message of [...messages.value].reverse()) { + if ( + message?.role !== 'assistant' || + message.suggestedActionsLocked || + !Array.isArray(message.suggestedActions) || + !message.suggestedActions.length + ) { + continue + } + + const exactMatches = [] + const fuzzyMatches = [] + message.suggestedActions.forEach((action) => { + if (String(action?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) { + return + } + const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + const fieldKey = String(payload.field_key || payload.fieldKey || '').trim() + const value = String(payload.value || action?.label || '').trim() + const label = String(action?.label || value).trim() + const tokens = [value, label] + .map((item) => normalizeStewardRuntimeInputText(item)) + .filter(Boolean) + if (!fieldKey || !value || !tokens.length) { + return + } + if (tokens.includes(normalizedInput)) { + exactMatches.push({ message, action }) + return + } + const actionTransportAlias = resolveStewardRuntimeTransportAlias(`${value}${label}`) + if ( + transportAlias && + ( + tokens.includes(normalizeStewardRuntimeInputText(transportAlias)) || + actionTransportAlias === transportAlias + ) + ) { + fuzzyMatches.push({ message, action }) + return + } + if (tokens.some((token) => token.length >= 2 && normalizedInput.includes(token))) { + fuzzyMatches.push({ message, action }) + } + }) + + if (exactMatches.length === 1) { + return exactMatches[0] + } + if (exactMatches.length > 1) { + return null + } + const uniqueFuzzyMatches = fuzzyMatches.filter((item, index, list) => + list.findIndex((candidate) => candidate.action === item.action) === index + ) + if (uniqueFuzzyMatches.length === 1) { + return uniqueFuzzyMatches[0] + } + if (uniqueFuzzyMatches.length > 1) { + return null + } + } + return null + } + + function buildStewardRuntimeState() { + const latestApplicationMessage = findLatestApplicationPreviewMessage() + const applicationPreview = latestApplicationMessage?.applicationPreview + ? normalizeApplicationPreview(latestApplicationMessage.applicationPreview) + : null + const applicationContinuation = latestApplicationMessage?.stewardContinuation || null + const pendingSlotContext = findPendingSlotSuggestedActionContext() + const pendingStewardContext = pendingSlotContext ? null : findPendingStewardSuggestedActionContext() + const pendingActionPayload = pendingStewardContext?.action?.payload && typeof pendingStewardContext.action.payload === 'object' + ? pendingStewardContext.action.payload + : {} + const pendingSlotPayload = pendingSlotContext?.action?.payload && typeof pendingSlotContext.action.payload === 'object' + ? pendingSlotContext.action.payload + : {} + const continuation = applicationContinuation || pendingStewardContext?.message?.stewardContinuation || null + const remainingTasks = Array.isArray(continuation?.remainingTasks) + ? continuation.remainingTasks + : [] + const pendingApplication = latestApplicationMessage && applicationPreview + ? { + message_id: String(latestApplicationMessage.id || '').trim(), + task_id: String( + applicationContinuation?.currentTaskId || + applicationContinuation?.current_task_id || + applicationContinuation?.currentTask?.task_id || + applicationContinuation?.currentTask?.taskId || + '' + ).trim(), + ready_to_submit: Boolean(applicationPreview.readyToSubmit), + missing_fields: Array.isArray(applicationPreview.missingFields) ? applicationPreview.missingFields : [], + fields: applicationPreview.fields || {} + } + : null + return { + waiting_for: pendingApplication + ? (pendingApplication.ready_to_submit ? 'application_submit_confirmation' : 'application_field_completion') + : pendingSlotContext + ? 'application_field_completion' + : pendingStewardContext + ? 'steward_next_task_confirmation' + : '', + current_task: continuation?.currentTask || continuation?.current_task || null, + remaining_tasks: remainingTasks, + completed_tasks: messages.value + .filter((message) => message?.applicationSubmitConfirmed) + .map((message) => ({ + message_id: String(message.id || '').trim(), + task_type: 'expense_application' + })), + pending_application: pendingApplication, + pending_steward_action: pendingStewardContext + ? { + message_id: String(pendingStewardContext.message?.id || '').trim(), + action_type: String(pendingStewardContext.action?.action_type || '').trim(), + label: String(pendingStewardContext.action?.label || '').trim(), + target_task_id: String(pendingActionPayload.steward_next_task_id || pendingActionPayload.target_task_id || '').trim(), + payload: pendingActionPayload + } + : null, + pending_slot_action: pendingSlotContext + ? { + message_id: String(pendingSlotContext.message?.id || '').trim(), + field_key: String(pendingSlotPayload.field_key || pendingSlotPayload.fieldKey || '').trim(), + label: String(pendingSlotContext.action?.label || '').trim(), + payload: pendingSlotPayload + } + : null + } + } + + function hasActiveStewardRuntimeDecisionContext(runtimeState = {}) { + return Boolean( + String(runtimeState?.waiting_for || '').trim() || + runtimeState?.pending_application || + runtimeState?.pending_steward_action || + runtimeState?.pending_slot_action || + runtimeState?.current_task || + (Array.isArray(runtimeState?.remaining_tasks) && runtimeState.remaining_tasks.length > 0) || + (Array.isArray(runtimeState?.completed_tasks) && runtimeState.completed_tasks.length > 0) + ) + } + + function pushStewardRuntimeUserMessage(userText = '') { + const normalizedText = String(userText || '').trim() + if (!normalizedText) { + return false + } + messages.value.push(createMessage('user', normalizedText)) + composerDraft.value = '' + persistSessionState() + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + return true + } + + function pushStewardRuntimeResponse(userText = '', decision = null, options = {}) { + if (userText && !options.userMessageAlreadyAdded) { + messages.value.push(createMessage('user', userText)) + } + const text = String(decision?.question || decision?.response_text || decision?.responseText || decision?.rationale || '').trim() + if (text) { + messages.value.push(createMessage('assistant', text, [], { + assistantName: STEWARD_ASSISTANT_NAME, + meta: [STEWARD_ASSISTANT_NAME] + })) + } + composerDraft.value = '' + persistSessionState() + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + } + + function buildStewardRuntimeFastPathDecision(rawText = '', runtimeState = {}) { + const normalizedText = String(rawText || '').trim() + if (!normalizedText) { + return null + } + if (shouldPlanNewStewardTasksLocally(normalizedText, runtimeState)) { + return { + next_action: 'plan_new_tasks' + } + } + if (isStewardRuntimeCancelText(normalizedText)) { + return { + next_action: 'cancel_current_action', + response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果你要重新规划,请直接告诉我新的财务事项。' + } + } + const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText) + const payload = slotContext?.action?.payload && typeof slotContext.action.payload === 'object' + ? slotContext.action.payload + : {} + if (slotContext) { + return { + next_action: 'fill_current_slot', + target_message_id: String(slotContext.message?.id || '').trim(), + field_key: String(payload.field_key || payload.fieldKey || '').trim(), + field_value: String(payload.value || slotContext.action?.label || normalizedText).trim() + } + } + if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) { + if (runtimeState?.pending_application?.ready_to_submit) { + return { + next_action: 'submit_current_application', + target_message_id: runtimeState.pending_application.message_id || '' + } + } + if (runtimeState?.pending_steward_action) { + return { + next_action: 'continue_next_task', + target_message_id: runtimeState.pending_steward_action.message_id || '', + target_task_id: runtimeState.pending_steward_action.target_task_id || '' + } + } + } + if (String(runtimeState?.waiting_for || '').trim() === 'application_field_completion') { + if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) { + const missingFields = Array.isArray(runtimeState?.pending_application?.missing_fields) + ? runtimeState.pending_application.missing_fields + : [] + return { + next_action: 'ask_user', + response_text: missingFields.length + ? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。你可以直接回复对应选项或填写具体内容。` + : '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。' + } + } + } + return null + } + + function shouldUseStewardRuntimeLlmDecision(rawText = '', runtimeState = {}) { + if (shouldPlanNewStewardTasksLocally(rawText, runtimeState)) { + return false + } + const normalizedText = normalizeStewardRuntimeInputText(rawText) + if (!normalizedText) { + return false + } + if ( + isApplicationSubmitConfirmationText(normalizedText) || + isStewardRuntimeContinueText(normalizedText) || + isStewardRuntimeCancelText(normalizedText) + ) { + return false + } + if ( + findPendingSlotSuggestedActionContextByInput(normalizedText) + ) { + return false + } + return true + } + + async function executeStewardRuntimeDecision(decision = null, rawText = '', options = {}) { + const nextAction = String(decision?.next_action || decision?.nextAction || '').trim() + const userMessageAlreadyAdded = Boolean(options.userMessageAlreadyAdded) + if (nextAction === 'submit_current_application') { + const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim() + const targetMessage = targetMessageId + ? messages.value.find((message) => String(message.id || '') === targetMessageId) + : findPendingApplicationSubmitMessage() + if (!targetMessage?.applicationPreview) { + return false + } + const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview) + if (!normalizedPreview.readyToSubmit) { + pushApplicationSubmitBlockedMessage(rawText, targetMessage, { userMessageAlreadyAdded }) + return true + } + targetMessage.applicationPreview = normalizedPreview + applicationSubmitConfirmDialog.value = { open: true, message: targetMessage } + await confirmApplicationSubmit({ userText: rawText, skipUserMessage: userMessageAlreadyAdded }) + return true + } + if (nextAction === 'continue_next_task') { + const context = findPendingStewardSuggestedActionContext(decision) + if (!context) { + return false + } + if (rawText && !userMessageAlreadyAdded) { + messages.value.push(createMessage('user', rawText)) + } + context.action.confirmedByText = true + composerDraft.value = '' + persistSessionState() + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + await handleSuggestedAction(context.message, context.action) + return true + } + if (nextAction === 'fill_current_slot') { + const context = findPendingSlotSuggestedActionContext(decision) + if (!context) { + return false + } + await handleSuggestedAction(context.message, { + ...context.action, + label: String(decision?.field_value || decision?.fieldValue || context.action.label || '').trim(), + suppressUserEcho: userMessageAlreadyAdded + }) + return true + } + if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') { + pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded }) + return true + } + return false + } + + async function handleStewardRuntimeDecision(options = {}) { + if (!isStewardSession.value || options.skipStewardPlan) { + return false + } + const rawText = String(options.rawText ?? composerDraft.value ?? '').trim() + const files = Array.from(options.files ?? attachedFiles.value ?? []) + if (!rawText || files.length) { + return false + } + const runtimeState = buildStewardRuntimeState() + if (!hasActiveStewardRuntimeDecisionContext(runtimeState)) { + return false + } + const userMessageAlreadyAdded = options.skipUserMessage + ? false + : pushStewardRuntimeUserMessage(rawText) + try { + const fastDecision = buildStewardRuntimeFastPathDecision(rawText, runtimeState) + if (fastDecision) { + if (String(fastDecision.next_action || fastDecision.nextAction || '').trim() === 'plan_new_tasks') { + await submitStewardPlan({ + ...options, + rawText, + userText: rawText, + skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage + }) + return true + } + const fastExecuted = await executeStewardRuntimeDecision(fastDecision, rawText, { userMessageAlreadyAdded }) + if (fastExecuted) { + return true + } + } + if (!shouldUseStewardRuntimeLlmDecision(rawText, runtimeState)) { + if (userMessageAlreadyAdded) { + pushStewardRuntimeResponse('', { + response_text: '我还需要先确认当前等待项。请回复系统刚刚追问的选项或具体补充内容。' + }, { userMessageAlreadyAdded: true }) + return true + } + return false + } + const decision = await fetchStewardRuntimeDecision({ + user_message: rawText, + session_type: SESSION_TYPE_STEWARD, + runtime_state: runtimeState, + context_json: { + entry_source: props.entrySource, + user_id: resolveCurrentUserId() + } + }, { + timeoutMs: 45000, + timeoutMessage: '小财管家运行时决策超时,已回到当前上下文兜底处理。' + }) + if (String(decision?.next_action || decision?.nextAction || '').trim() === 'plan_new_tasks') { + await submitStewardPlan({ + ...options, + rawText, + userText: rawText, + skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage + }) + return true + } + const executed = await executeStewardRuntimeDecision(decision, rawText, { userMessageAlreadyAdded }) + if (executed) { + return true + } + if (userMessageAlreadyAdded) { + await submitStewardPlan({ + ...options, + rawText, + userText: rawText, + skipUserMessage: true + }) + return true + } + return false + } catch (error) { + console.warn('Steward runtime decision failed:', error) + if (userMessageAlreadyAdded) { + await submitStewardPlan({ + ...options, + rawText, + userText: rawText, + skipUserMessage: true + }) + return true + } + return false + } + } + function openApplicationSubmitConfirm(message) { if (!message) { return @@ -2240,6 +3161,7 @@ export default { auto_submit: true, steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation', steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(), + steward_current_task: nextTask, steward_remaining_tasks: restTasks } } @@ -2263,18 +3185,51 @@ export default { } } - function buildStewardFollowupThinkingEvents() { + function extractStewardCarryLine(text = '', label = '') { + const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[::]([^\\n]+)`, 'u')) + return match ? match[1].trim() : '' + } + + function extractStewardFollowupNextTitle(text = '') { + const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[::]([^。\n]+)/u) + if (taskMatch?.[1]) { + return taskMatch[1].trim() + } + const nextMatch = String(text || '').match(/下一步[::]([^。\n]+)/u) + return nextMatch?.[1]?.trim() || '下一项财务任务' + } + + function buildStewardFollowupThinkingEvents(finalMessage = null, actions = []) { const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}` + const firstAction = Array.isArray(actions) ? actions[0] : null + const actionPayload = firstAction?.payload && typeof firstAction.payload === 'object' ? firstAction.payload : {} + const carryText = String(actionPayload.carry_text || '').trim() + const finalText = String(finalMessage?.text || '').trim() + const nextTitle = extractStewardFollowupNextTitle(carryText || finalText) + const nextSummary = extractStewardCarryLine(carryText, '任务摘要') + const nextMissing = extractStewardCarryLine(carryText, '还需要补充') return [ { eventId: `${eventPrefix}-review`, title: '复盘结果', - content: '当前动作已完成,小财管家正在检查剩余任务队列。' + content: finalText.includes('申请单已完成') + ? '申请单已经完成,我把当前出差申请标记为已处理,不会重复创建。' + : '当前动作已经完成,我会把已完成事项从任务队列中移除。' }, { eventId: `${eventPrefix}-next`, - title: '选择下一步', - content: '我会继续保持一步一步推进,先说明下一步,再等你确认后执行。' + title: '读取剩余任务', + content: nextSummary + ? `剩余队列里的下一项是“${nextTitle}”:${nextSummary}。` + : `剩余队列里的下一项是“${nextTitle}”。` + }, + { + eventId: `${eventPrefix}-gate`, + title: '判断下一步条件', + content: nextMissing + ? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。` + : '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。' } ] } @@ -2304,7 +3259,7 @@ export default { nextTick(scrollToBottom) const typedEvents = [] - for (const eventData of buildStewardFollowupThinkingEvents()) { + for (const eventData of buildStewardFollowupThinkingEvents(finalMessage, finalActions)) { const event = { eventId: eventData.eventId, stage: 'steward_followup', @@ -2318,11 +3273,12 @@ export default { nextTick(scrollToBottom) const chars = Array.from(eventData.content) - for (let index = 0; index < chars.length; index += 1) { + for (let index = 0; index < chars.length;) { await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS) - event.content = chars.slice(0, index + 1).join('') + index = Math.min(chars.length, index + STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE) + event.content = chars.slice(0, index).join('') finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId) - if ((index + 1) % 4 === 0 || index === chars.length - 1) { + if (index % STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE === 0 || index === chars.length) { nextTick(scrollToBottom) } } @@ -2335,12 +3291,13 @@ export default { finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中'] finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId) const chars = Array.from(finalText) - for (let index = 0; index < chars.length; index += 1) { + for (let index = 0; index < chars.length;) { await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS) - finalMessage.text = chars.slice(0, index + 1).join('') + index = Math.min(chars.length, index + STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE) + finalMessage.text = chars.slice(0, index).join('') finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中'] finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId) - if ((index + 1) % 4 === 0 || index === chars.length - 1) { + if (index % STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) { nextTick(scrollToBottom) } } @@ -2356,7 +3313,11 @@ export default { function buildStewardContinuationCarryText(task, restTasks = []) { const taskType = String(task?.task_type || task?.taskType || '').trim() const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType) - const missingFields = formatStewardMissingFieldList(task?.missing_fields || task?.missingFields || [], taskType) + const missingFields = formatStewardMissingFieldList( + task?.missing_fields || task?.missingFields || [], + taskType, + { includeHints: false } + ) const lines = [ taskType === 'expense_application' ? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}。` @@ -2364,7 +3325,9 @@ export default { task.summary ? `任务摘要:${task.summary}` : '', fields ? `已识别信息:${fields}` : '', missingFields ? `还需要补充:${missingFields}` : '', - '请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。' + missingFields + ? '请先追问上述缺失信息,不要直接生成核对结果,也不要替用户默认填写。' + : '请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。' ] if (restTasks.length) { lines.push('当前步骤完成后,请继续引导我处理剩余任务:') @@ -2384,7 +3347,7 @@ export default { return buildStewardFieldItems(fields, taskType) } - async function confirmApplicationSubmit() { + async function confirmApplicationSubmit(options = {}) { const message = applicationSubmitConfirmDialog.value.message if (!message || submitting.value || reviewActionBusy.value) { return @@ -2400,15 +3363,18 @@ export default { open: false, message: null } + const stewardSubmitContinuation = message?.stewardContinuation || null reviewActionBusy.value = true try { const payload = await submitComposer({ rawText: applicationSubmitText, - userText: '确认提交', + userText: String(options.userText || '').trim() || '确认提交', + skipUserMessage: Boolean(options.skipUserMessage), pendingText: '正在提交费用申请...', systemGenerated: true, skipScopeGuard: true, skipStewardPlan: true, + stewardContinuation: stewardSubmitContinuation, sessionTypeOverride: SESSION_TYPE_APPLICATION, feedbackOperationType: 'submit_application', extraContext: { @@ -2429,6 +3395,7 @@ export default { const claimNo = String(draftPayload.claim_no || '').trim() const claimId = String(draftPayload.claim_id || '').trim() if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) { + message.applicationSubmitConfirmed = true emit('draft-saved', { claimId, claimNo, @@ -2678,6 +3645,12 @@ export default { // submitting.value = true // recognizeOcrFiles(files) // submitting.value = false + if (await handleStewardRuntimeDecision(options)) { + return null + } + if (await handleApplicationSubmitConfirmationText(options)) { + return null + } if (isStewardSession.value && !options.skipStewardPlan && await submitStewardPlan(options)) { return null } @@ -2820,10 +3793,13 @@ export default { resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, shouldShowDraftSavedCard, + canOpenDraftDetail, resolveReimbursementDraftClaimNo, openApplicationDraftDetail, - isOperationFeedbackVisible, - dismissOperationFeedbackForMessage, + shouldShowAssistantMessageActions, + copyAssistantMessage, + speakAssistantMessage, + isMessageFeedbackSelected, submitOperationFeedbackForMessage, runWelcomeQuickAction: runShortcut, handleSuggestedAction, @@ -2927,7 +3903,7 @@ export default { return { emit, messageItemUi, insightPanelUi, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection, toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText, - attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, isStewardSession, hotKnowledgeQuestions, + attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, isStewardSession, showStewardInitialRecognition, hotKnowledgeQuestions, hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, assistantHeaderTitle, assistantHeaderDescription, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer, reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument, reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS, @@ -2937,7 +3913,7 @@ export default { renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles, requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, - queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, shouldShowDraftSavedCard, resolveReimbursementDraftClaimNo, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft + queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, shouldShowDraftSavedCard, resolveReimbursementDraftClaimNo, openApplicationDraftDetail, shouldShowAssistantMessageActions, copyAssistantMessage, speakAssistantMessage, isMessageFeedbackSelected, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft } } } diff --git a/web/src/views/scripts/budgetAssistantReportModel.js b/web/src/views/scripts/budgetAssistantReportModel.js index 6009ddf..b0f05bc 100644 --- a/web/src/views/scripts/budgetAssistantReportModel.js +++ b/web/src/views/scripts/budgetAssistantReportModel.js @@ -203,7 +203,7 @@ export function shouldUseBudgetCompileReport(rawText, options = {}) { const isBudgetContextEditIntent = isBudgetContext && hasBudgetKeyword && hasCompileKeyword return Boolean( - budgetContext || + (isBudgetContext && budgetContext) || ( text && (isWholeBudgetCompileIntent || isBudgetContextPeriodIntent || isBudgetContextEditIntent) diff --git a/web/src/views/scripts/receiptFolderListFilters.js b/web/src/views/scripts/receiptFolderListFilters.js new file mode 100644 index 0000000..1996b40 --- /dev/null +++ b/web/src/views/scripts/receiptFolderListFilters.js @@ -0,0 +1,179 @@ +import { computed, reactive, ref } from 'vue' + +export const RECEIPT_FILTER_ALL = 'all' + +const QUALITY_OPTIONS = [ + { value: RECEIPT_FILTER_ALL, label: '全部置信度' }, + { value: 'high', label: '高置信度' }, + { value: 'medium', label: '中等置信度' }, + { value: 'low', label: '低置信度' }, + { value: 'missing', label: '待确认' } +] + +function normalizeText(value) { + return String(value ?? '').trim() +} + +function getFilterValue(filters, key) { + return normalizeText(filters?.[key]) || RECEIPT_FILTER_ALL +} + +function buildUniqueOptions(rows, valueKey, labelKey, allLabel) { + const seen = new Map() + for (const row of Array.isArray(rows) ? rows : []) { + const value = normalizeText(row?.[valueKey]) + if (!value || seen.has(value)) continue + seen.set(value, normalizeText(row?.[labelKey]) || value) + } + + return [ + { value: RECEIPT_FILTER_ALL, label: allLabel }, + ...Array.from(seen.entries()) + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label, 'zh-Hans-CN')) + ] +} + +function resolveReceiptMonth(row) { + const raw = normalizeText(row?.document_date) || normalizeText(row?.uploaded_at) + const match = raw.match(/^(\d{4})[-/年]?(\d{1,2})/) + if (!match) return '' + return `${match[1]}-${String(match[2]).padStart(2, '0')}` +} + +function buildMonthOptions(rows) { + const months = new Set((Array.isArray(rows) ? rows : []).map(resolveReceiptMonth).filter(Boolean)) + return [ + { value: RECEIPT_FILTER_ALL, label: '全部月份' }, + ...Array.from(months) + .sort((left, right) => right.localeCompare(left)) + .map((value) => ({ value, label: `${value.replace('-', '年')}月` })) + ] +} + +function resolveScore(row) { + const score = Number(row?.avg_score || 0) + return Number.isFinite(score) ? score : 0 +} + +function matchesQuality(row, quality) { + if (quality === RECEIPT_FILTER_ALL) return true + const score = resolveScore(row) + if (quality === 'missing') return score <= 0 + if (quality === 'high') return score >= 0.9 + if (quality === 'medium') return score >= 0.75 && score < 0.9 + if (quality === 'low') return score > 0 && score < 0.75 + return true +} + +export function buildReceiptFilterControls(rows, filters) { + return [ + { + key: 'documentType', + label: '票据类型', + options: buildUniqueOptions(rows, 'document_type', 'document_type_label', '全部类型') + }, + { + key: 'scene', + label: '费用场景', + options: buildUniqueOptions(rows, 'scene_code', 'scene_label', '全部场景') + }, + { + key: 'month', + label: '票据月份', + options: buildMonthOptions(rows) + }, + { + key: 'quality', + label: '置信度', + options: QUALITY_OPTIONS + } + ].map((control) => ({ + ...control, + value: getFilterValue(filters, control.key) + })) +} + +export function applyReceiptListFilters(rows, filters) { + const documentType = getFilterValue(filters, 'documentType') + const scene = getFilterValue(filters, 'scene') + const month = getFilterValue(filters, 'month') + const quality = getFilterValue(filters, 'quality') + + return (Array.isArray(rows) ? rows : []).filter((row) => ( + (documentType === RECEIPT_FILTER_ALL || normalizeText(row?.document_type) === documentType) + && (scene === RECEIPT_FILTER_ALL || normalizeText(row?.scene_code) === scene) + && (month === RECEIPT_FILTER_ALL || resolveReceiptMonth(row) === month) + && matchesQuality(row, quality) + )) +} + +export function buildReceiptFilterTokens(controls, filters) { + return (Array.isArray(controls) ? controls : []) + .map((control) => { + const value = getFilterValue(filters, control.key) + if (value === RECEIPT_FILTER_ALL) return '' + const option = control.options.find((item) => item.value === value) + return `${control.label}:${option?.label || value}` + }) + .filter(Boolean) +} + +export function createReceiptFolderListFilterModel({ receipts, activeRows, keyword }) { + const openReceiptFilterKey = ref('') + const receiptFilters = reactive({ + documentType: RECEIPT_FILTER_ALL, + scene: RECEIPT_FILTER_ALL, + month: RECEIPT_FILTER_ALL, + quality: RECEIPT_FILTER_ALL + }) + const receiptFilterControls = computed(() => buildReceiptFilterControls(receipts.value, receiptFilters)) + const hasActiveReceiptFilters = computed(() => Object.values(receiptFilters).some((value) => value !== RECEIPT_FILTER_ALL)) + const filteredRows = computed(() => { + const normalized = keyword.value.trim().toLowerCase() + const filtered = applyReceiptListFilters(activeRows.value, receiptFilters) + if (!normalized) return filtered + return filtered.filter((item) => [ + item.file_name, + item.document_type_label, + item.scene_label, + item.summary, + item.amount, + item.document_date, + item.linked_claim_no + ].filter(Boolean).join('').toLowerCase().includes(normalized)) + }) + + function toggleReceiptFilter(key) { + openReceiptFilterKey.value = openReceiptFilterKey.value === key ? '' : key + } + + function selectReceiptFilter(key, value) { + receiptFilters[key] = value + openReceiptFilterKey.value = '' + } + + function resolveReceiptFilterLabel(control) { + return control.options.find((option) => option.value === receiptFilters[control.key])?.label || control.label + } + + function clearReceiptFilters() { + receiptFilters.documentType = RECEIPT_FILTER_ALL + receiptFilters.scene = RECEIPT_FILTER_ALL + receiptFilters.month = RECEIPT_FILTER_ALL + receiptFilters.quality = RECEIPT_FILTER_ALL + openReceiptFilterKey.value = '' + } + + return { + filteredRows, + hasActiveReceiptFilters, + openReceiptFilterKey, + receiptFilterControls, + receiptFilters, + clearReceiptFilters, + resolveReceiptFilterLabel, + selectReceiptFilter, + toggleReceiptFilter + } +} diff --git a/web/src/views/scripts/stewardFieldCompletionModel.js b/web/src/views/scripts/stewardFieldCompletionModel.js new file mode 100644 index 0000000..c65aef2 --- /dev/null +++ b/web/src/views/scripts/stewardFieldCompletionModel.js @@ -0,0 +1,131 @@ +import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js' + +const APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_MAP = { + applicationType: 'expense_type', + time: 'time_range', + location: 'location', + reason: 'reason', + amount: 'amount', + transportMode: 'transport_mode', + department: 'department_name', + applicant: 'employee_name', + grade: 'employee_grade' +} + +const APPLICATION_PREVIEW_FIELD_LABEL_MAP = { + applicationType: '申请类型', + time: '时间', + location: '地点', + reason: '事由', + amount: '金额', + transportMode: '出行方式', + department: '所属部门', + applicant: '申请人', + grade: '职级' +} + +function compactValue(value = '') { + return String(value || '').trim() +} + +function resolveStewardCurrentTask(continuation = null) { + const task = continuation?.currentTask || continuation?.current_task || null + return task && typeof task === 'object' ? task : null +} + +function resolveTaskOntologyFields(task = null) { + const fields = task?.ontology_fields || task?.ontologyFields || {} + return fields && typeof fields === 'object' ? fields : {} +} + +function resolveFieldValue(...candidates) { + for (const candidate of candidates) { + const value = compactValue(candidate) + if (value && !['待补充', '待测算', '未知'].includes(value)) { + return value + } + } + return '' +} + +function buildUpdatedTask(task = null, fieldKey = '', value = '') { + if (!task || typeof task !== 'object') { + return null + } + + const canonicalKey = APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_MAP[fieldKey] || '' + if (!canonicalKey) { + return { ...task } + } + + const ontologyFields = { + ...resolveTaskOntologyFields(task), + [canonicalKey]: value + } + const sourceMissingFields = Array.isArray(task.missing_fields) + ? task.missing_fields + : Array.isArray(task.missingFields) + ? task.missingFields + : [] + + return { + ...task, + ontology_fields: ontologyFields, + missing_fields: sourceMissingFields.filter((field) => compactValue(field) !== canonicalKey) + } +} + +export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') { + const source = continuation && typeof continuation === 'object' ? continuation : {} + const currentTask = resolveStewardCurrentTask(source) + const updatedTask = buildUpdatedTask(currentTask, fieldKey, value) + if (!updatedTask) { + return source + } + + return { + ...source, + currentTask: updatedTask + } +} + +export function buildStewardFieldCompletionRawText({ + preview = {}, + fieldKey = '', + fieldLabel = '', + value = '', + continuation = null +} = {}) { + const normalizedPreview = normalizeApplicationPreview(preview) + const fields = normalizedPreview.fields || {} + const currentTask = resolveStewardCurrentTask(continuation) + const ontologyFields = resolveTaskOntologyFields(currentTask) + const selectedLabel = compactValue(fieldLabel) || APPLICATION_PREVIEW_FIELD_LABEL_MAP[fieldKey] || '补充项' + const selectedValue = compactValue(value) + const transportMode = fieldKey === 'transportMode' + ? selectedValue + : resolveFieldValue(fields.transportMode, ontologyFields.transport_mode) + + const knownLines = [ + ['申请类型', resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')], + ['时间', resolveFieldValue(fields.time, ontologyFields.time_range)], + ['地点', resolveFieldValue(fields.location, ontologyFields.location)], + ['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)], + ['天数', resolveFieldValue(fields.days)], + ['出行方式', transportMode] + ] + .filter(([, fieldValue]) => fieldValue) + .map(([label, fieldValue]) => `${label}:${fieldValue}`) + + return [ + '小财管家继续执行申请单字段补齐。', + `用户已补充:${selectedLabel}:${selectedValue}。`, + currentTask?.summary ? `任务摘要:${currentTask.summary}` : '', + '', + '已识别信息:', + ...knownLines, + '', + '处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。', + '如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。' + ].filter((line) => line !== '').join('\n') +} diff --git a/web/src/views/scripts/stewardPlanModel.js b/web/src/views/scripts/stewardPlanModel.js index a6ddc96..e2a6226 100644 --- a/web/src/views/scripts/stewardPlanModel.js +++ b/web/src/views/scripts/stewardPlanModel.js @@ -79,6 +79,25 @@ const FIELD_ALIASES = { application_transport_mode: 'transport_mode' } +const APPLICATION_NON_BLOCKING_MISSING_FIELDS = new Set([ + 'amount', + 'attachments', + 'employee_no', + 'employee_name', + 'department_name' +]) + +const FIELD_VALUE_DISPLAY_CONFIG = { + expense_type: { + travel: '差旅', + business_entertainment: '业务招待', + transportation: '交通费', + traffic: '交通费', + accommodation: '住宿费', + meal: '餐饮费' + } +} + export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) { const safeFiles = Array.isArray(files) ? files : [] return { @@ -123,9 +142,10 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) { tasks: Array.isArray(rawPlan.tasks) ? rawPlan.tasks.map((item) => { const taskType = String(item.task_type || item.taskType || '') - const missingFields = Array.isArray(item.missing_fields || item.missingFields) + const rawMissingFields = Array.isArray(item.missing_fields || item.missingFields) ? item.missing_fields || item.missingFields : [] + const missingFields = filterStewardBlockingMissingFields(rawMissingFields, taskType) return { taskId: String(item.task_id || item.taskId || ''), taskType, @@ -188,7 +208,7 @@ export function buildStewardPlanMessageText(plan) { } export function buildStewardFieldItems(fields = [], taskType = '') { - const safeFields = Array.isArray(fields) ? fields : [] + const safeFields = filterStewardBlockingMissingFields(fields, taskType) const seen = new Set() return safeFields .map((field) => normalizeFieldKey(field)) @@ -202,18 +222,44 @@ export function buildStewardFieldItems(fields = [], taskType = '') { .map((field) => resolveFieldDisplay(field, taskType)) } -export function formatStewardMissingFieldList(fields = [], taskType = '') { +export function formatStewardMissingFieldList(fields = [], taskType = '', options = {}) { + const includeHints = options.includeHints !== false return buildStewardFieldItems(fields, taskType) - .map((item) => item.hint ? `${item.label}(${item.hint})` : item.label) + .map((item) => includeHints && item.hint ? `${item.label}(${item.hint})` : item.label) .join('、') } +export function filterStewardBlockingMissingFields(fields = [], taskType = '') { + const safeFields = Array.isArray(fields) ? fields : [] + const seen = new Set() + if (taskType !== 'expense_application') { + return safeFields + .map((field) => normalizeFieldKey(field)) + .filter((field) => { + if (!field || seen.has(field)) { + return false + } + seen.add(field) + return true + }) + } + return safeFields + .map((field) => normalizeFieldKey(field)) + .filter((field) => { + if (!field || seen.has(field) || APPLICATION_NON_BLOCKING_MISSING_FIELDS.has(field)) { + return false + } + seen.add(field) + return true + }) +} + export function formatStewardOntologyFields(fields = {}, taskType = '') { return Object.entries(fields || {}) .filter(([, value]) => String(value || '').trim()) .map(([key, value]) => { const field = resolveFieldDisplay(key, taskType) - return `${field.label}:${value}` + return `${field.label}:${formatStewardFieldDisplayValue(field.key, value)}` }) .join(';') } @@ -246,6 +292,7 @@ export function buildStewardSuggestedActions(plan) { steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''), steward_plan_id: normalized.planId, steward_next_task_id: task?.taskId || '', + steward_current_task: buildStewardTaskPayload(task), steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length, steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId) } @@ -447,7 +494,11 @@ function buildStewardCarryText(actionType, task, group, normalized = null) { } const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType) - const missingFields = formatStewardMissingFieldList(task.missingFields || [], task.taskType) + const missingFields = formatStewardMissingFieldList( + task.missingFields || [], + task.taskType, + { includeHints: false } + ) const lines = [ actionType === 'confirm_create_application' ? `小财管家已完成意图识别,请先创建申请单:${task.title || task.taskTypeLabel}。` @@ -458,8 +509,12 @@ function buildStewardCarryText(actionType, task, group, normalized = null) { group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '', missingFields ? `还需要补充:${missingFields}` : '', actionType === 'confirm_create_application' - ? '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。' - : '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。' + ? missingFields + ? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。' + : '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。' + : missingFields + ? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。' + : '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。' ] const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : '' if (remainingTaskText) { @@ -495,6 +550,12 @@ function resolveFieldDisplay(field, taskType = '') { } } +function formatStewardFieldDisplayValue(field, value) { + const key = normalizeFieldKey(field) + const normalizedValue = String(value || '').trim() + return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue +} + function buildRemainingTaskText(normalized, currentTaskId) { const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId) if (!remainingTasks.length) { @@ -512,13 +573,20 @@ function buildRemainingTaskText(normalized, currentTaskId) { function buildRemainingTaskPayload(normalized, currentTaskId) { return normalized.tasks .filter((task) => task.taskId !== currentTaskId) - .map((task) => ({ - task_id: task.taskId, - task_type: task.taskType, - title: task.title, - summary: task.summary, - assigned_agent: task.assignedAgent, - ontology_fields: task.ontologyFields || {}, - missing_fields: task.missingFields || [] - })) + .map((task) => buildStewardTaskPayload(task)) +} + +function buildStewardTaskPayload(task) { + if (!task) { + return null + } + return { + task_id: task.taskId || task.task_id || '', + task_type: task.taskType || task.task_type || '', + title: task.title || '', + summary: task.summary || '', + assigned_agent: task.assignedAgent || task.assigned_agent || '', + ontology_fields: task.ontologyFields || task.ontology_fields || {}, + missing_fields: task.missingFields || task.missing_fields || [] + } } diff --git a/web/src/views/scripts/useStewardPlanFlow.js b/web/src/views/scripts/useStewardPlanFlow.js index 9541761..a8bbd55 100644 --- a/web/src/views/scripts/useStewardPlanFlow.js +++ b/web/src/views/scripts/useStewardPlanFlow.js @@ -6,8 +6,10 @@ import { } from './stewardPlanModel.js' import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js' -const STEWARD_TYPEWRITER_INTERVAL_MS = 18 -const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 14 +const STEWARD_TYPEWRITER_INTERVAL_MS = 10 +const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8 +const STEWARD_TYPEWRITER_CHUNK_SIZE = 4 +const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5 export function useStewardPlanFlow({ activeSessionType, @@ -174,7 +176,7 @@ export function useStewardPlanFlow({ if (runId !== stewardTypewriterRunId) { return } - index += 1 + index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE) const message = messages.value.find((item) => item.id === messageId) if (!message) { return @@ -269,7 +271,7 @@ export function useStewardPlanFlow({ if (runId !== stewardTypewriterRunId) { return } - index += 1 + index = Math.min(chars.length, index + STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE) updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId) } diff --git a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js index fc7d396..cef21c9 100644 --- a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js +++ b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js @@ -13,7 +13,10 @@ import { buildLocalApplicationPreview, buildLocalApplicationPreviewMessage, buildModelRefinedApplicationPreview, + applicationDateRangesOverlap, normalizeApplicationPreview, + normalizeTransportModeOption, + resolveApplicationDateRange, shouldUseLocalApplicationPreview } from '../../utils/expenseApplicationPreview.js' import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js' @@ -21,16 +24,275 @@ import { fetchOntologyParse } from '../../services/ontology.js' import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js' import { calculateTravelReimbursement } from '../../services/reimbursements.js' import { fetchReceiptFolderItems } from '../../services/receiptFolder.js' +import { fetchStewardSlotDecision } from '../../services/steward.js' import { handleBudgetCompileReportSubmit, shouldUseBudgetCompileReport } from './budgetAssistantReportModel.js' const STEWARD_ASSISTANT_NAME = '小财管家' -const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18 -const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14 +const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10 +const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8 +const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4 +const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5 const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field' +const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = { + applicationType: 'expense_type', + time: 'time_range', + location: 'location', + reason: 'reason', + amount: 'amount', + transportMode: 'transport_mode', + department: 'department_name', + applicant: 'employee_name', + grade: 'employee_grade' +} + +const APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP = { + 费用类型: 'expense_type', + 申请类型: 'expense_type', + 发生时间: 'time_range', + 出发时间: 'time_range', + 申请时间: 'time_range', + 地点: 'location', + 事由: 'reason', + 金额: 'amount', + 系统预估费用: 'amount', + 出行方式: 'transport_mode', + 附件: 'attachments', + '附件/凭证': 'attachments', + 商户: 'merchant_name', + '商户/开票方': 'merchant_name', + 客户: 'customer_name', + 客户或项目对象: 'customer_name' +} + +const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = { + expense_type: 'applicationType', + time_range: 'time', + location: 'location', + reason: 'reason', + amount: 'amount', + transport_mode: 'transportMode', + department_name: 'department', + employee_name: 'applicant', + employee_grade: 'grade' +} + +const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = { + expense_type: '费用类型', + time_range: '时间', + location: '地点', + reason: '事由', + amount: '金额', + transport_mode: '出行方式', + attachments: '附件/凭证', + customer_name: '客户或项目对象', + merchant_name: '商户/开票方', + department_name: '所属部门', + employee_name: '申请人', + employee_grade: '职级' +} + +const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([ + 'amount', + 'attachments', + 'employee_no', + 'department_name', + 'employee_name' +]) + +const APPLICATION_DUPLICATE_IGNORED_STATUSES = new Set([ + 'cancelled', + 'canceled', + 'void', + 'voided', + 'deleted', + '已取消', + '已作废', + '作废', + '已删除' +]) + +function normalizeClaimListPayload(payload) { + if (Array.isArray(payload)) { + return payload + } + return Array.isArray(payload?.items) ? payload.items : [] +} + +function normalizeClaimRiskFlags(claim) { + const flags = claim?.risk_flags_json || claim?.riskFlagsJson || claim?.riskFlags || [] + if (Array.isArray(flags)) { + return flags + } + return flags && typeof flags === 'object' ? [flags] : [] +} + +function extractApplicationDetailFromClaim(claim) { + return normalizeClaimRiskFlags(claim).reduce((found, item) => { + if (found || !item || typeof item !== 'object') { + return found + } + const detail = item.application_detail || item.applicationDetail + return detail && typeof detail === 'object' ? detail : null + }, null) +} + +function isApplicationClaimRecord(claim) { + const expenseType = String(claim?.expense_type || claim?.expenseType || '').trim().toLowerCase() + const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase() + return ( + expenseType === 'application' || + expenseType === 'expense_application' || + expenseType.endsWith('_application') || + claimNo.startsWith('AP-') || + claimNo.startsWith('APP-') || + Boolean(extractApplicationDetailFromClaim(claim)) + ) +} + +function normalizeApplicationExpenseType(value) { + const text = String(value || '').trim().toLowerCase() + if (!text) { + return '' + } + if (text === 'travel_application' || /差旅|出差/.test(text)) { + return 'travel_application' + } + if (text === 'purchase_application' || /采购/.test(text)) { + return 'purchase_application' + } + if (text === 'meeting_application' || /会务|会议/.test(text)) { + return 'meeting_application' + } + if (text === 'expense_application' || text === 'application' || text.endsWith('_application')) { + return text === 'application' ? 'expense_application' : text + } + return 'expense_application' +} + +function resolveClaimApplicationExpenseType(claim) { + const detail = extractApplicationDetailFromClaim(claim) || {} + return normalizeApplicationExpenseType( + claim?.expense_type || + claim?.expenseType || + detail.application_type || + detail.applicationType || + '' + ) +} + +function isIgnoredApplicationDuplicateStatus(status) { + return APPLICATION_DUPLICATE_IGNORED_STATUSES.has(String(status || '').trim().toLowerCase()) +} + +function resolveClaimApplicationDateRange(claim) { + const detail = extractApplicationDetailFromClaim(claim) || {} + return ( + resolveApplicationDateRange( + detail.time || + detail.time_range || + detail.timeRange || + detail.application_time || + detail.applicationTime || + detail.application_business_time || + detail.applicationBusinessTime || + detail.application_date || + detail.applicationDate, + detail.days || detail.application_days || detail.applicationDays + ) || + resolveApplicationDateRange(claim?.occurred_at || claim?.occurredAt || '') + ) +} + +function formatApplicationDateRangeLabel(range) { + if (!range?.startDate) { + return '待确认' + } + return range.startDate === range.endDate ? range.startDate : `${range.startDate} 至 ${range.endDate}` +} + +function findOverlappingApplicationClaim(applicationPreview, claimsPayload) { + const preview = normalizeApplicationPreview(applicationPreview) + const fields = preview.fields || {} + const currentRange = resolveApplicationDateRange(fields.time, fields.days) + if (!currentRange) { + return null + } + const currentExpenseType = normalizeApplicationExpenseType(fields.applicationType) + const claims = normalizeClaimListPayload(claimsPayload) + for (const claim of claims) { + if (!isApplicationClaimRecord(claim) || isIgnoredApplicationDuplicateStatus(claim?.status)) { + continue + } + const existingExpenseType = resolveClaimApplicationExpenseType(claim) + if (currentExpenseType && existingExpenseType && currentExpenseType !== existingExpenseType) { + continue + } + const existingRange = resolveClaimApplicationDateRange(claim) + if (!existingRange || !applicationDateRangesOverlap(currentRange, existingRange)) { + continue + } + return { + claim, + currentRange, + existingRange, + claimId: String(claim?.id || claim?.claim_id || claim?.claimId || '').trim(), + claimNo: String(claim?.claim_no || claim?.claimNo || claim?.id || '').trim(), + status: String(claim?.approval_stage || claim?.approvalStage || claim?.status || '').trim(), + reason: String(claim?.reason || '').trim(), + location: String(claim?.location || '').trim() + } + } + return null +} + +function buildApplicationDateConflictMessage(conflict) { + const claimNo = conflict?.claimNo || '已有申请' + return [ + '我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。', + '', + '已有申请:', + `- **单号**:${claimNo}`, + `- **申请时间**:${formatApplicationDateRangeLabel(conflict?.existingRange)}`, + conflict?.location ? `- **地点**:${conflict.location}` : '', + conflict?.reason ? `- **事由**:${conflict.reason}` : '', + `- **当前节点**:${conflict?.status || '处理中'}`, + '', + `本次识别时间:${formatApplicationDateRangeLabel(conflict?.currentRange)}`, + '', + '请先查看已有申请,或修改本次出差时间后再继续。' + ].filter(Boolean).join('\n') +} + +function buildApplicationDateConflictActions(conflict) { + const actions = [] + if (conflict?.claimId) { + actions.push({ + action_type: 'open_application_detail', + label: '查看已有申请', + description: conflict.claimNo ? `进入 ${conflict.claimNo} 单据详情。` : '进入已有申请单据详情。', + icon: 'mdi mdi-file-search-outline', + payload: { + claim_id: conflict.claimId, + claim_no: conflict.claimNo + } + }) + } + actions.push({ + action_type: 'prefill_composer', + label: '修改出差时间', + description: '在输入框中补充新的出差日期后继续。', + icon: 'mdi mdi-calendar-edit-outline', + payload: { + prompt_prefill: '修改出差时间为:' + } + }) + return actions +} + export function useTravelReimbursementSubmitComposer(ctx) { const { MAX_ATTACHMENTS, @@ -145,8 +407,21 @@ export function useTravelReimbursementSubmitComposer(ctx) { return Array.isArray(normalized.missingFields) ? normalized.missingFields : [] } + function isBlockingApplicationOntologyField(key = '') { + const normalizedKey = String(key || '').trim() + return Boolean(normalizedKey && !APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS.has(normalizedKey)) + } + + function resolveBlockingApplicationMissingFieldsForSteward(preview = {}) { + return resolveApplicationPreviewMissingFieldsForSteward(preview).filter((label) => { + const ontologyKey = APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '' + return !ontologyKey || isBlockingApplicationOntologyField(ontologyKey) + }) + } + function buildStewardApplicationPreviewSuggestedActions(preview = {}) { - const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview) + const normalized = normalizeApplicationPreview(preview) + const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized) if (!missingFields.includes('出行方式')) { return [] } @@ -158,77 +433,298 @@ export function useTravelReimbursementSubmitComposer(ctx) { return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({ action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET, label: mode, - description: `选择${mode}作为本次出行方式,并同步费用测算。`, + description: `选择${mode}后,由小财管家继续查询票价并测算费用。`, icon: iconMap[mode] || 'mdi mdi-map-marker-path', payload: { field_key: 'transportMode', field_label: '出行方式', - value: mode + value: mode, + applicationPreview: normalized, + steward_delegated_field_completion: true } })) } + function resolveStewardContinuationCurrentTask(continuation = null) { + const task = continuation?.currentTask || continuation?.current_task || null + return task && typeof task === 'object' ? task : null + } + + function normalizeCanonicalFieldList(fields = []) { + const normalized = [] + if (!Array.isArray(fields)) { + return normalized + } + fields.forEach((field) => { + const key = String(field || '').trim() + if (key && !normalized.includes(key)) { + normalized.push(key) + } + }) + return normalized + } + + function buildStewardSlotDecisionOntologyFields(preview = {}, continuation = null) { + const normalizedPreview = normalizeApplicationPreview(preview) + const previewFields = normalizedPreview.fields || {} + const task = resolveStewardContinuationCurrentTask(continuation) + const taskFields = task?.ontology_fields || task?.ontologyFields || {} + const fields = {} + Object.entries(taskFields || {}).forEach(([key, value]) => { + const normalizedKey = String(key || '').trim() + const normalizedValue = String(value || '').trim() + if (normalizedKey && normalizedValue) { + fields[normalizedKey] = normalizedValue + } + }) + Object.entries(APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP).forEach(([previewKey, ontologyKey]) => { + const value = String(previewFields[previewKey] || '').trim() + if (value && value !== '待补充' && !fields[ontologyKey]) { + fields[ontologyKey] = value + } + }) + return fields + } + + function buildStewardSlotDecisionMissingFields(preview = {}, continuation = null, ontologyFields = {}) { + const task = resolveStewardContinuationCurrentTask(continuation) + const taskMissingFields = normalizeCanonicalFieldList(task?.missing_fields || task?.missingFields || []) + .filter((key) => isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim()) + if (taskMissingFields.length) { + return taskMissingFields + } + return resolveApplicationPreviewMissingFieldsForSteward(preview) + .map((label) => APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '') + .filter((key, index, list) => + key && + isBlockingApplicationOntologyField(key) && + !String(ontologyFields[key] || '').trim() && + list.indexOf(key) === index + ) + } + + async function fetchStewardApplicationSlotDecision(preview = {}, rawText = '', continuation = null) { + const ontologyFields = buildStewardSlotDecisionOntologyFields(preview, continuation) + const missingFields = buildStewardSlotDecisionMissingFields(preview, continuation, ontologyFields) + try { + return await fetchStewardSlotDecision({ + task_type: 'expense_application', + user_message: String(rawText || '').trim(), + ontology_fields: ontologyFields, + missing_fields: missingFields, + task_context: { + steward_continuation: continuation || null, + application_preview: normalizeApplicationPreview(preview) + } + }, { + timeoutMs: 45000, + timeoutMessage: '小财管家字段决策超时,已按当前本体缺口继续追问。' + }) + } catch (error) { + console.warn('Steward slot decision failed:', error) + return null + } + } + + function formatStewardDecisionUserText(text = '') { + let formatted = String(text || '').trim() + Object.entries(ONTOLOGY_FIELD_DISPLAY_LABEL_MAP).forEach(([fieldKey, label]) => { + const escapedKey = fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + formatted = formatted + .replace(new RegExp(`(\\s*${escapedKey}\\s*)`, 'g'), '') + .replace(new RegExp(`\\(\\s*${escapedKey}\\s*\\)`, 'g'), '') + .replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), label) + }) + return formatted.replace(/\s{2,}/g, ' ').trim() + } + + function buildStewardSlotDecisionMessage(decision = null, preview = {}, fallbackText = '') { + if (!decision || String(decision.next_action || '').trim() !== 'ask_user') { + return fallbackText + } + const question = formatStewardDecisionUserText(decision.question || '') + const rationale = formatStewardDecisionUserText(decision.rationale || '') + const parts = [ + '我已经识别出这一步要先处理申请单,但当前还不能直接生成可提交的申请核对表。', + '', + rationale ? `**原因是:${rationale}**` : '', + '', + question || buildStewardApplicationPreviewMessage(preview, fallbackText) + ].filter((item) => item !== '') + return parts.join('\n') + } + + function buildStewardSlotDecisionSuggestedActions(decision = null, preview = {}) { + if (!decision || String(decision.next_action || '').trim() !== 'ask_user') { + return [] + } + const normalizedPreview = normalizeApplicationPreview(preview) + const iconMap = { + 火车: 'mdi mdi-train', + 飞机: 'mdi mdi-airplane', + 轮船: 'mdi mdi-ferry' + } + const actions = Array.isArray(decision.options) ? decision.options : [] + return actions.map((option) => { + const canonicalField = String(option?.field_key || option?.fieldKey || '').trim() + if (canonicalField && !isBlockingApplicationOntologyField(canonicalField)) { + return null + } + const fieldKey = ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP[canonicalField] || canonicalField + const value = String(option?.value || option?.label || '').trim() + const label = String(option?.label || value).trim() + const normalizedValue = fieldKey === 'transportMode' + ? normalizeTransportModeOption(value || label, '') + : value + if (!fieldKey || !value || !label) { + return null + } + if (fieldKey === 'transportMode' && !normalizedValue) { + return null + } + return { + action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET, + label, + description: String(option?.description || '').trim() || `选择${label}后,由小财管家继续测算并生成核对结果。`, + icon: iconMap[label] || iconMap[value] || 'mdi mdi-form-select', + payload: { + field_key: fieldKey, + field_label: ONTOLOGY_FIELD_DISPLAY_LABEL_MAP[canonicalField] || label, + value: normalizedValue, + applicationPreview: normalizedPreview, + steward_delegated_field_completion: true + } + } + }).filter(Boolean) + } + function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') { const normalized = normalizeApplicationPreview(preview) const fields = normalized.fields || {} - const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized) + const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized) if (!missingFields.length) { return fallbackText } if (missingFields.includes('出行方式')) { return [ - '我已经生成这一步的申请单核对结果,但现在还不能继续提交。', + '我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。', '', '**原因是:还缺少“出行方式”。**', '', `本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`, '', - '请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。' + '请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。' ].join('\n') } return [ - '我已经生成这一步的申请单核对结果,但现在还不能继续提交。', + '我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。', '', `**还需要你补充:${missingFields.join('、')}。**`, '', - `请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。` + `请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。` ].join('\n') } + function shouldPauseStewardApplicationPreview(preview = {}) { + return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0 + } + + function extractStewardCarryLine(text = '', label = '') { + const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[::]([^\\n]+)`, 'u')) + return match ? match[1].trim() : '' + } + + function extractStewardDelegatedTaskTitle(text = '', sessionType = '') { + const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[::]([^。\n]+)/u) + if (taskMatch?.[1]) { + return taskMatch[1].trim() + } + return String(sessionType || '').trim() === 'application' ? '本次出差申请' : '本次费用报销' + } + + function sanitizeStewardDelegatedTaskSummary(summary = '', sessionType = '') { + const text = String(summary || '').trim() + if (String(sessionType || '').trim() !== 'application') { + return text + } + return text + .replace(/交通方式和(?:预算|预计)?金额待补充/g, '交通方式待补充') + .replace(/出行方式和(?:预算|预计)?金额待补充/g, '出行方式待补充') + .replace(/交通方式及(?:预估|预计|预算)?费用/g, '交通方式') + .replace(/出行方式及(?:预估|预计|预算)?费用/g, '出行方式') + .replace(/(?:费用预算|预算费用|出差费用预算)(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)?[,,、;;\s]*/g, '') + .replace(/[,,、;;\s]*(?:预估|预计|预算)费用(?:待补充|待确认|待填写|需补充|需要补充|需确认|需要确认|未补充|缺失)?/g, '') + .replace(/[,,、;;\s]*(?:预算|预计)?金额(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)/g, '') + .replace(/([,,、;;。])\1+/g, '$1') + .replace(/[,,、;;\s]+。/g, '。') + .replace(/[,,、;;\s]+$/g, '') + .trim() + } + + function summarizeApplicationPreviewForSteward(preview = {}) { + const normalized = normalizeApplicationPreview(preview) + const fields = normalized.fields || {} + return [ + fields.time ? `时间:${fields.time}` : '', + fields.location ? `地点:${fields.location}` : '', + fields.reason ? `事由:${fields.reason}` : '', + fields.applicationType ? `类型:${fields.applicationType}` : '' + ].filter(Boolean).join(';') + } + function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) { const actionLabel = resolveStewardDelegatedActionLabel(sessionType) const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}` + const rawText = String(context.rawText || '').trim() + const taskTitle = extractStewardDelegatedTaskTitle(rawText, sessionType) + const taskSummary = sanitizeStewardDelegatedTaskSummary( + extractStewardCarryLine(rawText, '任务摘要'), + sessionType + ) + const identifiedInfo = summarizeApplicationPreviewForSteward(context.applicationPreview) + || extractStewardCarryLine(rawText, '已识别信息') + const carryMissingInfo = extractStewardCarryLine(rawText, '还需要补充') + const applicationMissingFields = context.applicationPreview + ? resolveBlockingApplicationMissingFieldsForSteward(context.applicationPreview) + : [] + const missingInfo = applicationMissingFields.length + ? applicationMissingFields.join('、') + : carryMissingInfo const events = [ { - eventId: `${eventPrefix}-confirm`, - title: '接收确认', - content: '已收到你的确认,小财管家继续推进当前任务。' + eventId: `${eventPrefix}-intent`, + title: '理解当前任务', + content: taskSummary + ? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}。` + : `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。` }, { - eventId: `${eventPrefix}-coordinate`, - title: '协调能力', - content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。` + eventId: `${eventPrefix}-known`, + title: '核对已知信息', + content: identifiedInfo + ? `当前已识别到:${identifiedInfo}。` + : `当前先围绕“${taskTitle}”生成可核对内容,具体缺口会在核对结果里继续判断。` } ] - const applicationMissingFields = context.applicationPreview - ? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview) - : [] - if (applicationMissingFields.length) { + if (missingInfo) { + const transportMissing = /出行方式/.test(missingInfo) events.push({ eventId: `${eventPrefix}-gap`, - title: '识别缺口', - content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。` + title: '判断待补充信息', + content: transportMissing + ? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。' + : `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。` + }) + } else { + events.push({ + eventId: `${eventPrefix}-ready`, + title: '判断下一步动作', + content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。` }) } - events.push( - { - eventId: `${eventPrefix}-output`, - title: '准备输出', - content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。' - } - ) return events } @@ -257,6 +753,9 @@ export function useTravelReimbursementSubmitComposer(ctx) { async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) { const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null + const pendingSuggestedActions = Array.isArray(finalExtras.suggestedActions) + ? finalExtras.suggestedActions + : [] const message = messages.value.find((item) => item.id === messageId) if (!message) { return @@ -287,11 +786,12 @@ export function useTravelReimbursementSubmitComposer(ctx) { nextTick(scrollToBottom) const chars = Array.from(String(eventData.content || '')) - for (let index = 0; index < chars.length; index += 1) { + for (let index = 0; index < chars.length;) { await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS) - event.content = chars.slice(0, index + 1).join('') + index = Math.min(chars.length, index + STEWARD_DELEGATED_THINKING_CHUNK_SIZE) + event.content = chars.slice(0, index).join('') message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming') - if ((index + 1) % 4 === 0 || index === chars.length - 1) { + if (index % STEWARD_DELEGATED_THINKING_CHUNK_SIZE === 0 || index === chars.length) { nextTick(scrollToBottom) } } @@ -304,14 +804,16 @@ export function useTravelReimbursementSubmitComposer(ctx) { const text = String(finalText || '') message.text = '' message.meta = [STEWARD_ASSISTANT_NAME, '输出中'] + message.suggestedActions = pendingSuggestedActions message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing') const chars = Array.from(text) - for (let index = 0; index < chars.length; index += 1) { + for (let index = 0; index < chars.length;) { await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS) - message.text = chars.slice(0, index + 1).join('') + index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE) + message.text = chars.slice(0, index).join('') message.meta = [STEWARD_ASSISTANT_NAME, '输出中'] message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing') - if ((index + 1) % 4 === 0 || index === chars.length - 1) { + if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) { nextTick(scrollToBottom) } } @@ -670,7 +1172,12 @@ export function useTravelReimbursementSubmitComposer(ctx) { return currentUser.value || user } - async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') { + async function buildApplicationPreviewWithModelReview( + rawText, + businessTimeContext = null, + sessionTypeOverride = '', + options = {} + ) { const user = await resolveApplicationPreviewUser() const localPreview = applyApplicationBusinessTimeContext( buildLocalApplicationPreview(rawText, user), @@ -697,6 +1204,16 @@ export function useTravelReimbursementSubmitComposer(ctx) { } } + if (options.skipModelReview) { + return { + applicationPreview: await enrichWithPolicyEstimate({ + ...localPreview, + modelReviewStatus: 'skipped' + }), + meta: ['申请核对预览', '结构化快路径'] + } + } + try { const ontology = await fetchOntologyParse( { @@ -828,7 +1345,7 @@ export function useTravelReimbursementSubmitComposer(ctx) { ? `新上传 ${fileNames.length} 份票据,请单独建立报销单。` : `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`) - if (shouldUseBudgetCompileReport(rawText, { + if (!stewardDelegated && shouldUseBudgetCompileReport(rawText, { sessionType: effectiveSessionType, entrySource: props.entrySource, budgetContext: props.initialBudgetContext @@ -944,9 +1461,62 @@ export function useTravelReimbursementSubmitComposer(ctx) { const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview( rawText, selectedBusinessTimeContext, - effectiveSessionType + effectiveSessionType, + { + skipModelReview: Boolean(stewardDelegated && options.skipApplicationModelReview) + } ) const reviewStatus = String(meta?.[1] || '').trim() + let applicationDateConflict = null + try { + const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 }) + applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims) + } catch (error) { + console.warn('Failed to check overlapping application dates:', error) + } + + if (applicationDateConflict) { + const conflictText = buildApplicationDateConflictMessage(applicationDateConflict) + const conflictActions = buildApplicationDateConflictActions(applicationDateConflict) + if (!stewardDelegated) { + completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt) + completeFlowStep( + 'application-review-preview', + '检测到同日期已有申请,已停止重复创建', + Date.now() - reviewStartedAt + ) + replaceMessage(pendingMessage.id, createMessage( + 'assistant', + conflictText, + [], + { + meta: ['申请日期冲突'], + suggestedActions: conflictActions, + stewardContinuation: options.stewardContinuation || null + } + )) + } else { + await typeStewardDelegatedMessage( + pendingMessage.id, + conflictText, + { + meta: [STEWARD_ASSISTANT_NAME, '申请日期冲突'], + suggestedActions: conflictActions, + stewardContinuation: options.stewardContinuation || null + }, + { + sessionType: effectiveSessionType, + rawText, + applicationPreview, + stewardContinuation: options.stewardContinuation || null + } + ) + } + persistSessionState() + nextTick(scrollToBottom) + return null + } + if (!stewardDelegated) { completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt) completeFlowStep( @@ -960,16 +1530,43 @@ export function useTravelReimbursementSubmitComposer(ctx) { ) } if (stewardDelegated) { + const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage( + applicationPreview, + buildLocalApplicationPreviewMessage(applicationPreview) + ) + const localPauseForMissingFields = shouldPauseStewardApplicationPreview(applicationPreview) + const shouldFetchSlotDecision = localPauseForMissingFields && !options.skipStewardSlotDecision + const slotDecision = shouldFetchSlotDecision + ? await fetchStewardApplicationSlotDecision( + applicationPreview, + rawText, + options.stewardContinuation || null + ) + : null + const slotDecisionActions = buildStewardSlotDecisionSuggestedActions(slotDecision, applicationPreview) + const pauseForMissingFields = slotDecision + ? String(slotDecision.next_action || '').trim() === 'ask_user' + : localPauseForMissingFields + const stewardApplicationText = buildStewardSlotDecisionMessage( + slotDecision, + applicationPreview, + fallbackStewardApplicationText + ) await typeStewardDelegatedMessage( pendingMessage.id, - buildLocalApplicationPreviewMessage(applicationPreview), + stewardApplicationText, { meta, - applicationPreview, + applicationPreview: pauseForMissingFields ? null : applicationPreview, + suggestedActions: slotDecisionActions.length + ? slotDecisionActions + : buildStewardApplicationPreviewSuggestedActions(applicationPreview), stewardContinuation: options.stewardContinuation || null }, { sessionType: effectiveSessionType, + rawText, + applicationPreview, stewardContinuation: options.stewardContinuation || null } ) @@ -1478,6 +2075,8 @@ export function useTravelReimbursementSubmitComposer(ctx) { }, { sessionType: effectiveSessionType, + rawText, + fileNames: effectiveFileNames, stewardContinuation: options.stewardContinuation || null } ) diff --git a/web/tests/app-shell-financial-assistant-entry.test.mjs b/web/tests/app-shell-financial-assistant-entry.test.mjs index 72cbb1e..ccb951f 100644 --- a/web/tests/app-shell-financial-assistant-entry.test.mjs +++ b/web/tests/app-shell-financial-assistant-entry.test.mjs @@ -51,11 +51,6 @@ const chatViewTemplate = readFileSync( fileURLToPath(new URL('../src/views/ChatView.vue', import.meta.url)), 'utf8' ) -const operationFeedbackInlineTemplate = readFileSync( - fileURLToPath(new URL('../src/components/shared/OperationFeedbackInlineCard.vue', import.meta.url)), - 'utf8' -) - test('application and reimbursement entries open the same financial assistant modal', () => { assert.match(appShellRouteView, / { ) }) -test('financial assistant toolbar renders four isolated assistant sessions', () => { +test('financial assistant toolbar renders isolated assistant sessions without steward entry', () => { assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/) + assert.match(assistantScript, /\.filter\(\(mode\) => mode\.key !== SESSION_TYPE_STEWARD\)/) + assert.match(assistantScript, /mode\.key === SESSION_TYPE_BUDGET/) assert.match(assistantScript, /visibleModes\.map/) assert.match(assistantScript, /targetSessionType:\s*mode\.key/) assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/) @@ -199,38 +196,39 @@ test('assistant message meta hides internal routing and permission chips', () => assert.doesNotMatch(chatViewTemplate, /agent-meta-row|agent-meta-chip/) }) -test('assistant operation feedback is inline and persists run context', () => { +test('assistant message action toolbar collects lightweight feedback', () => { assert.doesNotMatch(appShellRouteView, / 3/) - assert.match(operationFeedbackInlineTemplate, /v-if="showReasonInput"/) - assert.match(operationFeedbackInlineTemplate, /稍后/) const context = normalizeOperationFeedbackContext( { diff --git a/web/tests/document-center-archived-scope.test.mjs b/web/tests/document-center-archived-scope.test.mjs index f076308..f690c85 100644 --- a/web/tests/document-center-archived-scope.test.mjs +++ b/web/tests/document-center-archived-scope.test.mjs @@ -25,6 +25,17 @@ test('document center archived rows are detected from archive flag or request st expense_type: 'travel_application' } }), + false + ) + assert.equal( + isArchivedDocumentRow({ + rawRequest: { + status: 'approved', + approval_stage: '申请归档', + claim_no: 'AP-20260525120000-ABCDEFGH', + expense_type: 'travel_application' + } + }), true ) assert.equal( diff --git a/web/tests/document-center-new-state.test.mjs b/web/tests/document-center-new-state.test.mjs index a62e65a..bd60ddb 100644 --- a/web/tests/document-center-new-state.test.mjs +++ b/web/tests/document-center-new-state.test.mjs @@ -2,13 +2,17 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + buildDocumentViewedStatePatch, + buildDocumentsViewedStatePatches, countNewDocuments, isNewDocument, markDocumentViewed, markDocumentsViewed, + mergeNotificationStatesIntoViewedDocumentKeys, readDocumentScope, readViewedDocumentKeys, resolveDocumentNewKey, + resolveDocumentNotificationId, writeDocumentScope } from '../src/utils/documentCenterNewState.js' import { buildDocumentInboxRows } from '../src/composables/useDocumentCenterInbox.js' @@ -28,6 +32,38 @@ function createMemoryStorage(initial = {}) { test('document center new state resolves source scoped document keys', () => { assert.equal(resolveDocumentNewKey({ source: 'archive', claimId: 'claim-1' }), 'archive:claim-1') assert.equal(resolveDocumentNewKey({ source: 'approval', documentNo: 'EXP-1' }), 'approval:EXP-1') + assert.equal( + resolveDocumentNotificationId({ source: 'owned', claimId: 'claim-1', documentKey: 'owned:claim-1' }), + 'document:owned:claim-1' + ) +}) + +test('document center merges backend notification states into viewed keys', () => { + const storage = createMemoryStorage() + const viewedKeys = mergeNotificationStatesIntoViewedDocumentKeys([ + { notification_id: 'document:owned:claim-1', read_at: '2026-06-05T09:00:00+08:00' }, + { notificationId: 'document:approval:claim-2', hiddenAt: '2026-06-05T09:01:00+08:00' }, + { notification_id: 'workbench:todo:claim-3', read_at: '2026-06-05T09:02:00+08:00' } + ], readViewedDocumentKeys(storage), storage) + + assert.equal(isNewDocument({ source: 'owned', claimId: 'claim-1' }, viewedKeys), false) + assert.equal(isNewDocument({ source: 'approval', claimId: 'claim-2' }, viewedKeys), false) + assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1', 'approval:claim-2']) +}) + +test('document center builds backend viewed-state patches for unread rows', () => { + const rows = [ + { source: 'owned', claimId: 'claim-1', documentKey: 'owned:claim-1' }, + { source: 'approval', claimId: 'claim-2', documentKey: 'approval:claim-2' }, + { source: 'archive', claimId: 'claim-3', documentKey: 'archive:claim-3' } + ] + const patches = buildDocumentsViewedStatePatches(rows, new Set(['owned:claim-1'])) + + assert.deepEqual(patches.map((patch) => patch.notification_id), ['document:approval:claim-2']) + assert.equal(patches[0].read, true) + assert.equal(patches[0].hidden, false) + assert.equal(patches[0].context_json.kind, 'document') + assert.equal(buildDocumentViewedStatePatch(rows[2]), null) }) test('document center new state counts unseen documents and persists viewed rows', () => { diff --git a/web/tests/documents-center-status-filter.test.mjs b/web/tests/documents-center-status-filter.test.mjs index 55a2336..fad3bea 100644 --- a/web/tests/documents-center-status-filter.test.mjs +++ b/web/tests/documents-center-status-filter.test.mjs @@ -208,6 +208,9 @@ test('documents center category tabs render bubble counts for new documents', () test('documents center can mark all unread documents as read from toolbar', () => { assert.match(documentsCenterView, /markDocumentsViewed/) + assert.match(documentsCenterView, /patchNotificationStates/) + assert.match(documentsCenterView, /buildDocumentsViewedStatePatches/) + assert.match(documentsCenterView, /mergeNotificationStatesIntoViewedDocumentKeys/) assert.match( documentsCenterView, /const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/ @@ -222,6 +225,10 @@ test('documents center can mark all unread documents as read from toolbar', () = documentsCenterView, /function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/ ) + assert.match( + documentsCenterView, + /function markAllDocumentsRead\(\) \{[\s\S]*const viewedPatches = buildDocumentsViewedStatePatches\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)[\s\S]*syncDocumentViewedPatches\(viewedPatches\)/ + ) assert.match( documentsCenterView, /function resolveLiveDocumentRow\(row\) \{[\s\S]*isNewDocument: isNewDocument\(row, viewedDocumentKeys\.value\)[\s\S]*const visibleRows = computed\(\(\) => \{[\s\S]*\.map\(resolveLiveDocumentRow\)/ @@ -232,9 +239,10 @@ test('documents center can mark all unread documents as read from toolbar', () = test('documents center rows show NEW marker until the row is opened', () => { assert.match(documentsCenterView, /NEW<\/span>/) assert.match(documentsCenterView, /isNewDocument: archived\s*\?\s*false\s*:\s*isNewDocument\(/) + assert.match(documentsCenterView, /buildDocumentViewedStatePatch\(row\)/) assert.match( documentsCenterView, - /function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/ + /function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*syncDocumentViewedPatches\(\[viewedPatch\]\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/ ) assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*background:\s*#fff5f5;/) assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*border:\s*1px solid #fecaca;/) diff --git a/web/tests/expense-application-fast-preview.test.mjs b/web/tests/expense-application-fast-preview.test.mjs index 1212106..6ae46ab 100644 --- a/web/tests/expense-application-fast-preview.test.mjs +++ b/web/tests/expense-application-fast-preview.test.mjs @@ -15,7 +15,10 @@ import { buildLocalApplicationPreview, buildLocalApplicationPreviewMessage, buildModelRefinedApplicationPreview, + applicationDateRangesOverlap, normalizeApplicationPreview, + normalizeTransportModeOption, + resolveApplicationDateRange, resolveApplicationTimeLabel, shouldUseLocalApplicationPreview } from '../src/utils/expenseApplicationPreview.js' @@ -36,6 +39,17 @@ import { createMessage as createConversationMessage, hasMeaningfulSessionMessages } from '../src/views/scripts/travelReimbursementConversationModel.js' +import { + buildStewardSuggestedActions, + filterStewardBlockingMissingFields +} from '../src/views/scripts/stewardPlanModel.js' +import { + buildStewardFieldCompletionContinuation, + buildStewardFieldCompletionRawText +} from '../src/views/scripts/stewardFieldCompletionModel.js' +import { + shouldUseBudgetCompileReport +} from '../src/views/scripts/budgetAssistantReportModel.js' import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js' import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js' @@ -43,10 +57,22 @@ const submitComposerScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)), 'utf8' ) +const stewardServiceScript = readFileSync( + fileURLToPath(new URL('../src/services/steward.js', import.meta.url)), + 'utf8' +) const createViewScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)), 'utf8' ) +const stewardPlanFlowScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)), + 'utf8' +) +const stewardFieldCompletionScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/stewardFieldCompletionModel.js', import.meta.url)), + 'utf8' +) const createViewTemplate = readFileSync( fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)), 'utf8' @@ -506,7 +532,7 @@ test('application session shows intent flow, persists preview, and supports inli assert.match(submitComposerScript, /startFlowStep\('intent'/) assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/) assert.match(submitComposerScript, /completeFlowStep\('intent'/) - assert.doesNotMatch(submitComposerScript, /insightPanelCollapsed\.value = true/) + assert.match(submitComposerScript, /function resetStewardDelegatedInsightState\(\) \{[\s\S]*insightPanelCollapsed\.value = true/) assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/) assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/) assert.ok( @@ -542,9 +568,16 @@ test('application session shows intent flow, persists preview, and supports inli assert.match(messageItemTemplate, /申请单据已生成/) assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/) assert.match(messageItemTemplate, /报销草稿已生成/) + assert.match(messageItemTemplate, /报销草稿待保存/) assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/) + assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/) assert.match(messageItemTemplate, /class="reimbursement-draft-link"/) assert.match(messageItemTemplate, /查看详情/) + assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/) + assert.match(messageItemTemplate, /保存后可查看详情/) + assert.match(createViewScript, /function canOpenDraftDetail\(message\)/) + assert.match(createViewScript, /canOpenDraftDetail,/) + assert.match(createViewScript, /保存后生成/) assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/) assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/) assert.ok( @@ -558,9 +591,15 @@ test('application session shows intent flow, persists preview, and supports inli assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/) assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/) assert.match(messageItemTemplate, /ui\.openApplicationDraftDetail\(message\)/) - assert.match(messageItemTemplate, / { + assert.match(submitComposerScript, /function shouldPauseStewardApplicationPreview/) + assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/) + assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/) + assert.match(submitComposerScript, /出差费用预算/) + assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/) + assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/) + assert.match(submitComposerScript, /applicationPreview:\s*normalized/) + assert.match(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/) + assert.doesNotMatch(submitComposerScript, /缺少“出行方式”[\s\S]{0,500}更新下方核对表/) + + assert.match(createViewScript, /payload\.applicationPreview/) + assert.match(createViewScript, /function continueStewardApplicationFieldCompletion/) + assert.match(createViewScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/) + assert.match(createViewScript, /skipUserMessage:\s*true/) + assert.match(createViewScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/) + assert.match(createViewScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/) + assert.match(createViewScript, /commitApplicationPreviewEditor\(targetMessage\)/) + assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/) + assert.match(stewardFieldCompletionScript, /模拟查询交通票据/) +}) + +test('steward field completion reruns application preview instead of directly rendering table', () => { + const continuation = { + planId: 'steward-plan-transport-gap', + currentTaskId: 'task-application-beijing', + currentTask: { + task_id: 'task-application-beijing', + task_type: 'expense_application', + summary: '明天前往北京出差3天,支撑国网仿生产部署', + ontology_fields: { + time_range: '2026-06-05 至 2026-06-07', + location: '北京', + reason: '支撑国网仿生产部署' + }, + missing_fields: ['transport_mode'] + }, + remainingTasks: [] + } + const preview = normalizeApplicationPreview({ + fields: { + applicationType: '差旅费用申请', + time: '2026-06-05 至 2026-06-07', + location: '北京', + reason: '支撑国网仿生产部署', + days: '3天', + transportMode: '' + } + }) + + const nextContinuation = buildStewardFieldCompletionContinuation(continuation, 'transportMode', '火车') + assert.equal(nextContinuation.currentTask.ontology_fields.transport_mode, '火车') + assert.deepEqual(nextContinuation.currentTask.missing_fields, []) + + const carryText = buildStewardFieldCompletionRawText({ + preview, + fieldKey: 'transportMode', + fieldLabel: '出行方式', + value: '火车', + continuation: nextContinuation + }) + assert.match(carryText, /用户已补充:出行方式:火车/) + assert.match(carryText, /地点:北京/) + assert.match(carryText, /天数:3天/) + assert.match(carryText, /请先根据已补齐字段模拟查询交通票据/) + + const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' }) + assert.equal(rebuiltPreview.fields.location, '北京') + assert.equal(rebuiltPreview.fields.transportMode, '火车') + assert.equal(rebuiltPreview.fields.days, '3天') +}) + +test('budget compile report does not steal steward delegated application rerun', () => { + const staleBudgetContext = { + budgetNo: 'BUD-2026-TECH', + mode: 'edit', + categoryRows: [] + } + const stewardApplicationText = [ + '小财管家继续执行申请单字段补齐。', + '用户已补充:出行方式:火车。', + '地点:北京', + '天数:3天', + '处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。' + ].join('\n') + + assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, { + sessionType: 'application', + entrySource: 'workbench', + budgetContext: staleBudgetContext + }), false) + assert.equal(shouldUseBudgetCompileReport('帮我生成 2026 年 Q3 预算编制建议', { + sessionType: 'budget', + entrySource: 'budget', + budgetContext: staleBudgetContext + }), true) + assert.match(submitComposerScript, /if \(!stewardDelegated && shouldUseBudgetCompileReport/) +}) + +test('text confirmation submits pending application preview before replanning steward task', () => { + assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/) + assert.match(stewardServiceScript, /\/steward\/runtime-decisions/) + assert.match(createViewScript, /function buildStewardRuntimeState/) + assert.match(createViewScript, /function buildStewardRuntimeFastPathDecision/) + assert.match(createViewScript, /function shouldUseStewardRuntimeLlmDecision/) + assert.match(createViewScript, /function findPendingSlotSuggestedActionContextByInput/) + assert.match(createViewScript, /function shouldPlanNewStewardTasksLocally/) + assert.match(createViewScript, /function resolveStewardRuntimeTransportAlias/) + assert.match(createViewScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/) + assert.match(createViewScript, /actionTransportAlias === transportAlias/) + assert.match(createViewScript, /next_action:\s*'continue_next_task'/) + assert.match(createViewScript, /next_action:\s*'submit_current_application'/) + assert.match(createViewScript, /next_action:\s*'fill_current_slot'/) + assert.match(createViewScript, /next_action:\s*'plan_new_tasks'/) + assert.match(createViewScript, /suppressUserEcho:\s*userMessageAlreadyAdded/) + assert.match(createViewScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/) + assert.match(createViewScript, /skipApplicationModelReview:\s*true/) + assert.match(createViewScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/) + assert.match(createViewScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/) + assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/) + assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/) + assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/) + assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/) + assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/) + assert.match(submitComposerScript, /const pendingSuggestedActions = Array\.isArray\(finalExtras\.suggestedActions\)/) + assert.match(submitComposerScript, /message\.suggestedActions = pendingSuggestedActions[\s\S]*message\.stewardPlan = buildStewardDelegatedPlan\(continuation, \[\.\.\.typedEvents\], 'typing'\)/) + assert.match(createViewScript, /async function handleStewardRuntimeDecision/) + assert.match(createViewScript, /const runtimeState = buildStewardRuntimeState\(\)/) + assert.match(createViewScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/) + assert.match(createViewScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/) + assert.match(createViewScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/) + assert.match(createViewScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/) + assert.match(createViewScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/) + assert.match(createViewScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/) + assert.match(createViewScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/) + assert.match(createViewScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/) + assert.match(createViewScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/) + assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/) + assert.match(createViewScript, /function isApplicationSubmitConfirmationText/) + assert.match(createViewScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/) + assert.match(createViewScript, /function findPendingApplicationSubmitMessage/) + assert.match(createViewScript, /normalizedPreview\.readyToSubmit/) + assert.match(createViewScript, /async function handleApplicationSubmitConfirmationText/) + assert.match(createViewScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/) + assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/) + assert.match(createViewScript, /message\.applicationSubmitConfirmed = true/) + assert.match(createViewScript, /message\.applicationSubmitConfirmed[\s\S]*continue/) +}) + +test('steward streaming uses chunked typewriter to reduce perceived latency', () => { + assert.match(stewardPlanFlowScript, /STEWARD_TYPEWRITER_CHUNK_SIZE = 4/) + assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/) + assert.match(stewardPlanFlowScript, /index = Math\.min\(total, index \+ STEWARD_TYPEWRITER_CHUNK_SIZE\)/) + assert.match(stewardPlanFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/) + assert.match(submitComposerScript, /STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4/) + assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/) + assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE\)/) + assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/) + assert.match(createViewScript, /STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4/) + assert.match(createViewScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/) + assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE\)/) + assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/) +}) + +test('steward initial workbench entry shows recognition state before messages arrive', () => { + assert.match(createViewScript, /const hasStewardInitialAutoSubmitPayload = computed/) + assert.match(createViewScript, /const showStewardInitialRecognition = computed/) + assert.match(createViewScript, /!messages\.value\.length/) + assert.match(createViewScript, /workbenchVisible\.value \|\| submitting\.value/) + assert.match(createViewScript, /showStewardInitialRecognition/) + assert.match(createViewTemplate, /v-if="showStewardInitialRecognition"/) + assert.match(createViewTemplate, /class="steward-initial-recognition"/) + assert.match(createViewTemplate, /小财管家正在识别意图/) +}) + +test('steward application carry text does not leak transport examples into extraction', () => { + const actions = buildStewardSuggestedActions({ + plan_id: 'steward-plan-transport-gap', + plan_status: 'ready', + tasks: [ + { + task_id: 'task-application-beijing', + task_type: 'expense_application', + title: '北京出差申请', + summary: '明天前往北京出差3天,支撑国网仿生产部署', + assigned_agent: 'application_assistant', + ontology_fields: { + expense_type: 'travel', + time_range: '2026-06-05 至 2026-06-07', + location: '北京', + reason: '支撑国网仿生产部署' + }, + missing_fields: ['transport_mode', 'amount', 'attachments', 'employee_no'] + } + ], + confirmation_groups: [ + { + action_type: 'confirm_create_application', + target_task_id: 'task-application-beijing' + } + ] + }) + + const carryText = actions[0]?.payload?.carry_text || '' + const currentTask = actions[0]?.payload?.steward_current_task || null + assert.match(carryText, /费用类型:差旅/) + assert.doesNotMatch(carryText, /费用类型:travel/) + assert.match(carryText, /还需要补充:出行方式/) + assert.match(carryText, /请先追问上述缺失信息/) + assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/) + assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/) + assert.equal(currentTask?.task_type, 'expense_application') + assert.deepEqual(currentTask?.missing_fields, ['transport_mode']) + assert.deepEqual( + filterStewardBlockingMissingFields( + ['transport_type', 'amount', 'attachments', 'employee_no', 'department_name'], + 'expense_application' + ), + ['transport_mode'] + ) + assert.deepEqual( + filterStewardBlockingMissingFields(['amount', 'attachments'], 'reimbursement'), + ['amount', 'attachments'] + ) + + const preview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' }) + assert.equal(preview.fields.transportMode, '') + assert.equal(preview.missingFields.includes('出行方式'), true) + + assert.match(stewardServiceScript, /fetchStewardSlotDecision/) + assert.match(stewardServiceScript, /\/steward\/slot-decisions/) + assert.match(submitComposerScript, /fetchStewardApplicationSlotDecision/) + assert.match(submitComposerScript, /task_type:\s*'expense_application'/) + assert.match(submitComposerScript, /steward_continuation:\s*continuation/) + assert.match(createViewScript, /currentTask:\s*actionPayload\.steward_current_task/) +}) + +test('steward application slot fallback ignores non-blocking application fields', () => { + assert.match(submitComposerScript, /APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS/) + assert.match(submitComposerScript, /'attachments'/) + assert.match(submitComposerScript, /'employee_no'/) + assert.match(submitComposerScript, /'amount'/) + assert.match(submitComposerScript, /function formatStewardDecisionUserText/) + assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.question/) + assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/) + assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/) + assert.match(createViewScript, /normalizeTransportModeOption\(value, ''\)/) + assert.equal(normalizeTransportModeOption('高铁', ''), '火车') + assert.equal(normalizeTransportModeOption('自驾', ''), '') + assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/) + assert.match(submitComposerScript, /isBlockingApplicationOntologyField\(key\)/) + assert.match(submitComposerScript, /canonicalField && !isBlockingApplicationOntologyField\(canonicalField\)/) + assert.doesNotMatch(submitComposerScript, /附件\/凭证和员工编号为合规必需字段/) +}) + test('flow panel durations use backend timing instead of local preview delay', () => { const flow = createFlowHarness() flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') }) @@ -750,6 +1049,42 @@ test('assistant markdown tables render with component-scoped table styling', () assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;/) }) +test('assistant reimbursement recognition copy renders structured markdown sections', () => { + const rendered = renderMarkdown([ + '识别到您希望报销一笔“业务招待费”费用:', + '', + '基础信息识别结果:', + '时间:2026-06-04', + '事由:小财管家继续执行剩余任务,请填写报销单:客户接待费用报销。', + '', + '报销测算参考:', + '先以用户填写金额或票据识别金额为基础,再结合费用类型、发生地点、业务事由和规则中心限额进行复核。' + ].join('\n')) + + assert.match(rendered, /

基础信息识别结果<\/h3>/) + assert.match(rendered, /
  • 时间<\/strong>:2026-06-04<\/li>/) + assert.match(rendered, /
  • 事由<\/strong>:小财管家继续执行剩余任务/) + assert.match(rendered, /

    报销测算参考<\/h3>/) + assert.doesNotMatch(rendered, /基础信息识别结果:<\/h3>/) +}) + +test('application date overlap blocks steward preview before duplicate application table', () => { + const existingRange = resolveApplicationDateRange('2026-06-05 至 2026-06-07') + const currentRange = resolveApplicationDateRange('2026-06-06 至 2026-06-08') + const disjointRange = resolveApplicationDateRange('2026-06-08 至 2026-06-10') + + assert.equal(applicationDateRangesOverlap(currentRange, existingRange), true) + assert.equal(applicationDateRangesOverlap(disjointRange, existingRange), false) + assert.match(submitComposerScript, /function findOverlappingApplicationClaim\(applicationPreview, claimsPayload\)/) + assert.match(submitComposerScript, /function normalizeApplicationExpenseType\(value\)/) + assert.match(submitComposerScript, /currentExpenseType !== existingExpenseType/) + assert.match(submitComposerScript, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/) + assert.match(submitComposerScript, /buildApplicationDateConflictMessage\(applicationDateConflict\)/) + assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/) + assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/) + assert.match(createViewScript, /actionType === 'open_application_detail'/) +}) + test('application preview merges rule center travel estimate into highlighted rows', () => { const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天,服务项目部署,火车,预计费用1800元', { name: '李文静', diff --git a/web/tests/expense-claim-archive.test.mjs b/web/tests/expense-claim-archive.test.mjs index 5da601f..ce8cc69 100644 --- a/web/tests/expense-claim-archive.test.mjs +++ b/web/tests/expense-claim-archive.test.mjs @@ -17,6 +17,15 @@ test('isArchivedExpenseClaim recognizes finance archive stage', () => { claim_no: 'AP-20260525120000-ABCDEFGH', expense_type: 'travel_application' }), + false + ) + assert.equal( + isArchivedExpenseClaim({ + status: 'approved', + approval_stage: '申请归档', + claim_no: 'AP-20260525120000-ABCDEFGH', + expense_type: 'travel_application' + }), true ) assert.equal( diff --git a/web/tests/list-dropdown-filter-style.test.mjs b/web/tests/list-dropdown-filter-style.test.mjs new file mode 100644 index 0000000..51c4989 --- /dev/null +++ b/web/tests/list-dropdown-filter-style.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const root = process.cwd() + +function readProjectFile(path) { + return readFileSync(join(root, path), 'utf8') +} + +function testSharedDropdownStyleOwnsDocumentCenterPattern() { + const sharedStyles = readProjectFile('web/src/assets/styles/components/document-list-shared.css') + const documentStyles = readProjectFile('web/src/assets/styles/views/documents-center-view.css') + const logsStyles = readProjectFile('web/src/assets/styles/views/logs-view.css') + + assert.match(sharedStyles, /\.document-filter-menu\b/) + assert.match(sharedStyles, /\.date-range-popover\b/) + assert.match(sharedStyles, /\.status-filter-trigger\b/) + assert.doesNotMatch(documentStyles, /\.document-filter-menu\b/) + assert.doesNotMatch(logsStyles, /\.document-filter-menu\b[\s\S]*box-shadow/) +} + +function testListViewsUseSharedDropdownFilter() { + const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue') + const budgetView = readProjectFile('web/src/views/BudgetCenterView.vue') + const budgetScript = readProjectFile('web/src/views/scripts/BudgetCenterView.js') + const employeeView = readProjectFile('web/src/views/EmployeeManagementView.vue') + const employeeScript = readProjectFile('web/src/views/scripts/EmployeeManagementView.js') + const logsView = readProjectFile('web/src/views/LogsView.vue') + const auditPicker = readProjectFile('web/src/components/audit/AuditPickerFilter.vue') + const dropdown = readProjectFile('web/src/components/shared/DocumentDropdownFilter.vue') + + assert.match(dropdown, /class="picker-filter document-filter"/) + assert.match(dropdown, /class="picker-trigger filter-btn"/) + assert.match(dropdown, /class="picker-popover document-filter-menu"/) + assert.match(receiptView, /DocumentDropdownFilter/) + assert.match(budgetView, /DocumentDropdownFilter/) + assert.match(budgetScript, /DocumentDropdownFilter/) + assert.doesNotMatch(budgetScript, /EnterpriseSelect/) + assert.match(employeeView, /DocumentDropdownFilter/) + assert.match(employeeScript, /DocumentDropdownFilter/) + assert.match(logsView, /document-list-shared\.css/) + assert.match(auditPicker, /document-filter-menu/) + assert.doesNotMatch(auditPicker, /
    /) +} + +function run() { + testSharedDropdownStyleOwnsDocumentCenterPattern() + testListViewsUseSharedDropdownFilter() + console.log('list dropdown filter style tests passed') +} + +run() diff --git a/web/tests/notification-states-service.test.mjs b/web/tests/notification-states-service.test.mjs new file mode 100644 index 0000000..1415e88 --- /dev/null +++ b/web/tests/notification-states-service.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + NOTIFICATION_STATE_BATCH_SIZE, + chunkNotificationStatePatches +} from '../src/services/notificationStates.js' + +test('notification state patches are split before posting to backend', () => { + const patches = Array.from({ length: 205 }, (_, index) => ({ + notification_id: `document:owned:DOC-${index + 1}`, + read: true + })) + const chunks = chunkNotificationStatePatches(patches) + + assert.equal(NOTIFICATION_STATE_BATCH_SIZE, 100) + assert.deepEqual(chunks.map((chunk) => chunk.length), [100, 100, 5]) + assert.equal(chunks[0][0].notification_id, 'document:owned:DOC-1') + assert.equal(chunks[2][4].notification_id, 'document:owned:DOC-205') +}) diff --git a/web/tests/personal-workbench-compact-laptop.test.mjs b/web/tests/personal-workbench-compact-laptop.test.mjs new file mode 100644 index 0000000..06b6a5a --- /dev/null +++ b/web/tests/personal-workbench-compact-laptop.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const responsiveStyles = readFileSync( + fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-responsive.css', import.meta.url)), + 'utf8' +) + +test('personal workbench compacts hero input and capability cards on laptop screens', () => { + assert.match( + responsiveStyles, + /@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/ + ) + assert.match(responsiveStyles, /--hero-padding-top:\s*14px;/) + assert.match(responsiveStyles, /--hero-padding-bottom:\s*14px;/) + assert.match(responsiveStyles, /--hero-title-size:\s*24px;/) + assert.match(responsiveStyles, /--composer-min-height:\s*92px;/) + assert.match(responsiveStyles, /--composer-textarea-height:\s*38px;/) + assert.match(responsiveStyles, /--capability-row-height:\s*82px;/) + assert.match(responsiveStyles, /\.assistant-copy h1\s*\{[\s\S]*font-size:\s*var\(--hero-title-size\);/) + assert.match(responsiveStyles, /\.assistant-composer\s*\{[\s\S]*padding:\s*var\(--composer-padding-block\) 14px 8px;/) + assert.match(responsiveStyles, /\.quick-prompts button\s*\{[\s\S]*min-height:\s*24px;/) + assert.match(responsiveStyles, /\.capability-card\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 14px;[\s\S]*padding:\s*12px 12px 12px 16px;/) + assert.match(responsiveStyles, /@media \(max-width: 760px\)[\s\S]*\.workbench\s*\{[\s\S]*grid-template-rows:\s*none;/) +}) diff --git a/web/tests/receipt-folder-list-filters.test.mjs b/web/tests/receipt-folder-list-filters.test.mjs new file mode 100644 index 0000000..3197229 --- /dev/null +++ b/web/tests/receipt-folder-list-filters.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' + +import { + RECEIPT_FILTER_ALL, + applyReceiptListFilters, + buildReceiptFilterControls, + buildReceiptFilterTokens +} from '../src/views/scripts/receiptFolderListFilters.js' + +const rows = [ + { + id: 'r-1', + document_type: 'train_ticket', + document_type_label: '火车票', + scene_code: 'travel', + scene_label: '差旅', + document_date: '2026-05-02', + avg_score: 0.96 + }, + { + id: 'r-2', + document_type: 'vat_invoice', + document_type_label: '增值税发票', + scene_code: 'office', + scene_label: '办公', + uploaded_at: '2026-04-18T10:00:00Z', + avg_score: 0.82 + }, + { + id: 'r-3', + document_type: 'vat_invoice', + document_type_label: '增值税发票', + scene_code: 'travel', + scene_label: '差旅', + document_date: '', + uploaded_at: '2026-05-20T08:00:00Z', + avg_score: 0 + } +] + +function testBuildsDynamicOptions() { + const controls = buildReceiptFilterControls(rows, {}) + const typeControl = controls.find((item) => item.key === 'documentType') + const monthControl = controls.find((item) => item.key === 'month') + + assert.equal(typeControl.options[0].value, RECEIPT_FILTER_ALL) + assert.deepEqual(typeControl.options.map((item) => item.value).sort(), ['all', 'train_ticket', 'vat_invoice']) + assert.deepEqual(monthControl.options.map((item) => item.value), ['all', '2026-05', '2026-04']) +} + +function testAppliesCombinedFilters() { + const result = applyReceiptListFilters(rows, { + documentType: 'vat_invoice', + scene: 'travel', + month: '2026-05', + quality: 'missing' + }) + + assert.deepEqual(result.map((item) => item.id), ['r-3']) +} + +function testBuildsReadableTokens() { + const filters = { + documentType: 'train_ticket', + scene: 'travel', + month: RECEIPT_FILTER_ALL, + quality: 'high' + } + const tokens = buildReceiptFilterTokens(buildReceiptFilterControls(rows, filters), filters) + + assert.deepEqual(tokens, ['票据类型:火车票', '费用场景:差旅', '置信度:高置信度']) +} + +function run() { + testBuildsDynamicOptions() + testAppliesCombinedFilters() + testBuildsReadableTokens() + console.log('receipt folder list filter tests passed') +} + +run() diff --git a/web/tests/receipt-folder-view.test.mjs b/web/tests/receipt-folder-view.test.mjs index 537f252..6229c48 100644 --- a/web/tests/receipt-folder-view.test.mjs +++ b/web/tests/receipt-folder-view.test.mjs @@ -48,6 +48,12 @@ function testReceiptFolderViewSurface() { assert.match(view, /deleteCurrentReceipt/) assert.match(view, /ElCheckboxGroup/) assert.match(view, /fetchReceiptFolderItems\('all'\)/) + assert.match(view, /DocumentDropdownFilter/) + assert.match(view, /receiptFilterControls/) + assert.match(view, /clear-filter-btn/) + assert.match(view, /receiptFilters\[control\.key\]/) + assert.match(view, /clearReceiptFilters/) + assert.doesNotMatch(view, /class="filter-btn" type="button" @click="reloadReceipts"/) assert.match(view, /buildReceiptFile\(item\)/) assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/) assert.match(view, /emit\('open-assistant'/) @@ -92,9 +98,13 @@ function testSharedDocumentListStyleReuse() { assert.match(sharedStyles, /\.table-wrap\b/) assert.match(sharedStyles, /\.doc-kind-tag\b/) assert.match(sharedStyles, /\.list-foot\b/) + assert.match(sharedStyles, /\.clear-filter-btn\b/) + assert.match(sharedStyles, /\.document-filter-menu\b/) assert.doesNotMatch(receiptStyles, /\.table-wrap\b/) assert.doesNotMatch(receiptStyles, /\.doc-kind-tag\b/) assert.doesNotMatch(receiptStyles, /\.list-foot\b/) + assert.doesNotMatch(receiptStyles, /\.receipt-select-filter\b/) + assert.doesNotMatch(receiptStyles, /\.receipt-clear-filters\b/) } function testReceiptFolderDetailLayoutAdjustments() { diff --git a/web/tests/requestProgressSteps.test.mjs b/web/tests/requestProgressSteps.test.mjs index b099451..875cb3b 100644 --- a/web/tests/requestProgressSteps.test.mjs +++ b/web/tests/requestProgressSteps.test.mjs @@ -11,6 +11,8 @@ const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7' const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279' const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279' const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210' +const APPLICATION_LINK_STATUS = '\u5173\u8054\u5355\u636e\u72b6\u6001' +const APPLICATION_ARCHIVE = '\u7533\u8bf7\u5f52\u6863' const RETURNED = '\u9000\u56de' const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4' const LINKED_APPLICATION = '\u5173\u8054\u5355\u636e' @@ -156,7 +158,7 @@ test('application claims are mapped as application documents', () => { assert.equal(request.expenseTableSummary, '预计金额已随申请提交') assert.deepEqual( request.progressSteps.map((step) => step.label), - [CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED] + [CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] ) assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false) assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false) @@ -250,7 +252,7 @@ test('application claims wait for department P8 budget monitor after leader appr assert.deepEqual( request.progressSteps.map((step) => step.label), - [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED] + [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] ) assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true) assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过') @@ -293,7 +295,7 @@ test('application budget wait label uses claim-level budget approver snapshot', assert.equal(request.budgetApproverName, 'P8 Executive') assert.deepEqual( request.progressSteps.map((step) => step.label), - [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED] + [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] ) assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true) }) @@ -386,14 +388,16 @@ test('approved application claims complete after budget approval', () => { }) assert.equal(request.documentTypeCode, 'application') - assert.equal(request.workflowNode, '审批完成') + assert.equal(request.workflowNode, APPLICATION_LINK_STATUS) assert.deepEqual( request.progressSteps.map((step) => step.label), - ['创建申请', '直属领导审批', '预算管理者审批', '审批完成'] + [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] ) - assert.equal(request.progressSteps.every((step) => step.done), true) - assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过') - assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.time, '赵预算通过') + assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true) + assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.time, '未关联') + assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false) + assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李经理通过') + assert.equal(request.progressSteps.find((step) => step.label === BUDGET_MANAGER_APPROVAL)?.time, '赵预算通过') }) test('application claims hide budget step when leader approval also covers budget approval', () => { @@ -430,9 +434,10 @@ test('application claims hide budget step when leader approval also covers budge assert.deepEqual( request.progressSteps.map((step) => step.label), - [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED] + [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] ) - assert.equal(request.progressSteps.every((step) => step.done), true) + assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true) + assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false) assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false) assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李预算经理通过') }) @@ -481,13 +486,92 @@ test('approved application claims hide budget step when dynamic route skipped bu assert.deepEqual( request.progressSteps.map((step) => step.label), - [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED] + [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] ) - assert.equal(request.progressSteps.every((step) => step.done), true) + assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true) + assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false) assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false) assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过') }) +test('approved application claims show linked reimbursement status before archive', () => { + const request = mapExpenseClaimToRequest({ + id: 'claim-application-linked-draft', + claim_no: 'AP-202606050001-LINKED', + employee_name: '张三', + department_name: '交付部', + manager_name: 'Leader Li', + expense_type: 'travel_application', + reason: 'Project onsite support', + location: 'Shanghai', + amount: 500, + invoice_count: 0, + occurred_at: '2026-06-05T00:00:00.000Z', + submitted_at: '2026-06-05T02:00:00.000Z', + created_at: '2026-06-05T01:30:00.000Z', + updated_at: '2026-06-05T03:00:00.000Z', + status: 'approved', + approval_stage: APPLICATION_LINK_STATUS, + risk_flags_json: [ + { + source: 'manual_approval', + event_type: 'expense_application_approval', + operator: 'Leader Li', + previous_approval_stage: DIRECT_MANAGER_APPROVAL, + next_approval_stage: APPLICATION_LINK_STATUS, + generated_draft_claim_no: 'RE-202606050001-LINKED', + created_at: '2026-06-05T03:00:00.000Z' + } + ], + items: [] + }) + + const linkStep = request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS) + assert.equal(request.workflowNode, APPLICATION_LINK_STATUS) + assert.equal(linkStep?.current, true) + assert.equal(linkStep?.time, '关联中 RE-202606050001-LINKED') + assert.equal(request.secondaryStatusValue, '关联中 RE-202606050001-LINKED') + assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false) +}) + +test('application claims are archived only after linked reimbursement is paid', () => { + const request = mapExpenseClaimToRequest({ + id: 'claim-application-archived', + claim_no: 'AP-202606050001-ARCHIVED', + employee_name: '张三', + department_name: '交付部', + manager_name: 'Leader Li', + expense_type: 'travel_application', + reason: 'Project onsite support', + location: 'Shanghai', + amount: 500, + invoice_count: 0, + occurred_at: '2026-06-05T00:00:00.000Z', + submitted_at: '2026-06-05T02:00:00.000Z', + created_at: '2026-06-05T01:30:00.000Z', + updated_at: '2026-06-07T03:00:00.000Z', + status: 'approved', + approval_stage: APPLICATION_ARCHIVE, + risk_flags_json: [ + { + source: 'application_archive_sync', + event_type: 'expense_application_archived_by_reimbursement', + reimbursement_claim_no: 'RE-202606050001-ARCHIVED', + created_at: '2026-06-07T03:00:00.000Z' + } + ], + items: [] + }) + + assert.equal(request.workflowNode, APPLICATION_ARCHIVE) + assert.deepEqual( + request.progressSteps.map((step) => step.label), + [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED] + ) + assert.equal(request.progressSteps.every((step) => step.done), true) + assert.equal(request.secondaryStatusValue, '已归档') +}) + test('progress steps show approval operator time and current stay duration', () => { const originalNow = Date.now Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime() diff --git a/web/tests/sidebar-document-unread-dot.test.mjs b/web/tests/sidebar-document-unread-dot.test.mjs index 7c5510c..93e9de1 100644 --- a/web/tests/sidebar-document-unread-dot.test.mjs +++ b/web/tests/sidebar-document-unread-dot.test.mjs @@ -63,6 +63,7 @@ test('sidebar no longer renders document center unread indicators', () => { test('topbar bell owns document center unread notifications', () => { assert.match(topbar, /useDocumentCenterInbox/) assert.match(topbar, /useTopBarNotificationStates/) + assert.match(topbar, /resolveDocumentNotificationId/) assert.match(topbar, /notificationRows: documentInboxNotificationRows/) assert.match(topbar, /const documentNotificationItems = computed/) assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/) @@ -109,6 +110,8 @@ test('topbar notification state is persisted through backend API with local fall test('document inbox reuses document center viewed-key state', () => { assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/) + assert.match(documentInbox, /fetchNotificationStates/) + assert.match(documentInbox, /mergeNotificationStatesIntoViewedDocumentKeys/) assert.match(documentInbox, /readViewedDocumentKeys/) assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/) assert.match(documentInbox, /const notificationRows = computed/) @@ -120,5 +123,7 @@ test('document inbox reuses document center viewed-key state', () => { assert.match(documentInbox, /fetchArchivedExpenseClaims/) assert.match(documentInbox, /window\.addEventListener\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys\)/) assert.match(documentNewState, /export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/) + assert.match(documentNewState, /resolveDocumentNotificationId/) + assert.match(documentNewState, /buildDocumentsViewedStatePatches/) assert.match(documentNewState, /window\.dispatchEvent\(new CustomEvent\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT\)\)/) }) diff --git a/web/tests/topbar-compact-laptop.test.mjs b/web/tests/topbar-compact-laptop.test.mjs new file mode 100644 index 0000000..1e187c0 --- /dev/null +++ b/web/tests/topbar-compact-laptop.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const topbarStyles = readFileSync( + fileURLToPath(new URL('../src/assets/styles/components/top-bar.css', import.meta.url)), + 'utf8' +) + +test('topbar uses a compact laptop layout without overriding mobile layout', () => { + assert.match( + topbarStyles, + /@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/ + ) + assert.match(topbarStyles, /\.topbar\s*\{[\s\S]*padding:\s*12px 20px 14px;/) + assert.match(topbarStyles, /\.topbar h1\s*\{[\s\S]*font-size:\s*22px;/) + assert.match(topbarStyles, /\.topbar p\s*\{[\s\S]*-webkit-line-clamp:\s*1;/) + assert.match(topbarStyles, /\.range-shell\s*\{[\s\S]*height:\s*36px;/) + assert.match(topbarStyles, /\.dashboard-switch-select :deep\(\.el-select__wrapper\)\s*\{[\s\S]*min-height:\s*38px;/) + assert.match(topbarStyles, /\.topbar-icon-btn\s*\{[\s\S]*width:\s*30px;[\s\S]*height:\s*30px;/) + assert.match(topbarStyles, /@media \(max-width: 960px\)[\s\S]*\.topbar\s*\{[\s\S]*flex-direction:\s*column;/) +}) diff --git a/web/tests/travel-reimbursement-review-drawer-switch.test.mjs b/web/tests/travel-reimbursement-review-drawer-switch.test.mjs index 26d0abc..b736eed 100644 --- a/web/tests/travel-reimbursement-review-drawer-switch.test.mjs +++ b/web/tests/travel-reimbursement-review-drawer-switch.test.mjs @@ -118,6 +118,21 @@ test('document review drawer fills sidebar height and preview dialog is centered assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/) }) +test('assistant conversation keeps composer visible when generated cards grow tall', () => { + assert.match(createViewBaseStyles, /\.assistant-layout\s*\{[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*overflow:\s*hidden;/) + assert.match( + createViewBaseStyles, + /\.dialog-panel\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*min-height:\s*0;[\s\S]*overflow:\s*hidden;/ + ) + assert.match(createViewBaseStyles, /\.dialog-toolbar\s*\{[\s\S]*flex:\s*0 0 auto;/) + assert.match( + createViewBaseStyles, + /\.message-list\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*min-height:\s*0;[\s\S]*max-height:\s*100%;[\s\S]*overflow-y:\s*auto;[\s\S]*overscroll-behavior:\s*contain;/ + ) + assert.match(createViewBaseStyles, /\.composer\s*\{[\s\S]*position:\s*sticky;[\s\S]*bottom:\s*0;[\s\S]*flex:\s*0 0 auto;[\s\S]*flex-shrink:\s*0;/) + assert.match(createViewPart4Styles, /@media \(max-width:\s*1440px\)[\s\S]*\.dialog-panel\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*height:\s*auto;[\s\S]*max-height:\s*100%;/) +}) + test('document review OCR result card header keeps copy and navigation separated', () => { assert.match(insightPanelTemplate, /class="review-side-head-copy"[\s\S]*票据识别结果卡片[\s\S]*逐张查看 OCR 结果/) assert.match(insightPanelStyles, /\.review-document-switch-head\s*\{[\s\S]*display:\s*grid;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) auto;/) @@ -619,7 +634,10 @@ test('guided save draft emits refresh and exposes reimbursement draft detail car /emitSavedDraftRefresh\(payload\?\.result\?\.draft_payload \|\| null\)/ ) assert.match(createViewScript, /function shouldShowDraftSavedCard\(message\)/) + assert.match(createViewScript, /function canOpenDraftDetail\(message\)/) assert.match(createViewScript, /function resolveReimbursementDraftClaimNo\(draftPayload\)/) assert.doesNotMatch(createViewScript, /function buildReimbursementDraftSummaryItems\(draftPayload\)/) + assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/) assert.match(messageItemTemplate, /class="reimbursement-draft-link"/) + assert.match(messageItemTemplate, /reimbursement-draft-pending-detail/) }) diff --git a/web/tests/workbench-summary.test.mjs b/web/tests/workbench-summary.test.mjs index 4c484f4..0be7dfe 100644 --- a/web/tests/workbench-summary.test.mjs +++ b/web/tests/workbench-summary.test.mjs @@ -87,3 +87,52 @@ test('workbench summary builds real user notifications and progress from request assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '待办')) assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '进度')) }) + +test('workbench progress keeps application document type for AP claims', () => { + const summary = buildWorkbenchSummary( + [ + { + id: 'AP-202606050001-ABCDEFGH', + claimId: 'application-1', + claimNo: 'AP-202606050001-ABCDEFGH', + person: currentUser.name, + title: '差旅费用', + approvalKey: 'in_progress', + approvalStatus: '直属领导审批', + amount: 1880, + createdAt: '2026-06-05T09:00:00+08:00', + updatedAt: '2026-06-05T09:10:00+08:00', + progressSteps: [ + buildStep('创建申请', 0, 1), + buildStep('直属领导审批', 1, 1), + buildStep('归档', 2, 1) + ] + }, + { + id: 'REQ-APPLICATION-001', + claimId: 'application-2', + claimNo: 'REQ-APPLICATION-001', + documentTypeCode: 'application', + documentTypeLabel: '报销单', + person: currentUser.name, + title: '办公用品采购', + approvalKey: 'in_progress', + approvalStatus: '直属领导审批', + amount: 2600, + createdAt: '2026-06-05T09:05:00+08:00', + updatedAt: '2026-06-05T09:15:00+08:00', + progressSteps: [ + buildStep('创建申请', 0, 1), + buildStep('直属领导审批', 1, 1), + buildStep('归档', 2, 1) + ] + } + ], + currentUser + ) + + assert.deepEqual( + summary.progressItems.map((item) => item.documentTypeLabel), + ['申请单', '申请单'] + ) +})