fix: 修正内容流程节点与时效时间
This commit is contained in:
parent
c3cb493a1b
commit
6d5d5c871e
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user