fix: 完整同步并管理考核花名册人员
This commit is contained in:
parent
0974ffb387
commit
1970c0f644
@ -6,6 +6,7 @@ import {
|
||||
comparePerformanceSurveyEmployeeSyncPreview,
|
||||
previewPerformanceSurveyEmployeeSync,
|
||||
syncPerformanceSurveyEmployeeByIdentity,
|
||||
syncPerformanceSurveyEmployees,
|
||||
upsertPerformanceSurveyEmployeeProfileForSync,
|
||||
} from '@/lib/performance-survey-report-sync'
|
||||
|
||||
@ -255,6 +256,31 @@ describe('performance survey employee sync preview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('只读预检会纳入没有素材库账号的在职花名册人员', () => {
|
||||
const result = comparePerformanceSurveyEmployeeSyncPreview({
|
||||
identities: [],
|
||||
rows: [{
|
||||
dingtalkUserId: 'ding-xiangzhang',
|
||||
jobNumber: 'JS06002901',
|
||||
contactName: '香樟',
|
||||
realName: '马震宇',
|
||||
departmentName: '品牌设计部',
|
||||
positionTitle: '品牌设计师',
|
||||
positionLevel: 'P6',
|
||||
employmentStatus: '试用',
|
||||
}],
|
||||
existingProfiles: [],
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
fetched: 1,
|
||||
matchedAccounts: 0,
|
||||
created: 1,
|
||||
fallback: 0,
|
||||
plannedProfileWrites: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('未配置上游时只返回兜底预检,且不会写入本地数据库', async () => {
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
|
||||
id: 'user-1',
|
||||
@ -384,6 +410,89 @@ describe('performance survey employee sync preview', () => {
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('整批正式同步会写入没有素材库账号的在职花名册人员', async () => {
|
||||
process.env.REPORT_DB_SERVER = 'report.example.test'
|
||||
process.env.REPORT_DB_DATABASE = 'report'
|
||||
process.env.REPORT_DB_USER = 'readonly'
|
||||
process.env.REPORT_DB_PASSWORD = 'secret'
|
||||
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-xiangzhang' })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({
|
||||
performanceSurveyEmployeeProfile: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: profileCreate,
|
||||
update: jest.fn(),
|
||||
},
|
||||
performanceSurveyEmployeeDepartmentMembership: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
}))
|
||||
|
||||
const pool = {
|
||||
request: jest.fn(() => {
|
||||
const inputs = new Map<string, unknown>()
|
||||
const request: {
|
||||
input: jest.Mock
|
||||
query: jest.Mock
|
||||
} = {
|
||||
input: jest.fn(),
|
||||
query: jest.fn(),
|
||||
}
|
||||
request.input.mockImplementation((name: string, ...args: unknown[]) => {
|
||||
inputs.set(name, args[args.length - 1])
|
||||
return request
|
||||
})
|
||||
request.query.mockImplementation(async (query: string) => {
|
||||
if (query.includes('INFORMATION_SCHEMA.COLUMNS')) {
|
||||
const table = inputs.get('table')
|
||||
return {
|
||||
recordset: table === 'hr_employee'
|
||||
? ['userid', 'email', 'job_number', 'name', 'title', 'active']
|
||||
.map((COLUMN_NAME) => ({ COLUMN_NAME }))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
if (query.includes('[rpt].[hr_employee]')) {
|
||||
return {
|
||||
recordset: [{
|
||||
dingtalkUserId: 'ding-xiangzhang',
|
||||
jobNumber: 'JS06002901',
|
||||
contactName: '香樟',
|
||||
contactTitle: '品牌设计师',
|
||||
}],
|
||||
}
|
||||
}
|
||||
throw new Error(`未预期的查询:${query}`)
|
||||
})
|
||||
return request
|
||||
}),
|
||||
close: jest.fn(),
|
||||
}
|
||||
;(mssql.connect as jest.Mock).mockResolvedValue(pool)
|
||||
|
||||
await expect(syncPerformanceSurveyEmployees({ limit: 3000 })).resolves.toMatchObject({
|
||||
scannedAccounts: 0,
|
||||
scannedProfiles: 1,
|
||||
upserted: 1,
|
||||
fallback: 0,
|
||||
failed: 0,
|
||||
})
|
||||
expect(profileCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
dingtalkUserId: 'ding-xiangzhang',
|
||||
jobNumber: 'JS06002901',
|
||||
displayName: '香樟',
|
||||
positionTitle: '品牌设计师',
|
||||
}),
|
||||
})
|
||||
expect(pool.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('单人正式同步会保存全部所属部门和钉钉身份字段,且不映射为系统权限', async () => {
|
||||
process.env.REPORT_DB_SERVER = 'report.example.test'
|
||||
process.env.REPORT_DB_DATABASE = 'report'
|
||||
@ -555,6 +664,11 @@ describe('performance survey employee sync preview', () => {
|
||||
dingtalkUserId: 'ding-huai-de',
|
||||
jobNumber: null,
|
||||
dingEmail: 'huai.de@example.com',
|
||||
sourcePayload: {
|
||||
source: 'JSReportReplica.rpt',
|
||||
materialUserId: 'material-user-huai-de',
|
||||
materialUserEmail: 'huai.de@example.com',
|
||||
},
|
||||
createdAt: new Date('2026-07-01T00:00:00.000Z'),
|
||||
}
|
||||
const jobNumberProfile = {
|
||||
@ -661,6 +775,11 @@ describe('performance survey employee sync preview', () => {
|
||||
flowerName: '怀德',
|
||||
realName: '吾正鹏',
|
||||
displayName: '怀德',
|
||||
sourcePayload: expect.objectContaining({
|
||||
source: 'JSReportReplica.rpt',
|
||||
materialUserId: 'material-user-huai-de',
|
||||
materialUserEmail: 'huai.de@example.com',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(membershipDeleteMany).toHaveBeenCalledWith({
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
assertPerformanceSurveyPositionPlanPreviewMatches,
|
||||
createPerformanceSurveyManualPositionAssignments,
|
||||
getPerformanceSurveyPositionAdminConfig,
|
||||
listPerformanceSurveyManualPositionAssignments,
|
||||
listPerformanceSurveyPositionGroups,
|
||||
removePerformanceSurveyTemplate,
|
||||
savePerformanceSurveyPositionGroupDraft,
|
||||
@ -39,6 +40,9 @@ jest.mock('@/lib/prisma', () => ({
|
||||
performanceSurveyEmployeeProfile: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
performanceSurveyEmployeePositionAssignment: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
},
|
||||
}))
|
||||
@ -216,6 +220,45 @@ describe('performance survey position admin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('管理员人员分配可搜索没有素材库账号的在职花名册人员', async () => {
|
||||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([{
|
||||
id: 'employee-xiangzhang',
|
||||
displayName: '香樟',
|
||||
flowerName: '香樟',
|
||||
realName: '马震宇',
|
||||
jobNumber: 'JS06002901',
|
||||
departmentName: '品牌设计部',
|
||||
departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌设计部',
|
||||
positionTitle: '品牌设计师',
|
||||
positionLevel: 'P6',
|
||||
levelType: 'P',
|
||||
isLeadership: false,
|
||||
isDingtalkAdmin: false,
|
||||
isDingtalkBoss: false,
|
||||
isDingtalkSenior: false,
|
||||
departmentMemberships: [],
|
||||
}])
|
||||
;(prisma.performanceSurveyPositionGroup.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.performanceSurveyEmployeePositionAssignment.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const params = new URLSearchParams({
|
||||
month: '2026-07',
|
||||
search: '香樟',
|
||||
})
|
||||
await expect(listPerformanceSurveyManualPositionAssignments(params)).resolves.toMatchObject({
|
||||
total: 1,
|
||||
items: [{
|
||||
employeeId: 'employee-xiangzhang',
|
||||
displayName: '香樟',
|
||||
}],
|
||||
})
|
||||
|
||||
const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0]
|
||||
expect(JSON.stringify(query.where)).toContain('employmentStatus')
|
||||
expect(JSON.stringify(query.where)).not.toContain('materialUserId')
|
||||
expect(JSON.stringify(query.where)).not.toContain('materialUserEmail')
|
||||
})
|
||||
|
||||
test('模板管理配置只查询模板,不扫描岗位和人员档案', async () => {
|
||||
;(prisma.performanceSurveyTemplate.findMany as jest.Mock).mockResolvedValue([{
|
||||
id: 'template-1',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getPerformanceSurveyAccountProfileWhere } from '@/lib/performance-survey-report-sync'
|
||||
import { getActivePerformanceSurveyEmployeeProfileWhere } from '@/lib/performance-survey-report-sync'
|
||||
import {
|
||||
DEFAULT_PERFORMANCE_SURVEY_TEMPLATES,
|
||||
PERFORMANCE_SURVEY_VISUAL_TEMPLATE_VARIANTS,
|
||||
@ -977,7 +977,7 @@ export async function getPerformanceSurveyPositionAdminConfig(
|
||||
include: { versions: { orderBy: { version: 'desc' } } },
|
||||
}),
|
||||
prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: await getPerformanceSurveyAccountProfileWhere(),
|
||||
where: getActivePerformanceSurveyEmployeeProfileWhere(),
|
||||
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
|
||||
include: {
|
||||
departmentMemberships: {
|
||||
@ -1410,7 +1410,7 @@ async function assertPositionPlanHasNoConflicts(
|
||||
const [groups, employees] = await Promise.all([
|
||||
listPerformanceSurveyPositionGroups(month),
|
||||
prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: await getPerformanceSurveyAccountProfileWhere(),
|
||||
where: getActivePerformanceSurveyEmployeeProfileWhere(),
|
||||
include: {
|
||||
departmentMemberships: {
|
||||
orderBy: [{ isPrimary: 'desc' }, { departmentPath: 'asc' }],
|
||||
@ -2240,7 +2240,12 @@ export async function createPerformanceSurveyManualPositionAssignments(input: {
|
||||
const normalized = normalizeManualPositionAssignmentInput(input)
|
||||
await assertManualAssignmentPositionGroup(normalized.positionGroupId)
|
||||
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: { id: { in: employeeIds } },
|
||||
where: {
|
||||
AND: [
|
||||
getActivePerformanceSurveyEmployeeProfileWhere(),
|
||||
{ id: { in: employeeIds } },
|
||||
],
|
||||
},
|
||||
select: { id: true, displayName: true },
|
||||
})
|
||||
if (employees.length !== employeeIds.length) throw new PerformanceSurveyPositionConfigError('部分人员不存在或已不可用')
|
||||
@ -2392,11 +2397,10 @@ export async function listPerformanceSurveyManualPositionAssignments(params?: UR
|
||||
throw new PerformanceSurveyPositionConfigError('人员分配状态筛选无效')
|
||||
}
|
||||
|
||||
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
|
||||
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
accountWhere,
|
||||
getActivePerformanceSurveyEmployeeProfileWhere(),
|
||||
...(employeeId ? [{ id: employeeId }] : []),
|
||||
...(search ? [{
|
||||
OR: [
|
||||
@ -2535,7 +2539,7 @@ export async function previewPerformanceSurveyPositionPlanImpact(input: {
|
||||
const [currentGroups, employees] = await Promise.all([
|
||||
listPerformanceSurveyPositionGroups(month),
|
||||
prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: await getPerformanceSurveyAccountProfileWhere(),
|
||||
where: getActivePerformanceSurveyEmployeeProfileWhere(),
|
||||
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
|
||||
include: {
|
||||
departmentMemberships: {
|
||||
|
||||
@ -779,6 +779,12 @@ function buildRegisterListSql(registerColumns: TableColumns, whereSql?: string)
|
||||
managerName: pickColumn(registerColumns, FIELD_CANDIDATES.managerName),
|
||||
}
|
||||
|
||||
const whereParts = [
|
||||
notInactiveStatusCondition('r', r.employmentStatus),
|
||||
whereSql ? `(${whereSql})` : null,
|
||||
].filter(Boolean)
|
||||
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
|
||||
|
||||
return `
|
||||
SELECT TOP (@limit)
|
||||
${columnExpr('r', r.dingtalkUserId)} AS "dingtalkUserId",
|
||||
@ -798,7 +804,7 @@ function buildRegisterListSql(registerColumns: TableColumns, whereSql?: string)
|
||||
${columnExpr('r', r.managerJobNumber)} AS "managerJobNumber",
|
||||
${columnExpr('r', r.managerName)} AS "managerName"
|
||||
FROM ${quoteIdentifier(REGISTER_TABLE.schema)}.${quoteIdentifier(REGISTER_TABLE.table)} r
|
||||
${whereSql ? `WHERE ${whereSql}` : ''}
|
||||
${whereClause}
|
||||
ORDER BY COALESCE(${columnExpr('r', r.flowerName)}, ${columnExpr('r', r.registerName)}, ${columnExpr('r', r.jobNumber)}, ${columnExpr('r', r.dingtalkUserId)}) ASC
|
||||
`
|
||||
}
|
||||
@ -992,84 +998,6 @@ function getIdentityProfileMatchScore(identity: ReportIdentity, profile: ReturnT
|
||||
return getIdentityNames(identity).some((name) => profileNames.has(name.toLowerCase())) ? 1 : 0
|
||||
}
|
||||
|
||||
function getAccountScopedValues(identities: ReportIdentity[]) {
|
||||
return {
|
||||
dingtalkUserIds: uniqueClean(identities.map((item) => item.dingtalkUserId)),
|
||||
emails: uniqueClean(identities.map((item) => item.email)),
|
||||
names: uniqueClean(identities.flatMap((item) => getIdentityNames(item))),
|
||||
}
|
||||
}
|
||||
|
||||
function bindAccountScopedValues(
|
||||
request: any,
|
||||
sql: typeof import('mssql'),
|
||||
values: ReturnType<typeof getAccountScopedValues>,
|
||||
) {
|
||||
values.dingtalkUserIds.forEach((value, index) => request.input(`ding${index}`, sql.NVarChar(200), value))
|
||||
values.emails.forEach((value, index) => request.input(`email${index}`, sql.NVarChar(320), value))
|
||||
values.names.forEach((value, index) => request.input(`name${index}`, sql.NVarChar(200), value))
|
||||
}
|
||||
|
||||
function inCondition(alias: string, column: string | null, prefix: string, count: number) {
|
||||
if (!column || count === 0) return null
|
||||
const params = Array.from({ length: count }, (_, index) => `@${prefix}${index}`).join(', ')
|
||||
return `${alias}.${quoteIdentifier(column)} IN (${params})`
|
||||
}
|
||||
|
||||
function buildAccountScopedSelectWhere(
|
||||
schema: Awaited<ReturnType<typeof getReportSchema>>,
|
||||
values: ReturnType<typeof getAccountScopedValues>,
|
||||
) {
|
||||
const e = {
|
||||
dingtalkUserId: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||||
email: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.email),
|
||||
name: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.name),
|
||||
}
|
||||
const r = {
|
||||
dingtalkUserId: pickColumn(schema.registerColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||||
email: pickColumn(schema.registerColumns, FIELD_CANDIDATES.email),
|
||||
flowerName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.flowerName),
|
||||
realName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.registerRealName),
|
||||
registerName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.registerName),
|
||||
}
|
||||
const f = {
|
||||
flowerName: pickColumn(schema.flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName),
|
||||
name: pickColumn(schema.flowerColumns, FIELD_CANDIDATES.flowerBookName),
|
||||
}
|
||||
const conditions = [
|
||||
inCondition('e', e.dingtalkUserId, 'ding', values.dingtalkUserIds.length),
|
||||
inCondition('r', r.dingtalkUserId, 'ding', values.dingtalkUserIds.length),
|
||||
inCondition('e', e.email, 'email', values.emails.length),
|
||||
inCondition('r', r.email, 'email', values.emails.length),
|
||||
inCondition('e', e.name, 'name', values.names.length),
|
||||
inCondition('r', r.flowerName, 'name', values.names.length),
|
||||
inCondition('r', r.realName, 'name', values.names.length),
|
||||
inCondition('r', r.registerName, 'name', values.names.length),
|
||||
inCondition('f', f.flowerName, 'name', values.names.length),
|
||||
inCondition('f', f.name, 'name', values.names.length),
|
||||
].filter(Boolean)
|
||||
|
||||
return conditions.length > 0 ? `(${conditions.join(' OR ')})` : '1 = 0'
|
||||
}
|
||||
|
||||
function buildAccountScopedAliasWhere(
|
||||
alias: string,
|
||||
columns: {
|
||||
dingtalkUserId?: string | null
|
||||
email?: string | null
|
||||
names?: Array<string | null>
|
||||
},
|
||||
values: ReturnType<typeof getAccountScopedValues>,
|
||||
) {
|
||||
const conditions = [
|
||||
inCondition(alias, columns.dingtalkUserId || null, 'ding', values.dingtalkUserIds.length),
|
||||
inCondition(alias, columns.email || null, 'email', values.emails.length),
|
||||
...(columns.names || []).map((column) => inCondition(alias, column, 'name', values.names.length)),
|
||||
].filter(Boolean)
|
||||
|
||||
return conditions.length > 0 ? `(${conditions.join(' OR ')})` : '1 = 0'
|
||||
}
|
||||
|
||||
function getIndexed(index: Map<string, ReportEmployeeRow>, ...keys: unknown[]) {
|
||||
for (const key of keys) {
|
||||
const normalized = normalizedKey(key)
|
||||
@ -1153,121 +1081,6 @@ async function fetchFullSyncRows(
|
||||
return applyResolvedDepartmentPaths(pool, mergedRows.slice(0, limit))
|
||||
}
|
||||
|
||||
async function fetchAccountScopedSyncRows(
|
||||
pool: any,
|
||||
sql: typeof import('mssql'),
|
||||
schema: Awaited<ReturnType<typeof getReportSchema>>,
|
||||
identities: ReportIdentity[],
|
||||
limit: number,
|
||||
) {
|
||||
const values = getAccountScopedValues(identities)
|
||||
const scopedLimit = Math.max(limit, identities.length * 3, 100)
|
||||
const bindInputs = (request: any) => bindAccountScopedValues(request, sql, values)
|
||||
|
||||
const employeeColumns = {
|
||||
dingtalkUserId: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||||
email: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.email),
|
||||
name: pickColumn(schema.employeeColumns, FIELD_CANDIDATES.name),
|
||||
}
|
||||
const registerColumns = {
|
||||
dingtalkUserId: pickColumn(schema.registerColumns, FIELD_CANDIDATES.dingtalkUserId),
|
||||
email: pickColumn(schema.registerColumns, FIELD_CANDIDATES.email),
|
||||
flowerName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.flowerName),
|
||||
realName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.registerRealName),
|
||||
registerName: pickColumn(schema.registerColumns, FIELD_CANDIDATES.registerName),
|
||||
}
|
||||
const flowerColumns = {
|
||||
flowerName: pickColumn(schema.flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName),
|
||||
name: pickColumn(schema.flowerColumns, FIELD_CANDIDATES.flowerBookName),
|
||||
}
|
||||
|
||||
const employeeRows = await queryReportRows(
|
||||
pool,
|
||||
sql,
|
||||
buildEmployeeListSql(
|
||||
schema.employeeColumns,
|
||||
buildAccountScopedAliasWhere('e', {
|
||||
dingtalkUserId: employeeColumns.dingtalkUserId,
|
||||
email: employeeColumns.email,
|
||||
names: [employeeColumns.name],
|
||||
}, values),
|
||||
),
|
||||
scopedLimit,
|
||||
bindInputs,
|
||||
)
|
||||
const registerRows = schema.registerColumns.size > 0
|
||||
? await queryReportRows(
|
||||
pool,
|
||||
sql,
|
||||
buildRegisterListSql(
|
||||
schema.registerColumns,
|
||||
buildAccountScopedAliasWhere('r', {
|
||||
dingtalkUserId: registerColumns.dingtalkUserId,
|
||||
email: registerColumns.email,
|
||||
names: [registerColumns.flowerName, registerColumns.realName, registerColumns.registerName],
|
||||
}, values),
|
||||
),
|
||||
scopedLimit,
|
||||
bindInputs,
|
||||
)
|
||||
: []
|
||||
const flowerRows = schema.flowerColumns.size > 0
|
||||
? await queryReportRows(
|
||||
pool,
|
||||
sql,
|
||||
buildFlowerListSql(
|
||||
schema.flowerColumns,
|
||||
buildAccountScopedAliasWhere('f', {
|
||||
names: [flowerColumns.flowerName, flowerColumns.name],
|
||||
}, values),
|
||||
),
|
||||
scopedLimit,
|
||||
bindInputs,
|
||||
)
|
||||
: []
|
||||
|
||||
const registerByUserId = indexRows(registerRows, ['dingtalkUserId'])
|
||||
const registerByJobNumber = indexRows(registerRows, ['registerJobNumber', 'jobNumber'])
|
||||
const registerByName = indexRows(registerRows, ['flowerName', 'realName', 'registerName'])
|
||||
const flowerByJobNumber = indexRows(flowerRows, ['registerJobNumber', 'jobNumber'])
|
||||
const flowerByName = indexRows(flowerRows, ['flowerBookFlowerName', 'flowerBookName'])
|
||||
|
||||
const mergedRows: ReportEmployeeRow[] = []
|
||||
const seen = new Set<string>()
|
||||
const pushMerged = (row: ReportEmployeeRow) => {
|
||||
const normalized = normalizeEmployeeRow(row)
|
||||
const accountMatched = getNormalizedProfileMatchKeys(normalized)
|
||||
.some((key) => identities.some((identity) => getIdentityMatchKeys(identity).includes(key)))
|
||||
if (!accountMatched) return
|
||||
|
||||
const key = normalized.dingtalkUserId || normalized.jobNumber || normalized.dingEmail || normalized.displayName
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
mergedRows.push(row)
|
||||
}
|
||||
|
||||
for (const employee of employeeRows) {
|
||||
const register = getIndexed(registerByUserId, employee.dingtalkUserId)
|
||||
|| getIndexed(registerByJobNumber, employee.jobNumber)
|
||||
|| getIndexed(registerByName, employee.contactName)
|
||||
const flower = getIndexed(flowerByJobNumber, register?.registerJobNumber, employee.jobNumber)
|
||||
|| getIndexed(flowerByName, register?.flowerName, register?.realName, register?.registerName, employee.contactName)
|
||||
pushMerged(mergeReportRows(employee, register, flower))
|
||||
}
|
||||
|
||||
for (const register of registerRows) {
|
||||
const flower = getIndexed(flowerByJobNumber, register.registerJobNumber, register.jobNumber)
|
||||
|| getIndexed(flowerByName, register.flowerName, register.realName, register.registerName)
|
||||
pushMerged(mergeReportRows(register, flower))
|
||||
}
|
||||
|
||||
for (const flower of flowerRows) {
|
||||
pushMerged(flower)
|
||||
}
|
||||
|
||||
return applyResolvedDepartmentPaths(pool, mergedRows.slice(0, scopedLimit))
|
||||
}
|
||||
|
||||
function buildIdentityWhere(
|
||||
employeeColumns: TableColumns,
|
||||
registerColumns: TableColumns,
|
||||
@ -1441,6 +1254,30 @@ function getSourcePayloadLink(payload: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeExistingMaterialAccountLink(payload: unknown, existingPayload?: unknown) {
|
||||
const merged = payload && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? { ...(payload as Record<string, unknown>) }
|
||||
: {}
|
||||
const incomingLink = getSourcePayloadLink(merged)
|
||||
const existingLink = getSourcePayloadLink(existingPayload)
|
||||
|
||||
if (!incomingLink.materialUserId && existingLink.materialUserId) {
|
||||
merged.materialUserId = existingLink.materialUserId
|
||||
}
|
||||
if (!incomingLink.materialUserEmail && existingLink.materialUserEmail) {
|
||||
merged.materialUserEmail = existingLink.materialUserEmail
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function getNormalizedProfileSyncKey(profile: ReturnType<typeof normalizeEmployeeRow>) {
|
||||
return clean(profile.dingtalkUserId)?.toLowerCase()
|
||||
|| clean(profile.jobNumber)?.toLowerCase()
|
||||
|| clean(profile.dingEmail)?.toLowerCase()
|
||||
|| `name:${clean(profile.displayName)?.toLowerCase() || 'unknown'}`
|
||||
}
|
||||
|
||||
function previewValueEquals(left: unknown, right: unknown) {
|
||||
if (typeof left === 'boolean' || typeof right === 'boolean') {
|
||||
return Boolean(left) === Boolean(right)
|
||||
@ -1620,7 +1457,13 @@ export function comparePerformanceSurveyEmployeeSyncPreview(input: {
|
||||
return
|
||||
}
|
||||
|
||||
const fields = hasBusinessProfileChanges(existing, proposed)
|
||||
const fields = hasBusinessProfileChanges(existing, {
|
||||
...proposed,
|
||||
sourcePayload: mergeExistingMaterialAccountLink(
|
||||
proposed.sourcePayload,
|
||||
existing.sourcePayload,
|
||||
),
|
||||
})
|
||||
if (fields.length === 0) {
|
||||
unchanged += 1
|
||||
return
|
||||
@ -1632,26 +1475,39 @@ export function comparePerformanceSurveyEmployeeSyncPreview(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const materialIdentityByProfileKey = new Map<string, ReportIdentity & { userId?: string }>()
|
||||
for (const [index, match] of Array.from(bestMatchesByIdentity.entries())) {
|
||||
const identity = identities[index]
|
||||
const proposed: PreviewProfileData = {
|
||||
...match.normalized,
|
||||
sourcePayload: {
|
||||
...(match.normalized.sourcePayload || {}),
|
||||
materialUserId: identity.userId || null,
|
||||
materialUserEmail: clean(identity.email),
|
||||
},
|
||||
const profileKey = getNormalizedProfileSyncKey(match.normalized)
|
||||
if (!materialIdentityByProfileKey.has(profileKey)) {
|
||||
materialIdentityByProfileKey.set(profileKey, identity)
|
||||
}
|
||||
const profileKey = getProposedProfileMatchKeys(proposed)[0]
|
||||
|| `name:${clean(proposed.displayName)?.toLowerCase() || index}`
|
||||
matchedIdentityIndexes.add(index)
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const normalized = normalizeEmployeeRow(row)
|
||||
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) {
|
||||
continue
|
||||
}
|
||||
const profileKey = getNormalizedProfileSyncKey(normalized)
|
||||
if (plannedProfileKeys.has(profileKey)) {
|
||||
deduplicated += 1
|
||||
matchedIdentityIndexes.add(index)
|
||||
continue
|
||||
}
|
||||
|
||||
plannedProfileKeys.add(profileKey)
|
||||
matchedIdentityIndexes.add(index)
|
||||
const identity = materialIdentityByProfileKey.get(profileKey)
|
||||
const proposed: PreviewProfileData = identity
|
||||
? {
|
||||
...normalized,
|
||||
sourcePayload: {
|
||||
...(normalized.sourcePayload || {}),
|
||||
materialUserId: identity.userId || null,
|
||||
materialUserEmail: clean(identity.email),
|
||||
},
|
||||
}
|
||||
: normalized
|
||||
classify(proposed)
|
||||
}
|
||||
|
||||
@ -1785,15 +1641,22 @@ export async function upsertPerformanceSurveyEmployeeProfileForSync(
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const existing = await resolveEmployeeProfileIdentityInTransaction(tx, data)
|
||||
const profileId = existing?.id || randomUUID()
|
||||
const profileWriteData = {
|
||||
...profileData,
|
||||
sourcePayload: mergeExistingMaterialAccountLink(
|
||||
profileData.sourcePayload,
|
||||
existing?.sourcePayload,
|
||||
) as any,
|
||||
}
|
||||
const profile = existing
|
||||
? await tx.performanceSurveyEmployeeProfile.update({
|
||||
where: { id: existing.id },
|
||||
data: profileData,
|
||||
data: profileWriteData,
|
||||
})
|
||||
: await tx.performanceSurveyEmployeeProfile.create({
|
||||
data: {
|
||||
id: profileId,
|
||||
...profileData,
|
||||
...profileWriteData,
|
||||
},
|
||||
})
|
||||
|
||||
@ -1829,11 +1692,10 @@ function getParentDepartmentName(departmentName?: string | null, departmentPath?
|
||||
}
|
||||
|
||||
async function upsertDepartments() {
|
||||
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
|
||||
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
accountWhere,
|
||||
getActivePerformanceSurveyEmployeeProfileWhere(),
|
||||
{ departmentName: { not: null } },
|
||||
],
|
||||
},
|
||||
@ -2153,7 +2015,7 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n
|
||||
const { sql, pool } = await getReportPool()
|
||||
try {
|
||||
const schema = await getReportSchema(pool)
|
||||
rows = await fetchAccountScopedSyncRows(pool, sql, schema, identities, limit)
|
||||
rows = await fetchFullSyncRows(pool, sql, schema, limit)
|
||||
} finally {
|
||||
await pool.close()
|
||||
}
|
||||
@ -2170,13 +2032,16 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n
|
||||
return !latest || profile.syncedAt > latest ? profile.syncedAt : latest
|
||||
}, null)
|
||||
const warnings = [
|
||||
'这是只读预检:只读取素材库账号、本地人员档案和上游花名册,不会写入或停用任何人员。',
|
||||
'这是只读预检:只读取素材库账号、本地人员档案和完整在职花名册,不会写入或停用任何人员。',
|
||||
'系统尚未记录“整批同步成功时间”;last.latestLocalProfileSyncedAt 仅表示最近一条本地人员档案的同步时间。',
|
||||
'现有正式同步不会把“本次上游未返回”直接视为离职或停用,因此停用人数无法可靠推断。',
|
||||
]
|
||||
|
||||
if (totalAccounts > identities.length) {
|
||||
warnings.push(`本次按上限 ${limit} 预检了 ${identities.length}/${totalAccounts} 个素材库账号,结果不是全量。`)
|
||||
warnings.push(`本次只读取了 ${identities.length}/${totalAccounts} 个素材库账号用于建立账号关联;花名册人员仍按上限独立读取。`)
|
||||
}
|
||||
if (configured && rows.length >= limit) {
|
||||
warnings.push(`本次花名册读取达到上限 ${limit} 人,可能不是完整人员范围,请提高同步上限后重试。`)
|
||||
}
|
||||
if (!configured) {
|
||||
warnings.push('上游花名册数据库未配置;正式同步只会使用素材库账号建立或更新兜底档案。')
|
||||
@ -2216,7 +2081,8 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n
|
||||
requestedLimit: limit,
|
||||
totalAccounts,
|
||||
scannedAccounts: identities.length,
|
||||
truncated: totalAccounts > identities.length,
|
||||
scannedProfiles: rows.length,
|
||||
truncated: configured ? rows.length >= limit : totalAccounts > identities.length,
|
||||
previewStartedAt: startedAt.toISOString(),
|
||||
previewCompletedAt: completedAt.toISOString(),
|
||||
durationMs: completedAt.getTime() - startedAt.getTime(),
|
||||
@ -2237,7 +2103,7 @@ export async function previewPerformanceSurveyEmployeeSync(options?: { limit?: n
|
||||
deactivation: {
|
||||
status: 'unsupported',
|
||||
count: null,
|
||||
reason: '当前同步范围仅覆盖素材库现有账号,且正式同步没有“上游缺失即停用”的规则,无法可靠计算停用人数。',
|
||||
reason: '正式同步不会把“本次上游未返回”直接视为离职或停用,无法仅根据一次读取可靠计算停用人数。',
|
||||
},
|
||||
changedFields: comparison.changedFields,
|
||||
warnings,
|
||||
@ -2256,6 +2122,7 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number
|
||||
invalidatePerformanceSurveyAccountProfileWhereCache()
|
||||
return {
|
||||
scannedAccounts: identities.length,
|
||||
scannedProfiles: 0,
|
||||
upserted: 0,
|
||||
fallback: identities.length,
|
||||
skipped: 0,
|
||||
@ -2269,73 +2136,52 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number
|
||||
|
||||
try {
|
||||
const schema = await getReportSchema(pool)
|
||||
const rows = await fetchAccountScopedSyncRows(pool, sql, schema, identities, limit)
|
||||
const identityIndexesByKey = new Map<string, number[]>()
|
||||
identities.forEach((identity, index) => {
|
||||
for (const key of getIdentityMatchKeys(identity)) {
|
||||
const indexes = identityIndexesByKey.get(key) || []
|
||||
indexes.push(index)
|
||||
identityIndexesByKey.set(key, indexes)
|
||||
}
|
||||
})
|
||||
const rows = await fetchFullSyncRows(pool, sql, schema, limit)
|
||||
const { bestMatchesByIdentity } = matchNormalizedRowsToIdentities(identities, rows)
|
||||
|
||||
let upserted = 0
|
||||
let fallback = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
const matchedIdentityIndexes = new Set<number>()
|
||||
const bestMatchesByIdentity = new Map<number, {
|
||||
normalized: ReturnType<typeof normalizeEmployeeRow>
|
||||
score: number
|
||||
}>()
|
||||
const materialIdentityByProfileKey = new Map<string, ReportIdentity & { userId?: string }>()
|
||||
for (const [index, match] of Array.from(bestMatchesByIdentity.entries())) {
|
||||
const profileKey = getNormalizedProfileSyncKey(match.normalized)
|
||||
if (!materialIdentityByProfileKey.has(profileKey)) {
|
||||
materialIdentityByProfileKey.set(profileKey, identities[index])
|
||||
}
|
||||
matchedIdentityIndexes.add(index)
|
||||
}
|
||||
|
||||
const syncedProfileKeys = new Set<string>()
|
||||
for (const row of rows) {
|
||||
const normalized = normalizeEmployeeRow(row)
|
||||
if (!normalized.dingtalkUserId && !normalized.jobNumber && !normalized.dingEmail) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateIndexes = new Set<number>()
|
||||
for (const key of getNormalizedProfileMatchKeys(normalized)) {
|
||||
for (const index of identityIndexesByKey.get(key) || []) {
|
||||
candidateIndexes.add(index)
|
||||
}
|
||||
}
|
||||
|
||||
for (const index of Array.from(candidateIndexes)) {
|
||||
const score = getIdentityProfileMatchScore(identities[index], normalized)
|
||||
if (score <= 0) continue
|
||||
const current = bestMatchesByIdentity.get(index)
|
||||
if (!current || score > current.score) {
|
||||
bestMatchesByIdentity.set(index, { normalized, score })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const syncedProfileKeys = new Set<string>()
|
||||
for (const [index, match] of Array.from(bestMatchesByIdentity.entries())) {
|
||||
const normalized = match.normalized
|
||||
const identity = identities[index]
|
||||
;(normalized as any).sourcePayload = {
|
||||
...(normalized.sourcePayload || {}),
|
||||
materialUserId: identity.userId || null,
|
||||
materialUserEmail: clean(identity.email),
|
||||
}
|
||||
const profileKey = normalized.dingtalkUserId || normalized.jobNumber || normalized.dingEmail || normalized.displayName
|
||||
const profileKey = getNormalizedProfileSyncKey(normalized)
|
||||
if (syncedProfileKeys.has(profileKey)) {
|
||||
matchedIdentityIndexes.add(index)
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
syncedProfileKeys.add(profileKey)
|
||||
|
||||
const identity = materialIdentityByProfileKey.get(profileKey)
|
||||
if (identity) {
|
||||
;(normalized as any).sourcePayload = {
|
||||
...(normalized.sourcePayload || {}),
|
||||
materialUserId: identity.userId || null,
|
||||
materialUserEmail: clean(identity.email),
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await upsertPerformanceSurveyEmployeeProfileForSync(normalized)
|
||||
upserted += 1
|
||||
syncedProfileKeys.add(profileKey)
|
||||
matchedIdentityIndexes.add(index)
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
console.warn('[performance-survey] 单个人员档案同步失败,使用素材库账号兜底:', {
|
||||
console.warn('[performance-survey] 单个人员档案同步失败:', {
|
||||
dingtalkUserId: normalized.dingtalkUserId,
|
||||
dingEmail: normalized.dingEmail,
|
||||
error,
|
||||
@ -2350,8 +2196,17 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
await upsertFallbackEmployeeProfileForUser(identity)
|
||||
fallback += 1
|
||||
try {
|
||||
await upsertFallbackEmployeeProfileForUser(identity)
|
||||
fallback += 1
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
console.warn('[performance-survey] 素材库账号兜底档案同步失败:', {
|
||||
dingtalkUserId: identity.dingtalkUserId,
|
||||
email: identity.email,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const departmentCount = await upsertDepartments()
|
||||
@ -2359,6 +2214,7 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number
|
||||
|
||||
return {
|
||||
scannedAccounts: identities.length,
|
||||
scannedProfiles: rows.length,
|
||||
upserted,
|
||||
fallback,
|
||||
skipped,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user