Рейтинг сотрудников: history-number поле = последняя оценка, среднее за месяц — в поле options.avgFieldCode

- setUserProfileFieldValueFromAutomation: для history-number значением поля становится переданное число (последняя оценка), а не среднее
- recomputeUserFieldMonthlyAverage: среднее за текущий месяц пишется в поле, указанное в options.avgFieldCode history-number поля (если не задано — в само поле, обратная совместимость)
- Автоматизация «Оценка сотрудникам» (форма 11): сумма трёх числовых полей «Оценка 1/2/3» вместо одного select-поля
- Документация sandbox обновлена во всех четырёх местах (UI, MCP, Obsidian, AGENTS.md)
This commit is contained in:
2026-09-06 11:55:15 +03:00
parent a7a14d947a
commit ec86fa4c11
3 changed files with 42 additions and 17 deletions

View File

@@ -84,11 +84,12 @@ const CODE_TEMPLATE = `// ======================================================
// ctx.users.setFieldValue(id, fieldCodeOrId, value) — записать значение кастомного поля профиля // ctx.users.setFieldValue(id, fieldCodeOrId, value) — записать значение кастомного поля профиля
// пользователя (по id или code поля, с аудитом от имени «Автоматизация»). Для полей типа // пользователя (по id или code поля, с аудитом от имени «Автоматизация»). Для полей типа
// «history-number» переданное число добавляется в историю user_field_history // «history-number» переданное число добавляется в историю user_field_history
// (с привязкой к задаче триггера), а значением поля становится среднее за текущий месяц // (с привязкой к задаче триггера) и становится значением самого поля (последняя оценка);
// (автопересчёт); нечисловое значение вызовет ошибку автоматизации. // среднее за текущий месяц автопересчитывается в поле, указанное в options.avgFieldCode
// history-number поля (если не задано — в само поле); нечисловое значение вызовет ошибку автоматизации.
// ctx.users.recalcFieldAverage(id, fieldCodeOrId) — принудительный пересчёт среднего // ctx.users.recalcFieldAverage(id, fieldCodeOrId) — принудительный пересчёт среднего
// за текущий месяц для history-number поля (например, ежедневно по schedule; // за текущий месяц для history-number поля (например, ежедневно по schedule;
// нет записей в месяце — поле очищается). // нет записей в месяце — целевое поле среднего очищается).
// //
// Триггеры: // Триггеры:
// manual — запуск вручную // manual — запуск вручную
@@ -323,11 +324,12 @@ export function AutomationsContent() {
ctx.users.list() / ctx.users.get(userId) ctx.users.list() / ctx.users.get(userId)
ctx.users.update(userId, updates) / ctx.users.updateStatus(userId, statusId) ctx.users.update(userId, updates) / ctx.users.updateStatus(userId, statusId)
ctx.users.setFieldValue(userId, fieldCodeOrId, value) — записать кастомное поле профиля ctx.users.setFieldValue(userId, fieldCodeOrId, value) — записать кастомное поле профиля
(с аудитом; для полей «history-number» число пишется в историю user_field_history, (с аудитом; для полей «history-number» число пишется в историю user_field_history
а значением поля становится среднее за текущий месяц — автопересчёт; и в само поле (последняя оценка), а среднее за текущий месяц автопересчитывается
в поле из options.avgFieldCode history-number поля (если не задано — в само поле);
нечисловое значение — ошибка автоматизации) нечисловое значение — ошибка автоматизации)
ctx.users.recalcFieldAverage(userId, fieldCodeOrId) — принудительный пересчёт среднего ctx.users.recalcFieldAverage(userId, fieldCodeOrId) — принудительный пересчёт среднего
за текущий месяц для history-number поля (нет записей в месяце — поле очищается) за текущий месяц для history-number поля (нет записей в месяце — целевое поле среднего очищается)
Логирование: ctx.log(msg) Логирование: ctx.log(msg)

View File

@@ -2433,8 +2433,8 @@ Users:
- ctx.users.get(userId) — get one user - ctx.users.get(userId) — get one user
- ctx.users.update(userId, updates) — update user fields (firstName, lastName, position, statusId, etc.) - ctx.users.update(userId, updates) — update user fields (firstName, lastName, position, statusId, etc.)
- ctx.users.updateStatus(userId, statusId) — change user status - ctx.users.updateStatus(userId, statusId) — change user status
- ctx.users.setFieldValue(userId, fieldCodeOrId, value) — set a user profile custom field value by field id or code (with audit as 'Автоматизация'). For fields of type 'history-number' the numeric value is appended to user_field_history (linked to the trigger task) and the field value is set to the current-month average (auto-recalculated); a non-numeric value throws an error. - ctx.users.setFieldValue(userId, fieldCodeOrId, value) — set a user profile custom field value by field id or code (with audit as 'Автоматизация'). For fields of type 'history-number' the numeric value is appended to user_field_history (linked to the trigger task) and becomes the field value itself (last rating); the current-month average is auto-recalculated into the field specified in the history-number field's options.avgFieldCode (if not set — into the same field); a non-numeric value throws an error.
- ctx.users.recalcFieldAverage(userId, fieldCodeOrId) — recalculate the current-month average of a history-number field from user_field_history (use from schedule automations for monthly reset; clears the field if no entries this month). - ctx.users.recalcFieldAverage(userId, fieldCodeOrId) — recalculate the current-month average of a history-number field from user_field_history (use from schedule automations for monthly reset; clears the target average field if no entries this month).
Trigger types: 'manual', 'task.before_create', 'task.created', 'task.status_changed', 'qr.scan', 'schedule' Trigger types: 'manual', 'task.before_create', 'task.created', 'task.status_changed', 'qr.scan', 'schedule'
TriggerConfig for task.before_create / task.created / task.status_changed: { formId: number, statusId?: number } — statusId applies only to task.status_changed and limits firing to transitions INTO that status TriggerConfig for task.before_create / task.created / task.status_changed: { formId: number, statusId?: number } — statusId applies only to task.status_changed and limits firing to transitions INTO that status

View File

@@ -151,7 +151,8 @@ export class UserProfileStorage extends UsersStorage {
throw new Error(`Поле профиля "${fieldCodeOrId}" не найдено`); throw new Error(`Поле профиля "${fieldCodeOrId}" не найдено`);
} }
// Для history-number: переданное число идёт в историю, значением поля становится среднее за текущий месяц // Для history-number: переданное число идёт в историю и становится значением поля (последняя оценка);
// среднее за месяц пересчитывается отдельно — в поле, указанном в options.avgFieldCode (или в само поле)
let storedValue = value; let storedValue = value;
if (field.type === 'history-number') { if (field.type === 'history-number') {
const num = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.').trim()); const num = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.').trim());
@@ -165,7 +166,8 @@ export class UserProfileStorage extends UsersStorage {
value: String(num), value: String(num),
taskId: taskId ?? null, taskId: taskId ?? null,
}); });
storedValue = await this.recomputeUserFieldMonthlyAverage(userId, field.id, organizationId); storedValue = num;
await this.recomputeUserFieldMonthlyAverage(userId, field.id, organizationId);
} }
const existing = await this.getUserProfileFieldValue(userId, field.id); const existing = await this.getUserProfileFieldValue(userId, field.id);
@@ -190,10 +192,20 @@ export class UserProfileStorage extends UsersStorage {
} }
/** /**
* Пересчитывает значение history-number поля как среднее за текущий календарный месяц. * Пересчитывает среднее за текущий календарный месяц по истории history-number поля.
* Если в месяце нет записей — поле очищается (null). Возвращает новое значение. * Среднее пишется в поле, указанное в options.avgFieldCode самого history-number поля
* (например «Оценка» → «Средняя оценка»); если avgFieldCode не задан — в само history-number поле.
* Если в месяце нет записей — целевое поле очищается (null). Возвращает новое среднее.
*/ */
async recomputeUserFieldMonthlyAverage(userId: number, fieldId: number, organizationId: number) { async recomputeUserFieldMonthlyAverage(userId: number, fieldId: number, organizationId: number) {
const [field] = await db.select().from(userProfileFields).where(and(
eq(userProfileFields.id, fieldId),
eq(userProfileFields.organizationId, organizationId),
));
if (!field) {
throw new Error(`Поле профиля ${fieldId} не найдено`);
}
const [row] = await db const [row] = await db
.select({ avg: sql<number | null>`ROUND(AVG(${userFieldHistory.value})::numeric, 2)::float8` }) .select({ avg: sql<number | null>`ROUND(AVG(${userFieldHistory.value})::numeric, 2)::float8` })
.from(userFieldHistory) .from(userFieldHistory)
@@ -205,24 +217,35 @@ export class UserProfileStorage extends UsersStorage {
)); ));
const avg = row?.avg != null ? Number(row.avg) : null; const avg = row?.avg != null ? Number(row.avg) : null;
const existing = await this.getUserProfileFieldValue(userId, fieldId); // Целевое поле для среднего: из options.avgFieldCode, иначе — само history-number поле
let targetFieldId = fieldId;
const avgFieldCode = (field.options as { avgFieldCode?: string } | null)?.avgFieldCode;
if (avgFieldCode) {
const target = await this.getUserProfileFieldByCode(avgFieldCode, organizationId);
if (!target) {
throw new Error(`Поле для среднего "${avgFieldCode}" (options.avgFieldCode поля "${field.name}") не найдено`);
}
targetFieldId = target.id;
}
const existing = await this.getUserProfileFieldValue(userId, targetFieldId);
if (existing) { if (existing) {
if (existing.value !== avg) { if (existing.value !== avg) {
await this.updateUserProfileFieldValue(userId, fieldId, { value: avg as UserProfileFieldValue['value'] }); await this.updateUserProfileFieldValue(userId, targetFieldId, { value: avg as UserProfileFieldValue['value'] });
await this.createUserProfileAuditLog({ await this.createUserProfileAuditLog({
userId, userId,
organizationId, organizationId,
action: 'field.changed', action: 'field.changed',
fieldId, fieldId: targetFieldId,
oldValue: existing.value ?? null, oldValue: existing.value ?? null,
newValue: avg != null ? String(avg) : null, newValue: avg != null ? String(avg) : null,
changedBy: null, changedBy: null,
changedByName: 'Автоматизация', changedByName: 'Автоматизация',
metadata: { source: 'automation', reason: 'monthly-average-recalc' }, metadata: { source: 'automation', reason: 'monthly-average-recalc', historyFieldId: fieldId },
}); });
} }
} else if (avg != null) { } else if (avg != null) {
await this.createUserProfileFieldValue({ userId, fieldId, value: avg as InsertUserProfileFieldValue['value'] }); await this.createUserProfileFieldValue({ userId, fieldId: targetFieldId, value: avg as InsertUserProfileFieldValue['value'] });
} }
return avg; return avg;
} }