feat: автоматический пересчёт среднего для history-number в sandbox

- setFieldValue для history-number: переданное число пишется в историю, значением поля становится среднее за текущий месяц (автопересчёт)
- ctx.users.recalcFieldAverage(userId, fieldCodeOrId): принудительный пересчёт среднего за месяц (для schedule-автоматизаций; нет записей — поле очищается)
- документация обновлена (Automations.tsx, mcp.ts)
This commit is contained in:
2026-09-04 20:09:27 +03:00
parent 4d65049e02
commit 6aececeb4a
2 changed files with 80 additions and 19 deletions

View File

@@ -30,6 +30,7 @@ const ALLOWED_STORAGE_METHODS = new Set([
'getUsersByOrganization',
'updateUser',
'setUserProfileFieldValueFromAutomation',
'recalcUserFieldAverageFromAutomation',
]);
interface WorkerStorageCall {

View File

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