diff --git a/server/routes/automation.routes.ts b/server/routes/automation.routes.ts index 1029c57..a546855 100644 --- a/server/routes/automation.routes.ts +++ b/server/routes/automation.routes.ts @@ -30,6 +30,7 @@ const ALLOWED_STORAGE_METHODS = new Set([ 'getUsersByOrganization', 'updateUser', 'setUserProfileFieldValueFromAutomation', + 'recalcUserFieldAverageFromAutomation', ]); interface WorkerStorageCall { diff --git a/server/storage/user-profile.storage.ts b/server/storage/user-profile.storage.ts index a0a55ea..abecada 100644 --- a/server/storage/user-profile.storage.ts +++ b/server/storage/user-profile.storage.ts @@ -151,25 +151,8 @@ export class UserProfileStorage extends UsersStorage { throw new Error(`Поле профиля "${fieldCodeOrId}" не найдено`); } - const existing = await this.getUserProfileFieldValue(userId, field.id); - const saved = existing - ? await this.updateUserProfileFieldValue(userId, field.id, { value: value as UserProfileFieldValue['value'] }) - : await this.createUserProfileFieldValue({ userId, fieldId: field.id, value: value as InsertUserProfileFieldValue['value'] }); - - await this.createUserProfileAuditLog({ - userId, - organizationId, - action: 'field.changed', - fieldId: field.id, - fieldName: field.name, - oldValue: existing?.value ?? null, - newValue: typeof value === 'string' ? value : value != null ? JSON.stringify(value) : null, - changedBy: null, - changedByName: 'Автоматизация', - metadata: { source: 'automation', taskId: taskId ?? null }, - }); - - // Для полей с историей дополнительно пишем числовое значение в user_field_history + // Для history-number: переданное число идёт в историю, значением поля становится среднее за текущий месяц + let storedValue = value; if (field.type === 'history-number') { const num = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.').trim()); if (!Number.isFinite(num)) { @@ -182,11 +165,88 @@ export class UserProfileStorage extends UsersStorage { value: String(num), taskId: taskId ?? null, }); + storedValue = await this.recomputeUserFieldMonthlyAverage(userId, field.id, organizationId); } + const existing = await this.getUserProfileFieldValue(userId, field.id); + const saved = existing + ? await this.updateUserProfileFieldValue(userId, field.id, { value: storedValue as UserProfileFieldValue['value'] }) + : await this.createUserProfileFieldValue({ userId, fieldId: field.id, value: storedValue as InsertUserProfileFieldValue['value'] }); + + await this.createUserProfileAuditLog({ + userId, + organizationId, + action: 'field.changed', + fieldId: field.id, + fieldName: field.name, + oldValue: existing?.value ?? null, + newValue: typeof storedValue === 'string' ? storedValue : storedValue != null ? JSON.stringify(storedValue) : null, + changedBy: null, + changedByName: 'Автоматизация', + metadata: { source: 'automation', taskId: taskId ?? null }, + }); + return saved; } + /** + * Пересчитывает значение history-number поля как среднее за текущий календарный месяц. + * Если в месяце нет записей — поле очищается (null). Возвращает новое значение. + */ + async recomputeUserFieldMonthlyAverage(userId: number, fieldId: number, organizationId: number) { + const [row] = await db + .select({ avg: sql`ROUND(AVG(${userFieldHistory.value})::numeric, 2)::float8` }) + .from(userFieldHistory) + .where(and( + eq(userFieldHistory.userId, userId), + eq(userFieldHistory.fieldId, fieldId), + eq(userFieldHistory.organizationId, organizationId), + sql`${userFieldHistory.createdAt} >= date_trunc('month', now())`, + )); + const avg = row?.avg != null ? Number(row.avg) : null; + + const existing = await this.getUserProfileFieldValue(userId, fieldId); + if (existing) { + if (existing.value !== avg) { + await this.updateUserProfileFieldValue(userId, fieldId, { value: avg as UserProfileFieldValue['value'] }); + await this.createUserProfileAuditLog({ + userId, + organizationId, + action: 'field.changed', + fieldId, + oldValue: existing.value ?? null, + newValue: avg != null ? String(avg) : null, + changedBy: null, + changedByName: 'Автоматизация', + metadata: { source: 'automation', reason: 'monthly-average-recalc' }, + }); + } + } else if (avg != null) { + await this.createUserProfileFieldValue({ userId, fieldId, value: avg as InsertUserProfileFieldValue['value'] }); + } + return avg; + } + + /** + * ctx.users.recalcFieldAverage: принудительный пересчёт среднего за месяц для history-number поля. + */ + async recalcUserFieldAverageFromAutomation(userId: number, fieldCodeOrId: string | number, organizationId: number) { + const field = typeof fieldCodeOrId === 'number' + ? await this.getUserProfileFieldById(fieldCodeOrId, organizationId) + : await this.getUserProfileFieldByCode(String(fieldCodeOrId), organizationId); + if (!field) { + throw new Error(`Поле профиля "${fieldCodeOrId}" не найдено`); + } + if (field.type !== 'history-number') { + throw new Error(`Пересчёт среднего доступен только для полей типа history-number, у поля "${field.name}" тип ${field.type}`); + } + const user = await this.getUser(userId); + if (!user || user.organizationId !== organizationId) { + throw new Error(`Пользователь ${userId} не найден в организации`); + } + return this.recomputeUserFieldMonthlyAverage(userId, field.id, organizationId); + } + async bulkSaveUserProfileFields( organizationId: number, fieldIdsToDelete: number[],