merge: 合入候选分支(09-17借款/ERP退役、09-18编号、09-19预算并发、完整10-05错误码迁移,均经确认)

# Conflicts:
#	.agents/skills/trellis-before-dev/SKILL.md
#	.agents/skills/trellis-brainstorm/SKILL.md
#	.agents/skills/trellis-continue/SKILL.md
#	.agents/skills/trellis-start/SKILL.md
#	.trellis/tasks/08-23-isolated-backup-restore-drill/design.md
#	.trellis/tasks/08-23-isolated-backup-restore-drill/prd.md
#	.trellis/tasks/09-05-error-explainability-payment-readiness/prd.md
#	.trellis/tasks/09-05-error-explainability-payment-readiness/task.json
#	.trellis/tasks/09-09-existing-deploy-seed-separation/implement.md
#	.trellis/tasks/archive/2026-09/09-05-trellis-upgrade-rule-cleanup/task.json
#	.trellis/workflow.md
#	scripts/qa/error-reason-legacy-baseline.json
#	server/src/routes/apOperations.js
#	server/src/routes/portal.js
#	server/src/routes/todos.js
#	server/tests/unit/fixtures/error-reason-messages.json
#	server/tests/unit/routes/apOperations.permissions.test.js
这个提交包含在:
2026-09-26 07:54:10 +08:00
当前提交 46a70aa552
共修改 395 个文件,包含 23086 行新增和 13407 行删除
-93
查看文件
@@ -168,81 +168,6 @@ export type AssetMovementResponse = {
transferId?: string | null;
};
export type AssetErpTrackStatus =
| "not_triggered"
| "pending"
| "processing"
| "succeeded"
| "failed"
| "dead_letter"
| "skipped";
export type AssetErpBinding = {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
};
export type AssetErpOutbox = {
id: string;
eventType: string;
status: string;
attempts: number;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
};
export type AssetErpStage = {
eligible: boolean;
reason: string | null;
deliveryStatus: AssetErpTrackStatus | string;
actionHint: "none" | "backfill" | "retry" | "resync";
preview: {
changeType: string | null;
sourceEventName: string | null;
categoryCode: string | null;
legalEntityCode: string | null;
costCenterCode: string | null;
status: string | null;
};
outbox: AssetErpOutbox | null;
binding: AssetErpBinding | null;
};
export type AssetErpTrack = {
asset: {
id: string;
assetNo: string | null;
name: string | null;
status: string | null;
legalEntity: {
id: string;
code: string | null;
name: string | null;
} | null;
costCenter: {
id: string;
code: string | null;
name: string | null;
} | null;
purchaseRequest: {
id: string;
requestNo: string | null;
title: string | null;
} | null;
};
stage: AssetErpStage;
permissions: {
canRetry: boolean;
canSeeExternalRefs: boolean;
};
};
function toQueryString(query: Record<string, string | boolean | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
@@ -341,21 +266,3 @@ export async function transferAsset(
body: JSON.stringify(payload),
});
}
export async function fetchAssetErpTrack(assetId: string) {
return apiRequestData<AssetErpTrack>(`/api/integrations/erp/assets/${assetId}/track`);
}
export async function requeueAssetErpSync(assetId: string, runNow = true) {
const query = runNow ? '?runNow=1' : '?runNow=0';
return apiRequestData<{
outboxId: string;
status: string;
duplicated: boolean;
requeued: boolean;
eventType: string;
}>(`/api/integrations/erp/assets/${assetId}/requeue${query}`, {
method: 'POST',
body: JSON.stringify({}),
});
}
+51
查看文件
@@ -0,0 +1,51 @@
import { apiRequestBlob, apiRequestData } from "./http";
import type { PaymentBatchDetail } from "./paymentBatches";
export type LoanLender = {
id: string; name: string; legalEntityId: string; legalEntityName: string;
employeeId: string | null; identityStatus: string; identityVersion: number; accountVersion: number;
bankInstitutionId: string; bankName: string; bankAccountMasked: string | null; accountUnavailable: boolean;
};
export type LoanEvidence = {
id: string; kind: string; originalName: string; fileSize: number; mimeType: string;
governanceStatus: string; archivedAt: string | null;
};
export type CompanyLoan = {
id: string; loanNo: string; originReference: string; lender: LoanLender;
originalPrincipal: string; openingOutstandingPrincipal: string; receivedDate: string;
balanceAsOf: string; historicalEntry: boolean; confirmationStatus: string; version: number;
outstanding: string; available: string; reserved: string; settled: string;
evidences: LoanEvidence[]; accountingConfirmationRef: string | null;
};
export type LoanRepayment = {
id: string; repaymentNo: string; lenderId: string; lenderName: string; amount: string; reason: string;
bankResultState: string; paymentBatchId: string; status: string;
accountingHandoffRef: string | null; accountingHandoffAt: string | null;
allocations: Array<{ loanId: string; loanNo: string; amount: string; state: string; receivedDate: string; originReference: string; balanceAsOf: string; evidences: LoanEvidence[] }>;
};
export function fetchCompanyLoans(page: number, lenderId?: string, signal?: AbortSignal) {
return apiRequestData<{ items: CompanyLoan[]; total: number }>(`/api/company-loans?page=${page}&pageSize=30${lenderId ? `&lenderId=${encodeURIComponent(lenderId)}` : ""}`, { signal });
}
export function fetchLoanLenders(page: number, signal?: AbortSignal) {
return apiRequestData<{ items: LoanLender[]; total: number }>(`/api/loan-lenders?page=${page}&pageSize=30`, { signal });
}
export function fetchLoanRepayments(page: number, signal?: AbortSignal) {
return apiRequestData<{ items: LoanRepayment[]; total: number }>(`/api/loan-repayments?page=${page}&pageSize=30`, { signal });
}
export function fetchLoanIdentities(q: string, signal?: AbortSignal) {
return apiRequestData<{ items: Array<{ id: string; name: string; employeeNo: string }> }>(`/api/loan-lenders/identity-options?q=${encodeURIComponent(q)}`, { signal });
}
export function saveLoanAction<T>(path: string, body: unknown, method = "POST") {
return apiRequestData<T>(`/api${path}`, { method, body: JSON.stringify(body) });
}
export function createLoanRepayment(body: { requestKey: string; lenderId: string; reason: string;
allocations: Array<{ loanId: string; amount: string }> }) {
return saveLoanAction<{ item: PaymentBatchDetail }>("/loan-repayments", body);
}
export function uploadLoanEvidence(id: string, kind: string, file: File) {
const body = new FormData(); body.append("file", file);
return apiRequestData<LoanEvidence>(`/api/company-loans/${id}/evidences/${kind}`, { method: "POST", body });
}
export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string) {
return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId }) });
}
+622
查看文件
@@ -1,4 +1,626 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = {
"integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。",
"loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。",
"loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。",
"loan.source_unavailable": "公司、银行或关联人员已停用,请核对后重新选择。",
"loan.identity_required": "请先核实出借人与 OA 账号的关联;非员工须明确确认未关联账号。",
"loan.version_changed": "借款身份、账户或余额已变更,请刷新后重新核对。",
"loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。",
"loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。",
"loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。",
"loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
"loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。",
"loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。",
"loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。",
"loan.reviewer_required": "没有符合独立复核要求的经理或总经理;制单人、提交人和收款人不能复核。",
"loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。",
"loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。",
"loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。",
"loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。",
"config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。",
"config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。",
"document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。",
"document_sequence.category_missing": "指定的单据分类不存在,请重新选择分类。",
"document_sequence.category_type_mismatch": "单据分类与当前单据类型不匹配,请重新选择。",
"document_sequence.category_unavailable": "指定的单据分类已停用或归档,请选择可用分类。",
"workspace.update_input_invalid": "工作区设置不符合要求,请检查模块显示方式后保存。",
"confidential_ref.create_input_invalid": "资料索引信息不符合要求,请检查必填项和字段长度。",
"confidential_ref.update_input_invalid": "资料索引修改不符合要求,请检查要更新的字段。",
"confidential_ref.query_invalid": "资料索引查询条件不符合要求,请检查关联对象。",
"confidential_ref.feature_disabled": "当前未启用资料索引,请联系管理员确认。",
"cutover.id_invalid": "切换范围标识无效,请刷新列表后重新选择。",
"cutover.create_input_invalid": "切换范围信息不符合要求,请检查范围类型、标识和时间。",
"cutover.update_input_invalid": "切换范围修改不符合要求,请检查要更新的字段。",
"cutover.feature_disabled": "当前未启用切换控制台,请联系管理员确认。",
"cutover.scope_not_enabled": "当前业务尚未纳入允许操作的切换范围,请联系管理员确认。",
"migration_run.id_invalid": "迁移检查记录标识无效,请刷新列表后重新选择。",
"migration_run.file_required": "请选择用于迁移预检查的文件。",
"migration_run.type_invalid": "迁移检查类型无效,请重新选择检查类型。",
"feature_flag.version_conflict": "功能设置已被其他操作更新,请刷新后核对再保存。",
"feature_flag.key_invalid": "功能标识无效,请刷新功能设置列表后重新选择。",
"feature_flag.page_invalid": "功能设置历史查询条件不符合要求,请检查分页。",
"feature_flag.update_input_invalid": "功能设置不符合要求,请检查覆盖模式、范围和版本。",
"feature_flag.global_departments_invalid": "全局范围不能同时指定部门,请清除部门或选择部门范围。",
"feature_flag.batch_input_invalid": "批量功能设置不符合要求,请检查操作说明、版本和重复项目。",
"fee_control.input_invalid": "费用预检信息不符合要求,请检查单据类型和标识。",
"fee_control.entity_unsupported": "当前单据类型不支持费用预检,请联系管理员确认。",
"risk_flag.query_invalid": "风险记录查询条件不符合要求,请检查单据、状态和来源。",
"risk_flag.id_invalid": "风险记录标识无效,请刷新列表后重新选择。",
"risk_flag.action_input_invalid": "风险处理信息不符合要求,请检查备注长度。",
"risk_flag.not_open": "风险记录已处理,不能重复处理,请刷新后核对。",
"auth.entity_action_unsupported": "当前单据不支持此操作,请返回页面选择有效操作。",
"wecom.signature_url_invalid": "企业微信页面签名地址无效,请刷新当前页面后重试。",
"wecom.entry_config_incomplete": "企业微信登录入口尚未配置完整,请联系管理员确认。",
"wecom.auth_config_incomplete": "企业微信认证配置不完整,请联系管理员确认。",
"wecom.login_query_invalid": "企业微信登录参数无效,请从正式入口重新进入。",
"wecom.pc_mode_unsupported": "电脑扫码入口不支持此操作,请使用对应的绑定或入职入口。",
"wecom.bind_input_invalid": "企业微信绑定参数无效,请刷新绑定页面后重试。",
"integration.outbox_query_invalid": "消息队列查询条件不符合要求,请检查状态、类型和分页。",
"integration.bindings_query_invalid": "外部绑定查询条件不符合要求,请检查类型和分页。",
"integration.outbox_id_invalid": "消息队列记录标识无效,请刷新列表后重新选择。",
"integration.retry_state_invalid": "当前记录不处于失败或待人工处理状态,无法重试,请刷新后核对。",
"ai.reimbursement_id_invalid": "报销单标识无效,请刷新后重新打开报销详情。",
"ai.purchase_id_invalid": "采购申请标识无效,请刷新后重新打开采购详情。",
"ai.asset_id_invalid": "资产标识无效,请刷新后重新打开资产详情。",
"ai.feature_disabled": "当前单据未开放智能建议,请联系管理员确认。",
"weekly_report.query_invalid": "周报查询日期不符合要求,请选择有效日期。",
"weekly_report.write_input_invalid": "周报内容不符合要求,请检查文字长度和关联项目。",
"weekly_report.submit_input_invalid": "周报提交信息不符合要求,请检查周报日期。",
"weekly_report.feature_disabled": "当前未启用周报,请联系管理员确认。",
"weekly_report.submitted_readonly": "周报已提交,当前无法修改,请刷新后核对。",
"weekly_report.project_linking_disabled": "当前未启用项目关联,请移除关联项目或联系管理员确认。",
"audit.query_invalid": "审计日志查询条件不符合要求,请检查分页、日期和筛选条件。",
"audit.date_range_invalid": "审计日志结束时间早于开始时间,请调整查询范围。",
"search.feature_disabled": "当前未启用全局搜索,请联系管理员确认。",
"search.query_invalid": "搜索条件不符合要求,请输入至少2个字符并检查结果数量。",
"export.presence_query_invalid": "在岗历史导出条件不符合要求,请检查日期、人员和文件格式。",
"export.reimbursement_query_invalid": "报销导出条件不符合要求,请检查范围、状态、条数和文件格式。",
"export.invoice_query_invalid": "发票导出条件不符合要求,请检查归属、状态、条数和文件格式。",
"export.audit_query_invalid": "审计日志导出条件不符合要求,请检查日期、筛选条件和文件格式。",
"export.dashboard_query_invalid": "看板导出条件不符合要求,请检查日期、部门和文件格式。",
"export.ap_input_invalid": "应付工作台导出条件不符合要求,请检查筛选条件和文件格式。",
"export.dashboard_date_range_invalid": "看板导出结束日期早于开始日期,请调整日期范围。",
"export.payment_batch_disabled": "当前未启用付款批次功能,无法导出应付工作台数据,请联系管理员确认。",
"export.project_linking_disabled": "当前未启用项目关联,无法导出合同台账,请联系管理员确认。",
"export.ap_pending_date_range_invalid": "待付款批次导出结束日期早于开始日期,请调整创建日期范围。",
"attendance.clock_input_invalid": "打卡信息不符合要求,请检查备注后重试。",
"attendance.legacy_month_query_invalid": "考勤月份查询不符合要求,请选择2000至2100年内的有效月份。",
"attendance.team_query_invalid": "团队考勤查询不符合要求,请检查日期和部门。",
"attendance.policy_input_invalid": "考勤策略信息不符合要求,请检查时间格式和容差分钟数。",
"attendance.backfill_input_invalid": "补录信息不符合要求,请检查员工、日期和打卡时间。",
"attendance.sync_input_invalid": "考勤同步条件不符合要求,请选择不超过31天的日期范围和不超过50名员工。",
"attendance.sync_runs_query_invalid": "考勤同步记录查询不符合要求,请检查分页条件。",
"attendance.feature_disabled": "当前未启用网页打卡,请使用现行考勤渠道。",
"attendance.unified_feature_disabled": "当前未启用统一考勤,请联系管理员确认。",
"attendance.datetime_invalid": "打卡时间无法识别,请填写有效的日期时间。",
"attendance.exempt_day": "当天属于免打卡范围,无需提交打卡。",
"attendance.missing_clock_in": "当天尚未签到,请先签到或联系管理员核查。",
"attendance.missing_backfill_values": "补录缺少打卡时间,请至少填写签到或签退时间。",
"attendance.clock_range_invalid": "签退时间早于签到时间,请核对补录时间。",
"attendance.policy_time_invalid": "考勤策略时间无效,请使用00:00至23:59的小时和分钟格式。",
"attendance.tolerance_invalid": "迟到容差无效,请填写0至600之间的整数分钟数。",
"attendance.work_range_invalid": "上班时间必须早于下班时间,请调整考勤策略。",
"attendance.lunch_range_invalid": "午休开始时间必须早于结束时间,请调整考勤策略。",
"attendance.lunch_outside_work_range": "午休时间超出工作时段,请调整考勤策略。",
"attendance.year_invalid": "考勤年份无效,请选择2000至2100之间的年份。",
"presence.query_invalid": "在岗查询条件不符合要求,请检查日期。",
"presence.write_input_invalid": "在岗信息不符合要求,请检查日期、状态和备注。",
"presence.history_query_invalid": "在岗历史查询条件不符合要求,请检查日期范围。",
"presence.team_history_query_invalid": "团队在岗历史查询条件不符合要求,请检查日期、员工和筛选条件。",
"presence.employee_id_invalid": "员工标识无效,请刷新列表后重新选择。",
"presence.override_input_invalid": "在岗调整信息不符合要求,请检查日期、状态和备注。",
"presence.feature_disabled": "当前未启用在岗看板,请联系管理员确认。",
"presence.approved_leave_readonly": "当天已有获批请假记录,无法自行修改在岗状态,请联系管理员核查。",
"export.presence_too_large": "在岗历史导出条目过多,请缩小日期或人员范围。",
"delegation.id_invalid": "委托记录标识无效,请刷新委托列表后重试。",
"delegation.create_input_invalid": "委托资料格式有误,请检查受托人、范围及起止时间。",
"delegation.grant_input_invalid": "委托授权资料格式有误,请检查委托人、受托人、范围及起止时间。",
"delegation.start_time_invalid": "委托开始时间无效,请重新选择。",
"delegation.end_time_invalid": "委托结束时间无效,请重新选择。",
"delegation.time_range_invalid": "委托结束时间必须晚于开始时间,请调整后重试。",
"delegation.status_query_invalid": "委托状态筛选条件无效,请重新选择状态。",
"delegation.grants_query_invalid": "委托授权筛选条件有误,请检查人员与有效期选项。",
"delegation.delegate_unavailable": "受托人不存在或不在职,请选择在职人员。",
"delegation.delegate_proxy_permission_required": "受托人不具备报销代提交权限,请选择具备该权限的人员。",
"delegation.delegate_approval_permission_required": "受托人不具备报销审批权限,请选择具备审批权限的人员。",
"delegation.self_not_allowed": "委托人和受托人不能是同一人,请重新选择受托人。",
"delegation.principal_unavailable": "委托人不存在或不在职,请选择在职人员。",
"workflow_definition.id_invalid": "审批流程标识无效,请刷新流程列表后重试。",
"workflow_definition.create_input_invalid": "审批流程新增资料有误,请检查名称、业务类型及1至20个审批步骤。",
"workflow_definition.update_input_invalid": "审批流程修改内容为空或格式有误,请检查步骤和人员配置后保存。",
"workflow_definition.entity_type_invalid": "审批流程业务类型无效,请重新选择。",
"workflow_definition.feature_disabled": "审批流程管理功能尚未启用,请联系管理员确认。",
"workflow_definition.reimbursement_readonly": "报销审批流程由审批规则生成,请在审批规则中维护。",
"workflow_definition.not_editable": "已发布生效或已归档的审批流程不能直接修改,请先确认流程版本。",
"workflow_definition.archived_not_publishable": "已归档的审批流程不能发布,请选择可用草稿版本。",
"approval_rule.create_input_invalid": "审批规则新增资料有误,请检查金额范围、审批角色和优先级。",
"approval_rule.update_input_invalid": "审批规则修改资料有误,请检查金额范围、审批角色和优先级。",
"approval_rule.id_invalid": "审批规则标识无效,请刷新规则列表后重试。",
"approval_rule.amount_invalid": "审批金额范围超出可处理范围,请填写有效的上下限金额。",
"approval_rule.amount_range_invalid": "审批金额上限必须大于下限,请调整金额范围。",
"approval_rule.fallback_role_invalid": "负责人本人申请的兜底角色仅支持财务或财务经理,请重新选择。",
"approval_rule.empty_update": "尚未填写需要修改的审批规则内容,请修改后保存。",
"workflow.steps_invalid": "审批流程缺少有效步骤,请联系管理员检查流程配置。",
"workflow.business_role_unsupported": "审批流程包含不支持的业务审批角色,请联系管理员修正。",
"workflow.approval_rule_invalid": "审批规则配置无效,请联系管理员核对后重试。",
"workflow.approval_chain_empty": "审批规则未配置审批层级,请联系管理员补充。",
"workflow.fallback_role_unsupported": "负责人本人申请的兜底审批角色无效,请联系管理员核对。",
"workflow.fallback_role_is_self": "当前兜底审批角色由申请人本人持有,无法继续,请联系管理员安排符合要求的审批人。",
"workflow.fallback_holder_unavailable": "当前没有可处理的非本人兜底审批人,请联系管理员核对角色人员。",
"workflow.fallback_chain_empty": "本人申请调整后没有可用审批步骤,请联系管理员检查审批规则。",
"workflow.assignee_rule_invalid": "审批人分配规则无效,请联系管理员检查流程配置。",
"workflow.role_assignee_invalid": "审批步骤未配置有效的角色,请联系管理员修正。",
"workflow.user_assignee_invalid": "审批步骤指定的人员标识无效,请联系管理员重新配置。",
"workflow.manager_context_invalid": "无法确定申请人的主管关系,请联系管理员核对人员资料。",
"workflow.assignee_type_unsupported": "审批人分配方式不受支持,请联系管理员修正流程。",
"workflow.manager_unavailable": "无法找到可处理的主管审批人,请联系管理员检查汇报关系后重试。",
"workflow.assignee_resolution_invalid": "审批步骤未能解析出有效审批人,请联系管理员检查配置。",
"purchase_request.id_invalid": "采购申请标识无效,请刷新采购列表后重试。",
"purchase_attachment.id_invalid": "采购附件标识无效,请刷新附件列表后重试。",
"purchase_request.query_invalid": "采购筛选条件有误,请检查状态、页码和归属信息。",
"purchase_request.update_input_invalid": "采购修改资料格式有误,请检查字段、明细和交付日期。",
"purchase_request.archive_input_invalid": "采购归档资料有误,请填写采购单号并检查合同号和附件清单。",
"purchase_request.precheck_blocked": "采购预检未通过,请查看预检结果并处理阻断项后提交。",
"purchase_request.quantity_invalid": "采购明细数量超出可处理范围,请填写有效数量。",
"purchase_request.quantity_too_small": "采购明细数量不得小于0.01,请修正数量后保存。",
"purchase_request.unit_price_invalid": "采购明细单价超出可处理范围,请填写有效单价。",
"purchase_request.unit_price_negative": "采购明细单价不能为负数,请修正后保存。",
"purchase_request.company_not_found": "采购所属公司不存在,请刷新后重新选择。",
"purchase_request.company_inactive": "采购所属公司已停用或归档,请选择可用公司。",
"purchase_request.cost_center_not_found": "采购成本中心不存在,请刷新后重新选择。",
"purchase_request.cost_center_inactive": "采购成本中心已停用或归档,请选择可用成本中心。",
"purchase_request.cost_center_company_mismatch": "采购成本中心不属于所选公司,请核对归属信息。",
"purchase_request.supplier_not_found": "采购供应商不存在,请刷新后重新选择。",
"purchase_request.supplier_inactive": "采购供应商已停用或归档,请选择可用供应商。",
"purchase_request.supplier_company_mismatch": "采购供应商不适用于所选公司,请核对供应商归属。",
"purchase_request.document_category_not_found": "采购单据分类不存在,请刷新后重新选择。",
"purchase_request.document_category_type_invalid": "所选单据分类不适用于采购申请,请重新选择。",
"purchase_request.document_category_inactive": "采购单据分类已停用或归档,请选择可用分类。",
"purchase_request.budget_pool_not_found": "所选采购预算池不存在,请刷新后重新选择。",
"purchase_request.budget_pool_inactive": "所选采购预算池不可用,请联系财务核对预算。",
"purchase_request.project_link_disabled": "采购项目关联功能尚未启用,请联系管理员确认。",
"purchase_request.project_archived": "已归档项目不能新增采购关联,请选择可用项目。",
"purchase_attachment.download_input_invalid": "采购附件下载参数有误,请检查下载理由后重试。",
"purchase_attachment.delete_input_invalid": "删除采购附件需要2至200字的理由,请填写后重试。",
"purchase_attachment.download_blocked": "采购附件已被隔离或未通过安全校验,无法下载,请联系管理员核查。",
"purchase_request.not_editable": "只有草稿采购申请可以修改,请刷新确认申请状态。",
"purchase_request.workflow_missing": "采购审批流程尚未配置,请联系管理员配置后提交。",
"purchase_request.approver_unavailable": "采购申请当前没有可处理的审批人,请联系管理员核对审批配置后重试。",
"purchase_request.not_submittable": "只有草稿采购申请可以提交,请刷新确认申请状态。",
"purchase_request.company_required": "提交采购申请前,请选择所属公司。",
"purchase_request.cost_center_required": "提交采购申请前,请选择成本中心。",
"purchase_request.supplier_required": "提交采购申请前,请选择供应商。",
"purchase_request.document_category_required": "提交采购申请前,请选择单据分类。",
"purchase_request.items_required": "提交采购申请前,请至少添加一条采购明细。",
"purchase_request.budget_context_mismatch": "所选预算池与当前采购归属或期间不匹配,请重新选择预算池。",
"purchase_request.budget_required": "采购申请缺少适用预算池,请联系财务配置预算后提交。",
"purchase_request.not_withdrawable": "只有审批中的采购申请可以撤回,请刷新确认申请状态。",
"purchase_request.not_archivable": "只有已批准的采购申请可以登记订单归档,请先完成审批。",
"purchase_attachment.not_editable": "只有草稿采购申请可以上传或删除附件,请刷新确认申请状态。",
"purchase_attachment.multipart_required": "请通过文件上传入口选择采购附件后重试。",
"purchase_attachment.single_file_required": "每次只能上传一个采购附件,请逐个上传。",
"purchase_attachment.field_invalid": "采购附件上传字段有误,请重新选择文件后上传。",
"purchase_attachment.too_large": "采购附件超过允许大小,请缩小文件后重新上传。",
"purchase_attachment.multipart_limit": "采购附件上传字段或分段过多,请重新选择文件后上传。",
"purchase_attachment.validation_failed": "采购附件未通过文件校验,请更换有效文件后重试。",
"purchase_attachment.file_required": "请选择采购附件后上传。",
"purchase_attachment.download_reason_required": "下载此采购附件需要填写理由,请补充后重试。",
"purchase_attachment.dangerous_filename": "采购附件文件名不符合安全要求,请使用不含路径或可执行扩展名的普通文件名。",
"purchase_attachment.corrupt_content": "采购附件内容损坏或格式无效,请重新导出文件后上传。",
"purchase_attachment.multiple_pages": "采购附件图片包含多帧或多页,请转换为单张图片后上传。",
"purchase_attachment.pixel_limit": "采购附件图片像素过大,请降低分辨率后上传。",
"purchase_attachment.type_mismatch": "采购附件实际内容与文件类型不一致,请重新导出正确格式后上传。",
"purchase_attachment.empty_file": "采购附件内容为空,请选择有内容的文件。",
"asset_movement.id_invalid": "资产标识无效,请刷新资产列表后重试。",
"asset_movement.assign_input_invalid": "资产领用资料有误,请选择领用人并检查备注。",
"asset_movement.return_input_invalid": "资产归还资料有误,请检查备注后重试。",
"asset_movement.transfer_input_invalid": "资产调拨资料有误,请检查接收人、位置和备注。",
"asset_movement.status_blocked": "已报废或归档的资产不能领用、归还或调拨,请确认资产状态。",
"asset_movement.not_found": "待流转的资产不存在,请刷新资产列表后重试。",
"asset_movement.employee_not_found": "资产接收人不存在,请刷新人员列表后重新选择。",
"asset_movement.employee_inactive": "资产接收人已不在职,请选择在职人员。",
"asset_movement.already_assigned": "资产已有领用人或未关闭的领用记录,请先归还或使用调拨。",
"asset_movement.not_assigned": "资产尚未领用,无需归还,请刷新确认资产记录。",
"asset_movement.use_return": "清空资产领用人请使用归还操作,不能通过调拨清空。",
"asset_movement.no_change": "本次调拨没有改变接收人或位置,请填写实际变更。",
"inventory_check.id_invalid": "盘点单标识无效,请刷新盘点列表后重试。",
"inventory_check.scope_id_invalid": "盘点范围标识无效,请重新选择公司或成本中心。",
"inventory_check.query_invalid": "盘点筛选条件有误,请检查状态和范围类型。",
"inventory_check.create_input_invalid": "盘点创建资料有误,请检查范围类型和范围值。",
"inventory_check.record_input_invalid": "盘点结果有误,请至少填写一项并检查资产、结果和备注。",
"inventory_check.scope_required": "请填写所选盘点范围对应的公司、成本中心或位置。",
"inventory_check.company_not_found": "盘点范围的公司不存在,请刷新后重新选择。",
"inventory_check.cost_center_not_found": "盘点范围的成本中心不存在,请刷新后重新选择。",
"inventory_check.not_found": "盘点单不存在,请刷新盘点列表后重试。",
"inventory_check.start_state_invalid": "只有草稿盘点单可以开始,请刷新确认盘点状态。",
"inventory_check.record_state_invalid": "只有进行中的盘点单可以记录结果,请先开始盘点或刷新状态。",
"inventory_check.complete_state_invalid": "只有进行中的盘点单可以完成,请刷新确认盘点状态。",
"inventory_check.asset_scope_mismatch": "资产不属于本次盘点范围,请核对公司、成本中心或位置。",
"inventory_check.duplicate_asset": "同批盘点结果包含重复资产,请合并后重新提交。",
"inventory_check.asset_not_found": "盘点结果中的资产不存在,请刷新资产列表后重新选择。",
"asset.category_id_invalid": "资产分类标识无效,请刷新分类列表后重试。",
"asset.id_invalid": "资产标识无效,请刷新资产列表后重试。",
"asset.category_query_invalid": "资产分类筛选条件有误,请检查启用和归档选项。",
"asset.query_invalid": "资产筛选条件有误,请检查分类、人员、归属和状态后重试。",
"asset.assignee_query_invalid": "领用人查询条件有误,请缩短关键词后重试。",
"asset.category_create_input_invalid": "资产分类资料不完整或格式有误,请检查编码、名称和单据分类。",
"asset.category_update_input_invalid": "资产分类修改内容为空或格式有误,请检查后保存。",
"asset.create_input_invalid": "资产资料不完整或格式有误,请检查必填项、人员和日期。",
"asset.update_input_invalid": "资产修改内容为空或格式有误,请检查字段和日期后保存。",
"asset.index_import_input_invalid": "脱敏资产索引格式有误,请检查条目、字段和数量后重新导入。",
"asset.unsafe_content": "资产资料包含不允许保存的文本、哈希或元数据,请脱敏并检查格式和长度。",
"asset.index_import_unsafe_content": "脱敏资产索引含不允许保存的文本或哈希,请检查并脱敏后重试。",
"asset.document_category_not_found": "所选单据分类不存在,请刷新后重新选择。",
"asset.document_category_type_invalid": "所选单据分类不适用于资产卡片,请选择资产卡片分类。",
"asset.document_category_inactive": "所选单据分类已停用或归档,请选择可用分类。",
"asset.category_not_found": "资产分类不存在,请刷新后重新选择。",
"asset.category_inactive": "资产分类已停用或归档,请选择可用分类。",
"asset.category_document_invalid": "资产分类关联的单据分类不可用,请联系管理员修正分类设置。",
"asset.company_not_found": "资产所属公司不存在,请刷新后重新选择。",
"asset.company_inactive": "资产所属公司已停用或归档,请选择可用公司。",
"asset.cost_center_not_found": "资产成本中心不存在,请刷新后重新选择。",
"asset.cost_center_inactive": "资产成本中心已停用或归档,请选择可用成本中心。",
"asset.cost_center_company_mismatch": "资产成本中心不属于所选公司,请核对公司与成本中心。",
"asset.purchase_not_found": "关联采购申请不存在,请刷新后重新选择。",
"asset.purchase_not_approved": "关联采购申请尚未批准,请在采购审批完成后登记资产。",
"asset.index_import_dimensions_required": "写入资产索引前,请填写资产分类、所属公司和成本中心。",
"asset.not_found": "资产不存在,请刷新资产列表后重试。",
"asset.assignee_not_found": "所选资产领用人不存在,请刷新人员列表后重新选择。",
"asset.assignee_inactive": "所选资产领用人已不在职,请选择在职人员。",
"asset.owner_not_found": "所选资产责任人不存在,请刷新人员列表后重新选择。",
"asset.owner_inactive": "所选资产责任人已不在职,请选择在职人员。",
"asset.keeper_not_found": "所选资产保管人不存在,请刷新人员列表后重新选择。",
"asset.keeper_inactive": "所选资产保管人已不在职,请选择在职人员。",
"contract_registry.create_input_invalid": "合同台账新增资料不完整或格式有误,请检查必填项、金额和日期。",
"contract_registry.update_input_invalid": "合同台账修改内容为空或格式有误,请检查后重新保存。",
"contract_registry.terminate_input_invalid": "请填写10至1000字的合同终止原因后重试。",
"contract_registry.correction_input_invalid": "请填写10至1000字的合同纠错说明后重试。",
"contract_registry.feature_disabled": "合同台账功能尚未启用,请联系管理员确认。",
"contract_registry.state_conflict": "合同状态或资料已被更新,请刷新后重试。",
"contract_registry.terminated_is_final": "已终止的合同不能修改,请查看原合同记录。",
"contract_registry.archived_fields_locked": "已归档合同不能直接修改资料,请通过纠错记录补充说明。",
"contract_registry.terminate_not_allowed": "仅生效或已到期的合同可以终止,请刷新确认合同状态。",
"contract_registry.terminate_conflict": "合同已发生变更,本次未完成终止,请刷新后确认。",
"contract_registry.correction_requires_archived": "仅已归档合同可以追加纠错记录,请先确认合同状态。",
"contract_registry.correction_state_conflict": "合同归档状态已变化,本次未保存纠错记录,请刷新后重试。",
"contract_registry.renew_not_allowed": "仅生效或已到期的合同可以续订,请刷新确认合同状态。",
"purchase_budget.reserve_already_exists": "该采购申请已有未释放的预算占用,请刷新查看,避免重复提交。",
"purchase_budget.pool_not_found": "采购预算池已不存在,请联系财务核对预算后重试。",
"travel_reconciliation.query_invalid": "差旅对账筛选条件无效,请检查日期、状态和分页后重试。",
"travel_reconciliation.create_input_invalid": "对账批次资料不完整或格式有误,请检查公司、成本中心和期间后重试。",
"travel_reconciliation.period_reversed": "对账开始日期不能晚于结束日期,请调整对账期间。",
"travel_reconciliation.company_not_found": "对账公司不存在,请刷新公司列表后重新选择。",
"travel_reconciliation.cost_center_not_found": "对账成本中心不存在,请刷新列表后重新选择。",
"travel_reconciliation.cost_center_company_mismatch": "对账成本中心与所选公司不匹配,请重新选择。",
"travel_reconciliation.empty_scope": "所选范围没有可对账的订单或交易,请检查期间和公司范围后重试。",
"travel_reconciliation.batch_id_invalid": "对账批次标识无效,请刷新批次列表后重新选择。",
"travel_reconciliation.item_id_invalid": "对账条目标识无效,请刷新批次详情后重新选择。",
"travel_reconciliation.confirm_input_invalid": "对账确认备注格式有误,请检查填写内容后重新确认。",
"travel_reconciliation.batch_not_editable": "对账批次已不可修改,请刷新并确认批次状态。",
"travel_reconciliation.item_not_pending": "该对账条目当前不是待确认状态,请刷新后核对处理结果。",
"travel_reconciliation.item_not_confirmed": "只有已确认的对账条目可以重开,请刷新并确认条目状态。",
"travel_reconciliation.matched_item_not_reopenable": "自动匹配成功的对账条目不能重开,请检查关联订单和交易记录。",
"travel_reconciliation.batch_has_pending_items": "对账批次仍有未确认条目,请先处理全部条目后再结案。",
"travel_import.sync_disabled": "差旅同步尚未启用,请联系管理员确认后再导入。",
"travel_payment.budget_pool_not_found": "关联预算池不存在,请联系财务检查预算配置后重试。",
"travel_order.import_input_invalid": "差旅订单导入列表格式有误,请检查数据并确保每批为1至100项。",
"travel_order.import_empty": "未识别到有效的差旅订单记录,请检查必填字段和格式后重新导入。",
"travel_order.id_invalid": "差旅订单标识无效,请刷新列表后重新选择。",
"travel_authorization.import_input_invalid": "差旅授权导入列表格式有误,请检查数据并确保每批为1至100项。",
"travel_authorization.import_empty": "未识别到有效的差旅授权记录,请检查必填字段和格式后重新导入。",
"travel_authorization.query_invalid": "差旅授权筛选条件无效,请检查筛选项和分页后重试。",
"travel_transaction.import_input_invalid": "差旅交易导入列表格式有误,请检查数据并确保每批为1至100项。",
"travel_transaction.import_empty": "未识别到有效的差旅交易记录,请检查必填字段和格式后重新导入。",
"travel_transaction.query_invalid": "差旅交易筛选条件无效,请检查筛选项和分页后重试。",
"travel_transaction.id_invalid": "差旅交易标识无效,请刷新列表后重新选择。",
"travel_order.payment_input_invalid": "差旅付款记录格式有误,请检查付款日期、渠道和凭证号后重试。",
"travel_order.invalid_pay_status": "该差旅订单当前不能标记付款,请刷新并确认订单状态。",
"travel_order.already_paid_conflict": "该差旅订单已记录付款,本次付款资料与原记录不同,请核对原付款记录。",
"travel_order.settled_transaction_required": "该订单缺少金额和币种一致的已结算扣款交易,请先核对并补齐交易记录。",
"travel_transaction.freeze_input_invalid": "冻结建议的原因或备注格式有误,请选择有效原因后重新提交。",
"travel_request.query_invalid": "差旅申请筛选条件无效,请检查状态、分页和筛选项后重试。",
"travel_request.id_invalid": "差旅申请标识无效,请刷新列表后重新选择。",
"travel_request.create_input_invalid": "差旅申请资料不完整或格式有误,请检查事由、日期、费用及行程后重新创建。",
"travel_request.update_input_invalid": "差旅申请修改内容格式有误,请检查填写内容后重试。",
"travel_request.project_link_input_invalid": "项目关联参数无效,请重新选择项目或清除关联后重试。",
"travel_request.segment_time_invalid": "行程出发或到达时间无法识别,请填写有效时间。",
"travel_request.amount_invalid": "差旅预估金额无法识别或超出支持范围,请填写有效金额。",
"travel_request.amount_negative": "差旅预估金额不能为负数,请检查金额后重试。",
"travel_request.segment_time_reversed": "行程到达时间不能早于出发时间,请调整行程时间。",
"travel_request.date_range_reversed": "差旅结束日期不能早于开始日期,请调整出差日期。",
"travel_request.precheck_blocked": "差旅申请未通过费用预检,请检查预算与费用标准提示,处理后重新提交。",
"travel_request.company_unavailable": "所选公司不存在、已停用或已归档,请选择有效公司。",
"travel_request.cost_center_unavailable": "所选成本中心不存在、已停用或已归档,请选择有效成本中心。",
"travel_request.cost_center_company_mismatch": "成本中心与所选公司不匹配,请重新选择同一公司的成本中心。",
"travel_request.update_empty": "尚未填写差旅修改内容,请修改至少一项后保存。",
"travel_request.only_draft_editable": "只有草稿差旅申请可以修改,请刷新并确认申请状态。",
"travel_request.only_draft_submittable": "只有草稿差旅申请可以提交,请刷新并确认申请状态,避免重复提交。",
"travel_request.segments_required": "差旅申请缺少行程,请补充至少一段行程后提交。",
"travel_request.workflow_missing": "差旅审批流程尚未配置,请联系管理员配置后重新提交。",
"travel_request.approver_unavailable": "差旅审批流程没有可处理的审批人,请联系管理员检查审批人配置后重试。",
"travel_request.project_linking_disabled": "差旅项目关联功能暂不可用,请联系管理员确认开放范围后重试。",
"travel_request.only_reviewing_withdrawable": "只有审批中的差旅申请可以撤回,请刷新并确认申请状态。",
"project.create_input_invalid": "项目新增内容不完整或格式有误,请检查填写内容后重试。",
"project.update_input_invalid": "项目修改内容不完整或格式有误,请检查填写内容后重试。",
"project.member_create_input_invalid": "项目成员新增内容不完整或格式有误,请检查填写内容后重试。",
"project.member_update_input_invalid": "项目成员修改内容不完整或格式有误,请检查填写内容后重试。",
"project.milestone_create_input_invalid": "里程碑新增内容不完整或格式有误,请检查填写内容后重试。",
"project.milestone_update_input_invalid": "里程碑修改内容不完整或格式有误,请检查填写内容后重试。",
"project.task_create_input_invalid": "项目任务新增内容不完整或格式有误,请检查填写内容后重试。",
"project.task_update_input_invalid": "项目任务修改内容不完整或格式有误,请检查填写内容后重试。",
"project.risk_create_input_invalid": "项目风险新增内容不完整或格式有误,请检查填写内容后重试。",
"project.risk_update_input_invalid": "项目风险修改内容不完整或格式有误,请检查填写内容后重试。",
"project.communication_log_create_input_invalid": "项目沟通记录新增内容不完整或格式有误,请检查填写内容后重试。",
"project.communication_log_update_input_invalid": "项目沟通记录修改内容不完整或格式有误,请检查填写内容后重试。",
"project.closure_checklist_update_input_invalid": "项目收口项修改内容不完整或格式有误,请检查填写内容后重试。",
"project.delivery_create_input_invalid": "项目交付记录新增内容不完整或格式有误,请检查填写内容后重试。",
"project.id_invalid": "项目标识无效,请刷新项目列表后重新选择。",
"project.member_id_invalid": "项目成员标识无效,请刷新项目详情后重新选择。",
"project.milestone_id_invalid": "里程碑标识无效,请刷新项目详情后重新选择。",
"project.task_id_invalid": "项目任务标识无效,请刷新项目详情后重新选择。",
"project.risk_id_invalid": "项目风险标识无效,请刷新项目详情后重新选择。",
"project.communication_log_id_invalid": "项目沟通记录标识无效,请刷新项目详情后重新选择。",
"project.closure_checklist_id_invalid": "项目收口项标识无效,请刷新项目详情后重新选择。",
"project.feature_disabled": "项目功能暂不可用,请联系管理员确认开放范围后重试。",
"project.linking_disabled": "项目关联功能暂不可用,请联系管理员确认开放范围后重试。",
"project.communication_disabled": "项目沟通记录功能暂不可用,请联系管理员确认开放范围后重试。",
"project.closure_disabled": "项目收口清单功能暂不可用,请联系管理员确认开放范围后重试。",
"project.document_refs_disabled": "项目资料引用功能暂不可用,请联系管理员确认开放范围后重试。",
"project.query_invalid": "项目筛选条件无效,请检查状态和筛选项后重试。",
"project.waiver_note_required": "豁免项目收口项需要填写理由,请补充说明后保存。",
"expense_standard.id_invalid": "费用标准标识无效,请刷新列表后重新选择。",
"expense_standard.query_invalid": "费用标准筛选条件无效,请检查筛选项后重试。",
"expense_standard.create_input_invalid": "费用标准资料不完整或格式有误,请检查必填项后重新创建。",
"expense_standard.update_input_invalid": "费用标准修改内容格式有误,请检查填写内容后重试。",
"expense_standard.update_empty": "尚未填写费用标准修改内容,请修改至少一项后保存。",
"expense_type.id_invalid": "费用类型标识无效,请刷新列表后重新选择。",
"expense_type.query_invalid": "费用类型筛选条件无效,请检查筛选项后重试。",
"expense_type.create_input_invalid": "费用类型资料不完整或格式有误,请检查必填项后重新创建。",
"expense_type.update_input_invalid": "费用类型修改内容格式有误,请检查填写内容后重试。",
"expense_type.update_empty": "尚未填写费用类型修改内容,请修改至少一项后保存。",
"expense_type.code_duplicate": "费用类型编码已存在,请使用其他编码。",
"leave_type.id_invalid": "假别标识无效,请刷新列表后重新选择。",
"leave_type.query_invalid": "假别筛选条件无效,请检查筛选项后重试。",
"leave_type.create_input_invalid": "假别资料不完整或格式有误,请检查必填项后重新创建。",
"leave_type.update_input_invalid": "假别修改内容格式有误,请检查填写内容后重试。",
"leave_type.code_duplicate": "假别编码已存在,请使用其他编码。",
"reimbursement_template.id_invalid": "报销模板标识无效,请刷新列表后重新选择。",
"reimbursement_template.query_invalid": "报销模板筛选条件无效,请检查筛选项后重试。",
"reimbursement_template.create_input_invalid": "报销模板资料不完整或格式有误,请检查必填项后重新创建。",
"reimbursement_template.update_input_invalid": "报销模板修改内容格式有误,请检查填写内容后重试。",
"reimbursement_template.update_empty": "尚未填写报销模板修改内容,请修改至少一项后保存。",
"expense_standard.amount_not_positive": "费用标准金额须大于零,请填写有效的金额。",
"expense_standard.expense_type_not_found": "所选费用类型不存在,请刷新费用类型列表后重新选择。",
"expense_standard.department_required": "部门费用标准需要指定部门,请选择部门后保存。",
"expense_standard.department_unavailable": "所选部门不存在或已归档,请选择有效部门。",
"expense_standard.scope_conflict": "该费用类型在相同范围已有标准,请修改已有标准或选择其他范围。",
"reimbursement_template.expense_types_unavailable": "模板中有不存在或已停用的费用类型,请重新选择有效类型后保存。",
"holiday_calendar.id_invalid": "节假日日历标识无效,请刷新列表后重新选择。",
"holiday_calendar.query_invalid": "节假日日历筛选条件无效,请检查筛选项后重试。",
"holiday_calendar.create_input_invalid": "日历资料不完整或格式有误,请检查编码和名称后重新创建。",
"holiday_calendar.update_input_invalid": "日历修改内容格式有误,请检查填写内容后重试。",
"holiday_calendar.update_empty": "尚未填写日历修改内容,请修改至少一项后保存。",
"holiday_calendar.days_input_invalid": "日历日期列表无效,请检查真实日期、日期类型和名称,且一次不超过500项。",
"holiday_calendar.date_duplicate": "日历日期列表有重复日期,请每个日期只保留一项后重试。",
"holiday_calendar.code_duplicate": "日历编码已存在,请使用其他编码。",
"holiday_calendar.archived_locked": "该日历已归档,无法修改资料或日期;请选择有效日历。",
"holiday_calendar.bound_as_default": "该日历仍是公司的默认日历,请先调整默认日历后再归档。",
"legal_entity.id_invalid": "公司标识无效,请刷新列表后重新选择。",
"legal_entity.query_invalid": "公司筛选条件无效,请检查筛选项后重试。",
"legal_entity.create_input_invalid": "公司资料不完整或格式有误,请检查必填项后重新创建。",
"legal_entity.update_input_invalid": "公司修改内容格式有误,请检查填写内容后重试。",
"legal_entity.update_empty": "尚未填写公司修改内容,请修改至少一项后保存。",
"legal_entity.code_duplicate": "公司编码已存在,请使用其他编码。",
"legal_entity.archived_locked": "该公司已归档,无法修改;请返回列表选择有效记录。",
"cost_center.id_invalid": "成本中心标识无效,请刷新列表后重新选择。",
"cost_center.query_invalid": "成本中心筛选条件无效,请检查筛选项后重试。",
"cost_center.create_input_invalid": "成本中心资料不完整或格式有误,请检查必填项后重新创建。",
"cost_center.update_input_invalid": "成本中心修改内容格式有误,请检查填写内容后重试。",
"cost_center.update_empty": "尚未填写成本中心修改内容,请修改至少一项后保存。",
"cost_center.code_duplicate": "成本中心编码已存在,请使用其他编码。",
"cost_center.archived_locked": "该成本中心已归档,无法修改;请返回列表选择有效记录。",
"document_category.id_invalid": "单据分类标识无效,请刷新列表后重新选择。",
"document_category.query_invalid": "单据分类筛选条件无效,请检查筛选项后重试。",
"document_category.create_input_invalid": "单据分类资料不完整或格式有误,请检查必填项后重新创建。",
"document_category.update_input_invalid": "单据分类修改内容格式有误,请检查填写内容后重试。",
"document_category.update_empty": "尚未填写单据分类修改内容,请修改至少一项后保存。",
"document_category.code_duplicate": "单据分类编码已存在,请使用其他编码。",
"document_category.archived_locked": "该单据分类已归档,无法修改;请返回列表选择有效记录。",
"supplier.id_invalid": "供应商标识无效,请刷新列表后重新选择。",
"supplier.query_invalid": "供应商筛选条件无效,请检查筛选项后重试。",
"supplier.create_input_invalid": "供应商资料不完整或格式有误,请检查必填项后重新创建。",
"supplier.update_input_invalid": "供应商修改内容格式有误,请检查填写内容后重试。",
"supplier.update_empty": "尚未填写供应商修改内容,请修改至少一项后保存。",
"supplier.code_duplicate": "供应商编码已存在,请使用其他编码。",
"supplier.archived_locked": "该供应商已归档,无法修改;请返回列表选择有效记录。",
"legal_entity.reference_not_found": "所选公司不存在,请刷新公司列表后重新选择。",
"legal_entity.reference_archived": "所选公司已归档,请选择有效公司。",
"legal_entity.has_cost_centers": "该公司仍有未归档的成本中心,请先处理成本中心后再归档公司。",
"cost_center.owner_unavailable": "所选负责人不存在或已非在职状态,请选择在职员工。",
"cost_center.parent_self_forbidden": "成本中心不能作为自身的上级,请重新选择上级成本中心。",
"cost_center.parent_not_found": "所选上级成本中心不存在,请刷新列表后重新选择。",
"cost_center.parent_archived": "所选上级成本中心已归档,请选择有效的上级成本中心。",
"cost_center.parent_company_mismatch": "上级成本中心与当前成本中心须属于同一公司,请重新选择。",
"cost_center.parent_cycle_forbidden": "所选上级成本中心会形成循环层级,请选择其他上级。",
"cost_center.has_children": "该成本中心仍有未归档的子级,请先处理子级后再归档。",
"employee.contract_attachment_download_blocked": "此合同附件未通过文件安全校验,暂不能下载,请联系有权限的管理员核查并重新上传有效附件。",
"employee.id_invalid": "员工或档案记录标识无效,请刷新列表后重新选择。",
"employee.lifecycle_disabled": "员工资料与生命周期管理暂不可用,请联系管理员确认功能开放范围。",
"employee.hr_sync_disabled": "企业微信人事主数据同步暂未启用,请联系管理员确认同步安排。",
"employee.hr_sync_unavailable": "企业微信人事主数据同步所需组件不可用,请联系管理员检查系统组件后重试。",
"employee.directory_sync_unavailable": "企业微信通讯录同步所需组件不可用,请联系管理员检查系统组件后重试。",
"employee.contract_date_range_invalid": "员工合同结束日期不能早于开始日期,请调整合同日期后保存。",
"employee.contract_termination_date_invalid": "员工合同终止日期不能早于开始日期,请核对终止日期后保存。",
"employee.training_date_range_invalid": "培训结束日期不能早于开始日期,请调整培训日期后保存。",
"employee.certificate_date_range_invalid": "证书到期日期不能早于取得日期,请核对证书日期后保存。",
"employee.contract_number_duplicate": "已有相同编号的员工合同,请核对合同编号并避免重复登记。",
"employee.active_contract_delete_forbidden": "有效中的员工合同不能直接删除,请先按实际情况登记终止,再填写删除理由。",
"employee.self_profile_fields_forbidden": "本人资料中包含无权修改的字段,请仅修改允许的个人资料,其他变更联系管理员办理。",
"employee.hr_sync_input_invalid": "人事同步参数无效,请检查预览或执行模式、人员范围和数量限制后重试。",
"employee.hr_sync_query_invalid": "人事同步记录筛选条件无效,请重新选择有效的每页条数。",
"employee.directory_retry_input_invalid": "通讯录同步重试参数无效,请重新选择有效的同步操作后重试。",
"employee.contract_create_input_invalid": "员工合同资料无效,请检查编号、合同类型和日期后登记。",
"employee.contract_update_input_invalid": "员工合同修改内容无效,请检查合同类型、日期和需要修改的字段。",
"employee.contract_delete_input_invalid": "请填写有效的合同删除理由后重试。",
"employee.training_create_input_invalid": "培训资料无效,请检查名称、类型、状态和日期后登记。",
"employee.training_update_input_invalid": "培训修改内容无效,请检查类型、状态、日期和需要修改的字段。",
"employee.certificate_create_input_invalid": "证书资料无效,请检查名称、颁发机构和日期后登记。",
"employee.certificate_update_input_invalid": "证书修改内容无效,请检查名称、机构、日期和需要修改的字段。",
"employee.profile_input_invalid": "员工资料修改内容无效,请检查日期、联系方式和需要修改的字段后保存。",
"employee.onboarding_input_invalid": "入职办理资料无效,请检查生效日期、试用期结束日期和备注后重试。",
"employee.regularize_input_invalid": "转正办理资料无效,请检查生效日期和备注后重试。",
"employee.transfer_input_invalid": "调动办理资料无效,请检查生效日期、目标部门、岗位或直属主管后重试。",
"employee.offboarding_input_invalid": "离职办理资料无效,请检查生效日期、最后工作日期和备注后重试。",
"employee.avatar_multipart_required": "头像上传方式无效,请通过头像上传入口重新选择图片。",
"employee.avatar_file_required": "请选择头像图片后上传。",
"employee.avatar_field_invalid": "头像上传字段无效,请刷新页面并重新选择头像图片。",
"employee.avatar_type_invalid": "头像仅支持JPEG、PNG或WebP图片,请选择支持的图片格式。",
"employee.avatar_file_empty": "头像文件为空,请选择有内容的图片后上传。",
"employee.avatar_file_too_large": "头像文件不能超过2MiB,请压缩图片后重新上传。",
"employee.avatar_image_invalid": "头像文件无法解析为图片,请重新导出JPEG、PNG或WebP图片后上传。",
"employee.contract_attachment_multipart_required": "合同附件上传方式无效,请通过附件上传入口重新选择文件。",
"employee.contract_attachment_file_required": "请选择员工合同附件后上传。",
"employee.contract_attachment_field_invalid": "合同附件上传字段无效,请刷新页面并重新选择附件。",
"employee.contract_attachment_type_invalid": "员工合同附件仅支持PDF、JPEG或PNG,请选择支持的文件格式。",
"employee.contract_attachment_file_empty": "合同附件为空,请选择有内容的文件后上传。",
"employee.contract_attachment_file_too_large": "员工合同附件不能超过10MiB,请缩小文件后重新上传。",
"employee.contract_attachment_content_invalid": "合同附件内容与声明格式不符或未通过文件检查,请重新导出有效PDF、JPEG或PNG后上传。",
"user.id_invalid": "员工账号标识无效,请刷新列表后重新选择。",
"user.list_query_invalid": "员工列表筛选条件无效,请检查页码、每页条数和查看范围后重试。",
"user.password_policy_failed": "密码需至少12位,包含大小写字母和数字,并避免常见弱密码,请调整后重试。",
"user.sensitive_input_invalid": "敏感资料填写无效,请核对所选银行、账号格式或证件资料后保存。",
"user.sensitive_update_empty": "没有可保存的敏感资料修改,请填写需要修改的字段后保存。",
"user.department_not_found": "所选员工部门已不存在,请刷新后重新选择部门。",
"user.department_archived": "所选员工部门已归档,请选择可用部门或联系管理员核查。",
"user.manager_self_forbidden": "员工不能担任自己的直属主管,请选择其他在职人员;批量设置时主管不能在所选员工中。",
"user.manager_not_found": "所选直属主管已不存在,请刷新后重新选择。",
"user.manager_inactive": "所选直属主管当前不在职,请选择其他在职人员。",
"user.unique_conflict": "员工资料与已有记录重复,本次操作未完成,请核对工号、邮箱等唯一资料后重试。",
"user.bank_reveal_input_invalid": "请填写2至500字的查看银行账号理由后重试。",
"user.bank_institution_unavailable": "所选银行已停用、归档或不存在,请刷新后重新选择可用银行。",
"user.self_disable_forbidden": "不能停用或归档当前登录账号,请从本次操作中移除自己。",
"user.reset_password_input_invalid": "重置密码内容无效,请填写有效的新密码,或留空使用系统生成的临时密码。",
"user.self_role_change_forbidden": "不能修改当前登录账号自己的角色,请由其他有权限的管理员处理。",
"user.roles_input_invalid": "角色设置无效,请至少选择一个可用角色后保存。",
"user.batch_transfer_input_invalid": "批量调部门资料无效,请选择员工和目标部门后重试。",
"user.batch_manager_input_invalid": "批量设置主管资料无效,请选择员工和直属主管后重试。",
"user.batch_toggle_input_invalid": "批量启用或停用的员工列表无效,请重新选择员工后重试。",
"user.batch_limit_exceeded": "每次最多处理50名不同员工,请缩小选择范围后分批操作。",
"user.lifecycle_disabled": "员工生命周期管理暂不可用,请联系管理员确认功能开放范围。",
"user.generated_password_unavailable": "系统未能生成符合安全要求的临时密码,本次操作未完成,请联系管理员核查。",
"department.id_invalid": "部门标识无效,请刷新列表后重新选择。",
"department.query_invalid": "部门筛选条件无效,请重新选择是否包含已归档部门。",
"department.create_input_invalid": "部门资料无效,请检查部门名称和上级部门后创建。",
"department.update_input_invalid": "部门修改内容无效,请检查部门名称和上级部门后保存。",
"department.update_empty": "没有可保存的部门修改,请填写名称或调整上级部门后保存。",
"department.parent_not_found": "所选上级部门已不存在,请刷新后重新选择上级部门。",
"department.parent_archived": "所选上级部门已归档,请选择可用部门。",
"department.archived_locked": "该部门已归档,无法修改,请刷新确认当前部门状态。",
"department.parent_self_forbidden": "部门不能把自己设为上级,请选择其他部门或清空上级。",
"department.parent_cycle_forbidden": "所选上级部门会形成层级循环,请选择当前部门下级以外的部门。",
"department.children_exist": "该部门仍有下级部门,暂不能归档,请先调整下级部门的归属。",
"department.active_employees_exist": "该部门仍有在职员工,暂不能归档,请先办理员工部门调整。",
"budget.company_required": "选择成本中心时必须同时指定所属公司,请补齐公司后重试。",
"budget.scope_mixed": "预算范围不能同时使用部门和公司或成本中心,请选择一种范围后重试。",
"budget.cost_center_required": "公司范围预算需要指定成本中心,请补齐该公司的成本中心后重试。",
"budget.pool_changed": "预算池状态或适用范围已变化,请刷新后重新提交。",
"budget.pool_ambiguous": "同一成本中心匹配到多个有效预算,无法确定使用哪一个,请联系预算管理员核查重叠的预算期间。",
"budget.effective_date_invalid": "用于匹配预算的业务日期无效,请核对单据日期;仍无法处理时联系管理员。",
"budget_rule.id_invalid": "预算控制规则标识无效,请刷新列表后重新选择。",
"budget_rule.query_invalid": "预算控制规则筛选条件无效,请重新选择业务类型和启用状态。",
"budget_rule.create_input_invalid": "预算控制规则资料无效,请检查名称、业务类型、控制方式、优先级和适用范围后创建。",
"budget_rule.update_input_invalid": "预算控制规则修改内容无效,请检查要修改的字段并填写有效内容后保存。",
"budget_rule.company_required": "本次预算规则填写了成本中心,请同时填写所属公司后保存。",
"budget_rule.expense_type_unsupported": "指定费用类型时,本次提交的业务类型必须为报销,请同时选择报销类型或移除费用类型后保存。",
"budget.id_invalid": "预算标识无效,请刷新预算列表后重新选择。",
"budget.query_invalid": "预算筛选条件无效,请检查状态、所属范围和分页后重试。",
"budget.create_input_invalid": "预算资料不完整或格式有误,请检查编号、名称、期间、金额和预警比例后创建。",
"budget.update_input_invalid": "预算修改内容无效,请检查名称、金额、预警比例和状态后保存。",
"budget.update_empty": "没有可保存的预算修改,请修改资料后再保存。",
"budget.period_invalid": "预算结束日期不能早于开始日期,请调整预算期间后重试。",
"budget.department_not_found": "所选预算部门已不存在,请刷新后重新选择部门。",
"budget.department_archived": "所选预算部门已归档,请选择可用部门或联系管理员核查。",
"budget.legal_entity_not_found": "所选预算公司已不存在,请刷新后重新选择公司。",
"budget.legal_entity_unavailable": "所选预算公司已停用或归档,请选择可用公司或联系管理员核查。",
"budget.cost_center_not_found": "所选预算成本中心已不存在,请刷新后重新选择成本中心。",
"budget.cost_center_unavailable": "所选预算成本中心已停用或归档,请选择可用成本中心或联系管理员核查。",
"budget.cost_center_company_mismatch": "所选成本中心不属于当前预算公司,请重新选择匹配的公司和成本中心。",
"performance.id_invalid": "绩效周期或员工标识无效,请刷新列表后重新选择。",
"performance.cycle_create_input_invalid": "绩效周期资料无效,请检查名称、周期类型和起止日期后创建。",
"performance.cycle_update_input_invalid": "绩效周期修改内容无效,请检查名称、类型、日期和状态,并填写需要修改的内容。",
"performance.review_input_invalid": "绩效评价内容无效,请检查总分和各维度评分(1至5分,以0.5分递增)、重复维度及评语长度。",
"performance.cycle_date_range_invalid": "绩效周期结束日期不能早于开始日期,请调整起止日期后重试。",
"performance.disabled": "绩效管理功能暂不可用,请联系管理员确认功能开放范围。",
"performance.active_cycle_exists": "已有进行中的绩效周期,请先核对并结束该周期,再激活其他周期。",
"performance.completed_cycle_locked": "该绩效周期已结束,无法再修改,请刷新查看当前周期状态。",
"performance.active_cycle_only_complete": "进行中的绩效周期只能结束,不能修改资料,请保留周期资料后执行结束操作。",
"performance.draft_cannot_complete": "草稿绩效周期不能直接结束,请先激活周期并完成评价,再结束周期。",
"performance.employee_inactive": "该员工当前不在职,不能录入绩效评价,请刷新并核对员工状态。",
"performance.active_cycle_required": "只有进行中的绩效周期可以录入评价,请刷新并选择进行中的周期。",
"payroll.id_invalid": "薪资记录或员工标识无效,请刷新列表后重新选择。",
"payroll.item_create_input_invalid": "薪资项资料不完整或格式有误,请检查名称、收入或扣除类型及排序后创建。",
"payroll.item_update_input_invalid": "薪资项修改内容无效,请检查名称、类型和排序后保存。",
"payroll.profile_input_invalid": "薪资档案资料无效,请检查生效日期、薪资项和金额,并移除重复薪资项。",
"payroll.payslip_update_input_invalid": "工资条修改内容无效,请检查薪资项和金额,并移除重复薪资项。",
"payroll.amount_invalid": "薪资金额格式无效,请填写不小于零且最多两位小数的金额。",
"payroll.item_name_duplicate": "已有同名薪资项,请更换名称后保存。",
"payroll.item_not_found": "部分薪资项已不存在,请刷新后重新选择薪资项。",
"payroll.month_already_generated": "该月工资条已生成,请查看现有工资条,避免重复生成。",
"payroll.active_employees_missing": "没有可生成工资条的在职员工,请先核对员工状态。",
"payroll.enabled_items_missing": "没有启用的薪资项,请先设置并启用薪资项,再生成工资条。",
"payroll.profiles_missing": "部分员工缺少该月有效的薪资档案,请补齐档案和生效日期后再生成工资条。",
"payroll.payslip_not_editable": "此工资条已发放,无法继续修改,请刷新查看当前状态。",
"payroll.month_not_generated": "该月尚未生成工资条,请先生成并核对,再发放。",
"payroll.month_already_issued": "该月工资条已全部发放,无需重复操作,请刷新查看。",
"payroll.month_invalid": "工资月份参数无效,请重新选择有效的年和月后重试。",
"payroll.export_query_invalid": "工资导出条件无效,请检查月份和导出格式后重试。",
"attendance.month_query_invalid": "考勤月报筛选条件无效,请检查月份和所选员工后重试。",
"attendance.month_invalid": "考勤月份无效,请重新选择有效的年和月后重试。",
"user.import_file_too_large": "导入文件超过上传大小限制,请拆分或缩小文件后重试。",
"user.import_file_missing": "请选择员工导入文件后重试。",
"user.import_file_invalid": "导入文件格式损坏或无法解析,请重新导出为CSV或XLSX后再上传。",
"user.import_extension_unsupported": "员工导入仅支持CSV或XLSX文件,请使用导入模板重新保存。",
"user.import_mime_unsupported": "导入文件类型无法识别,请将表格另存为CSV或XLSX后重新上传。",
"user.import_headers_missing": "导入表格缺少必填列,请按模板补齐工号、姓名、邮箱和部门列。",
"user.import_file_empty": "导入表格没有员工数据,请在表头下填写员工资料后重试。",
"user.import_row_limit_exceeded": "每次最多导入500行员工数据,请拆分表格后分别预览和导入。",
"announcement.id_invalid": "公告标识无效,请刷新列表后重新选择。",
"announcement.create_input_invalid": "公告资料不完整或格式有误,请检查标题、内容和时间后重新创建。",
"announcement.update_input_invalid": "公告修改内容无效,请检查修改项及时间后重新保存。",
"announcement.acknowledgement_input_invalid": "请先勾选签收确认,再提交签收。",
"announcement.query_invalid": "公告筛选条件无效,请刷新列表后重试。",
"announcement.disabled": "公告功能暂未启用,请联系管理员确认。",
"announcement.statement_required": "制度签收公告必须填写签收声明,请补充后保存。",
"announcement.acknowledged_content_locked": "此公告已有员工签收,内容变更请发布新版本。",
"announcement.acknowledgement_not_required": "此公告无需签收,请返回公告列表查看。",
"announcement.statement_missing": "此公告缺少签收声明,暂时无法签收,请联系管理员补充。",
"employee_directory.query_invalid": "员工筛选条件无效,请检查入职日期、人员状态及导出格式后重试。",
"employee_directory.hire_date_range_invalid": "入职结束日期不能早于开始日期,请重新选择筛选范围。",
"contract_registry.query_invalid": "合同筛选条件无效,请检查到期日期、合同状态及导出格式后重试。",
"contract_registry.expiry_date_range_invalid": "合同到期结束日期不能早于开始日期,请重新选择筛选范围。",
"contract_registry.expiring_query_invalid": "即将到期的筛选天数无效,请输入1至365之间的整数。",
"presence_history.date_range_invalid": "在岗历史结束日期不能早于开始日期,请重新选择日期范围。",
"presence_history.date_range_too_large": "在岗历史每次最多查询31天,请缩小日期范围后重试。",
"dashboard.query_invalid": "统计筛选条件无效,请检查日期、部门和显示数量后重试。",
"dashboard.date_range_invalid": "统计结束日期不能早于开始日期,请重新选择日期范围。",
"ap_operations.disabled": "付款工作台暂未启用,请联系管理员确认。",
"ap_operations.ledger_query_invalid": "财务台账筛选条件无效,请检查日期、状态和分页后重试。",
"user.create_input_invalid": "账号资料不完整或格式有误,请检查姓名、邮箱、入职日期和角色后重新创建。",
"user.update_input_invalid": "账号修改内容格式有误,请检查联系方式、入职日期和角色后重新保存。",
"user.employee_no_immutable": "工号创建后不能修改,请保留原工号;如登记有误,请联系管理员核查。",
"user.update_empty": "没有可保存的账号修改,请修改资料后再保存。",
"user.import_rows_invalid": "导入表格中仍有错误行,本次未导入任何员工,请先预览并修正标出的行后重试。",
"notification.list_query_invalid": "通知筛选条件无效,请检查页码、每页条数和未读筛选后重试。",
"notification.delivery_query_invalid": "通知投递筛选条件无效,请重新选择通知、渠道或投递状态后重试。",
"notification.id_invalid": "通知或渠道标识无效,请刷新列表后重新选择。",
"notification.channel_input_invalid": "通知渠道资料不完整或格式有误,请检查名称、发送方式、渠道配置和消息模板。",
"notification.channel_name_duplicate": "已有同名通知渠道,请更换名称后保存。",
"notification.encryption_key_unavailable": "通知渠道加密配置不可用,请联系管理员检查原字段加密密钥后再保存;不要生成新密钥替代。",
"todo.input_invalid": "待办资料不完整或格式有误,请检查标题、负责人、截止日期和优先级后重试。",
"todo.id_invalid": "待办标识无效,请刷新列表后重新选择。",
"todo.disabled": "待办功能暂未启用,请联系管理员确认。",
"todo.query_invalid": "待办筛选条件无效,请检查状态、范围和负责人后重试。",
"portal.disabled": "工作台暂未启用,请联系管理员确认。",
"workflow.task_already_processed": "此审批任务已处理,请刷新待办查看最新进度。",
"workflow.entity_unavailable": "审批对应的单据不存在或当前不可处理,请联系管理员核查。",
"workflow.self_approval_forbidden": "不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。",
-107
查看文件
@@ -1,71 +1,5 @@
import { apiRequestData } from "./http";
export type ErpMasterDataResourceType = "legal_entity" | "cost_center" | "supplier";
export type ErpGateStatus = {
envEnabled: boolean;
effectiveEnabled: boolean;
effectiveReason: string;
provider: string;
masterDataEndpointConfigured: boolean;
journalEndpointConfigured: boolean;
masterDataSyncCron: string | null;
};
export type ErpMasterDataSyncSummary = {
requested: number;
created: number;
updated: number;
archived: number;
skipped: number;
sampleSkipped: Array<{ key: string; reason: string }>;
};
export type ErpMasterDataSyncItem = {
resourceType: ErpMasterDataResourceType;
status: string;
lastStartedAt: string | null;
lastFinishedAt: string | null;
lastSuccessAt: string | null;
lastError: string | null;
lastResult: ErpMasterDataSyncSummary | null;
};
export type ErpMasterDataStatusResponse = {
gate: ErpGateStatus;
items: ErpMasterDataSyncItem[];
triggerMode?: string;
};
export type ReimbursementJournalPreview = {
journal: {
journalType: string;
sourceEventName: string;
entityType: string;
entityId: string;
documentNo: string | null;
currencyCode: string;
totalAmount: string | null;
occurredAt: string | null;
paymentVoucherNo: string | null;
paymentDate: string | null;
dimensions: {
departmentId: string | null;
ownerEmployeeId: string | null;
submitterEmployeeId: string | null;
legalEntityCode: string | null;
costCenterCode: string | null;
};
};
exportState: {
externalId: string | null;
lastOutboxId: string | null;
lastOutboxStatus: string | null;
lastError: string | null;
exportedAt: string | null;
};
};
export type IntegrationOutboxStatus =
| "pending"
| "processing"
@@ -159,47 +93,6 @@ function toQueryString(query: Record<string, string | number | boolean | undefin
return encoded ? `?${encoded}` : "";
}
export async function triggerErpSync(reimbursementId: string, runNow = true) {
return apiRequestData<{
outboxId: string;
status: string;
duplicated: boolean;
}>(`/api/integrations/erp/sync/${reimbursementId}?runNow=${runNow ? "1" : "0"}`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function fetchErpMasterDataStatus() {
return apiRequestData<ErpMasterDataStatusResponse>("/api/integrations/erp/master-data/status");
}
export async function syncErpMasterData(payload: { resources?: ErpMasterDataResourceType[] }) {
return apiRequestData<ErpMasterDataStatusResponse>("/api/integrations/erp/master-data/sync", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function previewReimbursementJournal(reimbursementId: string) {
return apiRequestData<ReimbursementJournalPreview>(
`/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`,
);
}
export async function exportReimbursementJournal(reimbursementId: string, runNow = true) {
return apiRequestData<{
outboxId: string;
status: string;
duplicated: boolean;
requeued: boolean;
eventType: string;
}>(`/api/integrations/erp/journals/reimbursements/${reimbursementId}/export?runNow=${runNow ? "1" : "0"}`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function fetchIntegrationOutbox(query: IntegrationOutboxQuery = {}) {
return apiRequestData<IntegrationOutboxListResponse>(
`/api/integrations/outbox${toQueryString({
+3 -1
查看文件
@@ -3,7 +3,7 @@ import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/
import type { RiskFlag } from "./feeControl";
import type { ProcessingProgress } from "../types/processingProgress";
export type PaymentBatchEntityType = "reimbursement" | "travel_order";
export type PaymentBatchEntityType = "reimbursement" | "travel_order" | "loan_repayment";
export type PaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled";
export type PaymentChannel = "bank" | "alipay" | "wechat" | "cash" | "other";
@@ -87,6 +87,8 @@ export type PaymentBatch = {
export type PaymentBatchDetail = PaymentBatch & {
processingProgress: ProcessingProgress;
loanRepayment?: import("./companyLoans").LoanRepayment;
loanRepaymentId?: string | null;
};
export type PaymentReadiness = {
-103
查看文件
@@ -122,78 +122,6 @@ export type PurchaseOrderArchive = {
archivedFiles: PurchaseArchivedFile[];
};
export type PurchaseErpTrackStatus =
| "not_triggered"
| "pending"
| "processing"
| "succeeded"
| "failed"
| "dead_letter"
| "skipped";
export type PurchaseErpBinding = {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
};
export type PurchaseErpOutbox = {
id: string;
eventType: string;
status: string;
attempts: number;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
};
export type PurchaseErpTrackStage = {
eligible: boolean;
reason: string | null;
status: PurchaseErpTrackStatus | string;
preview: Record<string, unknown>;
outbox: PurchaseErpOutbox | null;
binding?: PurchaseErpBinding | null;
bindings?: {
po: PurchaseErpBinding | null;
contract: PurchaseErpBinding | null;
};
};
export type PurchaseRequestErpTrack = {
purchaseRequest: {
id: string;
requestNo: string | null;
status: string;
totalAmount: string;
supplier: {
id: string;
code: string | null;
name: string | null;
} | null;
archiveOrder: {
id: string;
poNo: string;
contractNo: string | null;
archivedAt: string | null;
} | null;
};
stages: {
commitmentJournal: PurchaseErpTrackStage;
accrualJournal: PurchaseErpTrackStage;
archiveSummary: PurchaseErpTrackStage;
};
permissions: {
canRetry: boolean;
};
};
export type PurchaseRequestErpRequeueTarget = "commitment_journal" | "accrual_journal" | "archive_summary";
export type PurchaseRequestDetail = PurchaseRequestSummary & {
items: PurchaseRequestItem[];
attachments: PurchaseAttachment[];
@@ -397,34 +325,3 @@ export async function downloadPurchaseAttachment(purchaseRequestId: string, atta
},
);
}
export async function fetchPurchaseRequestErpTrack(purchaseRequestId: string) {
return apiRequestData<PurchaseRequestErpTrack>(
`/api/integrations/erp/procurement/purchase-requests/${purchaseRequestId}/track`,
);
}
export async function requeuePurchaseRequestErpSync(
purchaseRequestId: string,
payload: {
targets: PurchaseRequestErpRequeueTarget[];
},
runNow = true,
) {
return apiRequestData<{
items: Array<{
target: PurchaseRequestErpRequeueTarget;
outboxId: string;
status: string;
duplicated: boolean;
requeued: boolean;
eventType: string;
}>;
}>(
`/api/integrations/erp/procurement/purchase-requests/${purchaseRequestId}/requeue${toQueryString({ runNow })}`,
{
method: 'POST',
body: JSON.stringify(payload),
},
);
}
-34
查看文件
@@ -87,33 +87,6 @@ export type TravelPaymentBoundInvoice = {
linkedTravelOrderId: string | null;
};
export type TravelPaymentErpStage = {
binding: {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
} | null;
outbox: {
id: string;
eventType: string;
status: string;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
} | null;
exportState: {
externalId: string | null;
lastOutboxId: string | null;
lastOutboxStatus: string | null;
lastError: string | null;
exportedAt: string | null;
};
};
export type TravelPaymentOrder = {
id: string;
externalOrderNo: string;
@@ -128,7 +101,6 @@ export type TravelPaymentOrder = {
paidBy: string | null;
boundInvoices: TravelPaymentBoundInvoice[];
matchedTransactions: TravelPaymentTransaction[];
erpStage: TravelPaymentErpStage;
actions: {
canMarkPaid: boolean;
canBindInvoice: boolean;
@@ -242,12 +214,6 @@ export async function markTravelOrderPaid(
paymentVoucherNo: string | null;
paymentDate: string | null;
paymentChannel: TravelPaymentChannel | null;
erpJournal: {
eventType: string;
outboxId: string | null;
dedupeKey: string;
enqueued: boolean;
};
}>(`/api/travel-orders/${travelOrderId}/mark-paid`, {
method: "POST",
body: JSON.stringify(payload),
-91
查看文件
@@ -107,7 +107,6 @@ export type TravelReconciliationBatchDetail = TravelReconciliationBatchSummary &
items: TravelReconciliationItem[];
permissions: {
canManagePayments: boolean;
canRetryErp: boolean;
};
};
@@ -135,84 +134,10 @@ export type TravelReconciliationCreatePayload = {
periodEnd: string;
};
export type TravelReconciliationErpTrack = {
batch: {
id: string;
batchNo: string;
status: string;
periodStart: string | null;
periodEnd: string | null;
legalEntity: {
id: string;
code: string;
name: string;
} | null;
costCenter: {
id: string;
code: string;
name: string;
} | null;
};
stage: {
summaryExport: {
eligible: boolean;
reason: string | null;
status: string;
preview: {
exportType: string;
entityType: string;
entityId: string | null;
batchNo: string | null;
occurredAt: string | null;
};
outbox: {
id: string;
eventType: string;
status: string;
attempts: number;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
} | null;
bindings: {
payableSummary: {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
} | null;
settlementBatch: {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
} | null;
};
};
};
permissions: {
canRetry: boolean;
canSeeExternalRefs: boolean;
};
};
export type TravelReconciliationFinalizeResponse = {
batchId: string;
batchNo: string;
status: TravelReconciliationBatchStatus;
erpExport: {
eventType: string;
outboxId: string | null;
duplicated: boolean;
requeued: boolean;
enqueued: boolean;
};
};
export type TravelReconciliationRequeueResponse = {
@@ -281,19 +206,3 @@ export async function finalizeTravelReconciliationBatch(batchId: string) {
},
);
}
export async function fetchTravelReconciliationErpTrack(batchId: string) {
return apiRequestData<TravelReconciliationErpTrack>(
`/api/integrations/erp/travel/reconciliation-batches/${batchId}/track`,
);
}
export async function requeueTravelReconciliationBatch(batchId: string) {
return apiRequestData<TravelReconciliationRequeueResponse>(
`/api/integrations/erp/travel/reconciliation-batches/${batchId}/requeue`,
{
method: 'POST',
body: JSON.stringify({}),
},
);
}
+2
查看文件
@@ -86,6 +86,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"payment.batch.review",
],
finance: [
"loan.manage",
"finance.ledger.view",
...EMPLOYEE_BASE_PERMISSIONS,
...MASTERDATA_VIEW_PERMISSIONS,
@@ -107,6 +108,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"payroll.manage",
],
finance_mgr: [
"loan.manage",
"finance.ledger.view",
...EMPLOYEE_BASE_PERMISSIONS,
...MASTERDATA_MANAGE_PERMISSIONS,
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../../auth/AuthContext";
import { downloadLoanEvidence, saveLoanAction, type LoanRepayment } from "../../api/companyLoans";
import type { PaymentBatchReceiptAttachment } from "../../api/paymentBatches";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
import { downloadBlob } from "../../lib/download";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { formatDisplayDate } from "../../lib/displayFormat";
export function LoanPaymentDetails({ repayment, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) {
const { me } = useAuth(); const cache = useQueryClient();
const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
const [action, setAction] = useState<"handoff" | "failure" | "download" | null>(null);
const [text, setText] = useState(""); const [attachmentId, setAttachmentId] = useState("");
const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null);
const finance = me?.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r));
const reviewer = me?.roleCodes.some((r) => ["manager", "ceo"].includes(r));
const mutation = useMutation({ mutationFn: async () => {
if (action === "handoff") await saveLoanAction(`/loan-repayments/${repayment.id}/accounting-handoff`, { reference: text });
if (action === "failure") await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/bank-failure-review`, { attachmentId });
if (action === "download" && evidence) {
const result = await downloadLoanEvidence(evidence.loanId, evidence.id, text, repayment.paymentBatchId);
if (active.current) downloadBlob(result.blob, result.filename ?? evidence.name);
}
}, onSuccess: async () => {
if (!active.current) return;
setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] })));
} });
function open(next: typeof action) { setText(""); setAttachmentId(""); mutation.reset(); setAction(next); }
return <section className="card-std space-y-3 p-4" aria-label="本金还款分配">
<h2 className="font-semibold">原借款分配与会计交接</h2>
<p className="break-words text-sm">{repayment.reason}</p>
{repayment.bankResultState === "unknown" ? <p className="text-sm text-amber-800">银行结果待查证;本金保持占用,不能取消释放或重复付款。</p> : null}
{repayment.bankResultState === "confirmed_failed" ? <p className="text-sm text-amber-800">银行失败证据已由独立人员确认。请财务取消本申请释放额度,保留原记录。</p> : null}
{repayment.allocations.map((allocation) => <div key={allocation.loanId} className="space-y-2 rounded-md border p-3 text-sm">
<p className="break-all font-medium">{allocation.loanNo} · 本次归还 ¥{allocation.amount}</p>
<p>借入 {formatDisplayDate(allocation.receivedDate)} · 核对截至 {formatDisplayDate(allocation.balanceAsOf)}</p>
<p className="break-words">原凭据:{allocation.originReference}</p>
<div className="flex flex-wrap gap-2">{allocation.evidences.map((item) => <Button key={item.id} variant="link" className="max-w-full whitespace-normal break-all"
onClick={() => { setEvidence({ loanId: allocation.loanId, id: item.id, name: item.originalName }); open("download"); }}>{item.originalName}</Button>)}</div>
</div>)}
<p className="text-sm">会计交接:{repayment.accountingHandoffRef ?? "待交接(与银行付款登记分别确认)"}</p>
<div className="flex flex-wrap gap-2">
{finance && repayment.status === "executed" && !repayment.accountingHandoffAt ? <Button variant="outline" onClick={() => open("handoff")}>确认会计交接</Button> : null}
{reviewer && ["reviewed", "failed"].includes(repayment.status) && repayment.bankResultState === "unknown" ? <Button variant="outline" onClick={() => open("failure")}>复核银行失败证据</Button> : null}
</div>
<Dialog open={action !== null} onOpenChange={(value) => { if (!value && !mutation.isPending) setAction(null); }}>
<DialogContent><DialogHeader><DialogTitle>{action === "handoff" ? "确认会计交接" : action === "failure" ? "独立复核银行失败证据" : "下载原借款材料"}</DialogTitle>
<DialogDescription>{action === "failure" ? "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"}</DialogDescription></DialogHeader>
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(); }}>
{mutation.error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error)}</p> : null}
{action === "failure" ? <label className="grid gap-2 text-sm">银行失败回单<select required className="min-h-11 rounded border px-3" value={attachmentId} onChange={(e) => setAttachmentId(e.target.value)}>
<option value="">请选择</option>{attachments.filter((item) => !item.archivedAt).map((item) => <option key={item.id} value={item.id}>{item.originalName}</option>)}
</select></label> : <label className="grid gap-2 text-sm">{action === "handoff" ? "会计凭证或交接引用" : "下载用途"}<Input required minLength={2} maxLength={action === "handoff" ? 200 : 500} value={text} onChange={(e) => setText(e.target.value)} /></label>}
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? "处理中…" : "确认"}</Button>
</form>
</DialogContent>
</Dialog>
</section>;
}
@@ -38,6 +38,7 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
{ to: "/company-loans", label: "公司借款", icon: "account_balance", anyOf: ["loan.manage"] },
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["finance.ledger.view"] },
{ to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
@@ -138,7 +139,7 @@ function renderBackupSection(items: ResolvedNavItem[], pathname: string, permiss
}
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
const paymentPaths = new Set(["/payment-batches", "/ap-operations"]);
const paymentPaths = new Set(["/payment-batches", "/ap-operations", "/company-loans"]);
const financeOnlyPaths = new Set(["/invoice-pool"]);
const backupOnlyPaths = new Set(BACKUP_NAV_ORDER);
const mapItems = (paths: string[]) =>
@@ -51,17 +51,6 @@ function paymentChannelLabel(value: TravelPaymentChannel | string | null) {
return "-";
}
function erpStageLabel(order: TravelPaymentOrder) {
const status = order.erpStage.exportState.lastOutboxStatus;
if (status) {
return status;
}
if (order.erpStage.binding?.externalId) {
return "已同步";
}
return "摘要导出未开启";
}
type TravelPaymentTrackCardProps = {
travelRequestId: string;
};
@@ -250,7 +239,6 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
<div>金额:{order.amount} {order.currency}</div>
<div>付款凭证:{order.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}</div>
<div>导出状态:{erpStageLabel(order)}</div>
</div>
</div>
{canManagePayments ? (
@@ -10,10 +10,8 @@ import {
createTravelReconciliationBatch,
fetchTravelReconciliationBatchDetail,
fetchTravelReconciliationBatches,
fetchTravelReconciliationErpTrack,
finalizeTravelReconciliationBatch,
reopenTravelReconciliationItem,
requeueTravelReconciliationBatch,
type TravelReconciliationBatchStatus,
type TravelReconciliationItem,
} from '../../api/travelReconciliation';
@@ -113,12 +111,6 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
queryFn: () => fetchTravelReconciliationBatchDetail(selectedBatchId),
});
const trackQuery = useQuery({
enabled: Boolean(selectedBatchId),
queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId],
queryFn: () => fetchTravelReconciliationErpTrack(selectedBatchId),
});
useEffect(() => {
if (selectedBatchId || !(listQuery.data?.items?.length)) {
return;
@@ -179,7 +171,6 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'detail', selectedBatchId] }),
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'list'] }),
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }),
]);
toast.success(`已确认差异:${variables.item.snapshot.externalOrderNo ?? variables.item.snapshot.externalTxnId ?? variables.item.id}`);
},
@@ -199,7 +190,6 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'detail', selectedBatchId] }),
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'list'] }),
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }),
]);
toast.success('差异项已重新打开');
},
@@ -228,29 +218,12 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
},
});
const requeueMutation = useMutation({
mutationFn: async () => {
if (!selectedBatchId) {
throw new Error('请选择待重试的对账批次');
}
return requeueTravelReconciliationBatch(selectedBatchId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] });
toast.success('交接摘要已重新入队');
},
onError: (error) => {
toast.error(resolveError(error));
},
});
function handleConfirm(item: TravelReconciliationItem) {
setConfirmItemTarget(item);
setConfirmItemNote(item.confirmNote ?? '');
}
const detail = detailQuery.data ?? null;
const track = trackQuery.data ?? null;
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
return (
@@ -436,11 +409,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
{finalizeMutation.isPending ? '收口中...' : detail.status === 'reconciled' ? '已收口' : 'Finalize'}
</Button>
) : null}
{track?.permissions.canRetry ? (
<Button type="button" variant="secondary" onClick={() => requeueMutation.mutate()} disabled={requeueMutation.isPending}>
{requeueMutation.isPending ? '重试中...' : '重试导出'}
</Button>
) : null}
</div>
</div>
@@ -464,40 +433,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
</div>
</div>
<div className="card-std space-y-4 p-6">
<div>
<h3 className="text-base font-semibold text-zinc-900">交接摘要</h3>
<p className="mt-1 text-sm text-zinc-500">批次级摘要导出轨道,不会回改订单、申请或预算原值。</p>
</div>
{trackQuery.isLoading ? <CardSkeleton lines={4} /> : null}
{trackQuery.isError ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
摘要轨道加载失败:{resolveError(trackQuery.error)}
</p>
) : null}
{track ? (
<div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4">
<div>
<p className="text-zinc-500">阶段状态</p>
<div className="mt-1"><StatusBadge status={track.stage.summaryExport.status} /></div>
</div>
<div>
<p className="text-zinc-500">Outbox</p>
<p className="mt-1 font-medium text-zinc-900">{track.stage.summaryExport.outbox?.id ?? '未入队'}</p>
</div>
<div>
<p className="text-zinc-500">应付摘要</p>
<p className="mt-1 font-medium text-zinc-900">{track.stage.summaryExport.bindings.payableSummary?.externalCode ?? '未生成'}</p>
</div>
<div>
<p className="text-zinc-500">结算批次</p>
<p className="mt-1 font-medium text-zinc-900">{track.stage.summaryExport.bindings.settlementBatch?.externalCode ?? '未生成'}</p>
</div>
</div>
) : null}
</div>
<div className="card-std space-y-4 p-6">
<div>
+3 -1
查看文件
@@ -3,7 +3,7 @@ import {
resolveManagementRoute,
} from "./managementRoutes";
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"];
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/company-loans", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"];
export const ADMIN_NAV_ORDER = ["/system/settings"];
export const BACKUP_NAV_ORDER = ["/weekly-reports", "/announcements", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"];
@@ -19,6 +19,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/purchase-requests", title: "采购", group: "业务" },
{ path: "/travel-requests", title: "差旅申请", group: "更多" },
{ path: "/budgets", title: "预算管理", group: "财务" },
{ path: "/company-loans", title: "公司借款", group: "财务" },
{ path: "/mobile/company-loans", title: "移动公司借款", group: "财务" },
{ path: "/payment-batches", title: "付款", group: "财务" },
{ path: "/ap-operations", title: "应付账款", group: "财务" },
{ path: "/invoice-pool", title: "票据", group: "财务" },
+4
查看文件
@@ -129,6 +129,10 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
return "/mobile";
}
if (normalizedPath === "/company-loans") {
return `/mobile/company-loans${search}`;
}
if (normalizedPath === "/payment-batches") {
return `/mobile/payment-batches${search}`;
}
-1
查看文件
@@ -38,7 +38,6 @@ export const TEST_IDS = {
withdraw: "reimbursement-withdraw",
pay: "reimbursement-pay",
budgetHint: "reimbursement-budget-hint",
erpExport: "reimbursement-erp-export",
overview: "reimbursement-overview",
workflowProgress: "reimbursement-workflow-progress",
invoices: "reimbursement-invoices",
+2 -3
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -107,9 +108,7 @@ export function AnnouncementsPage() {
await queryClient.invalidateQueries({ queryKey: ["announcements"] });
toast.success("签收已记录");
},
onError: () => {
toast.error("签收失败,请刷新后重试");
},
onError: (error) => toast.error(resolveErrorMessage(error, "签收失败,请刷新后重试")),
});
return (
+4 -16
查看文件
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { isApiError } from "../api/types";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { exportAuditLogs } from "../api/exports";
import { fetchAuditLogs, type AuditLogListResponse } from "../api/audit";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
@@ -47,11 +47,7 @@ export function AuditLogsPage() {
}
} catch (err) {
if (!cancelled) {
if (isApiError(err)) {
setError(`${err.code}: ${err.message}`);
} else {
setError("加载审计日志失败,请稍后重试");
}
setError(resolveErrorMessage(err, "加载审计日志失败,请稍后重试"));
}
} finally {
if (!cancelled) {
@@ -93,11 +89,7 @@ export function AuditLogsPage() {
downloadBlob(response.blob, response.filename ?? "audit-logs.csv");
setError("");
} catch (err) {
if (isApiError(err)) {
setError(`${err.code}: ${err.message}`);
} else {
setError("导出审计日志失败,请稍后重试");
}
setError(resolveErrorMessage(err, "导出审计日志失败,请稍后重试"));
} finally {
setExporting(false);
}
@@ -118,11 +110,7 @@ export function AuditLogsPage() {
downloadBlob(response.blob, response.filename ?? "audit-logs.xlsx");
setError("");
} catch (err) {
if (isApiError(err)) {
setError(`${err.code}: ${err.message}`);
} else {
setError("导出审计日志失败,请稍后重试");
}
setError(resolveErrorMessage(err, "导出审计日志失败,请稍后重试"));
} finally {
setExporting(false);
}
+187
查看文件
@@ -0,0 +1,187 @@
import { cloneElement, isValidElement, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { fetchLegalEntities } from "../api/masterData";
import { fetchBankInstitutions } from "../api/paymentAccounts";
import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoanIdentities, fetchLoanLenders,
fetchLoanRepayments, saveLoanAction, uploadLoanEvidence, type CompanyLoan, type LoanLender } from "../api/companyLoans";
import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { formatDisplayDate } from "../lib/displayFormat";
import { downloadBlob } from "../lib/download";
const evidenceLabels: Record<string, string> = { agreement: "协议或借款确认书", incoming: "原借入凭据",
accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" };
const stateLabels: Record<string, string> = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" };
type Mode = "lender" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null;
const selectClass = "min-h-11 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm";
function Field({ label, children }: { label: string; children: ReactNode }) {
const id = useId();
return <div className="grid gap-1.5 text-sm"><Label htmlFor={id}>{label}</Label>
{isValidElement<{ id?: string }>(children) ? cloneElement(children, { id }) : children}</div>;
}
export function CompanyLoansPage() {
const { me, permissions } = useAuth(); const navigate = useNavigate(); const cache = useQueryClient();
const paymentPath = useLocation().pathname.startsWith("/mobile/") ? "/mobile/payment-batches" : "/payment-batches";
const mounted = useRef(true);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const allowed = Boolean(me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role))) && hasPermission(permissions, "loan.manage");
const [page, setPage] = useState(1); const [lenderPage, setLenderPage] = useState(1); const [repayPage, setRepayPage] = useState(1);
const [tab, setTab] = useState<"loans" | "lenders" | "repayments">("loans");
const [mode, setMode] = useState<Mode>(null); const [selectedLender, setSelectedLender] = useState<LoanLender | null>(null);
const [selectedLoan, setSelectedLoan] = useState<CompanyLoan | null>(null);
const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null);
const [lenderId, setLenderId] = useState(""); const [identitySearch, setIdentitySearch] = useState("");
const [amounts, setAmounts] = useState<Record<string, string>>({});
const [candidatePage, setCandidatePage] = useState(1);
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
const identity = useRef(me?.id); identity.current = me?.id;
const loans = useQuery({ queryKey: ["company-loans", me?.id, page], queryFn: ({ signal }) => fetchCompanyLoans(page, undefined, signal), enabled: allowed });
const lenders = useQuery({ queryKey: ["loan-lenders", me?.id, lenderPage], queryFn: ({ signal }) => fetchLoanLenders(lenderPage, signal), enabled: allowed });
const repayments = useQuery({ queryKey: ["loan-repayments", me?.id, repayPage], queryFn: ({ signal }) => fetchLoanRepayments(repayPage, signal), enabled: allowed });
const candidates = useQuery({ queryKey: ["company-loans", me?.id, "candidates", lenderId, candidatePage],
queryFn: ({ signal }) => fetchCompanyLoans(candidatePage, lenderId, signal), enabled: allowed && mode === "repay" && Boolean(lenderId) });
const companies = useQuery({ queryKey: ["loan-companies", me?.id], queryFn: () => fetchLegalEntities({ enabled: true }), enabled: allowed && mode === "lender" });
const banks = useQuery({ queryKey: ["loan-banks", me?.id], queryFn: fetchBankInstitutions, enabled: allowed && ["lender", "account"].includes(mode ?? "") });
const people = useQuery({ queryKey: ["loan-identities", me?.id, identitySearch], queryFn: ({ signal }) => fetchLoanIdentities(identitySearch, signal), enabled: allowed && mode === "identity" });
const mutation = useMutation({ mutationFn: async (action: () => Promise<unknown>) => action(), onSuccess: async () => {
await Promise.all(["company-loans", "loan-lenders", "loan-repayments", "payment-batches"].map((key) => cache.invalidateQueries({ queryKey: [key] })));
} });
useEffect(() => { setMode(null); setSelectedLender(null); setSelectedLoan(null); setAmounts({}); }, [me?.id]);
function open(next: Mode, lender: LoanLender | null = null, loan: CompanyLoan | null = null) {
mutation.reset(); setRequestKey(crypto.randomUUID()); setMode(next); setSelectedLender(lender); setSelectedLoan(loan);
setLenderId(lender?.id ?? ""); setAmounts({}); setIdentitySearch(""); setCandidatePage(1);
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const values = new FormData(event.currentTarget);
const text = (key: string) => String(values.get(key) ?? "").trim();
const actorId = me?.id;
mutation.mutate(async () => {
if (mode === "lender") await saveLoanAction("/loan-lenders", { requestKey, legalEntityId: text("company"), name: text("name"),
bankInstitutionId: text("bank"), bankName: text("bankName"), bankAccount: text("account"), accountName: text("accountName") });
if (mode === "account" && selectedLender) await saveLoanAction(`/loan-lenders/${selectedLender.id}/account`, {
expectedVersion: selectedLender.accountVersion, bankInstitutionId: text("bank"), bankName: text("bankName"),
bankAccount: text("account"), accountName: text("accountName"),
}, "PATCH");
if (mode === "identity" && selectedLender) await saveLoanAction(`/loan-lenders/${selectedLender.id}/identity-confirmation`, {
expectedVersion: selectedLender.identityVersion, employeeId: text("employee") === "external" ? null : text("employee"),
externalPersonConfirmed: text("employee") === "external", reason: text("reason"),
});
if (mode === "loan") await saveLoanAction("/company-loans", { requestKey, lenderId: text("lender"), originReference: text("origin"),
originalPrincipal: text("principal"), openingOutstandingPrincipal: text("opening"), receivedDate: text("received"), balanceAsOf: text("asOf"),
historicalEntry: text("entry") === "historical" });
if (mode === "confirm" && selectedLoan) await saveLoanAction(`/company-loans/${selectedLoan.id}/confirm`, {
expectedVersion: selectedLoan.version, accountingConfirmationRef: text("reference"),
});
if (mode === "download" && evidence) {
const response = await downloadLoanEvidence(evidence.loanId, evidence.id, text("reason"));
if (mounted.current && identity.current === actorId) downloadBlob(response.blob, response.filename ?? evidence.name);
}
if (mode === "repay") {
const response = await createLoanRepayment({ requestKey, lenderId, reason: text("reason"),
allocations: Object.entries(amounts).filter(([, amount]) => amount.trim()).map(([loanId, amount]) => ({ loanId, amount: amount.trim() })) });
if (mounted.current && identity.current === actorId) navigate(`${paymentPath}?id=${encodeURIComponent(response.item.id)}`);
}
if (mounted.current && identity.current === actorId) setMode(null);
});
}
if (!allowed) return <p role="alert">当前账号无借款台账权限。</p>;
const error = mutation.error ?? loans.error ?? lenders.error ?? repayments.error ?? companies.error ?? banks.error ?? people.error;
const current = tab === "loans" ? loans : tab === "lenders" ? lenders : repayments;
const modalQueryBlocked = mode === "lender" ? !companies.isSuccess || !banks.isSuccess : mode === "account" ? !banks.isSuccess : mode === "identity" ? !people.isSuccess : mode === "loan" ? !lenders.isSuccess : mode === "repay" ? !lenders.isSuccess || !candidates.isSuccess : false;
const currentPage = tab === "loans" ? page : tab === "lenders" ? lenderPage : repayPage;
const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage;
return <ListWorkspace>
<WorkspaceHeader title="公司借款" subtitle="记录公司向个人借入及归还的人民币本金。余额以核对截止日及 OA 登记为依据,未核实不代表已结清。"
actions={<HeaderActionGroup><Button variant="outline" onClick={() => open("lender")}>登记出借人</Button>
<Button onClick={() => open("loan")}>登记借入</Button><Button variant="outline" onClick={() => open("repay")}>申请还本</Button></HeaderActionGroup>} />
<div className="flex flex-wrap gap-2" role="tablist" aria-label="借款台账视图">
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"]] as const).map(([key, label]) =>
<Button key={key} role="tab" aria-selected={tab === key} variant={tab === key ? "default" : "outline"} onClick={() => setTab(key)}>{label}</Button>)}
</div>
{error && !mode ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{current.isPending ? <p role="status">加载中…</p> : null}
{current.isSuccess && !current.data.items.length ? <p className="card-std p-4">暂无记录;不能据此判断银行实际借还款情况。</p> : null}
{tab === "loans" && loans.isSuccess ? loans.data.items.map((loan) => <section key={loan.id} className="card-std min-w-0 space-y-3 p-4">
<div className="flex flex-wrap justify-between gap-2"><h2 className="font-semibold break-all">{loan.lender.name} · {loan.loanNo}</h2>
<span>{loan.confirmationStatus === "confirmed" ? "余额已确认" : "余额待核实"}</span></div>
<p className="text-sm text-zinc-600">{loan.lender.legalEntityName} · 借入 {formatDisplayDate(loan.receivedDate)} · 核对截至 {formatDisplayDate(loan.balanceAsOf)} · {loan.historicalEntry ? "历史登记" : "当期借入"}</p>
<div className="detail-fact-grid text-sm">
{[["原本金", loan.originalPrincipal], ["未还本金", loan.outstanding], ["待付款占用", loan.reserved], ["可申请还本", loan.available]].map(([label, value]) =>
<div key={label} className="detail-fact-card"><div className="detail-fact-card__label">{label}</div><div className="detail-fact-card__value">¥{value}</div></div>)}
</div>
<p className="break-words text-sm">原借入凭据:{loan.originReference}</p>
<details><summary className="min-h-11 cursor-pointer py-3 text-sm font-medium">借款材料与余额确认</summary>
<div className="grid gap-3 md:grid-cols-2">{Object.entries(evidenceLabels).map(([kind, label]) => <div key={kind} className="rounded-md border p-3">
<Label htmlFor={`${loan.id}-${kind}`}>{label}</Label>
<Input id={`${loan.id}-${kind}`} type="file" className="mt-2" accept=".pdf,.png,.jpg,.jpeg,.webp" disabled={mutation.isPending}
onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = "";
if (file) mutation.mutate(() => uploadLoanEvidence(loan.id, kind, file)); }} />
{loan.evidences.filter((e) => e.kind === kind && !e.archivedAt).map((e) => <Button key={e.id} variant="link" className="max-w-full whitespace-normal break-all"
onClick={() => { setEvidence({ loanId: loan.id, id: e.id, name: e.originalName }); open("download"); }}>{e.originalName}</Button>)}
</div>)}</div>
{loan.confirmationStatus !== "confirmed" ? <Button className="mt-3" onClick={() => open("confirm", loan.lender, loan)}>确认期初余额</Button> : null}
</details>
</section>) : null}
{tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) => <section key={lender.id} className="card-std space-y-3 p-4">
<h2 className="font-semibold">{lender.name} · {lender.legalEntityName}</h2>
<p className="break-words text-sm">{lender.bankName} · {lender.bankAccountMasked ?? "账户不可用"} · {lender.identityStatus === "verified" ? "身份已核实" : "身份待核实"}</p>
<div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => open("identity", lender)}>核实身份关联</Button>
<Button variant="outline" onClick={() => open("account", lender)}>更新收款资料</Button></div>
</section>) : null}
{tab === "repayments" && repayments.isSuccess ? repayments.data.items.map((repayment) => <section key={repayment.id} className="card-std space-y-2 p-4">
<h2 className="font-semibold break-all">{repayment.lenderName} · {repayment.repaymentNo}</h2>
<p>¥{repayment.amount} · {stateLabels[repayment.status] ?? "待核对"}</p>
{repayment.bankResultState === "unknown" ? <p className="text-sm text-amber-800">银行结果待查证,额度保持占用,请勿重复付款。</p> : null}
<p className="text-sm">会计交接:{repayment.accountingHandoffRef ?? "待交接"}</p>
<Link className="inline-flex min-h-11 items-center text-primary underline" to={`${paymentPath}?id=${encodeURIComponent(repayment.paymentBatchId)}`}>查看还款分配、付款与回单</Link>
</section>) : null}
<div className="flex items-center gap-3"><Button variant="outline" disabled={currentPage <= 1} onClick={() => setCurrentPage(currentPage - 1)}>上一页</Button>
<span>第 {currentPage} 页</span><Button variant="outline" disabled={!current.data || currentPage * 30 >= current.data.total} onClick={() => setCurrentPage(currentPage + 1)}>下一页</Button></div>
<Dialog open={mode !== null} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>
{({ lender: "登记出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
</DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款,提交后进入独立付款复核。" :
mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
mode === "account" ? "更新后须重新确认身份,原未付申请失效,请安全取消后重新申请。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
<form onSubmit={submit} className="grid gap-4">
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{mode === "lender" ? <><Field label="公司主体"><select required name="company" className={selectClass}><option value="">请选择</option>{companies.data?.items.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
<Field label="出借人姓名"><Input name="name" required maxLength={120} /></Field></> : null}
{mode === "lender" || mode === "account" ? <><Field label="收款银行"><select name="bank" required className={selectClass} defaultValue={selectedLender?.bankInstitutionId ?? ""}><option value="">请选择</option>{banks.data?.items.filter((b) => b.code === "CCB").map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select></Field>
<Field label="开户行名称"><Input name="bankName" required maxLength={200} defaultValue={selectedLender?.bankName} /></Field>
<Field label="账户名"><Input name="accountName" required maxLength={120} autoComplete="off" /></Field>
<Field label="收款账号"><Input name="account" required inputMode="numeric" pattern="[0-9]{10,30}" autoComplete="off" /></Field></> : null}
{mode === "identity" ? <><Field label="检索 OA 人员"><Input value={identitySearch} onChange={(e) => setIdentitySearch(e.target.value)} placeholder="姓名或员工编号" /></Field>
<Field label="出借人对应身份"><select key={identitySearch} name="employee" required className={selectClass} defaultValue=""><option value="">请选择已核实身份</option><option value="external">已核实:非员工且没有关联 OA 账号</option>
{people.data?.items.map((p) => <option key={p.id} value={p.id}>{p.name} · {p.employeeNo}</option>)}</select></Field></> : null}
{mode === "loan" ? <><Field label="出借人"><select required name="lender" className={selectClass}><option value="">请选择本页已登记出借人</option>{lenders.data?.items.map((l) => <option key={l.id} value={l.id}>{l.name} · {l.legalEntityName}</option>)}</select></Field>
<Field label="原借入凭据唯一引用"><Input name="origin" required maxLength={200} /></Field>
<Field label="原始本金"><Input name="principal" required inputMode="decimal" pattern="[0-9]{1,12}(\.[0-9]{1,2})?" /></Field>
<Field label="核对日未还本金"><Input name="opening" required inputMode="decimal" pattern="[0-9]{1,12}(\.[0-9]{1,2})?" /></Field>
<Field label="实际到账日期"><Input name="received" required type="date" /></Field><Field label="余额核对截止日"><Input name="asOf" required type="date" /></Field>
<Field label="登记性质"><select name="entry" className={selectClass}><option value="historical">历史借入登记</option><option value="current">当期实际借入</option></select></Field></> : null}
{mode === "confirm" ? <><p>原本金 ¥{selectedLoan?.originalPrincipal},核对日未还 ¥{selectedLoan?.openingOutstandingPrincipal}</p>
<Field label="代账余额确认引用"><Input required name="reference" minLength={2} maxLength={200} /></Field></> : null}
{mode === "repay" ? <><Field label="出借人"><select required className={selectClass} value={lenderId} onChange={(e) => { setLenderId(e.target.value); setAmounts({}); setCandidatePage(1); }}><option value="">请选择</option>
{lenders.data?.items.map((l) => <option key={l.id} value={l.id}>{l.name} · {l.legalEntityName}</option>)}</select></Field>
<p className="text-sm text-zinc-600">填写已确认借款的分配金额,留空表示本次不归还。翻页保留已填写金额。</p>
{candidates.error ? <p role="alert">{resolveErrorMessage(candidates.error)}</p> : null}
{candidates.isSuccess ? candidates.data.items.filter((l) => l.lender.id === lenderId && l.confirmationStatus === "confirmed").map((l) => <Field key={l.id} label={`${l.originReference}(可还 ¥${l.available})`}>
<Input aria-label={`分配 ${l.originReference}`} inputMode="decimal" pattern="[0-9]{1,12}(\.[0-9]{1,2})?" value={amounts[l.id] ?? ""} onChange={(e) => setAmounts({ ...amounts, [l.id]: e.target.value })} /></Field>) : null}</> : null}
{mode === "repay" && lenderId ? <div className="flex flex-wrap items-center gap-2"><Button type="button" variant="outline" disabled={candidatePage === 1} onClick={() => setCandidatePage(candidatePage - 1)}>上一页借款</Button>
<span>第 {candidatePage} 页 · 已填 {Object.values(amounts).filter((v) => v.trim()).length} 笔</span><Button type="button" variant="outline" disabled={!candidates.data || candidatePage * 30 >= candidates.data.total} onClick={() => setCandidatePage(candidatePage + 1)}>下一页借款</Button></div> : null}
{["identity", "repay", "download"].includes(mode ?? "") ? <Field label={mode === "download" ? "下载用途" : "说明"}><Input name="reason" required minLength={2} maxLength={500} /></Field> : null}
<Button type="submit" disabled={mutation.isPending || modalQueryBlocked || mode === "repay" && !Object.values(amounts).some((v) => v.trim())}>{mutation.isPending ? "处理中…" : mode === "repay" ? "提交还款申请" : "确认"}</Button>
</form>
</DialogContent>
</Dialog>
</ListWorkspace>;
}
+10 -28
查看文件
@@ -73,7 +73,15 @@ export function InvoicePoolPage() {
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
const [selectedTravelRequestId, setSelectedTravelRequestId] = useState("");
const [selectedTravelOrderId, setSelectedTravelOrderId] = useState("");
const [detailInvoiceId, setDetailInvoiceId] = useState(searchParams.get("id") ?? "");
const detailInvoiceId = searchParams.get("id") ?? "";
const setDetailInvoiceId = (id: string) => {
setSearchParams((current) => {
const next = new URLSearchParams(current);
if (id) next.set("id", id);
else next.delete("id");
return next;
}, { replace: true });
};
const [editOpen, setEditOpen] = useState(false);
const [editSetAvailable, setEditSetAvailable] = useState(false);
const [editRemarks, setEditRemarks] = useState("");
@@ -204,27 +212,6 @@ export function InvoicePoolPage() {
setSelectedTravelOrderId("");
}, [canManageTravelPayments]);
useEffect(() => {
const searchInvoiceId = searchParams.get("id") ?? "";
if (searchInvoiceId !== detailInvoiceId) {
setDetailInvoiceId(searchInvoiceId);
}
}, [detailInvoiceId, searchParams]);
useEffect(() => {
const currentId = searchParams.get("id") ?? "";
if (detailInvoiceId === currentId) {
return;
}
const next = new URLSearchParams(searchParams);
if (detailInvoiceId) {
next.set("id", detailInvoiceId);
} else {
next.delete("id");
}
setSearchParams(next, { replace: true });
}, [detailInvoiceId, searchParams, setSearchParams]);
// --- Helper for error handling ---
const toUserError = (err: unknown, fallback: string) => {
@@ -459,12 +446,7 @@ export function InvoicePoolPage() {
detail.uploadedByEmployeeId === me?.id),
);
const isInvoiceDetailMode = Boolean(detailInvoiceId);
const returnToInvoiceList = () => {
setDetailInvoiceId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
};
const returnToInvoiceList = () => setDetailInvoiceId("");
// --- Render ---
+3
查看文件
@@ -639,6 +639,9 @@ export function MasterDataPage() {
</TabsContent>
<TabsContent value="document-categories" className="space-y-4">
<p className="text-sm text-zinc-500">
统一编号启用后,报销按月、采购和盘点按年、资产永久编号。以下编号设置仅继续用于旧规则及电子资产前缀,不会覆盖统一编号;已有单号保持不变。
</p>
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="grid gap-3 md:grid-cols-3">
<select
+1
查看文件
@@ -104,6 +104,7 @@ export function MobileMePage() {
<SectionPanel title="移动入口" description="只保留手机上常用的个人办事入口。">
<div className="mobile-entry-grid mobile-me-entry-grid" data-testid="mobile-more-links">
{me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role)) ? <MobileActionLink to="/mobile/company-loans" label="公司借款" icon="account_balance" /> : null}
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
<MobileActionLink to="/mobile/leave" label="请假记录" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
+6 -3
查看文件
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { Eye } from "lucide-react";
import { LoanPaymentDetails } from "../components/common/LoanPaymentDetails";
import {
cancelPaymentBatch,
createPaymentBatch,
@@ -84,6 +85,7 @@ type PaymentActionKind =
function entityTypeLabel(value: string) {
if (value === "reimbursement") return "报销";
if (value === "travel_order") return "差旅订单";
if (value === "loan_repayment") return "借款本金归还";
return value || "-";
}
@@ -121,7 +123,7 @@ function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string }
const subject = blocker.entityCode ? `${blocker.entityCode}:` : "";
const messages: Record<string, string> = {
status_not_reviewed: "仅待财务付款批次可生成制单",
reimbursement_only: "仅报销付款批次支持制单",
reimbursement_only: "当前付款来源不支持银行制单",
bank_channel_required: "付款渠道不是银行转账",
legacy_payment_account_snapshot_missing: "历史批次未冻结公司付款账户,仅支持人工付款",
payment_account_snapshot_invalid: "冻结的公司付款账户资料不完整",
@@ -314,7 +316,7 @@ export function PaymentBatchesPage() {
const canCancelSelectedBatch =
canManageBatch &&
!!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status);
!["executed", "cancelled"].includes(selectedBatch.status) && selectedBatch.loanRepayment?.bankResultState !== "unknown";
const paymentActionModel = useMemo(() => {
const canDownloadOrder =
@@ -904,8 +906,9 @@ export function PaymentBatchesPage() {
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
<h3 className="text-base font-semibold text-zinc-900">
{selectedBatch.entityType === "reimbursement" ? "本批次报销单" : "本批次差旅订单"}
{selectedBatch.entityType === "reimbursement" ? "本批次报销单" : selectedBatch.entityType === "loan_repayment" ? "本次本金还款" : "本批次差旅订单"}
</h3>
{selectedBatch.loanRepayment ? <LoanPaymentDetails key={`${selectedBatch.id}-${me?.id}`} repayment={selectedBatch.loanRepayment} attachments={receiptAttachmentsQuery.data?.items ?? []} /> : null}
<div className="space-y-4">
{selectedBatch.items.map((item) => (
<div key={item.id} className="detail-evidence-card">
+4 -3
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -61,9 +62,7 @@ export function PortalAnnouncementsPage() {
await refresh();
toast.success("公告已创建");
},
onError: () => {
toast.error("公告创建失败,请检查填写内容");
},
onError: (error) => toast.error(resolveErrorMessage(error, "公告创建失败,请检查填写内容")),
});
const archiveMutation = useMutation({
@@ -72,6 +71,7 @@ export function PortalAnnouncementsPage() {
await refresh();
toast.success("公告已归档");
},
onError: (error) => toast.error(resolveErrorMessage(error, "公告归档失败,请刷新后重试")),
});
const toggleMutation = useMutation({
@@ -80,6 +80,7 @@ export function PortalAnnouncementsPage() {
await refresh();
toast.success("公告状态已更新");
},
onError: (error) => toast.error(resolveErrorMessage(error, "公告状态更新失败,请刷新后重试")),
});
const handleCreate = () => {
+7 -1
查看文件
@@ -14,6 +14,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { toast } from "sonner";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
const ENTITY_TYPES: WorkflowDefinitionEditorModel["entityType"][] = ["reimbursement", "leave", "purchase_request", "travel_request"];
@@ -65,6 +66,7 @@ export function WorkflowDefinitionsPage() {
await refresh();
toast.success("流程定义草稿已创建");
},
onError: (error) => toast.error(resolveErrorMessage(error, "创建流程定义失败")),
});
const saveMutation = useMutation({
@@ -73,6 +75,7 @@ export function WorkflowDefinitionsPage() {
await refresh();
toast.success("流程定义已保存");
},
onError: (error) => toast.error(resolveErrorMessage(error, "保存流程定义失败")),
});
const publishMutation = useMutation({
@@ -81,6 +84,7 @@ export function WorkflowDefinitionsPage() {
await refresh();
toast.success("流程定义已发布");
},
onError: (error) => toast.error(resolveErrorMessage(error, "发布流程定义失败")),
});
const archiveMutation = useMutation({
@@ -89,6 +93,7 @@ export function WorkflowDefinitionsPage() {
await refresh();
toast.success("流程定义已归档");
},
onError: (error) => toast.error(resolveErrorMessage(error, "归档流程定义失败")),
});
return (
@@ -119,7 +124,8 @@ export function WorkflowDefinitionsPage() {
<div className="text-xs text-zinc-500">{item.status} · {item.source}</div>
</button>
))}
{!scopedItems.length ? <div className="text-sm text-zinc-500">当前实体暂无流程定义</div> : null}
{listQuery.isError ? <div className="text-sm text-red-600">{resolveErrorMessage(listQuery.error, "加载流程定义失败")}</div> : null}
{!listQuery.isError && !scopedItems.length ? <div className="text-sm text-zinc-500">当前实体暂无流程定义</div> : null}
</div>
</div>
+1
查看文件
@@ -38,6 +38,7 @@ const MODULE_LABELS: Record<string, string> = {
budgets: "预算管理",
paymentBatches: "付款",
apOperations: "应付账款",
companyLoans: "公司借款",
invoicePool: "票据",
inventoryChecks: "资产盘点",
cutover: "切换控制台",
@@ -170,6 +170,13 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开完整材料失败,可逐份查看原件")),
});
const refreshApprovalState = () => Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
refetchTask(),
]);
const actionMutation = useMutation({
mutationFn: async (action: "approve" | "return" | "transfer") => {
if (!decisionReady) {
@@ -181,11 +188,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
return transferWorkflowTask(taskId, selectedApprover, transferReason.trim());
},
onSuccess: async (_data, action) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
refetchTask(),
]);
await refreshApprovalState();
setActionError("");
setActiveSecondaryAction(null);
setConfirmAction(null);
@@ -193,7 +196,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
toast.success({ approve: "已通过", return: "已退回", transfer: "已转审" }[action]);
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) void refetchTask();
if (isAlreadyProcessedTaskError(error)) void refreshApprovalState();
const message = resolveUserErrorMessage(error, "动作执行失败,请稍后重试");
setActionError(message);
toast.error(message);
+55 -54
查看文件
@@ -1,10 +1,10 @@
import { registerSW } from "virtual:pwa-register";
import { canUseWindow, isEnterpriseMessengerContainer, matchesMedia } from "./lib/runtimeContext";
import { PWA_UPDATE_READY_EVENT } from "./lib/pwaUpdateEvents";
const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i;
const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"];
const CLEARABLE_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime", "workbox-precache"];
const RUNTIME_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime"];
const CLEARABLE_CACHE_PREFIXES = [...RUNTIME_CACHE_PREFIXES, "workbox-precache"];
const AUTH_INPUT_PATHS = ["/login", "/change-password", "/forgot-password", "/reset-password"];
const BUILD_SHA_DISPLAY_LENGTH = 12;
@@ -15,31 +15,22 @@ type ReadyResponse = {
};
let buildFreshnessMonitorInstalled = false;
let pwaRegistrationStarted = false;
function isAuthInputPath(pathname: string) {
return AUTH_INPUT_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`));
}
function bindServiceWorkerUpdateCleanup(registration: ServiceWorkerRegistration | undefined) {
if (!canUseWindow() || !registration) {
return;
}
const watchInstallingWorker = (worker: ServiceWorker | null) => {
if (!worker) {
return;
}
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && window.navigator.serviceWorker.controller) {
void clearPwaRuntimeCaches();
}
});
};
watchInstallingWorker(registration.installing);
registration.addEventListener("updatefound", () => {
watchInstallingWorker(registration.installing);
function waitForActivatedWorker(worker: ServiceWorker): Promise<boolean> {
if (worker.state === "activated") return Promise.resolve(true);
if (worker.state === "redundant") return Promise.resolve(false);
return new Promise((resolve) => {
const onStateChange = () => {
if (worker.state !== "activated" && worker.state !== "redundant") return;
worker.removeEventListener("statechange", onStateChange);
resolve(worker.state === "activated");
};
worker.addEventListener("statechange", onStateChange);
});
}
@@ -120,43 +111,49 @@ export function installBuildFreshnessMonitor() {
}
export function registerPwaIfEnabled() {
if (import.meta.env.DEV) {
if (import.meta.env.DEV || import.meta.env.VITE_PWA_ENABLED === "false" ||
!canUseWindow() || !("serviceWorker" in window.navigator) || pwaRegistrationStarted) {
return;
}
pwaRegistrationStarted = true;
const serviceWorker = window.navigator.serviceWorker;
let previousController = serviceWorker.controller;
let refreshScheduled = false;
let refreshApplied = false;
if (import.meta.env.VITE_PWA_ENABLED === "false") {
return;
}
let updateServiceWorker: ((reloadPage?: boolean) => Promise<void>) | null = null;
updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
const applyUpdate = () => {
void clearPwaRuntimeCaches()
.catch(() => undefined)
.finally(() => {
void updateServiceWorker?.(true);
});
};
if (canUseWindow() && isAuthInputPath(window.location.pathname)) {
window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate } }));
// Native navigation may start an update before register() resolves. Subscribe
// first: updatefound/activated listeners installed afterward can miss it.
serviceWorker.addEventListener("controllerchange", () => {
const controller = serviceWorker.controller;
if (!controller || controller === previousController) return;
const isUpdate = previousController !== null;
previousController = controller;
void waitForActivatedWorker(controller).then((activated) => {
if (!activated || controller !== serviceWorker.controller) return;
if (!isUpdate) {
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
return;
}
applyUpdate();
},
onOfflineReady() {
if (canUseWindow()) {
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
if (refreshScheduled) return;
refreshScheduled = true;
const applyUpdate = () => {
if (refreshApplied) return;
refreshApplied = true;
// Workbox owns new precache installation and old-revision cleanup.
// Deleting it here races activation and destroys the new offline shell.
void clearPwaCaches(RUNTIME_CACHE_PREFIXES)
.catch(() => undefined)
.finally(() => window.location.reload());
};
if (isAuthInputPath(window.location.pathname)) {
window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate } }));
} else {
applyUpdate();
}
},
onRegisteredSW(_swScriptUrl, registration) {
bindServiceWorkerUpdateCleanup(registration);
},
});
});
void serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`, { scope: import.meta.env.BASE_URL })
.catch(() => undefined);
}
export function buildMobileApprovalsPath(taskId?: string | null) {
@@ -196,14 +193,14 @@ export function shouldPreferMobileApprovalExperience() {
return isStandalone || isEnterpriseMessengerContainer() || isTouchSmallScreen;
}
export async function clearPwaRuntimeCaches() {
async function clearPwaCaches(prefixes: readonly string[]) {
if (!canUseWindow() || !("caches" in window)) {
return;
}
const cacheNames = await window.caches.keys();
const cachesToDelete = cacheNames.filter((cacheName) =>
CLEARABLE_CACHE_PREFIXES.some((prefix) => cacheName.startsWith(prefix)),
prefixes.some((prefix) => cacheName.startsWith(prefix)),
);
await Promise.all(cachesToDelete.map(async (cacheName) => {
@@ -213,3 +210,7 @@ export async function clearPwaRuntimeCaches() {
}
}));
}
export async function clearPwaRuntimeCaches() {
await clearPwaCaches(CLEARABLE_CACHE_PREFIXES);
}
+4
查看文件
@@ -16,6 +16,7 @@ import { NotFoundPage } from "../pages/NotFoundPage";
import { ProfilePage } from "../pages/ProfilePage";
import {
ApOperationsPage,
CompanyLoansPage,
ApprovalRulesPage,
AnnouncementsPage,
AttendancePage,
@@ -303,6 +304,9 @@ const routeElements = (
}
/>
<Route path="company-loans" element={<RequirePermission anyOf={["loan.manage"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
<Route path="mobile/company-loans" element={<RequirePermission anyOf={["loan.manage"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
<Route
path="payment-batches"
element={
@@ -0,0 +1 @@
export { CompanyLoansPage as default } from "../../pages/CompanyLoansPage";
+1
查看文件
@@ -1,4 +1,5 @@
import { lazy } from "react";
export const CompanyLoansPage = lazy(() => import("./CompanyLoansPage"));
export const ApOperationsPage = lazy(() => import("./ApOperationsPage"));
export const AttendancePage = lazy(() => import("./AttendancePage"));
@@ -2,6 +2,13 @@ import { expect, test, type Page, type Route } from "@playwright/test";
import { ADMIN, EMPLOYEE, login } from "./helpers/auth";
// These APIs are page-level fixtures; a service worker must not bypass them.
test.use({ serviceWorkers: "block" });
// Use the same scenario clock in the simulated API and browser. Wall-clock
// passage must not turn the pending-acknowledgement case into an overdue one.
const SCENARIO_NOW = new Date("2026-05-28T03:00:00.000Z");
type MockUserKey = "admin" | "employee";
type MockAnnouncement = {
@@ -71,7 +78,7 @@ function buildMe(user: MockUserKey) {
function mapAnnouncementForUser(item: MockAnnouncement, currentUser: MockUserKey) {
const me = buildMe(currentUser);
const acknowledged = item.acknowledgedEmployeeIds.has(me.id);
const overdue = Boolean(item.acknowledgementDueAt && new Date(item.acknowledgementDueAt).getTime() < Date.now());
const overdue = Boolean(item.acknowledgementDueAt && new Date(item.acknowledgementDueAt).getTime() < SCENARIO_NOW.getTime());
return {
...item,
acknowledgedEmployeeIds: undefined,
@@ -275,16 +282,21 @@ async function clearBrowserSession(page: Page) {
});
}
test("制度签收公告:管理员创建、员工签收、管理员看到统计变化", async ({ page }) => {
for (const scenario of [
{ title: "制度签收公告:管理员创建、员工签收、管理员看到统计变化", dueDate: "2026-06-03", status: "待签收", action: "确认签收" },
{ title: "制度签收公告:逾期员工补签后管理员统计同步变化", dueDate: "2026-05-27", status: "已逾期", action: "逾期补签" },
]) {
test(scenario.title, async ({ page }) => {
await page.clock.setFixedTime(SCENARIO_NOW);
await mockPolicyAckApis(page);
const title = `自动化制度签收 ${Date.now()}`;
const title = `自动化制度签收 ${scenario.dueDate}`;
await login(page, ADMIN);
await page.goto("/system/announcements");
await page.getByRole("tab", { name: "制度签收" }).click();
await page.getByPlaceholder("例如:费用报销制度签收").fill(title);
await page.getByPlaceholder("写清楚适用范围、入口和处理要求。").fill("请全员阅读并确认制度签收流程。");
await page.locator('input[type="date"]').fill("2026-06-03");
await page.locator('input[type="date"]').fill(scenario.dueDate);
await page.getByRole("button", { name: "创建制度签收公告" }).click();
await expect(page.getByText(title)).toBeVisible();
await expect(page.getByText("已签 0/2 · 待签 2")).toBeVisible();
@@ -293,9 +305,9 @@ test("制度签收公告:管理员创建、员工签收、管理员看到统
await login(page, EMPLOYEE);
await page.goto("/announcements");
await expect(page.getByText(title)).toBeVisible();
await expect(page.getByText("待签收")).toBeVisible();
await expect(page.getByText(scenario.status, { exact: true })).toBeVisible();
await page.getByLabel("我已阅读并理解该制度内容,确认按公司制度执行。").check();
await page.getByRole("button", { name: "确认签收" }).click();
await page.getByRole("button", { name: scenario.action, exact: true }).click();
await expect(page.getByText("已签收")).toBeVisible();
await expect(page.getByText(/完成本人签收/)).toBeVisible();
@@ -305,3 +317,4 @@ test("制度签收公告:管理员创建、员工签收、管理员看到统
await expect(page.getByText(title)).toBeVisible();
await expect(page.getByText("已签 1/2 · 待签 1")).toBeVisible();
});
}
@@ -26,7 +26,7 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
await login(page, EMPLOYEE);
await page.goto("/announcements");
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
await expect(page.getByRole("heading", { name: "公告", level: 1, exact: true })).toBeVisible();
await expect(page.getByText(announcementTitle)).toBeVisible();
await page.goto("/team");
@@ -217,6 +217,7 @@ test("BIZ8 AP 财务台账展示项目并支持项目关键字搜索", async ({
await loginAsFinance(page);
await page.goto("/ap-operations");
await page.getByRole("tab", { name: /^财务台账/ }).click();
await expect(page.getByTestId("finance-ledger-table")).toContainText("交付项目");
await expect(page.getByTestId("finance-ledger-table")).toContainText("PJ-2026-001");
@@ -1,4 +1,4 @@
import { mkdirSync } from "node:fs";
import { mkdirSync, mkdtempSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
import {
@@ -7,7 +7,9 @@ import {
} from "../../src/lib/notificationLinks";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/mobile-ia-flow-remediation");
const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(process.cwd(), "../output/playwright"));
mkdirSync(visualRoot, { recursive: true });
const SCREENSHOT_DIR = mkdtempSync(path.join(visualRoot, "dg7-mobile-"));
const IOS_WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
@@ -753,9 +755,8 @@ async function mockApis(
async function loginForMobile(page: Page, role: RolePreset) {
await page.goto("/login");
const accountLoginButton = page.getByRole("button", { name: "使用账号密码登录" });
if (await accountLoginButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
await accountLoginButton.click();
}
// This fixture always enables the WeCom-first account entry; wait for config.
await accountLoginButton.click({ timeout: 30_000 });
await expect(page.getByTestId("login-identifier")).toBeVisible();
await page.getByTestId("login-identifier").fill(`${role}@hyinda.com`);
await page.getByTestId("login-password").fill("password");
@@ -890,55 +891,57 @@ test.describe("MIF7 移动信息架构整改", () => {
test.skip(process.env.E2E_MODE === "live", "MIF7 本地专项使用 mock 数据验证信息架构。");
test("首页常用入口不重复底部 Tab,员工进度页按状态分标签", async ({ browser }) => {
for (const width of [320, 360, 375, 390, 393, 414, 430]) {
const notifications = buildNotifications();
const mobile = await newMobilePage(browser, width);
try {
await mockApis(mobile.page, "employee", notifications);
await loginForMobile(mobile.page, "employee");
const mobile = await newMobilePage(browser, 320);
try {
await mockApis(mobile.page, "employee", buildNotifications());
await loginForMobile(mobile.page, "employee");
// Widths exercise the same employee/data; login is independent of width.
for (const width of [320, 360, 375, 390, 393, 414, 430]) {
await mobile.page.setViewportSize({ width, height: width < 360 ? 568 : 844 });
await test.step(`员工首页与进度 ${width}px`, async () => {
await mobile.page.goto("/mobile");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
await expect(mobile.page.getByTestId("mobile-action-approvals-label")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-action-grid")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-action-purchase-label")).toHaveText("采购");
await expect(mobile.page.getByTestId("mobile-action-asset-scan")).toHaveCount(0);
await expectCommonActionsDoNotDuplicateBottomTabs(mobile.page);
await expectActionTileGapStable(mobile.page);
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath(`home-employee-w${width}`), fullPage: true });
await mobile.page.goto("/mobile");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
await expect(mobile.page.getByTestId("mobile-action-approvals-label")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-action-grid")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-action-purchase-label")).toHaveText("采购");
await expect(mobile.page.getByTestId("mobile-action-asset-scan")).toHaveCount(0);
await expectCommonActionsDoNotDuplicateBottomTabs(mobile.page);
await expectActionTileGapStable(mobile.page);
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath(`home-employee-w${width}`), fullPage: true });
await mobile.page.getByTestId("mobile-nav-approvals").click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
await expect(mobile.page.getByTestId("mobile-approvals-progress")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-progress-tab-draft")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-progress-tab-reviewing")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-progress-tab-approved")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-progress-tab-returned")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-approval-progress-card")).toHaveCount(3);
await expectMobileApprovalStatusSlots(mobile.page);
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath(`employee-progress-w${width}`), fullPage: true });
await mobile.page.getByTestId("mobile-nav-approvals").click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
await expect(mobile.page.getByTestId("mobile-approvals-progress")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-progress-tab-draft")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-progress-tab-reviewing")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-progress-tab-approved")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-progress-tab-returned")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-approval-progress-card")).toHaveCount(3);
await expectMobileApprovalStatusSlots(mobile.page);
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath(`employee-progress-w${width}`), fullPage: true });
await mobile.page.goto("/mobile/leave?view=records&status=approved");
await expect(mobile.page.getByTestId("mobile-leave-view-records")).toHaveAttribute("data-state", "active");
await expect(mobile.page.getByTestId("mobile-leave-record-card")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-leave-balances")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-leave-balances")).toContainText("企业微信主办");
await expect(mobile.page.getByTestId("mobile-leave-balance-summary")).toContainText("4.0");
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
await mobile.page.goto("/mobile/leave?view=records&status=approved");
await expect(mobile.page.getByTestId("mobile-leave-view-records")).toHaveAttribute("data-state", "active");
await expect(mobile.page.getByTestId("mobile-leave-record-card")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-leave-balances")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-leave-balances")).toContainText("企业微信主办");
await expect(mobile.page.getByTestId("mobile-leave-balance-summary")).toContainText("4.0");
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
await mobile.page.goto("/mobile/purchase-requests?status=approved");
await expect(mobile.page.getByTestId("mobile-purchase-requests-list")).toBeVisible();
await expect(mobile.page.getByLabel("当前列表搜索")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-purchase-request-card")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-purchase-tab-approved")).toHaveAttribute("data-state", "active");
} finally {
await mobile.context.close();
await mobile.page.goto("/mobile/purchase-requests?status=approved");
await expect(mobile.page.getByTestId("mobile-purchase-requests-list")).toBeVisible();
await expect(mobile.page.getByLabel("当前列表搜索")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-purchase-request-card")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-purchase-tab-approved")).toHaveAttribute("data-state", "active");
});
}
} finally {
await mobile.context.close();
}
});
@@ -1184,3 +1187,53 @@ test.describe("MIF7 移动信息架构整改", () => {
}
});
});
test("编号兼容:PC 与手机报销列表、搜索和详情保留新旧单号", async ({ browser }) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成编号,不访问线上");
const forms = [
{ id: "reimbursement-numbering-old", formNumber: "BX-20260917-0198" },
{ id: "reimbursement-numbering-new", formNumber: "BX-202609-0002" },
];
for (const mobile of [false, true]) {
const context = await browser.newContext({
viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 },
isMobile: mobile, hasTouch: mobile, serviceWorkers: "block",
...(mobile ? { userAgent: IOS_WECOM_UA } : {}),
});
const page = await context.newPage();
try {
await mockApis(page, "employee", buildNotifications());
await page.route("**/api/auth/wecom/config", route => fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false }));
await page.route(url => url.pathname === "/api/reimbursements", route => fulfillJson(route, {
page: 1, pageSize: 20, total: forms.length, scope: "own",
items: forms.map(form => ({ ...reimbursementListItem(), ...form })),
}));
for (const form of forms) {
await page.route(`**/api/reimbursements/${form.id}`, route => fulfillJson(route, { ...reimbursementDetail(), ...form }));
}
await page.goto("/login");
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("password");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/(portal|mobile)$/);
const listPath = mobile ? "/mobile/reimbursements" : "/reimbursements";
for (const form of forms) {
await page.goto(`${listPath}?status=reviewing`);
const rows = page.getByTestId(mobile ? "mobile-reimbursement-card" : "reimbursement-row");
await expect(rows).toHaveCount(2);
for (const source of forms) await expect(rows.filter({ hasText: source.formNumber })).toBeVisible();
if (mobile) {
await page.getByLabel("当前列表搜索").fill(form.formNumber);
await expect(rows).toHaveCount(1);
}
await rows.filter({ hasText: form.formNumber }).getByRole("link").click();
await expect(page).toHaveURL(new RegExp(`${listPath}/${form.id}(?:\\?|$)`));
await expect(page.getByTestId(mobile ? "mobile-reimbursement-detail-header" : "reimbursement-desktop-workspace")).toBeVisible();
const numberLabel = mobile
? page.getByTestId("mobile-reimbursement-detail-header").getByText(form.formNumber, { exact: true })
: page.getByRole("heading", { name: `报销单 ${form.formNumber}`, exact: true });
await expect(numberLabel).toBeVisible();
}
} finally { await context.close(); }
}
});
+5 -2
查看文件
@@ -93,8 +93,11 @@ async function readJson<T>(response: { ok(): boolean; status(): number; text():
return parsed.data;
}
export async function login(page: Page, user: E2EUser) {
await page.goto("/login");
export async function login(page: Page, user: E2EUser, { reuseLoginPage = false } = {}) {
// Opt-in for a sequential UI flow whose preceding logout already reached
// the canonical page without a redirect. Form and portal checks stay unchanged.
const current = new URL(page.url());
if (!reuseLoginPage || current.pathname !== "/login" || current.search || current.hash) await page.goto("/login");
await page.getByTestId(TEST_IDS.login.identifier).fill(user.identifier);
await page.getByTestId(TEST_IDS.login.password).fill(user.password);
await page.getByTestId(TEST_IDS.login.submit).click();
@@ -10,15 +10,15 @@ export class ReimbursementDetailPageObject {
}
get overview() {
return this.page.getByTestId(TEST_IDS.reimbursement.overview);
return this.page.getByTestId("reimbursement-desktop-workspace");
}
get summary() {
return this.page.getByTestId(TEST_IDS.reimbursement.summary);
return this.overview.locator("section.workbench-hero");
}
get itemsTable() {
return this.page.getByTestId(TEST_IDS.reimbursement.items);
return this.page.getByTestId("reimbursement-expense-items");
}
get addItemBtn() {
@@ -34,7 +34,7 @@ export class ReimbursementDetailPageObject {
}
get invoices() {
return this.page.getByTestId(TEST_IDS.reimbursement.invoices);
return this.page.getByTestId("approval-invoices");
}
get invoiceAddBtn() {
@@ -53,10 +53,6 @@ export class ReimbursementDetailPageObject {
return this.page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit);
}
get erpExport() {
return this.page.getByTestId(TEST_IDS.reimbursement.erpExport);
}
get submitBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.submit);
}
@@ -79,7 +75,7 @@ export class ReimbursementDetailPageObject {
}
async uploadInvoicePdf(fileName: string, buffer: Buffer) {
const invoiceCards = this.invoices.locator(":scope > div");
const invoiceCards = this.invoices.getByTestId("approval-invoice-evidence");
const invoiceCountBeforeUpload = await invoiceCards.count();
await this.invoiceAddBtn.click();
await expect(this.invoiceDialog).toBeVisible();
@@ -103,20 +99,50 @@ export class ReimbursementDetailPageObject {
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
}
await expect(invoiceCards).toHaveCount(invoiceCountBeforeUpload + 1, { timeout: 90_000 });
await this.page.keyboard.press("Escape");
// OCR selects the invoice; binding remains an explicit user action.
const invoiceRow = this.invoiceDialog.locator("label").filter({ hasText: fileName });
await expect(invoiceRow.getByRole("checkbox")).toBeChecked({ timeout: 90_000 });
const target = invoiceRow.getByRole("combobox", { name: "添加到哪条明细" });
const existingItems = target.locator('option:not([value=""])');
await expect(existingItems).toHaveCount(1);
await target.selectOption((await existingItems.getAttribute("value"))!);
await this.invoiceDialog.getByRole("button", { name: "添加到报销单", exact: true }).click();
await expect(invoiceCards).toHaveCount(invoiceCountBeforeUpload + 1);
await expect(this.itemsTable.getByTestId("approval-item-evidence")).toHaveCount(1);
await expect(this.invoiceDialog).toHaveCount(0);
return invoiceId;
}
// 当前 seed 保证 precheck 不触发 warn/manual_review;若 seed 变更,此处需相应调整。
async submitReimbursement(acceptConfirm = true) {
const dialogPromise = acceptConfirm
? this.page.waitForEvent("dialog", { timeout: 2_000 }).then((dialog) => dialog.accept()).catch(() => undefined)
: Promise.resolve();
async addItem(amount = "10.00") {
await this.addItemBtn.click();
const expenseType = this.page.getByTestId("reimbursement-item-expense-type");
await expect(expenseType.locator("option")).not.toHaveCount(1);
const value = await expenseType.locator("option").evaluateAll(options =>
(options as HTMLOptionElement[]).find(option => option.textContent === "邮寄快递")?.value);
expect(value, "synthetic fixture requires the seeded courier expense type").toBeTruthy();
await expenseType.selectOption(value!);
await this.page.getByTestId("reimbursement-item-amount").fill(amount);
await this.page.getByTestId("reimbursement-item-description").fill("合成快递费用,低于票面金额部分为非报销支出");
await this.page.getByTestId("reimbursement-item-save").click();
await expect(this.page.getByTestId("reimbursement-item-editor")).toHaveCount(0);
await expect(this.itemsTable.getByTestId("approval-item-evidence")).toHaveCount(1);
}
async submitReimbursement() {
const precheck = this.page.waitForResponse(response =>
new URL(response.url()).pathname.endsWith("/precheck") && response.request().method() === "POST");
const submitted = this.page.waitForResponse(response =>
new URL(response.url()).pathname.endsWith("/submit") && response.request().method() === "POST");
await this.submitBtn.click();
await dialogPromise;
const check = await precheck;
expect(check.ok()).toBe(true);
const data = (await check.json()).data;
if (data?.decision === "warn" || data?.decision === "manual_review" || data?.standardChecks?.some((item: {exceeded: boolean; controlMode: string}) => item.exceeded && item.controlMode === "warn")) {
await this.page.getByRole("dialog", { name: "确认提交报销单?" }).getByRole("button", { name: "确认提交", exact: true }).click();
}
const response = await submitted;
expect(response.ok(), `synthetic submit HTTP ${response.status()}`).toBe(true);
return (await response.json()).data as {currentTaskId: string};
}
}
@@ -0,0 +1,254 @@
import { createRequire } from "node:module";
import { expect, type APIRequestContext, type Page } from "@playwright/test";
import { ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER, E2E_BASE_URL, apiGetData, apiPostData, createAuthenticatedApiClient, login, logout } from "./auth";
import { ReimbursementDetailPageObject } from "./pageObjects/ReimbursementDetailPage";
const HR = { identifier: "[email protected]", password: "HrPassword1234", name: "HR" };
// Public fictitious accounts; exclusively written to the run-owned local database.
const payer = "000011112222";
const payee = "000033334444";
const requireServer = createRequire(new URL("../../../../server/package.json", import.meta.url));
async function syntheticPdf(label: string) {
const pdf = await requireServer("pdf-lib").PDFDocument.create();
pdf.addPage().drawText(label);
return Buffer.from(await pdf.save());
}
async function prepareBankFacts(api: APIRequestContext, employeeId: string) {
expect(new URL(E2E_BASE_URL).hostname).toBe("127.0.0.1");
expect(process.env.QA_ENVIRONMENT_CONTEXT, "financial fixture needs the managed disposable environment").toBeTruthy();
const banks = await apiGetData<{items: Array<{id: string; code: string}>}>(api, "/api/bank-institutions");
const bank = banks.items.find(item => item.code === "CCB");
expect(bank).toBeTruthy();
const entities = await apiGetData<{items: Array<{id: string}>}>(api, "/api/master-data/legal-entities?enabled=true");
expect(entities.items).toHaveLength(1);
const legalEntityId = entities.items[0].id;
const created = await api.post("/api/payment-accounts", { data: { legalEntityId, bankInstitutionId: bank!.id, accountName: "合成企业付款账户", accountNumber: payer } });
expect([201, 409]).toContain(created.status());
const accounts = await apiGetData<{items: Array<{legalEntity: {id: string}; enabled: boolean; accountName: string; accountNumberMasked: string}>}>(api, "/api/payment-accounts");
const active = accounts.items.filter(item => item.enabled && item.legalEntity.id === legalEntityId);
expect(active).toHaveLength(1);
expect(active[0].accountName).toBe("合成企业付款账户");
expect(active[0].accountNumberMasked).toContain("2222");
const hr = await createAuthenticatedApiClient(HR);
try {
const response = await hr.api.patch(`/api/users/${employeeId}/sensitive`, {data: {bankInstitutionId: bank!.id, bankAccount: payee}});
expect(response.ok(), `synthetic bank setup HTTP ${response.status()}`).toBe(true);
} finally { await hr.dispose(); }
}
async function manualReceipt(api: APIRequestContext, reviewer: APIRequestContext, batchId: string, itemId: string, attachmentId: string) {
const base = `/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/manual-verification`;
type Detail = { item: {version: number; state: string; pagesExpected: number} };
await expect.poll(async () => (await apiGetData<Detail>(api, base)).item.state, {timeout: 60_000}).toBe("ocr_failed");
const { item: detail } = await apiGetData<Detail>(api, base);
expect(detail.pagesExpected).toBe(1);
const { item: proposal } = await apiPostData<{item: {id: string; version: number; status: string}}>(api, base, {
expectedVersion: detail.version, reason: "合成回单逐项核对,不代表真实银行付款", pages: [{pageNumber: 1, itemId,
fields: Object.entries({小写金额: "10.00", 币别: "CNY", 付款人账号: payer, 收款人账号: payee, 凭证号: `SYNTHETIC-${batchId}`, 单据状态: "交易成功"}).map(([Name, Value]) => ({Name, Value}))}],
});
expect(proposal.status).toBe("pending_review");
const reviewPath = `${base}/${proposal.id}/review`;
const body = {expectedVersion: proposal.version, decision: "approve", reason: "合成独立复核"};
expect((await api.post(reviewPath, {data: body})).status()).toBe(403);
await apiPostData(reviewer, reviewPath, body);
}
export async function reimbursementPaymentFlow(page: Page) {
const started = Date.now();
const checkpoint = (stage: string) => console.info("[reimbursement-payment-flow]", JSON.stringify({ stage, elapsedMs: Date.now() - started }));
checkpoint("api-auth:start");
const detail = new ReimbursementDetailPageObject(page);
const clients = await Promise.all([ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER].map(user => createAuthenticatedApiClient(user)));
const [admin, employee, finance, financeMgr, manager] = clients;
checkpoint("api-auth:complete");
try {
checkpoint("bank-fixture:start");
await prepareBankFacts(financeMgr.api, employee.me.id);
checkpoint("employee-ui:start");
await login(page, EMPLOYEE, { reuseLoginPage: true });
await page.goto("/reimbursements");
const creating = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/reimbursements");
await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", {name: "空白报销"}).click();
const created = await creating;
expect(created.ok()).toBe(true);
const reimbursementId = (await created.json()).data.id as string;
await detail.assertLoaded();
await detail.addItem();
checkpoint("invoice-upload:start");
const invoicePdf = await syntheticPdf(`Synthetic invoice ${reimbursementId}`);
const invoiceId = await detail.uploadInvoicePdf(`synthetic-${reimbursementId}.pdf`, invoicePdf);
await expect(page.locator(`#reimbursement-invoice-${invoiceId}`)).toBeVisible();
{
checkpoint("invoice-view:start");
const signedResponse = page.waitForResponse(response =>
response.request().method() === "POST" && new URL(response.url()).pathname === `/api/invoice-pool/${invoiceId}/signed-url`);
const popupReady = page.waitForEvent("popup");
await page.locator(`#reimbursement-invoice-${invoiceId}`).getByRole("button", {name: /^查看发票/}).click();
const popup = await popupReady;
checkpoint("invoice-view:popup");
// Headless PDF navigation can remain about:blank. Verify the UI-created
// signed URL serves the exact valid PDF; native viewer rendering is separate.
const signed = await signedResponse;
expect(signed.ok()).toBe(true);
const url = (await signed.json()).data.signedUrl as string;
expect(new URL(url).origin).toBe(new URL(E2E_BASE_URL).origin);
expect(new URL(url).pathname).toBe(`/api/invoice-pool/${invoiceId}/file`);
const file = await page.request.get(url);
expect(file.ok()).toBe(true);
expect(file.headers()["content-type"]).toContain("application/pdf");
expect(file.headers()["content-disposition"]).toContain("inline");
expect(await file.body()).toEqual(invoicePdf);
await popup.close();
}
checkpoint("reimbursement-submit:start");
const submitted = await detail.submitReimbursement();
const reimbursement = await apiGetData<{formNumber: string; status: string}>(employee.api, `/api/reimbursements/${reimbursementId}`);
expect(reimbursement.status).toBe("reviewing");
await expect(detail.workflowProgress).toContainText(MANAGER.name);
await expect(detail.workflowProgress).toContainText("财务复核");
await logout(page, EMPLOYEE.name);
let taskId: string | null = submitted.currentTaskId;
for (const [user, client, role] of [[MANAGER, manager, "manager"], [FINANCE, finance, "finance"]] as const) {
checkpoint(`${role}-approval:start`);
expect(taskId).toBeTruthy();
const task = await apiGetData<{task: {stepId: string; assigneeId: string | null; assigneeRole: string | null}}>(client.api, `/api/workflow/tasks/${taskId}`);
expect(task.task.stepId).toBe(role === "manager" ? "approval_1_manager" : "approval_2_finance");
if (role === "manager") expect(task.task.assigneeId).toBe(manager.me.id);
else expect(task.task.assigneeRole).toBe("finance");
await login(page, user, { reuseLoginPage: true });
await page.goto(`/workflow/tasks/${taskId}`);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
const approving = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/workflow/tasks/${taskId}/approve`);
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toBeVisible();
await page.getByTestId("workflow-action-confirm-submit").click();
const approved = await approving;
expect(approved.ok()).toBe(true);
const result = (await approved.json()).data;
expect(result.reimbursementStatus).toBe(role === "manager" ? "reviewing" : "approved");
taskId = result.nextTaskId;
if (role === "finance") {
expect(taskId).toBeNull();
// No reload: covers the reported finance progress refresh regression.
const financeStage = page.getByTestId("reimbursement-approval-processing-progress").locator("li").filter({hasText: "财务复核"});
await Promise.all([
expect(financeStage).toContainText("通过"),
expect(financeStage).toContainText(FINANCE.name),
expect(financeStage).not.toContainText("处理中"),
expect(financeStage).toHaveAttribute("data-state", "completed"),
]);
}
// The same finance actor next creates the batch. Keep that real session
// until the next role change instead of immediately logging it in again.
if (role !== "finance") await logout(page, user.name);
checkpoint(`${role}-approval:complete`);
}
await expect.poll(async () => {
const response = await employee.api.get(`/api/reimbursements/${reimbursementId}`);
const payload = await response.json();
if (!response.ok()) {
const code = /^[A-Z][A-Z0-9_]{0,40}$/.test(payload.error?.code ?? "") ? payload.error.code : "UNKNOWN";
const audit = await admin.api.get("/api/audit-logs?pageSize=100");
const history = audit.ok() ? (await audit.json()).data?.items ?? [] : [];
const allowed = new Set(["REFRESH_REUSE_DETECTED", "AUTH_PASSWORD_CHANGED", "AUTH_PASSWORD_RESET", "SYSTEM_USER_ROLES_SET", "SYSTEM_USER_DISABLED"]);
console.info("[reimbursement-payment-flow]", JSON.stringify({
stage: "employee-after-approval:error", status: response.status(), code,
auditStatus: audit.status(),
authEvents: history.filter((item: { targetId: string; action: string; createdAt: string }) =>
item.targetId === employee.me.id && allowed.has(item.action) && Date.parse(item.createdAt) >= started)
.map((item: { action: string; createdAt: string }) => ({ action: item.action, elapsedMs: Date.parse(item.createdAt) - started })),
}));
throw new Error(`Employee approval read HTTP ${response.status()} ${code}`);
}
return payload.data.status;
}).toBe("approved");
checkpoint("payment-batch-create:start");
await page.goto("/payment-batches");
const createPanel = page.getByTestId("payment-batch-create-panel");
await createPanel.getByTestId("payment-batch-candidate-search").fill(reimbursement.formNumber);
const candidate = createPanel.getByTestId("payment-batch-candidate-row").filter({hasText: reimbursement.formNumber});
await expect(candidate).toBeVisible();
await candidate.getByRole("checkbox").check();
await createPanel.getByTestId("payment-batch-default-channel").selectOption("bank");
const creatingBatch = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/payment-batches");
await createPanel.getByRole("button", {name: "创建并提交复核"}).click();
const createdBatch = await creatingBatch;
expect(createdBatch.ok()).toBe(true);
expect(createdBatch.request().postDataJSON()).toMatchObject({entityType: "reimbursement", entityIds: [reimbursementId], defaultPaymentChannel: "bank"});
const batch = (await createdBatch.json()).data.item as {id: string; name: string; items: Array<{id: string}>};
await page.goto(`/payment-batches?id=${batch.id}`);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("0/1");
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", {name: "复核通过"})).toHaveCount(0);
expect((await finance.api.post(`/api/payment-batches/${batch.id}/review`, {data: {}})).status()).toBe(403);
await logout(page, FINANCE.name);
checkpoint("payment-batch-review:start");
await login(page, MANAGER, { reuseLoginPage: true });
await page.goto(`/payment-batches?id=${batch.id}`);
await page.getByTestId("payment-batch-action-panel").getByRole("button", {name: "复核通过"}).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核");
await page.getByTestId("action-confirm-submit").click();
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("1/1");
await logout(page, MANAGER.name);
await login(page, FINANCE, { reuseLoginPage: true });
await page.goto(`/payment-batches?id=${batch.id}`);
expect((await finance.api.post(`/api/payment-batches/${batch.id}/execute`, {data: {}})).status()).toBe(409);
checkpoint("receipt-upload:start");
const receiptPdf = await syntheticPdf(`Synthetic receipt ${batch.id}`);
const uploading = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/payment-batches/${batch.id}/receipt-attachments`);
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({name: "synthetic-receipt.pdf", mimeType: "application/pdf", buffer: receiptPdf});
const upload = await uploading;
expect(upload.ok()).toBe(true);
const attachmentId = (await upload.json()).data.items[0].id as string;
const batchDetail = await apiGetData<{item: {items: Array<{id: string}>}}>(finance.api, `/api/payment-batches/${batch.id}`);
checkpoint("receipt-manual-review:start");
await manualReceipt(finance.api, manager.api, batch.id, batchDetail.item.items[0].id, attachmentId);
checkpoint("payment-execution:start");
await page.reload();
const executing = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/payment-batches/${batch.id}/execute`);
await page.getByTestId("payment-batch-action-panel").getByRole("button", {name: "登记付款完成"}).click();
await page.getByTestId("action-confirm-submit").click();
expect((await executing).ok()).toBe(true);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("已付款");
const receipts = page.getByTestId("payment-batch-receipt-panel");
await Promise.all([
expect(page.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0),
expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0),
expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible(),
expect(receipts.getByRole("button", {name: "上传银行回单", exact: true})).toHaveCount(0),
expect(receipts.getByRole("button", {name: "作废", exact: true})).toHaveCount(0),
expect(receipts.getByRole("button", {name: "下载", exact: true})).toBeVisible(),
]);
checkpoint("downstream-audit:start");
await Promise.all([["REIMBURSEMENT_PAYMENT_MARKED", reimbursementId], ["PAYMENT_BATCH_RECEIPT_UPLOADED", batch.id], ["PAYMENT_RECEIPT_MANUAL_REVIEWED", batch.id]].map(async ([action, targetId]) => {
const audit = await apiGetData<{items: Array<{targetId: string; extra: {idempotent?: boolean; attachmentIds?: string[]}}>}>(admin.api, `/api/audit-logs?action=${action}&pageSize=50`);
const event = audit.items.find(item => item.targetId === targetId);
expect(event, `${action} audit required`).toBeTruthy();
if (action === "REIMBURSEMENT_PAYMENT_MARKED") expect(event!.extra.idempotent).toBe(false);
if (action === "PAYMENT_BATCH_RECEIPT_UPLOADED") expect(event!.extra.attachmentIds).toContain(attachmentId);
}));
await page.goto("/ap-operations");
await page.getByRole("tab", {name: /财务台账/}).click();
await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursement.formNumber);
await expect(page.getByTestId("finance-ledger-table")).toContainText("已付款");
await page.goto(`/reimbursements/${reimbursementId}`);
await detail.assertLoaded();
expect((await apiGetData<{status: string}>(employee.api, `/api/reimbursements/${reimbursementId}`)).status).toBe("paid");
await expect(detail.summary).toContainText("已支付");
await expect(detail.workflowProgress).toContainText("已付款");
await expect(page.getByTestId("reimbursement-pay")).toHaveCount(0);
const invoice = await apiGetData<{status: string; archivedAt: string; linkedReimbursementId: string}>(employee.api, `/api/invoice-pool/${invoiceId}`);
expect(invoice).toMatchObject({status: "archived", linkedReimbursementId: reimbursementId});
expect(invoice.archivedAt).toBeTruthy();
expect((await financeMgr.api.get(`/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`)).status()).toBe(404);
await logout(page, FINANCE.name);
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
} finally {
checkpoint("api-dispose:start");
await Promise.all(clients.map(client => client.dispose()));
checkpoint("api-dispose:complete");
}
}
@@ -0,0 +1,28 @@
export type VisualPageIdentity = { route: string; role: string; viewport: string };
export type VisualPageCompletion = VisualPageIdentity & { actualRoute: string };
export type VisualGroupResult = { id: string; status: string };
const pageKey = (page: VisualPageIdentity) => JSON.stringify([page.viewport, page.role, page.route]);
// Only fully audited pages belong in completed; arrival alone is insufficient.
export function visualCoverageProblems(
planned: VisualPageIdentity[],
completed: VisualPageCompletion[],
expectedGroups: string[],
groups: VisualGroupResult[],
) {
const problems: string[] = [];
const plannedKeys = planned.map(pageKey);
const completedKeys = completed.map(pageKey);
if (!plannedKeys.length) problems.push("empty-plan");
if (new Set(plannedKeys).size !== plannedKeys.length) problems.push("duplicate-plan");
if (new Set(completedKeys).size !== completedKeys.length) problems.push("duplicate-completion");
if (plannedKeys.some(key => !completedKeys.includes(key))) problems.push("missing-page");
if (completedKeys.some(key => !plannedKeys.includes(key))) problems.push("unexpected-page");
if (completed.some(page => page.actualRoute !== page.route)) problems.push("wrong-arrival");
if (!expectedGroups.length || new Set(expectedGroups).size !== expectedGroups.length) problems.push("invalid-group-plan");
if (new Set(groups.map(group => group.id)).size !== groups.length) problems.push("duplicate-group");
if (expectedGroups.some(id => !groups.some(group => group.id === id && group.status === "passed"))) problems.push("incomplete-group");
if (groups.some(group => !expectedGroups.includes(group.id))) problems.push("unexpected-group");
return problems;
}
@@ -43,7 +43,19 @@ test("R12 普通用户错误消息只经过共享解析入口且不显示内部
expect(residuals).toEqual([]);
expect(directMapperConsumers).toEqual([]);
expect(rawThrownMessageConsumers).toEqual([]);
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "VALID_001: 请补充审批意见" }))).toBe("请补充审批意见");
const unknownValidation = "操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。";
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "VALID_001: 请补充审批意见" }))).toBe(unknownValidation);
expect(resolveErrorMessage(new ApiError({
status: 400, code: "VALID_001", message: "合成内部解析内容", requestId: "synthetic-request-12",
details: { reasonCode: "synthetic.unknown_reason", file: "合成单元格" },
}))).toBe(`${unknownValidation} 排障编号:synthetic-request-12`);
expect(resolveErrorMessage(new ApiError({
status: 400, code: "VALID_001", message: "合成内部解析内容", requestId: "/synthetic/private/path",
}))).toBe(unknownValidation);
expect(resolveErrorMessage(new ApiError({
status: 400, code: "VALID_001", message: "合成内部解析内容",
details: { reasonCode: "user.import_file_invalid", file: "invalid_format" },
}))).toBe("导入文件格式损坏或无法解析,请重新导出为CSV或XLSX后再上传。");
expect(resolveErrorMessage(new Error("500: 保存失败"), "保存失败,请稍后重试")).toBe("保存失败");
expect(resolveErrorMessage(new Error("Failed to fetch /api/workflow/tasks"), "加载审批任务失败")).toBe("加载审批任务失败");
expect(resolveErrorMessage(new Error("读取失败 /tmp/private/payload.json"), "读取资料失败")).toBe("读取资料失败");
+26 -21
查看文件
@@ -1,6 +1,5 @@
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, login, logout } from "./helpers/auth";
import { TEST_IDS } from "./helpers/testIds";
import { EMPLOYEE, FINANCE, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
async function createBlankReimbursement(page: import("@playwright/test").Page) {
@@ -29,23 +28,14 @@ test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({
const reimbursementId = await createBlankReimbursement(page);
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}$`));
await reimbursementDetail.assertLoaded();
await reimbursementDetail.addItem();
await page.getByTestId(TEST_IDS.reimbursement.invoiceAdd).click();
await expect(page.getByRole("dialog", { name: "添加发票" })).toBeVisible();
// Historical title retained for result mapping; current UX explicitly confirms binding.
const marker = `MOCK_R73_SUCCESS_${Date.now()}`;
const invoiceFileName = `r73-success-${Date.now()}.pdf`;
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadFile).setInputFiles({
name: invoiceFileName,
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
});
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).scrollIntoViewIfNeeded();
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).click();
await expect(page.getByTestId(TEST_IDS.reimbursement.invoices)).toContainText(invoiceFileName, { timeout: 90_000 });
await page.keyboard.press("Escape");
await expect(page.getByTestId(TEST_IDS.reimbursement.invoiceDialog)).toHaveCount(0);
await reimbursementDetail.uploadInvoicePdf(
`r73-success-${Date.now()}.pdf`,
Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
);
await reimbursementDetail.submitReimbursement();
await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 });
@@ -54,20 +44,26 @@ test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({
});
test("E2E 轻交互 OCR:票据缺关键字段后可在当前页修正并标记可用", async ({ page }) => {
test.skip(process.env.PLAYWRIGHT_OCR_MOCK !== "1", "需要 mock OCR 环境和 OCR 作业开启");
test.skip(process.env.E2E_MODE === "live" || process.env.PLAYWRIGHT_OCR_MOCK !== "1", "需要本地受管 mock OCR 环境和 OCR 作业开启");
await login(page, EMPLOYEE);
await login(page, FINANCE);
await (await openNavLink(page, "票据")).click();
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
const filename = `r73-missing-${Date.now()}.pdf`;
await page.getByRole("button", { name: "上传票据" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles({
name: `r73-missing-${Date.now()}.pdf`,
name: filename,
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nMOCK_OCR_MISSING:invoiceCode,sellerName\n%%EOF\n", "utf8"),
});
const uploaded = page.waitForResponse(response => new URL(response.url()).pathname === "/api/invoice-pool/upload" && response.request().method() === "POST");
await page.getByTestId("invoice-upload-submit").click();
const upload = await uploaded;
expect(upload.ok()).toBe(true);
const invoiceId = (await upload.json()).data.invoicePoolId as string;
await expect(page).toHaveURL(url => url.pathname === "/invoice-pool" && url.searchParams.get("id") === invoiceId);
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:识别失败", { timeout: 90_000 });
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("票据识别缺少关键字段:发票代码、销售方。");
@@ -84,5 +80,14 @@ test("E2E 轻交互 OCR:票据缺关键字段后可在当前页修正并标记
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 30_000 });
await expect(page.getByTestId("invoice-detail-panel")).toContainText("北京悦海堂餐饮管理有限公司");
await logout(page, EMPLOYEE.name);
await expect(page).toHaveURL(url => url.searchParams.get("id") === invoiceId);
await page.getByRole("button", { name: "返回列表", exact: true }).click();
await expect(page).toHaveURL(url => url.pathname === "/invoice-pool" && !url.searchParams.has("id"));
const row = page.getByRole("row").filter({ hasText: filename });
await row.getByRole("button", { name: "more_horiz", exact: true }).click();
await page.getByTestId("invoice-detail-button").click();
await expect(page).toHaveURL(url => url.searchParams.get("id") === invoiceId);
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用");
await expect(page.getByTestId("invoice-detail-panel")).toContainText("北京悦海堂餐饮管理有限公司");
await logout(page, FINANCE.name);
});
+2 -2
查看文件
@@ -122,7 +122,7 @@ async function mockLeaveBoundaryApis(page: Page) {
activeUser = payload.identifier === ADMIN.identifier ? ADMIN : EMPLOYEE;
await fulfillJson(
route,
{ accessToken: `mock-token-${activeUser.name}`, expiresIn: 900, mustChangePassword: false },
{ accessToken: `mock-token-${activeUser.employeeNo}`, expiresIn: 900, mustChangePassword: false },
200,
{ "set-cookie": "has_session=1; Path=/; SameSite=Strict" },
);
@@ -130,7 +130,7 @@ async function mockLeaveBoundaryApis(page: Page) {
}
if (path === "/api/auth/refresh") {
await fulfillJson(route, { accessToken: `mock-token-${activeUser.name}`, expiresIn: 900 });
await fulfillJson(route, { accessToken: `mock-token-${activeUser.employeeNo}`, expiresIn: 900 });
return;
}
@@ -369,6 +369,10 @@ async function installSyntheticApis(page: Page, roleFixture: RoleFixture) {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/payment-batches/candidates") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/payment-batches") {
await fulfillJson(route, { items: [] });
return;
@@ -447,6 +451,7 @@ async function expectNamedControlVisible(page: Page, name: string) {
page.getByRole("button", { name, exact: true }),
page.getByRole("link", { name, exact: true }),
page.getByRole("tab", { name, exact: true }),
page.getByRole("textbox", { name, exact: true }),
page.getByRole("menuitem", { name, exact: true }),
page.getByText(name, { exact: true }),
];
-180
查看文件
@@ -1,180 +0,0 @@
import { test, expect } from "./helpers/fixtures";
import { ADMIN, EMPLOYEE, FINANCE, MANAGER, apiGetData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { ensureFeatureFlagOn } from "./helpers/featureFlags";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
// 主链路包含 OCR 等待和三角色切换,full-local 下会超过默认 120s。
test.setTimeout(300_000);
const adminApi = await createAuthenticatedApiClient(ADMIN);
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright main flow");
await adminApi.dispose();
// 1) employee: create reimbursement
await login(page, EMPLOYEE);
await (await openNavLink(page, "我的报销")).click();
await expect(page).toHaveURL(/\/reimbursements/);
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
});
await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", { name: "空白报销" }).click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const reimbursementId = createPayload.data?.id;
if (!reimbursementId) {
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
}
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
// 2) employee: upload invoice and wait OCR
await (await openNavLink(page, "票据")).click();
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const invoiceFileName = `e2e-${Date.now()}.pdf`;
const uploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
});
await page.getByRole("button", { name: "上传票据" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles({
name: invoiceFileName,
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
});
await page.getByTestId("invoice-upload-submit").click();
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
const invoiceId = uploadPayload.data?.invoicePoolId;
if (!invoiceId) {
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
}
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 });
// 预览(popup 存在即可)
const popupPromise = page.waitForEvent("popup");
await page.getByTestId("invoice-preview-button").click();
const popup = await popupPromise;
await expect(popup).toHaveURL(new RegExp(`/api/invoice-pool/${invoiceId}`));
await popup.close();
// 3) bind invoice to reimbursement
await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await page.getByRole("button", { name: "绑定" }).click();
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
await page.getByTestId("invoice-bind-submit").click();
// 4) submit reimbursement
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await reimbursementDetail.submitReimbursement();
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible();
// 5) manager approve
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(managerTaskRow).toBeVisible();
await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
await logout(page, MANAGER.name);
// 6) finance approve, then pay from the payment batch context
await login(page, FINANCE);
await (await openNavLink(page, "待我审批")).click();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(financeTaskRow).toBeVisible();
await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
const reimbursement = await apiGetData<{ formNumber: string | null }>(page.request, `/api/reimbursements/${reimbursementId}`);
const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId;
await page.goto("/payment-batches");
const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard).toBeVisible();
const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
await createCard.getByRole("button", { name: "创建并提交复核" }).click();
const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string; name?: string } } };
const batchId = createBatchPayload.data?.item?.id;
const batchName = createBatchPayload.data?.item?.name;
if (!batchId || !batchName) {
throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
}
await logout(page, FINANCE.name);
await login(page, MANAGER);
await page.goto("/ap-operations");
await page.getByRole("tab", { name: /待付款/ }).click();
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
await expect(pendingRow).toBeVisible();
await pendingRow.getByRole("button", { name: "快捷复核" }).click();
await expect(page.getByRole("dialog")).toContainText(batchName);
await page.getByPlaceholder("请输入复核意见").fill("E2E main flow payment review");
await page.getByRole("button", { name: "提交复核" }).click();
await expect(pendingRow).toContainText("复核 1/1");
await logout(page, MANAGER.name);
await login(page, FINANCE);
await page.goto(`/payment-batches?id=${batchId}`);
const executedBatchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(executedBatchDetailCard).toBeVisible();
await expect(executedBatchDetailCard).toContainText("1/1");
const receiptCard = page.getByTestId("payment-batch-receipt-panel");
await expect(receiptCard).toBeVisible();
const receiptUploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` &&
response.ok();
});
await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({
name: "e2e-bank-receipt.pdf",
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
});
await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("e2e-bank-receipt.pdf");
await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
await page.getByTestId("action-confirm-submit").click();
await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款");
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
await logout(page, FINANCE.name);
// 7) guard: unauth -> login
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
@@ -1,49 +1,5 @@
import { devices, expect, test } from "@playwright/test";
import { apiGetData, apiPostData, createAuthenticatedApiClient, EMPLOYEE, MANAGER, type E2EUser } from "./helpers/auth";
import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "./helpers/workflow";
type LeaveTypeListResponse = {
items: Array<{ id: string; requiresAttachment: boolean }>;
};
type WorkflowSubmitResponse = {
workflowInstanceId: string;
};
function formatDate(offsetDays: number) {
const value = new Date();
value.setUTCDate(value.getUTCDate() + offsetDays);
return value.toISOString().slice(0, 10);
}
async function createSubmittedLeaveViaApi(input: { reasonPrefix: string }) {
const client = await createAuthenticatedApiClient(EMPLOYEE);
const leaveTypes = await apiGetData<LeaveTypeListResponse>(client.api, "/api/leave-types?enabled=true");
const leaveType = leaveTypes.items.find((item) => !item.requiresAttachment) ?? leaveTypes.items[0];
if (!leaveType) {
throw new Error("缺少可用于移动审批离线测试的请假类型");
}
const offset = 3600 + (Date.now() % 1200);
const startDate = formatDate(offset);
const created = await apiPostData<{ id: string }>(client.api, "/api/leave-requests", {
leaveTypeId: leaveType.id,
startDate,
endDate: startDate,
startSession: "am",
endSession: "pm",
reason: `${input.reasonPrefix}-${Date.now()}`,
});
await apiPostData<WorkflowSubmitResponse>(client.api, `/api/leave-requests/${created.id}/submit`, {});
return {
client,
leaveRequest: {
requestId: created.id,
startDate,
},
};
}
import { MANAGER, type E2EUser } from "./helpers/auth";
async function loginMobile(page: import("@playwright/test").Page, user: E2EUser) {
await page.goto("/login");
@@ -87,100 +43,15 @@ async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, t
.toBe(true);
}
async function expectMobileApprovalDetailUrl(page: import("@playwright/test").Page, taskId: string) {
const encodedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await expect(page).toHaveURL(new RegExp(`/mobile/approvals(?:/${encodedTaskId}|\\?taskId=${encodedTaskId})`));
}
async function returnToMobileApprovalList(page: import("@playwright/test").Page) {
await page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
await expect(page).toHaveURL(/\/mobile\/approvals(?:\?|$)/);
}
// Daily leave is owned by WeCom; its read-only boundary is covered by leave-flow.spec.ts.
test.use({
...devices["iPhone 13"],
serviceWorkers: "block",
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1",
});
test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }) => {
const created = await createSubmittedLeaveViaApi({
reasonPrefix: "Playwright移动审批通过",
});
const managerApi = await createAuthenticatedApiClient(MANAGER);
try {
const task = await waitForWorkflowTask(managerApi.api, {
entityId: created.leaveRequest.requestId,
entityType: "leave",
});
await loginMobile(page, MANAGER);
await page.goto(`/workflow/tasks/${task.id}`);
await expectMobileApprovalDetailUrl(page, task.id);
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("mobile-approvals-detail")).toContainText("请假");
await waitForWorkflowTaskCache(page, task.id);
await page.context().setOffline(true);
await expect(page.getByText("当前离线")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-approve")).toBeEnabled({ timeout: 15_000 });
await page.getByTestId("mobile-approvals-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
await page.getByTestId("workflow-action-confirm-submit").click();
await expect(page.getByRole("main").getByText("当前离线,通过草稿已加入待同步队列。")).toBeVisible();
await page.context().setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await expect(page.getByRole("main").getByText(/草稿同步完成:成功 1,失败 0,冲突 0/)).toBeVisible({ timeout: 30_000 });
} finally {
await Promise.all([created.client.dispose(), managerApi.dispose()]);
}
});
test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
const created = await createSubmittedLeaveViaApi({
reasonPrefix: "Playwright移动审批冲突",
});
const managerApi = await createAuthenticatedApiClient(MANAGER);
try {
const task = await waitForWorkflowTask(managerApi.api, {
entityId: created.leaveRequest.requestId,
entityType: "leave",
});
await loginMobile(page, MANAGER);
await page.goto(`/workflow/tasks/${task.id}`);
await expectMobileApprovalDetailUrl(page, task.id);
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("mobile-approvals-detail")).toContainText("请假");
await waitForWorkflowTaskCache(page, task.id);
await page.context().setOffline(true);
await page.getByTestId("mobile-approvals-more").click();
await expect(page.getByTestId("mobile-approvals-reject")).toBeEnabled({ timeout: 15_000 });
await page.getByTestId("mobile-approvals-comment").fill("离线驳回意见:请补充差旅安排说明");
await page.getByTestId("mobile-approvals-reject").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
await page.getByTestId("workflow-action-confirm-submit").click();
await expect(page.getByRole("main").getByText("当前离线,驳回草稿已加入待同步队列。")).toBeVisible();
await approveWorkflowTaskViaApi(managerApi.api, task.id);
await page.context().setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await expect(page.getByRole("main").getByText(/草稿同步完成:成功 0,失败 0,冲突 1/)).toBeVisible({ timeout: 30_000 });
await returnToMobileApprovalList(page);
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("存在冲突", { timeout: 30_000 });
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("最近错误", { timeout: 30_000 });
await expect(page.getByRole("button", { name: "查看任务" })).toBeVisible();
await expect(page.getByRole("button", { name: "丢弃草稿" })).toBeVisible();
} finally {
await Promise.all([created.client.dispose(), managerApi.dispose()]);
}
});
test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail closed", async ({ page }) => {
const taskId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
@@ -271,6 +142,7 @@ test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail cl
}],
};
let signedIn = false;
const fulfillData = (route: import("@playwright/test").Route, data: unknown, headers?: Record<string, string>) => route.fulfill({
status: 200,
contentType: "application/json",
@@ -289,6 +161,10 @@ test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail cl
loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [],
});
if (url.pathname === "/api/auth/login" || url.pathname === "/api/auth/refresh") {
if (url.pathname === "/api/auth/refresh" && !signedIn) {
return route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_001", message: "未登录" } }) });
}
if (url.pathname === "/api/auth/login") signedIn = true;
return fulfillData(route, { accessToken: "cache-boundary-token", expiresIn: 900, mustChangePassword: false }, { "set-cookie": "has_session=1; Path=/; SameSite=Lax" });
}
if (url.pathname === "/api/me") return fulfillData(route, {
@@ -325,6 +325,11 @@ async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: Mo
await fulfillJson(route, { days: 30, total: 0, items: [] });
return;
}
if (pathName === `/api/contracts-registry/${CONTRACT_ITEM.id}` && request.method() === "GET") {
await fulfillJson(route, { item: { ...CONTRACT_ITEM, terminatedAt: null, terminatedReason: null,
terminatedBy: null, terminator: null, documentRefs: [], history: [] } });
return;
}
if (pathName === "/api/contracts-registry") {
await fulfillJson(route, { items: scenario.contracts ?? [] });
return;
@@ -400,6 +405,22 @@ test("只有付款复核职责的经理看不到新建批次", async ({ page })
expectNoPageErrors(pageErrors);
});
async function expectNoContractWriteActions(page: Page) {
for (const name of ["新增合同", "编辑", "续约", "归档"]) {
const controls = page.getByRole("button", { name, exact: true })
.or(page.getByRole("link", { name, exact: true }))
.or(page.getByRole("menuitem", { name, exact: true }));
expect(await controls.count(), `readonly contract leaked action: ${name}`).toBe(0);
}
}
test("合同只读动作断言能发现新增编辑续约归档泄露", async ({ page }) => {
for (const name of ["新增合同", "编辑", "续约", "归档"]) {
await page.setContent(`<main><button>${name}</button></main>`);
await expect(expectNoContractWriteActions(page)).rejects.toThrow(/readonly contract leaked action/);
}
});
test("合同只读人员看不到新增编辑续约和归档动作", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
@@ -411,20 +432,68 @@ test("合同只读人员看不到新增编辑续约和归档动作", async ({ pa
await expect(page.getByRole("heading", { level: 1, name: "合同", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "新增合同", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
await expectNoContractWriteActions(page);
const contractRow = page
.getByTestId("contracts-registry-table")
.getByRole("row")
.filter({ hasText: CONTRACT_ITEM.contractNo });
await expect(contractRow).toBeVisible();
await contractRow.getByRole("button", { name: "更多", exact: true }).click();
await expect(page.getByRole("menuitem", { name: "查看项目", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "续约", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "归档", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/contracts-registry$/);
await contractRow.getByRole("link", { name: "查看详情", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/contracts-registry/${CONTRACT_ITEM.id}$`));
await expect(page.getByTestId("contract-detail-workspace")).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: CONTRACT_ITEM.contractNo, exact: true })).toBeVisible();
await expectNoContractWriteActions(page);
await expect(page.getByRole("link", { name: "返回合同列表", exact: true })).toBeVisible();
expectNoPageErrors(pageErrors);
});
test("合同管理身份在同一生效合同上可见新增编辑续约归档", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
const actor = { ...CONTRACT_READER_ACTOR, roleCodes: ["admin"] };
await mockSyntheticApis(page, actor, { contracts: [CONTRACT_ITEM] });
await login(page, actor);
await page.goto("/contracts-registry");
await expect(page.getByRole("link", { name: "新增合同", exact: true })).toBeVisible();
const row = page.getByTestId("contracts-registry-table").getByRole("row").filter({ hasText: CONTRACT_ITEM.contractNo });
await row.getByRole("link", { name: "查看详情", exact: true }).click();
await expect(page.getByTestId("contract-detail-workspace")).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: CONTRACT_ITEM.contractNo, exact: true })).toBeVisible();
for (const name of ["编辑", "续约", "归档"]) {
await expect(page.getByRole("button", { name, exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name, exact: true }).first()).toBeEnabled();
}
expectNoPageErrors(pageErrors);
});
for (const action of ["新增合同", "编辑", "续约", "归档"]) {
test(`合同真实只读页面反例能拦截泄露动作:${action}`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
await mockSyntheticApis(page, CONTRACT_READER_ACTOR, { contracts: [CONTRACT_ITEM] });
await login(page, CONTRACT_READER_ACTOR);
await page.goto("/contracts-registry");
await expect(page.getByTestId("contracts-registry-table")).toContainText(CONTRACT_ITEM.contractNo);
if (action !== "新增合同") {
const row = page.getByTestId("contracts-registry-table").getByRole("row").filter({ hasText: CONTRACT_ITEM.contractNo });
await row.getByRole("link", { name: "查看详情", exact: true }).click();
await expect(page.getByTestId("contract-detail-workspace")).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: CONTRACT_ITEM.contractNo, exact: true })).toBeVisible();
}
await expectNoContractWriteActions(page);
await page.evaluate(name => {
const control = document.createElement(name === "新增合同" ? "a" : "button");
if (control instanceof HTMLAnchorElement) control.href = "/contracts-registry/new";
control.textContent = name;
control.dataset.qaCounterexample = "contract-action";
document.querySelector("main")!.append(control);
}, action);
await expect(expectNoContractWriteActions(page)).rejects.toThrow(`readonly contract leaked action: ${action}`);
await page.locator('[data-qa-counterexample="contract-action"]').evaluate(node => node.remove());
await expectNoContractWriteActions(page);
});
}
test("无票据上传权限的兼容角色直达票据页时失败关闭", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
@@ -624,3 +693,76 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
});
expectNoPageErrors(pageErrors);
});
test("编号兼容:付款候选完整搜索、新旧批次和混合报销明细保持编号与实体对应", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成编号,不访问线上");
const codes = ["BX-202609-0002", "BX-202609-0010", "BX-202609-10000", "BX-20260917-0198"];
const candidates = codes.map((businessNo, index) => ({
...PAYMENT_CANDIDATE, entityId: `11111111-1111-4111-8111-${String(index + 1).padStart(12, "0")}`, businessNo,
}));
const queries: string[] = [];
let created: unknown;
await mockSyntheticApis(page, FINANCE_ACTOR, {
paymentCandidates: candidates, onCandidateQuery: query => queries.push(query.get("q") ?? ""),
onCreatePaymentBatch: payload => { created = payload; },
});
const base = {
name: "合成编号兼容批次", status: "draft", entityType: "reimbursement", totalAmount: "5122.00", highRisk: false,
defaultPaymentDate: null, defaultPaymentChannel: "cash", createdBy: FINANCE_ACTOR.id,
paymentAccount: null, paymentOrderExportCount: 0, canExportPaymentOrder: false,
paymentOrderExportBlockers: [], processingProgress: { stages: [] },
reviewRequirement: { completedReviewCount: 0, requiredReviewCount: 1, nextStep: "first_review" },
};
const batches = [
{ ...base, id: "33333333-3333-4333-8333-333333333333", batchNo: "PB-20260917-001", sources: candidates.slice(-1) },
{ ...base, id: CREATED_PAYMENT_BATCH_ID, batchNo: "PB-2026-0001", sources: candidates },
];
await page.route(url => url.pathname === "/api/payment-batches", async route => {
if (route.request().method() !== "GET") return route.fallback();
await fulfillJson(route, { items: batches });
});
for (const batch of batches) {
await page.route(`**/api/payment-batches/${batch.id}`, route => fulfillJson(route, { item: {
...batch, items: batch.sources.map((source, index) => ({
id: `${batch.id}-item-${index}`, entityId: source.entityId, entityCode: source.businessNo,
entityTitle: "合成报销", amount: source.amount, currencyCode: "CNY", status: "pending",
paymentChannelOverride: null, paymentDateOverride: null, paymentVoucherNoOverride: null,
failureCode: null, failureMessage: null, expenseItems: [], expenseItemsTotal: "0.00", openRiskFlags: [],
})),
} }));
await page.route(`**/api/payment-batches/${batch.id}/receipt-attachments`, route => fulfillJson(route, { items: [] }));
}
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
const search = page.getByTestId("payment-batch-candidate-search");
for (const source of [candidates[0], candidates[3]]) {
await search.fill(source.businessNo);
await expect.poll(() => queries.at(-1)).toBe(source.businessNo);
await expect(page.getByTestId("payment-batch-candidate-row").filter({ hasText: source.businessNo })).toBeVisible();
}
await search.fill("");
for (const source of candidates) {
await page.getByTestId("payment-batch-candidate-row").filter({ hasText: source.businessNo }).getByRole("checkbox").check();
}
await page.getByTestId("payment-batch-default-channel").selectOption("cash");
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`id=${CREATED_PAYMENT_BATCH_ID}`));
expect(created).toEqual({ entityType: "reimbursement", entityIds: candidates.map(source => source.entityId), defaultPaymentChannel: "cash" });
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 });
for (const batch of batches) {
await page.goto("/payment-batches?view=batches");
const list = page.getByTestId(width === 390 ? "payment-batches-mobile-list" : "payment-batches-table");
for (const item of batches) await expect(list).toContainText(item.batchNo);
await page.goto(`/payment-batches?view=batches&id=${batch.id}`);
await expect(page.getByText(batch.batchNo, { exact: true }).first()).toBeVisible();
const cards = page.getByTestId("payment-batch-items-panel").locator(".detail-evidence-card");
await expect(cards).toHaveCount(batch.sources.length);
// The API owns natural sorting; the page preserves its order and ID links.
for (const [index, source] of batch.sources.entries()) {
await expect(cards.nth(index)).toContainText(source.businessNo);
await expect(cards.nth(index).getByRole("link", { name: "查看报销单" })).toHaveAttribute("href", `/reimbursements/${source.entityId}`);
}
}
}
});
@@ -1397,8 +1397,10 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 17:00");
const workflowActionPanel = page.getByTestId("workflow-task-action-panel").locator("xpath=ancestor::section[1]");
await expect(workflowActionPanel).toHaveCSS("position", "sticky");
await expect(workflowActionPanel).toHaveCSS("top", "96px");
await expect(workflowActionPanel).toHaveCSS("position", "static");
const progressPanel = page.getByTestId("reimbursement-approval-processing-progress");
await expect(progressPanel).toBeVisible();
expect((await progressPanel.boundingBox())!.y).toBeGreaterThan((await workflowActionPanel.boundingBox())!.y);
const workflowLayout = await page.locator(".detail-workspace-grid").evaluate((workspace) => {
const [main, aside] = Array.from(workspace.children) as HTMLElement[];
return {
@@ -1018,7 +1018,7 @@ async function captureTaskEvidence(page: Page, name: string) {
}
const pages = [
{ path: "/payment-batches", proof: "data-testid=payment-batches-table", screenshot: "01-payment-batches", expectStatusStrip: false },
{ path: "/payment-batches?view=batches", proof: "data-testid=payment-batches-table", screenshot: "01-payment-batches", expectStatusStrip: false },
{ path: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false },
{ path: "/invoice-pool", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool", expectStatusStrip: false },
{ path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" },
@@ -1057,13 +1057,14 @@ test("PC 财务列表使用明确动作入口", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await login(page);
await page.goto("/payment-batches");
await page.goto("/payment-batches?view=batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "新建批次" }).click();
await page.getByRole("button", { name: /^待组批 / }).click();
await expect(page.getByTestId("payment-batch-create-panel")).toBeVisible();
await expect(page.getByTestId("payment-batch-create-panel")).toContainText("新建批次");
await expect(page.getByTestId("payment-batch-create-panel")).toContainText("待组批报销");
await page.goto("/ap-operations");
await page.getByRole("tab", { name: /^财务台账/ }).click();
await expect(page.getByTestId("ap-export")).toBeVisible();
await page.getByTestId("ap-export").click();
await expect(page.getByTestId("ap-export-csv")).toBeVisible();
@@ -1139,7 +1140,7 @@ test("/payment-batches 在 768x1024 与 1024x768 下操作列可达", async ({ p
{ width: 1024, height: 768 },
]) {
await page.setViewportSize(viewport);
await page.goto("/payment-batches");
await page.goto("/payment-batches?view=batches");
await expect(page.getByRole("heading", { name: "付款", exact: true })).toBeVisible();
const table = page.getByTestId("payment-batches-table");
await expect(table).toBeVisible();
+136 -115
查看文件
@@ -1,4 +1,4 @@
import { expect, test, type APIRequestContext, type APIResponse, type Browser } from "@playwright/test";
import { expect, test, type APIRequestContext, type APIResponse, type Browser, type Page } from "@playwright/test";
import {
ADMIN,
@@ -169,16 +169,14 @@ async function openAs(browser: Browser, user: E2EUser) {
return { context, page };
}
async function expectForbiddenRoutes(browser: Browser, user: E2EUser, paths: string[]) {
const { context, page } = await openAs(browser, user);
try {
for (const path of paths) {
await page.goto(path);
await assertForbiddenPage(page);
}
} finally {
await context.close();
}
async function assertAllowedRoute(page: Page, user: E2EUser, path: string) {
// A negative URL assertion can pass before auth bootstrap and permission
// routing finish. Wait for the real page before another document navigation.
await expect(page).toHaveURL(url => url.pathname === path);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: /^(无权限访问|页面不存在)$/ })).toHaveCount(0);
await expect(page.getByTestId("login-identifier")).toHaveCount(0);
await expect(page, `${user.name} should not be routed to /403 for ${path}`).not.toHaveURL(/\/403/);
}
async function expectDirectRoutesNotForbidden(browser: Browser, user: E2EUser, paths: string[]) {
@@ -186,7 +184,7 @@ async function expectDirectRoutesNotForbidden(browser: Browser, user: E2EUser, p
try {
for (const path of paths) {
await page.goto(path);
await expect(page, `${user.name} should not be routed to /403 for ${path}`).not.toHaveURL(/\/403/);
await assertAllowedRoute(page, user, path);
}
} finally {
await context.close();
@@ -212,60 +210,62 @@ function expectArray(value: unknown, context: string) {
return value as JsonObject[];
}
async function expectPcNavMatrix(browser: Browser, user: E2EUser, input: {
async function expectPcNavMatrix(page: Page, user: E2EUser, input: {
visible?: string[];
hidden?: string[];
managementVisible?: string[];
managementHidden?: string[];
}) {
const { context, page } = await openAs(browser, user);
try {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/portal");
for (const href of input.visible ?? []) {
await expect
.poll(() => page.locator(`nav a[href="${href}"]`).count(), {
message: `${user.name} should have entry ${href}`,
})
.toBeGreaterThan(0);
await page.setViewportSize({ width: 1440, height: 960 });
await expect(page).toHaveURL(/\/portal/);
for (const href of input.visible ?? []) {
await expect
.poll(() => page.locator(`nav a[href="${href}"]`).count(), {
message: `${user.name} should have entry ${href}`,
})
.toBeGreaterThan(0);
}
for (const href of input.hidden ?? []) {
await expect(page.locator(`nav a[href="${href}"]`), `${user.name} should not see ${href}`).toHaveCount(0);
}
if (input.managementVisible) {
await page.goto("/system/settings");
await expect(page.getByTestId("system-settings-summary")).toBeVisible();
for (const href of input.managementVisible) {
await expect(page.locator(`main a[href="${href}"]`)).toBeVisible();
}
for (const href of input.hidden ?? []) {
await expect(page.locator(`nav a[href="${href}"]`), `${user.name} should not see ${href}`).toHaveCount(0);
for (const href of input.managementHidden ?? []) {
await expect(page.locator(`main a[href="${href}"]`)).toHaveCount(0);
}
} finally {
await context.close();
}
}
async function expectMobileApprovalEntry(browser: Browser, user: E2EUser, canApprove: boolean) {
const { context, page } = await openAs(browser, user);
try {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile");
await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible();
const approvalTab = page.getByTestId("mobile-nav-approvals");
if (canApprove) {
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("待我审批");
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
} else {
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
await expect
.poll(
async () =>
(await page.getByTestId("mobile-approvals-progress").count()) +
(await page.getByTestId("mobile-approvals-list").count()),
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
await expect(page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
await expect(page.getByTestId("mobile-approvals-reject")).toHaveCount(0);
}
} finally {
await context.close();
async function expectMobileApprovalEntry(page: Page, user: E2EUser, canApprove: boolean) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile");
await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible();
const approvalTab = page.getByTestId("mobile-nav-approvals");
if (canApprove) {
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("待我审批");
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
} else {
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
await expect
.poll(
async () =>
(await page.getByTestId("mobile-approvals-progress").count()) +
(await page.getByTestId("mobile-approvals-list").count()),
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
await expect(page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
await expect(page.getByTestId("mobile-approvals-return")).toHaveCount(0);
}
}
@@ -391,60 +391,77 @@ async function expectAuditAction(api: APIRequestContext, action: string, predica
test.describe("R3 权限直连审计", () => {
test("前端直连备用路径按角色拦截", async ({ browser }) => {
await expectForbiddenRoutes(browser, EMPLOYEE, [
"/budgets",
"/ap-operations",
"/system/integrations/erp",
"/payment-batches",
"/assets/new",
]);
await expectForbiddenRoutes(browser, MANAGER, [
"/ap-operations",
"/system/integrations/erp",
"/payment-batches",
]);
await expectForbiddenRoutes(browser, FINANCE, [
"/system/integrations/erp",
]);
await expectForbiddenRoutes(browser, ADMIN, [
"/workflow/tasks",
const forbiddenByRole = new Map<E2EUser, string[]>([
[EMPLOYEE, ["/budgets", "/ap-operations", "/payment-batches", "/assets/new"]],
[MANAGER, ["/ap-operations"]],
[ADMIN, ["/workflow/tasks"]],
]);
// Each role has an isolated session; reuse it for all of that role's URLs.
for (const user of [EMPLOYEE, MANAGER, FINANCE, FINANCE_MGR, ADMIN]) {
const { context, page } = await openAs(browser, user);
try {
await test.step(`${user.name} 直连路径`, async () => {
for (const path of forbiddenByRole.get(user) ?? []) {
await page.goto(path);
await assertForbiddenPage(page);
}
if (user === MANAGER) {
await page.goto("/payment-batches");
await assertAllowedRoute(page, user, "/payment-batches");
}
await page.goto("/system/integrations/erp");
await expect(page.getByRole("heading", { name: "页面不存在", exact: true })).toBeVisible();
});
} finally { await context.close(); }
}
});
test("PA6 角色入口矩阵:PC 与移动入口按正式业务角色裁剪", async ({ browser }) => {
await expectPcNavMatrix(browser, EMPLOYEE, {
async function checkRole(user: E2EUser, matrix: Parameters<typeof expectPcNavMatrix>[2], canApprove: boolean | null) {
const { context, page } = await openAs(browser, user);
try {
await test.step(`${user.name} PC 与移动入口`, async () => {
await expectPcNavMatrix(page, user, matrix);
if (canApprove !== null) {
await expectMobileApprovalEntry(page, user, canApprove);
} else {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/me");
await expect(page.getByText("管理入口", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("mobile-me-action-link").filter({ hasText: "审批" }).first()).toBeVisible();
}
});
} finally { await context.close(); }
}
await checkRole(EMPLOYEE, {
visible: ["/portal", "/reimbursements"],
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches", "/audit-logs", "/system/settings"],
});
await expectPcNavMatrix(browser, MANAGER, {
visible: ["/workflow/tasks"],
hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/settings"],
});
await expectPcNavMatrix(browser, FINANCE, {
visible: ["/invoice-pool"],
hidden: ["/audit-logs", "/system/settings", "/system/integrations/erp"],
});
await expectPcNavMatrix(browser, ADMIN, {
visible: ["/audit-logs", "/system/settings"],
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches"],
});
}, false);
await checkRole(MANAGER, {
visible: ["/workflow/tasks", "/payment-batches", "/system/settings"],
hidden: ["/ap-operations", "/audit-logs"],
managementVisible: ["/organization"],
managementHidden: ["/system/master-data", "/system/payment-accounts", "/system/users", "/system/approval-rules", "/system/workflow-definitions", "/audit-logs"],
}, true);
await checkRole(FINANCE, {
visible: ["/invoice-pool", "/system/settings"],
hidden: ["/audit-logs", "/system/integrations/erp"],
managementVisible: ["/organization", "/system/master-data", "/system/payment-accounts"],
managementHidden: ["/system/users", "/system/approval-rules", "/system/workflow-definitions", "/audit-logs"],
}, null);
await checkRole(ADMIN, {
visible: ["/system/settings", "/ap-operations", "/payment-batches"],
hidden: ["/workflow/tasks", "/audit-logs"],
managementVisible: ["/system/users", "/system/approval-rules", "/system/workflow-definitions", "/audit-logs"],
}, false);
await expectMobileApprovalEntry(browser, EMPLOYEE, false);
await expectMobileApprovalEntry(browser, MANAGER, true);
await expectMobileApprovalEntry(browser, ADMIN, false);
const { context, page } = await openAs(browser, FINANCE);
// Legacy admin navigation compatibility does not grant a business reviewer role.
const admin = await createAuthenticatedApiClient(ADMIN);
try {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/me");
await expect(page.getByText("管理入口", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("mobile-me-action-link").filter({ hasText: "审批" }).first()).toBeVisible();
} finally {
await context.close();
}
const response = await admin.api.post("/api/payment-batches/00000000-0000-4000-8000-000000000099/review", { data: {} });
expect(response.status()).toBe(403);
} finally { await admin.dispose(); }
});
test("PA6 portal 角色能力字段不把 admin-only 当业务审批人", async () => {
@@ -496,12 +513,16 @@ test.describe("R3 权限直连审计", () => {
}
});
test("员工自助直连页面不进入管理态 403", async ({ browser }) => {
await expectDirectRoutesNotForbidden(browser, EMPLOYEE, [
"/travel-requests",
"/invoice-pool",
"/contracts-registry",
]);
test("员工自助直连页面可用且冻结差旅拒绝进入", async ({ browser }) => {
const { context, page } = await openAs(browser, EMPLOYEE);
try {
for (const path of ["/invoice-pool", "/contracts-registry"]) {
await page.goto(path);
await assertAllowedRoute(page, EMPLOYEE, path);
}
await page.goto("/travel-requests");
await assertForbiddenPage(page);
} finally { await context.close(); }
});
test("资产创建路由只允许 asset.manage 角色直连", async ({ browser }) => {
@@ -522,7 +543,7 @@ test.describe("R3 权限直连审计", () => {
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/ap-operations", statuses: [403, 409], note: "员工不得直连 AP;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "员工不得直连财务台账;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/payment-batches", statuses: [403, 409], note: "员工不得直连付款批次;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "员工不得直连 ERP 管理" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/integrations/erp/master-data/status", statuses: [404], note: "ERP 已退役,员工接口不存在" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/invoice-pool?ownerScope=all", statuses: [403], note: "员工不得查看全量票据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/audit-logs", statuses: [403], note: "员工不得查看审计日志" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/notifications/deliveries", statuses: [403], note: "员工不得查看通知投递审计" });
@@ -543,11 +564,11 @@ test.describe("R3 权限直连审计", () => {
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations", statuses: [403, 409], note: "主管不得直连 AP" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "主管不得直连财务台账" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [200, 409], note: "主管可读取待复核付款批次;409 表示功能门禁关闭" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [404], note: "ERP 已退役,主管接口不存在" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/audit-logs", statuses: [403], note: "主管不得查看审计日志" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/budgets", statuses: [200], note: "主管可直连预算" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "财务不得直连 ERP 管理" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/integrations/erp/master-data/status", statuses: [404], note: "ERP 已退役,财务接口不存在" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/audit-logs", statuses: [403], note: "普通财务不得查看审计日志" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/system/feature-flags", statuses: [403], note: "普通财务不得管理系统开关" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/notifications/deliveries", statuses: [403], note: "普通财务不得查看通知渠道投递审计" });
@@ -559,8 +580,8 @@ test.describe("R3 权限直连审计", () => {
role: "finance_mgr",
api: financeMgr.api,
path: "/api/integrations/erp/master-data/status",
statuses: [200],
note: "财务主管可进入 ERP 管理状态",
statuses: [404],
note: "ERP 已退役,财务主管也不能调用",
});
expectNoSensitiveKeys(erpStatus.body?.data);
+21
查看文件
@@ -38,6 +38,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
);
await page.getByTestId("purchase-requests-create").click();
const createResponse = await createResponsePromise;
expect(createResponse.status(), "创建合成采购草稿").toBe(200);
const createPayload = (await createResponse.json()) as {
data: { purchaseRequest: { id: string } };
};
@@ -62,12 +63,32 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("100");
await expect(page.getByTestId("purchase-budget-hint")).toContainText("未匹配预算池");
const saveResponsePromise = page.waitForResponse(
(response) => response.request().method() === "PATCH" && new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}`,
);
const precheckResponsePromise = page.waitForResponse(
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}/precheck`,
);
const submitResponsePromise = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}/submit`,
);
// Keep later listeners handled when an earlier stage fails; assertions still
// await the original promises and never turn rejection into success.
void precheckResponsePromise.catch(() => undefined);
void submitResponsePromise.catch(() => undefined);
await page.getByTestId("purchase-submit").click();
const saved = await saveResponsePromise;
expect(saved.status(), "提交前保存合成采购草稿").toBe(200);
const prechecked = await precheckResponsePromise;
expect(prechecked.status(), "合成采购提交前校验").toBe(200);
const precheck = (await prechecked.json()).data as { decision: string };
expect(["pass", "warn", "manual_review"]).toContain(precheck.decision);
if (precheck.decision === "warn" || precheck.decision === "manual_review") {
await expect(page.getByTestId("action-confirm-dialog")).toBeVisible();
await page.getByTestId("action-confirm-submit").click();
}
const submitResponse = await submitResponsePromise;
if (!submitResponse.ok()) {
const failureText = await submitResponse.text();
+304 -65
查看文件
@@ -1,9 +1,10 @@
import { execSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page } from "@playwright/test";
import { expect, test, type Browser, type Page, type Request, type Response, type ConsoleMessage } from "@playwright/test";
import { ADMIN, EMPLOYEE, MANAGER, createAuthenticatedApiClient, type E2EUser } from "./helpers/auth";
import { ADMIN, EMPLOYEE, FINANCE, MANAGER, createAuthenticatedApiClient, createAuthenticatedBrowserStorageState, type E2EUser } from "./helpers/auth";
import { visualCoverageProblems, type VisualPageIdentity, type VisualPageCompletion } from "./helpers/visual-coverage";
type MatrixRow = {
Domain: string;
@@ -37,19 +38,63 @@ const MATRIX_PATH_CANDIDATES = [
const MATRIX_PATH = MATRIX_PATH_CANDIDATES.find((candidate) => existsSync(candidate)) ?? MATRIX_PATH_CANDIDATES[0];
const ISSUE_ID = process.env.QA_ISSUE_ID?.trim() || "project-wide-visual";
const BASE_URL = process.env.E2E_BASE_URL?.trim() || null;
const DEFAULT_RUN_DIR = path.join(ROOT_DIR, "artifacts/qa-layout/project-visual-runs", `${Date.now()}-${process.pid}`);
const SCREENSHOT_DIR = resolveRootPath(
process.env.QA_VISUAL_OUTPUT_DIR,
path.join(ROOT_DIR, "output/playwright/project-wide-visual"),
path.join(DEFAULT_RUN_DIR, "screenshots"),
);
const ARTIFACT_DIR = resolveRootPath(
process.env.QA_VISUAL_ARTIFACT_DIR,
path.join(ROOT_DIR, "artifacts/qa-flow/project-wide-visual"),
process.env.QA_VISUAL_OUTPUT_DIR ? path.resolve(process.env.QA_VISUAL_OUTPUT_DIR, "project-wide-reports") : path.join(DEFAULT_RUN_DIR, "reports"),
);
const WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
const findings: VisualFinding[] = [];
const completedPages: VisualPageCompletion[] = [];
const arrivals: Array<{ route: string; actualRoute: string; role: string; viewport: string }> = [];
const SYSTEM_HEADINGS: Record<string, string> = {
"/system/reimbursement-templates": "报销模板管理",
"/system/users": "系统用户管理",
"/system/departments": "部门管理",
"/system/master-data": "主数据",
"/system/payment-accounts": "企业银行账户",
"/system/notification-channels": "通知渠道",
"/system/workspace": "工作区设置",
"/system/settings": "管理",
"/system/approval-rules": "审批规则",
"/system/expense-standards": "费用标准管理",
"/system/feature-flags": "功能管理",
"/system/announcements": "公告管理",
"/system/workflow-definitions": "流程定义",
"/system/cutover": "切换控制台",
};
async function assertRouteArrival(page: Page, route: string, timeout?: number) {
const arrivalExpect = timeout === undefined ? expect : expect.configure({ timeout });
await arrivalExpect(page, `必须到达目标路由 ${route.replace(UUID_RE, ":id")}`).toHaveURL(url => url.pathname === route);
const checks = [arrivalExpect(page.getByRole("heading", { name: /^(无权限访问|页面不存在)$/ })).toHaveCount(0)];
if (route === "/login") {
checks.push(arrivalExpect(page.getByTestId("login-identifier")).toBeVisible());
} else {
checks.push(arrivalExpect(page.getByTestId("login-identifier")).toHaveCount(0));
const anchor = route === "/portal" ? "portal-work-summary"
: /^\/reimbursements\/[^/]+$/.test(route) ? "reimbursement-desktop-workspace"
: /^\/mobile\/reimbursements\/[^/]+$/.test(route) ? "reimbursement-mobile-workspace" : null;
if (anchor) {
checks.push(arrivalExpect(page.getByTestId(anchor)).toBeVisible());
} else {
const heading = SYSTEM_HEADINGS[route];
checks.push(arrivalExpect(page.getByRole("heading", { level: 1, ...(heading ? { name: heading, exact: true } : {}) })).toBeVisible());
}
}
// All original guards must pass. Preserve error priority for counterexamples
// while avoiding sequential browser round trips for independent assertions.
for (const result of await Promise.allSettled(checks)) {
if (result.status === "rejected") throw result.reason;
}
}
function resolveRootPath(value: string | undefined, fallback: string) {
if (!value?.trim()) {
@@ -122,7 +167,8 @@ function sanitizeRoute(route: string) {
function addFinding(finding: VisualFinding) {
findings.push({
...finding,
detail: finding.detail.replace(UUID_RE, "[uuid]"),
detail: finding.detail.replaceAll(ROOT_DIR, "<worktree>").replace(UUID_RE, "[uuid]"),
screenshot: finding.screenshot ? path.relative(ROOT_DIR, finding.screenshot) : undefined,
});
}
@@ -135,28 +181,6 @@ function replaceDynamicRoute(route: string, prepared: PreparedRoutes) {
return route;
}
async function loginForVisual(page: Page, user: E2EUser) {
await page.goto("/login");
await expect(page.getByTestId("login-identifier")).toBeVisible();
await page.getByTestId("login-identifier").fill(user.identifier);
await page.getByTestId("login-password").fill(user.password);
await page.getByTestId("login-submit").click();
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
await page.waitForLoadState("networkidle").catch(() => undefined);
}
async function newWecomContext(browser: Browser) {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
userAgent: WECOM_UA,
locale: "zh-CN",
serviceWorkers: "block",
});
return context;
}
async function prepareRoutes() {
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
const managerClient = await createAuthenticatedApiClient(MANAGER);
@@ -189,7 +213,14 @@ async function prepareRoutes() {
}
}
function userForRoute(route: string): E2EUser {
function roleForRoute(route: string) {
const user = userForRoute(route);
return user === ADMIN ? "admin" : user === FINANCE ? "finance" : user === MANAGER ? "manager" : user === EMPLOYEE ? "employee" : "anonymous";
}
function userForRoute(route: string): E2EUser | null {
if (route === "/login") return null;
if (route === "/ap-operations") return FINANCE;
if (route.startsWith("/mobile/approvals") || route.startsWith("/workflow/tasks")) return MANAGER;
if (
route.startsWith("/system") ||
@@ -204,36 +235,110 @@ function userForRoute(route: string): E2EUser {
return EMPLOYEE;
}
async function visibleRouteLink(page: Page, route: string) {
const links = page.locator(`a[href=${JSON.stringify(route)}]:visible`);
return await links.count() ? links.first() : null;
}
async function navigateForAudit(page: Page, route: string) {
const timing = visualTiming(route, "navigation");
let link = await visibleRouteLink(page, route);
timing("find-link");
if (!link && route.startsWith("/system/") && route !== "/system/settings") {
const management = await visibleRouteLink(page, "/system/settings");
if (management) {
await management.click();
await expect(page.getByTestId("system-settings-summary")).toBeVisible();
timing("management-arrival");
link = await visibleRouteLink(page, route);
}
}
if (link || new URL(page.url()).pathname === route) {
// Keep the target document HTTP check while using real application links.
// Re-loading the entire app for every matrix row repeats authentication.
const [document] = await Promise.all([
page.request.get(route, { headers: { accept: "text/html" } }),
link ? link.click() : Promise.resolve(),
]);
timing("document-and-link-arrival");
return document;
}
const document = await page.goto(route);
timing("document-navigation");
return document;
}
function visualTiming(route: string, scope: string) {
const start = Date.now();
let previous = start;
return (stage: string) => {
const now = Date.now();
console.info("[visual-audit]", JSON.stringify({ scope, stage, route: route.replace(UUID_RE, ":id"),
elapsedMs: now - start, phaseMs: now - previous }));
previous = now;
};
}
async function auditPage(page: Page, input: {
route: string;
domain: string;
viewport: "pc" | "mobile-wecom";
role: string;
}) {
const timing = visualTiming(input.route, "page");
const responses: Array<{ status: number; url: string }> = [];
const consoleErrors: string[] = [];
page.on("response", (response) => {
if (response.status() >= 500) {
responses.push({ status: response.status(), url: response.url() });
const pendingRequests = new Set<Request>();
let quietSince = Date.now();
const onRequest = (request: Request) => {
if (!/^https?:/.test(request.url())) return;
pendingRequests.add(request);
quietSince = 0;
};
const onRequestSettled = (request: Request) => {
if (pendingRequests.delete(request) && pendingRequests.size === 0) quietSince = Date.now();
};
const onResponse = (response: Response) => {
const url = new URL(response.url());
const deniedApi = url.origin === new URL(page.url()).origin && url.pathname.startsWith("/api/") && [403, 404].includes(response.status());
if (response.status() >= 500 || deniedApi) {
responses.push({ status: response.status(), url: url.pathname.replace(UUID_RE, ":id") });
}
});
page.on("console", (message) => {
};
const onConsole = (message: ConsoleMessage) => {
if (message.type() === "error") {
const text = message.text();
if (!/Failed to load resource: the server responded with a status of 4\d\d/i.test(text)) {
consoleErrors.push(text);
}
}
});
};
page.on("response", onResponse);
page.on("console", onConsole);
page.on("request", onRequest);
page.on("requestfinished", onRequestSettled);
page.on("requestfailed", onRequestSettled);
await page.goto(input.route);
await page.waitForLoadState("networkidle").catch(() => undefined);
await page.waitForTimeout(300);
const navigation = await navigateForAudit(page, input.route);
timing("navigation-complete");
expect(navigation?.ok(), `目标文档 HTTP ${navigation?.status()}`).toBe(true);
await assertRouteArrival(page, input.route);
timing("arrival");
// A same-document route retains the old load state. Observe the requests
// made by this navigation instead of treating that old state as readiness.
await expect.poll(() => pendingRequests.size === 0 && Date.now() - quietSince >= 500, {
message: `目标页面请求完成 ${input.route.replace(UUID_RE, ":id")}`,
intervals: [50, 100],
}).toBe(true);
timing("requests-complete");
arrivals.push({ route: input.route.replace(UUID_RE, ":id"), actualRoute: new URL(page.url()).pathname.replace(UUID_RE, ":id"), role: input.role, viewport: input.viewport });
const screenshot = outputPath(
SCREENSHOT_DIR,
`${input.viewport}-${sanitizeRoute(input.route)}.png`,
);
await page.screenshot({ path: screenshot, fullPage: true });
timing("screenshot");
const metrics = await page.evaluate(() => {
const text = document.body?.innerText ?? "";
@@ -266,6 +371,7 @@ async function auditPage(page: Page, input: {
}));
return { text, width, scrollWidth, visibleTables, sidebarLike, clipped };
});
timing("metrics");
if (responses.length) {
addFinding({
@@ -358,6 +464,11 @@ async function auditPage(page: Page, input: {
screenshot,
});
}
page.off("response", onResponse);
page.off("console", onConsole);
page.off("request", onRequest);
page.off("requestfinished", onRequestSettled);
page.off("requestfailed", onRequestSettled);
}
function buildRoutePlans(matrix: MatrixRow[], prepared: PreparedRoutes, viewport: "pc" | "mobile-wecom") {
@@ -368,33 +479,51 @@ function buildRoutePlans(matrix: MatrixRow[], prepared: PreparedRoutes, viewport
const tokens = splitRoutes(viewport === "pc" ? row.PC_Routes : row.Mobile_WeCom_Routes);
for (const token of tokens) {
const route = replaceDynamicRoute(token, prepared);
if (!route || seen.has(route)) continue;
if (!route || /[:*]/.test(route)) throw new Error(`视觉矩阵路由未解析: ${token}`);
if (route.startsWith("/system/") && !SYSTEM_HEADINGS[route]) throw new Error(`系统路由缺目标合同: ${route}`);
if (seen.has(route)) continue;
seen.add(route);
plans.push({ route, domain: row.Domain });
}
}
if (viewport === "pc") {
// Router additions must be explicitly owned by the visual matrix.
const router = readFileSync(path.join(ROOT_DIR, "client/src/router/index.tsx"), "utf8");
const systemRoutes = [...router.matchAll(/path="(system\/[^"*:]+)"/g)].map(match => `/${match[1]}`).sort();
expect(plans.map(plan => plan.route).filter(route => route.startsWith("/system/")).sort()).toEqual(systemRoutes);
}
return plans;
}
async function auditPlans(browser: Browser, plans: Array<{ route: string; domain: string }>, viewport: "pc" | "mobile-wecom") {
const plansByUser = new Map<string, { user: E2EUser; plans: Array<{ route: string; domain: string }> }>();
const plansByUser = new Map<string, { user: E2EUser | null; plans: Array<{ route: string; domain: string }> }>();
for (const plan of plans) {
const user = userForRoute(plan.route);
const entry = plansByUser.get(user.identifier) ?? { user, plans: [] };
const entry = plansByUser.get(user?.identifier ?? "anonymous") ?? { user, plans: [] };
entry.plans.push(plan);
plansByUser.set(user.identifier, entry);
plansByUser.set(user?.identifier ?? "anonymous", entry);
}
for (const { user, plans: userPlans } of plansByUser.values()) {
const context = viewport === "mobile-wecom"
? await newWecomContext(browser)
: await browser.newContext({ viewport: { width: 1440, height: 960 }, serviceWorkers: "block" });
// Each context gets a fresh real server session. Anonymous /login is still
// audited, while every business route keeps its document/arrival checks.
console.info("[visual-audit]", JSON.stringify({ stage: "session:start", viewport,
role: user === ADMIN ? "admin" : user === FINANCE ? "finance" : user === MANAGER ? "manager" : user === EMPLOYEE ? "employee" : "anonymous" }));
const storageState = user ? await createAuthenticatedBrowserStorageState(user) : undefined;
const context = await browser.newContext({
viewport: viewport === "mobile-wecom" ? { width: 390, height: 844 } : { width: 1440, height: 960 },
serviceWorkers: "block", storageState,
...(viewport === "mobile-wecom" ? { isMobile: true, hasTouch: true, userAgent: WECOM_UA, locale: "zh-CN" } : {}),
});
try {
const page = await context.newPage();
await loginForVisual(page, user);
for (const plan of userPlans) {
await auditPage(page, { ...plan, viewport });
await test.step(`${viewport} ${plan.route.replace(UUID_RE, ":id")}`, async () => {
console.info("[visual-audit]", JSON.stringify({ stage: "route:start", viewport, route: plan.route.replace(UUID_RE, ":id") }));
await auditPage(page, { ...plan, viewport, role: roleForRoute(plan.route) });
completedPages.push({ route: plan.route, actualRoute: new URL(page.url()).pathname, role: roleForRoute(plan.route), viewport });
});
}
} finally {
await context.close();
@@ -402,32 +531,142 @@ async function auditPlans(browser: Browser, plans: Array<{ route: string; domain
}
}
test.describe("项目级全页面视觉巡检", () => {
test("PC 与企业微信移动端没有已知视觉和文案回归", async ({ browser }) => {
const prepared = await prepareRoutes();
const matrix = parseMatrix();
const pcPlans = buildRoutePlans(matrix, prepared, "pc");
const mobilePlans = buildRoutePlans(matrix, prepared, "mobile-wecom");
const VISUAL_GROUPS = [
{ viewport: "pc", role: "anonymous", count: 1 },
{ viewport: "pc", role: "employee", count: 7 },
{ viewport: "pc", role: "finance", count: 1 },
{ viewport: "pc", role: "manager", count: 1 },
{ viewport: "pc", role: "admin", count: 15 },
{ viewport: "mobile-wecom", role: "employee", count: 7 },
{ viewport: "mobile-wecom", role: "manager", count: 1 },
] as const;
await auditPlans(browser, pcPlans, "pc");
await auditPlans(browser, mobilePlans, "mobile-wecom");
const reportPath = outputPath(ARTIFACT_DIR, "report.json");
writeFileSync(reportPath, JSON.stringify({
// Preserve this file's existing sequential page execution. Other files keep
// the runner's original workers and zero retries. One failed group skips its
// serial successors, which remain incomplete in the aggregate report.
test.describe.serial("项目级全页面视觉巡检", () => {
const groups = VISUAL_GROUPS.map(group => ({
...group, id: `${group.viewport}:${group.role}`, status: "pending",
}));
let plans: Array<VisualPageIdentity & { domain: string }> = [];
let preparationMs = 0;
const reportPath = () => outputPath(ARTIFACT_DIR, "report.json");
const coverage = () => visualCoverageProblems(plans, completedPages, groups.map(group => group.id), groups);
function writeReport() {
const coverageProblems = coverage();
writeFileSync(reportPath(), JSON.stringify({
issueId: ISSUE_ID,
baseURL: BASE_URL,
commit: currentCommit(),
checkedAt: new Date().toISOString(),
status: findings.some((finding) => finding.severity === "P1") ? "FAIL" : "PASS",
matrix: {
pc: "1440x960",
mobileWeCom: "390x844",
},
pcRoutes: pcPlans.map((item) => item.route),
mobileRoutes: mobilePlans.map((item) => item.route),
status: coverageProblems.length ? "INCOMPLETE_OR_FAILED" : findings.some(finding => finding.severity === "P1") ? "FAIL" : "PASS",
matrix: { pc: "1440x960", mobileWeCom: "390x844" },
preparationMs,
groups,
coverageProblems,
pcRoutes: plans.filter(plan => plan.viewport === "pc").map(plan => plan.route.replace(UUID_RE, ":id")),
mobileRoutes: plans.filter(plan => plan.viewport === "mobile-wecom").map(plan => plan.route.replace(UUID_RE, ":id")),
arrivals,
completedPages: completedPages.map(page => ({ ...page, route: page.route.replace(UUID_RE, ":id"), actualRoute: page.actualRoute.replace(UUID_RE, ":id") })),
findings,
}, null, 2), "utf8");
}
expect(findings.filter((finding) => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath}`).toEqual([]);
test.beforeAll(async () => {
writeReport();
const start = Date.now();
try {
const prepared = await prepareRoutes();
const matrix = parseMatrix();
plans = (["pc", "mobile-wecom"] as const).flatMap(viewport =>
buildRoutePlans(matrix, prepared, viewport).map(plan => ({ ...plan, viewport, role: roleForRoute(plan.route) })),
);
expect(plans).toHaveLength(33);
for (const group of groups) {
expect(plans.filter(plan => plan.viewport === group.viewport && plan.role === group.role), group.id).toHaveLength(group.count);
}
} finally {
preparationMs = Date.now() - start;
console.info("[visual-audit]", JSON.stringify({ stage: "preparation", elapsedMs: preparationMs }));
writeReport();
}
});
test.afterEach(async ({}, testInfo) => {
const group = groups.find(group => testInfo.title === group.id);
if (group) group.status = testInfo.status ?? "unknown";
writeReport();
});
test.afterAll(async () => {
writeReport();
expect(coverage(), "全部33页和7组必须在同一次运行完整通过").toEqual([]);
expect(findings.filter(finding => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath()}`).toEqual([]);
});
for (const group of groups) {
test(group.id, async ({ browser }) => {
const groupPlans = plans.filter(plan => plan.viewport === group.viewport && plan.role === group.role);
await auditPlans(browser, groupPlans, group.viewport);
expect(findings.filter(finding => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath()}`).toEqual([]);
});
}
});
const coverageFixture = () => {
const planned = [{ viewport: "pc", role: "employee", route: "/portal" }, { viewport: "mobile-wecom", role: "employee", route: "/mobile" }];
return { planned, completed: planned.map(page => ({ ...page, actualRoute: page.route })), expectedGroups: ["pc:employee", "mobile-wecom:employee"], groups: [
{ id: "pc:employee", status: "passed" }, { id: "mobile-wecom:employee", status: "passed" },
] };
};
const fixtureProblems = (fixture: ReturnType<typeof coverageFixture>) =>
visualCoverageProblems(fixture.planned, fixture.completed, fixture.expectedGroups, fixture.groups);
test("视觉覆盖接受全部页面审完及全部组通过", () => {
expect(fixtureProblems(coverageFixture())).toEqual([]);
});
for (const [label, mutate, problem] of [
["缺页或只到达未审完", (fixture) => { fixture.completed.pop(); }, "missing-page"],
["重复完成", (fixture) => { fixture.completed.push(fixture.completed[0]); }, "duplicate-completion"],
["重复计划", (fixture) => { fixture.planned.push(fixture.planned[0]); }, "duplicate-plan"],
["错误角色", (fixture) => { fixture.completed[0].role = "admin"; }, "unexpected-page"],
["错误终端", (fixture) => { fixture.completed[0].viewport = "mobile-wecom"; }, "unexpected-page"],
["错误到达", (fixture) => { fixture.completed[0].actualRoute = "/login"; }, "wrong-arrival"],
["缺组", (fixture) => { fixture.groups.pop(); }, "incomplete-group"],
["重复组", (fixture) => { fixture.groups.push(fixture.groups[0]); }, "duplicate-group"],
["未知组", (fixture) => { fixture.groups.push({ id: "pc:admin", status: "passed" }); }, "unexpected-group"],
["失败组", (fixture) => { fixture.groups[0].status = "failed"; }, "incomplete-group"],
["跳过组", (fixture) => { fixture.groups[0].status = "skipped"; }, "incomplete-group"],
["未运行组", (fixture) => { fixture.groups[0].status = "pending"; }, "incomplete-group"],
] satisfies Array<[string, (fixture: ReturnType<typeof coverageFixture>) => void, string]>) {
test(`视觉覆盖拒绝${label}`, () => {
const fixture = coverageFixture();
mutate(fixture);
expect(fixtureProblems(fixture)).toContain(problem);
});
}
// A SPA can return HTTP 200 for a forbidden/not-found page. Those must not
// satisfy the same arrival guard used by the real route matrix.
for (const [label, route, html] of [
["404", "/system/users", "<h1>页面不存在</h1>"],
["403", "/system/users", "<h1>无权限访问</h1>"],
["wrong-page", "/system/users", "<h1>管理</h1>"],
["login-fallback", "/login", '<input data-testid="login-identifier"><h1>登录</h1>'],
]) {
test(`视觉到达门禁拒绝 ${label}`, async ({ page }) => {
await page.route("**/*", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: html }));
await page.goto(route);
// The synthetic page is final; a short assertion budget avoids waiting for
// a state that this counterexample intentionally never produces.
await expect(assertRouteArrival(page, "/system/users", 100)).rejects.toThrow(label === "login-fallback" ? /toHaveURL/ : label === "wrong-page" ? /toBeVisible/ : /toHaveCount/);
});
}
test("视觉到达门禁接受真实目标结构和匿名登录入口", async ({ page }) => {
await page.route("**/*", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: new URL(route.request().url()).pathname === "/login" ? '<input data-testid="login-identifier">' : "<h1>系统用户管理</h1>" }));
await page.goto("/system/users");
await assertRouteArrival(page, "/system/users", 100);
await page.goto("/login");
await assertRouteArrival(page, "/login", 100);
});
+11 -6
查看文件
@@ -53,7 +53,7 @@ async function ensureFeatureFlags(api: APIRequestContext, keys: string[]) {
test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async ({ page }) => {
const adminClient = await createAuthenticatedApiClient(ADMIN);
const suffix = Date.now();
const today = new Date().toISOString().slice(0, 10);
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
await ensureFeatureFlags(adminClient.api, [
"portal_home_v1",
@@ -90,19 +90,24 @@ test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async
const communicationPanel = page.getByTestId("project-communication-panel");
await communicationPanel.getByTestId("project-communication-summary-input").fill(communicationSummary);
await communicationPanel.locator('input[type="date"]').nth(1).fill(today);
await communicationPanel.getByPlaceholder('例如 2026-05-25').nth(1).fill(today);
const savedCommunication = page.waitForResponse(response =>
response.request().method() === "POST" && new URL(response.url()).pathname === `/api/projects/${projectId}/communication-logs`);
await communicationPanel.getByTestId("project-communication-submit").click();
const saved = await savedCommunication;
expect(saved.ok()).toBe(true);
expect(saved.request().postDataJSON()).toMatchObject({ nextFollowUpDate: today });
await expect(communicationPanel.getByText(communicationSummary)).toBeVisible();
await expect(page.getByText(deliveryTitle)).toBeVisible();
await expect(page.getByTestId("project-closure-panel")).toContainText("确认交付/验收记录齐全");
const portal = await apiGetData<{ projectSummary: { followUpDueTodayCount: number } }>(adminClient.api, "/api/portal/home");
expect(portal.projectSummary.followUpDueTodayCount).toBeGreaterThan(0);
await page.goto("/portal");
await expect(page.getByRole("heading", { name: "你好,Admin" })).toBeVisible();
await page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" }).click();
await expect(page.getByRole("heading", { name: "项目跟进与组织协同" })).toBeVisible();
await expect(page.getByRole("link", { name: /项目风险/ })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日待处理", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: /今日项目跟进/ })).toBeVisible();
await adminClient.dispose();
});
@@ -19,9 +19,13 @@ type RequestLedgerEntry = {
method: string;
path: string;
status: number;
time: number;
};
type WorkerEvent = { time: number; event: string; state: string | null; documentSha: string | null };
type BrowserWitnessState = {
events: WorkerEvent[];
controllerChanges: number;
updateReadyEvents: number;
};
@@ -29,6 +33,7 @@ type BrowserWitnessState = {
type CacheSnapshot = {
names: string[];
paths: string[];
indexSha256: string[];
};
type ServiceWorkerSnapshot = {
@@ -122,6 +127,7 @@ function recordResponse(request: IncomingMessage, pathname: string, status: numb
method: request.method ?? "GET",
path: pathname,
status,
time: Date.now(),
});
}
@@ -377,19 +383,42 @@ async function installWitnessObservers(context: BrowserContext) {
const readState = (): BrowserWitnessState => {
try {
const current = window.sessionStorage.getItem(storageKey);
return current ? JSON.parse(current) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0 };
return current ? JSON.parse(current) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0, events: [] };
} catch {
return { controllerChanges: 0, updateReadyEvents: 0 };
return { controllerChanges: 0, updateReadyEvents: 0, events: [] };
}
};
const writeState = (state: BrowserWitnessState) => {
window.sessionStorage.setItem(storageKey, JSON.stringify(state));
};
writeState(readState());
const record = (event: string, worker?: ServiceWorker | null) => {
const state = readState();
state.events.push({ time: Date.now(), event, state: worker?.state ?? null,
documentSha: document.documentElement?.dataset.buildSha ?? null });
state.events = state.events.slice(-100);
writeState(state);
};
record("document-start", window.navigator.serviceWorker.controller);
document.addEventListener("DOMContentLoaded", () => record("document-ready", window.navigator.serviceWorker.controller));
const watched = new WeakSet<ServiceWorker>();
const watch = (worker: ServiceWorker | null, role: string) => {
if (!worker || watched.has(worker)) return;
watched.add(worker);
record(role, worker);
worker.addEventListener("statechange", () => record("worker-state", worker));
};
void window.navigator.serviceWorker.getRegistration().then((registration) => {
if (!registration) return;
watch(registration.active, "active-observed");
watch(registration.installing, "installing-observed");
registration.addEventListener("updatefound", () => watch(registration.installing, "update-found"));
});
window.navigator.serviceWorker.addEventListener("controllerchange", () => {
const state = readState();
state.controllerChanges += 1;
writeState(state);
record("controller-change", window.navigator.serviceWorker.controller);
});
window.addEventListener("hyb-oa:pwa-update-ready", () => {
const state = readState();
@@ -402,7 +431,7 @@ async function installWitnessObservers(context: BrowserContext) {
async function readWitnessState(page: Page): Promise<BrowserWitnessState> {
return page.evaluate((storageKey) => {
const value = window.sessionStorage.getItem(storageKey);
return value ? JSON.parse(value) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0 };
return value ? JSON.parse(value) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0, events: [] };
}, WITNESS_STATE_KEY);
}
@@ -410,13 +439,22 @@ async function readCacheSnapshot(page: Page): Promise<CacheSnapshot> {
return page.evaluate(async () => {
const names = await window.caches.keys();
const paths = new Set<string>();
const indexSha256: string[] = [];
for (const name of names) {
const cache = await window.caches.open(name);
for (const request of await cache.keys()) {
paths.add(new URL(request.url).pathname);
const pathname = new URL(request.url).pathname;
paths.add(pathname);
if (pathname === "/index.html") {
const response = await cache.match(request);
if (response) {
const digest = await window.crypto.subtle.digest("SHA-256", await response.arrayBuffer());
indexSha256.push(Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""));
}
}
}
}
return { names, paths: [...paths].sort() };
return { names, paths: [...paths].sort(), indexSha256 };
});
}
@@ -433,7 +471,7 @@ async function controllerPath(page: Page) {
async function serviceWorkerSnapshot(page: Page): Promise<ServiceWorkerSnapshot> {
return page.evaluate(async () => {
const registration = await window.navigator.serviceWorker.ready;
const registration = await window.navigator.serviceWorker.getRegistration();
const controller = window.navigator.serviceWorker.controller;
const response = await window.fetch("/sw.js", { cache: "no-store" });
if (!response.ok) {
@@ -441,13 +479,19 @@ async function serviceWorkerSnapshot(page: Page): Promise<ServiceWorkerSnapshot>
}
const digest = await window.crypto.subtle.digest("SHA-256", await response.arrayBuffer());
return {
controllerIsActive: controller !== null && registration.active === controller,
controllerIsActive: controller !== null && registration?.active === controller,
controllerState: controller?.state ?? null,
fetchedSha256: Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""),
};
});
}
async function loadedAssets(page: Page) {
return page.evaluate(() => performance.getEntriesByType("resource")
.filter((entry) => (entry as PerformanceResourceTiming).responseEnd > 0)
.map((entry) => new URL(entry.name).pathname));
}
function requestCount(build: BuildLabel, pathname: string, status = 200) {
return requestLedger.filter((entry) => entry.build === build && entry.path === pathname && entry.status === status).length;
}
@@ -457,15 +501,16 @@ function safeLedger(input: {
documentSha: string | null;
controller: string | null;
cacheNames: string[];
aOnlyCached: number;
bOnlyCached: number;
bOnlyLoaded: number;
aOnlyCached: number | null;
bOnlyCached: number | null;
bOnlyLoaded: number | null;
navigationCount: number;
witnessState: BrowserWitnessState;
serviceWorker: ServiceWorkerSnapshot;
witnessState: BrowserWitnessState | null;
serviceWorker: ServiceWorkerSnapshot | null;
}) {
return {
phase: input.phase,
workerEvents: input.witnessState?.events ?? null,
documentSha: input.documentSha,
controller: input.controller,
cacheNames: input.cacheNames.sort(),
@@ -473,16 +518,16 @@ function safeLedger(input: {
bOnlyCached: input.bOnlyCached,
bOnlyLoaded: input.bOnlyLoaded,
navigationCount: input.navigationCount,
controllerChanges: input.witnessState.controllerChanges,
controllerIsActive: input.serviceWorker.controllerIsActive,
controllerState: input.serviceWorker.controllerState,
updateReadyEvents: input.witnessState.updateReadyEvents,
controllerChanges: input.witnessState?.controllerChanges ?? null,
controllerIsActive: input.serviceWorker?.controllerIsActive ?? null,
controllerState: input.serviceWorker?.controllerState ?? null,
updateReadyEvents: input.witnessState?.updateReadyEvents ?? null,
refresh200: requestCount(activeBuild.label, "/api/auth/refresh"),
me200: requestCount(activeBuild.label, "/api/me"),
loginNavigations: requestLedger.filter((entry) => entry.path === "/login").length,
static404: requestLedger.filter((entry) => entry.status === 404 && /\.(?:css|js)$/u.test(entry.path)).length,
swSha256Prefix: activeBuild.swSha256.slice(0, 12),
fetchedSwSha256Matches: input.serviceWorker.fetchedSha256 === activeBuild.swSha256,
fetchedSwSha256Matches: input.serviceWorker ? input.serviceWorker.fetchedSha256 === activeBuild.swSha256 : null,
};
}
@@ -576,11 +621,7 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a
const cacheB = await readCacheSnapshot(pageB);
const finalAOnly = [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath));
const finalBOnly = [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath));
const loadedAssetPaths = await pageB.evaluate(() => [
...Array.from(document.querySelectorAll<HTMLScriptElement>("script[src]"), (element) => new URL(element.src).pathname),
...Array.from(document.querySelectorAll<HTMLLinkElement>("link[href]"), (element) => new URL(element.href).pathname),
...performance.getEntriesByType("resource").map((entry) => new URL(entry.name).pathname),
]);
const loadedAssetPaths = await loadedAssets(pageB);
const loadedBOnly = [...bOnlyAssets].filter((assetPath) => loadedAssetPaths.includes(assetPath));
const stateB = await readWitnessState(pageB);
workerB = await serviceWorkerSnapshot(pageB);
@@ -593,7 +634,8 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a
fetchedSha256: buildB.swSha256,
});
expect(finalAOnly).toEqual([]);
expect(finalBOnly.length + loadedBOnly.length).toBeGreaterThan(0);
expect(finalBOnly.length).toBeGreaterThan(0);
expect(loadedBOnly.length).toBeGreaterThan(0);
expect(pageB.url()).not.toContain("/login");
expect(requestLedger.filter((entry) => entry.status === 404 && /\.(?:css|js)$/u.test(entry.path))).toEqual([]);
expect(pageErrors).toEqual([]);
@@ -614,21 +656,24 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a
serviceWorker: workerB,
})));
} catch (error) {
const cacheB = await readCacheSnapshot(pageB).catch(() => ({ names: [], paths: [] }));
const stateB = await readWitnessState(pageB).catch(() => ({ controllerChanges: 0, updateReadyEvents: 0 }));
const frozenWorkerB = workerB ?? {
controllerIsActive: false,
controllerState: null,
fetchedSha256: "",
};
const cacheB = await readCacheSnapshot(pageB).catch(() => null);
const stateB = await readWitnessState(pageB).catch(() => null);
const frozenWorkerB = await serviceWorkerSnapshot(pageB).catch(() => null);
const loadedPaths = await loadedAssets(pageB).catch(() => null);
console.log("[pwa-cross-version-diagnostic]", JSON.stringify({
time: Date.now(), pageErrors, chunkErrors,
cacheObserved: cacheB !== null,
cachedIndexBuilds: cacheB?.indexSha256.map((digest) => digest === sha256(readFileSync(path.join(buildA.root, "index.html"))) ? "A" : digest === sha256(readFileSync(path.join(buildB.root, "index.html"))) ? "B" : "unknown") ?? null,
documentResponses: requestLedger.filter((entry) => ["/portal", "/index.html", "/sw.js"].includes(entry.path)),
}));
console.log("[pwa-cross-version]", JSON.stringify(safeLedger({
phase: "B_FIRST_FAILURE_FROZEN",
documentSha: await documentBuildSha(pageB).catch(() => null),
controller: await controllerPath(pageB).catch(() => null),
cacheNames: cacheB.names,
aOnlyCached: [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length,
bOnlyCached: [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length,
bOnlyLoaded: 0,
cacheNames: cacheB?.names ?? [],
aOnlyCached: cacheB ? [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length : null,
bOnlyCached: cacheB ? [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length : null,
bOnlyLoaded: loadedPaths ? [...bOnlyAssets].filter((assetPath) => loadedPaths.includes(assetPath)).length : null,
navigationCount: bNavigations,
witnessState: stateB,
serviceWorker: frozenWorkerB,
@@ -252,7 +252,7 @@ test.describe("R100 design baseline diagnostics", () => {
await login(page);
await page.goto("/mobile");
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: "今日待处理", exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
await attachDiagnostics(page, testInfo, "r100-mobile-home");
@@ -1,5 +1,8 @@
import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
// Keep API fixtures authoritative across full navigation in the production PWA.
test.use({ serviceWorkers: "block" });
function envelope(data: unknown) {
return { requestId: "mock-r101-mobile", data };
}
@@ -370,13 +373,23 @@ test.describe("R101 mobile WIP closeout", () => {
test("移动首页和我的页收口", async ({ page }, testInfo) => {
await page.goto("/mobile");
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: "今日待处理", exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
await expect(page.locator("aside")).toHaveCount(0);
await assertNoMobileDesignRegression(page);
await attachScreenshot(page, testInfo, "r101-mobile-home.png");
await page.goto("/mobile/me");
const [refreshResponse, meResponse] = await Promise.all([
page.waitForResponse((response) => new URL(response.url()).pathname === "/api/auth/refresh"),
page.waitForResponse((response) => new URL(response.url()).pathname === "/api/me"),
page.goto("/mobile/me"),
]);
for (const response of [refreshResponse, meResponse]) {
expect(response.status()).toBe(200);
expect(response.fromServiceWorker()).toBe(false);
expect((await response.json()).requestId).toBe("mock-r101-mobile");
}
await expect(page).toHaveURL(/\/mobile\/me$/);
await expect(page.getByRole("heading", { name: "我的" })).toBeVisible();
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
await expect(page.getByText("低频模块放在这里")).toHaveCount(0);
@@ -386,7 +399,7 @@ test.describe("R101 mobile WIP closeout", () => {
test("移动报销页使用卡片入口", async ({ page }, testInfo) => {
await page.goto("/mobile/reimbursements");
await expect(page.getByRole("heading", { name: "报销" })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: "移动报销", exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursements-create")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
const card = page.getByTestId("mobile-reimbursement-card").first();
@@ -400,7 +413,7 @@ test.describe("R101 mobile WIP closeout", () => {
test("移动请假页默认只看记录并提示企微主办", async ({ page }, testInfo) => {
await page.goto("/mobile/leave");
await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible();
await expect(page.getByText("企业微信主办")).toBeVisible();
await expect(page.getByText("企业微信主办", { exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-leave-records")).toBeVisible();
await expect(page.getByTestId("mobile-leave-record-card")).toBeVisible();
@@ -366,7 +366,7 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByText(reimbursementId)).toHaveCount(0);
await page.getByRole("button", { name: "更多" }).first().click();
await page.getByRole("menuitem", { name: "删除草稿" }).click();
await page.getByRole("menuitem", { name: "删除报销单", exact: true }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber);
await page.getByTestId("action-confirm-submit").click();
await expect(page.getByTestId("reimbursement-row")).toHaveCount(0);
@@ -387,10 +387,12 @@ test.describe("R102 reimbursement main flow", () => {
await page.getByTestId("reimbursement-more-toggle").click();
await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible();
await expect(page.getByTestId("reimbursement-secondary-payment-status")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
await page.getByTestId("reimbursement-invoice-add").click();
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
await expect(page.getByTestId("reimbursement-invoice-dialog")).toContainText("添加后自动生成或匹配报销明细");
await expect(page.getByTestId("reimbursement-invoice-dialog")).toContainText("选择可添加发票,或上传新发票");
await expect(page.getByText("绑定整单")).toHaveCount(0);
await expect(page.getByText("不绑定明细")).toHaveCount(0);
await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveCount(0);
@@ -405,7 +407,7 @@ test.describe("R102 reimbursement main flow", () => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === `/api/invoice-pool/${invoiceId}/bind` && response.ok();
});
await page.getByRole("button", { name: "添加选中发票" }).click();
await page.getByRole("button", { name: "添加到报销单", exact: true }).click();
await bindResponsePromise;
await expect(page.getByTestId("reimbursement-invoice-dialog")).toHaveCount(0);
});
@@ -1,4 +1,4 @@
import { mkdirSync } from "node:fs";
import { mkdirSync, mkdtempSync } from "node:fs";
import path from "node:path";
import { expect, test, type Page, type Route } from "@playwright/test";
@@ -16,7 +16,9 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
});
}
const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r103");
const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(process.cwd(), "../output/playwright"));
mkdirSync(visualRoot, { recursive: true });
const screenshotDir = mkdtempSync(path.join(visualRoot, "r103-"));
function screenshotPath(fileName: string) {
mkdirSync(screenshotDir, { recursive: true });
@@ -659,7 +661,8 @@ async function expectLedgerHeaderActions(
const actionRegion = header.locator(":scope > .page-header-shell__actions");
if (target.expectedActions.length === 0) {
await expect(actionRegion).toHaveCount(0);
// Some workspaces retain an empty shared action slot; neither may expose a control.
await expect(actionRegion.locator("button, a")).toHaveCount(0);
return;
}
@@ -695,7 +698,7 @@ test.describe("R103 cross page UI consistency", () => {
{ path: "/contracts-registry", heading: "合同", expectedActions: ["新增合同"] },
{ path: "/projects", heading: "项目", expectedActions: ["新建项目"] },
{ path: "/assets", heading: "资产", expectedActions: ["新增资产", "更多"] },
{ path: "/payment-batches", heading: "付款", expectedActions: ["新建批次"] },
{ path: "/payment-batches", heading: "付款", expectedActions: [] },
{ path: "/ap-operations", heading: "应付账款", expectedActions: [] },
{ path: "/invoice-pool", heading: "票据", expectedActions: ["上传票据"] },
];
@@ -951,7 +954,8 @@ test.describe("R103 cross page UI consistency", () => {
expect(failedResponse.status()).toBe(400);
await expect(page).toHaveURL(/\/assets\/new$/);
await expect(page.getByTestId("asset-name")).toHaveValue("合成失败资产");
await expect(page.getByRole("main").getByText("资产保存失败,请检查填写内容。", { exact: true })).toBeVisible();
await expect(page.getByRole("main").getByText("操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。 排障编号:mock-r103-asset-create-failure", { exact: true })).toBeVisible();
await expect(page.getByText("资产保存失败,请检查填写内容。", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "返回资产列表", exact: true }).click();
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存");
+5 -3
查看文件
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import path from "node:path";
import { expect, test, type BrowserContext, type Locator, type Page } from "@playwright/test";
@@ -42,8 +42,10 @@ type PageMetrics = {
};
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r106");
const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/r106-visual-audit");
const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(ROOT_DIR, "output/playwright"));
mkdirSync(visualRoot, { recursive: true });
const SCREENSHOT_DIR = mkdtempSync(path.join(visualRoot, "r106-"));
const ARTIFACT_DIR = path.join(SCREENSHOT_DIR, "report");
const WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.0 MicroMessenger/8.0.37 Language/zh_CN";
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
@@ -1,15 +1,17 @@
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { expect, test, type Browser, type Page } from "@playwright/test";
import {
EMPLOYEE,
MANAGER,
createAuthenticatedApiClient,
apiGetData,
apiPostData,
type AuthenticatedApiClient,
type E2EUser,
} from "./helpers/auth";
import { createSubmittedTravelRequestViaApi } from "./helpers/travel";
import { waitForWorkflowTask } from "./helpers/workflow";
type Finding = {
@@ -44,8 +46,12 @@ type MobileMetrics = {
};
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r108");
const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/r108-mobile-wecom-visual");
const RUN_DIR = process.env.QA_VISUAL_OUTPUT_DIR
? path.resolve(process.env.QA_VISUAL_OUTPUT_DIR, "r108")
: path.join(ROOT_DIR, "artifacts/qa-layout/r108-runs", `${Date.now()}-${process.pid}`);
const SCREENSHOT_DIR = path.join(RUN_DIR, "screenshots");
const ARTIFACT_DIR = path.join(RUN_DIR, "reports");
const requireServer = createRequire(new URL("../../../server/package.json", import.meta.url));
const WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
@@ -64,7 +70,7 @@ function artifactPath(fileName: string) {
}
function redact(value: string) {
return value.replace(UUID_RE, "[uuid]");
return value.replaceAll(ROOT_DIR, "<worktree>").replace(UUID_RE, "[uuid]");
}
function addFinding(finding: Finding) {
@@ -96,43 +102,36 @@ async function newWecomPage(browser: Browser, viewport: { width: number; height:
return { context, page: await context.newPage() };
}
async function firstEnabledExpenseTypeId(client: AuthenticatedApiClient) {
const response = await client.api.get("/api/expense-types?enabled=true");
const payload = await response.json() as {
data?: { items?: Array<{ id: string; enabled?: boolean }> };
};
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0];
if (!response.ok() || !item?.id) {
throw new Error(`无法读取可用费用类型:HTTP ${response.status()}`);
}
return item.id;
async function syntheticPdf(label: string) {
const document = await requireServer("pdf-lib").PDFDocument.create();
document.addPage().drawText(`Synthetic R108 ${label} ${Date.now()}`, { x: 40, y: 700 });
return Buffer.from(await document.save());
}
async function seedEmployeeMobileData() {
const client = await createAuthenticatedApiClient(EMPLOYEE);
try {
const expenseTypeId = await firstEnabledExpenseTypeId(client);
const reimbursementResponse = await client.api.post("/api/reimbursements", {
data: { description: "R108 移动端巡检报销草稿" },
const expenseDate = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date());
const types = await apiGetData<{ items: Array<{ id: string; code: string; enabled: boolean }> }>(client.api, "/api/expense-types?enabled=true");
const expenseType = types.items.find(item => item.enabled && item.code === "office_postal");
expect(expenseType, "R108 requires the active synthetic postal expense type").toBeDefined();
const reimbursement = await apiPostData<{ id: string }>(client.api, "/api/reimbursements", {
description: "R108 移动审批合成报销", reimbursementDate: expenseDate,
});
const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } };
if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) {
throw new Error(`创建移动端报销草稿失败:HTTP ${reimbursementResponse.status()}`);
}
const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementPayload.data.id}/items`, {
data: {
expenseTypeId,
expenseDate: "2026-05-07",
amount: "88.80",
description: "R108 移动巡检明细",
},
const item = await apiPostData<{ id: string }>(client.api, `/api/reimbursements/${reimbursement.id}/items`, {
expenseTypeId: expenseType!.id, expenseDate, amount: "10.00", description: "R108 合成邮寄费",
});
if (!itemResponse.ok()) {
throw new Error(`创建移动端报销明细失败:HTTP ${itemResponse.status()}`);
}
return { reimbursementId: reimbursementPayload.data.id };
const uploaded = await client.api.post("/api/invoice-pool/upload", {
multipart: { file: { name: "r108-approval.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("approval") }, ocr_type: "general" },
});
expect(uploaded.ok(), `R108 upload HTTP ${uploaded.status()}`).toBe(true);
const invoice = await uploaded.json() as { data: { invoicePoolId: string } };
await expect.poll(async () => (await apiGetData<{ status: string }>(client.api, `/api/invoice-pool/${invoice.data.invoicePoolId}`)).status, { timeout: 90_000 }).toBe("available");
await apiPostData(client.api, `/api/invoice-pool/${invoice.data.invoicePoolId}/bind`, { reimbursementId: reimbursement.id, linkedItemId: item.id });
const submitted = await apiPostData<{ reimbursementId: string; formNumber: string; currentTaskId: string }>(client.api, `/api/reimbursements/${reimbursement.id}/submit`, {});
const denied = await client.api.post(`/api/workflow/tasks/${submitted.currentTaskId}/approve`, { data: {} });
expect(denied.status(), "员工不可审批分配给经理的任务").toBe(403);
return submitted;
} finally {
await client.dispose();
}
@@ -370,9 +369,9 @@ async function auditMobilePage(page: Page, options: MobileAuditOptions): Promise
return metrics;
}
function writeReport() {
function writeReport(completed: boolean) {
const generatedAt = new Date().toISOString();
const report = { generatedAt, checks, findings };
const report = { generatedAt, status: completed && findings.length === 0 ? "PASS" : "INCOMPLETE_OR_FAILED", checks, findings };
writeFileSync(artifactPath("report.json"), redact(JSON.stringify(report, null, 2)));
const lines = [
@@ -382,9 +381,10 @@ function writeReport() {
"",
"## 结论",
"",
`- 完整执行:${completed ? "是" : "否(以浏览器失败为准)"}`,
`- 巡检记录数:${checks.length}`,
`- 发现问题数:${findings.length}`,
`- 截图目录:${SCREENSHOT_DIR}`,
`- 截图目录:${path.relative(ROOT_DIR, SCREENSHOT_DIR)}`,
"",
"## 发现的问题",
"",
@@ -412,33 +412,21 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
test.skip(process.env.E2E_MODE === "live", "R108 本地移动专项只在临时本地环境运行");
test("员工移动页面、票据上传队列与经理移动审批可巡检", async ({ browser }) => {
let travel: Awaited<ReturnType<typeof createSubmittedTravelRequestViaApi>> | null = null;
let completed = false;
const contexts: Array<Awaited<ReturnType<typeof newWecomPage>>["context"]> = [];
let managerClient: AuthenticatedApiClient | null = null;
try {
try {
await seedEmployeeMobileData();
travel = await createSubmittedTravelRequestViaApi({
user: EMPLOYEE,
purposePrefix: "R108移动审批视觉",
amount: "1098.00",
});
managerClient = await createAuthenticatedApiClient(MANAGER);
} catch (error) {
test.skip(true, `R108 初始化 API 不可用,跳过本地视觉巡检:${error instanceof Error ? error.message : String(error)}`);
return;
}
if (!travel || !managerClient) {
test.skip(true, "R108 初始化未产生必要测试数据,跳过本地视觉巡检。");
return;
}
const reimbursement = await seedEmployeeMobileData();
managerClient = await createAuthenticatedApiClient(MANAGER);
const task = await waitForWorkflowTask(managerClient.api, {
entityId: travel.travelRequest.requestId,
entityType: "travel_request",
entityId: reimbursement.reimbursementId,
entityType: "reimbursement",
});
expect(task.id).toBe(reimbursement.currentTaskId);
const employee390 = await newWecomPage(browser, { width: 390, height: 844 });
contexts.push(employee390.context);
await loginForMobile(employee390.page, EMPLOYEE);
await auditMobilePage(employee390.page, { label: "企微 390-首页", route: "/mobile", screenshotName: "mobile-390-home" });
await auditMobilePage(employee390.page, { label: "企微 390-报销", route: "/mobile/reimbursements", screenshotName: "mobile-390-reimbursements" });
@@ -451,8 +439,8 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
await employee390.page.evaluate(() => window.dispatchEvent(new Event("offline")));
await expect(employee390.page.getByText("离线", { exact: true })).toBeVisible();
await employee390.page.getByTestId("mobile-capture-pdf-file").setInputFiles([
{ name: "r108-mobile-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
{ name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
{ name: "r108-mobile-a.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("queue-a") },
{ name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("queue-b") },
]);
await expect(employee390.page.getByText("已选择 2 个")).toBeVisible();
await employee390.page.getByTestId("mobile-capture-upload").click();
@@ -466,33 +454,43 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
await employee390.context.close();
const employee414 = await newWecomPage(browser, { width: 414, height: 896 });
contexts.push(employee414.context);
await loginForMobile(employee414.page, EMPLOYEE);
await auditMobilePage(employee414.page, { label: "企微 414-首页", route: "/mobile", screenshotName: "mobile-414-home" });
await auditMobilePage(employee414.page, { label: "企微 414-票据上传", route: "/mobile/capture", screenshotName: "mobile-414-capture" });
await employee414.context.close();
const manager390 = await newWecomPage(browser, { width: 390, height: 844 });
contexts.push(manager390.context);
await loginForMobile(manager390.page, MANAGER);
await manager390.page.goto(`/mobile/approvals?taskId=${encodeURIComponent(task.id)}`);
await expect(manager390.page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
await expect(manager390.page.getByTestId("mobile-approvals-detail")).toContainText(travel.travelRequest.requestNo);
await expect(manager390.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible({ timeout: 30_000 });
await expect(manager390.page.getByTestId("reimbursement-mobile-workspace")).toContainText(reimbursement.formNumber);
await expect(manager390.page.getByTestId("mobile-approvals-approve")).toBeEnabled();
await expect(manager390.page.getByTestId("mobile-approvals-reject")).toBeEnabled();
await expect(manager390.page.getByTestId("mobile-approvals-reject")).toHaveCount(0);
await expect(manager390.page.getByTestId("mobile-approvals-return")).toBeEnabled();
await manager390.page.getByTestId("mobile-approvals-return").click();
await manager390.page.getByTestId("mobile-approvals-comment").fill("R108 移动审批操作区验证");
await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail" });
await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail", skipNavigation: true });
await manager390.page.getByTestId("mobile-approvals-approve").click();
await expect(manager390.page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
const approval = manager390.page.waitForResponse(response => new URL(response.url()).pathname === `/api/workflow/tasks/${task.id}/approve` && response.request().method() === "POST");
await manager390.page.getByTestId("workflow-action-confirm-submit").click();
await expect(manager390.page.getByText("操作成功")).toBeVisible({ timeout: 30_000 });
expect((await approval).ok()).toBe(true);
const processed = await apiGetData<{ task: { status: string; outcome: string } }>(managerClient.api, `/api/workflow/tasks/${task.id}`);
expect(processed.task.status).toBe("completed");
expect(processed.task.outcome).toBe("approved");
await expect(manager390.page.getByText("已通过", { exact: true })).toBeVisible({ timeout: 30_000 });
await manager390.context.close();
writeReport();
if (process.env.R108_MOBILE_ALLOW_FINDINGS !== "1") {
{
const summary = findings.map((finding) => `${finding.severity} ${finding.page} / ${finding.category}: ${finding.title}`).join("\n");
expect(findings, summary || "R108 mobile visual findings should be empty").toEqual([]);
}
completed = true;
} finally {
await Promise.all([travel?.client.dispose(), managerClient?.dispose()]);
writeReport(completed);
await Promise.all([...contexts.map(context => context.close()), managerClient?.dispose()]);
}
});
});
@@ -1,4 +1,4 @@
import { mkdirSync } from "node:fs";
import { mkdirSync, mkdtempSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page } from "@playwright/test";
@@ -10,7 +10,9 @@ import {
} from "./helpers/auth";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r109-mobile-feedback");
const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(ROOT_DIR, "output/playwright"));
mkdirSync(visualRoot, { recursive: true });
const SCREENSHOT_DIR = mkdtempSync(path.join(visualRoot, "r109-"));
const WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
@@ -178,16 +180,25 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
},
]);
await employee.page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect(employee.page.getByTestId("reimbursement-invoice-upload-submit")).toHaveText("上传发票", {
timeout: 20_000,
});
const invoiceDialog = employee.page.getByTestId("reimbursement-invoice-dialog");
await expect(invoiceDialog.getByRole("checkbox", { checked: true })).toHaveCount(1, { timeout: 90_000 });
const target = invoiceDialog.getByRole("combobox", { name: "添加到哪条明细" });
const existingItems = target.locator('option:not([value=""])');
await expect(existingItems).toHaveCount(1);
await target.selectOption((await existingItems.getAttribute("value"))!);
await invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true }).click();
await expect(invoiceDialog.getByText("已添加 1 张发票", { exact: true })).toBeVisible();
await invoiceDialog.getByRole("button", { name: "返回报销单", exact: true }).click();
await expect(invoiceDialog).toHaveCount(0);
await expect(employee.page.getByTestId("approval-invoice-evidence")).toHaveCount(1);
await employee.page.getByTestId("reimbursement-invoice-add").click();
await employee.page.getByRole("button", { name: "已有发票" }).click();
await expect(employee.page.getByTestId("mobile-reimbursement-invoice-search")).toBeVisible();
await employee.page.screenshot({ path: screenshotPath("mobile-invoice-dialog") });
await employee.page.goto("/mobile/leave?view=form");
await expect(employee.page.getByTestId("mobile-leave-form")).toBeVisible();
await expect(employee.page.getByTestId("mobile-leave-form")).toHaveCount(0);
await expect(employee.page.getByText("日常请假、补卡、外出和加班在企业微信完成;OA 这里只查看记录、额度和历史状态。", { exact: true })).toBeVisible();
await employee.page.screenshot({ path: screenshotPath("mobile-leave") });
const leaveMetrics = await collectMobileMetrics(employee.page);
expect(leaveMetrics.navHeight).toBeLessThanOrEqual(68);
@@ -145,7 +145,8 @@ test.describe.serial("R110 手机和 PC 路由分流", () => {
await mobile.page.goto("/leave-requests?action=create");
await expect(mobile.page).toHaveURL(/\/mobile\/leave\?view=form$/);
await expect(mobile.page.getByTestId("mobile-leave-form")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
await expect(mobile.page.getByText("日常请假、补卡、外出和加班在企业微信完成;OA 这里只查看记录、额度和历史状态。", { exact: true })).toBeVisible();
await expectNoPcShell(mobile.page);
await mobile.page.goto("/invoice-pool");
@@ -596,7 +596,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await draftCard.locator("summary").click();
await draftCard.getByTestId("mobile-reimbursement-delete").click();
await expect(mobile.page.getByRole("dialog")).toBeVisible();
await mobile.page.getByRole("button", { name: "删除草稿" }).last().click();
await mobile.page.getByRole("dialog").getByRole("button", { name: "删除报销单", exact: true }).click();
await expect(mobile.page.getByTestId("mobile-reimbursement-card").filter({ hasText: "R124 删除草稿" })).toHaveCount(0);
await mobile.page.screenshot({ path: screenshotPath("mobile-reimbursements-after-delete") });
await expectMobilePageContract(mobile.page);
@@ -1,9 +1,12 @@
import { mkdirSync } from "node:fs";
import { mkdirSync, mkdtempSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r125-invoice-reupload-after-delete");
const SCREENSHOT_ROOT = process.env.QA_VISUAL_OUTPUT_DIR || path.join(ROOT_DIR, "output/playwright/r125-invoice-reupload-after-delete");
mkdirSync(SCREENSHOT_ROOT, { recursive: true });
const SCREENSHOT_DIR = mkdtempSync(path.join(SCREENSHOT_ROOT, "r125-"));
test.use({ serviceWorkers: "block" });
const WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
@@ -107,6 +110,8 @@ function buildState() {
invoices: [] as ReturnType<typeof buildBoundInvoice>[],
availableInvoices: [buildAvailableInvoice()],
uploadCount: 0,
archived: false,
bindRequests: [] as Array<{ id: string; reimbursementId?: string; linkedItemId?: string }>,
};
}
@@ -297,7 +302,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildState>) {
if (pathName === "/api/invoice-pool/upload") {
state.uploadCount += 1;
const invoicePoolId = state.uploadCount === 1 ? reusableInvoiceId : newInvoiceId;
const invoicePoolId = state.archived ? newInvoiceId : reusableInvoiceId;
state.availableInvoices = [...state.availableInvoices.filter((item) => item.id !== invoicePoolId), { ...buildAvailableInvoice(), id: invoicePoolId }];
await fulfillJson(route, {
invoicePoolId,
status: "available",
@@ -311,7 +317,10 @@ async function mockApis(page: Page, state: ReturnType<typeof buildState>) {
const bindMatch = pathName.match(/^\/api\/invoice-pool\/([^/]+)\/bind$/);
if (bindMatch) {
const payload = request.postDataJSON() as { reimbursementId?: string; linkedItemId?: string };
state.bindRequests.push({ id: bindMatch[1], ...payload });
state.invoices = [buildBoundInvoice(bindMatch[1])];
state.availableInvoices = state.availableInvoices.filter((item) => item.id !== bindMatch[1]);
await fulfillJson(route, {
id: bindMatch[1],
status: "bound",
@@ -339,6 +348,7 @@ async function mockApis(page: Page, state: ReturnType<typeof buildState>) {
if (pathName === `/api/invoice-pool/${archivedInvoiceId}/archive`) {
state.availableInvoices = [];
state.archived = true;
await fulfillJson(route, {
id: archivedInvoiceId,
status: "archived",
@@ -385,29 +395,36 @@ test.describe("R125 发票删除后重传", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-invoice-add").click();
await page.getByLabel("新发票添加位置").selectOption("item-r125");
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
name: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r125 invoice"),
});
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 });
await page.keyboard.press("Escape");
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
await expect(page.getByText("发票上传完成:1 张已入我的发票,请选择后添加")).toBeVisible({ timeout: 10_000 });
expect(state.bindRequests).toHaveLength(0);
await page.getByRole("button", { name: "添加到报销单", exact: true }).click();
await expect(page.locator(`#reimbursement-invoice-${reusableInvoiceId}`)).toBeVisible();
expect(state.bindRequests).toEqual([{ id: reusableInvoiceId, reimbursementId, linkedItemId: "item-r125" }]);
await page.getByRole("button", { name: "移除" }).first().click();
await page.locator(`#reimbursement-invoice-${reusableInvoiceId}`).getByRole("button", { name: "移除对应关系", exact: true }).click();
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toHaveCount(0);
await page.getByTestId("reimbursement-invoice-add").click();
await page.getByLabel("新发票添加位置").selectOption("item-r125");
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
name: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r125 invoice"),
});
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 });
await page.keyboard.press("Escape");
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
await expect(page.getByText("发票上传完成:1 张已入我的发票,请选择后添加")).toBeVisible({ timeout: 10_000 });
expect(state.bindRequests).toHaveLength(1);
await page.getByRole("button", { name: "添加到报销单", exact: true }).click();
await expect(page.locator(`#reimbursement-invoice-${reusableInvoiceId}`)).toBeVisible();
expect(state.bindRequests).toHaveLength(2);
expect(state.bindRequests[1]).toEqual({ id: reusableInvoiceId, reimbursementId, linkedItemId: "item-r125" });
await expectNoRawReimbursementCodes(page);
await page.screenshot({ path: screenshotPath("pc-reupload-after-unbind.png"), fullPage: true });
});
@@ -431,16 +448,20 @@ test.describe("R125 发票删除后重传", () => {
await expect(availableInvoiceCard).toHaveCount(0);
await invoiceDialog.getByRole("button", { name: "上传" }).click();
await mobile.page.getByLabel("新发票添加位置").selectOption("item-r125");
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
name: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r125 invoice"),
});
await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect(
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText(/1 张已添加|已添加 1 张发票/),
).toBeVisible({ timeout: 10_000 });
await mobile.page.keyboard.press("Escape");
await expect(invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true })).toBeEnabled();
expect(state.bindRequests).toHaveLength(0);
await invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true }).click();
await expect(invoiceDialog.getByText("已添加 1 张发票", { exact: true })).toBeVisible();
expect(state.archived).toBe(true);
expect(state.bindRequests).toEqual([{ id: newInvoiceId, reimbursementId, linkedItemId: "item-r125" }]);
await invoiceDialog.getByRole("button", { name: "返回报销单", exact: true }).click();
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
await mobile.page.screenshot({ path: screenshotPath("mobile-reupload-after-archive.png"), fullPage: true });
} finally {
@@ -627,7 +627,7 @@ test.describe("MUD6 专用移动页面统一设计", () => {
await expect(page.getByTestId(mobilePage.marker)).toBeVisible();
if (mobilePage.key === "leave") {
await expect(page.getByTestId("mobile-leave-form")).toHaveCount(0);
await expect(page.getByText("企业微信主办")).toBeVisible();
await expect(page.getByText("企业微信主办", { exact: true })).toBeVisible();
}
if (mobilePage.key === "approvals") {
await expect(page.getByText("批量")).toHaveCount(0);
@@ -1,4 +1,5 @@
import { type APIResponse } from "@playwright/test";
import { createRequire } from "node:module";
import { expect, test } from "./helpers/fixtures";
import {
@@ -55,6 +56,7 @@ type EmployeeSummary = {
type ExpenseTypeListResponse = {
items: Array<{
id: string;
code: string;
enabled: boolean;
name: string;
}>;
@@ -79,11 +81,7 @@ type SubmittedReimbursementSeed = {
type AuthedApi = Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"];
function formatDate(offsetDays: number) {
const value = new Date();
value.setUTCDate(value.getUTCDate() + offsetDays);
return value.toISOString().slice(0, 10);
}
const requireServer = createRequire(new URL("../../../server/package.json", import.meta.url));
async function readApiData<T>(response: APIResponse) {
const payload = (await response.json()) as { data?: T; error?: { code?: string; message?: string } };
@@ -118,7 +116,7 @@ async function ensureFeatureFlags(api: Awaited<ReturnType<typeof createAuthentic
}
async function createSubmittedReimbursement(api: AuthedApi, label: string): Promise<SubmittedReimbursementSeed> {
const reimbursementDate = formatDate(30 + Math.floor(Math.random() * 45));
const reimbursementDate = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date());
const description = `Playwright 通知补盲 ${label}`;
const created = await apiPostData<{ id: string }>(api, "/api/reimbursements", {
reimbursementDate,
@@ -126,25 +124,27 @@ async function createSubmittedReimbursement(api: AuthedApi, label: string): Prom
});
const expenseTypes = await apiGetData<ExpenseTypeListResponse>(api, "/api/expense-types?enabled=true");
const expenseTypeId = expenseTypes.items[0]?.id;
const expenseTypeId = expenseTypes.items.find((item) => item.enabled && item.code === "office_postal")?.id;
if (!expenseTypeId) {
throw new Error("未找到可用报销费用类型");
throw new Error("未找到合成通知用例必需的邮寄快递费用类型");
}
await apiPostData(api, `/api/reimbursements/${created.id}/items`, {
const item = await apiPostData<{ id: string }>(api, `/api/reimbursements/${created.id}/items`, {
expenseTypeId,
expenseDate: reimbursementDate,
amount: "1683.00",
amount: "10.00",
description,
});
const document = await requireServer("pdf-lib").PDFDocument.create();
document.addPage().drawText(`Synthetic R75 notification ${label} ${Date.now()}`, { x: 40, y: 700 });
const upload = await readApiData<InvoiceUploadResponse>(
await api.post("/api/invoice-pool/upload", {
multipart: {
file: {
name: `notify-${label}.pdf`,
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\nPW_R75_NOTIFY_${label}_${Date.now()}_${Math.random().toString(16).slice(2)}\n%%EOF\n`, "utf8"),
buffer: Buffer.from(await document.save()),
},
ocr_type: "general",
},
@@ -166,9 +166,14 @@ async function createSubmittedReimbursement(api: AuthedApi, label: string): Prom
await apiPostData(api, `/api/invoice-pool/${upload.invoicePoolId}/bind`, {
reimbursementId: created.id,
linkedItemId: item.id,
});
const submitted = await apiPostData<SubmittedReimbursementSeed>(api, `/api/reimbursements/${created.id}/submit`, {});
const response = await api.post(`/api/reimbursements/${created.id}/submit`, { data: {} });
const payload = await response.json() as { data?: SubmittedReimbursementSeed; error?: { code?: string; details?: { reasonCode?: string } } };
expect(response.ok(), JSON.stringify({ status: response.status(), code: payload.error?.code, reasonCode: payload.error?.details?.reasonCode, fields: Object.keys(payload.error?.details ?? {}) })).toBe(true);
expect(payload.data).toBeDefined();
const submitted = payload.data!;
return {
reimbursementId: submitted.reimbursementId,
formNumber: submitted.formNumber,
@@ -198,7 +203,7 @@ async function fetchEmployeeByKeyword(
const encoded = encodeURIComponent(keyword);
const payload = await apiGetData<{ items: EmployeeSummary[] }>(
api,
`/api/employees?page=1&pageSize=20&q=${encoded}`,
`/api/employees?page=1&pageSize=20&scope=management&q=${encoded}`,
);
return payload.items.find((item) => item.employeeNo === keyword || item.email === keyword) ?? null;
}
@@ -486,7 +486,7 @@ test.describe("R89 移动底部导航与核心页面", () => {
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-return")).toBeVisible();
await page.getByTestId("mobile-approvals-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
await page.getByTestId("workflow-action-confirm-cancel").click();
@@ -3,6 +3,9 @@ import { expect, test, type Page, type Route, type TestInfo } from "@playwright/
import { ADMIN } from "./helpers/auth";
import { TEST_IDS } from "./helpers/testIds";
// Route mocks must own requests; an installed worker bypasses page.route.
test.use({ serviceWorkers: "block" });
function envelope(data: unknown) {
return { requestId: "r92-mock", data };
}
@@ -23,6 +26,7 @@ async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) {
}
async function mockAuthenticatedShell(page: Page) {
let signedIn = false;
await page.route((url) => url.pathname === "/api/auth/sso/providers", (route) => fulfillJson(route, []));
await page.route((url) => url.pathname === "/api/auth/wecom/config", (route) =>
fulfillJson(route, {
@@ -39,6 +43,7 @@ async function mockAuthenticatedShell(page: Page) {
}),
);
await page.route((url) => url.pathname === "/api/auth/login", async (route) => {
signedIn = true;
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -50,13 +55,17 @@ async function mockAuthenticatedShell(page: Page) {
})),
});
});
await page.route((url) => url.pathname === "/api/auth/refresh", (route) =>
fulfillJson(route, {
await page.route((url) => url.pathname === "/api/auth/refresh", async (route) => {
if (!signedIn) {
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_001" } }) });
return;
}
await fulfillJson(route, {
accessToken: "r92-access-token",
expiresIn: 900,
mustChangePassword: false,
}),
);
});
});
await page.route((url) => url.pathname === "/api/me", (route) =>
fulfillJson(route, {
id: "employee-mock-1",
@@ -1,274 +1,7 @@
import { test, expect } from "./helpers/fixtures";
import {
ADMIN,
apiGetData,
createAuthenticatedApiClient,
EMPLOYEE,
FINANCE,
FINANCE_MGR,
login,
logout,
MANAGER,
} from "./helpers/auth";
import { ensureFeatureFlagOn } from "./helpers/featureFlags";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
import { test } from "@playwright/test";
import { reimbursementPaymentFlow } from "./helpers/reimbursementPaymentFlow";
test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({
page,
reimbursementDetail,
workflowDetail,
}) => {
test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({ page }) => {
test.setTimeout(300_000);
const adminApi = await createAuthenticatedApiClient(ADMIN);
const employeeApi = await createAuthenticatedApiClient(EMPLOYEE);
const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR);
try {
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow");
await ensureFeatureFlagOn(adminApi.api, "erp_sync", "playwright reimbursement payment batch flow");
await login(page, EMPLOYEE);
await (await openNavLink(page, "我的报销")).click();
await expect(page).toHaveURL(/\/reimbursements/);
const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
});
await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", { name: "空白报销" }).click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const reimbursementId = createPayload.data?.id;
if (!reimbursementId) {
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
}
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
const marker = `R78_BATCH_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const invoiceId = await reimbursementDetail.uploadInvoicePdf(
`r78-${Date.now()}.pdf`,
Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
);
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await reimbursementDetail.submitReimbursement();
const reimbursementAfterSubmit = await apiGetData<{
formNumber: string | null;
status: string;
totalAmount: string | null;
}>(
employeeApi.api,
`/api/reimbursements/${reimbursementId}`,
);
expect(reimbursementAfterSubmit.status).toBe("reviewing");
await expect(page.getByText("审批中", { exact: true }).first()).toBeVisible();
const reimbursementDisplayCode = reimbursementAfterSubmit.formNumber ?? reimbursementId;
const secondApprovalUser = Number(reimbursementAfterSubmit.totalAmount ?? 0) > 500 ? FINANCE_MGR : FINANCE;
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first();
await expect(managerTaskRow).toBeVisible();
await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(page.getByText("已完成", { exact: true }).first()).toBeVisible();
await expect(page.getByText(`通过 · ${MANAGER.name}`, { exact: true })).toBeVisible();
await logout(page, MANAGER.name);
await login(page, secondApprovalUser);
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first();
await expect(financeTaskRow).toBeVisible();
await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(page.getByText("已完成", { exact: true }).first()).toBeVisible();
await expect(page.getByText(`通过 · ${secondApprovalUser.name}`, { exact: true })).toBeVisible();
await logout(page, secondApprovalUser.name);
await login(page, FINANCE);
await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches/);
const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard).toBeVisible();
const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
await createCard.getByTestId("payment-batch-default-channel").selectOption("bank");
await createCard.getByRole("button", { name: "创建并提交复核" }).click();
const createBatchResponse = await createBatchResponsePromise;
expect(createBatchResponse.request().postDataJSON()).toMatchObject({
entityType: "reimbursement",
entityIds: [reimbursementId],
defaultPaymentChannel: "bank",
});
const createBatchPayload = (await createBatchResponse.json()) as {
data?: { item?: { id?: string; name?: string } };
};
const batchId = createBatchPayload.data?.item?.id;
const batchName = createBatchPayload.data?.item?.name;
if (!batchId || !batchName) {
throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
}
await page.goto(`/payment-batches?id=${batchId}`);
const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(batchDetailCard).toBeVisible();
await expect(batchDetailCard).toContainText("待付款复核");
await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理");
await expect(batchDetailCard).toContainText("0/1");
const draftItemsPanel = page.getByTestId("payment-batch-items-panel");
await expect(draftItemsPanel).toContainText(EMPLOYEE.name);
await expect(draftItemsPanel).toContainText("开户银行");
await expect(draftItemsPanel).toContainText("银行账户");
await expect(draftItemsPanel).toContainText("银行转账");
await expect(draftItemsPanel.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
const draftReceiptPanel = page.getByTestId("payment-batch-receipt-panel");
await expect(draftReceiptPanel).toContainText("付款复核通过后,由财务在线下完成付款并上传银行回单");
await expect(draftReceiptPanel.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
await expect(draftReceiptPanel.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await logout(page, FINANCE.name);
await login(page, MANAGER);
await page.goto(`/payment-batches?id=${batchId}`);
const managerBatchDetail = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(managerBatchDetail).toBeVisible();
await expect(page.getByTestId("payment-batch-items-panel")).toContainText(EMPLOYEE.name);
await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "复核通过" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核");
await page.getByTestId("action-confirm-submit").click();
await expect(managerBatchDetail).toContainText("1/1");
await logout(page, MANAGER.name);
await login(page, FINANCE);
await page.goto(`/payment-batches?id=${batchId}`);
const executedBatchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(executedBatchDetailCard).toBeVisible();
await expect(executedBatchDetailCard).toContainText("1/1");
const receiptCard = page.getByTestId("payment-batch-receipt-panel");
await expect(receiptCard).toBeVisible();
await expect(page.getByTestId("payment-batch-item-payment-fields")).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "上传银行回单" })).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
const receiptUploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` &&
response.ok();
});
await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({
name: "r78-bank-receipt.pdf",
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
});
await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("r78-bank-receipt.pdf");
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
const executeBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
url.pathname === `/api/payment-batches/${batchId}/execute` &&
response.ok();
});
await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
await page.getByTestId("action-confirm-submit").click();
await executeBatchResponsePromise;
await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
await expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
await expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible();
await expect(receiptCard.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
await expect(receiptCard.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(receiptCard.getByRole("button", { name: "作废" })).toHaveCount(0);
await expect(receiptCard.getByRole("button", { name: "下载" })).toBeVisible();
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
const reimbursementAfterPayment = await apiGetData<{ status: string }>(
employeeApi.api,
`/api/reimbursements/${reimbursementId}`,
);
expect(reimbursementAfterPayment.status).toBe("paid");
await expect(page.getByText("已支付", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款");
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
await expect(page.getByText("执行支付")).toHaveCount(0);
const paymentAudit = await apiGetData<{
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;
}>(
adminApi.api,
"/api/audit-logs?action=REIMBURSEMENT_PAYMENT_MARKED&page=1&pageSize=20",
);
expect(
paymentAudit.items.some((item) => item.targetId === reimbursementId && item.extra?.idempotent === false),
"付款动作应写入报销付款审计",
).toBe(true);
const receiptAudit = await apiGetData<{
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;
}>(
adminApi.api,
"/api/audit-logs?action=PAYMENT_BATCH_RECEIPT_UPLOADED&page=1&pageSize=20",
);
expect(
receiptAudit.items.some((item) => item.targetId === batchId && Array.isArray(item.extra?.attachmentIds)),
"银行回单上传应写入付款批次审计",
).toBe(true);
await page.goto("/ap-operations");
await page.getByRole("tab", { name: /财务台账/ }).click();
await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursementDisplayCode);
await expect(page.getByTestId("finance-ledger-table")).toContainText("已付款");
await expect(page.getByRole("tab", { name: /待付款/ })).toBeVisible();
await expect(page.getByText(batchName)).toHaveCount(0);
await logout(page, FINANCE.name);
const invoiceDetail = await apiGetData<{
status: string;
archivedAt: string | null;
linkedReimbursementId: string | null;
}>(
employeeApi.api,
`/api/invoice-pool/${invoiceId}`,
);
expect(invoiceDetail.status).toBe("archived");
expect(invoiceDetail.archivedAt).toBeTruthy();
expect(invoiceDetail.linkedReimbursementId).toBe(reimbursementId);
const erpPreview = await apiGetData<{
exportState: {
lastOutboxId: string | null;
lastOutboxStatus: string | null;
};
}>(
financeMgrApi.api,
`/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`,
);
expect(erpPreview.exportState.lastOutboxId).toBeTruthy();
expect(erpPreview.exportState.lastOutboxStatus).toBe("pending");
} finally {
await Promise.all([adminApi.dispose(), employeeApi.dispose(), financeMgrApi.dispose()]);
}
await reimbursementPaymentFlow(page);
});
@@ -1,13 +1,13 @@
import type { APIRequestContext } from "@playwright/test";
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { EMPLOYEE, MANAGER, apiGetData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
async function firstEnabledExpenseTypeId(api: APIRequestContext) {
const response = await api.get("/api/expense-types?enabled=true");
const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean; category?: string | null }> } };
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false && candidate.category !== "travel");
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false && candidate.category === "office");
if (!response.ok() || !item?.id) {
throw new Error(`无法读取费用类型:HTTP ${response.status()}`);
}
@@ -20,7 +20,7 @@ async function createReimbursementItem(api: APIRequestContext, reimbursementId:
data: {
expenseTypeId,
expenseDate: "2026-05-13",
amount: "168.30",
amount: "10.00",
description: "E2E 报销审批进度明细",
},
});
@@ -64,16 +64,24 @@ test("E2E 报销处理进度:提交后按真实 workflow 展示职责进度",
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await reimbursementDetail.submitReimbursement();
const actual = await apiGetData<{ workflowProgress: { steps: Array<{id: string}> }; processingProgress: {stages: Array<{key: string; state: string}>} }>(apiClient.api, `/api/reimbursements/${reimbursementId}`);
expect(actual.workflowProgress.steps.map(step => step.id)).toEqual(["approval_1_manager", "approval_2_finance"]);
await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress.locator(":scope > li")).toHaveCount(4);
await expect(reimbursementDetail.workflowProgress.locator(":scope > li")).toHaveCount(6);
await expect(reimbursementDetail.workflowProgress).toContainText("申请提交");
await expect(reimbursementDetail.workflowProgress).toContainText("审批");
await expect(reimbursementDetail.workflowProgress.locator(":scope > li").nth(1)).toContainText(MANAGER.name);
await expect(reimbursementDetail.workflowProgress).toContainText("财务复核");
await expect(reimbursementDetail.workflowProgress).toContainText("组建付款批次");
await expect(reimbursementDetail.workflowProgress).not.toContainText("审批节点1");
await expect(reimbursementDetail.workflowProgress).toContainText("付款复核");
await expect(reimbursementDetail.workflowProgress).toContainText("付款完成");
await expect(reimbursementDetail.workflowProgress).toContainText("非审批环节");
// Payment work is separate from the two actual approval steps; no legacy badge is required.
expect(actual.processingProgress.stages.slice(3).map(stage => stage.key)).toEqual([
"payment_batch_created", "payment_batch_review", "payment_and_receipt",
]);
for (const stage of actual.processingProgress.stages.slice(3)) expect(stage.state).toBe("pending");
await expect(reimbursementDetail.workflowProgress).not.toContainText(/第 \d+ 步/);
await logout(page, EMPLOYEE.name);
@@ -0,0 +1,45 @@
import { expect, test } from "@playwright/test";
import { createAuthenticatedApiClient, EMPLOYEE, FINANCE, FINANCE_MGR, login } from "./helpers/auth";
// Original travel chains remain byte-for-byte in their specs, owned by G19's
// frozen obligations. These assertions exercise the current retained boundary.
test("冻结差旅:员工入口与共享付款边界", async ({ page }) => {
const employee = await createAuthenticatedApiClient(EMPLOYEE);
const finance = await createAuthenticatedApiClient(FINANCE_MGR);
try {
expect(employee.me.roleCodes).toContain("employee");
const denied = await employee.api.post("/api/travel-requests", { data: {} });
expect(denied.status()).toBe(403);
await login(page, EMPLOYEE);
await page.goto("/portal");
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
await expect(page.getByRole("link", { name: "差旅申请", exact: true })).toHaveCount(0);
const readiness = await finance.api.post("/api/payment-batches/readiness", {
data: { entityType: "travel_order", entityIds: ["ffffffff-ffff-4fff-8fff-ffffffffffff"], defaultPaymentChannel: "bank" },
});
expect(readiness.status()).toBe(200);
const payload = await readiness.json();
expect(payload.data.ready).toBe(false);
expect(payload.data.blockers).toContainEqual(expect.objectContaining({ reasonCode: "payment_batch.travel_payment_frozen" }));
} finally {
await employee.dispose();
await finance.dispose();
}
});
test("冻结差旅:财务对账权限与入站导入权限边界", async ({ page }) => {
const finance = await createAuthenticatedApiClient(FINANCE);
try {
expect(finance.me.roleCodes).toContain("finance");
for (const endpoint of ["/api/travel/reconciliation/batches", "/api/travel/orders/import", "/api/travel/transactions/import", "/api/travel/authorizations/import"]) {
const denied = await finance.api.post(endpoint, { data: {} });
expect(denied.status(), endpoint).toBe(403);
}
await login(page, FINANCE);
await page.goto("/portal");
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
await expect(page.getByRole("link", { name: "差旅申请", exact: true })).toHaveCount(0);
} finally {
await finance.dispose();
}
});
+3
查看文件
@@ -1,5 +1,8 @@
import { expect, test } from "@playwright/test";
// These synthetic auth routes must also handle the post-change /api/me request.
test.use({ serviceWorkers: "block" });
function envelope(data: unknown) {
return {
requestId: "mock-request",
+5 -1
查看文件
@@ -1,8 +1,12 @@
import { mkdir, writeFile } from "node:fs/promises";
import { mkdirSync, mkdtempSync } from "node:fs";
import path from "node:path";
import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/pc-frontend-visual-consistency-wave1");
const SCREENSHOT_ROOT = process.env.QA_VISUAL_OUTPUT_DIR || path.resolve(process.cwd(), "../output/playwright/pc-frontend-visual-consistency-wave1");
mkdirSync(SCREENSHOT_ROOT, { recursive: true });
const SCREENSHOT_DIR = mkdtempSync(path.join(SCREENSHOT_ROOT, "visual-shell-"));
test.use({ serviceWorkers: "block" });
function envelope(data: unknown) {
return { requestId: "mock-r96-ui", data };
@@ -1,6 +1,7 @@
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
function formatWeekStart(offsetDays: number) {
@@ -739,3 +739,85 @@ test.describe("R7E 审批动作二次确认", () => {
});
}
});
for (const mobile of [false, true]) {
for (const action of ["approve", "return", "transfer", "already_processed", "failed"] as const) {
test(`财务审批原地进度 ${mobile ? "手机" : "桌面"} ${action}`, async ({ page }) => {
const reimbursementId = "synthetic-finance-progress";
let changed = false;
let readsAfterAction = 0;
let writes = 0;
const expectedResult = action === "return" ? "已退回修改" : action === "transfer" ? "已转审" : "财务复核通过";
const source = {
id: reimbursementId, formNumber: "BX-20260917-0003", status: "reviewing",
reimbursementDate: "2026-09-17", totalAmount: "100.00", description: "合成审批进度验证",
owner: { id: "synthetic-owner", employeeNo: "SYN-1", name: "合成申请人" },
submitter: { id: "synthetic-owner", employeeNo: "SYN-1", name: "合成申请人" },
items: [{ id: "synthetic-item", expenseTypeId: "synthetic-type", expenseDate: "2026-09-17",
amount: "100.00", description: "合成明细", assetPriorApprovalBasis: null,
expenseType: { id: "synthetic-type", code: "synthetic", name: "合成费用", category: "office", enabled: true, requiresInvoice: false } }],
invoices: [], attachments: [],
};
await mockWorkflowConfirmApis(page);
await page.route("**/api/me", (route) => fulfillJson(route, { ...mockMe, roleCodes: action === "transfer" ? ["finance", "manager"] : ["finance"],
permissions: ["expense.approve", "expense.transfer", "expense.view_all"] }));
await page.route(`**/api/reimbursements/${reimbursementId}`, async (route) => {
if (changed) readsAfterAction++;
await fulfillJson(route, { ...source, status: changed && action !== "transfer" ? (action === "return" ? "draft" : "approved") : "reviewing",
processingProgress: { stages: [{ key: "synthetic-finance-task", label: "财务复核",
state: changed ? (action === "return" ? "exception" : action === "transfer" ? "current" : "completed") : "current", actorName: "合成财务", occurredAt: null,
result: changed ? expectedResult : "等待财务复核", comment: null, detail: null, targetHref: null }] } });
});
await page.route(`**/api/workflow/tasks/${pendingTask.id}`, (route) => fulfillJson(route, {
...pendingTaskDetail, task: { ...pendingTaskDetail.task, stepName: "财务复核", assigneeRole: "finance",
status: changed ? "completed" : "pending" },
workflowInstance: { ...pendingTaskDetail.workflowInstance, entityType: "reimbursement",
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "available", parseStatus: "ready" } },
reimbursement: source, leaveRequest: null, history: [], allowedActions: changed ? [] : ["approve", "return", "transfer"],
}));
await page.route(`**/api/workflow/tasks/${pendingTask.id}/*`, async (route) => {
if (route.request().method() !== "POST") return route.fallback();
writes++;
changed = action !== "failed";
if (action === "already_processed" || action === "failed") {
await route.fulfill({ status: action === "failed" ? 500 : 409, contentType: "application/json",
body: JSON.stringify({ requestId: "synthetic-progress", error: { code: action === "failed" ? "SYS_001" : "VALID_001",
message: "合成失败", details: action === "already_processed" ? { task: "already_processed" } : {} } }) });
} else await fulfillJson(route, { ok: true });
});
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1280, height: 900 });
await login(page, MANAGER);
await page.goto(`${mobile ? "/mobile/approvals" : "/workflow/tasks"}/${pendingTask.id}`);
const progress = page.getByTestId("reimbursement-approval-processing-progress");
await expect(progress).toContainText("等待财务复核");
const documentMarker = await page.evaluate(() => {
const marker = crypto.randomUUID(); document.documentElement.dataset.syntheticDocument = marker; return marker;
});
if (action === "transfer") {
await page.getByTestId(mobile ? "mobile-approvals-transfer" : "workflow-task-transfer-menu-item").click();
await page.getByTestId(mobile ? "mobile-approvals-transfer-to" : "workflow-task-transfer-approver").selectOption("employee-transfer-1");
await page.getByTestId(mobile ? "mobile-approvals-transfer-reason" : "workflow-task-transfer-reason").fill("合成转审理由用于验证处理进度刷新");
await page.getByTestId(mobile ? "mobile-approvals-transfer-submit" : "workflow-task-transfer").click();
await page.getByTestId("workflow-transfer-confirm-dialog").getByRole("button", { name: "确认转审", exact: true }).click();
} else {
if (action === "return") {
await page.getByTestId(mobile ? "mobile-approvals-return" : "workflow-task-return-toggle").click();
await page.getByTestId(mobile ? "mobile-approvals-comment" : "workflow-task-return-comment").fill("请补充合成资料");
await page.getByTestId(mobile ? "mobile-approvals-return-submit" : "workflow-task-return").click();
} else await page.getByTestId(mobile ? "mobile-approvals-approve" : "workflow-task-approve").click();
await page.getByTestId("workflow-action-confirm-submit").click();
}
await expect.poll(() => writes).toBe(1);
if (action === "failed") {
await expect(page.getByTestId(mobile ? "mobile-approvals-approve" : "workflow-task-approve")).toBeEnabled();
await expect(progress).toContainText("等待财务复核");
expect(readsAfterAction).toBe(0);
} else {
await expect(progress).toContainText(expectedResult);
await expect(progress.locator("li")).toHaveAttribute("data-state", action === "return" ? "exception" : action === "transfer" ? "current" : "completed");
expect(readsAfterAction).toBeGreaterThan(0);
}
expect(await page.evaluate(() => document.documentElement.dataset.syntheticDocument)).toBe(documentMarker);
});
}
}
+1
查看文件
@@ -71,6 +71,7 @@ export default defineConfig(({ mode }) => {
VitePWA({
disable: false,
registerType: "autoUpdate",
injectRegister: false, // pwa.ts owns native registration and update reloads.
includeAssets: ["pwa-192x192.png", "pwa-512x512.png", "pwa-maskable-512x512.png"],
manifest: {
name: "hyb-oa 移动工作台",