修复报销进度审批节点覆盖(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -109,13 +109,31 @@ if (!hasPermission(actor, "payment.batch.review")
|
|||||||
- Response: `processingProgress.stages[]` with `key`, `label`, `state`,
|
- Response: `processingProgress.stages[]` with `key`, `label`, `state`,
|
||||||
nullable `actorName`, `occurredAt`, `result`, `comment`, `detail` and
|
nullable `actorName`, `occurredAt`, `result`, `comment`, `detail` and
|
||||||
permission-gated `targetHref`.
|
permission-gated `targetHref`.
|
||||||
- Stable stage keys: `submitted`, `manager_approval`, `finance_review`,
|
- Fixed stage keys: `submitted`, `payment_batch_review` and
|
||||||
`payment_batch_review`, `payment_and_receipt`.
|
`payment_and_receipt`. Each persisted workflow task contributes one unique
|
||||||
|
approval-stage key derived from task identity; clients treat keys as opaque
|
||||||
|
strings and never infer responsibility from their value.
|
||||||
|
|
||||||
### 3. Contracts
|
### 3. Contracts
|
||||||
|
|
||||||
- Always return the five stages in the stable order above. UI labels describe
|
- Always return submission first, then one stage per real workflow task in the
|
||||||
responsibilities and never expose step IDs, role codes or enum values.
|
query's stable `created_at,id` order. Because the engine creates only the
|
||||||
|
current task, append not-yet-materialized future steps from the immutable
|
||||||
|
`workflow_steps_snapshot` after the current step, then return payment-batch
|
||||||
|
review and payment/receipt. The current two-approval production rule therefore
|
||||||
|
returns five stages from initial submission onward; configured extra or
|
||||||
|
repeated-role approvals increase the array length instead of being discarded,
|
||||||
|
merged or temporarily hidden.
|
||||||
|
- Never collect workflow tasks into a `Map` keyed by semantic responsibility:
|
||||||
|
two finance, manager or CEO tasks are distinct historical facts and later
|
||||||
|
tasks must not overwrite earlier ones. The UI renders `stages` with a plain
|
||||||
|
ordered `map` and contains no fixed-length or positional responsibility logic.
|
||||||
|
- Approval labels use a readable non-generic `step_name` first. Empty,
|
||||||
|
ordinal (`步骤2`, `审批节点1`, `step 3`), raw technical identifier or generic
|
||||||
|
(`审批`, `审核`, `复核`) names fall back through role mapping
|
||||||
|
(`manager -> 主管审批`, `finance|finance_mgr -> 财务复核`,
|
||||||
|
`ceo -> 负责人复核`) and finally `审批`. UI labels never expose step IDs,
|
||||||
|
role codes or sequence numbers.
|
||||||
- Completed stages use the actual completer/actor and completion time. Current
|
- Completed stages use the actual completer/actor and completion time. Current
|
||||||
workflow stages use the actual pending assignee when available. Future stages
|
workflow stages use the actual pending assignee when available. Future stages
|
||||||
keep `actorName=null`; never guess a person from today's role holders.
|
keep `actorName=null`; never guess a person from today's role holders.
|
||||||
@@ -131,8 +149,15 @@ if (!hasPermission(actor, "payment.batch.review")
|
|||||||
|
|
||||||
### 4. Validation & Error Matrix
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
- No workflow instance -> submission/applicant state remains readable; approval
|
- No workflow instance -> submission/applicant state remains readable and the
|
||||||
and payment stages stay pending.
|
approval segment falls back to pending `主管审批` / `财务复核` placeholders.
|
||||||
|
- Workflow instance with no task yet -> build pending approval stages from its
|
||||||
|
immutable snapshot. Current task exists but later tasks do not -> append those
|
||||||
|
future snapshot stages with `actorName=null`; never infer future employees.
|
||||||
|
- A third unknown task -> one additional approval stage in its real position;
|
||||||
|
it must not disappear before the payment stages.
|
||||||
|
- Two tasks with the same role -> two unique ordered stages with their own
|
||||||
|
actor, state, time and comment; neither may overwrite the other.
|
||||||
- Workflow approved + no batch -> payment-batch stage is current, future actor
|
- Workflow approved + no batch -> payment-batch stage is current, future actor
|
||||||
remains null.
|
remains null.
|
||||||
- Batch `draft|reviewed|executed|failed|cancelled` -> map to explicit current,
|
- Batch `draft|reviewed|executed|failed|cancelled` -> map to explicit current,
|
||||||
@@ -148,36 +173,40 @@ if (!hasPermission(actor, "payment.batch.review")
|
|||||||
|
|
||||||
- Good: `财务复核=通过` followed by current `待财务创建付款批次`, so the owner
|
- Good: `财务复核=通过` followed by current `待财务创建付款批次`, so the owner
|
||||||
can diagnose the exact responsibility without asking three people.
|
can diagnose the exact responsibility without asking three people.
|
||||||
- Base: a newly submitted reimbursement shows known submission/current approval
|
- Base: a newly submitted reimbursement shows known submission, every real
|
||||||
and three future stages with no future names.
|
configured approval task, and the two future payment stages with no guessed
|
||||||
- Bad: display an expected checker before batch creation, label a shared batch
|
future names.
|
||||||
as `您的报销待复核`, or hide the whole timeline because no receipt exists.
|
- Bad: classify tasks into manager/finance keys and overwrite duplicates, skip
|
||||||
|
the third task, display an expected checker before batch creation, label a
|
||||||
|
shared batch as `您的报销待复核`, or hide the whole timeline because no receipt
|
||||||
|
exists.
|
||||||
|
|
||||||
### 6. Tests Required
|
### 6. Tests Required
|
||||||
|
|
||||||
- Route tests for no workflow, pending/completed/returned/rejected tasks, all
|
- Route tests for no workflow, empty-task instance, a current task plus future
|
||||||
five batch statuses, receipt count zero/positive and paid-without-batch legacy
|
snapshot-only steps, pending/completed/returned/rejected tasks, a third
|
||||||
data.
|
unclassified task, two same-role tasks, all five batch statuses, receipt count
|
||||||
|
zero/positive and paid-without-batch legacy data.
|
||||||
- Assert future stages have null actors and ordinary applicants receive no
|
- Assert future stages have null actors and ordinary applicants receive no
|
||||||
batch link or batch-adjacent business data.
|
batch link or batch-adjacent business data.
|
||||||
- PC/mobile tests assert five responsibility labels, no `第 N 步`, comments on
|
- PC/mobile tests assert variable-length ordered rendering, no `第 N 步` or raw
|
||||||
exception nodes, four-block order and no loss of earlier stages when a
|
ordinal label, comments on exception nodes, four-block order and no loss of
|
||||||
downstream source is absent.
|
earlier stages when a downstream source is absent.
|
||||||
|
|
||||||
### 7. Wrong vs Correct
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
#### Wrong
|
#### Wrong
|
||||||
|
|
||||||
```js
|
```js
|
||||||
return workflowInstance ? buildTimeline(workflowInstance) : null;
|
for (const task of workflowTasks) {
|
||||||
|
stageByRole.set(classify(task), buildStage(task));
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Correct
|
#### Correct
|
||||||
|
|
||||||
```js
|
```js
|
||||||
return buildProcessingProgress({
|
const approvalStages = workflowTasks.map((task) => buildApprovalStage(task));
|
||||||
reimbursement,
|
approvalStages.push(...buildFutureSnapshotStages(workflowSnapshot, workflowTasks));
|
||||||
workflowInstance: workflowInstance ?? null,
|
return [submittedStage, ...approvalStages, batchReviewStage, paymentStage];
|
||||||
paymentBatchItem: paymentBatchItem ?? null,
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export type ReimbursementWorkflowProgress = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ReimbursementProcessingStage = {
|
export type ReimbursementProcessingStage = {
|
||||||
key: "submitted" | "manager_approval" | "finance_review" | "payment_batch_review" | "payment_and_receipt";
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
state: "completed" | "current" | "pending" | "exception";
|
state: "completed" | "current" | "pending" | "exception";
|
||||||
actorName: string | null;
|
actorName: string | null;
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export class ReimbursementDetailPageObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async uploadInvoicePdf(fileName: string, buffer: Buffer) {
|
async uploadInvoicePdf(fileName: string, buffer: Buffer) {
|
||||||
|
const invoiceCards = this.invoices.locator(":scope > div");
|
||||||
|
const invoiceCountBeforeUpload = await invoiceCards.count();
|
||||||
await this.invoiceAddBtn.click();
|
await this.invoiceAddBtn.click();
|
||||||
await expect(this.invoiceDialog).toBeVisible();
|
await expect(this.invoiceDialog).toBeVisible();
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ export class ReimbursementDetailPageObject {
|
|||||||
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(this.invoices).toContainText(fileName, { timeout: 90_000 });
|
await expect(invoiceCards).toHaveCount(invoiceCountBeforeUpload + 1, { timeout: 90_000 });
|
||||||
await this.page.keyboard.press("Escape");
|
await this.page.keyboard.press("Escape");
|
||||||
await expect(this.invoiceDialog).toHaveCount(0);
|
await expect(this.invoiceDialog).toHaveCount(0);
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ const reimbursementDetail = {
|
|||||||
stages: [
|
stages: [
|
||||||
{ key: "submitted", label: "申请提交", state: "completed", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "已提交", comment: null, detail: null, targetHref: null },
|
{ key: "submitted", label: "申请提交", state: "completed", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "已提交", comment: null, detail: null, targetHref: null },
|
||||||
{ key: "manager_approval", label: "主管审批", state: "current", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "处理中", comment: null, detail: "待主管审批处理", targetHref: null },
|
{ key: "manager_approval", label: "主管审批", state: "current", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "处理中", comment: null, detail: "待主管审批处理", targetHref: null },
|
||||||
|
{ key: "approval_extra-review", label: "负责人复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
|
||||||
{ key: "finance_review", label: "财务复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
|
{ key: "finance_review", label: "财务复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
|
||||||
{ key: "payment_batch_review", label: "付款批次复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务创建付款批次", targetHref: null },
|
{ key: "payment_batch_review", label: "付款批次复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务创建付款批次", targetHref: null },
|
||||||
{ key: "payment_and_receipt", label: "付款与回单", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款批次复核后处理", targetHref: null },
|
{ key: "payment_and_receipt", label: "付款与回单", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款批次复核后处理", targetHref: null },
|
||||||
@@ -1096,6 +1097,7 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
|
|||||||
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
|
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
|
||||||
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
|
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
|
||||||
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
|
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核");
|
||||||
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款批次复核");
|
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款批次复核");
|
||||||
await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/);
|
await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/);
|
||||||
|
|
||||||
@@ -1107,6 +1109,7 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
|
|||||||
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
|
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
|
||||||
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
|
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
|
||||||
|
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("负责人复核");
|
||||||
await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10");
|
await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10");
|
||||||
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
|
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
|
||||||
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
|
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { openNavLink } from "./helpers/navigation";
|
|||||||
|
|
||||||
async function firstEnabledExpenseTypeId(api: APIRequestContext) {
|
async function firstEnabledExpenseTypeId(api: APIRequestContext) {
|
||||||
const response = await api.get("/api/expense-types?enabled=true");
|
const response = await api.get("/api/expense-types?enabled=true");
|
||||||
const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean }> } };
|
const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean; category?: string | null }> } };
|
||||||
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0];
|
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false && candidate.category !== "travel");
|
||||||
if (!response.ok() || !item?.id) {
|
if (!response.ok() || !item?.id) {
|
||||||
throw new Error(`无法读取费用类型:HTTP ${response.status()}`);
|
throw new Error(`无法读取费用类型:HTTP ${response.status()}`);
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ async function createReimbursementItem(api: APIRequestContext, reimbursementId:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("E2E 报销处理进度:提交后详情页展示五阶段职责进度", async ({ page, reimbursementDetail }) => {
|
test("E2E 报销处理进度:提交后按真实 workflow 展示职责进度", async ({ page, reimbursementDetail }) => {
|
||||||
const apiClient = await createAuthenticatedApiClient(EMPLOYEE);
|
const apiClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
@@ -67,9 +67,10 @@ test("E2E 报销处理进度:提交后详情页展示五阶段职责进度", a
|
|||||||
|
|
||||||
await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
|
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(reimbursementDetail.workflowProgress.locator(":scope > li")).toHaveCount(4);
|
||||||
await expect(reimbursementDetail.workflowProgress).toContainText("申请提交");
|
await expect(reimbursementDetail.workflowProgress).toContainText("申请提交");
|
||||||
await expect(reimbursementDetail.workflowProgress).toContainText("主管审批");
|
await expect(reimbursementDetail.workflowProgress).toContainText("审批");
|
||||||
await expect(reimbursementDetail.workflowProgress).toContainText("财务复核");
|
await expect(reimbursementDetail.workflowProgress).not.toContainText("审批节点1");
|
||||||
await expect(reimbursementDetail.workflowProgress).toContainText("付款批次复核");
|
await expect(reimbursementDetail.workflowProgress).toContainText("付款批次复核");
|
||||||
await expect(reimbursementDetail.workflowProgress).toContainText("付款与回单");
|
await expect(reimbursementDetail.workflowProgress).toContainText("付款与回单");
|
||||||
await expect(reimbursementDetail.workflowProgress).not.toContainText(/第 \d+ 步/);
|
await expect(reimbursementDetail.workflowProgress).not.toContainText(/第 \d+ 步/);
|
||||||
|
|||||||
@@ -341,16 +341,52 @@ function isoDateTimeOrNull(value) {
|
|||||||
return value?.toISOString?.() ?? null;
|
return value?.toISOString?.() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyApprovalTask(task, index) {
|
function isOrdinalApprovalLabel(value) {
|
||||||
|
const label = String(value ?? "").trim();
|
||||||
|
if (!label) return true;
|
||||||
|
const ordinal = "(?:\\d+|[一二三四五六七八九十百]+)";
|
||||||
|
return new RegExp(`^(?:第\\s*)?${ordinal}\\s*(?:步|步骤|节点)$`, "i").test(label)
|
||||||
|
|| new RegExp(`^(?:审批)?(?:步骤?|节点|step)\\s*[-_#::]?\\s*${ordinal}$`, "i").test(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApprovalStageLabel(task) {
|
||||||
|
const stepName = String(task?.step_name ?? "").trim();
|
||||||
|
const stepId = String(task?.step_id ?? "").trim();
|
||||||
|
const isTechnicalIdentifier = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+$/i.test(stepName);
|
||||||
|
const isGenericLabel = ["审批", "审核", "复核"].includes(stepName);
|
||||||
|
if (stepName && stepName !== stepId && !isOrdinalApprovalLabel(stepName) && !isTechnicalIdentifier && !isGenericLabel) {
|
||||||
|
return stepName;
|
||||||
|
}
|
||||||
|
|
||||||
const role = String(task?.assignee_role ?? "").toLowerCase();
|
const role = String(task?.assignee_role ?? "").toLowerCase();
|
||||||
const text = `${task?.step_id ?? ""} ${task?.step_name ?? ""}`.toLowerCase();
|
if (role === "manager") return "主管审批";
|
||||||
if (["finance", "finance_mgr"].includes(role) || /财务|finance/.test(text)) {
|
if (["finance", "finance_mgr"].includes(role)) return "财务复核";
|
||||||
return "finance_review";
|
if (role === "ceo") return "负责人复核";
|
||||||
}
|
return "审批";
|
||||||
if (["manager", "ceo"].includes(role) || /主管|经理|manager|ceo/.test(text)) {
|
}
|
||||||
return "manager_approval";
|
|
||||||
}
|
function resolveSnapshotApprovalRole(step) {
|
||||||
return index === 0 ? "manager_approval" : index === 1 ? "finance_review" : null;
|
if (step?.assignee_rule?.type === "department_manager") return "manager";
|
||||||
|
if (step?.assignee_rule?.type === "role") return step.assignee_rule.value ?? null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPendingSnapshotApprovalStage(step, index) {
|
||||||
|
return {
|
||||||
|
key: `approval_pending_${index + 1}`,
|
||||||
|
label: resolveApprovalStageLabel({
|
||||||
|
step_id: step?.id,
|
||||||
|
step_name: step?.name,
|
||||||
|
assignee_role: resolveSnapshotApprovalRole(step),
|
||||||
|
}),
|
||||||
|
state: "pending",
|
||||||
|
actorName: null,
|
||||||
|
occurredAt: null,
|
||||||
|
result: null,
|
||||||
|
comment: null,
|
||||||
|
detail: "待处理",
|
||||||
|
targetHref: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApprovalProcessingStage(task, fallback) {
|
function buildApprovalProcessingStage(task, fallback) {
|
||||||
@@ -436,10 +472,34 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [index, task] of (workflowInstance?.workflow_tasks ?? []).entries()) {
|
const approvalStages = [];
|
||||||
const stageKey = classifyApprovalTask(task, index);
|
const workflowTasks = workflowInstance?.workflow_tasks ?? [];
|
||||||
if (!stageKey || !stageByKey.has(stageKey)) continue;
|
for (const [index, task] of workflowTasks.entries()) {
|
||||||
stageByKey.set(stageKey, buildApprovalProcessingStage(task, stageByKey.get(stageKey)));
|
const fallback = {
|
||||||
|
key: `approval_${task.id ?? index + 1}`,
|
||||||
|
label: resolveApprovalStageLabel(task),
|
||||||
|
state: "pending",
|
||||||
|
actorName: null,
|
||||||
|
occurredAt: null,
|
||||||
|
result: null,
|
||||||
|
comment: null,
|
||||||
|
detail: "待处理",
|
||||||
|
targetHref: null,
|
||||||
|
};
|
||||||
|
approvalStages.push(buildApprovalProcessingStage(task, fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowInstance) {
|
||||||
|
const snapshotSteps = getWorkflowStepsSnapshot(workflowInstance);
|
||||||
|
const currentStepIndex = snapshotSteps.findIndex((step) => step.id === workflowInstance.current_step_id);
|
||||||
|
const materializedStepIds = new Set(workflowTasks.map((task) => task.step_id).filter(Boolean));
|
||||||
|
const pendingStartIndex = workflowTasks.length === 0 ? 0 : currentStepIndex + 1;
|
||||||
|
for (let index = Math.max(pendingStartIndex, 0); index < snapshotSteps.length; index += 1) {
|
||||||
|
const step = snapshotSteps[index];
|
||||||
|
if (!materializedStepIds.has(step.id)) {
|
||||||
|
approvalStages.push(buildPendingSnapshotApprovalStage(step, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const batch = paymentBatchItem?.payment_batch ?? null;
|
const batch = paymentBatchItem?.payment_batch ?? null;
|
||||||
@@ -516,7 +576,16 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { stages: PROCESSING_STAGE_DEFINITIONS.map(({ key }) => stageByKey.get(key)) };
|
return {
|
||||||
|
stages: [
|
||||||
|
stageByKey.get("submitted"),
|
||||||
|
...(approvalStages.length > 0
|
||||||
|
? approvalStages
|
||||||
|
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
|
||||||
|
stageByKey.get("payment_batch_review"),
|
||||||
|
stageByKey.get("payment_and_receipt"),
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureApplicantOrAdmin(request, reimbursement) {
|
function ensureApplicantOrAdmin(request, reimbursement) {
|
||||||
|
|||||||
@@ -320,8 +320,8 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
|||||||
});
|
});
|
||||||
expect(response.json().data.processingProgress.stages).toEqual([
|
expect(response.json().data.processingProgress.stages).toEqual([
|
||||||
expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }),
|
expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }),
|
||||||
expect.objectContaining({ key: "manager_approval", state: "completed", actorName: "王经理", result: "通过" }),
|
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed", actorName: "王经理", result: "通过" }),
|
||||||
expect.objectContaining({ key: "finance_review", state: "current", actorName: "财务专员", result: "处理中" }),
|
expect.objectContaining({ key: "approval_finance-task", label: "财务审批", state: "current", actorName: "财务专员", result: "处理中" }),
|
||||||
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务创建付款批次" }),
|
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务创建付款批次" }),
|
||||||
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
|
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
|
||||||
]);
|
]);
|
||||||
@@ -360,6 +360,243 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a third approval task and uses the role label when its configured name is only an ordinal", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
latestWorkflowInstance: {
|
||||||
|
id: PENDING_INSTANCE_ID,
|
||||||
|
current_step_id: "ceo_approve",
|
||||||
|
current_status: "pending",
|
||||||
|
created_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
started_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
context_data: {
|
||||||
|
workflow_steps_snapshot: [
|
||||||
|
{ id: "manager_approve", name: "主管审批" },
|
||||||
|
{ id: "finance_approve", name: "财务复核" },
|
||||||
|
{ id: "ceo_approve", name: "步骤3" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workflow_definition: {
|
||||||
|
definition_json: {
|
||||||
|
steps: [
|
||||||
|
{ id: "manager_approve", name: "主管审批" },
|
||||||
|
{ id: "finance_approve", name: "财务复核" },
|
||||||
|
{ id: "ceo_approve", name: "步骤3" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workflow_tasks: [
|
||||||
|
{
|
||||||
|
id: "manager-task",
|
||||||
|
step_id: "manager_approve",
|
||||||
|
step_name: "主管审批",
|
||||||
|
assignee_role: "manager",
|
||||||
|
status: "completed",
|
||||||
|
outcome: "approved",
|
||||||
|
comment: null,
|
||||||
|
created_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
completed_at: new Date("2026-04-10T11:00:00.000Z"),
|
||||||
|
assignee_employee: { name: "主管" },
|
||||||
|
completer_employee: { name: "主管" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "finance-task",
|
||||||
|
step_id: "finance_approve",
|
||||||
|
step_name: "财务复核",
|
||||||
|
assignee_role: "finance",
|
||||||
|
status: "completed",
|
||||||
|
outcome: "approved",
|
||||||
|
comment: null,
|
||||||
|
created_at: new Date("2026-04-10T11:00:00.000Z"),
|
||||||
|
completed_at: new Date("2026-04-10T12:00:00.000Z"),
|
||||||
|
assignee_employee: { name: "财务" },
|
||||||
|
completer_employee: { name: "财务" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ceo-task",
|
||||||
|
step_id: "ceo_approve",
|
||||||
|
step_name: "步骤3",
|
||||||
|
assignee_role: "ceo",
|
||||||
|
status: "pending",
|
||||||
|
outcome: null,
|
||||||
|
comment: null,
|
||||||
|
created_at: new Date("2026-04-10T12:00:00.000Z"),
|
||||||
|
completed_at: null,
|
||||||
|
assignee_employee: { name: "负责人" },
|
||||||
|
completer_employee: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.processingProgress.stages.slice(1, 4)).toEqual([
|
||||||
|
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed" }),
|
||||||
|
expect.objectContaining({ key: "approval_finance-task", label: "财务复核", state: "completed" }),
|
||||||
|
expect.objectContaining({ key: "approval_ceo-task", label: "负责人复核", state: "current", actorName: "负责人" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends future snapshot approvals that do not have workflow tasks yet", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
latestWorkflowInstance: {
|
||||||
|
id: PENDING_INSTANCE_ID,
|
||||||
|
current_step_id: "manager_approve",
|
||||||
|
current_status: "pending",
|
||||||
|
created_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
started_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
context_data: {
|
||||||
|
workflow_steps_snapshot: [
|
||||||
|
{ id: "manager_approve", name: "主管审批", type: "approval", assignee_rule: { type: "department_manager" } },
|
||||||
|
{ id: "finance_approve", name: "财务复核", type: "approval", assignee_rule: { type: "role", value: "finance" } },
|
||||||
|
{ id: "ceo_approve", name: "步骤3", type: "approval", assignee_rule: { type: "role", value: "ceo" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workflow_definition: { definition_json: { steps: [] } },
|
||||||
|
workflow_tasks: [{
|
||||||
|
id: "manager-task",
|
||||||
|
step_id: "manager_approve",
|
||||||
|
step_name: "主管审批",
|
||||||
|
assignee_role: "manager",
|
||||||
|
status: "pending",
|
||||||
|
outcome: null,
|
||||||
|
comment: null,
|
||||||
|
created_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
completed_at: null,
|
||||||
|
assignee_employee: { name: "主管" },
|
||||||
|
completer_employee: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.processingProgress.stages.slice(1, 4)).toEqual([
|
||||||
|
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "current", actorName: "主管" }),
|
||||||
|
expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }),
|
||||||
|
expect.objectContaining({ key: "approval_pending_3", label: "负责人复核", state: "pending", actorName: null }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps multiple tasks with the same role as separate ordered approval stages", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
latestWorkflowInstance: {
|
||||||
|
id: PENDING_INSTANCE_ID,
|
||||||
|
current_step_id: "finance_final",
|
||||||
|
current_status: "pending",
|
||||||
|
created_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
started_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
context_data: {
|
||||||
|
workflow_steps_snapshot: [
|
||||||
|
{ id: "finance_first", name: "财务初审" },
|
||||||
|
{ id: "finance_final", name: "财务终审" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workflow_definition: {
|
||||||
|
definition_json: {
|
||||||
|
steps: [
|
||||||
|
{ id: "finance_first", name: "财务初审" },
|
||||||
|
{ id: "finance_final", name: "财务终审" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workflow_tasks: [
|
||||||
|
{
|
||||||
|
id: "finance-first-task",
|
||||||
|
step_id: "finance_first",
|
||||||
|
step_name: "finance_first",
|
||||||
|
assignee_role: "finance",
|
||||||
|
status: "completed",
|
||||||
|
outcome: "approved",
|
||||||
|
comment: "初审通过",
|
||||||
|
created_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
completed_at: new Date("2026-04-10T11:00:00.000Z"),
|
||||||
|
assignee_employee: { name: "初审人" },
|
||||||
|
completer_employee: { name: "初审人" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "finance-final-task",
|
||||||
|
step_id: "finance_final",
|
||||||
|
step_name: "财务终审",
|
||||||
|
assignee_role: "finance",
|
||||||
|
status: "pending",
|
||||||
|
outcome: null,
|
||||||
|
comment: null,
|
||||||
|
created_at: new Date("2026-04-10T11:00:00.000Z"),
|
||||||
|
completed_at: null,
|
||||||
|
assignee_employee: { name: "终审人" },
|
||||||
|
completer_employee: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.processingProgress.stages.slice(1, 3)).toEqual([
|
||||||
|
expect.objectContaining({ key: "approval_finance-first-task", label: "财务复核", actorName: "初审人", state: "completed" }),
|
||||||
|
expect.objectContaining({ key: "approval_finance-final-task", label: "财务终审", actorName: "终审人", state: "current" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to pending manager and finance placeholders when an instance has no tasks yet", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
latestWorkflowInstance: {
|
||||||
|
id: PENDING_INSTANCE_ID,
|
||||||
|
current_step_id: null,
|
||||||
|
current_status: "pending",
|
||||||
|
created_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
started_at: new Date("2026-04-10T10:00:00.000Z"),
|
||||||
|
context_data: {
|
||||||
|
workflow_steps_snapshot: [
|
||||||
|
{ id: "manager_approve", name: "主管审批" },
|
||||||
|
{ id: "finance_approve", name: "财务复核" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workflow_definition: {
|
||||||
|
definition_json: {
|
||||||
|
steps: [
|
||||||
|
{ id: "manager_approve", name: "主管审批" },
|
||||||
|
{ id: "finance_approve", name: "财务复核" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workflow_tasks: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.processingProgress.stages.slice(1, 3)).toEqual([
|
||||||
|
expect.objectContaining({ key: "approval_pending_1", label: "主管审批", state: "pending", actorName: null }),
|
||||||
|
expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["draft", "current", "pending", "待所在付款批次复核通过"],
|
["draft", "current", "pending", "待所在付款批次复核通过"],
|
||||||
["reviewed", "completed", "current", "所在付款批次已复核,待执行付款"],
|
["reviewed", "completed", "current", "所在付款批次已复核,待执行付款"],
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户