fix: align workflow milestones with confirmed review nodes

This commit is contained in:
江林铖 2026-08-28 18:44:16 +08:00
parent 60fa35e87e
commit 378ae08ec4
5 changed files with 200 additions and 26 deletions

View File

@ -28,7 +28,7 @@ web
- “数据口径”使用独立二级栏直接进入“公司组织架构”,在品牌中心外壳内复用 `/organization` 的真实组织树、人员明细与管理员权限。 - “数据口径”使用独立二级栏直接进入“公司组织架构”,在品牌中心外壳内复用 `/organization` 的真实组织树、人员明细与管理员权限。
- “进行中项目”只同步两个已确认的钉钉流程类型,并只统计北京时间 2026 年 4 月 1 日起发起且尚未完结的流程;总览和所有下钻维度共用这一口径。 - “进行中项目”只同步两个已确认的钉钉流程类型,并只统计北京时间 2026 年 4 月 1 日起发起且尚未完结的流程;总览和所有下钻维度共用这一口径。
- “进行中项目”项目明细按业务导出表展示审批标题、审批编号、需求人、需求部门、平台、品牌、类目、SPU、需求类型、标准时效、当前节点与负责人;需求人取流程发起人,需求部门按 HR 人员架构匹配。 - “进行中项目”项目明细按业务导出表展示审批标题、审批编号、需求人、需求部门、平台、品牌、类目、SPU、需求类型、标准时效、当前节点与负责人;需求人取流程发起人,需求部门按 HR 人员架构匹配。
- 美术设计流程的明细开始时间取“品牌策划主管分配任务”节点创建时间,结束时间取“品牌策划主管审核”节点完成时间;内容视频流程的明细开始时间取“内容主管分配任务”节点创建时间,结束时间按实际分支取“编导审核”或“内容主管审核”节点完成时间。兼容上游当前使用的“内容部负责人”“编导”和“品牌中心负责人审核”等节点名,但对用户统一展示确认后的业务名称;若历史轨迹中同时存在两类审核,取较晚完成的实际结束节点。 - 美术设计流程的明细开始时间取“品牌策划主管分配任务”节点创建时间,结束时间取“品牌策划主管审核”节点完成时间;内容视频流程的明细开始时间取已确认的“内容主管分配任务”节点创建时间,结束时间按规则分支取“编导审核”或“内容主管审核”节点完成时间。内容流程中多个主管分配节点、编导制作节点及普通领导者任务均不等同于最终审核;未知 activityId 不得仅凭负责人身份、AGREE 结果或完成状态推断为审核节点。
- 美术设计申请流程的 `ding_WorkflowTask.activityId` 按已确认流程定义映射为精确中文节点名并写入本地快照;未知节点继续使用同步字段推断或明确回退,不改写上游只读数据。 - 美术设计申请流程的 `ding_WorkflowTask.activityId` 按已确认流程定义映射为精确中文节点名并写入本地快照;未知节点继续使用同步字段推断或明确回退,不改写上游只读数据。
- 钉钉流程快照每小时从只读报表库的 `ding_Workflow`、`ding_WorkflowTask`、`ding_WorkflowScheme` 联合增量同步,只接收已批准的美术设计申请和内容视频流程;三表复合游标写入现有同步状态并继续复用同一批生产快照数据。 - 钉钉流程快照每小时从只读报表库的 `ding_Workflow`、`ding_WorkflowTask`、`ding_WorkflowScheme` 联合增量同步,只接收已批准的美术设计申请和内容视频流程;三表复合游标写入现有同步状态并继续复用同一批生产快照数据。
- “本月上新”只读取表单字段“需求类型”为“新品”的六品牌美术设计流程,不以流程标题关键词判断,也不计“优化”等其他需求类型;分子为本月整条流程已完结数量,分母为本月已完结数量与当前进行中数量之和;其中进行中流程直接复用“进行中项目”的美术设计流程状态、时间与当前品牌中心处理人条件。 - “本月上新”只读取表单字段“需求类型”为“新品”的六品牌美术设计流程,不以流程标题关键词判断,也不计“优化”等其他需求类型;分子为本月整条流程已完结数量,分母为本月已完结数量与当前进行中数量之和;其中进行中流程直接复用“进行中项目”的美术设计流程状态、时间与当前品牌中心处理人条件。

