feat: 完成P6-P8前端能力闭环(V3,D2,D3,B3)
这个提交包含在:
@@ -0,0 +1,83 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type BudgetPool = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
departmentId: string | null;
|
||||
status: "active" | "archived";
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
allocatedAmount: string;
|
||||
usedAmount: string;
|
||||
remainingAmount: string;
|
||||
warningThresholdPercent: number;
|
||||
archivedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type BudgetListResponse = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: BudgetPool[];
|
||||
};
|
||||
|
||||
export type BudgetListQuery = {
|
||||
status?: "active" | "archived";
|
||||
departmentId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
function toQueryString(query: BudgetListQuery = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (query.status) params.set("status", query.status);
|
||||
if (query.departmentId) params.set("departmentId", query.departmentId);
|
||||
if (query.page != null) params.set("page", String(query.page));
|
||||
if (query.pageSize != null) params.set("pageSize", String(query.pageSize));
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : "";
|
||||
}
|
||||
|
||||
export async function fetchBudgets(query: BudgetListQuery = {}) {
|
||||
return apiRequestData<BudgetListResponse>(`/api/budgets${toQueryString(query)}`);
|
||||
}
|
||||
|
||||
export async function createBudget(payload: {
|
||||
code: string;
|
||||
name: string;
|
||||
departmentId?: string | null;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
allocatedAmount: string;
|
||||
warningThresholdPercent?: number;
|
||||
}) {
|
||||
return apiRequestData<BudgetPool>(`/api/budgets`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateBudget(
|
||||
budgetId: string,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
allocatedAmount: string;
|
||||
warningThresholdPercent: number;
|
||||
status: "active" | "archived";
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<BudgetPool>(`/api/budgets/${budgetId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveBudget(budgetId: string) {
|
||||
return apiRequestData<BudgetPool>(`/api/budgets/${budgetId}/archive`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type DashboardQuery = {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
departmentId?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
function toQueryString(query: DashboardQuery = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (query.dateFrom) params.set("dateFrom", query.dateFrom);
|
||||
if (query.dateTo) params.set("dateTo", query.dateTo);
|
||||
if (query.departmentId) params.set("departmentId", query.departmentId);
|
||||
if (query.limit != null) params.set("limit", String(query.limit));
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : "";
|
||||
}
|
||||
|
||||
export type DashboardExpenseOverview = {
|
||||
totalCount: number;
|
||||
totalAmount: string;
|
||||
paidCount: number;
|
||||
paidAmount: string;
|
||||
reviewingCount: number;
|
||||
};
|
||||
|
||||
export type DashboardTrendPoint = {
|
||||
date: string;
|
||||
amount: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DashboardTopCategory = {
|
||||
code: string;
|
||||
name: string;
|
||||
amount: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DashboardApprovalEfficiency = {
|
||||
approvedCount: number;
|
||||
averageHours: number;
|
||||
};
|
||||
|
||||
export async function fetchDashboardOverview(query: DashboardQuery = {}) {
|
||||
return apiRequestData<DashboardExpenseOverview>(`/api/dashboard/expense-overview${toQueryString(query)}`);
|
||||
}
|
||||
|
||||
export async function fetchDashboardTrends(query: DashboardQuery = {}) {
|
||||
return apiRequestData<{ points: DashboardTrendPoint[] }>(`/api/dashboard/trends${toQueryString(query)}`);
|
||||
}
|
||||
|
||||
export async function fetchDashboardTopCategories(query: DashboardQuery = {}) {
|
||||
return apiRequestData<{ items: DashboardTopCategory[] }>(`/api/dashboard/top-categories${toQueryString(query)}`);
|
||||
}
|
||||
|
||||
export async function fetchDashboardApprovalEfficiency(query: DashboardQuery = {}) {
|
||||
return apiRequestData<DashboardApprovalEfficiency>(`/api/dashboard/approval-efficiency${toQueryString(query)}`);
|
||||
}
|
||||
@@ -114,6 +114,35 @@ export function mapApiErrorMessage(
|
||||
if (details?.ocr === "disabled") {
|
||||
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。";
|
||||
}
|
||||
|
||||
|
||||
if (details?.verification === "disabled") {
|
||||
return "当前环境未开启发票验真。";
|
||||
}
|
||||
|
||||
if (details?.verification === "already_pending") {
|
||||
return "已有验真任务正在处理中,请稍后查看结果。";
|
||||
}
|
||||
|
||||
if (details?.verification === "too_frequent") {
|
||||
return "验真触发过于频繁,请稍后重试。";
|
||||
}
|
||||
|
||||
if (details?.verification === "attempts_exceeded") {
|
||||
return "验真重试次数已达上限,请联系财务人工核验。";
|
||||
}
|
||||
|
||||
if (details?.budget === "exceeded_block_mode") {
|
||||
return "当前报销金额超出预算,系统已阻断提交。";
|
||||
}
|
||||
|
||||
if (details?.integration === "erp_sync_disabled") {
|
||||
return "ERP 同步功能未开启。";
|
||||
}
|
||||
|
||||
if (details?.integration === "travel_sync_disabled") {
|
||||
return "差旅同步功能未开启。";
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackMessage || "请求失败,请稍后重试。";
|
||||
|
||||
+15
-1
@@ -41,5 +41,19 @@ export async function exportAuditLogs(params: {
|
||||
return apiRequestBlob(`/api/exports/audit-logs?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export type { ApiBlobResponse };
|
||||
export async function exportDashboardReimbursements(params: {
|
||||
format: "csv" | "xlsx";
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
departmentId?: string;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("format", params.format);
|
||||
if (params.dateFrom) qs.set("dateFrom", params.dateFrom);
|
||||
if (params.dateTo) qs.set("dateTo", params.dateTo);
|
||||
if (params.departmentId) qs.set("departmentId", params.departmentId);
|
||||
|
||||
return apiRequestBlob(`/api/exports/dashboard/reimbursements?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export type { ApiBlobResponse };
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export async function triggerErpSync(reimbursementId: string, runNow = true) {
|
||||
return apiRequestData<{
|
||||
outboxId: string;
|
||||
status: string;
|
||||
duplicated: boolean;
|
||||
}>(`/api/integrations/erp/sync/${reimbursementId}?runNow=${runNow ? "1" : "0"}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function importTravelOrders(payload: {
|
||||
orders: Array<{
|
||||
source: string;
|
||||
externalOrderNo: string;
|
||||
employeeId: string;
|
||||
linkedReimbursementId?: string;
|
||||
occurredAt?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
}>;
|
||||
}) {
|
||||
return apiRequestData<{
|
||||
requested: number;
|
||||
imported: number;
|
||||
skipped: Array<{ source: string; externalOrderNo: string; reason: string }>;
|
||||
items: Array<{
|
||||
id: string;
|
||||
source: string;
|
||||
externalOrderNo: string;
|
||||
employeeId: string;
|
||||
linkedReimbursementId: string | null;
|
||||
}>;
|
||||
}>(`/api/travel/orders/import`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function testNotificationChannel(payload: {
|
||||
channelType: string;
|
||||
name: string;
|
||||
}) {
|
||||
return apiRequestData<{
|
||||
channel: {
|
||||
id: string;
|
||||
channelType: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
testResult: {
|
||||
ok: boolean;
|
||||
code: string;
|
||||
response?: Record<string, unknown>;
|
||||
};
|
||||
}>(`/api/notifications/channels/test`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,22 @@ import { apiRequestData } from "./http";
|
||||
|
||||
export type InvoicePoolStatus = "pending_ocr" | "ocr_failed" | "available" | "bound" | "archived";
|
||||
|
||||
export type InvoiceVerificationRecord = {
|
||||
id: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
resultCode: string | null;
|
||||
resultMessage: string | null;
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
durationMs: number | null;
|
||||
requestedBy: string | null;
|
||||
requestedAt: string | null;
|
||||
completedAt: string | null;
|
||||
retryOfId: string | null;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
export type InvoicePoolListItem = {
|
||||
id: string;
|
||||
status: InvoicePoolStatus;
|
||||
@@ -21,6 +37,7 @@ export type InvoicePoolListItem = {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
verification: InvoiceVerificationRecord | null;
|
||||
owner: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
@@ -75,6 +92,7 @@ export type InvoicePoolDetail = {
|
||||
overrideRefFormNumber: string | null;
|
||||
overrideReason: string | null;
|
||||
overriddenBy: string | null;
|
||||
verification: InvoiceVerificationRecord | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
@@ -193,3 +211,29 @@ export async function overrideInvoiceDuplicate(invoiceId: string, payload: { ref
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function triggerInvoiceVerification(invoiceId: string) {
|
||||
return apiRequestData<{ invoiceId: string; verificationId: string; provider: string; triggerMode: string }>(
|
||||
`/api/invoice-pool/${invoiceId}/verify`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchInvoiceVerifications(invoiceId: string, limit = 20) {
|
||||
return apiRequestData<{ invoiceId: string; items: InvoiceVerificationRecord[] }>(
|
||||
`/api/invoice-pool/${invoiceId}/verification?limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function retryInvoiceVerification(invoiceId: string) {
|
||||
return apiRequestData<{ invoiceId: string; verificationId: string; status: string }>(
|
||||
`/api/invoice-pool/${invoiceId}/verification/retry`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,22 @@ export type ReimbursementListResponse = {
|
||||
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 ReimbursementDetail = {
|
||||
id: string;
|
||||
formNumber: string | null;
|
||||
@@ -86,6 +102,7 @@ export type ReimbursementDetail = {
|
||||
invoiceNumber: string | null;
|
||||
totalAmount: string | null;
|
||||
}>;
|
||||
budgetHint: ReimbursementBudgetHint | null;
|
||||
pendingWorkflow: {
|
||||
instanceId: string;
|
||||
stepId: string;
|
||||
@@ -135,6 +152,22 @@ export type ReimbursementItemDeleteResponse = {
|
||||
totalAmount: string;
|
||||
};
|
||||
|
||||
export type ReimbursementSubmitResponse = {
|
||||
reimbursementId: string;
|
||||
formNumber: string;
|
||||
workflowInstanceId: string;
|
||||
currentTaskId: string;
|
||||
status: "reviewing";
|
||||
budget: {
|
||||
budgetId: string;
|
||||
budgetCode: string;
|
||||
controlMode: "warn" | "block";
|
||||
usagePercent: number;
|
||||
exceededThreshold: boolean;
|
||||
exceededBudget: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -171,7 +204,7 @@ export async function createReimbursement(payload: {
|
||||
}
|
||||
|
||||
export async function submitReimbursement(reimbursementId: string) {
|
||||
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/submit`, {
|
||||
return apiRequestData<ReimbursementSubmitResponse>(`/api/reimbursements/${reimbursementId}/submit`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户