311 lines
14 KiB
TypeScript
311 lines
14 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,
|
|
normalizeDingWorkflowBrand,
|
|
normalizeDingWorkflowDepartment,
|
|
normalizeDingWorkflowDetailValue,
|
|
normalizeDingWorkflowProcessCode,
|
|
parseDingWorkflowSourceCursor,
|
|
resolveDingWorkflowInitiator,
|
|
resolveDingWorkflowDetailMilestones,
|
|
serializeDingWorkflowSourceCursor,
|
|
sourceWorkflowTimestampToIso,
|
|
} 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 timestamps as Asia/Shanghai when no zone is present', () => {
|
|
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('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('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 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('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部',
|
|
})
|
|
})
|
|
})
|