View File

@ -161,6 +161,27 @@ describe('Ding workflow delivery SLA', () => {
}) })
}) })
it('does not use a leadership or content-owner task as the supervisor review', () => {
const assessment = assessDingWorkflowDelivery({
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: '2026-08-02T00:00:00.000Z',
formFields: formFields({ demandType: '白底图-策划专用', taskType: '布局拍摄' }),
tasks: [
task('内容主管分配任务', '2026-08-01T00:00:00.000Z', '2026-08-01T00:05:00.000Z'),
task('内容部负责人', '2026-08-01T06:00:00.000Z', '2026-08-01T06:05:00.000Z'),
task('品牌中心负责人审核', '2026-08-01T06:05:00.000Z', '2026-08-01T06:10:00.000Z'),
],
})
expect(assessment).toMatchObject({
status: 'pending',
actualDuration: null,
finishedAt: null,
endNodeLabel: '内容主管审核',
})
})
it('does not fall back to workflow completion when the required review task is missing', () => { it('does not fall back to workflow completion when the required review task is missing', () => {
const assessment = assessDingWorkflowDelivery({ const assessment = assessDingWorkflowDelivery({
processCode: CONTENT_PROCESS_CODE, processCode: CONTENT_PROCESS_CODE,
@ -182,6 +203,53 @@ describe('Ding workflow delivery SLA', () => {
}) })
}) })
it('ignores a finish timestamp on an active review task', () => {
const assessment = assessDingWorkflowDelivery({
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: null,
formFields: formFields({ demandType: '抖音-千川素材', taskType: '布局拍摄' }),
tasks: [
task('内容主管分配任务', '2026-08-01T00:00:00.000Z', '2026-08-01T00:05:00.000Z'),
{
...task('编导审核', '2026-08-02T00:00:00.000Z', '2026-08-02T01:00:00.000Z'),
isActive: true,
reviewPassed: false,
result: null,
},
],
})
expect(assessment).toMatchObject({
status: 'pending',
actualDuration: null,
finishedAt: null,
})
})
it('ignores an unconfirmed review with a mirrored finish timestamp', () => {
const assessment = assessDingWorkflowDelivery({
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: null,
formFields: formFields({ demandType: '抖音-千川素材', taskType: '布局拍摄' }),
tasks: [{
...task('内容主管分配任务', '2026-08-01T00:00:00.000Z', '2026-08-01T00:05:00.000Z'),
taskId: 'running-review-with-finish-time',
nodeName: '内容主管审核',
isActive: false,
reviewPassed: false,
result: null,
}],
})
expect(assessment).toMatchObject({
status: 'pending',
actualDuration: null,
finishedAt: null,
})
})
it('keeps missing configuration or milestone data pending', () => { it('keeps missing configuration or milestone data pending', () => {
const assessment = assessDingWorkflowDelivery({ const assessment = assessDingWorkflowDelivery({
processCode: CONTENT_PROCESS_CODE, processCode: CONTENT_PROCESS_CODE,

View File

@ -10,6 +10,7 @@ import {
getShanghaiMonthRange, getShanghaiMonthRange,
getDingWorkflowActivityNodeName, getDingWorkflowActivityNodeName,
getDingWorkflowProjectType, getDingWorkflowProjectType,
isDingWorkflowTaskOngoing,
isAllowedDingWorkflowProcessCode, isAllowedDingWorkflowProcessCode,
isDingWorkflowStartedOnOrAfterCutoff, isDingWorkflowStartedOnOrAfterCutoff,
isDingWorkflowOngoing, isDingWorkflowOngoing,
@ -123,13 +124,19 @@ describe('ding workflow snapshot sync semantics', () => {
it('maps all confirmed content workflow milestone activity ids', () => { it('maps all confirmed content workflow milestone activity ids', () => {
const contentCode = DING_WORKFLOW_ALLOWED_PROCESS_CODES[1] const contentCode = DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]
expect(getDingWorkflowActivityNodeName(contentCode, 'cc59_3e5a')).toBe('内容主管分配任务') expect(getDingWorkflowActivityNodeName(contentCode, 'cc59_3e5a')).toBe('内容负责人分配任务')
expect(getDingWorkflowActivityNodeName(contentCode, '9fb8_32e7')).toBe('内容主管分配任务') expect(getDingWorkflowActivityNodeName(contentCode, '9fb8_32e7')).toBe('内容主管分配任务')
expect(getDingWorkflowActivityNodeName(contentCode, 'a021_1e2e')).toBe('编导审核') expect(getDingWorkflowActivityNodeName(contentCode, 'a021_1e2e')).toBe('编导审核')
expect(getDingWorkflowActivityNodeName(contentCode, '39ba_efb1')).toBe('编导审核') expect(getDingWorkflowActivityNodeName(contentCode, '39ba_efb1')).toBe('编导审核')
expect(getDingWorkflowActivityNodeName(contentCode, '6c43_575f')).toBe('内容主管审核') expect(getDingWorkflowActivityNodeName(contentCode, '6c43_575f')).toBe('内容主管审核')
}) })
it('treats a task with finishTime as history even if its mirrored status is still running', () => {
expect(isDingWorkflowTaskOngoing('RUNNING', null)).toBe(true)
expect(isDingWorkflowTaskOngoing('RUNNING', '2026-08-25 11:20:00')).toBe(false)
expect(isDingWorkflowTaskOngoing('COMPLETED', null)).toBe(false)
})
it('upgrades the legacy workflow-only cursor to a three-table cursor', () => { it('upgrades the legacy workflow-only cursor to a three-table cursor', () => {
expect(parseDingWorkflowSourceCursor('2026-08-26 19:42:36')).toEqual({ expect(parseDingWorkflowSourceCursor('2026-08-26 19:42:36')).toEqual({
version: 3, version: 3,
@ -296,7 +303,7 @@ describe('ding workflow snapshot sync semantics', () => {
result: 'AGREE', createdAt: '2026-08-01T01:00:00.000Z', finishedAt: '2026-08-01T02:00:00.000Z', result: 'AGREE', createdAt: '2026-08-01T01:00:00.000Z', finishedAt: '2026-08-01T02:00:00.000Z',
}, },
{ {
taskId: 'task-2', activityId: 'art-end', nodeName: '品牌策划主管审核', taskId: 'task-2', activityId: 'c255_84b3', nodeName: '品牌策划主管审核',
assigneeUserId: 'user-2', assigneeName: '乙', assigneeDepartment: '品牌中心', assigneeUserId: 'user-2', assigneeName: '乙', assigneeDepartment: '品牌中心',
isBrandCenter: true, isLeadership: true, isActive: false, reviewPassed: true, isBrandCenter: true, isLeadership: true, isActive: false, reviewPassed: true,
result: 'AGREE', createdAt: '2026-08-02T01:00:00.000Z', finishedAt: '2026-08-02T02:00:00.000Z', result: 'AGREE', createdAt: '2026-08-02T01:00:00.000Z', finishedAt: '2026-08-02T02:00:00.000Z',
@ -315,7 +322,7 @@ describe('ding workflow snapshot sync semantics', () => {
{ ...tasks[0], taskId: 'task-4', nodeName: '编导', finishedAt: '2026-08-05T02:00:00.000Z' }, { ...tasks[0], taskId: 'task-4', nodeName: '编导', finishedAt: '2026-08-05T02:00:00.000Z' },
])).toEqual({ ])).toEqual({
assignmentNodeLabel: '内容主管分配任务', assignmentNodeLabel: '内容主管分配任务',
assignmentStartedAt: '2026-08-03T01:00:00.000Z', assignmentStartedAt: null,
reviewNodeLabel: '内容主管审核', reviewNodeLabel: '内容主管审核',
reviewFinishedAt: null, reviewFinishedAt: null,
}) })
@ -326,9 +333,34 @@ describe('ding workflow snapshot sync semantics', () => {
{ ...tasks[0], taskId: 'task-6', nodeName: '品牌中心负责人审核', finishedAt: '2026-08-08T02:00:00.000Z' }, { ...tasks[0], taskId: 'task-6', nodeName: '品牌中心负责人审核', finishedAt: '2026-08-08T02:00:00.000Z' },
])).toEqual({ ])).toEqual({
assignmentNodeLabel: '内容主管分配任务', assignmentNodeLabel: '内容主管分配任务',
assignmentStartedAt: '2026-08-06T01:00:00.000Z', assignmentStartedAt: null,
reviewNodeLabel: '内容主管审核', reviewNodeLabel: '内容主管审核',
reviewFinishedAt: '2026-08-08T02:00:00.000Z', reviewFinishedAt: null,
})
})
it('does not treat unknown leadership tasks or assignment labels as review milestones', () => {
const contentCode = DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]
expect(getDingWorkflowActivityNodeName(contentCode, '8814_bf03')).toBeNull()
expect(resolveDingWorkflowDetailMilestones(contentCode, [{
taskId: 'unknown-leadership-task',
activityId: '8814_bf03',
nodeName: '品牌中心负责人审核',
assigneeUserId: 'leader-1',
assigneeName: '领导者',
assigneeDepartment: '品牌中心',
isBrandCenter: true,
isLeadership: true,
isActive: false,
reviewPassed: true,
result: 'AGREE',
createdAt: '2026-08-26T03:00:00.000Z',
finishedAt: '2026-08-26T03:19:00.000Z',
}])).toEqual({
assignmentNodeLabel: '内容主管分配任务',
assignmentStartedAt: null,
reviewNodeLabel: '内容主管审核',
reviewFinishedAt: null,
}) })
}) })
@ -371,6 +403,32 @@ describe('ding workflow snapshot sync semantics', () => {
}) })
}) })
it('ignores an unconfirmed review that has a mirrored finish timestamp', () => {
expect(resolveDingWorkflowDetailMilestones(
DING_WORKFLOW_ALLOWED_PROCESS_CODES[1],
[{
taskId: 'running-supervisor-review',
activityId: '6c43_575f',
nodeName: '内容主管审核',
assigneeUserId: 'user-2',
assigneeName: '乙',
assigneeDepartment: '品牌中心',
isBrandCenter: true,
isLeadership: true,
isActive: false,
reviewPassed: false,
result: null,
createdAt: '2026-08-27T03:00:00.000Z',
finishedAt: '2026-08-27T03:19:00.000Z',
}],
)).toEqual({
assignmentNodeLabel: '内容主管分配任务',
assignmentStartedAt: null,
reviewNodeLabel: '内容主管审核',
reviewFinishedAt: null,
})
})
it('matches initiators by DingTalk roster profile and formats the HR organization path', () => { it('matches initiators by DingTalk roster profile and formats the HR organization path', () => {
expect(resolveDingWorkflowInitiator({ expect(resolveDingWorkflowInitiator({
displayName: '荀瑶', displayName: '荀瑶',

View File

@ -350,7 +350,12 @@ function latestTaskTimeByField(tasks: OngoingDingWorkflowActiveTask[], names: st
} }
function latestTaskTime(tasks: OngoingDingWorkflowActiveTask[], names: string[], after: number | null) { function latestTaskTime(tasks: OngoingDingWorkflowActiveTask[], names: string[], after: number | null) {
return tasks.flatMap((task) => names.includes(task.nodeName) && taskTime(task, 'finishedAt') ? [taskTime(task, 'finishedAt')!] : []) return tasks.flatMap((task) => names.includes(task.nodeName)
&& task.isActive !== true
&& task.reviewPassed === true
&& taskTime(task, 'finishedAt')
? [taskTime(task, 'finishedAt')!]
: [])
.filter((entry) => after === null || entry.timestamp >= after) .filter((entry) => after === null || entry.timestamp >= after)
.sort((a, b) => b.timestamp - a.timestamp)[0] || null .sort((a, b) => b.timestamp - a.timestamp)[0] || null
} }
@ -514,14 +519,14 @@ export function assessDingWorkflowDelivery(input: DingWorkflowDeliveryInput): Di
} }
if (input.processCode === CONTENT_PROCESS_CODE) { if (input.processCode === CONTENT_PROCESS_CODE) {
const assignmentNames = ['内容主管分配任务', '内容负责人分配任务', '内容部负责人'] const assignmentNames = ['内容主管分配任务', '内容负责人分配任务']
const start = earliestTaskTime(input.tasks, assignmentNames, 'createdAt') const start = earliestTaskTime(input.tasks, assignmentNames, 'createdAt')
|| earliestTaskTime(input.tasks, ['编导'], 'createdAt') || earliestTaskTime(input.tasks, ['编导'], 'createdAt')
|| (createdAt === null || !input.sourceCreatedAt ? null : { value: input.sourceCreatedAt, timestamp: createdAt }) || (createdAt === null || !input.sourceCreatedAt ? null : { value: input.sourceCreatedAt, timestamp: createdAt })
const rule = createdAt === null ? null : resolveContentRule(input.formFields, createdAt) const rule = createdAt === null ? null : resolveContentRule(input.formFields, createdAt)
const endNodeLabel = rule?.actualCalcTo === 'contentReview' ? '内容主管审核' : '编导审核' const endNodeLabel = rule?.actualCalcTo === 'contentReview' ? '内容主管审核' : '编导审核'
const endNames = rule?.actualCalcTo === 'contentReview' const endNames = rule?.actualCalcTo === 'contentReview'
? ['内容主管审核', '内容负责人审核', '内容部负责人审核', '品牌中心负责人审核'] ? ['内容主管审核']
: ['编导审核'] : ['编导审核']
const taskFinish = latestTaskTime(input.tasks, endNames, start?.timestamp ?? null) const taskFinish = latestTaskTime(input.tasks, endNames, start?.timestamp ?? null)
// 内容流程的实际用时必须截止到规则指定的审核节点;流程实例的最终 // 内容流程的实际用时必须截止到规则指定的审核节点;流程实例的最终

View File

@ -41,6 +41,14 @@ export const DING_WORKFLOW_NEW_PRODUCT_DEMAND_TYPE = '新品'
const ALLOWED_PROCESS_CODE_VALUES: string[] = [...DING_WORKFLOW_ALLOWED_PROCESS_CODES] const ALLOWED_PROCESS_CODE_VALUES: string[] = [...DING_WORKFLOW_ALLOWED_PROCESS_CODES]
const ALLOWED_PROCESS_CODE_SET = new Set<string>(DING_WORKFLOW_ALLOWED_PROCESS_CODES) const ALLOWED_PROCESS_CODE_SET = new Set<string>(DING_WORKFLOW_ALLOWED_PROCESS_CODES)
const BRAND_CENTER_ACTIVE_TASK_JSON = [{ isBrandCenter: true, isActive: true }] const BRAND_CENTER_ACTIVE_TASK_JSON = [{ isBrandCenter: true, isActive: true }]
// Review milestones come only from the confirmed activityId mapping. A
// completed leadership task with AGREE is not sufficient to infer a review:
// content flows contain several supervisor assignment and production tasks.
const CONFIRMED_REVIEW_NODE_NAMES = new Set([
'品牌策划主管审核',
'编导审核',
'内容主管审核',
])
const PROCESS_TYPE_LABELS: Record<string, string> = { const PROCESS_TYPE_LABELS: Record<string, string> = {
[DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]]: '美术设计流程', [DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]]: '美术设计流程',
[DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: '内容视频和图片拍摄', [DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: '内容视频和图片拍摄',
@ -73,7 +81,7 @@ const ACTIVITY_NODE_NAMES_BY_PROCESS_CODE: Readonly<Record<string, Readonly<Reco
}, },
[DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: { [DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: {
'sid-startevent': '发起人', 'sid-startevent': '发起人',
'cc59_3e5a': '内容主管分配任务', 'cc59_3e5a': '内容负责人分配任务',
'9fb8_32e7': '内容主管分配任务', '9fb8_32e7': '内容主管分配任务',
'd742_3645': '内容主管分配任务', 'd742_3645': '内容主管分配任务',
'b9a4_c03a': '内容主管分配任务', 'b9a4_c03a': '内容主管分配任务',
@ -660,12 +668,12 @@ const DETAIL_MILESTONE_NODES: Readonly<Record<string, {
}, },
[DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: { [DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: {
assignmentNodeLabel: '内容主管分配任务', assignmentNodeLabel: '内容主管分配任务',
assignmentNodeNames: ['内容主管分配任务', '内容负责人分配任务', '内容部负责人'], assignmentNodeNames: ['内容主管分配任务', '内容负责人分配任务'],
// 进行中项目只展示内容主管审核已完成的时间;编导审核是制作分支 // 进行中项目只展示内容主管审核已完成的时间;编导审核是制作分支
// 的过程节点,不能在主管尚未审核时被误显示为“审核结束时间”。 // 的过程节点,不能在主管尚未审核时被误显示为“审核结束时间”。
reviewNodes: [{ reviewNodes: [{
label: '内容主管审核', label: '内容主管审核',
names: ['内容主管审核', '内容部负责人审核', '品牌中心负责人审核'], names: ['内容主管审核'],
}], }],
}, },
} }
@ -691,8 +699,23 @@ export function resolveDingWorkflowDetailMilestones(
const completedReviews = config.reviewNodes.flatMap((reviewNode) => tasks const completedReviews = config.reviewNodes.flatMap((reviewNode) => tasks
// A source task may carry a finish timestamp while the approval node is // A source task may carry a finish timestamp while the approval node is
// still active (for example, during an upstream mirror update). An active // still active (for example, during an upstream mirror update). An active
// review node is not an end milestone, even when finishTime is present. // or unconfirmed review node is not an end milestone, even when
.filter((task) => reviewNode.names.includes(task.nodeName) && task.finishedAt && !task.isActive) // finishTime is present.
.filter((task) => {
if (
!reviewNode.names.includes(task.nodeName)
|| !task.finishedAt
|| task.isActive
|| task.reviewPassed !== true
) return false
// When an activity id is present, require it to resolve to the same
// canonical review label. This protects old snapshots from arbitrary
// labels attached by the previous leadership-task fallback.
const mappedName = task.activityId
? getDingWorkflowActivityNodeName(processCode, task.activityId)
: task.nodeName
return mappedName === task.nodeName
})
.map((task) => ({ .map((task) => ({
label: reviewNode.label, label: reviewNode.label,
finishedAt: task.finishedAt as string, finishedAt: task.finishedAt as string,
@ -1115,7 +1138,11 @@ async function fetchWorkflowAggregates(pool: ConnectionPool, processInstanceIds:
SELECT SELECT
processInstanceId, processInstanceId,
COUNT_BIG(*) AS taskCount, COUNT_BIG(*) AS taskCount,
SUM(CASE WHEN UPPER(LTRIM(RTRIM(COALESCE(status, '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED') THEN 1 ELSE 0 END) AS activeTaskCount, SUM(CASE
WHEN UPPER(LTRIM(RTRIM(COALESCE(status, '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED')
AND NULLIF(LTRIM(RTRIM(COALESCE(finishTime, ''))), '') IS NULL
THEN 1 ELSE 0
END) AS activeTaskCount,
MAX(CASE WHEN latestRow = 1 THEN status END) AS latestTaskStatus, MAX(CASE WHEN latestRow = 1 THEN status END) AS latestTaskStatus,
MAX(CASE WHEN latestRow = 1 THEN createTime END) AS latestTaskAtRaw MAX(CASE WHEN latestRow = 1 THEN createTime END) AS latestTaskAtRaw
FROM ranked_tasks FROM ranked_tasks
@ -1182,7 +1209,11 @@ async function fetchWorkflowAggregates(pool: ConnectionPool, processInstanceIds:
} }
} }
function sourceTaskIsOngoing(status: unknown): boolean { export function isDingWorkflowTaskOngoing(status: unknown, finishTime: unknown): boolean {
// The external dashboard treats a task with a finish timestamp as history,
// even when the mirrored status field still says RUNNING. This is important
// for assignment nodes that have already handed work to the next node.
if (clean(finishTime)) return false
const normalized = clean(status)?.toUpperCase() const normalized = clean(status)?.toUpperCase()
return Boolean(normalized && !TERMINAL_WORKFLOW_STATUSES.has(normalized)) return Boolean(normalized && !TERMINAL_WORKFLOW_STATUSES.has(normalized))
} }
@ -1306,28 +1337,40 @@ function buildSnapshotPayload(
const assigneeUserId = clean(sourceTask.userId) const assigneeUserId = clean(sourceTask.userId)
const taskStatus = clean(sourceTask.status)?.toUpperCase() || null const taskStatus = clean(sourceTask.status)?.toUpperCase() || null
const taskResult = clean(sourceTask.result)?.toUpperCase() || null const taskResult = clean(sourceTask.result)?.toUpperCase() || null
const isActive = ongoing && sourceTaskIsOngoing(sourceTask.status) const isActive = ongoing && isDingWorkflowTaskOngoing(sourceTask.status, sourceTask.finishTime)
const matchingAssignments = assigneeUserId const matchingAssignments = assigneeUserId
? assignments.filter((assignment) => assignment.userId === assigneeUserId) ? assignments.filter((assignment) => assignment.userId === assigneeUserId)
: [] : []
const employee = assigneeUserId ? employeeDirectory.get(assigneeUserId) : null const employee = assigneeUserId ? employeeDirectory.get(assigneeUserId) : null
const reviewPassed = taskStatus === 'COMPLETED'
&& taskResult === 'AGREE'
&& employee?.isBrandCenter === true
&& employee.isLeadership
const uniqueAssignmentNodeNames = Array.from(new Set(matchingAssignments.map((assignment) => assignment.nodeName))) const uniqueAssignmentNodeNames = Array.from(new Set(matchingAssignments.map((assignment) => assignment.nodeName)))
const configuredNodeName = getDingWorkflowActivityNodeName(processCode, activityId) const configuredNodeName = getDingWorkflowActivityNodeName(processCode, activityId)
const inferredNodeName = processCode && activityId const inferredNodeName = processCode && activityId
? activityNodeNames.get(`${processCode}:${activityId}`) ? activityNodeNames.get(`${processCode}:${activityId}`)
: null : null
// Inference can label otherwise unnamed production tasks, but it must
// never manufacture a review milestone. Review nodes are identified by
// the explicit activityId mapping above.
const safeInferredNodeName = inferredNodeName && !CONFIRMED_REVIEW_NODE_NAMES.has(inferredNodeName)
? inferredNodeName
: null
const safeAssignmentNodeName = uniqueAssignmentNodeNames.find(
(nodeName) => !CONFIRMED_REVIEW_NODE_NAMES.has(nodeName),
) || null
const nodeName = configuredNodeName
|| safeInferredNodeName
|| safeAssignmentNodeName
|| (isActive ? '当前处理节点' : '历史处理节点')
// Once the activity id resolves to a confirmed review node, completion
// is determined by the task decision itself. The employee directory is
// intentionally not part of this gate: a stale/missing roster entry
// must not erase a valid review timestamp from the dashboard.
const reviewPassed = CONFIRMED_REVIEW_NODE_NAMES.has(nodeName)
&& taskStatus === 'COMPLETED'
&& taskResult === 'AGREE'
return [{ return [{
taskId, taskId,
activityId, activityId,
nodeName: configuredNodeName nodeName,
|| (reviewPassed ? '品牌中心负责人审核' : null)
|| inferredNodeName
|| (uniqueAssignmentNodeNames.length === 1 ? uniqueAssignmentNodeNames[0] : null)
|| (isActive ? '当前处理节点' : '历史处理节点'),
assigneeUserId, assigneeUserId,
assigneeName: matchingAssignments.find((assignment) => assignment.name)?.name assigneeName: matchingAssignments.find((assignment) => assignment.name)?.name
|| employee?.name || employee?.name