diff --git a/lib/__tests__/ding-workflow-delivery.test.ts b/lib/__tests__/ding-workflow-delivery.test.ts index 84447b5..7b05551 100644 --- a/lib/__tests__/ding-workflow-delivery.test.ts +++ b/lib/__tests__/ding-workflow-delivery.test.ts @@ -114,6 +114,31 @@ describe('Ding workflow delivery SLA', () => { }) }) + it('uses the confirmed content assignment and review timestamps after report-clock normalization', () => { + const assessment = assessDingWorkflowDelivery({ + processCode: CONTENT_PROCESS_CODE, + sourceCreatedAt: '2026-08-14T06:52:00.000Z', + sourceFinishedAt: '2026-08-17T01:10:00.000Z', + formFields: formFields({ demandType: '抖音-千川素材', taskType: '二次剪辑' }), + tasks: [ + task('内容主管分配任务', '2026-08-14T06:59:00.000Z', '2026-08-14T08:04:00.000Z'), + task('编导', '2026-08-15T07:25:00.000Z', '2026-08-15T07:25:00.000Z'), + task('编导审核', '2026-08-15T07:35:00.000Z', '2026-08-15T09:11:00.000Z'), + ], + }) + + expect(assessment).toMatchObject({ + status: 'on_time', + standardDuration: 48, + durationUnit: 'hour', + actualDuration: 26.2, + startedAt: '2026-08-14T06:59:00.000Z', + finishedAt: '2026-08-15T09:11:00.000Z', + startNodeLabel: '内容主管分配任务', + endNodeLabel: '编导审核', + }) + }) + it('ends content-supervisor rules at 内容主管审核', () => { 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 0b040c6..ed4f69b 100644 --- a/lib/__tests__/ding-workflow-sync.test.ts +++ b/lib/__tests__/ding-workflow-sync.test.ts @@ -24,6 +24,7 @@ import { resolveDingWorkflowDetailMilestones, serializeDingWorkflowSourceCursor, sourceWorkflowTimestampToIso, + storedWorkflowTimestampToIso, } from '@/lib/ding-workflow-sync' jest.mock('mssql', () => ({ @@ -47,18 +48,25 @@ describe('ding workflow snapshot sync semantics', () => { expect(isDingWorkflowOngoing(status, finishTime)).toBe(expected) }) - it('interprets upstream timestamps as Asia/Shanghai when no zone is present', () => { + it('interprets upstream report timestamps as Asia/Shanghai wall-clock values', () => { expect(sourceWorkflowTimestampToIso('2026-08-20 10:30:00')).toBe('2026-08-20T02:30:00.000Z') - expect(sourceWorkflowTimestampToIso('2026-08-20T10:30:00Z')).toBe('2026-08-20T10:30:00.000Z') + expect(sourceWorkflowTimestampToIso('2026-08-20T10:30:00Z')).toBe('2026-08-20T02:30:00.000Z') + expect(sourceWorkflowTimestampToIso('2026-08-20T10:30:00+08:00')).toBe('2026-08-20T02:30:00.000Z') expect(sourceWorkflowTimestampToIso('invalid')).toBeNull() }) + it('does not reinterpret canonical snapshot timestamps a second time', () => { + expect(storedWorkflowTimestampToIso('2026-08-20T02:30:00.000Z')).toBe('2026-08-20T02:30:00.000Z') + expect(storedWorkflowTimestampToIso('2026-08-20 10:30:00')).toBe('2026-08-20T02:30:00.000Z') + expect(storedWorkflowTimestampToIso('invalid')).toBeNull() + }) + it('only includes workflows started from 2026-04-01 Asia/Shanghai', () => { expect(DING_WORKFLOW_STARTED_AT_CUTOFF_ISO).toBe('2026-03-31T16:00:00.000Z') expect(isDingWorkflowStartedOnOrAfterCutoff('2026-03-31 23:59:59')).toBe(false) expect(isDingWorkflowStartedOnOrAfterCutoff('2026-04-01 00:00:00')).toBe(true) - expect(isDingWorkflowStartedOnOrAfterCutoff('2026-03-31T15:59:59.999Z')).toBe(false) - expect(isDingWorkflowStartedOnOrAfterCutoff('2026-03-31T16:00:00.000Z')).toBe(true) + expect(isDingWorkflowStartedOnOrAfterCutoff('2026-03-31T23:59:59.999Z')).toBe(false) + expect(isDingWorkflowStartedOnOrAfterCutoff('2026-04-01T00:00:00.000Z')).toBe(true) expect(isDingWorkflowStartedOnOrAfterCutoff('invalid')).toBe(false) }) @@ -92,6 +100,15 @@ describe('ding workflow snapshot sync semantics', () => { expect(isAllowedDingWorkflowProcessCode(null)).toBe(false) }) + 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, '9fb8_32e7')).toBe('内容主管分配任务') + expect(getDingWorkflowActivityNodeName(contentCode, 'a021_1e2e')).toBe('编导审核') + expect(getDingWorkflowActivityNodeName(contentCode, '39ba_efb1')).toBe('编导审核') + expect(getDingWorkflowActivityNodeName(contentCode, '6c43_575f')).toBe('内容主管审核') + }) + it('upgrades the legacy workflow-only cursor to a three-table cursor', () => { expect(parseDingWorkflowSourceCursor('2026-08-26 19:42:36')).toEqual({ version: 3, diff --git a/lib/ding-workflow-sync.ts b/lib/ding-workflow-sync.ts index 947ae12..ae5adc5 100644 --- a/lib/ding-workflow-sync.ts +++ b/lib/ding-workflow-sync.ts @@ -71,6 +71,80 @@ const ACTIVITY_NODE_NAMES_BY_PROCESS_CODE: Readonly= range.start.getTime() && finishedAtMs < range.end.getTime() @@ -1258,14 +1345,14 @@ function buildSnapshotPayload( flowType: clean(row.flowType), joinedAtRaw: clean(row.joinedAtRaw), sourceCreatedAtRaw, - sourceUpdatedAtRaw: clean(row.sourceUpdatedAtRaw), + sourceUpdatedAtRaw: sourceWorkflowTimestampToIso(row.sourceUpdatedAtRaw), sourceFinishedAtRaw: sourceWorkflowTimestampToIso(row.sourceFinishedAtRaw), isOngoing: ongoing, schemeFieldCount: nonNegativeInteger(scheme?.schemeFieldCount), taskCount: nonNegativeInteger(task?.taskCount), activeTaskCount: ongoing ? nonNegativeInteger(task?.activeTaskCount) : 0, latestTaskStatus: clean(task?.latestTaskStatus)?.toUpperCase() || null, - latestTaskAtRaw: clean(task?.latestTaskAtRaw), + latestTaskAtRaw: sourceWorkflowTimestampToIso(task?.latestTaskAtRaw), brand, demandType, requesterName, @@ -1618,8 +1705,8 @@ function mapSnapshotListItem(row: { activeTasks: tasks.filter((task) => task.isActive), originatorUserId: row.originatorUserId, originatorDeptName: row.originatorDeptName, - sourceCreatedAt: sourceWorkflowTimestampToIso(row.sourceCreatedAtRaw), - sourceUpdatedAt: sourceWorkflowTimestampToIso(row.sourceUpdatedAtRaw), + sourceCreatedAt: storedWorkflowTimestampToIso(row.sourceCreatedAtRaw), + sourceUpdatedAt: storedWorkflowTimestampToIso(row.sourceUpdatedAtRaw), activeTaskCount: row.activeTaskCount, taskCount: row.taskCount, schemeFieldCount: row.schemeFieldCount, @@ -1762,7 +1849,7 @@ async function readMonthlyLaunchInsightsWithoutAccessCheck(now = new Date()): Pr ), launchStatus, result: row.result, - sourceFinishedAt: sourceWorkflowTimestampToIso(row.sourceFinishedAtRaw), + sourceFinishedAt: storedWorkflowTimestampToIso(row.sourceFinishedAtRaw), }) const items = [ ...completedRows.map((row) => mapMonthlyItem(row, 'completed')), @@ -1838,8 +1925,8 @@ async function readOnTimeDeliveryInsightsWithoutAccessCheck(now = new Date()): P const items = rows.map((row): OnTimeDeliveryWorkflowItem => { const processCode = normalizeDingWorkflowProcessCode(row.processCode) || 'UNKNOWN' const allTasks = normalizeWorkflowTasks(row.activeTasks) - const sourceCreatedAt = sourceWorkflowTimestampToIso(row.sourceCreatedAtRaw) - const sourceFinishedAt = sourceWorkflowTimestampToIso(row.sourceFinishedAtRaw) + const sourceCreatedAt = storedWorkflowTimestampToIso(row.sourceCreatedAtRaw) + const sourceFinishedAt = storedWorkflowTimestampToIso(row.sourceFinishedAtRaw) return { ...mapSnapshotListItem( row,