feat: автоматический пересчёт среднего для history-number в sandbox
- setFieldValue для history-number: переданное число пишется в историю, значением поля становится среднее за текущий месяц (автопересчёт) - ctx.users.recalcFieldAverage(userId, fieldCodeOrId): принудительный пересчёт среднего за месяц (для schedule-автоматизаций; нет записей — поле очищается) - документация обновлена (Automations.tsx, mcp.ts)
This commit is contained in:
@@ -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<number | null>`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[],
|
||||
|
||||
Reference in New Issue
Block a user