fix: 完整同步并管理考核花名册人员

This commit is contained in:
chenpei 2026-07-28 17:20:38 +08:00
parent 0974ffb387
commit 1970c0f644
4 changed files with 291 additions and 269 deletions

View File

@ -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({

View File

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

View File

@ -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: {

View File

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