Рейтинг сотрудников: 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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user