修正 ERP 边界与资产页面一致性(05-30-fix-erp-boundary-asset-consistency)

这个提交包含在:
2026-05-30 23:06:02 +08:00
父节点 16fd9bdf52
当前提交 f299ceeb74
共修改 47 个文件,包含 476 行新增和 1633 行删除
@@ -5,7 +5,6 @@ import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
import { fetchProjects } from "../api/projects";
import {
createReimbursementItem,
@@ -108,32 +107,6 @@ function workflowActionLabel(action: string) {
return labels[action] ?? action;
}
function resolveErpSyncStatus(
exportState:
| {
exportedAt: string | null;
lastOutboxStatus: string | null;
}
| null,
) {
if (!exportState) {
return "未导出";
}
if (exportState.lastOutboxStatus === "pending") {
return "待导出";
}
if (exportState.lastOutboxStatus === "processing") {
return "导出中";
}
if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") {
return "导出失败";
}
if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) {
return "已导出";
}
return "未导出";
}
function budgetControlModeLabel(controlMode: string | null | undefined) {
const labels: Record<string, string> = {
block: "超额阻断",
@@ -180,7 +153,6 @@ export function ReimbursementDetailPage() {
const [itemError, setItemError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
const [showSecondaryDetail, setShowSecondaryDetail] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
@@ -215,8 +187,6 @@ export function ReimbursementDetailPage() {
queryFn: () => fetchProjects({ mine: true }),
});
const showExternalSyncUi = import.meta.env.VITE_SHOW_ERP_UI === "true";
const canManageIntegration = hasPermission(permissions, "integration.manage") && showExternalSyncUi;
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
@@ -229,12 +199,6 @@ export function ReimbursementDetailPage() {
queryFn: () => precheckReimbursement(reimbursementId),
});
const erpPreviewQuery = useQuery({
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"],
queryFn: () => previewReimbursementJournal(reimbursementId),
});
const bindableInvoicesQuery = useQuery({
enabled:
Boolean(reimbursementId) &&
@@ -461,41 +425,6 @@ export function ReimbursementDetailPage() {
},
});
const erpExportMutation = useMutation({
mutationFn: async () => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
return exportReimbursementJournal(reimbursementId, true);
},
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setActionError("");
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
toast.success(`财务桥接出队${suffix}`);
},
onError: (error) => {
if (isApiError(error)) {
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(msg);
toast.error(msg);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("财务桥接出队失败,请稍后重试");
toast.error("财务桥接出队失败,请稍后重试");
},
});
const bindInvoicesMutation = useMutation({
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
if (!reimbursementId) {
@@ -809,8 +738,6 @@ export function ReimbursementDetailPage() {
paidBy: null,
};
const displayCode = resolveReimbursementDisplayCode(detail);
const isPaidReimbursement = detail.status === "paid";
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
const budgetHint = detail.budgetHint;
const canPay = hasPermission(permissions, "expense.finance.pay");
const canEditItems = ["draft", "rejected"].includes(detail.status);
@@ -1732,72 +1659,6 @@ export function ReimbursementDetailPage() {
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
{canManageIntegration ? (
<SectionPanel
title="财务导出状态"
description="付款后可生成财务导出记录。"
actions={
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
disabled={!isPaidReimbursement || erpPreviewQuery.isLoading || Boolean(erpPreviewQuery.isError) || !erpPreviewQuery.data}
onClick={() => setIsErpPreviewOpen(true)}
>
预览凭证
</Button>
<Button type="button" disabled={!isPaidReimbursement || erpExportMutation.isPending} onClick={() => erpExportMutation.mutate()}>
{erpExportMutation.isPending ? "导出中..." : "生成导出"}
</Button>
</div>
}
>
{!isPaidReimbursement ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
未满足导出条件,仅已付款报销可生成财务导出记录。
</p>
) : null}
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载财务凭证预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
erpPreviewQuery.error.code,
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载财务凭证预览失败"}
</p>
) : null}
<div className="detail-fact-grid text-sm xl:grid-cols-4" data-testid="reimbursement-erp-export">
<div className="detail-fact-card">
<div className="detail-fact-card__label">导出状态</div>
<div className="detail-fact-card__value">{erpSyncStatus}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">最近导出</div>
<div className="detail-fact-card__value">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">导出任务</div>
<div className="detail-fact-card__value break-all">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">外部凭证号</div>
<div className="detail-fact-card__value break-all">{erpPreviewQuery.data?.exportState.externalId ?? "-"}</div>
</div>
</div>
{erpPreviewQuery.data?.exportState.lastError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
最近错误:{erpPreviewQuery.data.exportState.lastError}
</div>
) : null}
</SectionPanel>
) : null}
</div>
</SectionPanel>
@@ -2058,41 +1919,6 @@ export function ReimbursementDetailPage() {
</DialogContent>
</Dialog>
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>财务凭证预览</DialogTitle>
<DialogDescription>查看凭证内容和最近导出状态。</DialogDescription>
</DialogHeader>
{erpPreviewQuery.data ? (
<div className="grid gap-4 text-sm lg:grid-cols-2">
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
</div>
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>导出状态:{erpSyncStatus}</div>
<div>外部凭证号:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>任务编号:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>任务状态:{erpSyncStatus}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">暂无可预览的财务凭证数据。</p>
)}
</DialogContent>
</Dialog>
<Dialog
open={Boolean(submitConfirmPrecheck)}
onOpenChange={(open) => {