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
这个提交包含在:
@@ -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({}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }) });
|
||||
}
|
||||
@@ -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": "不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。",
|
||||
|
||||
@@ -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,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 = {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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: "财务" },
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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(); }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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("读取资料失败");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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("资产表单尚未保存");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 移动工作台",
|
||||
|
||||
在新工单中引用
屏蔽一个用户