实现 B-2a 报销审批内容工作台(reimbursement-approval-content-workbench)
这个提交包含在:
@@ -72,6 +72,15 @@ export type WorkflowHistoryItem = {
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
export type ReimbursementBudgetCheckSnapshot = {
|
||||
budgetId: string;
|
||||
budgetCode: string;
|
||||
controlMode: "warn" | "block";
|
||||
usagePercent: number;
|
||||
exceededThreshold: boolean;
|
||||
exceededBudget: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowTaskDetail = {
|
||||
task: {
|
||||
id: string;
|
||||
@@ -93,6 +102,9 @@ export type WorkflowTaskDetail = {
|
||||
currentStepId: string;
|
||||
contextData: {
|
||||
standardViolations: ExpenseStandardCheck[];
|
||||
budgetCheck: ReimbursementBudgetCheckSnapshot | null;
|
||||
budgetCheckStatus: "available" | "not_applicable" | "missing";
|
||||
parseStatus: "ready" | "invalid";
|
||||
};
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
|
||||
import type {
|
||||
ReimbursementApprovalEvidenceProjection,
|
||||
ReimbursementAttachmentEvidence,
|
||||
ReimbursementInvoiceEvidence,
|
||||
ReimbursementItemEvidence,
|
||||
} from "../../lib/reimbursementApprovalProjection";
|
||||
|
||||
export type ReimbursementEvidenceCapabilities = {
|
||||
onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
|
||||
onOpenAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
|
||||
onOpenMaterialBundle?: () => void;
|
||||
onReferenceItem?: (item: ReimbursementItemEvidence) => void;
|
||||
onReferenceInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
|
||||
onEditItem?: (item: ReimbursementItemEvidence) => void;
|
||||
onDeleteItem?: (item: ReimbursementItemEvidence) => void;
|
||||
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
|
||||
onUnlinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
evidence: ReimbursementApprovalEvidenceProjection;
|
||||
capabilities?: ReimbursementEvidenceCapabilities;
|
||||
};
|
||||
|
||||
function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) {
|
||||
if (status === "verified") return "票面金额已读取";
|
||||
if (status === "ocr_failed") return "票面金额未能读取,暂时不能完成核验";
|
||||
return "票面金额需查看原件人工核对";
|
||||
}
|
||||
|
||||
function InvoiceCard({
|
||||
invoice,
|
||||
capabilities,
|
||||
}: {
|
||||
invoice: ReimbursementInvoiceEvidence;
|
||||
capabilities: ReimbursementEvidenceCapabilities;
|
||||
}) {
|
||||
const title = invoice.invoiceNumber || invoice.originalName || "未命名发票";
|
||||
return (
|
||||
<article className="rounded-lg border border-zinc-200 bg-white p-3" data-testid="approval-invoice-evidence">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="truncate text-sm font-medium text-zinc-900">{title}</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-600">
|
||||
{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"}
|
||||
{" · "}{formatDisplayDate(invoice.invoiceDate)}
|
||||
{" · "}{formatMoney(invoice.totalAmount)}
|
||||
</p>
|
||||
<p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>
|
||||
{evidenceStatusCopy(invoice.amountEvidenceStatus)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.onOpenInvoice ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
|
||||
查看原件
|
||||
</Button>
|
||||
) : null}
|
||||
{capabilities.onReferenceInvoice ? (
|
||||
<Button className="min-h-11" data-testid="approval-reference-invoice" onClick={() => capabilities.onReferenceInvoice?.(invoice)} size="sm" type="button" variant="secondary">
|
||||
引用到退回意见
|
||||
</Button>
|
||||
) : null}
|
||||
{capabilities.onLinkInvoice && !invoice.linkedItemId ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onLinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">选择明细</Button>
|
||||
) : null}
|
||||
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: Props) {
|
||||
return (
|
||||
<div className="space-y-5" data-testid="reimbursement-approval-evidence">
|
||||
<div className="space-y-3">
|
||||
{evidence.items.length ? evidence.items.map((item, index) => (
|
||||
<article className="rounded-xl border border-zinc-200 bg-zinc-50/60 p-4" data-testid="approval-item-evidence" key={item.id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">明细 {index + 1}</p>
|
||||
<h3 className="mt-1 font-medium text-zinc-950">{item.expenseType?.name ?? "未分类费用"}</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
{formatDisplayDate(item.expenseDate)} · {formatMoney(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
{capabilities.onReferenceItem ? (
|
||||
<Button className="min-h-11" data-testid="approval-reference-item" onClick={() => capabilities.onReferenceItem?.(item)} size="sm" type="button" variant="secondary">
|
||||
引用到退回意见
|
||||
</Button>
|
||||
) : null}
|
||||
{capabilities.onEditItem ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑明细</Button>
|
||||
) : null}
|
||||
{capabilities.onDeleteItem ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onDeleteItem?.(item)} size="sm" type="button" variant="secondary">删除明细</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-700">{item.description || "未填写明细说明"}</p>
|
||||
{item.assetPriorApprovalBasis ? (
|
||||
<p className="mt-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
|
||||
事前批准依据:{item.assetPriorApprovalBasis}
|
||||
</p>
|
||||
) : null}
|
||||
{item.standardViolations.map((violation) => (
|
||||
<p
|
||||
className={violation.controlMode === "block"
|
||||
? "mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
: "mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"}
|
||||
key={`${violation.itemId}-${violation.standardId}`}
|
||||
>
|
||||
提交时费用标准提示:申报 {formatMoney(violation.amount)},标准 {formatMoney(violation.maxAmount)},超出 {formatMoney(violation.exceededAmount)}。审批时请核对原因。
|
||||
</p>
|
||||
))}
|
||||
{item.showOfficeElectronicsHint ? (
|
||||
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
该金额通常应选择办公电子设备,请核对费用类型和事前批准依据。
|
||||
</p>
|
||||
) : null}
|
||||
{item.amountExceeded ? (
|
||||
<p className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">所属票据({item.invoices.length})</p>
|
||||
{item.invoices.length ? item.invoices.map((invoice) => (
|
||||
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} />
|
||||
)) : (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
这条明细尚无对应票据,请在作出审批决定前核对是否需要补充。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)) : (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{evidence.unlinkedInvoices.length ? (
|
||||
<section className="space-y-2 rounded-xl border border-red-200 bg-red-50 p-4" data-testid="approval-unlinked-invoices">
|
||||
<div>
|
||||
<h3 className="font-medium text-red-900">尚未对应明细的票据({evidence.unlinkedInvoices.length})</h3>
|
||||
<p className="mt-1 text-sm text-red-800">这些票据当前没有明确费用归属,审批前需要申请人处理或在意见中说明。</p>
|
||||
</div>
|
||||
{evidence.unlinkedInvoices.map((invoice) => (
|
||||
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} />
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2" data-testid="approval-attachments">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-zinc-900">补充附件({evidence.attachments.length})</h3>
|
||||
{capabilities.onOpenMaterialBundle ? (
|
||||
<Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">
|
||||
查看完整材料
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{evidence.attachments.length ? evidence.attachments.map((attachment) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 px-3 py-2" key={attachment.id}>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</p>
|
||||
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
{capabilities.onOpenAttachment ? (
|
||||
<Button className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
|
||||
查看附件
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ExpenseStandardCheck } from "../api/feeControl";
|
||||
import type { ReimbursementDetail } from "../api/reimbursements";
|
||||
import type { ReimbursementBudgetCheckSnapshot } from "../api/workflow";
|
||||
import { shouldShowOfficeElectronicsHint } from "./reimbursementPolicyHints";
|
||||
|
||||
type DetailItem = ReimbursementDetail["items"][number];
|
||||
type DetailInvoice = ReimbursementDetail["invoices"][number];
|
||||
type DetailAttachment = ReimbursementDetail["attachments"][number];
|
||||
export type ReimbursementAttachmentEvidence = Pick<
|
||||
DetailAttachment,
|
||||
"id" | "originalName" | "mimeType" | "fileSize" | "createdAt"
|
||||
>;
|
||||
|
||||
export type ReimbursementInvoiceEvidence = Pick<
|
||||
DetailInvoice,
|
||||
"id" | "invoiceCode" | "invoiceNumber" | "invoiceDate" | "sellerName" |
|
||||
"documentCategory" | "documentSubtype" | "totalAmount" | "originalName" |
|
||||
"mimeType" | "linkedItemId" | "amountEvidenceStatus"
|
||||
>;
|
||||
|
||||
export type ReimbursementItemEvidence = Pick<
|
||||
DetailItem,
|
||||
"id" | "expenseDate" | "amount" | "description" | "assetPriorApprovalBasis" | "expenseType"
|
||||
> & {
|
||||
invoices: ReimbursementInvoiceEvidence[];
|
||||
standardViolations: ExpenseStandardCheck[];
|
||||
showOfficeElectronicsHint: boolean;
|
||||
amountExceeded: boolean;
|
||||
};
|
||||
|
||||
export type ReimbursementApprovalEvidenceProjection = {
|
||||
items: ReimbursementItemEvidence[];
|
||||
unlinkedInvoices: ReimbursementInvoiceEvidence[];
|
||||
attachments: ReimbursementAttachmentEvidence[];
|
||||
currentCheck: {
|
||||
invoiceCount: number;
|
||||
unlinkedInvoiceCount: number;
|
||||
missingInvoiceItemCount: number;
|
||||
amountUnavailableItemCount: number;
|
||||
manualReviewItemCount: number;
|
||||
amountExceededItemCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReimbursementSubmissionSnapshot = {
|
||||
standardViolations: ExpenseStandardCheck[];
|
||||
budgetCheck: ReimbursementBudgetCheckSnapshot | null;
|
||||
budgetCheckStatus: "available" | "not_applicable" | "missing";
|
||||
parseStatus: "ready" | "invalid";
|
||||
};
|
||||
|
||||
function toInvoiceEvidence(invoice: DetailInvoice): ReimbursementInvoiceEvidence {
|
||||
return {
|
||||
id: invoice.id,
|
||||
invoiceCode: invoice.invoiceCode,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoiceDate: invoice.invoiceDate,
|
||||
sellerName: invoice.sellerName,
|
||||
documentCategory: invoice.documentCategory,
|
||||
documentSubtype: invoice.documentSubtype,
|
||||
totalAmount: invoice.totalAmount,
|
||||
originalName: invoice.originalName,
|
||||
mimeType: invoice.mimeType,
|
||||
linkedItemId: invoice.linkedItemId,
|
||||
amountEvidenceStatus: invoice.amountEvidenceStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReimbursementApprovalEvidence(
|
||||
detail: Pick<ReimbursementDetail, "items" | "invoices" | "attachments">,
|
||||
snapshot: ReimbursementSubmissionSnapshot,
|
||||
): ReimbursementApprovalEvidenceProjection {
|
||||
const invoicesByItem = new Map<string, ReimbursementInvoiceEvidence[]>();
|
||||
const unlinkedInvoices: ReimbursementInvoiceEvidence[] = [];
|
||||
|
||||
for (const invoice of detail.invoices) {
|
||||
const evidence = toInvoiceEvidence(invoice);
|
||||
if (!invoice.linkedItemId) {
|
||||
unlinkedInvoices.push(evidence);
|
||||
continue;
|
||||
}
|
||||
const itemInvoices = invoicesByItem.get(invoice.linkedItemId) ?? [];
|
||||
itemInvoices.push(evidence);
|
||||
invoicesByItem.set(invoice.linkedItemId, itemInvoices);
|
||||
}
|
||||
|
||||
const violationsByItem = new Map<string, ExpenseStandardCheck[]>();
|
||||
for (const violation of snapshot.standardViolations) {
|
||||
const itemViolations = violationsByItem.get(violation.itemId) ?? [];
|
||||
itemViolations.push(violation);
|
||||
violationsByItem.set(violation.itemId, itemViolations);
|
||||
}
|
||||
|
||||
const items = detail.items.map((item) => {
|
||||
const invoices = invoicesByItem.get(item.id) ?? [];
|
||||
const allAmountsVerified = invoices.length > 0 && invoices.every((invoice) => invoice.amountEvidenceStatus === "verified");
|
||||
const invoiceTotal = allAmountsVerified
|
||||
? invoices.reduce((sum, invoice) => sum + Number(invoice.totalAmount ?? 0), 0)
|
||||
: 0;
|
||||
return {
|
||||
id: item.id,
|
||||
expenseDate: item.expenseDate,
|
||||
amount: item.amount,
|
||||
description: item.description,
|
||||
assetPriorApprovalBasis: item.assetPriorApprovalBasis,
|
||||
expenseType: item.expenseType,
|
||||
invoices,
|
||||
standardViolations: violationsByItem.get(item.id) ?? [],
|
||||
showOfficeElectronicsHint: shouldShowOfficeElectronicsHint(item.expenseType, item.amount),
|
||||
amountExceeded: allAmountsVerified && Number.isFinite(invoiceTotal) && Number(item.amount) > invoiceTotal,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
unlinkedInvoices,
|
||||
attachments: detail.attachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
originalName: attachment.originalName,
|
||||
mimeType: attachment.mimeType,
|
||||
fileSize: attachment.fileSize,
|
||||
createdAt: attachment.createdAt,
|
||||
})),
|
||||
currentCheck: {
|
||||
invoiceCount: detail.invoices.length,
|
||||
unlinkedInvoiceCount: unlinkedInvoices.length,
|
||||
missingInvoiceItemCount: items.filter((item) => item.expenseType?.requiresInvoice && item.invoices.length === 0).length,
|
||||
amountUnavailableItemCount: items.filter((item) => item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed")).length,
|
||||
manualReviewItemCount: items.filter((item) => item.invoices.some((invoice) => invoice.amountEvidenceStatus === "manual_review")).length,
|
||||
amountExceededItemCount: items.filter((item) => item.amountExceeded).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReimbursementReturnReference(input: {
|
||||
item?: ReimbursementItemEvidence;
|
||||
invoice?: ReimbursementInvoiceEvidence;
|
||||
}) {
|
||||
if (input.invoice) {
|
||||
const invoiceName = input.invoice.invoiceNumber || input.invoice.originalName || "未命名发票";
|
||||
return `【发票:${invoiceName}】`;
|
||||
}
|
||||
if (input.item) {
|
||||
return `【明细:${input.item.expenseType?.name ?? "未分类费用"} · ${input.item.expenseDate ?? "日期未填"} · ¥${input.item.amount}】`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const OFFICE_ELECTRONICS_HINT_THRESHOLD = 2000;
|
||||
|
||||
export function shouldShowOfficeElectronicsHint(
|
||||
expenseType: { code: string } | null | undefined,
|
||||
amount: string | number,
|
||||
) {
|
||||
const numericAmount = Number(amount);
|
||||
return expenseType?.code === "office_supplies"
|
||||
&& Number.isFinite(numericAmount)
|
||||
&& numericAmount >= OFFICE_ELECTRONICS_HINT_THRESHOLD;
|
||||
}
|
||||
@@ -24,11 +24,19 @@ export type WorkflowDraftQueueItem = {
|
||||
taskSnapshot: WorkflowTaskReferenceSnapshot;
|
||||
};
|
||||
|
||||
type CachedWorkflowTaskDetail = {
|
||||
type CachedWorkflowTaskDetailProjection = Omit<WorkflowTaskDetail, "workflowInstance"> & {
|
||||
workflowInstance: Omit<NonNullable<WorkflowTaskDetail["workflowInstance"]>, "contextData"> | null;
|
||||
};
|
||||
|
||||
type PersistedWorkflowTaskDetail = {
|
||||
id: string;
|
||||
ownerEmployeeId: string | null;
|
||||
savedAt: string;
|
||||
taskSnapshot: WorkflowTaskReferenceSnapshot;
|
||||
detail: CachedWorkflowTaskDetailProjection;
|
||||
};
|
||||
|
||||
export type CachedWorkflowTaskDetail = Omit<PersistedWorkflowTaskDetail, "detail"> & {
|
||||
detail: WorkflowTaskDetail;
|
||||
};
|
||||
|
||||
@@ -71,13 +79,185 @@ export function resolveWorkflowTaskReferenceSnapshot(detail: WorkflowTaskDetail)
|
||||
};
|
||||
}
|
||||
|
||||
export function projectWorkflowTaskDetailForCache(detail: WorkflowTaskDetail): CachedWorkflowTaskDetailProjection {
|
||||
const reimbursement = detail.reimbursement
|
||||
? {
|
||||
id: detail.reimbursement.id,
|
||||
formNumber: detail.reimbursement.formNumber,
|
||||
status: detail.reimbursement.status,
|
||||
reimbursementDate: detail.reimbursement.reimbursementDate,
|
||||
totalAmount: detail.reimbursement.totalAmount,
|
||||
description: detail.reimbursement.description,
|
||||
owner: detail.reimbursement.owner ? {
|
||||
id: detail.reimbursement.owner.id,
|
||||
employeeNo: detail.reimbursement.owner.employeeNo,
|
||||
name: detail.reimbursement.owner.name,
|
||||
} : null,
|
||||
submitter: detail.reimbursement.submitter ? {
|
||||
id: detail.reimbursement.submitter.id,
|
||||
employeeNo: detail.reimbursement.submitter.employeeNo,
|
||||
name: detail.reimbursement.submitter.name,
|
||||
} : null,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
task: {
|
||||
id: detail.task.id,
|
||||
instanceId: detail.task.instanceId,
|
||||
stepId: detail.task.stepId,
|
||||
stepName: detail.task.stepName,
|
||||
assigneeId: detail.task.assigneeId,
|
||||
assigneeRole: detail.task.assigneeRole,
|
||||
status: detail.task.status,
|
||||
outcome: detail.task.outcome,
|
||||
comment: detail.task.comment,
|
||||
createdAt: detail.task.createdAt,
|
||||
completedAt: detail.task.completedAt,
|
||||
},
|
||||
workflowInstance: detail.workflowInstance
|
||||
? {
|
||||
id: detail.workflowInstance.id,
|
||||
entityType: detail.workflowInstance.entityType,
|
||||
currentStatus: detail.workflowInstance.currentStatus,
|
||||
currentStepId: detail.workflowInstance.currentStepId,
|
||||
startedAt: detail.workflowInstance.startedAt,
|
||||
completedAt: detail.workflowInstance.completedAt,
|
||||
}
|
||||
: null,
|
||||
reimbursement,
|
||||
leaveRequest: detail.leaveRequest ? {
|
||||
id: detail.leaveRequest.id,
|
||||
status: detail.leaveRequest.status,
|
||||
startDate: detail.leaveRequest.startDate,
|
||||
endDate: detail.leaveRequest.endDate,
|
||||
startSession: detail.leaveRequest.startSession,
|
||||
endSession: detail.leaveRequest.endSession,
|
||||
requestedDays: detail.leaveRequest.requestedDays,
|
||||
reason: detail.leaveRequest.reason,
|
||||
attachment: detail.leaveRequest.attachment ? {
|
||||
originalName: detail.leaveRequest.attachment.originalName,
|
||||
mimeType: detail.leaveRequest.attachment.mimeType,
|
||||
} : null,
|
||||
leaveType: detail.leaveRequest.leaveType ? {
|
||||
id: detail.leaveRequest.leaveType.id,
|
||||
code: detail.leaveRequest.leaveType.code,
|
||||
name: detail.leaveRequest.leaveType.name,
|
||||
} : null,
|
||||
owner: detail.leaveRequest.owner ? {
|
||||
id: detail.leaveRequest.owner.id,
|
||||
employeeNo: detail.leaveRequest.owner.employeeNo,
|
||||
name: detail.leaveRequest.owner.name,
|
||||
} : null,
|
||||
} : null,
|
||||
attendanceCorrection: detail.attendanceCorrection ? {
|
||||
id: detail.attendanceCorrection.id,
|
||||
status: detail.attendanceCorrection.status,
|
||||
kind: detail.attendanceCorrection.kind,
|
||||
workDate: detail.attendanceCorrection.workDate,
|
||||
missedPunchAt: detail.attendanceCorrection.missedPunchAt,
|
||||
startAt: detail.attendanceCorrection.startAt,
|
||||
endAt: detail.attendanceCorrection.endAt,
|
||||
reason: detail.attendanceCorrection.reason,
|
||||
locationSummary: detail.attendanceCorrection.locationSummary,
|
||||
compensationPolicy: detail.attendanceCorrection.compensationPolicy,
|
||||
owner: detail.attendanceCorrection.owner ? {
|
||||
id: detail.attendanceCorrection.owner.id,
|
||||
employeeNo: detail.attendanceCorrection.owner.employeeNo,
|
||||
name: detail.attendanceCorrection.owner.name,
|
||||
} : null,
|
||||
} : null,
|
||||
purchaseRequest: detail.purchaseRequest ? {
|
||||
id: detail.purchaseRequest.id,
|
||||
requestNo: detail.purchaseRequest.requestNo,
|
||||
status: detail.purchaseRequest.status,
|
||||
title: detail.purchaseRequest.title,
|
||||
reason: detail.purchaseRequest.reason,
|
||||
currencyCode: detail.purchaseRequest.currencyCode,
|
||||
totalAmount: detail.purchaseRequest.totalAmount,
|
||||
expectedDeliveryDate: detail.purchaseRequest.expectedDeliveryDate,
|
||||
applicant: detail.purchaseRequest.applicant ? {
|
||||
id: detail.purchaseRequest.applicant.id, employeeNo: detail.purchaseRequest.applicant.employeeNo, name: detail.purchaseRequest.applicant.name,
|
||||
} : null,
|
||||
supplier: detail.purchaseRequest.supplier ? {
|
||||
id: detail.purchaseRequest.supplier.id, code: detail.purchaseRequest.supplier.code, name: detail.purchaseRequest.supplier.name,
|
||||
} : null,
|
||||
legalEntity: detail.purchaseRequest.legalEntity ? {
|
||||
id: detail.purchaseRequest.legalEntity.id, code: detail.purchaseRequest.legalEntity.code, name: detail.purchaseRequest.legalEntity.name,
|
||||
} : null,
|
||||
costCenter: detail.purchaseRequest.costCenter ? {
|
||||
id: detail.purchaseRequest.costCenter.id, code: detail.purchaseRequest.costCenter.code, name: detail.purchaseRequest.costCenter.name,
|
||||
} : null,
|
||||
documentCategory: detail.purchaseRequest.documentCategory ? {
|
||||
id: detail.purchaseRequest.documentCategory.id, code: detail.purchaseRequest.documentCategory.code, name: detail.purchaseRequest.documentCategory.name,
|
||||
} : null,
|
||||
} : null,
|
||||
travelRequest: detail.travelRequest ? {
|
||||
id: detail.travelRequest.id,
|
||||
requestNo: detail.travelRequest.requestNo,
|
||||
status: detail.travelRequest.status,
|
||||
purpose: detail.travelRequest.purpose,
|
||||
tripType: detail.travelRequest.tripType,
|
||||
startDate: detail.travelRequest.startDate,
|
||||
endDate: detail.travelRequest.endDate,
|
||||
estimatedAmount: detail.travelRequest.estimatedAmount,
|
||||
applicant: detail.travelRequest.applicant ? {
|
||||
id: detail.travelRequest.applicant.id, employeeNo: detail.travelRequest.applicant.employeeNo, name: detail.travelRequest.applicant.name,
|
||||
} : null,
|
||||
legalEntity: detail.travelRequest.legalEntity ? {
|
||||
id: detail.travelRequest.legalEntity.id, code: detail.travelRequest.legalEntity.code, name: detail.travelRequest.legalEntity.name,
|
||||
} : null,
|
||||
costCenter: detail.travelRequest.costCenter ? {
|
||||
id: detail.travelRequest.costCenter.id, code: detail.travelRequest.costCenter.code, name: detail.travelRequest.costCenter.name,
|
||||
} : null,
|
||||
segments: detail.travelRequest.segments.map((segment) => ({
|
||||
id: segment.id,
|
||||
sortOrder: segment.sortOrder,
|
||||
segmentRole: segment.segmentRole,
|
||||
transportType: segment.transportType,
|
||||
fromCity: segment.fromCity,
|
||||
toCity: segment.toCity,
|
||||
})),
|
||||
} : null,
|
||||
allowedActions: [...detail.allowedActions],
|
||||
history: detail.history.map((item) => ({
|
||||
id: item.id,
|
||||
taskId: item.taskId,
|
||||
actorId: item.actorId,
|
||||
actorName: item.actorName,
|
||||
actorEmployeeNo: item.actorEmployeeNo,
|
||||
action: item.action,
|
||||
comment: item.comment,
|
||||
payload: null,
|
||||
createdAt: item.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateCachedWorkflowTaskDetail(detail: CachedWorkflowTaskDetailProjection): WorkflowTaskDetail {
|
||||
return {
|
||||
...detail,
|
||||
workflowInstance: detail.workflowInstance
|
||||
? {
|
||||
...detail.workflowInstance,
|
||||
contextData: {
|
||||
standardViolations: [],
|
||||
budgetCheck: null,
|
||||
budgetCheckStatus: "missing",
|
||||
parseStatus: "ready",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cacheWorkflowTaskDetail(detail: WorkflowTaskDetail, ownerEmployeeId: string | null) {
|
||||
const item: CachedWorkflowTaskDetail = {
|
||||
const item: PersistedWorkflowTaskDetail = {
|
||||
id: detail.task.id,
|
||||
ownerEmployeeId,
|
||||
savedAt: new Date().toISOString(),
|
||||
taskSnapshot: resolveWorkflowTaskReferenceSnapshot(detail),
|
||||
detail,
|
||||
detail: projectWorkflowTaskDetailForCache(detail),
|
||||
};
|
||||
|
||||
await database.withStore(DETAIL_CACHE_STORE_NAME, "readwrite", async (store) => {
|
||||
@@ -90,20 +270,21 @@ export async function cacheWorkflowTaskDetail(detail: WorkflowTaskDetail, ownerE
|
||||
|
||||
export async function getCachedWorkflowTaskDetail(taskId: string, ownerEmployeeId: string | null) {
|
||||
return database.withStore(DETAIL_CACHE_STORE_NAME, "readonly", async (store) => {
|
||||
const item = (await requestPromise(store.get(taskId))) as CachedWorkflowTaskDetail | undefined;
|
||||
const item = (await requestPromise(store.get(taskId))) as PersistedWorkflowTaskDetail | undefined;
|
||||
if (!item || !matchesOwner(item.ownerEmployeeId, ownerEmployeeId)) {
|
||||
return null;
|
||||
}
|
||||
return item;
|
||||
return { ...item, detail: hydrateCachedWorkflowTaskDetail(item.detail) };
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCachedWorkflowTaskDetails(ownerEmployeeId: string | null) {
|
||||
return database.withStore(DETAIL_CACHE_STORE_NAME, "readonly", async (store) => {
|
||||
const records = (await requestPromise(store.getAll())) as CachedWorkflowTaskDetail[];
|
||||
const records = (await requestPromise(store.getAll())) as PersistedWorkflowTaskDetail[];
|
||||
return records
|
||||
.filter((item) => matchesOwner(item.ownerEmployeeId, ownerEmployeeId))
|
||||
.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
||||
.sort((a, b) => b.savedAt.localeCompare(a.savedAt))
|
||||
.map((item) => ({ ...item, detail: hydrateCachedWorkflowTaskDetail(item.detail) }));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,15 @@ import { Link, useLocation, useNavigate, useParams, useSearchParams } from "reac
|
||||
import { toast } from "sonner";
|
||||
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
|
||||
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
|
||||
import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements";
|
||||
import {
|
||||
downloadReimbursementAttachment,
|
||||
fetchReimbursementAttachmentPreviewUrl,
|
||||
fetchReimbursementDetail,
|
||||
fetchReimbursementMaterialPreviewBundle,
|
||||
fetchReimbursements,
|
||||
type ReimbursementListItem,
|
||||
} from "../api/reimbursements";
|
||||
import { fetchInvoiceSignedUrl } from "../api/invoicePool";
|
||||
import { isApiError } from "../api/types";
|
||||
import {
|
||||
approveWorkflowTask,
|
||||
@@ -21,6 +29,8 @@ import {
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
@@ -36,6 +46,13 @@ import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
|
||||
import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
|
||||
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import {
|
||||
buildReimbursementApprovalEvidence,
|
||||
buildReimbursementReturnReference,
|
||||
} from "../lib/reimbursementApprovalProjection";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { cn } from "../lib/utils";
|
||||
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
|
||||
import {
|
||||
@@ -370,6 +387,7 @@ export function MobileApprovalsPage() {
|
||||
const [transferReason, setTransferReason] = useState("");
|
||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
||||
const [showSecondaryActions, setShowSecondaryActions] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const autoSyncRequestedRef = useRef(false);
|
||||
|
||||
@@ -543,6 +561,51 @@ export function MobileApprovalsPage() {
|
||||
);
|
||||
|
||||
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
|
||||
const mobileReimbursementId = selectedTask?.reimbursement?.id ?? "";
|
||||
const reimbursementDetailQuery = useQuery({
|
||||
enabled: Boolean(mobileReimbursementId) && canUseRemote,
|
||||
queryKey: ["reimbursements", mobileReimbursementId],
|
||||
queryFn: () => fetchReimbursementDetail(mobileReimbursementId),
|
||||
});
|
||||
const reimbursementEvidence = useMemo(() => {
|
||||
const snapshot = selectedTask?.workflowInstance?.contextData;
|
||||
if (!reimbursementDetailQuery.data || !snapshot) return null;
|
||||
return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, snapshot);
|
||||
}, [reimbursementDetailQuery.data, selectedTask?.workflowInstance?.contextData]);
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
const openInvoiceMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => {
|
||||
const result = await fetchInvoiceSignedUrl(invoiceId);
|
||||
const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
||||
if (!openedInWecom) openFileInBrowser(result.signedUrl);
|
||||
},
|
||||
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开发票失败,请重试")),
|
||||
});
|
||||
const openAttachmentMutation = useMutation({
|
||||
mutationFn: async ({ attachment, reason }: {
|
||||
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (isWecomWebView()) {
|
||||
const result = await fetchReimbursementAttachmentPreviewUrl(mobileReimbursementId, attachment.id, reason);
|
||||
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
||||
return null;
|
||||
}
|
||||
return downloadReimbursementAttachment(mobileReimbursementId, attachment.id, reason);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (!result) return;
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
},
|
||||
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开附件失败,请重试")),
|
||||
});
|
||||
const materialPreviewMutation = useMutation({
|
||||
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(mobileReimbursementId, reason),
|
||||
onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl),
|
||||
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开完整材料失败,可逐份查看原件")),
|
||||
});
|
||||
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
||||
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
|
||||
const selectedDetailReference = selectedDisplayReference ??
|
||||
@@ -571,6 +634,10 @@ export function MobileApprovalsPage() {
|
||||
selectedTask?.travelRequest?.estimatedAmount ??
|
||||
null;
|
||||
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
|
||||
const isSelectedReimbursement = selectedTask?.workflowInstance?.entityType === "reimbursement";
|
||||
const reimbursementDecisionReady = !isSelectedReimbursement || Boolean(
|
||||
canUseRemote && reimbursementEvidence && selectedTask?.workflowInstance?.contextData.parseStatus === "ready",
|
||||
);
|
||||
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
|
||||
const employeeProgressCards = useMemo(
|
||||
() =>
|
||||
@@ -599,6 +666,10 @@ export function MobileApprovalsPage() {
|
||||
});
|
||||
|
||||
function requestMobileWorkflowAction(action: WorkflowConfirmAction) {
|
||||
if (isSelectedReimbursement && !reimbursementDecisionReady) {
|
||||
setError("报销审批需联网加载完整明细、票据和提交时检查后才能处理。");
|
||||
return;
|
||||
}
|
||||
if (!canUseRemote && action === "return") {
|
||||
setError("当前离线不支持退回,请联网后再处理。");
|
||||
return;
|
||||
@@ -618,6 +689,31 @@ export function MobileApprovalsPage() {
|
||||
setConfirmAction(action);
|
||||
}
|
||||
|
||||
function appendReturnReference(reference: string) {
|
||||
if (!reference) return;
|
||||
setComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim());
|
||||
setShowSecondaryActions(true);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function handleOpenAttachment(attachmentId: string) {
|
||||
const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId);
|
||||
if (!attachment) {
|
||||
toast.error("附件信息已变化,请刷新后重试");
|
||||
return;
|
||||
}
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) return;
|
||||
openAttachmentMutation.mutate({ attachment, reason });
|
||||
}
|
||||
|
||||
async function handleOpenAllMaterials() {
|
||||
const governedAttachment = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason);
|
||||
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
|
||||
if (reason === null) return;
|
||||
materialPreviewMutation.mutate(reason);
|
||||
}
|
||||
|
||||
async function refreshDraftQueueCache() {
|
||||
if (!ownerEmployeeId) {
|
||||
return [];
|
||||
@@ -641,6 +737,9 @@ export function MobileApprovalsPage() {
|
||||
if (!selectedTask) {
|
||||
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
|
||||
}
|
||||
if (selectedTask.workflowInstance?.entityType === "reimbursement") {
|
||||
throw new Error("报销审批不能保存离线草稿,请联网核对完整内容后处理");
|
||||
}
|
||||
if (!selectedTask.allowedActions.includes(action)) {
|
||||
throw new Error("当前审批任务不允许该动作");
|
||||
}
|
||||
@@ -683,6 +782,9 @@ export function MobileApprovalsPage() {
|
||||
if (!selectedTask) {
|
||||
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
|
||||
}
|
||||
if (selectedTask.workflowInstance?.entityType === "reimbursement" && !reimbursementDecisionReady) {
|
||||
throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
|
||||
}
|
||||
if (!selectedTask.allowedActions.includes(action)) {
|
||||
throw new Error("当前审批任务不允许该动作");
|
||||
}
|
||||
@@ -842,7 +944,9 @@ export function MobileApprovalsPage() {
|
||||
: "最近 30 天已处理审批用于复核,不支持再次审批。";
|
||||
const selectedActionText = selectedTask
|
||||
? selectedTaskAllowsActions
|
||||
? canUseRemote
|
||||
? isSelectedReimbursement && !reimbursementDecisionReady
|
||||
? "报销审批需联网加载完整明细、票据和提交时检查;内容就绪前不能通过、退回、驳回或保存草稿。"
|
||||
: canUseRemote
|
||||
? "核对摘要和历史后,可在底部通过、驳回或退回。"
|
||||
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
|
||||
: "当前任务已处理,本页只展示业务摘要和处理历史。"
|
||||
@@ -877,7 +981,10 @@ export function MobileApprovalsPage() {
|
||||
|
||||
{!isOnline && canAccessRemoteApprovals ? (
|
||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
|
||||
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
|
||||
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}
|
||||
{isSelectedReimbursement
|
||||
? "当前报销任务只显示摘要,不能保存审批草稿;请联网核对完整内容。"
|
||||
: "仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -1168,6 +1275,61 @@ export function MobileApprovalsPage() {
|
||||
查看完整报销单
|
||||
</Link>
|
||||
</Button>
|
||||
{!canUseRemote ? (
|
||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-800">
|
||||
当前只显示允许离线保存的摘要。请联网加载完整明细和票据后再处理该报销审批。
|
||||
</p>
|
||||
) : reimbursementDetailQuery.isLoading ? (
|
||||
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||
正在加载完整报销内容;加载完成前所有审批动作均不可用。
|
||||
</p>
|
||||
) : reimbursementDetailQuery.isError ? (
|
||||
<div className="space-y-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
|
||||
<p>{resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")}</p>
|
||||
<Button className="min-h-11" onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.workflowInstance?.contextData.parseStatus === "invalid" ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
|
||||
提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。
|
||||
</p>
|
||||
) : null}
|
||||
{reimbursementEvidence ? (
|
||||
<>
|
||||
{selectedTask.task.status !== "pending" ? (
|
||||
<p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
|
||||
当前版本可能包含申请人在退回后所做的修改;历史审批意见和提交时检查仍对应当时版本。
|
||||
</p>
|
||||
) : null}
|
||||
<ReimbursementItemEvidenceList
|
||||
evidence={reimbursementEvidence}
|
||||
capabilities={{
|
||||
onOpenInvoice: (invoice) => openInvoiceMutation.mutate(invoice.id),
|
||||
onOpenAttachment: (attachment) => void handleOpenAttachment(attachment.id),
|
||||
onOpenMaterialBundle: () => void handleOpenAllMaterials(),
|
||||
onReferenceItem: (item) => appendReturnReference(buildReimbursementReturnReference({ item })),
|
||||
onReferenceInvoice: (invoice) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-2" data-testid="mobile-reimbursement-approval-checks">
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
|
||||
<p className="font-medium text-zinc-900">提交时检查</p>
|
||||
{selectedTask.workflowInstance?.contextData.budgetCheckStatus === "missing" ? (
|
||||
<p className="mt-1 text-amber-800">该次提交未记录预算快照。</p>
|
||||
) : selectedTask.workflowInstance?.contextData.budgetCheckStatus === "not_applicable" ? (
|
||||
<p className="mt-1 text-zinc-600">提交时未匹配适用预算。</p>
|
||||
) : selectedTask.workflowInstance?.contextData.budgetCheck ? (
|
||||
<p className="mt-1">预算 {selectedTask.workflowInstance.contextData.budgetCheck.budgetCode},提交后使用率 {selectedTask.workflowInstance.contextData.budgetCheck.usagePercent}%</p>
|
||||
) : null}
|
||||
<p className="mt-1">费用标准提示 {selectedTask.workflowInstance?.contextData.standardViolations.length ?? 0} 条</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
|
||||
<p className="font-medium text-zinc-900">当前票据检查</p>
|
||||
<p className="mt-1">票据 {reimbursementEvidence.currentCheck.invoiceCount} 张,尚未对应明细 {reimbursementEvidence.currentCheck.unlinkedInvoiceCount} 张;需人工核对 {reimbursementEvidence.currentCheck.manualReviewItemCount} 条,申报额超出票面 {reimbursementEvidence.currentCheck.amountExceededItemCount} 条。</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.leaveRequest ? (
|
||||
@@ -1231,6 +1393,7 @@ export function MobileApprovalsPage() {
|
||||
<Textarea
|
||||
id="mobile-approvals-comment"
|
||||
data-testid="mobile-approvals-comment"
|
||||
disabled={isSelectedReimbursement && !reimbursementDecisionReady}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="意见(驳回/退回必填)"
|
||||
rows={3}
|
||||
@@ -1238,45 +1401,67 @@ export function MobileApprovalsPage() {
|
||||
/>
|
||||
|
||||
{!canUseRemote ? (
|
||||
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{isSelectedReimbursement
|
||||
? "报销审批离线时不能保存审批草稿,请联网核对完整内容。"
|
||||
: "离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{showSecondaryActions ? (
|
||||
<div className="mobile-list-card space-y-3" data-testid="mobile-approvals-secondary-actions">
|
||||
<p className="text-sm font-medium text-zinc-900">其他处理</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
className="min-h-11 w-full"
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !reimbursementDecisionReady || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => requestMobileWorkflowAction("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-11 w-full"
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => requestMobileWorkflowAction("reject")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MobileStickyActionBar
|
||||
contentClassName="mobile-approval-action-grid"
|
||||
contentClassName="grid grid-cols-[minmax(0,1fr)_auto] gap-2"
|
||||
testId="mobile-approvals-sticky-actions"
|
||||
>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-approve"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("approve")}
|
||||
onClick={() => requestMobileWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => requestMobileWorkflowAction("reject")}
|
||||
aria-expanded={showSecondaryActions}
|
||||
className="min-h-12 px-4"
|
||||
data-testid="mobile-approvals-more"
|
||||
onClick={() => setShowSecondaryActions((current) => !current)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => requestMobileWorkflowAction("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
更多处理
|
||||
</Button>
|
||||
</MobileStickyActionBar>
|
||||
|
||||
{canTransfer ? (
|
||||
{canTransfer && showSecondaryActions ? (
|
||||
<div className="mobile-list-card space-y-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<Label htmlFor="mobile-approvals-transfer-to">目标审批人</Label>
|
||||
@@ -1463,6 +1648,7 @@ export function MobileApprovalsPage() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{downloadReasonDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
|
||||
import {
|
||||
focusFirstInvoiceGuardIssue,
|
||||
invoiceGuardInvoiceMessages,
|
||||
|
||||
@@ -91,10 +91,10 @@ import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFi
|
||||
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
|
||||
import {
|
||||
ReimbursementItemEditor,
|
||||
shouldShowOfficeElectronicsHint,
|
||||
type ItemEditorInitialValues,
|
||||
type ItemEditorSaveData,
|
||||
} from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import {
|
||||
focusFirstInvoiceGuardIssue,
|
||||
|
||||
@@ -13,11 +13,20 @@ import {
|
||||
transferWorkflowTask,
|
||||
} from "../api/workflow";
|
||||
import { toast } from "sonner";
|
||||
import { fetchInvoiceSignedUrl } from "../api/invoicePool";
|
||||
import {
|
||||
downloadReimbursementAttachment,
|
||||
fetchReimbursementAttachmentPreviewUrl,
|
||||
fetchReimbursementDetail,
|
||||
fetchReimbursementMaterialPreviewBundle,
|
||||
} from "../api/reimbursements";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import {
|
||||
BusinessDetailActionPanel,
|
||||
@@ -35,6 +44,13 @@ import { Label } from "../components/ui/label";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { approvalRoleLabel, resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import {
|
||||
buildReimbursementApprovalEvidence,
|
||||
buildReimbursementReturnReference,
|
||||
} from "../lib/reimbursementApprovalProjection";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import {
|
||||
buildPcDetailSource,
|
||||
resolvePcDetailSource,
|
||||
@@ -125,6 +141,53 @@ export function WorkflowTaskDetailPage() {
|
||||
queryKey: ["workflow", "task", taskId],
|
||||
queryFn: () => fetchWorkflowTaskDetail(taskId),
|
||||
});
|
||||
const reimbursementId = detailQuery.data?.reimbursement?.id ?? "";
|
||||
const reimbursementDetailQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId),
|
||||
queryKey: ["reimbursements", reimbursementId],
|
||||
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
||||
});
|
||||
|
||||
const reimbursementSnapshot = detailQuery.data?.workflowInstance?.contextData ?? null;
|
||||
const reimbursementEvidence = useMemo(() => {
|
||||
if (!reimbursementDetailQuery.data || !reimbursementSnapshot) return null;
|
||||
return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, reimbursementSnapshot);
|
||||
}, [reimbursementDetailQuery.data, reimbursementSnapshot]);
|
||||
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
const openInvoiceMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => {
|
||||
const result = await fetchInvoiceSignedUrl(invoiceId);
|
||||
const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
||||
if (!openedInWecom) openFileInBrowser(result.signedUrl);
|
||||
},
|
||||
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开发票失败,请重试")),
|
||||
});
|
||||
const openAttachmentMutation = useMutation({
|
||||
mutationFn: async ({ attachment, reason }: {
|
||||
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (isWecomWebView()) {
|
||||
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
|
||||
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
||||
return null;
|
||||
}
|
||||
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (!result) return;
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
},
|
||||
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开附件失败,请重试")),
|
||||
});
|
||||
const materialPreviewMutation = useMutation({
|
||||
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
|
||||
onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl),
|
||||
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开完整材料失败,可逐份查看原件")),
|
||||
});
|
||||
|
||||
const approverQuery = useQuery({
|
||||
enabled: hasPermission(permissions, "expense.transfer"),
|
||||
@@ -141,6 +204,12 @@ export function WorkflowTaskDetailPage() {
|
||||
if (!taskId) {
|
||||
throw new Error("missing taskId");
|
||||
}
|
||||
if (
|
||||
detailQuery.data?.workflowInstance?.entityType === "reimbursement" &&
|
||||
(!reimbursementEvidence || detailQuery.data.workflowInstance.contextData.parseStatus !== "ready")
|
||||
) {
|
||||
throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
|
||||
}
|
||||
|
||||
if (action === "approve") {
|
||||
return approveWorkflowTask(taskId);
|
||||
@@ -187,6 +256,13 @@ export function WorkflowTaskDetailPage() {
|
||||
const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
|
||||
|
||||
function requestWorkflowAction(action: WorkflowConfirmAction) {
|
||||
if (
|
||||
detailQuery.data?.workflowInstance?.entityType === "reimbursement" &&
|
||||
(!reimbursementEvidence || detailQuery.data.workflowInstance.contextData.parseStatus !== "ready")
|
||||
) {
|
||||
setActionError("完整报销内容或提交时检查尚未就绪,暂时不能处理;请重试加载后再决定。");
|
||||
return;
|
||||
}
|
||||
if (action === "reject" && rejectComment.trim().length < 1) {
|
||||
setActionError("驳回必须填写意见");
|
||||
return;
|
||||
@@ -215,6 +291,31 @@ export function WorkflowTaskDetailPage() {
|
||||
setTransferConfirmOpen(true);
|
||||
}
|
||||
|
||||
function appendReturnReference(reference: string) {
|
||||
if (!reference) return;
|
||||
setReturnComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim());
|
||||
setActiveSecondaryAction("return");
|
||||
setActionError("");
|
||||
}
|
||||
|
||||
async function handleOpenAttachment(attachmentId: string) {
|
||||
const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId);
|
||||
if (!attachment) {
|
||||
toast.error("附件信息已变化,请刷新后重试");
|
||||
return;
|
||||
}
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) return;
|
||||
openAttachmentMutation.mutate({ attachment, reason });
|
||||
}
|
||||
|
||||
async function handleOpenAllMaterials() {
|
||||
const governedAttachment = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason);
|
||||
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
|
||||
if (reason === null) return;
|
||||
materialPreviewMutation.mutate(reason);
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<div className="card-std p-5">
|
||||
@@ -249,10 +350,25 @@ export function WorkflowTaskDetailPage() {
|
||||
}
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const isReimbursementTask = detail.workflowInstance?.entityType === "reimbursement";
|
||||
const reimbursementDecisionReady = !isReimbursementTask || Boolean(
|
||||
reimbursementEvidence && detail.workflowInstance?.contextData.parseStatus === "ready",
|
||||
);
|
||||
const canApprove = allowedActions.has("approve");
|
||||
const canReturn = allowedActions.has("return");
|
||||
const canReject = allowedActions.has("reject");
|
||||
const canTransfer = allowedActions.has("transfer");
|
||||
const submissionConcernCount = isReimbursementTask
|
||||
? (detail.workflowInstance?.contextData.standardViolations.length ?? 0) +
|
||||
(detail.workflowInstance?.contextData.budgetCheck?.exceededThreshold ? 1 : 0)
|
||||
: 0;
|
||||
const currentEvidenceConcernCount = reimbursementEvidence
|
||||
? reimbursementEvidence.currentCheck.unlinkedInvoiceCount +
|
||||
reimbursementEvidence.currentCheck.missingInvoiceItemCount +
|
||||
reimbursementEvidence.currentCheck.amountUnavailableItemCount +
|
||||
reimbursementEvidence.currentCheck.manualReviewItemCount +
|
||||
reimbursementEvidence.currentCheck.amountExceededItemCount
|
||||
: 0;
|
||||
const transferDisabledReason = !selectedApprover
|
||||
? "请先选择目标审批人。"
|
||||
: transferReason.trim().length < 10
|
||||
@@ -303,6 +419,8 @@ export function WorkflowTaskDetailPage() {
|
||||
<DetailField label="报销金额" value={detail.reimbursement.totalAmount ?? "-"} />
|
||||
<DetailField label="提交人" value={detail.reimbursement.submitter?.name ?? "-"} />
|
||||
<DetailField label="报销人" value={detail.reimbursement.owner?.name ?? "-"} />
|
||||
<DetailField label="报销日期" value={detail.reimbursement.reimbursementDate ?? "-"} />
|
||||
<DetailField label="报销事由" value={detail.reimbursement.description ?? "-"} />
|
||||
</>
|
||||
) : null}
|
||||
{detail.leaveRequest ? (
|
||||
@@ -388,38 +506,79 @@ export function WorkflowTaskDetailPage() {
|
||||
state={{ from: currentTaskSource }}
|
||||
to={`/reimbursements/${encodeURIComponent(detail.reimbursement.id)}`}
|
||||
>
|
||||
查看报销单
|
||||
打开完整原始表单
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
{detail.workflowInstance?.entityType === "reimbursement" &&
|
||||
detail.workflowInstance.contextData.standardViolations.length ? (
|
||||
<SectionPanel title="费用标准提示">
|
||||
<div className="space-y-2">
|
||||
{detail.workflowInstance.contextData.standardViolations.map((item) => (
|
||||
<div
|
||||
key={`${item.itemId}-${item.standardId}`}
|
||||
className={
|
||||
item.controlMode === "block"
|
||||
? "rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
: "rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700"
|
||||
}
|
||||
>
|
||||
<div className="font-medium text-zinc-900">{item.expenseTypeName}</div>
|
||||
<div>
|
||||
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit}
|
||||
{isReimbursementTask ? (
|
||||
<SectionPanel title="报销明细与材料">
|
||||
{detail.task.status !== "pending" ? (
|
||||
<p className="mb-4 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
|
||||
当前展示报销单的最新版本,可能包含申请人在退回后所做的修改;审批意见和提交时检查仍保留历史语义。
|
||||
</p>
|
||||
) : null}
|
||||
{reimbursementDetailQuery.isLoading ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-4 text-sm text-zinc-600">
|
||||
正在加载完整报销内容和票据,请稍候;加载完成前不能作出审批决定。
|
||||
</div>
|
||||
) : null}
|
||||
{reimbursementDetailQuery.isError ? (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700" role="alert">
|
||||
<p>{resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")}</p>
|
||||
<Button onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.workflowInstance?.contextData.parseStatus === "invalid" ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700" role="alert">
|
||||
提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对该次提交记录。
|
||||
</p>
|
||||
) : null}
|
||||
{reimbursementEvidence ? (
|
||||
<ReimbursementItemEvidenceList
|
||||
evidence={reimbursementEvidence}
|
||||
capabilities={{
|
||||
onOpenInvoice: (invoice) => openInvoiceMutation.mutate(invoice.id),
|
||||
onOpenAttachment: (attachment) => void handleOpenAttachment(attachment.id),
|
||||
onOpenMaterialBundle: () => void handleOpenAllMaterials(),
|
||||
onReferenceItem: (item) => appendReturnReference(buildReimbursementReturnReference({ item })),
|
||||
onReferenceInvoice: (invoice) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
{isReimbursementTask ? (
|
||||
<SectionPanel title="提交时检查与当前票据检查">
|
||||
<div className="grid gap-3 md:grid-cols-2" data-testid="reimbursement-approval-checks">
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-700">
|
||||
<h3 className="font-medium text-zinc-900">提交时检查</h3>
|
||||
{detail.workflowInstance?.contextData.budgetCheckStatus === "missing" ? (
|
||||
<p className="mt-2 text-amber-800">该次提交未记录预算快照。</p>
|
||||
) : detail.workflowInstance?.contextData.budgetCheckStatus === "not_applicable" ? (
|
||||
<p className="mt-2 text-zinc-600">提交时未匹配适用预算。</p>
|
||||
) : detail.workflowInstance?.contextData.budgetCheck ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p>预算:{detail.workflowInstance.contextData.budgetCheck.budgetCode}</p>
|
||||
<p>提交后使用率:{detail.workflowInstance.contextData.budgetCheck.usagePercent}%</p>
|
||||
<p>{detail.workflowInstance.contextData.budgetCheck.exceededBudget ? "提交时已超出预算" : "提交时未超出预算"}</p>
|
||||
</div>
|
||||
<div>超出 ¥{item.exceededAmount} · {item.standardName}</div>
|
||||
<div>
|
||||
{item.controlMode === "block"
|
||||
? "处理影响:当前单据不应继续通过,请申请人调整金额或补充合规处理依据。"
|
||||
: "处理影响:可继续审批,但请核对超标原因并在审批意见中记录判断。"}
|
||||
) : null}
|
||||
<p className="mt-2">费用标准提示:{detail.workflowInstance?.contextData.standardViolations.length ?? 0} 条</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-700">
|
||||
<h3 className="font-medium text-zinc-900">当前票据检查</h3>
|
||||
{reimbursementEvidence ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p>票据 {reimbursementEvidence.currentCheck.invoiceCount} 张,尚未对应明细 {reimbursementEvidence.currentCheck.unlinkedInvoiceCount} 张</p>
|
||||
<p>缺少票据的明细 {reimbursementEvidence.currentCheck.missingInvoiceItemCount} 条</p>
|
||||
<p>金额未能读取 {reimbursementEvidence.currentCheck.amountUnavailableItemCount} 条,需人工核对 {reimbursementEvidence.currentCheck.manualReviewItemCount} 条,申报额超出票面 {reimbursementEvidence.currentCheck.amountExceededItemCount} 条</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
) : <p className="mt-2 text-red-700">当前票据检查尚未就绪。</p>}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
@@ -450,7 +609,7 @@ export function WorkflowTaskDetailPage() {
|
||||
primaryAction={canApprove ? (
|
||||
<Button
|
||||
data-testid="workflow-task-approve"
|
||||
disabled={mutation.isPending}
|
||||
disabled={mutation.isPending || !reimbursementDecisionReady}
|
||||
onClick={() => requestWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
@@ -463,7 +622,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<Button
|
||||
data-testid="workflow-task-return-toggle"
|
||||
variant="secondary"
|
||||
disabled={mutation.isPending}
|
||||
disabled={mutation.isPending || !reimbursementDecisionReady}
|
||||
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
|
||||
type="button"
|
||||
>
|
||||
@@ -474,7 +633,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<Button
|
||||
data-testid="workflow-task-reject-toggle"
|
||||
variant="secondary"
|
||||
disabled={mutation.isPending}
|
||||
disabled={mutation.isPending || !reimbursementDecisionReady}
|
||||
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")}
|
||||
type="button"
|
||||
>
|
||||
@@ -488,6 +647,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<summary>更多</summary>
|
||||
<button
|
||||
data-testid="workflow-task-transfer-menu-item"
|
||||
disabled={!reimbursementDecisionReady}
|
||||
type="button"
|
||||
onClick={() => setActiveSecondaryAction("transfer")}
|
||||
>
|
||||
@@ -503,6 +663,19 @@ export function WorkflowTaskDetailPage() {
|
||||
<div>创建时间:{formatDisplayDateTime(detail.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDisplayDateTime(detail.task.completedAt)}</div>
|
||||
<div>发起时间:{formatDisplayDateTime(detail.workflowInstance?.startedAt)}</div>
|
||||
{isReimbursementTask ? (
|
||||
<>
|
||||
<div>报销合计:{detail.reimbursement?.totalAmount ?? "-"}</div>
|
||||
<div>
|
||||
内容核验:{reimbursementDecisionReady ? "完整内容已就绪" : "尚未就绪,所有审批动作已停用"}
|
||||
</div>
|
||||
{reimbursementEvidence ? (
|
||||
<div>
|
||||
检查摘要:提交时提示 {submissionConcernCount} 项;当前票据需关注 {currentEvidenceConcernCount} 项
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
testId="workflow-task-action-panel"
|
||||
@@ -516,7 +689,7 @@ export function WorkflowTaskDetailPage() {
|
||||
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
data-testid="workflow-task-action-error"
|
||||
>
|
||||
{actionError}
|
||||
@@ -537,7 +710,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<Button
|
||||
data-testid="workflow-task-reject"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending || !reimbursementDecisionReady || rejectComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("reject")}
|
||||
type="button"
|
||||
>
|
||||
@@ -560,7 +733,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<Button
|
||||
data-testid="workflow-task-return"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||
disabled={!allowedActions.has("return") || mutation.isPending || !reimbursementDecisionReady || returnComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("return")}
|
||||
type="button"
|
||||
>
|
||||
@@ -610,6 +783,7 @@ export function WorkflowTaskDetailPage() {
|
||||
disabled={
|
||||
!allowedActions.has("transfer") ||
|
||||
mutation.isPending ||
|
||||
!reimbursementDecisionReady ||
|
||||
!selectedApprover ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
@@ -659,6 +833,7 @@ export function WorkflowTaskDetailPage() {
|
||||
await mutation.mutateAsync("transfer");
|
||||
}}
|
||||
/>
|
||||
{downloadReasonDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,18 +5,7 @@ import { fetchExpenseStandards } from "../../api/expenseStandards";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
|
||||
export const OFFICE_ELECTRONICS_HINT_THRESHOLD = 2000;
|
||||
|
||||
export function shouldShowOfficeElectronicsHint(
|
||||
expenseType: Pick<ExpenseType, "code"> | null | undefined,
|
||||
amount: string | number,
|
||||
) {
|
||||
const numericAmount = Number(amount);
|
||||
return expenseType?.code === "office_supplies"
|
||||
&& Number.isFinite(numericAmount)
|
||||
&& numericAmount >= OFFICE_ELECTRONICS_HINT_THRESHOLD;
|
||||
}
|
||||
import { shouldShowOfficeElectronicsHint } from "../../lib/reimbursementPolicyHints";
|
||||
|
||||
export type ItemEditorMode = "create" | "edit";
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await waitForWorkflowTaskCache(page, task.id);
|
||||
|
||||
await page.context().setOffline(true);
|
||||
await page.getByTestId("mobile-approvals-more").click();
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-comment").fill("离线驳回意见:请补充差旅安排说明");
|
||||
await page.getByTestId("mobile-approvals-reject").click();
|
||||
@@ -179,3 +180,180 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await Promise.all([created.client.dispose(), managerApi.dispose()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail closed", async ({ page }) => {
|
||||
const taskId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const now = new Date().toISOString();
|
||||
const detail = {
|
||||
task: {
|
||||
id: taskId,
|
||||
instanceId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
stepId: "finance",
|
||||
stepName: "财务审批",
|
||||
assigneeId: "manager",
|
||||
assigneeRole: "manager",
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
comment: null,
|
||||
createdAt: now,
|
||||
completedAt: null,
|
||||
},
|
||||
workflowInstance: {
|
||||
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
entityType: "reimbursement",
|
||||
currentStatus: "pending",
|
||||
currentStepId: "finance",
|
||||
contextData: {
|
||||
standardViolations: [{ itemId: "sensitive-item", amount: "999.00" }],
|
||||
budgetCheck: { budgetId: "sensitive-budget", budgetCode: "B-1", controlMode: "warn", usagePercent: 90, exceededThreshold: true, exceededBudget: false },
|
||||
budgetCheckStatus: "available",
|
||||
parseStatus: "ready",
|
||||
},
|
||||
startedAt: now,
|
||||
completedAt: null,
|
||||
},
|
||||
reimbursement: {
|
||||
id: reimbursementId,
|
||||
formNumber: "BX-CACHE-001",
|
||||
status: "reviewing",
|
||||
reimbursementDate: "2026-09-01",
|
||||
totalAmount: "999.00",
|
||||
description: "缓存边界合成报销",
|
||||
owner: { id: "owner", employeeNo: "E001", name: "合成申请人" },
|
||||
submitter: { id: "owner", employeeNo: "E001", name: "合成申请人" },
|
||||
items: [{ id: "must-not-persist" }],
|
||||
invoices: [{ id: "must-not-persist", amountEvidenceStatus: "manual_review" }],
|
||||
attachments: [{ id: "must-not-persist", materialUrl: "https://invalid.example/material" }],
|
||||
sharedProjection: { itemEvidenceModels: ["must-not-persist"] },
|
||||
},
|
||||
leaveRequest: null,
|
||||
attendanceCorrection: null,
|
||||
purchaseRequest: null,
|
||||
travelRequest: null,
|
||||
allowedActions: ["approve", "reject", "return"],
|
||||
history: [],
|
||||
};
|
||||
const reimbursementDetail = {
|
||||
id: reimbursementId,
|
||||
items: [{
|
||||
id: "item-1",
|
||||
expenseTypeId: "type-1",
|
||||
expenseDate: "2026-09-01",
|
||||
amount: "999.00",
|
||||
description: "合成明细",
|
||||
expenseType: { id: "type-1", code: "travel_taxi", name: "交通费", category: "travel", enabled: true, requiresInvoice: true },
|
||||
}],
|
||||
invoices: [{
|
||||
id: "invoice-1",
|
||||
status: "bound",
|
||||
invoiceCode: null,
|
||||
invoiceNumber: "FP-CACHE-1",
|
||||
invoiceDate: "2026-09-01",
|
||||
sellerName: "合成供应商",
|
||||
buyerName: null,
|
||||
documentCategory: "invoice",
|
||||
documentSubtype: null,
|
||||
totalAmount: null,
|
||||
originalName: "synthetic.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 100,
|
||||
linkedItemId: "item-1",
|
||||
amountEvidenceStatus: "manual_review",
|
||||
}],
|
||||
attachments: [{
|
||||
id: "attachment-1",
|
||||
originalName: "support.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 100,
|
||||
fileSha256: "must-not-persist",
|
||||
createdAt: now,
|
||||
}],
|
||||
};
|
||||
|
||||
const fulfillData = (route: import("@playwright/test").Route, data: unknown, headers?: Record<string, string>) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers,
|
||||
body: JSON.stringify({ requestId: "cache-boundary", data }),
|
||||
});
|
||||
await page.route((url) => [
|
||||
"/api/auth/sso/providers", "/api/auth/wecom/config", "/api/auth/login", "/api/auth/refresh",
|
||||
"/api/me", "/api/system/navigation", "/api/notifications", "/api/portal/home",
|
||||
"/api/workflow/tasks", "/api/workflow/tasks/summary",
|
||||
].includes(url.pathname), async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/api/auth/sso/providers") return fulfillData(route, []);
|
||||
if (url.pathname === "/api/auth/wecom/config") return fulfillData(route, {
|
||||
enabled: false, entryEnabled: false, oauthEnabled: false, corpId: null, agentId: null,
|
||||
loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [],
|
||||
});
|
||||
if (url.pathname === "/api/auth/login" || url.pathname === "/api/auth/refresh") {
|
||||
return fulfillData(route, { accessToken: "cache-boundary-token", expiresIn: 900, mustChangePassword: false }, { "set-cookie": "has_session=1; Path=/; SameSite=Lax" });
|
||||
}
|
||||
if (url.pathname === "/api/me") return fulfillData(route, {
|
||||
id: "manager", employeeNo: "M001", name: "合成审批人", phone: null, email: "[email protected]",
|
||||
departmentId: "department", directManagerId: null, status: "active", notificationPushEnabled: true,
|
||||
roleCodes: ["manager"], bankAccount: { bankName: null, bankInstitutionCode: null, accountNumberMasked: null },
|
||||
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 70 },
|
||||
});
|
||||
if (url.pathname === "/api/system/navigation") return fulfillData(route, {
|
||||
workspace: { id: "workspace", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "mobile", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null },
|
||||
sections: { main: [{ key: "workflow", href: "/workflow/tasks", label: "待我审批", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null }], admin: [], backup: [] },
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
if (url.pathname === "/api/notifications") return fulfillData(route, { page: 1, pageSize: 5, total: 0, unreadOnly: false, unreadCount: 0, items: [] });
|
||||
if (url.pathname === "/api/portal/home") return fulfillData(route, { hero: { title: "工作台", subtitle: "合成测试" }, metrics: [], todos: [], notifications: [] });
|
||||
if (url.pathname === "/api/workflow/tasks/summary") return fulfillData(route, { scope: "mine", pendingTotal: 1, overdueTotal: 0, earliestPendingAt: now, averagePendingAgeHours: 1, byEntityType: [], byStep: [], byAssigneeRole: [] });
|
||||
return fulfillData(route, { page: 1, pageSize: 100, total: 0, items: [] });
|
||||
});
|
||||
|
||||
await page.route(`**/api/workflow/tasks/${taskId}`, (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ requestId: "cache-boundary", data: detail }),
|
||||
}));
|
||||
await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ requestId: "cache-boundary", data: reimbursementDetail }),
|
||||
}));
|
||||
|
||||
await loginMobile(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${taskId}`);
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
|
||||
await waitForWorkflowTaskCache(page, taskId);
|
||||
|
||||
const rawCache = await page.evaluate(async (currentTaskId) => {
|
||||
const request = window.indexedDB.open("hyb-oa-mobile-workflow", 1);
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
try {
|
||||
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
|
||||
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const query = store.get(currentTaskId);
|
||||
query.onsuccess = () => resolve(query.result);
|
||||
query.onerror = () => reject(query.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}, taskId);
|
||||
const serialized = JSON.stringify(rawCache);
|
||||
for (const forbiddenField of [
|
||||
"items", "invoices", "attachments", "amountEvidenceStatus", "materialUrl",
|
||||
"fileSha256", "sharedProjection", "itemEvidenceModels", "standardViolations",
|
||||
"budgetCheck", "contextData", "sensitive-item", "sensitive-budget", "must-not-persist",
|
||||
]) {
|
||||
expect(serialized).not.toContain(forbiddenField);
|
||||
}
|
||||
|
||||
await page.context().setOffline(true);
|
||||
await expect(page.getByText("请联网加载完整明细和票据后再处理该报销审批")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-approve")).toBeDisabled();
|
||||
await page.getByTestId("mobile-approvals-more").click();
|
||||
await expect(page.getByTestId("mobile-approvals-return")).toBeDisabled();
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -110,7 +110,11 @@ function navigationItem(key: string, href: string, label: string, icon: string,
|
||||
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
async function mockWorkflowConfirmApis(page: Page) {
|
||||
async function mockWorkflowConfirmApis(page: Page, options: {
|
||||
taskDetail?: typeof pendingTaskDetail | Record<string, unknown>;
|
||||
reimbursementDetail?: Record<string, unknown>;
|
||||
reimbursementDetailFailure?: boolean;
|
||||
} = {}) {
|
||||
const actionCalls = {
|
||||
approve: 0,
|
||||
reject: 0,
|
||||
@@ -248,6 +252,19 @@ async function mockWorkflowConfirmApis(page: Page) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\/api\/reimbursements\/[^/]+$/.test(path)) {
|
||||
if (options.reimbursementDetailFailure) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ requestId: "mock-workflow-confirm", error: { code: "SYS_001", message: "合成加载失败", details: null } }),
|
||||
});
|
||||
} else {
|
||||
await fulfillJson(route, options.reimbursementDetail ?? {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMatch = path.match(/^\/api\/workflow\/tasks\/[^/]+\/(approve|reject|return)$/);
|
||||
if (actionMatch) {
|
||||
const action = actionMatch[1] as keyof typeof actionCalls;
|
||||
@@ -257,7 +274,7 @@ async function mockWorkflowConfirmApis(page: Page) {
|
||||
}
|
||||
|
||||
if (/^\/api\/workflow\/tasks\/[^/]+$/.test(path)) {
|
||||
await fulfillJson(route, pendingTaskDetail);
|
||||
await fulfillJson(route, options.taskDetail ?? pendingTaskDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -341,4 +358,101 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect.poll(() => actionCalls.return).toBe(1);
|
||||
});
|
||||
|
||||
test("PC 报销审批同屏展示证据、引用退回意见且不暴露维护动作", async ({ page }) => {
|
||||
const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const taskDetail = {
|
||||
...pendingTaskDetail,
|
||||
workflowInstance: {
|
||||
...pendingTaskDetail.workflowInstance,
|
||||
entityType: "reimbursement",
|
||||
contextData: {
|
||||
standardViolations: [{
|
||||
itemId: "item-1", expenseTypeId: "type-1", expenseTypeName: "交通费", amount: "180.00",
|
||||
standardId: "standard-1", standardName: "交通费标准", maxAmount: "120.00", perUnit: "item",
|
||||
controlMode: "warn", scopeType: "global", scopeKey: null, exceeded: true, exceededAmount: "60.00",
|
||||
}],
|
||||
budgetCheck: null,
|
||||
budgetCheckStatus: "missing",
|
||||
parseStatus: "ready",
|
||||
},
|
||||
},
|
||||
reimbursement: {
|
||||
id: reimbursementId,
|
||||
formNumber: "BX-APPROVAL-001",
|
||||
status: "reviewing",
|
||||
reimbursementDate: "2026-09-01",
|
||||
totalAmount: "180.00",
|
||||
description: "客户拜访交通费",
|
||||
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
|
||||
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
|
||||
},
|
||||
leaveRequest: null,
|
||||
};
|
||||
const reimbursementDetail = {
|
||||
id: reimbursementId,
|
||||
items: [{
|
||||
id: "item-1", expenseTypeId: "type-1", expenseDate: "2026-09-01", amount: "180.00",
|
||||
description: "客户拜访打车", assetPriorApprovalBasis: null,
|
||||
expenseType: { id: "type-1", code: "travel_taxi", name: "交通费", category: "travel", enabled: true, requiresInvoice: true },
|
||||
}],
|
||||
invoices: [
|
||||
{
|
||||
id: "invoice-1", status: "bound", invoiceCode: null, invoiceNumber: "FP-001", invoiceDate: "2026-09-01",
|
||||
sellerName: "合成供应商", buyerName: null, documentCategory: "invoice", documentSubtype: null,
|
||||
totalAmount: "120.00", originalName: "invoice.pdf", mimeType: "application/pdf", fileSize: 100,
|
||||
linkedItemId: "item-1", amountEvidenceStatus: "verified",
|
||||
},
|
||||
{
|
||||
id: "invoice-2", status: "bound", invoiceCode: null, invoiceNumber: "FP-002", invoiceDate: "2026-09-01",
|
||||
sellerName: "合成供应商", buyerName: null, documentCategory: "invoice", documentSubtype: null,
|
||||
totalAmount: null, originalName: "unlinked.pdf", mimeType: "application/pdf", fileSize: 100,
|
||||
linkedItemId: null, amountEvidenceStatus: "manual_review",
|
||||
},
|
||||
],
|
||||
attachments: [{ id: "attachment-1", originalName: "说明.pdf", mimeType: "application/pdf", fileSize: 100, createdAt: pendingTaskCreatedAt }],
|
||||
};
|
||||
await mockWorkflowConfirmApis(page, { taskDetail, reimbursementDetail });
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${pendingTask.id}`);
|
||||
|
||||
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("客户拜访打车");
|
||||
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("FP-002");
|
||||
await expect(page.getByTestId("reimbursement-approval-checks")).toContainText("该次提交未记录预算快照");
|
||||
await expect(page.getByText("当前申报金额高于已读取的票面金额合计")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "编辑明细" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "删除明细" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "选择明细" })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("approval-reference-item").click();
|
||||
await expect(page.getByTestId("workflow-task-return-comment")).toHaveValue(/【明细:交通费 · 2026-09-01 · ¥180\.00】/);
|
||||
});
|
||||
|
||||
test("PC 报销内容加载失败时四类审批入口 fail closed", async ({ page }) => {
|
||||
const taskDetail = {
|
||||
...pendingTaskDetail,
|
||||
workflowInstance: {
|
||||
...pendingTaskDetail.workflowInstance,
|
||||
entityType: "reimbursement",
|
||||
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" },
|
||||
},
|
||||
reimbursement: {
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", formNumber: "BX-FAIL-001", status: "reviewing",
|
||||
reimbursementDate: "2026-09-01", totalAmount: "100.00", description: "合成失败场景",
|
||||
owner: { id: "owner", employeeNo: "E001", name: "申请人" }, submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
|
||||
},
|
||||
leaveRequest: null,
|
||||
};
|
||||
await mockWorkflowConfirmApis(page, { taskDetail, reimbursementDetailFailure: true });
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${pendingTask.id}`);
|
||||
await expect(page.getByRole("alert")).toContainText("服务暂不可用");
|
||||
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
|
||||
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
|
||||
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
|
||||
await page.locator("summary", { hasText: "更多" }).click();
|
||||
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户