完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)

汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
2026-09-19 13:11:04 +08:00
父节点 729d90576f
当前提交 1f8d5b658a
共修改 367 个文件,包含 21079 行新增和 12287 行删除
-93
查看文件
@@ -168,81 +168,6 @@ export type AssetMovementResponse = {
transferId?: string | null;
};
export type AssetErpTrackStatus =
| "not_triggered"
| "pending"
| "processing"
| "succeeded"
| "failed"
| "dead_letter"
| "skipped";
export type AssetErpBinding = {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
};
export type AssetErpOutbox = {
id: string;
eventType: string;
status: string;
attempts: number;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
};
export type AssetErpStage = {
eligible: boolean;
reason: string | null;
deliveryStatus: AssetErpTrackStatus | string;
actionHint: "none" | "backfill" | "retry" | "resync";
preview: {
changeType: string | null;
sourceEventName: string | null;
categoryCode: string | null;
legalEntityCode: string | null;
costCenterCode: string | null;
status: string | null;
};
outbox: AssetErpOutbox | null;
binding: AssetErpBinding | null;
};
export type AssetErpTrack = {
asset: {
id: string;
assetNo: string | null;
name: string | null;
status: string | null;
legalEntity: {
id: string;
code: string | null;
name: string | null;
} | null;
costCenter: {
id: string;
code: string | null;
name: string | null;
} | null;
purchaseRequest: {
id: string;
requestNo: string | null;
title: string | null;
} | null;
};
stage: AssetErpStage;
permissions: {
canRetry: boolean;
canSeeExternalRefs: boolean;
};
};
function toQueryString(query: Record<string, string | boolean | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
@@ -341,21 +266,3 @@ export async function transferAsset(
body: JSON.stringify(payload),
});
}
export async function fetchAssetErpTrack(assetId: string) {
return apiRequestData<AssetErpTrack>(`/api/integrations/erp/assets/${assetId}/track`);
}
export async function requeueAssetErpSync(assetId: string, runNow = true) {
const query = runNow ? '?runNow=1' : '?runNow=0';
return apiRequestData<{
outboxId: string;
status: string;
duplicated: boolean;
requeued: boolean;
eventType: string;
}>(`/api/integrations/erp/assets/${assetId}/requeue${query}`, {
method: 'POST',
body: JSON.stringify({}),
});
}
+51
查看文件
@@ -0,0 +1,51 @@
import { apiRequestBlob, apiRequestData } from "./http";
import type { PaymentBatchDetail } from "./paymentBatches";
export type LoanLender = {
id: string; name: string; legalEntityId: string; legalEntityName: string;
employeeId: string | null; identityStatus: string; identityVersion: number; accountVersion: number;
bankInstitutionId: string; bankName: string; bankAccountMasked: string | null; accountUnavailable: boolean;
};
export type LoanEvidence = {
id: string; kind: string; originalName: string; fileSize: number; mimeType: string;
governanceStatus: string; archivedAt: string | null;
};
export type CompanyLoan = {
id: string; loanNo: string; originReference: string; lender: LoanLender;
originalPrincipal: string; openingOutstandingPrincipal: string; receivedDate: string;
balanceAsOf: string; historicalEntry: boolean; confirmationStatus: string; version: number;
outstanding: string; available: string; reserved: string; settled: string;
evidences: LoanEvidence[]; accountingConfirmationRef: string | null;
};
export type LoanRepayment = {
id: string; repaymentNo: string; lenderId: string; lenderName: string; amount: string; reason: string;
bankResultState: string; paymentBatchId: string; status: string;
accountingHandoffRef: string | null; accountingHandoffAt: string | null;
allocations: Array<{ loanId: string; loanNo: string; amount: string; state: string; receivedDate: string; originReference: string; balanceAsOf: string; evidences: LoanEvidence[] }>;
};
export function fetchCompanyLoans(page: number, lenderId?: string, signal?: AbortSignal) {
return apiRequestData<{ items: CompanyLoan[]; total: number }>(`/api/company-loans?page=${page}&pageSize=30${lenderId ? `&lenderId=${encodeURIComponent(lenderId)}` : ""}`, { signal });
}
export function fetchLoanLenders(page: number, signal?: AbortSignal) {
return apiRequestData<{ items: LoanLender[]; total: number }>(`/api/loan-lenders?page=${page}&pageSize=30`, { signal });
}
export function fetchLoanRepayments(page: number, signal?: AbortSignal) {
return apiRequestData<{ items: LoanRepayment[]; total: number }>(`/api/loan-repayments?page=${page}&pageSize=30`, { signal });
}
export function fetchLoanIdentities(q: string, signal?: AbortSignal) {
return apiRequestData<{ items: Array<{ id: string; name: string; employeeNo: string }> }>(`/api/loan-lenders/identity-options?q=${encodeURIComponent(q)}`, { signal });
}
export function saveLoanAction<T>(path: string, body: unknown, method = "POST") {
return apiRequestData<T>(`/api${path}`, { method, body: JSON.stringify(body) });
}
export function createLoanRepayment(body: { requestKey: string; lenderId: string; reason: string;
allocations: Array<{ loanId: string; amount: string }> }) {
return saveLoanAction<{ item: PaymentBatchDetail }>("/loan-repayments", body);
}
export function uploadLoanEvidence(id: string, kind: string, file: File) {
const body = new FormData(); body.append("file", file);
return apiRequestData<LoanEvidence>(`/api/company-loans/${id}/evidences/${kind}`, { method: "POST", body });
}
export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string) {
return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId }) });
}
+622
查看文件
@@ -1,4 +1,626 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = {
"integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。",
"loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。",
"loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。",
"loan.source_unavailable": "公司、银行或关联人员已停用,请核对后重新选择。",
"loan.identity_required": "请先核实出借人与 OA 账号的关联;非员工须明确确认未关联账号。",
"loan.version_changed": "借款身份、账户或余额已变更,请刷新后重新核对。",
"loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。",
"loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。",
"loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。",
"loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
"loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。",
"loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。",
"loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。",
"loan.reviewer_required": "没有符合独立复核要求的经理或总经理;制单人、提交人和收款人不能复核。",
"loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。",
"loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。",
"loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。",
"loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。",
"config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。",
"config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。",
"document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。",
"document_sequence.category_missing": "指定的单据分类不存在,请重新选择分类。",
"document_sequence.category_type_mismatch": "单据分类与当前单据类型不匹配,请重新选择。",
"document_sequence.category_unavailable": "指定的单据分类已停用或归档,请选择可用分类。",
"workspace.update_input_invalid": "工作区设置不符合要求,请检查模块显示方式后保存。",
"confidential_ref.create_input_invalid": "资料索引信息不符合要求,请检查必填项和字段长度。",
"confidential_ref.update_input_invalid": "资料索引修改不符合要求,请检查要更新的字段。",
"confidential_ref.query_invalid": "资料索引查询条件不符合要求,请检查关联对象。",
"confidential_ref.feature_disabled": "当前未启用资料索引,请联系管理员确认。",
"cutover.id_invalid": "切换范围标识无效,请刷新列表后重新选择。",
"cutover.create_input_invalid": "切换范围信息不符合要求,请检查范围类型、标识和时间。",
"cutover.update_input_invalid": "切换范围修改不符合要求,请检查要更新的字段。",
"cutover.feature_disabled": "当前未启用切换控制台,请联系管理员确认。",
"cutover.scope_not_enabled": "当前业务尚未纳入允许操作的切换范围,请联系管理员确认。",
"migration_run.id_invalid": "迁移检查记录标识无效,请刷新列表后重新选择。",
"migration_run.file_required": "请选择用于迁移预检查的文件。",
"migration_run.type_invalid": "迁移检查类型无效,请重新选择检查类型。",
"feature_flag.version_conflict": "功能设置已被其他操作更新,请刷新后核对再保存。",
"feature_flag.key_invalid": "功能标识无效,请刷新功能设置列表后重新选择。",
"feature_flag.page_invalid": "功能设置历史查询条件不符合要求,请检查分页。",
"feature_flag.update_input_invalid": "功能设置不符合要求,请检查覆盖模式、范围和版本。",
"feature_flag.global_departments_invalid": "全局范围不能同时指定部门,请清除部门或选择部门范围。",
"feature_flag.batch_input_invalid": "批量功能设置不符合要求,请检查操作说明、版本和重复项目。",
"fee_control.input_invalid": "费用预检信息不符合要求,请检查单据类型和标识。",
"fee_control.entity_unsupported": "当前单据类型不支持费用预检,请联系管理员确认。",
"risk_flag.query_invalid": "风险记录查询条件不符合要求,请检查单据、状态和来源。",
"risk_flag.id_invalid": "风险记录标识无效,请刷新列表后重新选择。",
"risk_flag.action_input_invalid": "风险处理信息不符合要求,请检查备注长度。",
"risk_flag.not_open": "风险记录已处理,不能重复处理,请刷新后核对。",
"auth.entity_action_unsupported": "当前单据不支持此操作,请返回页面选择有效操作。",
"wecom.signature_url_invalid": "企业微信页面签名地址无效,请刷新当前页面后重试。",
"wecom.entry_config_incomplete": "企业微信登录入口尚未配置完整,请联系管理员确认。",
"wecom.auth_config_incomplete": "企业微信认证配置不完整,请联系管理员确认。",
"wecom.login_query_invalid": "企业微信登录参数无效,请从正式入口重新进入。",
"wecom.pc_mode_unsupported": "电脑扫码入口不支持此操作,请使用对应的绑定或入职入口。",
"wecom.bind_input_invalid": "企业微信绑定参数无效,请刷新绑定页面后重试。",
"integration.outbox_query_invalid": "消息队列查询条件不符合要求,请检查状态、类型和分页。",
"integration.bindings_query_invalid": "外部绑定查询条件不符合要求,请检查类型和分页。",
"integration.outbox_id_invalid": "消息队列记录标识无效,请刷新列表后重新选择。",
"integration.retry_state_invalid": "当前记录不处于失败或待人工处理状态,无法重试,请刷新后核对。",
"ai.reimbursement_id_invalid": "报销单标识无效,请刷新后重新打开报销详情。",
"ai.purchase_id_invalid": "采购申请标识无效,请刷新后重新打开采购详情。",
"ai.asset_id_invalid": "资产标识无效,请刷新后重新打开资产详情。",
"ai.feature_disabled": "当前单据未开放智能建议,请联系管理员确认。",
"weekly_report.query_invalid": "周报查询日期不符合要求,请选择有效日期。",
"weekly_report.write_input_invalid": "周报内容不符合要求,请检查文字长度和关联项目。",
"weekly_report.submit_input_invalid": "周报提交信息不符合要求,请检查周报日期。",
"weekly_report.feature_disabled": "当前未启用周报,请联系管理员确认。",
"weekly_report.submitted_readonly": "周报已提交,当前无法修改,请刷新后核对。",
"weekly_report.project_linking_disabled": "当前未启用项目关联,请移除关联项目或联系管理员确认。",
"audit.query_invalid": "审计日志查询条件不符合要求,请检查分页、日期和筛选条件。",
"audit.date_range_invalid": "审计日志结束时间早于开始时间,请调整查询范围。",
"search.feature_disabled": "当前未启用全局搜索,请联系管理员确认。",
"search.query_invalid": "搜索条件不符合要求,请输入至少2个字符并检查结果数量。",
"export.presence_query_invalid": "在岗历史导出条件不符合要求,请检查日期、人员和文件格式。",
"export.reimbursement_query_invalid": "报销导出条件不符合要求,请检查范围、状态、条数和文件格式。",
"export.invoice_query_invalid": "发票导出条件不符合要求,请检查归属、状态、条数和文件格式。",
"export.audit_query_invalid": "审计日志导出条件不符合要求,请检查日期、筛选条件和文件格式。",
"export.dashboard_query_invalid": "看板导出条件不符合要求,请检查日期、部门和文件格式。",
"export.ap_input_invalid": "应付工作台导出条件不符合要求,请检查筛选条件和文件格式。",
"export.dashboard_date_range_invalid": "看板导出结束日期早于开始日期,请调整日期范围。",
"export.payment_batch_disabled": "当前未启用付款批次功能,无法导出应付工作台数据,请联系管理员确认。",
"export.project_linking_disabled": "当前未启用项目关联,无法导出合同台账,请联系管理员确认。",
"export.ap_pending_date_range_invalid": "待付款批次导出结束日期早于开始日期,请调整创建日期范围。",
"attendance.clock_input_invalid": "打卡信息不符合要求,请检查备注后重试。",
"attendance.legacy_month_query_invalid": "考勤月份查询不符合要求,请选择2000至2100年内的有效月份。",
"attendance.team_query_invalid": "团队考勤查询不符合要求,请检查日期和部门。",
"attendance.policy_input_invalid": "考勤策略信息不符合要求,请检查时间格式和容差分钟数。",
"attendance.backfill_input_invalid": "补录信息不符合要求,请检查员工、日期和打卡时间。",
"attendance.sync_input_invalid": "考勤同步条件不符合要求,请选择不超过31天的日期范围和不超过50名员工。",
"attendance.sync_runs_query_invalid": "考勤同步记录查询不符合要求,请检查分页条件。",
"attendance.feature_disabled": "当前未启用网页打卡,请使用现行考勤渠道。",
"attendance.unified_feature_disabled": "当前未启用统一考勤,请联系管理员确认。",
"attendance.datetime_invalid": "打卡时间无法识别,请填写有效的日期时间。",
"attendance.exempt_day": "当天属于免打卡范围,无需提交打卡。",
"attendance.missing_clock_in": "当天尚未签到,请先签到或联系管理员核查。",
"attendance.missing_backfill_values": "补录缺少打卡时间,请至少填写签到或签退时间。",
"attendance.clock_range_invalid": "签退时间早于签到时间,请核对补录时间。",
"attendance.policy_time_invalid": "考勤策略时间无效,请使用00:00至23:59的小时和分钟格式。",
"attendance.tolerance_invalid": "迟到容差无效,请填写0至600之间的整数分钟数。",
"attendance.work_range_invalid": "上班时间必须早于下班时间,请调整考勤策略。",
"attendance.lunch_range_invalid": "午休开始时间必须早于结束时间,请调整考勤策略。",
"attendance.lunch_outside_work_range": "午休时间超出工作时段,请调整考勤策略。",
"attendance.year_invalid": "考勤年份无效,请选择2000至2100之间的年份。",
"presence.query_invalid": "在岗查询条件不符合要求,请检查日期。",
"presence.write_input_invalid": "在岗信息不符合要求,请检查日期、状态和备注。",
"presence.history_query_invalid": "在岗历史查询条件不符合要求,请检查日期范围。",
"presence.team_history_query_invalid": "团队在岗历史查询条件不符合要求,请检查日期、员工和筛选条件。",
"presence.employee_id_invalid": "员工标识无效,请刷新列表后重新选择。",
"presence.override_input_invalid": "在岗调整信息不符合要求,请检查日期、状态和备注。",
"presence.feature_disabled": "当前未启用在岗看板,请联系管理员确认。",
"presence.approved_leave_readonly": "当天已有获批请假记录,无法自行修改在岗状态,请联系管理员核查。",
"export.presence_too_large": "在岗历史导出条目过多,请缩小日期或人员范围。",
"delegation.id_invalid": "委托记录标识无效,请刷新委托列表后重试。",
"delegation.create_input_invalid": "委托资料格式有误,请检查受托人、范围及起止时间。",
"delegation.grant_input_invalid": "委托授权资料格式有误,请检查委托人、受托人、范围及起止时间。",
"delegation.start_time_invalid": "委托开始时间无效,请重新选择。",
"delegation.end_time_invalid": "委托结束时间无效,请重新选择。",
"delegation.time_range_invalid": "委托结束时间必须晚于开始时间,请调整后重试。",
"delegation.status_query_invalid": "委托状态筛选条件无效,请重新选择状态。",
"delegation.grants_query_invalid": "委托授权筛选条件有误,请检查人员与有效期选项。",
"delegation.delegate_unavailable": "受托人不存在或不在职,请选择在职人员。",
"delegation.delegate_proxy_permission_required": "受托人不具备报销代提交权限,请选择具备该权限的人员。",
"delegation.delegate_approval_permission_required": "受托人不具备报销审批权限,请选择具备审批权限的人员。",
"delegation.self_not_allowed": "委托人和受托人不能是同一人,请重新选择受托人。",
"delegation.principal_unavailable": "委托人不存在或不在职,请选择在职人员。",
"workflow_definition.id_invalid": "审批流程标识无效,请刷新流程列表后重试。",
"workflow_definition.create_input_invalid": "审批流程新增资料有误,请检查名称、业务类型及1至20个审批步骤。",
"workflow_definition.update_input_invalid": "审批流程修改内容为空或格式有误,请检查步骤和人员配置后保存。",
"workflow_definition.entity_type_invalid": "审批流程业务类型无效,请重新选择。",
"workflow_definition.feature_disabled": "审批流程管理功能尚未启用,请联系管理员确认。",
"workflow_definition.reimbursement_readonly": "报销审批流程由审批规则生成,请在审批规则中维护。",
"workflow_definition.not_editable": "已发布生效或已归档的审批流程不能直接修改,请先确认流程版本。",
"workflow_definition.archived_not_publishable": "已归档的审批流程不能发布,请选择可用草稿版本。",
"approval_rule.create_input_invalid": "审批规则新增资料有误,请检查金额范围、审批角色和优先级。",
"approval_rule.update_input_invalid": "审批规则修改资料有误,请检查金额范围、审批角色和优先级。",
"approval_rule.id_invalid": "审批规则标识无效,请刷新规则列表后重试。",
"approval_rule.amount_invalid": "审批金额范围超出可处理范围,请填写有效的上下限金额。",
"approval_rule.amount_range_invalid": "审批金额上限必须大于下限,请调整金额范围。",
"approval_rule.fallback_role_invalid": "负责人本人申请的兜底角色仅支持财务或财务经理,请重新选择。",
"approval_rule.empty_update": "尚未填写需要修改的审批规则内容,请修改后保存。",
"workflow.steps_invalid": "审批流程缺少有效步骤,请联系管理员检查流程配置。",
"workflow.business_role_unsupported": "审批流程包含不支持的业务审批角色,请联系管理员修正。",
"workflow.approval_rule_invalid": "审批规则配置无效,请联系管理员核对后重试。",
"workflow.approval_chain_empty": "审批规则未配置审批层级,请联系管理员补充。",
"workflow.fallback_role_unsupported": "负责人本人申请的兜底审批角色无效,请联系管理员核对。",
"workflow.fallback_role_is_self": "当前兜底审批角色由申请人本人持有,无法继续,请联系管理员安排符合要求的审批人。",
"workflow.fallback_holder_unavailable": "当前没有可处理的非本人兜底审批人,请联系管理员核对角色人员。",
"workflow.fallback_chain_empty": "本人申请调整后没有可用审批步骤,请联系管理员检查审批规则。",
"workflow.assignee_rule_invalid": "审批人分配规则无效,请联系管理员检查流程配置。",
"workflow.role_assignee_invalid": "审批步骤未配置有效的角色,请联系管理员修正。",
"workflow.user_assignee_invalid": "审批步骤指定的人员标识无效,请联系管理员重新配置。",
"workflow.manager_context_invalid": "无法确定申请人的主管关系,请联系管理员核对人员资料。",
"workflow.assignee_type_unsupported": "审批人分配方式不受支持,请联系管理员修正流程。",
"workflow.manager_unavailable": "无法找到可处理的主管审批人,请联系管理员检查汇报关系后重试。",
"workflow.assignee_resolution_invalid": "审批步骤未能解析出有效审批人,请联系管理员检查配置。",
"purchase_request.id_invalid": "采购申请标识无效,请刷新采购列表后重试。",
"purchase_attachment.id_invalid": "采购附件标识无效,请刷新附件列表后重试。",
"purchase_request.query_invalid": "采购筛选条件有误,请检查状态、页码和归属信息。",
"purchase_request.update_input_invalid": "采购修改资料格式有误,请检查字段、明细和交付日期。",
"purchase_request.archive_input_invalid": "采购归档资料有误,请填写采购单号并检查合同号和附件清单。",
"purchase_request.precheck_blocked": "采购预检未通过,请查看预检结果并处理阻断项后提交。",
"purchase_request.quantity_invalid": "采购明细数量超出可处理范围,请填写有效数量。",
"purchase_request.quantity_too_small": "采购明细数量不得小于0.01,请修正数量后保存。",
"purchase_request.unit_price_invalid": "采购明细单价超出可处理范围,请填写有效单价。",
"purchase_request.unit_price_negative": "采购明细单价不能为负数,请修正后保存。",
"purchase_request.company_not_found": "采购所属公司不存在,请刷新后重新选择。",
"purchase_request.company_inactive": "采购所属公司已停用或归档,请选择可用公司。",
"purchase_request.cost_center_not_found": "采购成本中心不存在,请刷新后重新选择。",
"purchase_request.cost_center_inactive": "采购成本中心已停用或归档,请选择可用成本中心。",
"purchase_request.cost_center_company_mismatch": "采购成本中心不属于所选公司,请核对归属信息。",
"purchase_request.supplier_not_found": "采购供应商不存在,请刷新后重新选择。",
"purchase_request.supplier_inactive": "采购供应商已停用或归档,请选择可用供应商。",
"purchase_request.supplier_company_mismatch": "采购供应商不适用于所选公司,请核对供应商归属。",
"purchase_request.document_category_not_found": "采购单据分类不存在,请刷新后重新选择。",
"purchase_request.document_category_type_invalid": "所选单据分类不适用于采购申请,请重新选择。",
"purchase_request.document_category_inactive": "采购单据分类已停用或归档,请选择可用分类。",
"purchase_request.budget_pool_not_found": "所选采购预算池不存在,请刷新后重新选择。",
"purchase_request.budget_pool_inactive": "所选采购预算池不可用,请联系财务核对预算。",
"purchase_request.project_link_disabled": "采购项目关联功能尚未启用,请联系管理员确认。",
"purchase_request.project_archived": "已归档项目不能新增采购关联,请选择可用项目。",
"purchase_attachment.download_input_invalid": "采购附件下载参数有误,请检查下载理由后重试。",
"purchase_attachment.delete_input_invalid": "删除采购附件需要2至200字的理由,请填写后重试。",
"purchase_attachment.download_blocked": "采购附件已被隔离或未通过安全校验,无法下载,请联系管理员核查。",
"purchase_request.not_editable": "只有草稿采购申请可以修改,请刷新确认申请状态。",
"purchase_request.workflow_missing": "采购审批流程尚未配置,请联系管理员配置后提交。",
"purchase_request.approver_unavailable": "采购申请当前没有可处理的审批人,请联系管理员核对审批配置后重试。",
"purchase_request.not_submittable": "只有草稿采购申请可以提交,请刷新确认申请状态。",
"purchase_request.company_required": "提交采购申请前,请选择所属公司。",
"purchase_request.cost_center_required": "提交采购申请前,请选择成本中心。",
"purchase_request.supplier_required": "提交采购申请前,请选择供应商。",
"purchase_request.document_category_required": "提交采购申请前,请选择单据分类。",
"purchase_request.items_required": "提交采购申请前,请至少添加一条采购明细。",
"purchase_request.budget_context_mismatch": "所选预算池与当前采购归属或期间不匹配,请重新选择预算池。",
"purchase_request.budget_required": "采购申请缺少适用预算池,请联系财务配置预算后提交。",
"purchase_request.not_withdrawable": "只有审批中的采购申请可以撤回,请刷新确认申请状态。",
"purchase_request.not_archivable": "只有已批准的采购申请可以登记订单归档,请先完成审批。",
"purchase_attachment.not_editable": "只有草稿采购申请可以上传或删除附件,请刷新确认申请状态。",
"purchase_attachment.multipart_required": "请通过文件上传入口选择采购附件后重试。",
"purchase_attachment.single_file_required": "每次只能上传一个采购附件,请逐个上传。",
"purchase_attachment.field_invalid": "采购附件上传字段有误,请重新选择文件后上传。",
"purchase_attachment.too_large": "采购附件超过允许大小,请缩小文件后重新上传。",
"purchase_attachment.multipart_limit": "采购附件上传字段或分段过多,请重新选择文件后上传。",
"purchase_attachment.validation_failed": "采购附件未通过文件校验,请更换有效文件后重试。",
"purchase_attachment.file_required": "请选择采购附件后上传。",
"purchase_attachment.download_reason_required": "下载此采购附件需要填写理由,请补充后重试。",
"purchase_attachment.dangerous_filename": "采购附件文件名不符合安全要求,请使用不含路径或可执行扩展名的普通文件名。",
"purchase_attachment.corrupt_content": "采购附件内容损坏或格式无效,请重新导出文件后上传。",
"purchase_attachment.multiple_pages": "采购附件图片包含多帧或多页,请转换为单张图片后上传。",
"purchase_attachment.pixel_limit": "采购附件图片像素过大,请降低分辨率后上传。",
"purchase_attachment.type_mismatch": "采购附件实际内容与文件类型不一致,请重新导出正确格式后上传。",
"purchase_attachment.empty_file": "采购附件内容为空,请选择有内容的文件。",
"asset_movement.id_invalid": "资产标识无效,请刷新资产列表后重试。",
"asset_movement.assign_input_invalid": "资产领用资料有误,请选择领用人并检查备注。",
"asset_movement.return_input_invalid": "资产归还资料有误,请检查备注后重试。",
"asset_movement.transfer_input_invalid": "资产调拨资料有误,请检查接收人、位置和备注。",
"asset_movement.status_blocked": "已报废或归档的资产不能领用、归还或调拨,请确认资产状态。",
"asset_movement.not_found": "待流转的资产不存在,请刷新资产列表后重试。",
"asset_movement.employee_not_found": "资产接收人不存在,请刷新人员列表后重新选择。",
"asset_movement.employee_inactive": "资产接收人已不在职,请选择在职人员。",
"asset_movement.already_assigned": "资产已有领用人或未关闭的领用记录,请先归还或使用调拨。",
"asset_movement.not_assigned": "资产尚未领用,无需归还,请刷新确认资产记录。",
"asset_movement.use_return": "清空资产领用人请使用归还操作,不能通过调拨清空。",
"asset_movement.no_change": "本次调拨没有改变接收人或位置,请填写实际变更。",
"inventory_check.id_invalid": "盘点单标识无效,请刷新盘点列表后重试。",
"inventory_check.scope_id_invalid": "盘点范围标识无效,请重新选择公司或成本中心。",
"inventory_check.query_invalid": "盘点筛选条件有误,请检查状态和范围类型。",
"inventory_check.create_input_invalid": "盘点创建资料有误,请检查范围类型和范围值。",
"inventory_check.record_input_invalid": "盘点结果有误,请至少填写一项并检查资产、结果和备注。",
"inventory_check.scope_required": "请填写所选盘点范围对应的公司、成本中心或位置。",
"inventory_check.company_not_found": "盘点范围的公司不存在,请刷新后重新选择。",
"inventory_check.cost_center_not_found": "盘点范围的成本中心不存在,请刷新后重新选择。",
"inventory_check.not_found": "盘点单不存在,请刷新盘点列表后重试。",
"inventory_check.start_state_invalid": "只有草稿盘点单可以开始,请刷新确认盘点状态。",
"inventory_check.record_state_invalid": "只有进行中的盘点单可以记录结果,请先开始盘点或刷新状态。",
"inventory_check.complete_state_invalid": "只有进行中的盘点单可以完成,请刷新确认盘点状态。",
"inventory_check.asset_scope_mismatch": "资产不属于本次盘点范围,请核对公司、成本中心或位置。",
"inventory_check.duplicate_asset": "同批盘点结果包含重复资产,请合并后重新提交。",
"inventory_check.asset_not_found": "盘点结果中的资产不存在,请刷新资产列表后重新选择。",
"asset.category_id_invalid": "资产分类标识无效,请刷新分类列表后重试。",
"asset.id_invalid": "资产标识无效,请刷新资产列表后重试。",
"asset.category_query_invalid": "资产分类筛选条件有误,请检查启用和归档选项。",
"asset.query_invalid": "资产筛选条件有误,请检查分类、人员、归属和状态后重试。",
"asset.assignee_query_invalid": "领用人查询条件有误,请缩短关键词后重试。",
"asset.category_create_input_invalid": "资产分类资料不完整或格式有误,请检查编码、名称和单据分类。",
"asset.category_update_input_invalid": "资产分类修改内容为空或格式有误,请检查后保存。",
"asset.create_input_invalid": "资产资料不完整或格式有误,请检查必填项、人员和日期。",
"asset.update_input_invalid": "资产修改内容为空或格式有误,请检查字段和日期后保存。",
"asset.index_import_input_invalid": "脱敏资产索引格式有误,请检查条目、字段和数量后重新导入。",
"asset.unsafe_content": "资产资料包含不允许保存的文本、哈希或元数据,请脱敏并检查格式和长度。",
"asset.index_import_unsafe_content": "脱敏资产索引含不允许保存的文本或哈希,请检查并脱敏后重试。",
"asset.document_category_not_found": "所选单据分类不存在,请刷新后重新选择。",
"asset.document_category_type_invalid": "所选单据分类不适用于资产卡片,请选择资产卡片分类。",
"asset.document_category_inactive": "所选单据分类已停用或归档,请选择可用分类。",
"asset.category_not_found": "资产分类不存在,请刷新后重新选择。",
"asset.category_inactive": "资产分类已停用或归档,请选择可用分类。",
"asset.category_document_invalid": "资产分类关联的单据分类不可用,请联系管理员修正分类设置。",
"asset.company_not_found": "资产所属公司不存在,请刷新后重新选择。",
"asset.company_inactive": "资产所属公司已停用或归档,请选择可用公司。",
"asset.cost_center_not_found": "资产成本中心不存在,请刷新后重新选择。",
"asset.cost_center_inactive": "资产成本中心已停用或归档,请选择可用成本中心。",
"asset.cost_center_company_mismatch": "资产成本中心不属于所选公司,请核对公司与成本中心。",
"asset.purchase_not_found": "关联采购申请不存在,请刷新后重新选择。",
"asset.purchase_not_approved": "关联采购申请尚未批准,请在采购审批完成后登记资产。",
"asset.index_import_dimensions_required": "写入资产索引前,请填写资产分类、所属公司和成本中心。",
"asset.not_found": "资产不存在,请刷新资产列表后重试。",
"asset.assignee_not_found": "所选资产领用人不存在,请刷新人员列表后重新选择。",
"asset.assignee_inactive": "所选资产领用人已不在职,请选择在职人员。",
"asset.owner_not_found": "所选资产责任人不存在,请刷新人员列表后重新选择。",
"asset.owner_inactive": "所选资产责任人已不在职,请选择在职人员。",
"asset.keeper_not_found": "所选资产保管人不存在,请刷新人员列表后重新选择。",
"asset.keeper_inactive": "所选资产保管人已不在职,请选择在职人员。",
"contract_registry.create_input_invalid": "合同台账新增资料不完整或格式有误,请检查必填项、金额和日期。",
"contract_registry.update_input_invalid": "合同台账修改内容为空或格式有误,请检查后重新保存。",
"contract_registry.terminate_input_invalid": "请填写10至1000字的合同终止原因后重试。",
"contract_registry.correction_input_invalid": "请填写10至1000字的合同纠错说明后重试。",
"contract_registry.feature_disabled": "合同台账功能尚未启用,请联系管理员确认。",
"contract_registry.state_conflict": "合同状态或资料已被更新,请刷新后重试。",
"contract_registry.terminated_is_final": "已终止的合同不能修改,请查看原合同记录。",
"contract_registry.archived_fields_locked": "已归档合同不能直接修改资料,请通过纠错记录补充说明。",
"contract_registry.terminate_not_allowed": "仅生效或已到期的合同可以终止,请刷新确认合同状态。",
"contract_registry.terminate_conflict": "合同已发生变更,本次未完成终止,请刷新后确认。",
"contract_registry.correction_requires_archived": "仅已归档合同可以追加纠错记录,请先确认合同状态。",
"contract_registry.correction_state_conflict": "合同归档状态已变化,本次未保存纠错记录,请刷新后重试。",
"contract_registry.renew_not_allowed": "仅生效或已到期的合同可以续订,请刷新确认合同状态。",
"purchase_budget.reserve_already_exists": "该采购申请已有未释放的预算占用,请刷新查看,避免重复提交。",
"purchase_budget.pool_not_found": "采购预算池已不存在,请联系财务核对预算后重试。",
"travel_reconciliation.query_invalid": "差旅对账筛选条件无效,请检查日期、状态和分页后重试。",
"travel_reconciliation.create_input_invalid": "对账批次资料不完整或格式有误,请检查公司、成本中心和期间后重试。",
"travel_reconciliation.period_reversed": "对账开始日期不能晚于结束日期,请调整对账期间。",
"travel_reconciliation.company_not_found": "对账公司不存在,请刷新公司列表后重新选择。",
"travel_reconciliation.cost_center_not_found": "对账成本中心不存在,请刷新列表后重新选择。",
"travel_reconciliation.cost_center_company_mismatch": "对账成本中心与所选公司不匹配,请重新选择。",
"travel_reconciliation.empty_scope": "所选范围没有可对账的订单或交易,请检查期间和公司范围后重试。",
"travel_reconciliation.batch_id_invalid": "对账批次标识无效,请刷新批次列表后重新选择。",
"travel_reconciliation.item_id_invalid": "对账条目标识无效,请刷新批次详情后重新选择。",
"travel_reconciliation.confirm_input_invalid": "对账确认备注格式有误,请检查填写内容后重新确认。",
"travel_reconciliation.batch_not_editable": "对账批次已不可修改,请刷新并确认批次状态。",
"travel_reconciliation.item_not_pending": "该对账条目当前不是待确认状态,请刷新后核对处理结果。",
"travel_reconciliation.item_not_confirmed": "只有已确认的对账条目可以重开,请刷新并确认条目状态。",
"travel_reconciliation.matched_item_not_reopenable": "自动匹配成功的对账条目不能重开,请检查关联订单和交易记录。",
"travel_reconciliation.batch_has_pending_items": "对账批次仍有未确认条目,请先处理全部条目后再结案。",
"travel_import.sync_disabled": "差旅同步尚未启用,请联系管理员确认后再导入。",
"travel_payment.budget_pool_not_found": "关联预算池不存在,请联系财务检查预算配置后重试。",
"travel_order.import_input_invalid": "差旅订单导入列表格式有误,请检查数据并确保每批为1至100项。",
"travel_order.import_empty": "未识别到有效的差旅订单记录,请检查必填字段和格式后重新导入。",
"travel_order.id_invalid": "差旅订单标识无效,请刷新列表后重新选择。",
"travel_authorization.import_input_invalid": "差旅授权导入列表格式有误,请检查数据并确保每批为1至100项。",
"travel_authorization.import_empty": "未识别到有效的差旅授权记录,请检查必填字段和格式后重新导入。",
"travel_authorization.query_invalid": "差旅授权筛选条件无效,请检查筛选项和分页后重试。",
"travel_transaction.import_input_invalid": "差旅交易导入列表格式有误,请检查数据并确保每批为1至100项。",
"travel_transaction.import_empty": "未识别到有效的差旅交易记录,请检查必填字段和格式后重新导入。",
"travel_transaction.query_invalid": "差旅交易筛选条件无效,请检查筛选项和分页后重试。",
"travel_transaction.id_invalid": "差旅交易标识无效,请刷新列表后重新选择。",
"travel_order.payment_input_invalid": "差旅付款记录格式有误,请检查付款日期、渠道和凭证号后重试。",
"travel_order.invalid_pay_status": "该差旅订单当前不能标记付款,请刷新并确认订单状态。",
"travel_order.already_paid_conflict": "该差旅订单已记录付款,本次付款资料与原记录不同,请核对原付款记录。",
"travel_order.settled_transaction_required": "该订单缺少金额和币种一致的已结算扣款交易,请先核对并补齐交易记录。",
"travel_transaction.freeze_input_invalid": "冻结建议的原因或备注格式有误,请选择有效原因后重新提交。",
"travel_request.query_invalid": "差旅申请筛选条件无效,请检查状态、分页和筛选项后重试。",
"travel_request.id_invalid": "差旅申请标识无效,请刷新列表后重新选择。",
"travel_request.create_input_invalid": "差旅申请资料不完整或格式有误,请检查事由、日期、费用及行程后重新创建。",
"travel_request.update_input_invalid": "差旅申请修改内容格式有误,请检查填写内容后重试。",
"travel_request.project_link_input_invalid": "项目关联参数无效,请重新选择项目或清除关联后重试。",
"travel_request.segment_time_invalid": "行程出发或到达时间无法识别,请填写有效时间。",
"travel_request.amount_invalid": "差旅预估金额无法识别或超出支持范围,请填写有效金额。",
"travel_request.amount_negative": "差旅预估金额不能为负数,请检查金额后重试。",
"travel_request.segment_time_reversed": "行程到达时间不能早于出发时间,请调整行程时间。",
"travel_request.date_range_reversed": "差旅结束日期不能早于开始日期,请调整出差日期。",
"travel_request.precheck_blocked": "差旅申请未通过费用预检,请检查预算与费用标准提示,处理后重新提交。",
"travel_request.company_unavailable": "所选公司不存在、已停用或已归档,请选择有效公司。",
"travel_request.cost_center_unavailable": "所选成本中心不存在、已停用或已归档,请选择有效成本中心。",
"travel_request.cost_center_company_mismatch": "成本中心与所选公司不匹配,请重新选择同一公司的成本中心。",
"travel_request.update_empty": "尚未填写差旅修改内容,请修改至少一项后保存。",
"travel_request.only_draft_editable": "只有草稿差旅申请可以修改,请刷新并确认申请状态。",
"travel_request.only_draft_submittable": "只有草稿差旅申请可以提交,请刷新并确认申请状态,避免重复提交。",
"travel_request.segments_required": "差旅申请缺少行程,请补充至少一段行程后提交。",
"travel_request.workflow_missing": "差旅审批流程尚未配置,请联系管理员配置后重新提交。",
"travel_request.approver_unavailable": "差旅审批流程没有可处理的审批人,请联系管理员检查审批人配置后重试。",
"travel_request.project_linking_disabled": "差旅项目关联功能暂不可用,请联系管理员确认开放范围后重试。",
"travel_request.only_reviewing_withdrawable": "只有审批中的差旅申请可以撤回,请刷新并确认申请状态。",
"project.create_input_invalid": "项目新增内容不完整或格式有误,请检查填写内容后重试。",
"project.update_input_invalid": "项目修改内容不完整或格式有误,请检查填写内容后重试。",
"project.member_create_input_invalid": "项目成员新增内容不完整或格式有误,请检查填写内容后重试。",
"project.member_update_input_invalid": "项目成员修改内容不完整或格式有误,请检查填写内容后重试。",
"project.milestone_create_input_invalid": "里程碑新增内容不完整或格式有误,请检查填写内容后重试。",
"project.milestone_update_input_invalid": "里程碑修改内容不完整或格式有误,请检查填写内容后重试。",
"project.task_create_input_invalid": "项目任务新增内容不完整或格式有误,请检查填写内容后重试。",
"project.task_update_input_invalid": "项目任务修改内容不完整或格式有误,请检查填写内容后重试。",
"project.risk_create_input_invalid": "项目风险新增内容不完整或格式有误,请检查填写内容后重试。",
"project.risk_update_input_invalid": "项目风险修改内容不完整或格式有误,请检查填写内容后重试。",
"project.communication_log_create_input_invalid": "项目沟通记录新增内容不完整或格式有误,请检查填写内容后重试。",
"project.communication_log_update_input_invalid": "项目沟通记录修改内容不完整或格式有误,请检查填写内容后重试。",
"project.closure_checklist_update_input_invalid": "项目收口项修改内容不完整或格式有误,请检查填写内容后重试。",
"project.delivery_create_input_invalid": "项目交付记录新增内容不完整或格式有误,请检查填写内容后重试。",
"project.id_invalid": "项目标识无效,请刷新项目列表后重新选择。",
"project.member_id_invalid": "项目成员标识无效,请刷新项目详情后重新选择。",
"project.milestone_id_invalid": "里程碑标识无效,请刷新项目详情后重新选择。",
"project.task_id_invalid": "项目任务标识无效,请刷新项目详情后重新选择。",
"project.risk_id_invalid": "项目风险标识无效,请刷新项目详情后重新选择。",
"project.communication_log_id_invalid": "项目沟通记录标识无效,请刷新项目详情后重新选择。",
"project.closure_checklist_id_invalid": "项目收口项标识无效,请刷新项目详情后重新选择。",
"project.feature_disabled": "项目功能暂不可用,请联系管理员确认开放范围后重试。",
"project.linking_disabled": "项目关联功能暂不可用,请联系管理员确认开放范围后重试。",
"project.communication_disabled": "项目沟通记录功能暂不可用,请联系管理员确认开放范围后重试。",
"project.closure_disabled": "项目收口清单功能暂不可用,请联系管理员确认开放范围后重试。",
"project.document_refs_disabled": "项目资料引用功能暂不可用,请联系管理员确认开放范围后重试。",
"project.query_invalid": "项目筛选条件无效,请检查状态和筛选项后重试。",
"project.waiver_note_required": "豁免项目收口项需要填写理由,请补充说明后保存。",
"expense_standard.id_invalid": "费用标准标识无效,请刷新列表后重新选择。",
"expense_standard.query_invalid": "费用标准筛选条件无效,请检查筛选项后重试。",
"expense_standard.create_input_invalid": "费用标准资料不完整或格式有误,请检查必填项后重新创建。",
"expense_standard.update_input_invalid": "费用标准修改内容格式有误,请检查填写内容后重试。",
"expense_standard.update_empty": "尚未填写费用标准修改内容,请修改至少一项后保存。",
"expense_type.id_invalid": "费用类型标识无效,请刷新列表后重新选择。",
"expense_type.query_invalid": "费用类型筛选条件无效,请检查筛选项后重试。",
"expense_type.create_input_invalid": "费用类型资料不完整或格式有误,请检查必填项后重新创建。",
"expense_type.update_input_invalid": "费用类型修改内容格式有误,请检查填写内容后重试。",
"expense_type.update_empty": "尚未填写费用类型修改内容,请修改至少一项后保存。",
"expense_type.code_duplicate": "费用类型编码已存在,请使用其他编码。",
"leave_type.id_invalid": "假别标识无效,请刷新列表后重新选择。",
"leave_type.query_invalid": "假别筛选条件无效,请检查筛选项后重试。",
"leave_type.create_input_invalid": "假别资料不完整或格式有误,请检查必填项后重新创建。",
"leave_type.update_input_invalid": "假别修改内容格式有误,请检查填写内容后重试。",
"leave_type.code_duplicate": "假别编码已存在,请使用其他编码。",
"reimbursement_template.id_invalid": "报销模板标识无效,请刷新列表后重新选择。",
"reimbursement_template.query_invalid": "报销模板筛选条件无效,请检查筛选项后重试。",
"reimbursement_template.create_input_invalid": "报销模板资料不完整或格式有误,请检查必填项后重新创建。",
"reimbursement_template.update_input_invalid": "报销模板修改内容格式有误,请检查填写内容后重试。",
"reimbursement_template.update_empty": "尚未填写报销模板修改内容,请修改至少一项后保存。",
"expense_standard.amount_not_positive": "费用标准金额须大于零,请填写有效的金额。",
"expense_standard.expense_type_not_found": "所选费用类型不存在,请刷新费用类型列表后重新选择。",
"expense_standard.department_required": "部门费用标准需要指定部门,请选择部门后保存。",
"expense_standard.department_unavailable": "所选部门不存在或已归档,请选择有效部门。",
"expense_standard.scope_conflict": "该费用类型在相同范围已有标准,请修改已有标准或选择其他范围。",
"reimbursement_template.expense_types_unavailable": "模板中有不存在或已停用的费用类型,请重新选择有效类型后保存。",
"holiday_calendar.id_invalid": "节假日日历标识无效,请刷新列表后重新选择。",
"holiday_calendar.query_invalid": "节假日日历筛选条件无效,请检查筛选项后重试。",
"holiday_calendar.create_input_invalid": "日历资料不完整或格式有误,请检查编码和名称后重新创建。",
"holiday_calendar.update_input_invalid": "日历修改内容格式有误,请检查填写内容后重试。",
"holiday_calendar.update_empty": "尚未填写日历修改内容,请修改至少一项后保存。",
"holiday_calendar.days_input_invalid": "日历日期列表无效,请检查真实日期、日期类型和名称,且一次不超过500项。",
"holiday_calendar.date_duplicate": "日历日期列表有重复日期,请每个日期只保留一项后重试。",
"holiday_calendar.code_duplicate": "日历编码已存在,请使用其他编码。",
"holiday_calendar.archived_locked": "该日历已归档,无法修改资料或日期;请选择有效日历。",
"holiday_calendar.bound_as_default": "该日历仍是公司的默认日历,请先调整默认日历后再归档。",
"legal_entity.id_invalid": "公司标识无效,请刷新列表后重新选择。",
"legal_entity.query_invalid": "公司筛选条件无效,请检查筛选项后重试。",
"legal_entity.create_input_invalid": "公司资料不完整或格式有误,请检查必填项后重新创建。",
"legal_entity.update_input_invalid": "公司修改内容格式有误,请检查填写内容后重试。",
"legal_entity.update_empty": "尚未填写公司修改内容,请修改至少一项后保存。",
"legal_entity.code_duplicate": "公司编码已存在,请使用其他编码。",
"legal_entity.archived_locked": "该公司已归档,无法修改;请返回列表选择有效记录。",
"cost_center.id_invalid": "成本中心标识无效,请刷新列表后重新选择。",
"cost_center.query_invalid": "成本中心筛选条件无效,请检查筛选项后重试。",
"cost_center.create_input_invalid": "成本中心资料不完整或格式有误,请检查必填项后重新创建。",
"cost_center.update_input_invalid": "成本中心修改内容格式有误,请检查填写内容后重试。",
"cost_center.update_empty": "尚未填写成本中心修改内容,请修改至少一项后保存。",
"cost_center.code_duplicate": "成本中心编码已存在,请使用其他编码。",
"cost_center.archived_locked": "该成本中心已归档,无法修改;请返回列表选择有效记录。",
"document_category.id_invalid": "单据分类标识无效,请刷新列表后重新选择。",
"document_category.query_invalid": "单据分类筛选条件无效,请检查筛选项后重试。",
"document_category.create_input_invalid": "单据分类资料不完整或格式有误,请检查必填项后重新创建。",
"document_category.update_input_invalid": "单据分类修改内容格式有误,请检查填写内容后重试。",
"document_category.update_empty": "尚未填写单据分类修改内容,请修改至少一项后保存。",
"document_category.code_duplicate": "单据分类编码已存在,请使用其他编码。",
"document_category.archived_locked": "该单据分类已归档,无法修改;请返回列表选择有效记录。",
"supplier.id_invalid": "供应商标识无效,请刷新列表后重新选择。",
"supplier.query_invalid": "供应商筛选条件无效,请检查筛选项后重试。",
"supplier.create_input_invalid": "供应商资料不完整或格式有误,请检查必填项后重新创建。",
"supplier.update_input_invalid": "供应商修改内容格式有误,请检查填写内容后重试。",
"supplier.update_empty": "尚未填写供应商修改内容,请修改至少一项后保存。",
"supplier.code_duplicate": "供应商编码已存在,请使用其他编码。",
"supplier.archived_locked": "该供应商已归档,无法修改;请返回列表选择有效记录。",
"legal_entity.reference_not_found": "所选公司不存在,请刷新公司列表后重新选择。",
"legal_entity.reference_archived": "所选公司已归档,请选择有效公司。",
"legal_entity.has_cost_centers": "该公司仍有未归档的成本中心,请先处理成本中心后再归档公司。",
"cost_center.owner_unavailable": "所选负责人不存在或已非在职状态,请选择在职员工。",
"cost_center.parent_self_forbidden": "成本中心不能作为自身的上级,请重新选择上级成本中心。",
"cost_center.parent_not_found": "所选上级成本中心不存在,请刷新列表后重新选择。",
"cost_center.parent_archived": "所选上级成本中心已归档,请选择有效的上级成本中心。",
"cost_center.parent_company_mismatch": "上级成本中心与当前成本中心须属于同一公司,请重新选择。",
"cost_center.parent_cycle_forbidden": "所选上级成本中心会形成循环层级,请选择其他上级。",
"cost_center.has_children": "该成本中心仍有未归档的子级,请先处理子级后再归档。",
"employee.contract_attachment_download_blocked": "此合同附件未通过文件安全校验,暂不能下载,请联系有权限的管理员核查并重新上传有效附件。",
"employee.id_invalid": "员工或档案记录标识无效,请刷新列表后重新选择。",
"employee.lifecycle_disabled": "员工资料与生命周期管理暂不可用,请联系管理员确认功能开放范围。",
"employee.hr_sync_disabled": "企业微信人事主数据同步暂未启用,请联系管理员确认同步安排。",
"employee.hr_sync_unavailable": "企业微信人事主数据同步所需组件不可用,请联系管理员检查系统组件后重试。",
"employee.directory_sync_unavailable": "企业微信通讯录同步所需组件不可用,请联系管理员检查系统组件后重试。",
"employee.contract_date_range_invalid": "员工合同结束日期不能早于开始日期,请调整合同日期后保存。",
"employee.contract_termination_date_invalid": "员工合同终止日期不能早于开始日期,请核对终止日期后保存。",
"employee.training_date_range_invalid": "培训结束日期不能早于开始日期,请调整培训日期后保存。",
"employee.certificate_date_range_invalid": "证书到期日期不能早于取得日期,请核对证书日期后保存。",
"employee.contract_number_duplicate": "已有相同编号的员工合同,请核对合同编号并避免重复登记。",
"employee.active_contract_delete_forbidden": "有效中的员工合同不能直接删除,请先按实际情况登记终止,再填写删除理由。",
"employee.self_profile_fields_forbidden": "本人资料中包含无权修改的字段,请仅修改允许的个人资料,其他变更联系管理员办理。",
"employee.hr_sync_input_invalid": "人事同步参数无效,请检查预览或执行模式、人员范围和数量限制后重试。",
"employee.hr_sync_query_invalid": "人事同步记录筛选条件无效,请重新选择有效的每页条数。",
"employee.directory_retry_input_invalid": "通讯录同步重试参数无效,请重新选择有效的同步操作后重试。",
"employee.contract_create_input_invalid": "员工合同资料无效,请检查编号、合同类型和日期后登记。",
"employee.contract_update_input_invalid": "员工合同修改内容无效,请检查合同类型、日期和需要修改的字段。",
"employee.contract_delete_input_invalid": "请填写有效的合同删除理由后重试。",
"employee.training_create_input_invalid": "培训资料无效,请检查名称、类型、状态和日期后登记。",
"employee.training_update_input_invalid": "培训修改内容无效,请检查类型、状态、日期和需要修改的字段。",
"employee.certificate_create_input_invalid": "证书资料无效,请检查名称、颁发机构和日期后登记。",
"employee.certificate_update_input_invalid": "证书修改内容无效,请检查名称、机构、日期和需要修改的字段。",
"employee.profile_input_invalid": "员工资料修改内容无效,请检查日期、联系方式和需要修改的字段后保存。",
"employee.onboarding_input_invalid": "入职办理资料无效,请检查生效日期、试用期结束日期和备注后重试。",
"employee.regularize_input_invalid": "转正办理资料无效,请检查生效日期和备注后重试。",
"employee.transfer_input_invalid": "调动办理资料无效,请检查生效日期、目标部门、岗位或直属主管后重试。",
"employee.offboarding_input_invalid": "离职办理资料无效,请检查生效日期、最后工作日期和备注后重试。",
"employee.avatar_multipart_required": "头像上传方式无效,请通过头像上传入口重新选择图片。",
"employee.avatar_file_required": "请选择头像图片后上传。",
"employee.avatar_field_invalid": "头像上传字段无效,请刷新页面并重新选择头像图片。",
"employee.avatar_type_invalid": "头像仅支持JPEG、PNG或WebP图片,请选择支持的图片格式。",
"employee.avatar_file_empty": "头像文件为空,请选择有内容的图片后上传。",
"employee.avatar_file_too_large": "头像文件不能超过2MiB,请压缩图片后重新上传。",
"employee.avatar_image_invalid": "头像文件无法解析为图片,请重新导出JPEG、PNG或WebP图片后上传。",
"employee.contract_attachment_multipart_required": "合同附件上传方式无效,请通过附件上传入口重新选择文件。",
"employee.contract_attachment_file_required": "请选择员工合同附件后上传。",
"employee.contract_attachment_field_invalid": "合同附件上传字段无效,请刷新页面并重新选择附件。",
"employee.contract_attachment_type_invalid": "员工合同附件仅支持PDF、JPEG或PNG,请选择支持的文件格式。",
"employee.contract_attachment_file_empty": "合同附件为空,请选择有内容的文件后上传。",
"employee.contract_attachment_file_too_large": "员工合同附件不能超过10MiB,请缩小文件后重新上传。",
"employee.contract_attachment_content_invalid": "合同附件内容与声明格式不符或未通过文件检查,请重新导出有效PDF、JPEG或PNG后上传。",
"user.id_invalid": "员工账号标识无效,请刷新列表后重新选择。",
"user.list_query_invalid": "员工列表筛选条件无效,请检查页码、每页条数和查看范围后重试。",
"user.password_policy_failed": "密码需至少12位,包含大小写字母和数字,并避免常见弱密码,请调整后重试。",
"user.sensitive_input_invalid": "敏感资料填写无效,请核对所选银行、账号格式或证件资料后保存。",
"user.sensitive_update_empty": "没有可保存的敏感资料修改,请填写需要修改的字段后保存。",
"user.department_not_found": "所选员工部门已不存在,请刷新后重新选择部门。",
"user.department_archived": "所选员工部门已归档,请选择可用部门或联系管理员核查。",
"user.manager_self_forbidden": "员工不能担任自己的直属主管,请选择其他在职人员;批量设置时主管不能在所选员工中。",
"user.manager_not_found": "所选直属主管已不存在,请刷新后重新选择。",
"user.manager_inactive": "所选直属主管当前不在职,请选择其他在职人员。",
"user.unique_conflict": "员工资料与已有记录重复,本次操作未完成,请核对工号、邮箱等唯一资料后重试。",
"user.bank_reveal_input_invalid": "请填写2至500字的查看银行账号理由后重试。",
"user.bank_institution_unavailable": "所选银行已停用、归档或不存在,请刷新后重新选择可用银行。",
"user.self_disable_forbidden": "不能停用或归档当前登录账号,请从本次操作中移除自己。",
"user.reset_password_input_invalid": "重置密码内容无效,请填写有效的新密码,或留空使用系统生成的临时密码。",
"user.self_role_change_forbidden": "不能修改当前登录账号自己的角色,请由其他有权限的管理员处理。",
"user.roles_input_invalid": "角色设置无效,请至少选择一个可用角色后保存。",
"user.batch_transfer_input_invalid": "批量调部门资料无效,请选择员工和目标部门后重试。",
"user.batch_manager_input_invalid": "批量设置主管资料无效,请选择员工和直属主管后重试。",
"user.batch_toggle_input_invalid": "批量启用或停用的员工列表无效,请重新选择员工后重试。",
"user.batch_limit_exceeded": "每次最多处理50名不同员工,请缩小选择范围后分批操作。",
"user.lifecycle_disabled": "员工生命周期管理暂不可用,请联系管理员确认功能开放范围。",
"user.generated_password_unavailable": "系统未能生成符合安全要求的临时密码,本次操作未完成,请联系管理员核查。",
"department.id_invalid": "部门标识无效,请刷新列表后重新选择。",
"department.query_invalid": "部门筛选条件无效,请重新选择是否包含已归档部门。",
"department.create_input_invalid": "部门资料无效,请检查部门名称和上级部门后创建。",
"department.update_input_invalid": "部门修改内容无效,请检查部门名称和上级部门后保存。",
"department.update_empty": "没有可保存的部门修改,请填写名称或调整上级部门后保存。",
"department.parent_not_found": "所选上级部门已不存在,请刷新后重新选择上级部门。",
"department.parent_archived": "所选上级部门已归档,请选择可用部门。",
"department.archived_locked": "该部门已归档,无法修改,请刷新确认当前部门状态。",
"department.parent_self_forbidden": "部门不能把自己设为上级,请选择其他部门或清空上级。",
"department.parent_cycle_forbidden": "所选上级部门会形成层级循环,请选择当前部门下级以外的部门。",
"department.children_exist": "该部门仍有下级部门,暂不能归档,请先调整下级部门的归属。",
"department.active_employees_exist": "该部门仍有在职员工,暂不能归档,请先办理员工部门调整。",
"budget.company_required": "选择成本中心时必须同时指定所属公司,请补齐公司后重试。",
"budget.scope_mixed": "预算范围不能同时使用部门和公司或成本中心,请选择一种范围后重试。",
"budget.cost_center_required": "公司范围预算需要指定成本中心,请补齐该公司的成本中心后重试。",
"budget.pool_changed": "预算池状态或适用范围已变化,请刷新后重新提交。",
"budget.pool_ambiguous": "同一成本中心匹配到多个有效预算,无法确定使用哪一个,请联系预算管理员核查重叠的预算期间。",
"budget.effective_date_invalid": "用于匹配预算的业务日期无效,请核对单据日期;仍无法处理时联系管理员。",
"budget_rule.id_invalid": "预算控制规则标识无效,请刷新列表后重新选择。",
"budget_rule.query_invalid": "预算控制规则筛选条件无效,请重新选择业务类型和启用状态。",
"budget_rule.create_input_invalid": "预算控制规则资料无效,请检查名称、业务类型、控制方式、优先级和适用范围后创建。",
"budget_rule.update_input_invalid": "预算控制规则修改内容无效,请检查要修改的字段并填写有效内容后保存。",
"budget_rule.company_required": "本次预算规则填写了成本中心,请同时填写所属公司后保存。",
"budget_rule.expense_type_unsupported": "指定费用类型时,本次提交的业务类型必须为报销,请同时选择报销类型或移除费用类型后保存。",
"budget.id_invalid": "预算标识无效,请刷新预算列表后重新选择。",
"budget.query_invalid": "预算筛选条件无效,请检查状态、所属范围和分页后重试。",
"budget.create_input_invalid": "预算资料不完整或格式有误,请检查编号、名称、期间、金额和预警比例后创建。",
"budget.update_input_invalid": "预算修改内容无效,请检查名称、金额、预警比例和状态后保存。",
"budget.update_empty": "没有可保存的预算修改,请修改资料后再保存。",
"budget.period_invalid": "预算结束日期不能早于开始日期,请调整预算期间后重试。",
"budget.department_not_found": "所选预算部门已不存在,请刷新后重新选择部门。",
"budget.department_archived": "所选预算部门已归档,请选择可用部门或联系管理员核查。",
"budget.legal_entity_not_found": "所选预算公司已不存在,请刷新后重新选择公司。",
"budget.legal_entity_unavailable": "所选预算公司已停用或归档,请选择可用公司或联系管理员核查。",
"budget.cost_center_not_found": "所选预算成本中心已不存在,请刷新后重新选择成本中心。",
"budget.cost_center_unavailable": "所选预算成本中心已停用或归档,请选择可用成本中心或联系管理员核查。",
"budget.cost_center_company_mismatch": "所选成本中心不属于当前预算公司,请重新选择匹配的公司和成本中心。",
"performance.id_invalid": "绩效周期或员工标识无效,请刷新列表后重新选择。",
"performance.cycle_create_input_invalid": "绩效周期资料无效,请检查名称、周期类型和起止日期后创建。",
"performance.cycle_update_input_invalid": "绩效周期修改内容无效,请检查名称、类型、日期和状态,并填写需要修改的内容。",
"performance.review_input_invalid": "绩效评价内容无效,请检查总分和各维度评分(1至5分,以0.5分递增)、重复维度及评语长度。",
"performance.cycle_date_range_invalid": "绩效周期结束日期不能早于开始日期,请调整起止日期后重试。",
"performance.disabled": "绩效管理功能暂不可用,请联系管理员确认功能开放范围。",
"performance.active_cycle_exists": "已有进行中的绩效周期,请先核对并结束该周期,再激活其他周期。",
"performance.completed_cycle_locked": "该绩效周期已结束,无法再修改,请刷新查看当前周期状态。",
"performance.active_cycle_only_complete": "进行中的绩效周期只能结束,不能修改资料,请保留周期资料后执行结束操作。",
"performance.draft_cannot_complete": "草稿绩效周期不能直接结束,请先激活周期并完成评价,再结束周期。",
"performance.employee_inactive": "该员工当前不在职,不能录入绩效评价,请刷新并核对员工状态。",
"performance.active_cycle_required": "只有进行中的绩效周期可以录入评价,请刷新并选择进行中的周期。",
"payroll.id_invalid": "薪资记录或员工标识无效,请刷新列表后重新选择。",
"payroll.item_create_input_invalid": "薪资项资料不完整或格式有误,请检查名称、收入或扣除类型及排序后创建。",
"payroll.item_update_input_invalid": "薪资项修改内容无效,请检查名称、类型和排序后保存。",
"payroll.profile_input_invalid": "薪资档案资料无效,请检查生效日期、薪资项和金额,并移除重复薪资项。",
"payroll.payslip_update_input_invalid": "工资条修改内容无效,请检查薪资项和金额,并移除重复薪资项。",
"payroll.amount_invalid": "薪资金额格式无效,请填写不小于零且最多两位小数的金额。",
"payroll.item_name_duplicate": "已有同名薪资项,请更换名称后保存。",
"payroll.item_not_found": "部分薪资项已不存在,请刷新后重新选择薪资项。",
"payroll.month_already_generated": "该月工资条已生成,请查看现有工资条,避免重复生成。",
"payroll.active_employees_missing": "没有可生成工资条的在职员工,请先核对员工状态。",
"payroll.enabled_items_missing": "没有启用的薪资项,请先设置并启用薪资项,再生成工资条。",
"payroll.profiles_missing": "部分员工缺少该月有效的薪资档案,请补齐档案和生效日期后再生成工资条。",
"payroll.payslip_not_editable": "此工资条已发放,无法继续修改,请刷新查看当前状态。",
"payroll.month_not_generated": "该月尚未生成工资条,请先生成并核对,再发放。",
"payroll.month_already_issued": "该月工资条已全部发放,无需重复操作,请刷新查看。",
"payroll.month_invalid": "工资月份参数无效,请重新选择有效的年和月后重试。",
"payroll.export_query_invalid": "工资导出条件无效,请检查月份和导出格式后重试。",
"attendance.month_query_invalid": "考勤月报筛选条件无效,请检查月份和所选员工后重试。",
"attendance.month_invalid": "考勤月份无效,请重新选择有效的年和月后重试。",
"user.import_file_too_large": "导入文件超过上传大小限制,请拆分或缩小文件后重试。",
"user.import_file_missing": "请选择员工导入文件后重试。",
"user.import_file_invalid": "导入文件格式损坏或无法解析,请重新导出为CSV或XLSX后再上传。",
"user.import_extension_unsupported": "员工导入仅支持CSV或XLSX文件,请使用导入模板重新保存。",
"user.import_mime_unsupported": "导入文件类型无法识别,请将表格另存为CSV或XLSX后重新上传。",
"user.import_headers_missing": "导入表格缺少必填列,请按模板补齐工号、姓名、邮箱和部门列。",
"user.import_file_empty": "导入表格没有员工数据,请在表头下填写员工资料后重试。",
"user.import_row_limit_exceeded": "每次最多导入500行员工数据,请拆分表格后分别预览和导入。",
"announcement.id_invalid": "公告标识无效,请刷新列表后重新选择。",
"announcement.create_input_invalid": "公告资料不完整或格式有误,请检查标题、内容和时间后重新创建。",
"announcement.update_input_invalid": "公告修改内容无效,请检查修改项及时间后重新保存。",
"announcement.acknowledgement_input_invalid": "请先勾选签收确认,再提交签收。",
"announcement.query_invalid": "公告筛选条件无效,请刷新列表后重试。",
"announcement.disabled": "公告功能暂未启用,请联系管理员确认。",
"announcement.statement_required": "制度签收公告必须填写签收声明,请补充后保存。",
"announcement.acknowledged_content_locked": "此公告已有员工签收,内容变更请发布新版本。",
"announcement.acknowledgement_not_required": "此公告无需签收,请返回公告列表查看。",
"announcement.statement_missing": "此公告缺少签收声明,暂时无法签收,请联系管理员补充。",
"employee_directory.query_invalid": "员工筛选条件无效,请检查入职日期、人员状态及导出格式后重试。",
"employee_directory.hire_date_range_invalid": "入职结束日期不能早于开始日期,请重新选择筛选范围。",
"contract_registry.query_invalid": "合同筛选条件无效,请检查到期日期、合同状态及导出格式后重试。",
"contract_registry.expiry_date_range_invalid": "合同到期结束日期不能早于开始日期,请重新选择筛选范围。",
"contract_registry.expiring_query_invalid": "即将到期的筛选天数无效,请输入1至365之间的整数。",
"presence_history.date_range_invalid": "在岗历史结束日期不能早于开始日期,请重新选择日期范围。",
"presence_history.date_range_too_large": "在岗历史每次最多查询31天,请缩小日期范围后重试。",
"dashboard.query_invalid": "统计筛选条件无效,请检查日期、部门和显示数量后重试。",
"dashboard.date_range_invalid": "统计结束日期不能早于开始日期,请重新选择日期范围。",
"ap_operations.disabled": "付款工作台暂未启用,请联系管理员确认。",
"ap_operations.ledger_query_invalid": "财务台账筛选条件无效,请检查日期、状态和分页后重试。",
"user.create_input_invalid": "账号资料不完整或格式有误,请检查姓名、邮箱、入职日期和角色后重新创建。",
"user.update_input_invalid": "账号修改内容格式有误,请检查联系方式、入职日期和角色后重新保存。",
"user.employee_no_immutable": "工号创建后不能修改,请保留原工号;如登记有误,请联系管理员核查。",
"user.update_empty": "没有可保存的账号修改,请修改资料后再保存。",
"user.import_rows_invalid": "导入表格中仍有错误行,本次未导入任何员工,请先预览并修正标出的行后重试。",
"notification.list_query_invalid": "通知筛选条件无效,请检查页码、每页条数和未读筛选后重试。",
"notification.delivery_query_invalid": "通知投递筛选条件无效,请重新选择通知、渠道或投递状态后重试。",
"notification.id_invalid": "通知或渠道标识无效,请刷新列表后重新选择。",
"notification.channel_input_invalid": "通知渠道资料不完整或格式有误,请检查名称、发送方式、渠道配置和消息模板。",
"notification.channel_name_duplicate": "已有同名通知渠道,请更换名称后保存。",
"notification.encryption_key_unavailable": "通知渠道加密配置不可用,请联系管理员检查原字段加密密钥后再保存;不要生成新密钥替代。",
"todo.input_invalid": "待办资料不完整或格式有误,请检查标题、负责人、截止日期和优先级后重试。",
"todo.id_invalid": "待办标识无效,请刷新列表后重新选择。",
"todo.disabled": "待办功能暂未启用,请联系管理员确认。",
"todo.query_invalid": "待办筛选条件无效,请检查状态、范围和负责人后重试。",
"portal.disabled": "工作台暂未启用,请联系管理员确认。",
"workflow.task_already_processed": "此审批任务已处理,请刷新待办查看最新进度。",
"workflow.entity_unavailable": "审批对应的单据不存在或当前不可处理,请联系管理员核查。",
"workflow.self_approval_forbidden": "不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。",
-107
查看文件
@@ -1,71 +1,5 @@
import { apiRequestData } from "./http";
export type ErpMasterDataResourceType = "legal_entity" | "cost_center" | "supplier";
export type ErpGateStatus = {
envEnabled: boolean;
effectiveEnabled: boolean;
effectiveReason: string;
provider: string;
masterDataEndpointConfigured: boolean;
journalEndpointConfigured: boolean;
masterDataSyncCron: string | null;
};
export type ErpMasterDataSyncSummary = {
requested: number;
created: number;
updated: number;
archived: number;
skipped: number;
sampleSkipped: Array<{ key: string; reason: string }>;
};
export type ErpMasterDataSyncItem = {
resourceType: ErpMasterDataResourceType;
status: string;
lastStartedAt: string | null;
lastFinishedAt: string | null;
lastSuccessAt: string | null;
lastError: string | null;
lastResult: ErpMasterDataSyncSummary | null;
};
export type ErpMasterDataStatusResponse = {
gate: ErpGateStatus;
items: ErpMasterDataSyncItem[];
triggerMode?: string;
};
export type ReimbursementJournalPreview = {
journal: {
journalType: string;
sourceEventName: string;
entityType: string;
entityId: string;
documentNo: string | null;
currencyCode: string;
totalAmount: string | null;
occurredAt: string | null;
paymentVoucherNo: string | null;
paymentDate: string | null;
dimensions: {
departmentId: string | null;
ownerEmployeeId: string | null;
submitterEmployeeId: string | null;
legalEntityCode: string | null;
costCenterCode: string | null;
};
};
exportState: {
externalId: string | null;
lastOutboxId: string | null;
lastOutboxStatus: string | null;
lastError: string | null;
exportedAt: string | null;
};
};
export type IntegrationOutboxStatus =
| "pending"
| "processing"
@@ -159,47 +93,6 @@ function toQueryString(query: Record<string, string | number | boolean | undefin
return encoded ? `?${encoded}` : "";
}
export async function triggerErpSync(reimbursementId: string, runNow = true) {
return apiRequestData<{
outboxId: string;
status: string;
duplicated: boolean;
}>(`/api/integrations/erp/sync/${reimbursementId}?runNow=${runNow ? "1" : "0"}`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function fetchErpMasterDataStatus() {
return apiRequestData<ErpMasterDataStatusResponse>("/api/integrations/erp/master-data/status");
}
export async function syncErpMasterData(payload: { resources?: ErpMasterDataResourceType[] }) {
return apiRequestData<ErpMasterDataStatusResponse>("/api/integrations/erp/master-data/sync", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function previewReimbursementJournal(reimbursementId: string) {
return apiRequestData<ReimbursementJournalPreview>(
`/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`,
);
}
export async function exportReimbursementJournal(reimbursementId: string, runNow = true) {
return apiRequestData<{
outboxId: string;
status: string;
duplicated: boolean;
requeued: boolean;
eventType: string;
}>(`/api/integrations/erp/journals/reimbursements/${reimbursementId}/export?runNow=${runNow ? "1" : "0"}`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function fetchIntegrationOutbox(query: IntegrationOutboxQuery = {}) {
return apiRequestData<IntegrationOutboxListResponse>(
`/api/integrations/outbox${toQueryString({
+3 -1
查看文件
@@ -3,7 +3,7 @@ import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/
import type { RiskFlag } from "./feeControl";
import type { ProcessingProgress } from "../types/processingProgress";
export type PaymentBatchEntityType = "reimbursement" | "travel_order";
export type PaymentBatchEntityType = "reimbursement" | "travel_order" | "loan_repayment";
export type PaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled";
export type PaymentChannel = "bank" | "alipay" | "wechat" | "cash" | "other";
@@ -87,6 +87,8 @@ export type PaymentBatch = {
export type PaymentBatchDetail = PaymentBatch & {
processingProgress: ProcessingProgress;
loanRepayment?: import("./companyLoans").LoanRepayment;
loanRepaymentId?: string | null;
};
export type PaymentReadiness = {
-103
查看文件
@@ -122,78 +122,6 @@ export type PurchaseOrderArchive = {
archivedFiles: PurchaseArchivedFile[];
};
export type PurchaseErpTrackStatus =
| "not_triggered"
| "pending"
| "processing"
| "succeeded"
| "failed"
| "dead_letter"
| "skipped";
export type PurchaseErpBinding = {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
};
export type PurchaseErpOutbox = {
id: string;
eventType: string;
status: string;
attempts: number;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
};
export type PurchaseErpTrackStage = {
eligible: boolean;
reason: string | null;
status: PurchaseErpTrackStatus | string;
preview: Record<string, unknown>;
outbox: PurchaseErpOutbox | null;
binding?: PurchaseErpBinding | null;
bindings?: {
po: PurchaseErpBinding | null;
contract: PurchaseErpBinding | null;
};
};
export type PurchaseRequestErpTrack = {
purchaseRequest: {
id: string;
requestNo: string | null;
status: string;
totalAmount: string;
supplier: {
id: string;
code: string | null;
name: string | null;
} | null;
archiveOrder: {
id: string;
poNo: string;
contractNo: string | null;
archivedAt: string | null;
} | null;
};
stages: {
commitmentJournal: PurchaseErpTrackStage;
accrualJournal: PurchaseErpTrackStage;
archiveSummary: PurchaseErpTrackStage;
};
permissions: {
canRetry: boolean;
};
};
export type PurchaseRequestErpRequeueTarget = "commitment_journal" | "accrual_journal" | "archive_summary";
export type PurchaseRequestDetail = PurchaseRequestSummary & {
items: PurchaseRequestItem[];
attachments: PurchaseAttachment[];
@@ -397,34 +325,3 @@ export async function downloadPurchaseAttachment(purchaseRequestId: string, atta
},
);
}
export async function fetchPurchaseRequestErpTrack(purchaseRequestId: string) {
return apiRequestData<PurchaseRequestErpTrack>(
`/api/integrations/erp/procurement/purchase-requests/${purchaseRequestId}/track`,
);
}
export async function requeuePurchaseRequestErpSync(
purchaseRequestId: string,
payload: {
targets: PurchaseRequestErpRequeueTarget[];
},
runNow = true,
) {
return apiRequestData<{
items: Array<{
target: PurchaseRequestErpRequeueTarget;
outboxId: string;
status: string;
duplicated: boolean;
requeued: boolean;
eventType: string;
}>;
}>(
`/api/integrations/erp/procurement/purchase-requests/${purchaseRequestId}/requeue${toQueryString({ runNow })}`,
{
method: 'POST',
body: JSON.stringify(payload),
},
);
}
-34
查看文件
@@ -87,33 +87,6 @@ export type TravelPaymentBoundInvoice = {
linkedTravelOrderId: string | null;
};
export type TravelPaymentErpStage = {
binding: {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
} | null;
outbox: {
id: string;
eventType: string;
status: string;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
} | null;
exportState: {
externalId: string | null;
lastOutboxId: string | null;
lastOutboxStatus: string | null;
lastError: string | null;
exportedAt: string | null;
};
};
export type TravelPaymentOrder = {
id: string;
externalOrderNo: string;
@@ -128,7 +101,6 @@ export type TravelPaymentOrder = {
paidBy: string | null;
boundInvoices: TravelPaymentBoundInvoice[];
matchedTransactions: TravelPaymentTransaction[];
erpStage: TravelPaymentErpStage;
actions: {
canMarkPaid: boolean;
canBindInvoice: boolean;
@@ -242,12 +214,6 @@ export async function markTravelOrderPaid(
paymentVoucherNo: string | null;
paymentDate: string | null;
paymentChannel: TravelPaymentChannel | null;
erpJournal: {
eventType: string;
outboxId: string | null;
dedupeKey: string;
enqueued: boolean;
};
}>(`/api/travel-orders/${travelOrderId}/mark-paid`, {
method: "POST",
body: JSON.stringify(payload),
-91
查看文件
@@ -107,7 +107,6 @@ export type TravelReconciliationBatchDetail = TravelReconciliationBatchSummary &
items: TravelReconciliationItem[];
permissions: {
canManagePayments: boolean;
canRetryErp: boolean;
};
};
@@ -135,84 +134,10 @@ export type TravelReconciliationCreatePayload = {
periodEnd: string;
};
export type TravelReconciliationErpTrack = {
batch: {
id: string;
batchNo: string;
status: string;
periodStart: string | null;
periodEnd: string | null;
legalEntity: {
id: string;
code: string;
name: string;
} | null;
costCenter: {
id: string;
code: string;
name: string;
} | null;
};
stage: {
summaryExport: {
eligible: boolean;
reason: string | null;
status: string;
preview: {
exportType: string;
entityType: string;
entityId: string | null;
batchNo: string | null;
occurredAt: string | null;
};
outbox: {
id: string;
eventType: string;
status: string;
attempts: number;
lastError: string | null;
processedAt: string | null;
updatedAt: string | null;
} | null;
bindings: {
payableSummary: {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
} | null;
settlementBatch: {
id: string;
provider: string;
entityType: string;
localId: string;
externalId: string | null;
externalCode: string | null;
lastSyncedAt: string | null;
} | null;
};
};
};
permissions: {
canRetry: boolean;
canSeeExternalRefs: boolean;
};
};
export type TravelReconciliationFinalizeResponse = {
batchId: string;
batchNo: string;
status: TravelReconciliationBatchStatus;
erpExport: {
eventType: string;
outboxId: string | null;
duplicated: boolean;
requeued: boolean;
enqueued: boolean;
};
};
export type TravelReconciliationRequeueResponse = {
@@ -281,19 +206,3 @@ export async function finalizeTravelReconciliationBatch(batchId: string) {
},
);
}
export async function fetchTravelReconciliationErpTrack(batchId: string) {
return apiRequestData<TravelReconciliationErpTrack>(
`/api/integrations/erp/travel/reconciliation-batches/${batchId}/track`,
);
}
export async function requeueTravelReconciliationBatch(batchId: string) {
return apiRequestData<TravelReconciliationRequeueResponse>(
`/api/integrations/erp/travel/reconciliation-batches/${batchId}/requeue`,
{
method: 'POST',
body: JSON.stringify({}),
},
);
}
+2
查看文件
@@ -86,6 +86,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"payment.batch.review",
],
finance: [
"loan.manage",
"finance.ledger.view",
...EMPLOYEE_BASE_PERMISSIONS,
...MASTERDATA_VIEW_PERMISSIONS,
@@ -107,6 +108,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"payroll.manage",
],
finance_mgr: [
"loan.manage",
"finance.ledger.view",
...EMPLOYEE_BASE_PERMISSIONS,
...MASTERDATA_MANAGE_PERMISSIONS,
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../../auth/AuthContext";
import { downloadLoanEvidence, saveLoanAction, type LoanRepayment } from "../../api/companyLoans";
import type { PaymentBatchReceiptAttachment } from "../../api/paymentBatches";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
import { downloadBlob } from "../../lib/download";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { formatDisplayDate } from "../../lib/displayFormat";
export function LoanPaymentDetails({ repayment, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) {
const { me } = useAuth(); const cache = useQueryClient();
const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
const [action, setAction] = useState<"handoff" | "failure" | "download" | null>(null);
const [text, setText] = useState(""); const [attachmentId, setAttachmentId] = useState("");
const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null);
const finance = me?.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r));
const reviewer = me?.roleCodes.some((r) => ["manager", "ceo"].includes(r));
const mutation = useMutation({ mutationFn: async () => {
if (action === "handoff") await saveLoanAction(`/loan-repayments/${repayment.id}/accounting-handoff`, { reference: text });
if (action === "failure") await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/bank-failure-review`, { attachmentId });
if (action === "download" && evidence) {
const result = await downloadLoanEvidence(evidence.loanId, evidence.id, text, repayment.paymentBatchId);
if (active.current) downloadBlob(result.blob, result.filename ?? evidence.name);
}
}, onSuccess: async () => {
if (!active.current) return;
setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] })));
} });
function open(next: typeof action) { setText(""); setAttachmentId(""); mutation.reset(); setAction(next); }
return <section className="card-std space-y-3 p-4" aria-label="本金还款分配">
<h2 className="font-semibold">原借款分配与会计交接</h2>
<p className="break-words text-sm">{repayment.reason}</p>
{repayment.bankResultState === "unknown" ? <p className="text-sm text-amber-800">银行结果待查证;本金保持占用,不能取消释放或重复付款。</p> : null}
{repayment.bankResultState === "confirmed_failed" ? <p className="text-sm text-amber-800">银行失败证据已由独立人员确认。请财务取消本申请释放额度,保留原记录。</p> : null}
{repayment.allocations.map((allocation) => <div key={allocation.loanId} className="space-y-2 rounded-md border p-3 text-sm">
<p className="break-all font-medium">{allocation.loanNo} · 本次归还 ¥{allocation.amount}</p>
<p>借入 {formatDisplayDate(allocation.receivedDate)} · 核对截至 {formatDisplayDate(allocation.balanceAsOf)}</p>
<p className="break-words">原凭据:{allocation.originReference}</p>
<div className="flex flex-wrap gap-2">{allocation.evidences.map((item) => <Button key={item.id} variant="link" className="max-w-full whitespace-normal break-all"
onClick={() => { setEvidence({ loanId: allocation.loanId, id: item.id, name: item.originalName }); open("download"); }}>{item.originalName}</Button>)}</div>
</div>)}
<p className="text-sm">会计交接:{repayment.accountingHandoffRef ?? "待交接(与银行付款登记分别确认)"}</p>
<div className="flex flex-wrap gap-2">
{finance && repayment.status === "executed" && !repayment.accountingHandoffAt ? <Button variant="outline" onClick={() => open("handoff")}>确认会计交接</Button> : null}
{reviewer && ["reviewed", "failed"].includes(repayment.status) && repayment.bankResultState === "unknown" ? <Button variant="outline" onClick={() => open("failure")}>复核银行失败证据</Button> : null}
</div>
<Dialog open={action !== null} onOpenChange={(value) => { if (!value && !mutation.isPending) setAction(null); }}>
<DialogContent><DialogHeader><DialogTitle>{action === "handoff" ? "确认会计交接" : action === "failure" ? "独立复核银行失败证据" : "下载原借款材料"}</DialogTitle>
<DialogDescription>{action === "failure" ? "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"}</DialogDescription></DialogHeader>
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(); }}>
{mutation.error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error)}</p> : null}
{action === "failure" ? <label className="grid gap-2 text-sm">银行失败回单<select required className="min-h-11 rounded border px-3" value={attachmentId} onChange={(e) => setAttachmentId(e.target.value)}>
<option value="">请选择</option>{attachments.filter((item) => !item.archivedAt).map((item) => <option key={item.id} value={item.id}>{item.originalName}</option>)}
</select></label> : <label className="grid gap-2 text-sm">{action === "handoff" ? "会计凭证或交接引用" : "下载用途"}<Input required minLength={2} maxLength={action === "handoff" ? 200 : 500} value={text} onChange={(e) => setText(e.target.value)} /></label>}
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? "处理中…" : "确认"}</Button>
</form>
</DialogContent>
</Dialog>
</section>;
}
@@ -38,6 +38,7 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
{ to: "/company-loans", label: "公司借款", icon: "account_balance", anyOf: ["loan.manage"] },
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["finance.ledger.view"] },
{ to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
@@ -138,7 +139,7 @@ function renderBackupSection(items: ResolvedNavItem[], pathname: string, permiss
}
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
const paymentPaths = new Set(["/payment-batches", "/ap-operations"]);
const paymentPaths = new Set(["/payment-batches", "/ap-operations", "/company-loans"]);
const financeOnlyPaths = new Set(["/invoice-pool"]);
const backupOnlyPaths = new Set(BACKUP_NAV_ORDER);
const mapItems = (paths: string[]) =>
@@ -51,17 +51,6 @@ function paymentChannelLabel(value: TravelPaymentChannel | string | null) {
return "-";
}
function erpStageLabel(order: TravelPaymentOrder) {
const status = order.erpStage.exportState.lastOutboxStatus;
if (status) {
return status;
}
if (order.erpStage.binding?.externalId) {
return "已同步";
}
return "摘要导出未开启";
}
type TravelPaymentTrackCardProps = {
travelRequestId: string;
};
@@ -250,7 +239,6 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
<div>金额:{order.amount} {order.currency}</div>
<div>付款凭证:{order.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}</div>
<div>导出状态:{erpStageLabel(order)}</div>
</div>
</div>
{canManagePayments ? (
@@ -10,10 +10,8 @@ import {
createTravelReconciliationBatch,
fetchTravelReconciliationBatchDetail,
fetchTravelReconciliationBatches,
fetchTravelReconciliationErpTrack,
finalizeTravelReconciliationBatch,
reopenTravelReconciliationItem,
requeueTravelReconciliationBatch,
type TravelReconciliationBatchStatus,
type TravelReconciliationItem,
} from '../../api/travelReconciliation';
@@ -113,12 +111,6 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
queryFn: () => fetchTravelReconciliationBatchDetail(selectedBatchId),
});
const trackQuery = useQuery({
enabled: Boolean(selectedBatchId),
queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId],
queryFn: () => fetchTravelReconciliationErpTrack(selectedBatchId),
});
useEffect(() => {
if (selectedBatchId || !(listQuery.data?.items?.length)) {
return;
@@ -179,7 +171,6 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'detail', selectedBatchId] }),
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'list'] }),
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }),
]);
toast.success(`已确认差异:${variables.item.snapshot.externalOrderNo ?? variables.item.snapshot.externalTxnId ?? variables.item.id}`);
},
@@ -199,7 +190,6 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'detail', selectedBatchId] }),
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'list'] }),
queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }),
]);
toast.success('差异项已重新打开');
},
@@ -228,29 +218,12 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
},
});
const requeueMutation = useMutation({
mutationFn: async () => {
if (!selectedBatchId) {
throw new Error('请选择待重试的对账批次');
}
return requeueTravelReconciliationBatch(selectedBatchId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] });
toast.success('交接摘要已重新入队');
},
onError: (error) => {
toast.error(resolveError(error));
},
});
function handleConfirm(item: TravelReconciliationItem) {
setConfirmItemTarget(item);
setConfirmItemNote(item.confirmNote ?? '');
}
const detail = detailQuery.data ?? null;
const track = trackQuery.data ?? null;
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
return (
@@ -436,11 +409,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
{finalizeMutation.isPending ? '收口中...' : detail.status === 'reconciled' ? '已收口' : 'Finalize'}
</Button>
) : null}
{track?.permissions.canRetry ? (
<Button type="button" variant="secondary" onClick={() => requeueMutation.mutate()} disabled={requeueMutation.isPending}>
{requeueMutation.isPending ? '重试中...' : '重试导出'}
</Button>
) : null}
</div>
</div>
@@ -464,40 +433,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
</div>
</div>
<div className="card-std space-y-4 p-6">
<div>
<h3 className="text-base font-semibold text-zinc-900">交接摘要</h3>
<p className="mt-1 text-sm text-zinc-500">批次级摘要导出轨道,不会回改订单、申请或预算原值。</p>
</div>
{trackQuery.isLoading ? <CardSkeleton lines={4} /> : null}
{trackQuery.isError ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
摘要轨道加载失败:{resolveError(trackQuery.error)}
</p>
) : null}
{track ? (
<div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4">
<div>
<p className="text-zinc-500">阶段状态</p>
<div className="mt-1"><StatusBadge status={track.stage.summaryExport.status} /></div>
</div>
<div>
<p className="text-zinc-500">Outbox</p>
<p className="mt-1 font-medium text-zinc-900">{track.stage.summaryExport.outbox?.id ?? '未入队'}</p>
</div>
<div>
<p className="text-zinc-500">应付摘要</p>
<p className="mt-1 font-medium text-zinc-900">{track.stage.summaryExport.bindings.payableSummary?.externalCode ?? '未生成'}</p>
</div>
<div>
<p className="text-zinc-500">结算批次</p>
<p className="mt-1 font-medium text-zinc-900">{track.stage.summaryExport.bindings.settlementBatch?.externalCode ?? '未生成'}</p>
</div>
</div>
) : null}
</div>
<div className="card-std space-y-4 p-6">
<div>
+3 -1
查看文件
@@ -3,7 +3,7 @@ import {
resolveManagementRoute,
} from "./managementRoutes";
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"];
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/company-loans", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"];
export const ADMIN_NAV_ORDER = ["/system/settings"];
export const BACKUP_NAV_ORDER = ["/weekly-reports", "/announcements", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"];
@@ -19,6 +19,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/purchase-requests", title: "采购", group: "业务" },
{ path: "/travel-requests", title: "差旅申请", group: "更多" },
{ path: "/budgets", title: "预算管理", group: "财务" },
{ path: "/company-loans", title: "公司借款", group: "财务" },
{ path: "/mobile/company-loans", title: "移动公司借款", group: "财务" },
{ path: "/payment-batches", title: "付款", group: "财务" },
{ path: "/ap-operations", title: "应付账款", group: "财务" },
{ path: "/invoice-pool", title: "票据", group: "财务" },
+4
查看文件
@@ -129,6 +129,10 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
return "/mobile";
}
if (normalizedPath === "/company-loans") {
return `/mobile/company-loans${search}`;
}
if (normalizedPath === "/payment-batches") {
return `/mobile/payment-batches${search}`;
}
-1
查看文件
@@ -38,7 +38,6 @@ export const TEST_IDS = {
withdraw: "reimbursement-withdraw",
pay: "reimbursement-pay",
budgetHint: "reimbursement-budget-hint",
erpExport: "reimbursement-erp-export",
overview: "reimbursement-overview",
workflowProgress: "reimbursement-workflow-progress",
invoices: "reimbursement-invoices",
+2 -3
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -107,9 +108,7 @@ export function AnnouncementsPage() {
await queryClient.invalidateQueries({ queryKey: ["announcements"] });
toast.success("签收已记录");
},
onError: () => {
toast.error("签收失败,请刷新后重试");
},
onError: (error) => toast.error(resolveErrorMessage(error, "签收失败,请刷新后重试")),
});
return (
+4 -16
查看文件
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { isApiError } from "../api/types";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { exportAuditLogs } from "../api/exports";
import { fetchAuditLogs, type AuditLogListResponse } from "../api/audit";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
@@ -47,11 +47,7 @@ export function AuditLogsPage() {
}
} catch (err) {
if (!cancelled) {
if (isApiError(err)) {
setError(`${err.code}: ${err.message}`);
} else {
setError("加载审计日志失败,请稍后重试");
}
setError(resolveErrorMessage(err, "加载审计日志失败,请稍后重试"));
}
} finally {
if (!cancelled) {
@@ -93,11 +89,7 @@ export function AuditLogsPage() {
downloadBlob(response.blob, response.filename ?? "audit-logs.csv");
setError("");
} catch (err) {
if (isApiError(err)) {
setError(`${err.code}: ${err.message}`);
} else {
setError("导出审计日志失败,请稍后重试");
}
setError(resolveErrorMessage(err, "导出审计日志失败,请稍后重试"));
} finally {
setExporting(false);
}
@@ -118,11 +110,7 @@ export function AuditLogsPage() {
downloadBlob(response.blob, response.filename ?? "audit-logs.xlsx");
setError("");
} catch (err) {
if (isApiError(err)) {
setError(`${err.code}: ${err.message}`);
} else {
setError("导出审计日志失败,请稍后重试");
}
setError(resolveErrorMessage(err, "导出审计日志失败,请稍后重试"));
} finally {
setExporting(false);
}
+187
查看文件
@@ -0,0 +1,187 @@
import { cloneElement, isValidElement, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { fetchLegalEntities } from "../api/masterData";
import { fetchBankInstitutions } from "../api/paymentAccounts";
import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoanIdentities, fetchLoanLenders,
fetchLoanRepayments, saveLoanAction, uploadLoanEvidence, type CompanyLoan, type LoanLender } from "../api/companyLoans";
import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { formatDisplayDate } from "../lib/displayFormat";
import { downloadBlob } from "../lib/download";
const evidenceLabels: Record<string, string> = { agreement: "协议或借款确认书", incoming: "原借入凭据",
accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" };
const stateLabels: Record<string, string> = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" };
type Mode = "lender" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null;
const selectClass = "min-h-11 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm";
function Field({ label, children }: { label: string; children: ReactNode }) {
const id = useId();
return <div className="grid gap-1.5 text-sm"><Label htmlFor={id}>{label}</Label>
{isValidElement<{ id?: string }>(children) ? cloneElement(children, { id }) : children}</div>;
}
export function CompanyLoansPage() {
const { me, permissions } = useAuth(); const navigate = useNavigate(); const cache = useQueryClient();
const paymentPath = useLocation().pathname.startsWith("/mobile/") ? "/mobile/payment-batches" : "/payment-batches";
const mounted = useRef(true);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const allowed = Boolean(me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role))) && hasPermission(permissions, "loan.manage");
const [page, setPage] = useState(1); const [lenderPage, setLenderPage] = useState(1); const [repayPage, setRepayPage] = useState(1);
const [tab, setTab] = useState<"loans" | "lenders" | "repayments">("loans");
const [mode, setMode] = useState<Mode>(null); const [selectedLender, setSelectedLender] = useState<LoanLender | null>(null);
const [selectedLoan, setSelectedLoan] = useState<CompanyLoan | null>(null);
const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null);
const [lenderId, setLenderId] = useState(""); const [identitySearch, setIdentitySearch] = useState("");
const [amounts, setAmounts] = useState<Record<string, string>>({});
const [candidatePage, setCandidatePage] = useState(1);
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
const identity = useRef(me?.id); identity.current = me?.id;
const loans = useQuery({ queryKey: ["company-loans", me?.id, page], queryFn: ({ signal }) => fetchCompanyLoans(page, undefined, signal), enabled: allowed });
const lenders = useQuery({ queryKey: ["loan-lenders", me?.id, lenderPage], queryFn: ({ signal }) => fetchLoanLenders(lenderPage, signal), enabled: allowed });
const repayments = useQuery({ queryKey: ["loan-repayments", me?.id, repayPage], queryFn: ({ signal }) => fetchLoanRepayments(repayPage, signal), enabled: allowed });
const candidates = useQuery({ queryKey: ["company-loans", me?.id, "candidates", lenderId, candidatePage],
queryFn: ({ signal }) => fetchCompanyLoans(candidatePage, lenderId, signal), enabled: allowed && mode === "repay" && Boolean(lenderId) });
const companies = useQuery({ queryKey: ["loan-companies", me?.id], queryFn: () => fetchLegalEntities({ enabled: true }), enabled: allowed && mode === "lender" });
const banks = useQuery({ queryKey: ["loan-banks", me?.id], queryFn: fetchBankInstitutions, enabled: allowed && ["lender", "account"].includes(mode ?? "") });
const people = useQuery({ queryKey: ["loan-identities", me?.id, identitySearch], queryFn: ({ signal }) => fetchLoanIdentities(identitySearch, signal), enabled: allowed && mode === "identity" });
const mutation = useMutation({ mutationFn: async (action: () => Promise<unknown>) => action(), onSuccess: async () => {
await Promise.all(["company-loans", "loan-lenders", "loan-repayments", "payment-batches"].map((key) => cache.invalidateQueries({ queryKey: [key] })));
} });
useEffect(() => { setMode(null); setSelectedLender(null); setSelectedLoan(null); setAmounts({}); }, [me?.id]);
function open(next: Mode, lender: LoanLender | null = null, loan: CompanyLoan | null = null) {
mutation.reset(); setRequestKey(crypto.randomUUID()); setMode(next); setSelectedLender(lender); setSelectedLoan(loan);
setLenderId(lender?.id ?? ""); setAmounts({}); setIdentitySearch(""); setCandidatePage(1);
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const values = new FormData(event.currentTarget);
const text = (key: string) => String(values.get(key) ?? "").trim();
const actorId = me?.id;
mutation.mutate(async () => {
if (mode === "lender") await saveLoanAction("/loan-lenders", { requestKey, legalEntityId: text("company"), name: text("name"),
bankInstitutionId: text("bank"), bankName: text("bankName"), bankAccount: text("account"), accountName: text("accountName") });
if (mode === "account" && selectedLender) await saveLoanAction(`/loan-lenders/${selectedLender.id}/account`, {
expectedVersion: selectedLender.accountVersion, bankInstitutionId: text("bank"), bankName: text("bankName"),
bankAccount: text("account"), accountName: text("accountName"),
}, "PATCH");
if (mode === "identity" && selectedLender) await saveLoanAction(`/loan-lenders/${selectedLender.id}/identity-confirmation`, {
expectedVersion: selectedLender.identityVersion, employeeId: text("employee") === "external" ? null : text("employee"),
externalPersonConfirmed: text("employee") === "external", reason: text("reason"),
});
if (mode === "loan") await saveLoanAction("/company-loans", { requestKey, lenderId: text("lender"), originReference: text("origin"),
originalPrincipal: text("principal"), openingOutstandingPrincipal: text("opening"), receivedDate: text("received"), balanceAsOf: text("asOf"),
historicalEntry: text("entry") === "historical" });
if (mode === "confirm" && selectedLoan) await saveLoanAction(`/company-loans/${selectedLoan.id}/confirm`, {
expectedVersion: selectedLoan.version, accountingConfirmationRef: text("reference"),
});
if (mode === "download" && evidence) {
const response = await downloadLoanEvidence(evidence.loanId, evidence.id, text("reason"));
if (mounted.current && identity.current === actorId) downloadBlob(response.blob, response.filename ?? evidence.name);
}
if (mode === "repay") {
const response = await createLoanRepayment({ requestKey, lenderId, reason: text("reason"),
allocations: Object.entries(amounts).filter(([, amount]) => amount.trim()).map(([loanId, amount]) => ({ loanId, amount: amount.trim() })) });
if (mounted.current && identity.current === actorId) navigate(`${paymentPath}?id=${encodeURIComponent(response.item.id)}`);
}
if (mounted.current && identity.current === actorId) setMode(null);
});
}
if (!allowed) return <p role="alert">当前账号无借款台账权限。</p>;
const error = mutation.error ?? loans.error ?? lenders.error ?? repayments.error ?? companies.error ?? banks.error ?? people.error;
const current = tab === "loans" ? loans : tab === "lenders" ? lenders : repayments;
const modalQueryBlocked = mode === "lender" ? !companies.isSuccess || !banks.isSuccess : mode === "account" ? !banks.isSuccess : mode === "identity" ? !people.isSuccess : mode === "loan" ? !lenders.isSuccess : mode === "repay" ? !lenders.isSuccess || !candidates.isSuccess : false;
const currentPage = tab === "loans" ? page : tab === "lenders" ? lenderPage : repayPage;
const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage;
return <ListWorkspace>
<WorkspaceHeader title="公司借款" subtitle="记录公司向个人借入及归还的人民币本金。余额以核对截止日及 OA 登记为依据,未核实不代表已结清。"
actions={<HeaderActionGroup><Button variant="outline" onClick={() => open("lender")}>登记出借人</Button>
<Button onClick={() => open("loan")}>登记借入</Button><Button variant="outline" onClick={() => open("repay")}>申请还本</Button></HeaderActionGroup>} />
<div className="flex flex-wrap gap-2" role="tablist" aria-label="借款台账视图">
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"]] as const).map(([key, label]) =>
<Button key={key} role="tab" aria-selected={tab === key} variant={tab === key ? "default" : "outline"} onClick={() => setTab(key)}>{label}</Button>)}
</div>
{error && !mode ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{current.isPending ? <p role="status">加载中…</p> : null}
{current.isSuccess && !current.data.items.length ? <p className="card-std p-4">暂无记录;不能据此判断银行实际借还款情况。</p> : null}
{tab === "loans" && loans.isSuccess ? loans.data.items.map((loan) => <section key={loan.id} className="card-std min-w-0 space-y-3 p-4">
<div className="flex flex-wrap justify-between gap-2"><h2 className="font-semibold break-all">{loan.lender.name} · {loan.loanNo}</h2>
<span>{loan.confirmationStatus === "confirmed" ? "余额已确认" : "余额待核实"}</span></div>
<p className="text-sm text-zinc-600">{loan.lender.legalEntityName} · 借入 {formatDisplayDate(loan.receivedDate)} · 核对截至 {formatDisplayDate(loan.balanceAsOf)} · {loan.historicalEntry ? "历史登记" : "当期借入"}</p>
<div className="detail-fact-grid text-sm">
{[["原本金", loan.originalPrincipal], ["未还本金", loan.outstanding], ["待付款占用", loan.reserved], ["可申请还本", loan.available]].map(([label, value]) =>
<div key={label} className="detail-fact-card"><div className="detail-fact-card__label">{label}</div><div className="detail-fact-card__value">¥{value}</div></div>)}
</div>
<p className="break-words text-sm">原借入凭据:{loan.originReference}</p>
<details><summary className="min-h-11 cursor-pointer py-3 text-sm font-medium">借款材料与余额确认</summary>
<div className="grid gap-3 md:grid-cols-2">{Object.entries(evidenceLabels).map(([kind, label]) => <div key={kind} className="rounded-md border p-3">
<Label htmlFor={`${loan.id}-${kind}`}>{label}</Label>
<Input id={`${loan.id}-${kind}`} type="file" className="mt-2" accept=".pdf,.png,.jpg,.jpeg,.webp" disabled={mutation.isPending}
onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = "";
if (file) mutation.mutate(() => uploadLoanEvidence(loan.id, kind, file)); }} />
{loan.evidences.filter((e) => e.kind === kind && !e.archivedAt).map((e) => <Button key={e.id} variant="link" className="max-w-full whitespace-normal break-all"
onClick={() => { setEvidence({ loanId: loan.id, id: e.id, name: e.originalName }); open("download"); }}>{e.originalName}</Button>)}
</div>)}</div>
{loan.confirmationStatus !== "confirmed" ? <Button className="mt-3" onClick={() => open("confirm", loan.lender, loan)}>确认期初余额</Button> : null}
</details>
</section>) : null}
{tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) => <section key={lender.id} className="card-std space-y-3 p-4">
<h2 className="font-semibold">{lender.name} · {lender.legalEntityName}</h2>
<p className="break-words text-sm">{lender.bankName} · {lender.bankAccountMasked ?? "账户不可用"} · {lender.identityStatus === "verified" ? "身份已核实" : "身份待核实"}</p>
<div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => open("identity", lender)}>核实身份关联</Button>
<Button variant="outline" onClick={() => open("account", lender)}>更新收款资料</Button></div>
</section>) : null}
{tab === "repayments" && repayments.isSuccess ? repayments.data.items.map((repayment) => <section key={repayment.id} className="card-std space-y-2 p-4">
<h2 className="font-semibold break-all">{repayment.lenderName} · {repayment.repaymentNo}</h2>
<p>¥{repayment.amount} · {stateLabels[repayment.status] ?? "待核对"}</p>
{repayment.bankResultState === "unknown" ? <p className="text-sm text-amber-800">银行结果待查证,额度保持占用,请勿重复付款。</p> : null}
<p className="text-sm">会计交接:{repayment.accountingHandoffRef ?? "待交接"}</p>
<Link className="inline-flex min-h-11 items-center text-primary underline" to={`${paymentPath}?id=${encodeURIComponent(repayment.paymentBatchId)}`}>查看还款分配、付款与回单</Link>
</section>) : null}
<div className="flex items-center gap-3"><Button variant="outline" disabled={currentPage <= 1} onClick={() => setCurrentPage(currentPage - 1)}>上一页</Button>
<span>第 {currentPage} 页</span><Button variant="outline" disabled={!current.data || currentPage * 30 >= current.data.total} onClick={() => setCurrentPage(currentPage + 1)}>下一页</Button></div>
<Dialog open={mode !== null} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>
{({ lender: "登记出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
</DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款,提交后进入独立付款复核。" :
mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
mode === "account" ? "更新后须重新确认身份,原未付申请失效,请安全取消后重新申请。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
<form onSubmit={submit} className="grid gap-4">
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{mode === "lender" ? <><Field label="公司主体"><select required name="company" className={selectClass}><option value="">请选择</option>{companies.data?.items.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
<Field label="出借人姓名"><Input name="name" required maxLength={120} /></Field></> : null}
{mode === "lender" || mode === "account" ? <><Field label="收款银行"><select name="bank" required className={selectClass} defaultValue={selectedLender?.bankInstitutionId ?? ""}><option value="">请选择</option>{banks.data?.items.filter((b) => b.code === "CCB").map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select></Field>
<Field label="开户行名称"><Input name="bankName" required maxLength={200} defaultValue={selectedLender?.bankName} /></Field>
<Field label="账户名"><Input name="accountName" required maxLength={120} autoComplete="off" /></Field>
<Field label="收款账号"><Input name="account" required inputMode="numeric" pattern="[0-9]{10,30}" autoComplete="off" /></Field></> : null}
{mode === "identity" ? <><Field label="检索 OA 人员"><Input value={identitySearch} onChange={(e) => setIdentitySearch(e.target.value)} placeholder="姓名或员工编号" /></Field>
<Field label="出借人对应身份"><select key={identitySearch} name="employee" required className={selectClass} defaultValue=""><option value="">请选择已核实身份</option><option value="external">已核实:非员工且没有关联 OA 账号</option>
{people.data?.items.map((p) => <option key={p.id} value={p.id}>{p.name} · {p.employeeNo}</option>)}</select></Field></> : null}
{mode === "loan" ? <><Field label="出借人"><select required name="lender" className={selectClass}><option value="">请选择本页已登记出借人</option>{lenders.data?.items.map((l) => <option key={l.id} value={l.id}>{l.name} · {l.legalEntityName}</option>)}</select></Field>
<Field label="原借入凭据唯一引用"><Input name="origin" required maxLength={200} /></Field>
<Field label="原始本金"><Input name="principal" required inputMode="decimal" pattern="[0-9]{1,12}(\.[0-9]{1,2})?" /></Field>
<Field label="核对日未还本金"><Input name="opening" required inputMode="decimal" pattern="[0-9]{1,12}(\.[0-9]{1,2})?" /></Field>
<Field label="实际到账日期"><Input name="received" required type="date" /></Field><Field label="余额核对截止日"><Input name="asOf" required type="date" /></Field>
<Field label="登记性质"><select name="entry" className={selectClass}><option value="historical">历史借入登记</option><option value="current">当期实际借入</option></select></Field></> : null}
{mode === "confirm" ? <><p>原本金 ¥{selectedLoan?.originalPrincipal},核对日未还 ¥{selectedLoan?.openingOutstandingPrincipal}</p>
<Field label="代账余额确认引用"><Input required name="reference" minLength={2} maxLength={200} /></Field></> : null}
{mode === "repay" ? <><Field label="出借人"><select required className={selectClass} value={lenderId} onChange={(e) => { setLenderId(e.target.value); setAmounts({}); setCandidatePage(1); }}><option value="">请选择</option>
{lenders.data?.items.map((l) => <option key={l.id} value={l.id}>{l.name} · {l.legalEntityName}</option>)}</select></Field>
<p className="text-sm text-zinc-600">填写已确认借款的分配金额,留空表示本次不归还。翻页保留已填写金额。</p>
{candidates.error ? <p role="alert">{resolveErrorMessage(candidates.error)}</p> : null}
{candidates.isSuccess ? candidates.data.items.filter((l) => l.lender.id === lenderId && l.confirmationStatus === "confirmed").map((l) => <Field key={l.id} label={`${l.originReference}(可还 ¥${l.available})`}>
<Input aria-label={`分配 ${l.originReference}`} inputMode="decimal" pattern="[0-9]{1,12}(\.[0-9]{1,2})?" value={amounts[l.id] ?? ""} onChange={(e) => setAmounts({ ...amounts, [l.id]: e.target.value })} /></Field>) : null}</> : null}
{mode === "repay" && lenderId ? <div className="flex flex-wrap items-center gap-2"><Button type="button" variant="outline" disabled={candidatePage === 1} onClick={() => setCandidatePage(candidatePage - 1)}>上一页借款</Button>
<span>第 {candidatePage} 页 · 已填 {Object.values(amounts).filter((v) => v.trim()).length} 笔</span><Button type="button" variant="outline" disabled={!candidates.data || candidatePage * 30 >= candidates.data.total} onClick={() => setCandidatePage(candidatePage + 1)}>下一页借款</Button></div> : null}
{["identity", "repay", "download"].includes(mode ?? "") ? <Field label={mode === "download" ? "下载用途" : "说明"}><Input name="reason" required minLength={2} maxLength={500} /></Field> : null}
<Button type="submit" disabled={mutation.isPending || modalQueryBlocked || mode === "repay" && !Object.values(amounts).some((v) => v.trim())}>{mutation.isPending ? "处理中…" : mode === "repay" ? "提交还款申请" : "确认"}</Button>
</form>
</DialogContent>
</Dialog>
</ListWorkspace>;
}
+10 -28
查看文件
@@ -73,7 +73,15 @@ export function InvoicePoolPage() {
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
const [selectedTravelRequestId, setSelectedTravelRequestId] = useState("");
const [selectedTravelOrderId, setSelectedTravelOrderId] = useState("");
const [detailInvoiceId, setDetailInvoiceId] = useState(searchParams.get("id") ?? "");
const detailInvoiceId = searchParams.get("id") ?? "";
const setDetailInvoiceId = (id: string) => {
setSearchParams((current) => {
const next = new URLSearchParams(current);
if (id) next.set("id", id);
else next.delete("id");
return next;
}, { replace: true });
};
const [editOpen, setEditOpen] = useState(false);
const [editSetAvailable, setEditSetAvailable] = useState(false);
const [editRemarks, setEditRemarks] = useState("");
@@ -204,27 +212,6 @@ export function InvoicePoolPage() {
setSelectedTravelOrderId("");
}, [canManageTravelPayments]);
useEffect(() => {
const searchInvoiceId = searchParams.get("id") ?? "";
if (searchInvoiceId !== detailInvoiceId) {
setDetailInvoiceId(searchInvoiceId);
}
}, [detailInvoiceId, searchParams]);
useEffect(() => {
const currentId = searchParams.get("id") ?? "";
if (detailInvoiceId === currentId) {
return;
}
const next = new URLSearchParams(searchParams);
if (detailInvoiceId) {
next.set("id", detailInvoiceId);
} else {
next.delete("id");
}
setSearchParams(next, { replace: true });
}, [detailInvoiceId, searchParams, setSearchParams]);
// --- Helper for error handling ---
const toUserError = (err: unknown, fallback: string) => {
@@ -459,12 +446,7 @@ export function InvoicePoolPage() {
detail.uploadedByEmployeeId === me?.id),
);
const isInvoiceDetailMode = Boolean(detailInvoiceId);
const returnToInvoiceList = () => {
setDetailInvoiceId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
};
const returnToInvoiceList = () => setDetailInvoiceId("");
// --- Render ---
+3
查看文件
@@ -639,6 +639,9 @@ export function MasterDataPage() {
</TabsContent>
<TabsContent value="document-categories" className="space-y-4">
<p className="text-sm text-zinc-500">
统一编号启用后,报销按月、采购和盘点按年、资产永久编号。以下编号设置仅继续用于旧规则及电子资产前缀,不会覆盖统一编号;已有单号保持不变。
</p>
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="grid gap-3 md:grid-cols-3">
<select
+1
查看文件
@@ -104,6 +104,7 @@ export function MobileMePage() {
<SectionPanel title="移动入口" description="只保留手机上常用的个人办事入口。">
<div className="mobile-entry-grid mobile-me-entry-grid" data-testid="mobile-more-links">
{me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role)) ? <MobileActionLink to="/mobile/company-loans" label="公司借款" icon="account_balance" /> : null}
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
<MobileActionLink to="/mobile/leave" label="请假记录" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
+6 -3
查看文件
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { Eye } from "lucide-react";
import { LoanPaymentDetails } from "../components/common/LoanPaymentDetails";
import {
cancelPaymentBatch,
createPaymentBatch,
@@ -84,6 +85,7 @@ type PaymentActionKind =
function entityTypeLabel(value: string) {
if (value === "reimbursement") return "报销";
if (value === "travel_order") return "差旅订单";
if (value === "loan_repayment") return "借款本金归还";
return value || "-";
}
@@ -121,7 +123,7 @@ function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string }
const subject = blocker.entityCode ? `${blocker.entityCode}:` : "";
const messages: Record<string, string> = {
status_not_reviewed: "仅待财务付款批次可生成制单",
reimbursement_only: "仅报销付款批次支持制单",
reimbursement_only: "当前付款来源不支持银行制单",
bank_channel_required: "付款渠道不是银行转账",
legacy_payment_account_snapshot_missing: "历史批次未冻结公司付款账户,仅支持人工付款",
payment_account_snapshot_invalid: "冻结的公司付款账户资料不完整",
@@ -314,7 +316,7 @@ export function PaymentBatchesPage() {
const canCancelSelectedBatch =
canManageBatch &&
!!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status);
!["executed", "cancelled"].includes(selectedBatch.status) && selectedBatch.loanRepayment?.bankResultState !== "unknown";
const paymentActionModel = useMemo(() => {
const canDownloadOrder =
@@ -904,8 +906,9 @@ export function PaymentBatchesPage() {
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
<h3 className="text-base font-semibold text-zinc-900">
{selectedBatch.entityType === "reimbursement" ? "本批次报销单" : "本批次差旅订单"}
{selectedBatch.entityType === "reimbursement" ? "本批次报销单" : selectedBatch.entityType === "loan_repayment" ? "本次本金还款" : "本批次差旅订单"}
</h3>
{selectedBatch.loanRepayment ? <LoanPaymentDetails key={`${selectedBatch.id}-${me?.id}`} repayment={selectedBatch.loanRepayment} attachments={receiptAttachmentsQuery.data?.items ?? []} /> : null}
<div className="space-y-4">
{selectedBatch.items.map((item) => (
<div key={item.id} className="detail-evidence-card">
+4 -3
查看文件
@@ -1,3 +1,4 @@
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -61,9 +62,7 @@ export function PortalAnnouncementsPage() {
await refresh();
toast.success("公告已创建");
},
onError: () => {
toast.error("公告创建失败,请检查填写内容");
},
onError: (error) => toast.error(resolveErrorMessage(error, "公告创建失败,请检查填写内容")),
});
const archiveMutation = useMutation({
@@ -72,6 +71,7 @@ export function PortalAnnouncementsPage() {
await refresh();
toast.success("公告已归档");
},
onError: (error) => toast.error(resolveErrorMessage(error, "公告归档失败,请刷新后重试")),
});
const toggleMutation = useMutation({
@@ -80,6 +80,7 @@ export function PortalAnnouncementsPage() {
await refresh();
toast.success("公告状态已更新");
},
onError: (error) => toast.error(resolveErrorMessage(error, "公告状态更新失败,请刷新后重试")),
});
const handleCreate = () => {
+7 -1
查看文件
@@ -14,6 +14,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { toast } from "sonner";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
const ENTITY_TYPES: WorkflowDefinitionEditorModel["entityType"][] = ["reimbursement", "leave", "purchase_request", "travel_request"];
@@ -65,6 +66,7 @@ export function WorkflowDefinitionsPage() {
await refresh();
toast.success("流程定义草稿已创建");
},
onError: (error) => toast.error(resolveErrorMessage(error, "创建流程定义失败")),
});
const saveMutation = useMutation({
@@ -73,6 +75,7 @@ export function WorkflowDefinitionsPage() {
await refresh();
toast.success("流程定义已保存");
},
onError: (error) => toast.error(resolveErrorMessage(error, "保存流程定义失败")),
});
const publishMutation = useMutation({
@@ -81,6 +84,7 @@ export function WorkflowDefinitionsPage() {
await refresh();
toast.success("流程定义已发布");
},
onError: (error) => toast.error(resolveErrorMessage(error, "发布流程定义失败")),
});
const archiveMutation = useMutation({
@@ -89,6 +93,7 @@ export function WorkflowDefinitionsPage() {
await refresh();
toast.success("流程定义已归档");
},
onError: (error) => toast.error(resolveErrorMessage(error, "归档流程定义失败")),
});
return (
@@ -119,7 +124,8 @@ export function WorkflowDefinitionsPage() {
<div className="text-xs text-zinc-500">{item.status} · {item.source}</div>
</button>
))}
{!scopedItems.length ? <div className="text-sm text-zinc-500">当前实体暂无流程定义</div> : null}
{listQuery.isError ? <div className="text-sm text-red-600">{resolveErrorMessage(listQuery.error, "加载流程定义失败")}</div> : null}
{!listQuery.isError && !scopedItems.length ? <div className="text-sm text-zinc-500">当前实体暂无流程定义</div> : null}
</div>
</div>
+1
查看文件
@@ -38,6 +38,7 @@ const MODULE_LABELS: Record<string, string> = {
budgets: "预算管理",
paymentBatches: "付款",
apOperations: "应付账款",
companyLoans: "公司借款",
invoicePool: "票据",
inventoryChecks: "资产盘点",
cutover: "切换控制台",
@@ -170,6 +170,13 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开完整材料失败,可逐份查看原件")),
});
const refreshApprovalState = () => Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
refetchTask(),
]);
const actionMutation = useMutation({
mutationFn: async (action: "approve" | "return" | "transfer") => {
if (!decisionReady) {
@@ -181,11 +188,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
return transferWorkflowTask(taskId, selectedApprover, transferReason.trim());
},
onSuccess: async (_data, action) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
refetchTask(),
]);
await refreshApprovalState();
setActionError("");
setActiveSecondaryAction(null);
setConfirmAction(null);
@@ -193,7 +196,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
toast.success({ approve: "已通过", return: "已退回", transfer: "已转审" }[action]);
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) void refetchTask();
if (isAlreadyProcessedTaskError(error)) void refreshApprovalState();
const message = resolveUserErrorMessage(error, "动作执行失败,请稍后重试");
setActionError(message);
toast.error(message);
+55 -54
查看文件
@@ -1,10 +1,10 @@
import { registerSW } from "virtual:pwa-register";
import { canUseWindow, isEnterpriseMessengerContainer, matchesMedia } from "./lib/runtimeContext";
import { PWA_UPDATE_READY_EVENT } from "./lib/pwaUpdateEvents";
const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i;
const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"];
const CLEARABLE_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime", "workbox-precache"];
const RUNTIME_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime"];
const CLEARABLE_CACHE_PREFIXES = [...RUNTIME_CACHE_PREFIXES, "workbox-precache"];
const AUTH_INPUT_PATHS = ["/login", "/change-password", "/forgot-password", "/reset-password"];
const BUILD_SHA_DISPLAY_LENGTH = 12;
@@ -15,31 +15,22 @@ type ReadyResponse = {
};
let buildFreshnessMonitorInstalled = false;
let pwaRegistrationStarted = false;
function isAuthInputPath(pathname: string) {
return AUTH_INPUT_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`));
}
function bindServiceWorkerUpdateCleanup(registration: ServiceWorkerRegistration | undefined) {
if (!canUseWindow() || !registration) {
return;
}
const watchInstallingWorker = (worker: ServiceWorker | null) => {
if (!worker) {
return;
}
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && window.navigator.serviceWorker.controller) {
void clearPwaRuntimeCaches();
}
});
};
watchInstallingWorker(registration.installing);
registration.addEventListener("updatefound", () => {
watchInstallingWorker(registration.installing);
function waitForActivatedWorker(worker: ServiceWorker): Promise<boolean> {
if (worker.state === "activated") return Promise.resolve(true);
if (worker.state === "redundant") return Promise.resolve(false);
return new Promise((resolve) => {
const onStateChange = () => {
if (worker.state !== "activated" && worker.state !== "redundant") return;
worker.removeEventListener("statechange", onStateChange);
resolve(worker.state === "activated");
};
worker.addEventListener("statechange", onStateChange);
});
}
@@ -120,43 +111,49 @@ export function installBuildFreshnessMonitor() {
}
export function registerPwaIfEnabled() {
if (import.meta.env.DEV) {
if (import.meta.env.DEV || import.meta.env.VITE_PWA_ENABLED === "false" ||
!canUseWindow() || !("serviceWorker" in window.navigator) || pwaRegistrationStarted) {
return;
}
pwaRegistrationStarted = true;
const serviceWorker = window.navigator.serviceWorker;
let previousController = serviceWorker.controller;
let refreshScheduled = false;
let refreshApplied = false;
if (import.meta.env.VITE_PWA_ENABLED === "false") {
return;
}
let updateServiceWorker: ((reloadPage?: boolean) => Promise<void>) | null = null;
updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
const applyUpdate = () => {
void clearPwaRuntimeCaches()
.catch(() => undefined)
.finally(() => {
void updateServiceWorker?.(true);
});
};
if (canUseWindow() && isAuthInputPath(window.location.pathname)) {
window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate } }));
// Native navigation may start an update before register() resolves. Subscribe
// first: updatefound/activated listeners installed afterward can miss it.
serviceWorker.addEventListener("controllerchange", () => {
const controller = serviceWorker.controller;
if (!controller || controller === previousController) return;
const isUpdate = previousController !== null;
previousController = controller;
void waitForActivatedWorker(controller).then((activated) => {
if (!activated || controller !== serviceWorker.controller) return;
if (!isUpdate) {
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
return;
}
applyUpdate();
},
onOfflineReady() {
if (canUseWindow()) {
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
if (refreshScheduled) return;
refreshScheduled = true;
const applyUpdate = () => {
if (refreshApplied) return;
refreshApplied = true;
// Workbox owns new precache installation and old-revision cleanup.
// Deleting it here races activation and destroys the new offline shell.
void clearPwaCaches(RUNTIME_CACHE_PREFIXES)
.catch(() => undefined)
.finally(() => window.location.reload());
};
if (isAuthInputPath(window.location.pathname)) {
window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate } }));
} else {
applyUpdate();
}
},
onRegisteredSW(_swScriptUrl, registration) {
bindServiceWorkerUpdateCleanup(registration);
},
});
});
void serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`, { scope: import.meta.env.BASE_URL })
.catch(() => undefined);
}
export function buildMobileApprovalsPath(taskId?: string | null) {
@@ -196,14 +193,14 @@ export function shouldPreferMobileApprovalExperience() {
return isStandalone || isEnterpriseMessengerContainer() || isTouchSmallScreen;
}
export async function clearPwaRuntimeCaches() {
async function clearPwaCaches(prefixes: readonly string[]) {
if (!canUseWindow() || !("caches" in window)) {
return;
}
const cacheNames = await window.caches.keys();
const cachesToDelete = cacheNames.filter((cacheName) =>
CLEARABLE_CACHE_PREFIXES.some((prefix) => cacheName.startsWith(prefix)),
prefixes.some((prefix) => cacheName.startsWith(prefix)),
);
await Promise.all(cachesToDelete.map(async (cacheName) => {
@@ -213,3 +210,7 @@ export async function clearPwaRuntimeCaches() {
}
}));
}
export async function clearPwaRuntimeCaches() {
await clearPwaCaches(CLEARABLE_CACHE_PREFIXES);
}
+4
查看文件
@@ -16,6 +16,7 @@ import { NotFoundPage } from "../pages/NotFoundPage";
import { ProfilePage } from "../pages/ProfilePage";
import {
ApOperationsPage,
CompanyLoansPage,
ApprovalRulesPage,
AnnouncementsPage,
AttendancePage,
@@ -303,6 +304,9 @@ const routeElements = (
}
/>
<Route path="company-loans" element={<RequirePermission anyOf={["loan.manage"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
<Route path="mobile/company-loans" element={<RequirePermission anyOf={["loan.manage"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
<Route
path="payment-batches"
element={
@@ -0,0 +1 @@
export { CompanyLoansPage as default } from "../../pages/CompanyLoansPage";
+1
查看文件
@@ -1,4 +1,5 @@
import { lazy } from "react";
export const CompanyLoansPage = lazy(() => import("./CompanyLoansPage"));
export const ApOperationsPage = lazy(() => import("./ApOperationsPage"));
export const AttendancePage = lazy(() => import("./AttendancePage"));