656 行
17 KiB
TypeScript
656 行
17 KiB
TypeScript
import { apiRequestBlob, apiRequestData } from "./http";
|
|
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
|
|
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
|
import type { ProcessingProgress, ProcessingStage } from "../types/processingProgress";
|
|
|
|
export type ReimbursementStatus =
|
|
| "draft"
|
|
| "reviewing"
|
|
| "approved"
|
|
| "paid"
|
|
| "rejected"
|
|
| "cancelled";
|
|
|
|
export type ReimbursementItem = {
|
|
id: string;
|
|
expenseTypeId: string | null;
|
|
expenseDate: string | null;
|
|
amount: string;
|
|
description: string | null;
|
|
assetPriorApprovalBasis?: string | null;
|
|
expenseType: {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
category: string;
|
|
enabled: boolean;
|
|
requiresInvoice?: boolean;
|
|
} | null;
|
|
};
|
|
|
|
export type LinkedTravelRequestSummary = {
|
|
id: string;
|
|
requestNo: string | null;
|
|
purpose: string | null;
|
|
startDate: string | null;
|
|
endDate: string | null;
|
|
estimatedAmount: string;
|
|
};
|
|
|
|
export type ReimbursementListItem = {
|
|
id: string;
|
|
formNumber: string | null;
|
|
status: ReimbursementStatus;
|
|
reimbursementDate: string | null;
|
|
totalAmount: string | null;
|
|
description: string | null;
|
|
payment: {
|
|
paymentVoucherNo: string | null;
|
|
paymentDate: string | null;
|
|
paymentChannel: string | null;
|
|
paidAt: string | null;
|
|
paidBy: string | null;
|
|
};
|
|
owner: {
|
|
id: string;
|
|
employeeNo: string;
|
|
name: string;
|
|
} | null;
|
|
submitter: {
|
|
id: string;
|
|
employeeNo: string;
|
|
name: string;
|
|
} | null;
|
|
project: {
|
|
id: string;
|
|
projectNo: string;
|
|
name: string;
|
|
} | null;
|
|
pendingWorkflow: {
|
|
instanceId: string;
|
|
stepId: string;
|
|
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
|
|
task: {
|
|
id: string;
|
|
stepName: string;
|
|
assigneeId: string | null;
|
|
assigneeRole: string | null;
|
|
} | null;
|
|
} | null;
|
|
createdAt: string | null;
|
|
updatedAt: string | null;
|
|
};
|
|
|
|
export type ReimbursementListResponse = {
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
scope: "own" | "all";
|
|
items: ReimbursementListItem[];
|
|
};
|
|
|
|
export type ReimbursementBudgetHint = {
|
|
budgetId: string;
|
|
budgetCode: string;
|
|
budgetName: string;
|
|
controlMode: "warn" | "block";
|
|
warningThresholdPercent: number;
|
|
allocatedAmount: string;
|
|
usedAmount: string;
|
|
remainingAmount: string;
|
|
currentAmount: string;
|
|
nextUsedAmount: string;
|
|
nextUsagePercent: number;
|
|
exceedsThreshold: boolean;
|
|
exceedsBudget: boolean;
|
|
};
|
|
|
|
export type ReimbursementWorkflowProgress = {
|
|
currentStepIndex: number;
|
|
totalSteps: number;
|
|
steps: Array<{
|
|
id: string;
|
|
name: string;
|
|
status: "completed" | "current" | "pending";
|
|
}>;
|
|
};
|
|
|
|
export type ReimbursementProcessingStage = ProcessingStage;
|
|
export type ReimbursementProcessingProgress = ProcessingProgress;
|
|
|
|
export type ReimbursementApprovalHistoryItem = {
|
|
id: string;
|
|
stepName: string | null;
|
|
action: string;
|
|
actorName: string;
|
|
comment: string | null;
|
|
createdAt: string | null;
|
|
};
|
|
|
|
export type ReimbursementDetail = {
|
|
deletedAt?: string | null;
|
|
id: string;
|
|
formNumber: string | null;
|
|
submitterId: string;
|
|
ownerId: string;
|
|
departmentId: string;
|
|
department: {
|
|
id: string;
|
|
name: string;
|
|
} | null;
|
|
status: ReimbursementStatus;
|
|
createdAt: string | null;
|
|
submittedAt: string | null;
|
|
reimbursementDate: string | null;
|
|
totalAmount: string | null;
|
|
description: string | null;
|
|
travelSummary: {
|
|
isTravel: boolean;
|
|
purpose: string | null;
|
|
location: string | null;
|
|
startDate: string | null;
|
|
endDate: string | null;
|
|
};
|
|
payment: {
|
|
paymentVoucherNo: string | null;
|
|
paymentDate: string | null;
|
|
paymentChannel: string | null;
|
|
paidAt: string | null;
|
|
paidBy: {
|
|
id: string;
|
|
employeeNo: string;
|
|
name: string;
|
|
} | null;
|
|
};
|
|
projectId: string | null;
|
|
linkedTravelRequestId: string | null;
|
|
linkedTravelRequest: LinkedTravelRequestSummary | null;
|
|
project: {
|
|
id: string;
|
|
projectNo: string;
|
|
name: string;
|
|
} | null;
|
|
owner: {
|
|
id: string;
|
|
employee_no: string;
|
|
name: string;
|
|
} | null;
|
|
submitter: {
|
|
id: string;
|
|
employee_no: string;
|
|
name: string;
|
|
} | null;
|
|
invoices: Array<{
|
|
id: string;
|
|
status: string;
|
|
invoiceCode: string | null;
|
|
invoiceNumber: string | null;
|
|
invoiceDate: string | null;
|
|
sellerName: string | null;
|
|
buyerName: string | null;
|
|
documentCategory: string | null;
|
|
documentSubtype: string | null;
|
|
totalAmount: string | null;
|
|
originalName: string | null;
|
|
mimeType: string | null;
|
|
fileSize: number | null;
|
|
linkedItemId: string | null;
|
|
amountEvidenceStatus: "verified" | "manual_review" | "ocr_failed";
|
|
}>;
|
|
attachments: Array<{
|
|
id: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
fileSize: number;
|
|
fileSha256: string;
|
|
} & AttachmentGovernanceFields & {
|
|
uploadedBy: {
|
|
id: string;
|
|
name: string;
|
|
employeeNo: string;
|
|
} | null;
|
|
createdAt: string | null;
|
|
}>;
|
|
budgetHint: ReimbursementBudgetHint | null;
|
|
pendingWorkflow: {
|
|
instanceId: string;
|
|
stepId: string;
|
|
task: {
|
|
id: string;
|
|
step_name: string;
|
|
assignee_id: string | null;
|
|
assignee_role: string | null;
|
|
} | null;
|
|
} | null;
|
|
workflowProgress: ReimbursementWorkflowProgress | null;
|
|
processingProgress: ReimbursementProcessingProgress;
|
|
approvalHistory: ReimbursementApprovalHistoryItem[];
|
|
riskFlags: RiskFlag[];
|
|
invoiceGuardEnabled?: boolean;
|
|
invoiceAmountManualReviewRequired?: boolean;
|
|
items: ReimbursementItem[];
|
|
};
|
|
|
|
export type ReimbursementListQuery = {
|
|
page?: number;
|
|
pageSize?: number;
|
|
status?: ReimbursementStatus;
|
|
scope?: "own" | "all";
|
|
};
|
|
|
|
export type ReimbursementStatsQuery = {
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
departmentId?: string;
|
|
};
|
|
|
|
export type ReimbursementStatsGranularity = "month" | "week";
|
|
|
|
export type ReimbursementStatsSummary = {
|
|
totalCount: number;
|
|
totalAmount: string;
|
|
byStatus: Record<
|
|
ReimbursementStatus,
|
|
{
|
|
count: number;
|
|
totalAmount: string;
|
|
}
|
|
>;
|
|
avgProcessingDays: number | null;
|
|
};
|
|
|
|
export type ReimbursementStatsTrendItem = {
|
|
period: string | null;
|
|
count: number;
|
|
amount: string;
|
|
paidCount: number;
|
|
paidAmount: string;
|
|
};
|
|
|
|
export type ReimbursementTopExpenseTypeItem = {
|
|
expenseTypeId: string;
|
|
expenseTypeName: string;
|
|
count: number;
|
|
totalAmount: string;
|
|
};
|
|
|
|
export type ReimbursementApprovalBottleneckItem = {
|
|
assigneeId: string | null;
|
|
assigneeName: string;
|
|
pendingCount: number;
|
|
oldestPendingDays: number;
|
|
};
|
|
|
|
export type ReimbursementPayPayload = {
|
|
paymentVoucherNo?: string;
|
|
paymentDate?: string;
|
|
paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other";
|
|
paymentNote?: string;
|
|
};
|
|
|
|
export type ReimbursementCreatePayload = {
|
|
ownerEmployeeId?: string;
|
|
reimbursementDate?: string;
|
|
description?: string;
|
|
templateId?: string;
|
|
linkedTravelRequestId?: string;
|
|
travelPurpose?: string;
|
|
travelLocation?: string;
|
|
travelStartDate?: string;
|
|
travelEndDate?: string;
|
|
};
|
|
|
|
export type ReimbursementTravelSummaryPayload = {
|
|
travelPurpose: string | null;
|
|
travelLocation: string | null;
|
|
travelStartDate: string | null;
|
|
travelEndDate: string | null;
|
|
};
|
|
|
|
export type ReimbursementUpdatePayload = Partial<ReimbursementTravelSummaryPayload> & {
|
|
reimbursementDate?: string;
|
|
description?: string | null;
|
|
};
|
|
|
|
export type ReimbursementCreateResponse = {
|
|
id: string;
|
|
formNumber: string | null;
|
|
submitterId: string;
|
|
ownerId: string;
|
|
reimbursementDate: string | null;
|
|
status: ReimbursementStatus;
|
|
description: string | null;
|
|
projectId: string | null;
|
|
project: {
|
|
id: string;
|
|
projectNo: string;
|
|
name: string;
|
|
} | null;
|
|
totalAmount: string;
|
|
linkedTravelRequestId: string | null;
|
|
linkedTravelRequest: LinkedTravelRequestSummary | null;
|
|
items: ReimbursementItem[];
|
|
};
|
|
|
|
export type LinkableTravelRequestSummary = {
|
|
id: string;
|
|
requestNo: string | null;
|
|
purpose: string;
|
|
startDate: string | null;
|
|
endDate: string | null;
|
|
estimatedAmount: string;
|
|
};
|
|
|
|
export type LinkableTravelRequestListResponse = {
|
|
ownerEmployeeId: string;
|
|
items: LinkableTravelRequestSummary[];
|
|
};
|
|
|
|
export type ReimbursementItemPayload = {
|
|
expenseTypeId: string | null;
|
|
expenseDate: string;
|
|
amount: string;
|
|
description?: string;
|
|
assetPriorApprovalBasis?: string | null;
|
|
};
|
|
|
|
export type ReimbursementItemMutationResponse = {
|
|
item: {
|
|
id: string;
|
|
expenseTypeId: string | null;
|
|
expenseDate: string;
|
|
amount: string;
|
|
description: string | null;
|
|
assetPriorApprovalBasis?: string | null;
|
|
};
|
|
totalAmount: string;
|
|
};
|
|
|
|
export type ReimbursementItemDeleteResponse = {
|
|
outcome: "deleted";
|
|
totalAmount: string;
|
|
};
|
|
|
|
export type ReimbursementDeleteResponse = {
|
|
reimbursementId: string;
|
|
outcome: "deleted";
|
|
unboundInvoiceCount: number;
|
|
};
|
|
|
|
export type ReimbursementSubmitResponse = {
|
|
reimbursementId: string;
|
|
formNumber: string;
|
|
workflowInstanceId: string;
|
|
currentTaskId: string;
|
|
status: "reviewing";
|
|
budget: {
|
|
budgetId: string;
|
|
budgetCode: string;
|
|
controlMode: "warn" | "block" | "manual_review";
|
|
usagePercent: number;
|
|
exceededThreshold: boolean;
|
|
exceededBudget: boolean;
|
|
} | null;
|
|
precheckDecision: "pass" | "warn" | "manual_review";
|
|
standardWarnings: ExpenseStandardCheck[];
|
|
openedRiskFlagCount: number;
|
|
invoiceAmountManualReviewRequired: boolean;
|
|
manualReviewItemCount: number;
|
|
};
|
|
|
|
function toQueryString(query: Record<string, string | number | undefined>) {
|
|
const params = new URLSearchParams();
|
|
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (value == null || value === "") {
|
|
continue;
|
|
}
|
|
params.set(key, String(value));
|
|
}
|
|
|
|
const encoded = params.toString();
|
|
return encoded ? `?${encoded}` : "";
|
|
}
|
|
|
|
export async function fetchReimbursements(query: ReimbursementListQuery = {}) {
|
|
return apiRequestData<ReimbursementListResponse>(
|
|
`/api/reimbursements${toQueryString(query as Record<string, string | number | undefined>)}`,
|
|
);
|
|
}
|
|
|
|
export async function fetchReimbursementStatsSummary(query: ReimbursementStatsQuery = {}) {
|
|
return apiRequestData<ReimbursementStatsSummary>(
|
|
`/api/reimbursements/stats/summary${toQueryString(query as Record<string, string | number | undefined>)}`,
|
|
);
|
|
}
|
|
|
|
export async function fetchReimbursementStatsTrend(
|
|
query: ReimbursementStatsQuery & { granularity?: ReimbursementStatsGranularity } = {},
|
|
) {
|
|
return apiRequestData<{ items: ReimbursementStatsTrendItem[] }>(
|
|
`/api/reimbursements/stats/trend${toQueryString(query as Record<string, string | number | undefined>)}`,
|
|
);
|
|
}
|
|
|
|
export async function fetchReimbursementTopExpenseTypes(
|
|
query: ReimbursementStatsQuery & { limit?: number } = {},
|
|
) {
|
|
return apiRequestData<{ items: ReimbursementTopExpenseTypeItem[] }>(
|
|
`/api/reimbursements/stats/top-expense-types${toQueryString(query as Record<string, string | number | undefined>)}`,
|
|
);
|
|
}
|
|
|
|
export async function fetchReimbursementApprovalBottleneck(query: ReimbursementStatsQuery = {}) {
|
|
return apiRequestData<{ items: ReimbursementApprovalBottleneckItem[] }>(
|
|
`/api/reimbursements/stats/approval-bottleneck${toQueryString(query as Record<string, string | number | undefined>)}`,
|
|
);
|
|
}
|
|
|
|
export async function fetchReimbursementDetail(reimbursementId: string) {
|
|
return apiRequestData<ReimbursementDetail>(`/api/reimbursements/${reimbursementId}`);
|
|
}
|
|
|
|
export async function createReimbursement(payload: ReimbursementCreatePayload = {}) {
|
|
return apiRequestData<ReimbursementCreateResponse>(`/api/reimbursements`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export async function fetchLinkableTravelRequests(query: { ownerEmployeeId?: string } = {}) {
|
|
return apiRequestData<LinkableTravelRequestListResponse>(
|
|
`/api/reimbursements/linkable-travel-requests${toQueryString(query as Record<string, string | number | undefined>)}`,
|
|
);
|
|
}
|
|
|
|
export async function precheckReimbursement(reimbursementId: string) {
|
|
return apiRequestData<PrecheckResponse>(`/api/reimbursements/${reimbursementId}/precheck`, {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
export async function submitReimbursement(reimbursementId: string) {
|
|
return apiRequestData<ReimbursementSubmitResponse>(`/api/reimbursements/${reimbursementId}/submit`, {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
export async function linkReimbursementInvoiceItem(
|
|
reimbursementId: string,
|
|
invoiceId: string,
|
|
linkedItemId: string,
|
|
) {
|
|
return apiRequestData<{
|
|
reimbursementId: string;
|
|
invoiceId: string;
|
|
linkedItemId: string;
|
|
}>(`/api/reimbursements/${reimbursementId}/invoices/${invoiceId}/item-link`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ linkedItemId }),
|
|
});
|
|
}
|
|
|
|
export async function withdrawReimbursement(reimbursementId: string, comment?: string) {
|
|
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/withdraw`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ comment }),
|
|
});
|
|
}
|
|
|
|
export async function payReimbursement(reimbursementId: string, payload: ReimbursementPayPayload = {}) {
|
|
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/pay`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export async function deleteReimbursement(reimbursementId: string) {
|
|
return apiRequestData<ReimbursementDeleteResponse>(`/api/reimbursements/${reimbursementId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
export async function updateReimbursementTravelSummary(
|
|
reimbursementId: string,
|
|
payload: ReimbursementTravelSummaryPayload,
|
|
) {
|
|
return updateReimbursement(reimbursementId, payload);
|
|
}
|
|
|
|
export async function updateReimbursement(
|
|
reimbursementId: string,
|
|
payload: ReimbursementUpdatePayload,
|
|
) {
|
|
return apiRequestData<{
|
|
id: string;
|
|
status: ReimbursementStatus;
|
|
reimbursementDate: string;
|
|
description: string | null;
|
|
totalAmount: string;
|
|
travelSummary: Omit<ReimbursementDetail["travelSummary"], "isTravel">;
|
|
}>(`/api/reimbursements/${reimbursementId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export async function createReimbursementItem(
|
|
reimbursementId: string,
|
|
payload: ReimbursementItemPayload,
|
|
) {
|
|
return apiRequestData<ReimbursementItemMutationResponse>(
|
|
`/api/reimbursements/${reimbursementId}/items`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function updateReimbursementItem(
|
|
reimbursementId: string,
|
|
itemId: string,
|
|
payload: Partial<ReimbursementItemPayload>,
|
|
) {
|
|
return apiRequestData<ReimbursementItemMutationResponse>(
|
|
`/api/reimbursements/${reimbursementId}/items/${itemId}`,
|
|
{
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function deleteReimbursementItem(reimbursementId: string, itemId: string) {
|
|
return apiRequestData<ReimbursementItemDeleteResponse>(
|
|
`/api/reimbursements/${reimbursementId}/items/${itemId}`,
|
|
{
|
|
method: "DELETE",
|
|
},
|
|
);
|
|
}
|
|
|
|
export const MAX_REIMBURSEMENT_ATTACHMENT_FILES = 5;
|
|
|
|
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, reason: string) {
|
|
return apiRequestData<{ attachmentId: string; outcome: string }>(
|
|
`/api/reimbursements/${reimbursementId}/attachments/${attachmentId}`,
|
|
{
|
|
method: "DELETE",
|
|
body: JSON.stringify({ reason }),
|
|
},
|
|
);
|
|
}
|
|
|
|
export function reimbursementAttachmentFileUrl(reimbursementId: string, attachmentId: string) {
|
|
return `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`;
|
|
}
|
|
|
|
export async function downloadReimbursementAttachment(reimbursementId: string, attachmentId: string, reason?: string) {
|
|
return apiRequestBlob(buildAttachmentFileUrl(reimbursementAttachmentFileUrl(reimbursementId, attachmentId), reason));
|
|
}
|
|
|
|
export async function fetchReimbursementAttachmentPreviewUrl(
|
|
reimbursementId: string,
|
|
attachmentId: string,
|
|
reason?: string,
|
|
) {
|
|
return apiRequestData<{
|
|
signedUrl: string;
|
|
expiresAt: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
fileSize: number;
|
|
}>(`/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`, {
|
|
method: "POST",
|
|
body: JSON.stringify(reason ? { reason } : {}),
|
|
});
|
|
}
|
|
|
|
export type ReimbursementMaterialPreviewBundle = {
|
|
expiresAt: string;
|
|
signedUrl: string;
|
|
originalName: string;
|
|
mimeType: "application/pdf";
|
|
fileSize: number;
|
|
pageCount: number;
|
|
};
|
|
|
|
export async function fetchReimbursementMaterialPreviewBundle(
|
|
reimbursementId: string,
|
|
reason?: string,
|
|
) {
|
|
return apiRequestData<ReimbursementMaterialPreviewBundle>(
|
|
`/api/reimbursements/${reimbursementId}/materials/preview-bundle`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(reason ? { reason } : {}),
|
|
},
|
|
);
|
|
}
|
|
|
|
|
|
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`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ projectId }),
|
|
});
|
|
}
|