fix: align workflow milestones with confirmed review nodes
This commit is contained in:
parent
60fa35e87e
commit
378ae08ec4
@ -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` 联合增量同步,只接收已批准的美术设计申请和内容视频流程;三表复合游标写入现有同步状态并继续复用同一批生产快照数据。
|
||||||
- “本月上新”只读取表单字段“需求类型”为“新品”的六品牌美术设计流程,不以流程标题关键词判断,也不计“优化”等其他需求类型;分子为本月整条流程已完结数量,分母为本月已完结数量与当前进行中数量之和;其中进行中流程直接复用“进行中项目”的美术设计流程状态、时间与当前品牌中心处理人条件。
|
- “本月上新”只读取表单字段“需求类型”为“新品”的六品牌美术设计流程,不以流程标题关键词判断,也不计“优化”等其他需求类型;分子为本月整条流程已完结数量,分母为本月已完结数量与当前进行中数量之和;其中进行中流程直接复用“进行中项目”的美术设计流程状态、时间与当前品牌中心处理人条件。
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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: '荀瑶',
|
||||||
|
|||||||
@ -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)
|
||||||
// 内容流程的实际用时必须截止到规则指定的审核节点;流程实例的最终
|
// 内容流程的实际用时必须截止到规则指定的审核节点;流程实例的最终
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user