diff --git a/PRODUCT.md b/PRODUCT.md index 47ca213..53f03d7 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -28,7 +28,7 @@ web - “数据口径”使用独立二级栏直接进入“公司组织架构”,在品牌中心外壳内复用 `/organization` 的真实组织树、人员明细与管理员权限。 - “进行中项目”只同步两个已确认的钉钉流程类型,并只统计北京时间 2026 年 4 月 1 日起发起且尚未完结的流程;总览和所有下钻维度共用这一口径。 - “进行中项目”项目明细按业务导出表展示审批标题、审批编号、需求人、需求部门、平台、品牌、类目、SPU、需求类型、标准时效、当前节点与负责人;需求人取流程发起人,需求部门按 HR 人员架构匹配。 -- 美术设计流程的明细开始时间取“品牌策划主管分配任务”节点创建时间,结束时间取“品牌策划主管审核”节点完成时间;内容视频流程的明细开始时间取“内容主管分配任务”节点创建时间,结束时间按实际分支取“编导审核”或“内容主管审核”节点完成时间。兼容上游当前使用的“内容部负责人”“编导”和“品牌中心负责人审核”等节点名,但对用户统一展示确认后的业务名称;若历史轨迹中同时存在两类审核,取较晚完成的实际结束节点。 +- 美术设计流程的明细开始时间取“品牌策划主管分配任务”节点创建时间,结束时间取“品牌策划主管审核”节点完成时间;内容视频流程的明细开始时间取已确认的“内容主管分配任务”节点创建时间,结束时间按规则分支取“编导审核”或“内容主管审核”节点完成时间。内容流程中多个主管分配节点、编导制作节点及普通领导者任务均不等同于最终审核;未知 activityId 不得仅凭负责人身份、AGREE 结果或完成状态推断为审核节点。 - 美术设计申请流程的 `ding_WorkflowTask.activityId` 按已确认流程定义映射为精确中文节点名并写入本地快照;未知节点继续使用同步字段推断或明确回退,不改写上游只读数据。 - 钉钉流程快照每小时从只读报表库的 `ding_Workflow`、`ding_WorkflowTask`、`ding_WorkflowScheme` 联合增量同步,只接收已批准的美术设计申请和内容视频流程;三表复合游标写入现有同步状态并继续复用同一批生产快照数据。 - “本月上新”只读取表单字段“需求类型”为“新品”的六品牌美术设计流程,不以流程标题关键词判断,也不计“优化”等其他需求类型;分子为本月整条流程已完结数量,分母为本月已完结数量与当前进行中数量之和;其中进行中流程直接复用“进行中项目”的美术设计流程状态、时间与当前品牌中心处理人条件。 diff --git a/lib/__tests__/ding-workflow-delivery.test.ts b/lib/__tests__/ding-workflow-delivery.test.ts index b3a80f9..eb47db1 100644 --- a/lib/__tests__/ding-workflow-delivery.test.ts +++ b/lib/__tests__/ding-workflow-delivery.test.ts @@ -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', () => { const assessment = assessDingWorkflowDelivery({ 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', () => { const assessment = assessDingWorkflowDelivery({ processCode: CONTENT_PROCESS_CODE, diff --git a/lib/__tests__/ding-workflow-sync.test.ts b/lib/__tests__/ding-workflow-sync.test.ts index c7e0f55..c21f470 100644 --- a/lib/__tests__/ding-workflow-sync.test.ts +++ b/lib/__tests__/ding-workflow-sync.test.ts @@ -10,6 +10,7 @@ import { getShanghaiMonthRange, getDingWorkflowActivityNodeName, getDingWorkflowProjectType, + isDingWorkflowTaskOngoing, isAllowedDingWorkflowProcessCode, isDingWorkflowStartedOnOrAfterCutoff, isDingWorkflowOngoing, @@ -123,13 +124,19 @@ describe('ding workflow snapshot sync semantics', () => { it('maps all confirmed content workflow milestone activity ids', () => { 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, 'a021_1e2e')).toBe('编导审核') expect(getDingWorkflowActivityNodeName(contentCode, '39ba_efb1')).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', () => { expect(parseDingWorkflowSourceCursor('2026-08-26 19:42:36')).toEqual({ 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', }, { - taskId: 'task-2', activityId: 'art-end', nodeName: '品牌策划主管审核', + taskId: 'task-2', activityId: 'c255_84b3', nodeName: '品牌策划主管审核', assigneeUserId: 'user-2', assigneeName: '乙', assigneeDepartment: '品牌中心', isBrandCenter: true, isLeadership: true, isActive: false, reviewPassed: true, 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' }, ])).toEqual({ assignmentNodeLabel: '内容主管分配任务', - assignmentStartedAt: '2026-08-03T01:00:00.000Z', + assignmentStartedAt: null, reviewNodeLabel: '内容主管审核', 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' }, ])).toEqual({ assignmentNodeLabel: '内容主管分配任务', - assignmentStartedAt: '2026-08-06T01:00:00.000Z', + assignmentStartedAt: null, 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', () => { expect(resolveDingWorkflowInitiator({ displayName: '荀瑶', diff --git a/lib/ding-workflow-delivery.ts b/lib/ding-workflow-delivery.ts index 4190d20..ee1f097 100644 --- a/lib/ding-workflow-delivery.ts +++ b/lib/ding-workflow-delivery.ts @@ -350,7 +350,12 @@ function latestTaskTimeByField(tasks: OngoingDingWorkflowActiveTask[], names: st } 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) .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) { - const assignmentNames = ['内容主管分配任务', '内容负责人分配任务', '内容部负责人'] + const assignmentNames = ['内容主管分配任务', '内容负责人分配任务'] const start = earliestTaskTime(input.tasks, assignmentNames, 'createdAt') || earliestTaskTime(input.tasks, ['编导'], 'createdAt') || (createdAt === null || !input.sourceCreatedAt ? null : { value: input.sourceCreatedAt, timestamp: createdAt }) const rule = createdAt === null ? null : resolveContentRule(input.formFields, createdAt) const endNodeLabel = rule?.actualCalcTo === 'contentReview' ? '内容主管审核' : '编导审核' const endNames = rule?.actualCalcTo === 'contentReview' - ? ['内容主管审核', '内容负责人审核', '内容部负责人审核', '品牌中心负责人审核'] + ? ['内容主管审核'] : ['编导审核'] const taskFinish = latestTaskTime(input.tasks, endNames, start?.timestamp ?? null) // 内容流程的实际用时必须截止到规则指定的审核节点;流程实例的最终 diff --git a/lib/ding-workflow-sync.ts b/lib/ding-workflow-sync.ts index a708aa0..d4af108 100644 --- a/lib/ding-workflow-sync.ts +++ b/lib/ding-workflow-sync.ts @@ -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_SET = new Set(DING_WORKFLOW_ALLOWED_PROCESS_CODES) 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 = { [DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]]: '美术设计流程', [DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: '内容视频和图片拍摄', @@ -73,7 +81,7 @@ const ACTIVITY_NODE_NAMES_BY_PROCESS_CODE: Readonly tasks // A source task may carry a finish timestamp while the approval node is // still active (for example, during an upstream mirror update). An active - // review node is not an end milestone, even when finishTime is present. - .filter((task) => reviewNode.names.includes(task.nodeName) && task.finishedAt && !task.isActive) + // or unconfirmed review node is not an end milestone, even when + // 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) => ({ label: reviewNode.label, finishedAt: task.finishedAt as string, @@ -1115,7 +1138,11 @@ async function fetchWorkflowAggregates(pool: ConnectionPool, processInstanceIds: SELECT processInstanceId, 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 createTime END) AS latestTaskAtRaw 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() return Boolean(normalized && !TERMINAL_WORKFLOW_STATUSES.has(normalized)) } @@ -1306,28 +1337,40 @@ function buildSnapshotPayload( const assigneeUserId = clean(sourceTask.userId) const taskStatus = clean(sourceTask.status)?.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 ? assignments.filter((assignment) => assignment.userId === assigneeUserId) : [] 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 configuredNodeName = getDingWorkflowActivityNodeName(processCode, activityId) const inferredNodeName = processCode && activityId ? activityNodeNames.get(`${processCode}:${activityId}`) : 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 [{ taskId, activityId, - nodeName: configuredNodeName - || (reviewPassed ? '品牌中心负责人审核' : null) - || inferredNodeName - || (uniqueAssignmentNodeNames.length === 1 ? uniqueAssignmentNodeNames[0] : null) - || (isActive ? '当前处理节点' : '历史处理节点'), + nodeName, assigneeUserId, assigneeName: matchingAssignments.find((assignment) => assignment.name)?.name || employee?.name