gongxiangp2/lib/__tests__/ding-workflow-sync.test.ts
2026-08-28 10:52:44 +08:00

338 lines
15 KiB
TypeScript

import {
DING_WORKFLOW_ALLOWED_PROCESS_CODES,
DING_WORKFLOW_BRANDS,
DING_WORKFLOW_NEW_PRODUCT_DEMAND_TYPE,
DING_WORKFLOW_STARTED_AT_CUTOFF_ISO,
buildBrandCenterOngoingArtWorkflowWhere,
buildBrandCenterOngoingWorkflowWhere,
buildMonthlyLaunchOngoingWorkflowWhere,
buildMonthlyLaunchWorkflowWhere,
getShanghaiMonthRange,
getDingWorkflowActivityNodeName,
getDingWorkflowProjectType,
isAllowedDingWorkflowProcessCode,
isDingWorkflowStartedOnOrAfterCutoff,
isDingWorkflowOngoing,
isDingWorkflowCompletedInRange,
isDingWorkflowApprovedCompletion,
normalizeDingWorkflowBrand,
normalizeDingWorkflowDepartment,
normalizeDingWorkflowDetailValue,
normalizeDingWorkflowProcessCode,
parseDingWorkflowSourceCursor,
resolveDingWorkflowInitiator,
resolveDingWorkflowDetailMilestones,
serializeDingWorkflowSourceCursor,
sourceWorkflowTimestampToIso,
storedWorkflowTimestampToIso,
} from '@/lib/ding-workflow-sync'
jest.mock('mssql', () => ({
__esModule: true,
default: {},
}))
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/brand-center-access', () => ({ resolveBrandCenterAccessForUser: jest.fn() }))
describe('ding workflow snapshot sync semantics', () => {
test.each([
['RUNNING', null, true],
['NEW', '', true],
['COMPLETED', null, false],
['TERMINATED', null, false],
['CANCELED', null, false],
['RUNNING', '2026-08-20 10:30:00', false],
['', null, false],
])('status %s and finish time %s resolves ongoing=%s', (status, finishTime, expected) => {
expect(isDingWorkflowOngoing(status, finishTime)).toBe(expected)
})
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-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-31T23:59:59.999Z')).toBe(false)
expect(isDingWorkflowStartedOnOrAfterCutoff('2026-04-01T00:00:00.000Z')).toBe(true)
expect(isDingWorkflowStartedOnOrAfterCutoff('invalid')).toBe(false)
})
it('uses Shanghai calendar boundaries for monthly completion', () => {
const range = getShanghaiMonthRange(new Date('2026-08-26T03:00:00.000Z'))
expect(range.start.toISOString()).toBe('2026-07-31T16:00:00.000Z')
expect(range.end.toISOString()).toBe('2026-08-31T16:00:00.000Z')
expect(isDingWorkflowCompletedInRange('COMPLETED', '2026-08-01 00:00:00', range)).toBe(true)
expect(isDingWorkflowCompletedInRange('COMPLETED', '2026-09-01 00:00:00', range)).toBe(false)
expect(isDingWorkflowCompletedInRange('TERMINATED', '2026-08-12 12:00:00', range)).toBe(false)
expect(isDingWorkflowCompletedInRange('COMPLETED', null, range)).toBe(false)
})
it('only treats completed workflows without a rejected decision as approved completions', () => {
expect(isDingWorkflowApprovedCompletion('COMPLETED', 'AGREE', null)).toBe(true)
expect(isDingWorkflowApprovedCompletion('completed', null, 'NONE')).toBe(true)
expect(isDingWorkflowApprovedCompletion('COMPLETED', 'REFUSE', null)).toBe(false)
expect(isDingWorkflowApprovedCompletion('COMPLETED', '已拒绝', null)).toBe(false)
expect(isDingWorkflowApprovedCompletion('COMPLETED', null, 'reject')).toBe(false)
expect(isDingWorkflowApprovedCompletion('TERMINATED', 'AGREE', null)).toBe(false)
})
it('only accepts the two approved process types', () => {
expect(DING_WORKFLOW_ALLOWED_PROCESS_CODES).toEqual([
'PROC-3571C915-0506-4490-A91A-8AA7EA33F838',
'PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673',
])
expect(isAllowedDingWorkflowProcessCode(' proc-3571c915-0506-4490-a91a-8aa7ea33f838 ')).toBe(true)
expect(isAllowedDingWorkflowProcessCode('PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673')).toBe(true)
expect(isAllowedDingWorkflowProcessCode('PROC-OTHER')).toBe(false)
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,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: null,
schemeId: null,
})
})
it('round-trips the Workflow, Task and Scheme incremental cursors', () => {
const cursor = {
version: 3 as const,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: 2248339,
schemeId: 10634556,
}
expect(parseDingWorkflowSourceCursor(serializeDingWorkflowSourceCursor(cursor))).toEqual(cursor)
})
it('preserves empty Task and Scheme cursors so the next run performs alignment', () => {
const cursor = {
version: 3 as const,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: null,
schemeId: null,
}
expect(parseDingWorkflowSourceCursor(serializeDingWorkflowSourceCursor(cursor))).toEqual(cursor)
})
it('upgrades version 2 cursors and requests one full Task and Scheme alignment', () => {
expect(parseDingWorkflowSourceCursor(JSON.stringify({
version: 2,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: 2248339,
schemeId: 10634556,
}))).toEqual({
version: 3,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: null,
schemeId: null,
})
})
it('rejects malformed version 3 cursor ids and safely requests re-alignment', () => {
expect(parseDingWorkflowSourceCursor(JSON.stringify({
version: 3,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: -1,
schemeId: 'invalid',
}))).toEqual({
version: 3,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: null,
schemeId: null,
})
})
it('only queries ongoing workflows whose current assignee belongs to Brand Center', () => {
expect(buildBrandCenterOngoingWorkflowWhere()).toEqual({
isOngoing: true,
processCode: { in: [...DING_WORKFLOW_ALLOWED_PROCESS_CODES] },
sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO },
activeTasks: { array_contains: [{ isBrandCenter: true, isActive: true }] },
})
})
it('reuses the ongoing-project art-design subset for monthly launches', () => {
expect(buildBrandCenterOngoingArtWorkflowWhere()).toEqual({
isOngoing: true,
processCode: DING_WORKFLOW_ALLOWED_PROCESS_CODES[0],
sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO },
activeTasks: { array_contains: [{ isBrandCenter: true, isActive: true }] },
})
expect(buildMonthlyLaunchOngoingWorkflowWhere()).toEqual({
...buildBrandCenterOngoingArtWorkflowWhere(),
brand: { in: [...DING_WORKFLOW_BRANDS] },
demandType: DING_WORKFLOW_NEW_PRODUCT_DEMAND_TYPE,
})
})
it('only queries post-April art-design workflows for the six-brand monthly launch metric', () => {
expect(buildMonthlyLaunchWorkflowWhere()).toEqual({
processCode: 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838',
sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO },
brand: { in: [...DING_WORKFLOW_BRANDS] },
demandType: '新品',
})
})
it('normalizes process codes before persistence and comparison', () => {
expect(normalizeDingWorkflowProcessCode(' proc-ae9ff3e1-a442-4e6a-85cf-0f2b86409673 '))
.toBe('PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673')
expect(normalizeDingWorkflowProcessCode('')).toBeNull()
})
it('maps the two process codes to the dashboard task types', () => {
expect(getDingWorkflowProjectType('PROC-3571C915-0506-4490-A91A-8AA7EA33F838')).toBe('美术设计流程')
expect(getDingWorkflowProjectType('PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673')).toBe('内容视频和图片拍摄')
expect(getDingWorkflowProjectType('PROC-OTHER')).toBe('其他流程')
})
test.each([
['sid-startevent', '发起人'],
['1de3_de55', '部门负责人审核'],
['94c1_1f0d', '部门总监审核'],
['3cb8_8096', '品牌策划总监审批'],
['38da_544a', '品牌策划主管分配任务'],
['31cf_5b23', '出策划案'],
['a9e1_5be0', '出图'],
['de46_708d', '平面'],
['2f9e_36f0', 'AI视频'],
['b306_c849', '出图'],
['f073_738b', '平面'],
['6864_5429', '出图'],
['162b_23ea', '平面'],
['c9fe_a6b8', '出图/平面/AI视频'],
['c255_84b3', '品牌策划主管审核'],
['ebb9_213a', '运营接收图片并评分'],
['b426_1125', '运营接收图片并评分'],
['3e66_c105', '运营接收图片并评分'],
['64bb_b63a', '运营接收图片并评分'],
['2748_c5f9', '运营接收图片并评分'],
['45f4_e095', '运营接收图片并评分'],
['a8aa_ce41', '部门负责确认'],
['00d9_b57f', '抄送部门总监'],
])('maps art-design activity %s to %s from the supplied process definition', (activityId, expected) => {
expect(getDingWorkflowActivityNodeName(DING_WORKFLOW_ALLOWED_PROCESS_CODES[0], activityId)).toBe(expected)
})
it('does not reuse art-design activity names for other processes or unknown nodes', () => {
expect(getDingWorkflowActivityNodeName(DING_WORKFLOW_ALLOWED_PROCESS_CODES[1], '38da_544a')).toBeNull()
expect(getDingWorkflowActivityNodeName(DING_WORKFLOW_ALLOWED_PROCESS_CODES[0], 'unknown-node')).toBeNull()
expect(getDingWorkflowActivityNodeName(null, '38da_544a')).toBeNull()
})
it('normalizes approved brands and groups unmatched values as other brands', () => {
expect(normalizeDingWorkflowBrand('自有品牌-亿家达')).toBe('亿家达')
expect(normalizeDingWorkflowBrand('索菲亚')).toBe('索菲亚')
expect(normalizeDingWorkflowBrand('新品牌')).toBe('其他品牌')
expect(normalizeDingWorkflowBrand(null)).toBe('其他品牌')
})
it('normalizes DingTalk department fields before grouping requester dimensions', () => {
expect(normalizeDingWorkflowDepartment('["运营中心E"]')).toBe('运营中心E')
expect(normalizeDingWorkflowDepartment('[{"name":"品牌中心"}]')).toBe('品牌中心')
expect(normalizeDingWorkflowDepartment(['运营中心E', '运营中心E'])).toBe('运营中心E')
expect(normalizeDingWorkflowDepartment('综合支持部')).toBe('综合支持部')
expect(normalizeDingWorkflowDepartment(null)).toBeNull()
})
it('normalizes scalar, array and contact-shaped detail form values', () => {
expect(normalizeDingWorkflowDetailValue('天猫')).toBe('天猫')
expect(normalizeDingWorkflowDetailValue('["书桌","书柜"]')).toBe('书桌 / 书柜')
expect(normalizeDingWorkflowDetailValue([{ name: '亿家达' }, { name: '亿家达' }])).toBe('亿家达')
expect(normalizeDingWorkflowDetailValue(null)).toBeNull()
})
it('uses the confirmed art and content-video milestone definitions', () => {
const tasks = [
{
taskId: 'task-1', activityId: 'art-start', nodeName: '品牌策划主管分配任务',
assigneeUserId: 'user-1', assigneeName: '甲', assigneeDepartment: '品牌中心',
isBrandCenter: true, isLeadership: false, isActive: false, reviewPassed: true,
result: 'AGREE', createdAt: '2026-08-01T01:00:00.000Z', finishedAt: '2026-08-01T02:00:00.000Z',
},
{
taskId: 'task-2', activityId: 'art-end', 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',
},
]
expect(resolveDingWorkflowDetailMilestones(DING_WORKFLOW_ALLOWED_PROCESS_CODES[0], tasks)).toEqual({
assignmentNodeLabel: '品牌策划主管分配任务',
assignmentStartedAt: '2026-08-01T01:00:00.000Z',
reviewNodeLabel: '品牌策划主管审核',
reviewFinishedAt: '2026-08-02T02:00:00.000Z',
})
expect(resolveDingWorkflowDetailMilestones(DING_WORKFLOW_ALLOWED_PROCESS_CODES[1], [
{ ...tasks[0], nodeName: '内容部负责人', createdAt: '2026-08-03T01:00:00.000Z' },
{ ...tasks[0], taskId: 'task-3', nodeName: '编导', finishedAt: '2026-08-04T02:00:00.000Z' },
{ ...tasks[0], taskId: 'task-4', nodeName: '编导', finishedAt: '2026-08-05T02:00:00.000Z' },
])).toEqual({
assignmentNodeLabel: '内容主管分配任务',
assignmentStartedAt: '2026-08-03T01:00:00.000Z',
reviewNodeLabel: '编导审核',
reviewFinishedAt: '2026-08-05T02:00:00.000Z',
})
expect(resolveDingWorkflowDetailMilestones(DING_WORKFLOW_ALLOWED_PROCESS_CODES[1], [
{ ...tasks[0], nodeName: '内容部负责人', createdAt: '2026-08-06T01:00:00.000Z' },
{ ...tasks[0], taskId: 'task-5', nodeName: '编导', finishedAt: '2026-08-07T02:00:00.000Z' },
{ ...tasks[0], taskId: 'task-6', nodeName: '品牌中心负责人审核', finishedAt: '2026-08-08T02:00:00.000Z' },
])).toEqual({
assignmentNodeLabel: '内容主管分配任务',
assignmentStartedAt: '2026-08-06T01:00:00.000Z',
reviewNodeLabel: '内容主管审核',
reviewFinishedAt: '2026-08-08T02:00:00.000Z',
})
})
it('matches initiators by DingTalk roster profile and formats the HR organization path', () => {
expect(resolveDingWorkflowInitiator({
displayName: '荀瑶',
departmentName: '品类九部',
departmentPath: '江苏简尚品牌管理有限公司/运营中心A/事业四部/品类九部/淘系四组',
}, {
name: '旧需求人',
department: '旧需求部门',
})).toEqual({
name: '荀瑶',
department: '运营中心A-事业四部-品类九部-淘系四组',
})
})
it('uses the workflow snapshot when the initiator is absent from the HR roster', () => {
expect(resolveDingWorkflowInitiator(null, {
name: '林发起',
department: '[{"name":"品牌中心-品牌创意A部"}]',
})).toEqual({
name: '林发起',
department: '品牌中心-品牌创意A部',
})
})
})