修正 ERP 边界与资产页面一致性(05-30-fix-erp-boundary-asset-consistency)
这个提交包含在:
@@ -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) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户