fix: 修正内容流程节点与时效时间

This commit is contained in:
江林铖 2026-08-28 10:52:44 +08:00
parent c3cb493a1b
commit 6d5d5c871e
3 changed files with 145 additions and 16 deletions

View File

@ -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,

View File

@ -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,

View File

@ -71,6 +71,80 @@ const ACTIVITY_NODE_NAMES_BY_PROCESS_CODE: Readonly<Record<string, Readonly<Reco
'a8aa_ce41': '部门负责确认',
'00d9_b57f': '抄送部门总监',
},
[DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: {
'sid-startevent': '发起人',
'cc59_3e5a': '内容主管分配任务',
'9fb8_32e7': '内容主管分配任务',
'd742_3645': '内容主管分配任务',
'b9a4_c03a': '内容主管分配任务',
'9f14_7ed4': '内容主管分配任务',
'436a_c54c': '内容主管分配任务',
'fdcc_6e23': '内容主管分配任务',
'b596_d441': '内容主管分配任务',
'660b_13c2': '内容主管分配任务',
'0392_2965': '编导',
'2d94_f5f4': '编导',
'61b4_a6df': '编导',
'f4fb_5337': '编导',
'b7e6_434c': '编导',
'b454_f597': '编导',
'a25b_3158': '编导',
'3f4a_3e4a': '编导',
'ed38_970c': '编导',
'd3e1_85ec': '编导',
'27d5_92a3': '编导',
'375c_e8b0': '摄像',
'1d43_056a': '摄像',
'3078_2143': '摄像',
'f9fb_94bb': '摄像',
'003f_37a0': '摄像',
'be4c_5d3d': '摄像',
'6d09_69b9': '摄像',
'562b_2040': '摄像',
'9804_36c4': '摄像',
'663f_b63b': '摄像',
'7b9b_d818': '剪辑',
'3bde_6d5d': '剪辑',
'c372_4bc2': '剪辑',
'70e5_5548': '剪辑',
'538d_60ee': '剪辑',
'0768_9fc0': '剪辑',
'a8b6_f915': '剪辑',
'72f0_2fae': '剪辑',
'2fdb_dd98': '二次剪辑',
'238a_ba93': '二次剪辑',
'4151_7ec0': '二次剪辑',
'fe6d_4f56': '二次剪辑',
'7358_3df5': '封面',
'd027_fe1f': '封面',
'fe3d_054a': '封面',
'8a65_ac0b': '封面',
'7060_f10d': '封面',
'7190_0bdb': '封面',
'ca0a_c52d': '封面',
'3dfd_6eed': '修图',
'6b40_cb6e': '修图',
'45bd_56ad': '修图',
'7e09_6fad': '修图',
'e56b_4e05': 'AI视频制作',
'a021_1e2e': '编导审核',
'39ba_efb1': '编导审核',
'ce0a_7681': '编导审核',
'a3bb_f3fc': '编导审核',
'2e58_b875': '编导审核',
'6c43_575f': '内容主管审核',
'0abf_690f': '内容主管审核',
'36a0_2698': '内容主管审核',
'9a79_9db8': '运营验收',
'cdec_df3c': '运营验收',
'f7ff_0cd4': '运营验收',
'4073_b7af': '运营验收',
'cddb_3c2d': '运营验收',
'a03e_07f7': '策划验收',
'7f14_5f28': '策划验收',
'fa9d_aab6': '客服验收',
'5e28_391d': '预约客服安装',
},
}
const WORKFLOW_TABLE = {
@ -661,14 +735,27 @@ export function isDingWorkflowOngoing(status: unknown, finishedAt: unknown): boo
export function sourceWorkflowTimestampToIso(value: unknown): string | null {
const normalized = clean(value)
if (!normalized) return null
const hasTimeZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)
const candidate = hasTimeZone
? normalized
: `${normalized.replace(' ', 'T')}+08:00`
// 报表库三张钉钉表中的时间是上海本地墙上时间;SQL Server 驱动序列化
// datetime2 时会附加一个并不代表 UTC 的 `Z`。先移除这个伪时区标记,
// 再按 Asia/Shanghai 解释,避免页面展示和 SLA 计算整体多出 8 小时。
const workflowClockValue = normalized.replace(/Z$/i, '')
const hasNumericTimeZone = /[+-]\d{2}:?\d{2}$/.test(workflowClockValue)
const candidate = hasNumericTimeZone
? workflowClockValue
: `${workflowClockValue.replace(' ', 'T')}+08:00`
const parsed = new Date(candidate)
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
}
export function storedWorkflowTimestampToIso(value: unknown): string | null {
const normalized = clean(value)
if (!normalized) return null
const hasTimeZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)
if (!hasTimeZone) return sourceWorkflowTimestampToIso(normalized)
const parsed = new Date(normalized)
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
}
export function getShanghaiMonthRange(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
@ -690,7 +777,7 @@ export function isDingWorkflowCompletedInRange(
range: { start: Date; end: Date },
) {
if (String(status || '').trim().toUpperCase() !== 'COMPLETED') return false
const finishedAtIso = sourceWorkflowTimestampToIso(finishedAt)
const finishedAtIso = storedWorkflowTimestampToIso(finishedAt)
if (!finishedAtIso) return false
const finishedAtMs = new Date(finishedAtIso).getTime()
return finishedAtMs >= 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,