R111/A7-A10 统一报销双端发票附件体验
这个提交包含在:
@@ -62,9 +62,12 @@ export type InvoicePoolListResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UploadInvoiceResponse = {
|
export type UploadInvoiceResponse = {
|
||||||
invoicePoolId: string;
|
invoicePoolId: string | null;
|
||||||
status: InvoicePoolStatus;
|
status: InvoicePoolStatus;
|
||||||
fileSha256: string;
|
fileSha256: string;
|
||||||
|
uploadAction?: "create" | "reuse_existing" | "blocked";
|
||||||
|
unavailableReason?: string | null;
|
||||||
|
unavailableMessage?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InvoicePoolDetail = {
|
export type InvoicePoolDetail = {
|
||||||
@@ -136,6 +139,31 @@ export type BindableInvoiceListResponse = {
|
|||||||
items: BindableInvoiceSummary[];
|
items: BindableInvoiceSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InvoiceStatusItem = {
|
||||||
|
id: string;
|
||||||
|
status: InvoicePoolStatus;
|
||||||
|
reason: string;
|
||||||
|
message: string;
|
||||||
|
invoiceCode: string | null;
|
||||||
|
invoiceNumber: string | null;
|
||||||
|
invoiceDate: string | null;
|
||||||
|
sellerName: string | null;
|
||||||
|
totalAmount: string | null;
|
||||||
|
originalName: string | null;
|
||||||
|
linkedReimbursementId: string | null;
|
||||||
|
linkedItemId: string | null;
|
||||||
|
linkedTravelOrderId: string | null;
|
||||||
|
ocrAttempts: number;
|
||||||
|
ocrLastError: string | null;
|
||||||
|
ocrCompletedAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvoiceStatusResponse = {
|
||||||
|
items: InvoiceStatusItem[];
|
||||||
|
missingIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
@@ -170,6 +198,12 @@ export async function fetchBindableInvoices(query: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchInvoiceStatuses(ids: string[]) {
|
||||||
|
return apiRequestData<InvoiceStatusResponse>(
|
||||||
|
`/api/invoice-pool/status${toQueryString({ ids: ids.join(",") })}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadInvoice(
|
export async function uploadInvoice(
|
||||||
file: File,
|
file: File,
|
||||||
ocrType?: string,
|
ocrType?: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiRequestData } from "./http";
|
import { apiRequestBlob, apiRequestData } from "./http";
|
||||||
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
|
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
|
||||||
|
|
||||||
export type ReimbursementStatus =
|
export type ReimbursementStatus =
|
||||||
@@ -165,6 +165,19 @@ export type ReimbursementDetail = {
|
|||||||
originalName: string | null;
|
originalName: string | null;
|
||||||
linkedItemId: string | null;
|
linkedItemId: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
attachments: Array<{
|
||||||
|
id: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileSha256: string;
|
||||||
|
uploadedBy: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
employeeNo: string;
|
||||||
|
} | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
}>;
|
||||||
budgetHint: ReimbursementBudgetHint | null;
|
budgetHint: ReimbursementBudgetHint | null;
|
||||||
pendingWorkflow: {
|
pendingWorkflow: {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
@@ -463,6 +476,38 @@ export async function deleteReimbursementItem(reimbursementId: string, itemId: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadReimbursementAttachments(reimbursementId: string, files: File[]) {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append("files", file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequestData<{ items: ReimbursementDetail["attachments"] }>(
|
||||||
|
`/api/reimbursements/${reimbursementId}/attachments`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReimbursementAttachment(reimbursementId: string, attachmentId: string) {
|
||||||
|
return apiRequestData<{ attachmentId: string; outcome: string }>(
|
||||||
|
`/api/reimbursements/${reimbursementId}/attachments/${attachmentId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reimbursementAttachmentFileUrl(reimbursementId: string, attachmentId: string) {
|
||||||
|
return `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadReimbursementAttachment(reimbursementId: string, attachmentId: string) {
|
||||||
|
return apiRequestBlob(reimbursementAttachmentFileUrl(reimbursementId, attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) {
|
export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) {
|
||||||
return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, {
|
return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, {
|
||||||
|
|||||||
@@ -237,7 +237,11 @@ export function InvoicePoolPage() {
|
|||||||
setUploadProgress({ completed: index, total: files.length, currentFileName: file.name });
|
setUploadProgress({ completed: index, total: files.length, currentFileName: file.name });
|
||||||
try {
|
try {
|
||||||
const data = await uploadInvoice(file, "vat", uploadOwnerEmployeeId || undefined);
|
const data = await uploadInvoice(file, "vat", uploadOwnerEmployeeId || undefined);
|
||||||
|
if (data.uploadAction === "blocked" || !data.invoicePoolId) {
|
||||||
|
result.failures.push({ file, message: data.unavailableMessage ?? "该发票不可添加" });
|
||||||
|
} else {
|
||||||
result.successes.push({ file, data });
|
result.successes.push({ file, data });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
result.failures.push({ file, message: toUserError(err, "上传失败,请稍后重试") });
|
result.failures.push({ file, message: toUserError(err, "上传失败,请稍后重试") });
|
||||||
}
|
}
|
||||||
@@ -249,7 +253,7 @@ export function InvoicePoolPage() {
|
|||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
const lastSuccess = result.successes.at(-1);
|
const lastSuccess = result.successes.at(-1);
|
||||||
if (lastSuccess) {
|
if (lastSuccess?.data.invoicePoolId) {
|
||||||
setDetailInvoiceId(lastSuccess.data.invoicePoolId);
|
setDetailInvoiceId(lastSuccess.data.invoicePoolId);
|
||||||
}
|
}
|
||||||
setSelectedFiles(result.failures.map((item) => item.file));
|
setSelectedFiles(result.failures.map((item) => item.file));
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ import { fetchExpenseTypes } from "../api/expenseTypes";
|
|||||||
import {
|
import {
|
||||||
bindInvoice,
|
bindInvoice,
|
||||||
fetchBindableInvoices,
|
fetchBindableInvoices,
|
||||||
|
fetchInvoiceStatuses,
|
||||||
unbindInvoice,
|
unbindInvoice,
|
||||||
uploadInvoice,
|
uploadInvoice,
|
||||||
type BindableInvoiceSummary,
|
type BindableInvoiceSummary,
|
||||||
} from "../api/invoicePool";
|
} from "../api/invoicePool";
|
||||||
import {
|
import {
|
||||||
createReimbursementItem,
|
createReimbursementItem,
|
||||||
|
deleteReimbursementAttachment,
|
||||||
deleteReimbursement,
|
deleteReimbursement,
|
||||||
deleteReimbursementItem,
|
deleteReimbursementItem,
|
||||||
|
downloadReimbursementAttachment,
|
||||||
fetchReimbursementDetail,
|
fetchReimbursementDetail,
|
||||||
precheckReimbursement,
|
precheckReimbursement,
|
||||||
submitReimbursement,
|
submitReimbursement,
|
||||||
updateReimbursementItem,
|
updateReimbursementItem,
|
||||||
|
uploadReimbursementAttachments,
|
||||||
type ReimbursementItem,
|
type ReimbursementItem,
|
||||||
type ReimbursementStatus,
|
type ReimbursementStatus,
|
||||||
} from "../api/reimbursements";
|
} from "../api/reimbursements";
|
||||||
@@ -77,7 +81,7 @@ const INVOICE_STATUS_LABEL: Record<string, string> = {
|
|||||||
pending_ocr: "识别中",
|
pending_ocr: "识别中",
|
||||||
ocr_failed: "识别失败",
|
ocr_failed: "识别失败",
|
||||||
available: "可用",
|
available: "可用",
|
||||||
bound: "已绑定",
|
bound: "已添加",
|
||||||
archived: "已归档",
|
archived: "已归档",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,6 +104,13 @@ function formatDate(value: string | null | undefined) {
|
|||||||
return value || "未填写";
|
return value || "未填写";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return value.slice(0, 19).replace("T", " ");
|
||||||
|
}
|
||||||
|
|
||||||
function formatInvoiceTitle(invoice: {
|
function formatInvoiceTitle(invoice: {
|
||||||
invoiceCode?: string | null;
|
invoiceCode?: string | null;
|
||||||
invoiceNumber?: string | null;
|
invoiceNumber?: string | null;
|
||||||
@@ -128,6 +139,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { me, permissions } = useAuth();
|
const { me, permissions } = useAuth();
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
|
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
|
||||||
const [itemError, setItemError] = useState("");
|
const [itemError, setItemError] = useState("");
|
||||||
@@ -141,6 +153,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
const [invoiceSearch, setInvoiceSearch] = useState("");
|
const [invoiceSearch, setInvoiceSearch] = useState("");
|
||||||
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
||||||
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
||||||
|
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||||
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
||||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||||
@@ -168,6 +181,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice);
|
const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice);
|
||||||
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||||||
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense));
|
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense));
|
||||||
|
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||||
|
|
||||||
const bindableInvoicesQuery = useQuery({
|
const bindableInvoicesQuery = useQuery({
|
||||||
@@ -182,6 +196,13 @@ export function MobileReimbursementDetailPage() {
|
|||||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pendingInvoiceStatusesQuery = useQuery({
|
||||||
|
enabled: pendingOcrInvoiceIds.length > 0,
|
||||||
|
queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds],
|
||||||
|
queryFn: () => fetchInvoiceStatuses(pendingOcrInvoiceIds),
|
||||||
|
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timerId = window.setTimeout(() => {
|
const timerId = window.setTimeout(() => {
|
||||||
setInvoiceSearch(invoiceSearchInput.trim());
|
setInvoiceSearch(invoiceSearchInput.trim());
|
||||||
@@ -205,23 +226,6 @@ export function MobileReimbursementDetailPage() {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveDefaultLinkedItemId = (invoice?: BindableInvoiceSummary | null) => {
|
|
||||||
if (!detail?.items.length) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (detail.items.length === 1) {
|
|
||||||
return detail.items[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoiceAmount = Number(invoice?.totalAmount ?? NaN);
|
|
||||||
if (!Number.isFinite(invoiceAmount)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount);
|
|
||||||
return matchedItems.length === 1 ? matchedItems[0].id : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const startCreateItem = () => {
|
const startCreateItem = () => {
|
||||||
setItemForm({
|
setItemForm({
|
||||||
mode: "create",
|
mode: "create",
|
||||||
@@ -348,14 +352,14 @@ export function MobileReimbursementDetailPage() {
|
|||||||
|
|
||||||
if (!failed.length) {
|
if (!failed.length) {
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
setInvoiceActionMessage(`已绑定 ${succeededIds.length} 张发票`);
|
setInvoiceActionMessage(`已添加 ${succeededIds.length} 张发票`);
|
||||||
toast.success(`已绑定 ${succeededIds.length} 张发票`);
|
toast.success(`已添加 ${succeededIds.length} 张发票`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "绑定失败"}`).join(";");
|
const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "添加失败"}`).join(";");
|
||||||
setInvoiceActionError(message);
|
setInvoiceActionError(message);
|
||||||
toast.error("部分发票绑定失败");
|
toast.error("部分发票添加失败");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = resolveErrorMessage(error);
|
const message = resolveErrorMessage(error);
|
||||||
@@ -369,7 +373,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await invalidateDetail();
|
await invalidateDetail();
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
toast.success("发票已解绑");
|
toast.success("发票已移除");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = resolveErrorMessage(error);
|
const message = resolveErrorMessage(error);
|
||||||
@@ -401,21 +405,24 @@ export function MobileReimbursementDetailPage() {
|
|||||||
);
|
);
|
||||||
uploadedCount += 1;
|
uploadedCount += 1;
|
||||||
|
|
||||||
if (uploadResult.status === "available") {
|
if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) {
|
||||||
const linkedItemId = resolveDefaultLinkedItemId(null);
|
failures.push({
|
||||||
try {
|
file,
|
||||||
await bindInvoice(uploadResult.invoicePoolId, {
|
message: uploadResult.unavailableMessage ?? "该发票不可添加",
|
||||||
reimbursementId,
|
|
||||||
...(linkedItemId ? { linkedItemId } : {}),
|
|
||||||
});
|
});
|
||||||
|
} else if (uploadResult.status === "available") {
|
||||||
|
try {
|
||||||
|
await bindInvoice(uploadResult.invoicePoolId, { reimbursementId });
|
||||||
boundCount += 1;
|
boundCount += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push({ file, message: `上传成功,绑定失败:${resolveErrorMessage(error)}` });
|
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
|
||||||
}
|
}
|
||||||
} else if (uploadResult.status === "pending_ocr") {
|
} else if (uploadResult.status === "pending_ocr") {
|
||||||
pendingIds.push(uploadResult.invoicePoolId);
|
pendingIds.push(uploadResult.invoicePoolId);
|
||||||
} else if (uploadResult.status === "ocr_failed") {
|
} else if (uploadResult.status === "ocr_failed") {
|
||||||
failures.push({ file, message: "OCR 识别失败,请到发票池补录后再绑定" });
|
failures.push({ file, message: uploadResult.unavailableMessage ?? "发票识别失败,请重新识别或手动处理" });
|
||||||
|
} else {
|
||||||
|
failures.push({ file, message: uploadResult.unavailableMessage ?? "该发票不可添加" });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push({ file, message: resolveErrorMessage(error) });
|
failures.push({ file, message: resolveErrorMessage(error) });
|
||||||
@@ -438,7 +445,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
|
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
if (result.boundCount) {
|
if (result.boundCount) {
|
||||||
messages.push(`${result.boundCount} 张已绑定`);
|
messages.push(`${result.boundCount} 张已添加`);
|
||||||
}
|
}
|
||||||
if (result.pendingIds.length) {
|
if (result.pendingIds.length) {
|
||||||
messages.push(`${result.pendingIds.length} 张识别中`);
|
messages.push(`${result.pendingIds.length} 张识别中`);
|
||||||
@@ -464,6 +471,80 @@ export function MobileReimbursementDetailPage() {
|
|||||||
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uploadAttachmentsMutation = useMutation({
|
||||||
|
mutationFn: async (files: File[]) => {
|
||||||
|
if (!files.length) {
|
||||||
|
throw new Error("请选择附件材料");
|
||||||
|
}
|
||||||
|
return uploadReimbursementAttachments(reimbursementId, files);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateDetail();
|
||||||
|
setSelectedAttachmentFiles([]);
|
||||||
|
if (attachmentInputRef.current) {
|
||||||
|
attachmentInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
toast.success("附件材料已上传");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(resolveErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteAttachmentMutation = useMutation({
|
||||||
|
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateDetail();
|
||||||
|
toast.success("附件材料已删除");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(resolveErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openAttachmentMutation = useMutation({
|
||||||
|
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
const url = URL.createObjectURL(result.blob);
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(resolveErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const statuses = pendingInvoiceStatusesQuery.data?.items ?? [];
|
||||||
|
if (!statuses.length || bindInvoicesMutation.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableIds = statuses
|
||||||
|
.filter((item) => item.status === "available")
|
||||||
|
.map((item) => item.id);
|
||||||
|
const finishedIds = new Set(
|
||||||
|
statuses
|
||||||
|
.filter((item) => item.status !== "pending_ocr")
|
||||||
|
.map((item) => item.id),
|
||||||
|
);
|
||||||
|
const failedMessages = statuses
|
||||||
|
.filter((item) => item.status === "ocr_failed" || item.status === "archived")
|
||||||
|
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.message}`);
|
||||||
|
|
||||||
|
if (failedMessages.length) {
|
||||||
|
setInvoiceActionError(failedMessages.join(";"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableIds.length) {
|
||||||
|
bindInvoicesMutation.mutate(availableIds.map((invoiceId) => ({ invoiceId })));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finishedIds.size) {
|
||||||
|
setPendingOcrInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId)));
|
||||||
|
}
|
||||||
|
}, [bindInvoicesMutation, pendingInvoiceStatusesQuery.data?.items]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
return;
|
return;
|
||||||
@@ -529,21 +610,14 @@ export function MobileReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
[invoice.id]: previous[invoice.id] ?? resolveDefaultLinkedItemId(invoice),
|
[invoice.id]: previous[invoice.id] ?? "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => {
|
|
||||||
setSelectedInvoices((previous) => ({
|
|
||||||
...previous,
|
|
||||||
[invoiceId]: linkedItemId,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
|
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
|
||||||
if (!linkedItemId) {
|
if (!linkedItemId) {
|
||||||
return "整单";
|
return "自动生成或匹配明细";
|
||||||
}
|
}
|
||||||
const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
|
const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
|
||||||
return item ? itemLabel(item) : "明细";
|
return item ? itemLabel(item) : "明细";
|
||||||
@@ -760,7 +834,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
<div className="mt-1 text-xs text-zinc-500">
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
|
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">绑定:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
|
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
|
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
|
||||||
@@ -778,7 +852,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
disabled={unbindInvoiceMutation.isPending}
|
disabled={unbindInvoiceMutation.isPending}
|
||||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||||
>
|
>
|
||||||
解绑
|
移除
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
@@ -791,6 +865,98 @@ export function MobileReimbursementDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-attachments">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-zinc-900">附件材料</h2>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">合同、订单、付款截图或情况说明</p>
|
||||||
|
</div>
|
||||||
|
{canManageAttachments ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => attachmentInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
选择
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManageAttachments ? (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<input
|
||||||
|
ref={attachmentInputRef}
|
||||||
|
className="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
|
||||||
|
/>
|
||||||
|
{selectedAttachmentFiles.length ? (
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||||
|
{selectedAttachmentFiles.slice(0, 4).map((file) => (
|
||||||
|
<div key={`${file.name}-${file.size}`} className="truncate">
|
||||||
|
{file.name} · {Math.ceil(file.size / 1024)} KB
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{selectedAttachmentFiles.length > 4 ? <div>另有 {selectedAttachmentFiles.length - 4} 个文件</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
|
||||||
|
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
|
||||||
|
>
|
||||||
|
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(detail.attachments ?? []).length ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{(detail.attachments ?? []).map((attachment) => (
|
||||||
|
<article key={attachment.id} className="rounded-lg border border-zinc-200 px-3 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={openAttachmentMutation.isPending}
|
||||||
|
onClick={() => openAttachmentMutation.mutate(attachment.id)}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
{canManageAttachments ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={deleteAttachmentMutation.isPending}
|
||||||
|
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||||||
|
暂无附件材料
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-actions">
|
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-actions">
|
||||||
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
||||||
{actionError ? (
|
{actionError ? (
|
||||||
@@ -833,7 +999,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
>
|
>
|
||||||
<DialogHeader className="text-left">
|
<DialogHeader className="text-left">
|
||||||
<DialogTitle>添加发票</DialogTitle>
|
<DialogTitle>添加发票</DialogTitle>
|
||||||
<DialogDescription>可一次上传多张,识别完成后再绑定。</DialogDescription>
|
<DialogDescription>可一次上传多张,识别完成后会自动添加。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1">
|
<div className="mt-4 grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1">
|
||||||
@@ -900,7 +1066,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
|
|
||||||
{pendingOcrInvoiceIds.length ? (
|
{pendingOcrInvoiceIds.length ? (
|
||||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||||
{pendingOcrInvoiceIds.length} 张发票正在识别。识别完成后可在“已有发票”中绑定。
|
{pendingOcrInvoiceIds.length} 张发票正在识别,完成后会自动刷新并添加。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -940,7 +1106,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!bindableInvoicesQuery.isLoading && !bindableInvoicesQuery.isError && !(bindableInvoicesQuery.data?.items ?? []).length ? (
|
{!bindableInvoicesQuery.isLoading && !bindableInvoicesQuery.isError && !(bindableInvoicesQuery.data?.items ?? []).length ? (
|
||||||
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||||||
暂无可绑定发票
|
暂无可添加发票
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{(bindableInvoicesQuery.data?.items ?? []).map((invoice) => {
|
{(bindableInvoicesQuery.data?.items ?? []).map((invoice) => {
|
||||||
@@ -965,18 +1131,9 @@ export function MobileReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{checked ? (
|
{checked ? (
|
||||||
<select
|
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
|
||||||
className="select-std mt-3"
|
添加后自动生成或匹配报销明细
|
||||||
value={selectedInvoices[invoice.id] ?? ""}
|
</div>
|
||||||
onChange={(event) => updateSelectedInvoiceItem(invoice.id, event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">绑定整单</option>
|
|
||||||
{detail.items.map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{itemLabel(item)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : null}
|
) : null}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -989,14 +1146,14 @@ export function MobileReimbursementDetailPage() {
|
|||||||
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
bindInvoicesMutation.mutate(
|
bindInvoicesMutation.mutate(
|
||||||
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
selectedInvoiceEntries.map(([invoiceId]) => ({
|
||||||
invoiceId,
|
invoiceId,
|
||||||
linkedItemId: linkedItemId || undefined,
|
linkedItemId: undefined,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{bindInvoicesMutation.isPending ? "绑定中..." : `绑定选中发票 ${selectedInvoiceEntries.length}`}
|
{bindInvoicesMutation.isPending ? "添加中..." : `添加选中发票 ${selectedInvoiceEntries.length}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1050,7 +1207,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
open={confirmDeleteReimbursement}
|
open={confirmDeleteReimbursement}
|
||||||
onOpenChange={setConfirmDeleteReimbursement}
|
onOpenChange={setConfirmDeleteReimbursement}
|
||||||
title="删除草稿?"
|
title="删除草稿?"
|
||||||
description="删除后无法恢复,已绑定发票会回到可用状态。"
|
description="删除后无法恢复,已添加发票会回到可用状态。"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmLabel="删除草稿"
|
confirmLabel="删除草稿"
|
||||||
onConfirm={() => deleteReimbursementMutation.mutate()}
|
onConfirm={() => deleteReimbursementMutation.mutate()}
|
||||||
|
|||||||
@@ -9,22 +9,24 @@ import { exportReimbursementJournal, previewReimbursementJournal } from "../api/
|
|||||||
import { fetchProjects } from "../api/projects";
|
import { fetchProjects } from "../api/projects";
|
||||||
import {
|
import {
|
||||||
createReimbursementItem,
|
createReimbursementItem,
|
||||||
|
deleteReimbursementAttachment,
|
||||||
deleteReimbursement,
|
deleteReimbursement,
|
||||||
deleteReimbursementItem,
|
deleteReimbursementItem,
|
||||||
|
downloadReimbursementAttachment,
|
||||||
fetchReimbursementDetail,
|
fetchReimbursementDetail,
|
||||||
payReimbursement,
|
payReimbursement,
|
||||||
precheckReimbursement,
|
precheckReimbursement,
|
||||||
submitReimbursement,
|
submitReimbursement,
|
||||||
updateReimbursementItem,
|
updateReimbursementItem,
|
||||||
|
uploadReimbursementAttachments,
|
||||||
withdrawReimbursement,
|
withdrawReimbursement,
|
||||||
linkReimbursementProject,
|
linkReimbursementProject,
|
||||||
type ReimbursementItem,
|
type ReimbursementItem,
|
||||||
} from "../api/reimbursements";
|
} from "../api/reimbursements";
|
||||||
import {
|
import {
|
||||||
bindInvoice,
|
bindInvoice,
|
||||||
type BindableInvoiceSummary,
|
|
||||||
fetchBindableInvoices,
|
fetchBindableInvoices,
|
||||||
fetchInvoicePoolDetail,
|
fetchInvoiceStatuses,
|
||||||
unbindInvoice,
|
unbindInvoice,
|
||||||
uploadInvoice,
|
uploadInvoice,
|
||||||
} from "../api/invoicePool";
|
} from "../api/invoicePool";
|
||||||
@@ -130,6 +132,7 @@ export function ReimbursementDetailPage() {
|
|||||||
const [invoiceSearch, setInvoiceSearch] = useState("");
|
const [invoiceSearch, setInvoiceSearch] = useState("");
|
||||||
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
||||||
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
||||||
|
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||||
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
||||||
@@ -156,7 +159,8 @@ export function ReimbursementDetailPage() {
|
|||||||
queryFn: () => fetchProjects({ mine: true }),
|
queryFn: () => fetchProjects({ mine: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const canManageIntegration = hasPermission(permissions, "integration.manage");
|
const showExternalSyncUi = import.meta.env.VITE_SHOW_ERP_UI === "true";
|
||||||
|
const canManageIntegration = hasPermission(permissions, "integration.manage") && showExternalSyncUi;
|
||||||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||||
@@ -443,7 +447,7 @@ export function ReimbursementDetailPage() {
|
|||||||
results.push({
|
results.push({
|
||||||
invoiceId: selection.invoiceId,
|
invoiceId: selection.invoiceId,
|
||||||
success: false,
|
success: false,
|
||||||
message: buildActionErrorMessage(error, "绑定发票失败"),
|
message: buildActionErrorMessage(error, "添加发票失败"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,22 +476,22 @@ export function ReimbursementDetailPage() {
|
|||||||
setSelectedInvoices({});
|
setSelectedInvoices({});
|
||||||
setSelectedInvoiceFiles([]);
|
setSelectedInvoiceFiles([]);
|
||||||
setPendingOcrInvoiceIds([]);
|
setPendingOcrInvoiceIds([]);
|
||||||
toast.success(`已绑定 ${succeededIds.length} 张发票`);
|
toast.success(`已添加 ${succeededIds.length} 张发票`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = failedResults
|
const summary = failedResults
|
||||||
.map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "绑定失败"}`)
|
.map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "添加失败"}`)
|
||||||
.join(";");
|
.join(";");
|
||||||
setInvoiceActionError(summary);
|
setInvoiceActionError(summary);
|
||||||
if (succeededIds.length) {
|
if (succeededIds.length) {
|
||||||
toast.success(`已绑定 ${succeededIds.length} 张,${failedResults.length} 张失败`);
|
toast.success(`已添加 ${succeededIds.length} 张,${failedResults.length} 张失败`);
|
||||||
} else {
|
} else {
|
||||||
toast.error("发票绑定失败");
|
toast.error("发票添加失败");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = buildActionErrorMessage(error, "发票绑定失败");
|
const message = buildActionErrorMessage(error, "发票添加失败");
|
||||||
setInvoiceActionError(message);
|
setInvoiceActionError(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
},
|
},
|
||||||
@@ -502,10 +506,10 @@ export function ReimbursementDetailPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
|
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
|
||||||
]);
|
]);
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
toast.success("已解绑发票");
|
toast.success("发票已移除");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = buildActionErrorMessage(error, "解绑发票失败");
|
const message = buildActionErrorMessage(error, "移除发票失败");
|
||||||
setInvoiceActionError(message);
|
setInvoiceActionError(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
},
|
},
|
||||||
@@ -526,15 +530,27 @@ export function ReimbursementDetailPage() {
|
|||||||
const pendingById = new Map<string, string>();
|
const pendingById = new Map<string, string>();
|
||||||
const availableIds: string[] = [];
|
const availableIds: string[] = [];
|
||||||
const ocrFailures: string[] = [];
|
const ocrFailures: string[] = [];
|
||||||
|
const bindFailures: string[] = [];
|
||||||
|
let boundCount = 0;
|
||||||
setInvoiceUploadProgress({ completed: 0, total: files.length });
|
setInvoiceUploadProgress({ completed: 0, total: files.length });
|
||||||
|
|
||||||
for (const [index, file] of files.entries()) {
|
for (const [index, file] of files.entries()) {
|
||||||
try {
|
try {
|
||||||
const result = await uploadInvoice(file, "vat", ownerEmployeeId);
|
const result = await uploadInvoice(file, "vat", ownerEmployeeId);
|
||||||
if (result.status === "available") {
|
if (result.uploadAction === "blocked" || !result.invoicePoolId) {
|
||||||
|
uploadFailures.push({
|
||||||
|
file,
|
||||||
|
message: result.unavailableMessage ?? "该发票不可添加",
|
||||||
|
});
|
||||||
|
} else if (result.status === "available") {
|
||||||
availableIds.push(result.invoicePoolId);
|
availableIds.push(result.invoicePoolId);
|
||||||
} else if (result.status === "pending_ocr") {
|
} else if (result.status === "pending_ocr") {
|
||||||
pendingById.set(result.invoicePoolId, file.name);
|
pendingById.set(result.invoicePoolId, file.name);
|
||||||
|
} else {
|
||||||
|
uploadFailures.push({
|
||||||
|
file,
|
||||||
|
message: result.unavailableMessage ?? "该发票不可添加",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") });
|
uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") });
|
||||||
@@ -547,17 +563,9 @@ export function ReimbursementDetailPage() {
|
|||||||
const deadline = Date.now() + 60_000;
|
const deadline = Date.now() + 60_000;
|
||||||
while (pendingById.size && Date.now() < deadline) {
|
while (pendingById.size && Date.now() < deadline) {
|
||||||
await sleep(2_000);
|
await sleep(2_000);
|
||||||
const invoiceResults = await Promise.all(
|
const invoiceResults = await fetchInvoiceStatuses(Array.from(pendingById.keys()));
|
||||||
Array.from(pendingById.keys()).map(async (invoiceId) => {
|
|
||||||
try {
|
|
||||||
return await fetchInvoicePoolDetail(invoiceId);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const invoice of invoiceResults) {
|
for (const invoice of invoiceResults.items) {
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -567,7 +575,11 @@ export function ReimbursementDetailPage() {
|
|||||||
pendingById.delete(invoice.id);
|
pendingById.delete(invoice.id);
|
||||||
} else if (invoice.status === "ocr_failed") {
|
} else if (invoice.status === "ocr_failed") {
|
||||||
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
|
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
|
||||||
ocrFailures.push(`${fileName}: ${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
|
ocrFailures.push(`${fileName}: ${invoice.message || formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
|
||||||
|
pendingById.delete(invoice.id);
|
||||||
|
} else if (invoice.status === "archived" || invoice.status === "bound") {
|
||||||
|
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
|
||||||
|
ocrFailures.push(`${fileName}: ${invoice.message}`);
|
||||||
pendingById.delete(invoice.id);
|
pendingById.delete(invoice.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,10 +587,20 @@ export function ReimbursementDetailPage() {
|
|||||||
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
|
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const invoiceId of availableIds) {
|
||||||
|
try {
|
||||||
|
await bindInvoice(invoiceId, { reimbursementId });
|
||||||
|
boundCount += 1;
|
||||||
|
} catch (error) {
|
||||||
|
bindFailures.push(`${invoiceId.slice(0, 8)}: ${buildActionErrorMessage(error, "添加发票失败")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uploadedCount: files.length - uploadFailures.length,
|
uploadedCount: files.length - uploadFailures.length,
|
||||||
uploadFailures,
|
uploadFailures,
|
||||||
availableIds,
|
boundCount,
|
||||||
|
bindFailures,
|
||||||
pendingIds: Array.from(pendingById.keys()),
|
pendingIds: Array.from(pendingById.keys()),
|
||||||
ocrFailures,
|
ocrFailures,
|
||||||
};
|
};
|
||||||
@@ -590,28 +612,17 @@ export function ReimbursementDetailPage() {
|
|||||||
setPendingOcrInvoiceIds(result.pendingIds);
|
setPendingOcrInvoiceIds(result.pendingIds);
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
const refreshed = await bindableInvoicesQuery.refetch();
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id));
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
|
||||||
const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId));
|
await bindableInvoicesQuery.refetch();
|
||||||
if (selectableIds.length) {
|
|
||||||
const invoiceById = new Map((refreshed.data?.items ?? []).map((item) => [item.id, item]));
|
|
||||||
setSelectedInvoices((previous) => ({
|
|
||||||
...previous,
|
|
||||||
...Object.fromEntries(
|
|
||||||
selectableIds.map((invoiceId) => [
|
|
||||||
invoiceId,
|
|
||||||
previous[invoiceId] ?? resolveDefaultLinkedItemId(invoiceById.get(invoiceId)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
|
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
|
||||||
...result.ocrFailures,
|
...result.ocrFailures,
|
||||||
|
...result.bindFailures,
|
||||||
];
|
];
|
||||||
if (result.pendingIds.length) {
|
if (result.pendingIds.length) {
|
||||||
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 识别中,可稍后刷新`);
|
messages.push(`${result.pendingIds.length} 张发票仍在识别中,可稍后刷新`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messages.length) {
|
if (messages.length) {
|
||||||
@@ -622,7 +633,7 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
toast.success(`发票上传完成:${selectableIds.length || result.uploadedCount} 张可绑定`);
|
toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`);
|
||||||
},
|
},
|
||||||
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -632,6 +643,46 @@ export function ReimbursementDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uploadAttachmentsMutation = useMutation({
|
||||||
|
mutationFn: async (files: File[]) => {
|
||||||
|
if (!files.length) {
|
||||||
|
throw new Error("请选择附件材料");
|
||||||
|
}
|
||||||
|
return uploadReimbursementAttachments(reimbursementId, files);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
setSelectedAttachmentFiles([]);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
|
||||||
|
toast.success("附件材料已上传");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(buildActionErrorMessage(error, "上传附件失败"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteAttachmentMutation = useMutation({
|
||||||
|
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
|
||||||
|
toast.success("附件材料已删除");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(buildActionErrorMessage(error, "删除附件失败"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openAttachmentMutation = useMutation({
|
||||||
|
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
const url = URL.createObjectURL(result.blob);
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(buildActionErrorMessage(error, "打开附件失败"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!reimbursementId) {
|
if (!reimbursementId) {
|
||||||
return (
|
return (
|
||||||
<div className="card-std p-6">
|
<div className="card-std p-6">
|
||||||
@@ -684,7 +735,7 @@ export function ReimbursementDetailPage() {
|
|||||||
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
||||||
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
||||||
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
||||||
const missingInvoiceSubmitMessage = "提交前需先绑定至少 1 张发票,可在“关联票据”区域添加。";
|
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
|
||||||
const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
|
const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
|
||||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||||
|
|
||||||
@@ -786,24 +837,6 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveDefaultLinkedItemId = (invoice: BindableInvoiceSummary | null | undefined) => {
|
|
||||||
if (!detail.items.length) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail.items.length === 1) {
|
|
||||||
return detail.items[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoiceAmount = Number(invoice?.totalAmount ?? NaN);
|
|
||||||
if (!Number.isFinite(invoiceAmount)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount);
|
|
||||||
return matchedItems.length === 1 ? matchedItems[0].id : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
|
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
|
||||||
setSelectedInvoices((previous) => {
|
setSelectedInvoices((previous) => {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
@@ -812,37 +845,29 @@ export function ReimbursementDetailPage() {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoice = (bindableInvoicesQuery.data?.items ?? []).find((item) => item.id === invoiceId);
|
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
[invoiceId]: previous[invoiceId] ?? resolveDefaultLinkedItemId(invoice),
|
[invoiceId]: previous[invoiceId] ?? "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => {
|
|
||||||
setSelectedInvoices((previous) => ({
|
|
||||||
...previous,
|
|
||||||
[invoiceId]: linkedItemId,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBindSelectedInvoices = () => {
|
const handleBindSelectedInvoices = () => {
|
||||||
if (!selectedInvoiceEntries.length) {
|
if (!selectedInvoiceEntries.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bindInvoicesMutation.mutate(
|
bindInvoicesMutation.mutate(
|
||||||
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
selectedInvoiceEntries.map(([invoiceId]) => ({
|
||||||
invoiceId,
|
invoiceId,
|
||||||
linkedItemId: linkedItemId || undefined,
|
linkedItemId: undefined,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
|
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
|
||||||
if (!linkedItemId) {
|
if (!linkedItemId) {
|
||||||
return "整张报销单";
|
return "自动生成或匹配明细";
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkedItem = detail.items.find((item) => item.id === linkedItemId);
|
const linkedItem = detail.items.find((item) => item.id === linkedItemId);
|
||||||
@@ -908,7 +933,7 @@ export function ReimbursementDetailPage() {
|
|||||||
<div className="mt-2 font-medium text-zinc-900">{detail.items.length}</div>
|
<div className="mt-2 font-medium text-zinc-900">{detail.items.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||||
<div className="text-zinc-500">关联票据</div>
|
<div className="text-zinc-500">发票</div>
|
||||||
<div className="mt-2 font-medium text-zinc-900">{detail.invoices.length}</div>
|
<div className="mt-2 font-medium text-zinc-900">{detail.invoices.length}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -987,6 +1012,84 @@ export function ReimbursementDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">附件材料</h3>
|
||||||
|
<p className="text-xs text-zinc-500">支付截图、合同、订单或情况说明,不参与发票识别。</p>
|
||||||
|
</div>
|
||||||
|
{canEditItems ? (
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||||
|
<label
|
||||||
|
htmlFor="reimbursement-attachment-upload"
|
||||||
|
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
{selectedAttachmentFiles.length
|
||||||
|
? selectedAttachmentFiles.length === 1
|
||||||
|
? selectedAttachmentFiles[0].name
|
||||||
|
: `已选择 ${selectedAttachmentFiles.length} 个文件`
|
||||||
|
: "选择附件"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reimbursement-attachment-upload"
|
||||||
|
className="hidden"
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
|
||||||
|
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
|
||||||
|
>
|
||||||
|
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(detail.attachments ?? []).length ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(detail.attachments ?? []).map((attachment) => (
|
||||||
|
<div key={attachment.id} className="flex flex-col gap-3 rounded-lg border border-zinc-200 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={openAttachmentMutation.isPending}
|
||||||
|
onClick={() => openAttachmentMutation.mutate(attachment.id)}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
{canEditItems ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={deleteAttachmentMutation.isPending}
|
||||||
|
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">暂无附件材料</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="明细与票据">
|
<SectionPanel title="明细与票据">
|
||||||
@@ -1143,7 +1246,7 @@ export function ReimbursementDetailPage() {
|
|||||||
<div className="space-y-4 text-sm" data-testid={TEST_IDS.reimbursement.invoices}>
|
<div className="space-y-4 text-sm" data-testid={TEST_IDS.reimbursement.invoices}>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
|
<h3 className="text-base font-semibold text-zinc-900">发票</h3>
|
||||||
</div>
|
</div>
|
||||||
{canOpenInvoiceBindDialog ? (
|
{canOpenInvoiceBindDialog ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -1155,7 +1258,7 @@ export function ReimbursementDetailPage() {
|
|||||||
添加发票
|
添加发票
|
||||||
</Button>
|
</Button>
|
||||||
) : canEditItems ? (
|
) : canEditItems ? (
|
||||||
<p className="text-xs text-zinc-500">当前账号不可在此页绑定发票</p>
|
<p className="text-xs text-zinc-500">当前账号不可在此页添加发票</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1187,7 +1290,7 @@ export function ReimbursementDetailPage() {
|
|||||||
原始文件:{item.originalName || item.id}
|
原始文件:{item.originalName || item.id}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-zinc-500">
|
||||||
绑定范围:{resolveLinkedItemLabel(item.linkedItemId)}
|
明细:{resolveLinkedItemLabel(item.linkedItemId)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1202,7 +1305,7 @@ export function ReimbursementDetailPage() {
|
|||||||
disabled={unbindInvoiceMutation.isPending}
|
disabled={unbindInvoiceMutation.isPending}
|
||||||
onClick={() => unbindInvoiceMutation.mutate(item.id)}
|
onClick={() => unbindInvoiceMutation.mutate(item.id)}
|
||||||
>
|
>
|
||||||
解绑
|
移除
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1211,13 +1314,13 @@ export function ReimbursementDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-zinc-500">暂无关联票据</p>
|
<p className="text-sm text-zinc-500">暂无发票</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="风险与同步">
|
<SectionPanel title="预算与审核提示">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{budgetHint ? (
|
{budgetHint ? (
|
||||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-budget-hint">
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-budget-hint">
|
||||||
@@ -1406,10 +1509,10 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
<div className="max-h-[360px] space-y-3 overflow-y-auto pr-1">
|
<div className="max-h-[360px] space-y-3 overflow-y-auto pr-1">
|
||||||
{bindableInvoicesQuery.isLoading ? (
|
{bindableInvoicesQuery.isLoading ? (
|
||||||
<p className="text-sm text-zinc-500">正在加载可绑定发票...</p>
|
<p className="text-sm text-zinc-500">正在加载可添加发票...</p>
|
||||||
) : bindableInvoicesQuery.isError ? (
|
) : bindableInvoicesQuery.isError ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可绑定发票失败")}
|
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可添加发票失败")}
|
||||||
</p>
|
</p>
|
||||||
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
|
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
|
||||||
bindableInvoicesQuery.data?.items.map((item) => {
|
bindableInvoicesQuery.data?.items.map((item) => {
|
||||||
@@ -1440,22 +1543,8 @@ export function ReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-[220px] md:max-w-[240px]">
|
<div className="min-w-[220px] rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700 md:max-w-[240px]">
|
||||||
<div className="text-xs text-zinc-500">绑定到明细(可选)</div>
|
添加后自动生成或匹配报销明细
|
||||||
<select
|
|
||||||
className="select-std mt-1"
|
|
||||||
data-testid={`reimbursement-invoice-linked-item-${item.id}`}
|
|
||||||
value={selectedInvoices[item.id] ?? ""}
|
|
||||||
disabled={!isSelected}
|
|
||||||
onChange={(event) => updateSelectedInvoiceItem(item.id, event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">不绑定具体明细</option>
|
|
||||||
{detail.items.map((detailItem) => (
|
|
||||||
<option key={detailItem.id} value={detailItem.id}>
|
|
||||||
{detailItem.expenseType?.name ?? detailItem.expenseTypeId} · {detailItem.description ?? "未填写说明"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -1463,7 +1552,7 @@ export function ReimbursementDetailPage() {
|
|||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-500">
|
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-500">
|
||||||
当前没有可绑定发票,可直接在下方上传新发票。
|
当前没有可添加发票,可直接在下方上传新发票。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1475,7 +1564,7 @@ export function ReimbursementDetailPage() {
|
|||||||
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
||||||
onClick={handleBindSelectedInvoices}
|
onClick={handleBindSelectedInvoices}
|
||||||
>
|
>
|
||||||
{bindInvoicesMutation.isPending ? "绑定中..." : "绑定选中发票"}
|
{bindInvoicesMutation.isPending ? "添加中..." : "添加选中发票"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1530,7 +1619,7 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
{pendingOcrInvoiceIds.length ? (
|
{pendingOcrInvoiceIds.length ? (
|
||||||
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||||
{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别,可稍后刷新。
|
{pendingOcrInvoiceIds.length} 张发票正在识别,可稍后刷新。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1640,7 +1729,7 @@ export function ReimbursementDetailPage() {
|
|||||||
open={confirmDeleteReimbursement}
|
open={confirmDeleteReimbursement}
|
||||||
onOpenChange={setConfirmDeleteReimbursement}
|
onOpenChange={setConfirmDeleteReimbursement}
|
||||||
title={`确认删除 ${displayCode}?`}
|
title={`确认删除 ${displayCode}?`}
|
||||||
description="删除后无法恢复,已绑定发票会回到可用状态。"
|
description="删除后无法恢复,已添加发票会回到可用状态。"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmLabel="删除草稿"
|
confirmLabel="删除草稿"
|
||||||
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
|
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- R111: 报销附件材料。用于支付截图、合同、订单、说明等非发票材料,不参与发票 OCR/查重。
|
||||||
|
CREATE TABLE "reimbursement_attachments" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reimbursement_id" UUID NOT NULL,
|
||||||
|
"storage_path" TEXT NOT NULL,
|
||||||
|
"original_name" TEXT NOT NULL,
|
||||||
|
"mime_type" TEXT NOT NULL,
|
||||||
|
"file_size" INTEGER NOT NULL,
|
||||||
|
"file_sha256" TEXT NOT NULL,
|
||||||
|
"uploaded_by" UUID NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "reimbursement_attachments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_reimbursement_attachments_form_created"
|
||||||
|
ON "reimbursement_attachments"("reimbursement_id", "created_at");
|
||||||
|
|
||||||
|
CREATE INDEX "idx_reimbursement_attachments_uploader"
|
||||||
|
ON "reimbursement_attachments"("uploaded_by");
|
||||||
|
|
||||||
|
CREATE INDEX "idx_reimbursement_attachments_file_sha256"
|
||||||
|
ON "reimbursement_attachments"("file_sha256");
|
||||||
|
|
||||||
|
ALTER TABLE "reimbursement_attachments"
|
||||||
|
ADD CONSTRAINT "reimbursement_attachments_reimbursement_id_fkey"
|
||||||
|
FOREIGN KEY ("reimbursement_id") REFERENCES "reimbursement_forms"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "reimbursement_attachments"
|
||||||
|
ADD CONSTRAINT "reimbursement_attachments_uploaded_by_fkey"
|
||||||
|
FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -600,6 +600,7 @@ model Employee {
|
|||||||
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
|
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
|
||||||
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
|
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
|
||||||
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
|
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
|
||||||
|
reimbursement_attachments_uploaded ReimbursementAttachment[] @relation("ReimbursementAttachmentUploader")
|
||||||
leave_requests LeaveRequest[]
|
leave_requests LeaveRequest[]
|
||||||
leave_ledgers LeaveLedger[]
|
leave_ledgers LeaveLedger[]
|
||||||
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
||||||
@@ -1303,6 +1304,7 @@ model ReimbursementForm {
|
|||||||
travel_request TravelRequest? @relation(fields: [linked_travel_request_id], references: [id], onDelete: SetNull)
|
travel_request TravelRequest? @relation(fields: [linked_travel_request_id], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
items ReimbursementItem[]
|
items ReimbursementItem[]
|
||||||
|
attachments ReimbursementAttachment[]
|
||||||
invoice_pool_items InvoicePool[]
|
invoice_pool_items InvoicePool[]
|
||||||
workflow_instances WorkflowInstance[]
|
workflow_instances WorkflowInstance[]
|
||||||
approval_transfers ApprovalTransfer[]
|
approval_transfers ApprovalTransfer[]
|
||||||
@@ -1345,6 +1347,26 @@ model ReimbursementItem {
|
|||||||
@@map("reimbursement_items")
|
@@map("reimbursement_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ReimbursementAttachment {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reimbursement_id String @db.Uuid
|
||||||
|
storage_path String
|
||||||
|
original_name String
|
||||||
|
mime_type String
|
||||||
|
file_size Int
|
||||||
|
file_sha256 String
|
||||||
|
uploaded_by String @db.Uuid
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
|
||||||
|
uploader Employee @relation("ReimbursementAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([reimbursement_id, created_at], map: "idx_reimbursement_attachments_form_created")
|
||||||
|
@@index([uploaded_by], map: "idx_reimbursement_attachments_uploader")
|
||||||
|
@@index([file_sha256], map: "idx_reimbursement_attachments_file_sha256")
|
||||||
|
@@map("reimbursement_attachments")
|
||||||
|
}
|
||||||
|
|
||||||
model LeaveType {
|
model LeaveType {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
code String @unique
|
code String @unique
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
const { Prisma } = require("@prisma/client");
|
const { Prisma } = require("@prisma/client");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const path = require("node:path");
|
||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { buildAuditLogData } = require("../utils/audit");
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
const { sha256Hex } = require("../utils/crypto");
|
||||||
|
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||||
const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation");
|
const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation");
|
||||||
const { hasPermission, resolveEntityPermission } = require("../constants/permissions");
|
const { hasPermission, resolveEntityPermission } = require("../constants/permissions");
|
||||||
const {
|
const {
|
||||||
@@ -27,6 +30,7 @@ const {
|
|||||||
resolveEntityPrecheck,
|
resolveEntityPrecheck,
|
||||||
} = require("../services/feeControl");
|
} = require("../services/feeControl");
|
||||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||||
|
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||||
const {
|
const {
|
||||||
buildWorkflowStepsFromApprovalRule,
|
buildWorkflowStepsFromApprovalRule,
|
||||||
getWorkflowStepsSnapshot,
|
getWorkflowStepsSnapshot,
|
||||||
@@ -137,6 +141,12 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = {
|
|||||||
other: "transport",
|
other: "transport",
|
||||||
};
|
};
|
||||||
const PRINTABLE_WORKFLOW_ACTIONS = new Set(["start", "approve", "reject", "return", "transfer", "withdraw"]);
|
const PRINTABLE_WORKFLOW_ACTIONS = new Set(["start", "approve", "reject", "return", "transfer", "withdraw"]);
|
||||||
|
const ATTACHMENT_MIME_ALLOWLIST = new Set([
|
||||||
|
"application/pdf",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
]);
|
||||||
|
|
||||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||||
@@ -257,6 +267,29 @@ function ensureApplicantOrAdmin(request, reimbursement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeAttachmentFilename(fileName) {
|
||||||
|
if (!fileName) return "file";
|
||||||
|
return fileName.replace(/[\r\n"]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapReimbursementAttachment(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
originalName: item.original_name,
|
||||||
|
mimeType: item.mime_type,
|
||||||
|
fileSize: item.file_size,
|
||||||
|
fileSha256: item.file_sha256,
|
||||||
|
uploadedBy: item.uploader
|
||||||
|
? {
|
||||||
|
id: item.uploader.id,
|
||||||
|
name: item.uploader.name,
|
||||||
|
employeeNo: item.uploader.employee_no,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseWithdrawBody(body) {
|
function parseWithdrawBody(body) {
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
comment: z.string().trim().min(1).max(500).optional(),
|
comment: z.string().trim().min(1).max(500).optional(),
|
||||||
@@ -1969,6 +2002,24 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
attachments: {
|
||||||
|
orderBy: { created_at: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
original_name: true,
|
||||||
|
mime_type: true,
|
||||||
|
file_size: true,
|
||||||
|
file_sha256: true,
|
||||||
|
created_at: true,
|
||||||
|
uploader: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
employee_no: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
invoice_pool_items: {
|
invoice_pool_items: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -2129,6 +2180,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
})),
|
})),
|
||||||
|
attachments: reimbursement.attachments.map(mapReimbursementAttachment),
|
||||||
budgetHint,
|
budgetHint,
|
||||||
invoices: reimbursement.invoice_pool_items.map((item) => ({
|
invoices: reimbursement.invoice_pool_items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -2298,6 +2350,310 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
"/reimbursements/:id/attachments",
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
|
|
||||||
|
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||||
|
where: { id: reimbursementId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
owner_id: true,
|
||||||
|
submitter_id: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!reimbursement) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureApplicantOrAdmin(request, reimbursement);
|
||||||
|
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { status: "invalid_attachment_edit_status" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
try {
|
||||||
|
for await (const part of request.parts()) {
|
||||||
|
if (part.type !== "file") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalName = part.filename ?? "attachment";
|
||||||
|
const mimeType = part.mimetype;
|
||||||
|
if (!ATTACHMENT_MIME_ALLOWLIST.has(mimeType)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { file: "unsupported_mime_type" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
let fileSize = 0;
|
||||||
|
for await (const chunk of part.file) {
|
||||||
|
fileSize += chunk.length;
|
||||||
|
if (fileSize > fastify.config.maxUploadSizeBytes) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { file: "file_too_large" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSize === 0) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { file: "empty_file" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
buffer: Buffer.concat(chunks),
|
||||||
|
originalName,
|
||||||
|
mimeType,
|
||||||
|
fileSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { file: "file_too_large" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { file: "missing" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const datePrefix = compactDateString(fastify.config.appTimezone);
|
||||||
|
const createdItems = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const fileSha256 = sha256Hex(file.buffer);
|
||||||
|
const extension = path.extname(file.originalName ?? "").toLowerCase();
|
||||||
|
const objectKey = path.posix.join("reimbursement-attachments", datePrefix, `${crypto.randomUUID()}${extension}`);
|
||||||
|
const storagePath = await writeBuffer({
|
||||||
|
config: fastify.config,
|
||||||
|
key: objectKey,
|
||||||
|
buffer: file.buffer,
|
||||||
|
contentType: file.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await fastify.prisma.reimbursementAttachment.create({
|
||||||
|
data: {
|
||||||
|
reimbursement_id: reimbursementId,
|
||||||
|
storage_path: storagePath,
|
||||||
|
original_name: file.originalName,
|
||||||
|
mime_type: file.mimeType,
|
||||||
|
file_size: file.fileSize,
|
||||||
|
file_sha256: fileSha256,
|
||||||
|
uploaded_by: request.employee.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
original_name: true,
|
||||||
|
mime_type: true,
|
||||||
|
file_size: true,
|
||||||
|
file_sha256: true,
|
||||||
|
created_at: true,
|
||||||
|
uploader: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
employee_no: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdItems.push(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "REIMBURSEMENT_ATTACHMENTS_UPLOADED",
|
||||||
|
targetType: "reimbursement",
|
||||||
|
targetId: reimbursementId,
|
||||||
|
extra: {
|
||||||
|
attachmentIds: createdItems.map((item) => item.id),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
items: createdItems.map(mapReimbursementAttachment),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.delete(
|
||||||
|
"/reimbursements/:id/attachments/:attachmentId",
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
|
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
|
||||||
|
if (!parsedAttachmentId.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { attachmentId: "invalid_uuid" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const reimbursementRows = await tx.$queryRaw`
|
||||||
|
SELECT id, owner_id, submitter_id, status
|
||||||
|
FROM "reimbursement_forms"
|
||||||
|
WHERE id = ${reimbursementId}::uuid
|
||||||
|
FOR UPDATE
|
||||||
|
`;
|
||||||
|
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reimbursement = reimbursementRows[0];
|
||||||
|
ensureApplicantOrAdmin(request, reimbursement);
|
||||||
|
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { status: "invalid_attachment_edit_status" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await tx.reimbursementAttachment.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: parsedAttachmentId.data,
|
||||||
|
reimbursement_id: reimbursementId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (deleted.count === 0) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attachmentId: parsedAttachmentId.data };
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {
|
||||||
|
attachmentId: result.attachmentId,
|
||||||
|
outcome: "deleted",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
"/reimbursements/:id/attachments/:attachmentId/file",
|
||||||
|
{
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
|
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
|
||||||
|
if (!parsedAttachmentId.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { attachmentId: "invalid_uuid" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = await fastify.prisma.reimbursementAttachment.findFirst({
|
||||||
|
where: {
|
||||||
|
id: parsedAttachmentId.data,
|
||||||
|
reimbursement_id: reimbursementId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
storage_path: true,
|
||||||
|
original_name: true,
|
||||||
|
mime_type: true,
|
||||||
|
reimbursement_form: {
|
||||||
|
select: {
|
||||||
|
owner_id: true,
|
||||||
|
submitter_id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!attachment) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form);
|
||||||
|
|
||||||
|
let stream = null;
|
||||||
|
try {
|
||||||
|
const opened = await openReadStream({
|
||||||
|
config: fastify.config,
|
||||||
|
storagePath: attachment.storage_path,
|
||||||
|
});
|
||||||
|
stream = opened.stream;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof StorageNotFoundError) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.type(attachment.mime_type || "application/octet-stream");
|
||||||
|
reply.header(
|
||||||
|
"content-disposition",
|
||||||
|
`inline; filename=\"${sanitizeAttachmentFilename(attachment.original_name)}\"`,
|
||||||
|
);
|
||||||
|
return reply.send(stream);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/reimbursements/:id/items",
|
"/reimbursements/:id/items",
|
||||||
{
|
{
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户