Перейти к содержимому

Кейсы работы с сущностями Portal

Практические сценарии написания кода: списки, библиотеки, пользователи, связи между модулями. Примеры основаны на демо-модулях из репозитория — их можно собрать и установить как .portalmod.

Пошаговые гайды: работа с узлами, работа со списками, сценарии Event Receiver.

См. также: Значения полей списков, Функциональные модули, SDK provisioner.

КонтекстSDKКлючи полей при записиПрава
Provisioner (установка .portalmod)Portal.Module.Sdkinternal_name (title, status…)Системные (установка модуля)
WebPart (страница)Portal.WebPart.SdkUUID полей из GetFieldsAsyncТекущий пользователь страницы
Event Receiver (событие списка)Portal.EventReceiver.SdkUUID полейПользователь события
Timer Job (расписание)Portal.TimerJob.SdkUUID полейrunAsUserId из config
REST API (внешняя система)HTTPUUID полейТокен / сессия с нужными правами

Паттерн WebPart / Event Receiver / Timer Job / REST: сначала GetFieldsAsync → словарь internal_name → fieldId → читать/писать field_values[fieldId].

Паттерн Provisioner: писать сразу по internal_name — платформа сама сопоставит поля схемы списка.


Задача: при установке модуля создать подразделения с родительскими связями, затем сотрудников со ссылками на подразделение и должность.

Где: C# provisioner модуля «Адресная книга».

Источник: AddressBookProvisioner.cs

// 1. Создать корневое подразделение, получить id последнего элемента
var hqId = await CreateDepartment(ctx, departmentsListId, "Головной офис", null, null, ct);
var itId = await CreateDepartment(ctx, departmentsListId, "ИТ", hqId, "Головной офис", ct);
// 2. Lookup-поле parent — объект { itemId, display }
private static async Task<Guid> CreateDepartment(..., Guid? parentId, string? parentTitle, ...)
{
var fields = new Dictionary<string, object?> { ["title"] = title };
if (parentId is not null)
{
fields["parent"] = new Dictionary<string, object?>
{
["itemId"] = parentId.Value.ToString(),
["display"] = parentTitle ?? title,
};
}
await ctx.Services.CreateListItemAsync(listId, fields, ct);
var ids = await ctx.Services.GetListItemIdsOrderedAsync(listId, ct);
return ids[^1]; // id только что созданного элемента
}
// 3. Сотрудник со ссылками на справочники
await ctx.Services.CreateListItemAsync(employeesListId, new Dictionary<string, object?>
{
["title"] = "Анна Иванова",
["full_name"] = "Анна Иванова",
["department"] = Lookup(itId, "ИТ"),
["position"] = Lookup(devId, "Разработчик"),
["birth_date"] = "1990-03-15",
}, ct);

Сущности: списки departments, positions, employees; поля lookup, date, text.

Идempotентность: перед созданием проверяйте GetListItemCountAsync > 0 — повторная установка не дублирует данные.


Кейс 2. Демо-данные с пользователями и полем person

Заголовок раздела «Кейс 2. Демо-данные с пользователями и полем person»

Задача: создать локальных пользователей и задачи с исполнителем, статусом и диапазоном дат.

Где: provisioner модуля «Задачи».

Источник: TasksProvisioner.cs

var listId = await ctx.Services.GetListIdAsync("lists.tasks", ct);
// Локальные пользователи (идемпотентно)
var userId = await ctx.Services.EnsureLocalUserAsync(
"demo.ivanova", "Анна Иванова", "ivanova@portal.local", ct);
// Поле person (один объект допустим; при allowMultiple: true хранится как массив)
await ctx.Services.CreateListItemAsync(listId, new Dictionary<string, object?>
{
["title"] = "Подготовить отчёт",
["description"] = "Сводка по итогам квартала",
["status"] = "В работе",
["priority"] = "Средний",
["start_at"] = DateOnly.FromDateTime(DateTime.Now).ToString("yyyy-MM-dd"),
["due_at"] = DateOnly.FromDateTime(DateTime.Now.AddDays(1)).ToString("yyyy-MM-dd"),
["assigned_to"] = new Dictionary<string, object?>
{
["principalType"] = "user",
["id"] = userId.ToString(),
["display"] = "Анна Иванова",
},
}, ct);

По умолчанию у новых person-полей allowMultiple: true — значение в API обычно массив субъектов. Один объект при создании по-прежнему принимается.

Сущности: список user-tasks; поля choice, date, person; пользователи Portal.


Кейс 3. Загрузка файлов в библиотеку из архива модуля

Заголовок раздела «Кейс 3. Загрузка файлов в библиотеку из архива модуля»

Задача: при установке положить PNG-баннеры в библиотеку документов узла.

Где: provisioner модуля «Баннеры».

Источник: BannersProvisioner.cs

var libraryId = await ctx.Services.GetLibraryIdAsync("libraries.bannerImages", ct);
// Файл из ZIP-архива .portalmod (каталог seed/ внутри пакета)
var bytes = ReadModuleFile(ctx, "seed/banners/banner-welcome.png");
if (bytes is { Length: > 0 })
{
await ctx.Services.UploadLibraryFileAsync(
libraryId, "banner-welcome.png", bytes, "image/png", ct);
}

Сущности: библиотека banner-images; файлы в seed/ внутри .portalmod.

WebPart «Баннеры» затем читает библиотеку по bannersLibraryId из Property Pane (задаётся в module.json через $ref).


Задача: отобразить оргструктуру — загрузить все подразделения, построить дерево, показать сотрудников выбранного отдела.

Где: WebPart «Адресная книга».

Источник: AddressBookWebPart.cs

// listId приходит из Property Pane (tasksListId / departmentsListId)
var config = await LoadConfigAsync(ctx, ct); // проверка Guid из ctx.Properties
var items = await ctx.Lists.GetItemsAsync(config.EmployeesListId, limit: 500, ct);
foreach (var item in items)
{
var id = PortalItemJson.GetItemGuid(item);
var dept = ReadLookupField(item, config.DepartmentFieldId);
var name = ReadTextFieldById(item, config.FullNameFieldId);
// … построение HTML
}

Паттерн: один раз загрузить GetFieldsAsync → сохранить UUID полей в конфиге WebPart → читать field_values[fieldId].


Кейс 5. WebPart: создание элемента из формы (HandleActionAsync)

Заголовок раздела «Кейс 5. WebPart: создание элемента из формы (HandleActionAsync)»

Задача: пользователь кликает слот на таймлайне → заполняет форму → создаётся задача в списке.

Где: WebPart «Задачи».

Источник: TasksWebPart.cs — метод HandleCreateTaskAsync

public async Task<WebPartResult> HandleActionAsync(string action, JsonElement data, IWebPartContext ctx, CancellationToken ct)
{
if (action != "createTask") return ...;
var config = await LoadConfigAsync(ctx, ct); // UUID полей из GetFieldsAsync
var fieldValues = new Dictionary<string, object?>
{
[config.TitleFieldId] = title,
[config.StatusFieldId] = "Новая",
[config.StartFieldId] = start.ToString("yyyy-MM-dd"),
[config.AssignedToFieldId] = new Dictionary<string, object?>
{
["principalType"] = "user",
["id"] = userId.ToString(),
["display"] = displayName,
},
};
await ctx.Lists.CreateItemAsync(config.TasksListId, fieldValues, ct);
return await RenderTimelineAsync(ctx, config, ...); // перерисовать UI
}

Сущности: список задач; действие из HTML через data-wp-action="createTask".


Кейс 6. Два связанных списка: бронирование с проверкой конфликта

Заголовок раздела «Кейс 6. Два связанных списка: бронирование с проверкой конфликта»

Задача: справочник «Комнаты» + список «Брони». WebPart показывает таймлайн, перед созданием проверяет пересечение интервалов.

Где: provisioner + WebPart «Переговорные».

Источник: MeetingRoomProvisioner.cs, MeetingRoomBookingWebPart.cs

Provisioner — lookup на комнату и datetime:

await ctx.Services.CreateListItemAsync(bookingsListId, new Dictionary<string, object?>
{
["title"] = "Стендап команды",
["room"] = Lookup(roomA, "Переговорная «Альфа»"),
["start_at"] = start.ToString("O"), // ISO 8601 для datetime
["end_at"] = end.ToString("O"),
["booked_by"] = Person(adminId, "Администратор"),
}, ct);

WebPart — проверка конфликта в памяти:

var bookings = await LoadBookingsAsync(ctx, config, date, ct);
if (HasConflict(bookings, roomId, start, end))
{
return await RenderTimelineAsync(..., formError: "Переговорная уже занята...", ct);
}
// Создание с lookup на комнату (UUID полей)
[fieldValues] = {
[config.RoomFieldId] = new { itemId = roomId.ToString(), display = room.Title },
[config.StartFieldId] = start.ToString("O"),
...
};
await ctx.Lists.CreateItemAsync(config.BookingsListId, fieldValues, ct);
private static bool HasConflict(..., DateTime start, DateTime end) =>
bookings.Any(b => b.RoomItemId == roomId && b.Start < end && start < b.End);

Сущности: списки rooms, bookings; поля lookup, datetime, person.


Кейс 7. Зависимость модулей: изменение чужого списка

Заголовок раздела «Кейс 7. Зависимость модулей: изменение чужого списка»

Задача: модуль «Дни рождения» не создаёт список сотрудников — он использует employees из уже установленной «Адресной книги» и обновляет поле birth_date.

Где: provisioner с requires: ["demo.address-book@>=1.0.0"].

Источник: BirthdaysProvisioner.cs

// UUID списка из другого модуля (доступен после requires)
var employeesRef = ctx.Services.TryGetRef("modules.demo.address-book.lists.employees.id");
if (employeesRef is null || !Guid.TryParse(employeesRef, out var employeesListId))
{
ctx.Log.Warn("Список employees не найден — установите address-book первым");
return;
}
var itemIds = await ctx.Services.GetListItemIdsOrderedAsync(employeesListId, ct);
await ctx.Services.UpdateListItemAsync(employeesListId, itemIds[0], new Dictionary<string, object?>
{
["birth_date"] = $"{year}-{today.Month:D2}-{today.Day:D2}",
}, ct);

Сущности: cross-module $refmodules.{moduleId}.lists.{key}.id; порядок установки важен.


Кейс 8. Event Receiver: реакция на изменение элемента

Заголовок раздела «Кейс 8. Event Receiver: реакция на изменение элемента»

Задача: при обновлении элемента в целевом списке — залогировать и перечитать актуальное состояние (или запустить бизнес-логику).

Где: .portalevent, привязка к списку через config listId.

Источник: ListChangeHandler.cs

public async Task<object?> HandleAsync(EventContext context, IReceiverApi api, CancellationToken ct)
{
var configuredListId = GetConfigGuid(context.ReceiverConfig, "listId");
var eventListId = GetDetailsGuid(context.Details, "listId");
// Обрабатываем только «свой» список
if (configuredListId != Guid.Empty && eventListId != configuredListId)
return new { skipped = true };
if (context.EventName == "listItem.updated")
{
var itemRef = context.Details.GetProperty("item").GetProperty("id").GetRawText();
await api.Lists.GetItemAsync(eventListId.ToString(), itemRef, ct);
// … UpdateItemAsync, постановка job и т.д.
}
return new { ok = true };
}

События: listItem.created, listItem.updated, listItem.deleted.

Event Receiver можно объявить в module.json модуля — при установке .portalmod привязка создаётся автоматически.


Кейс 9. Timer Job: периодическая обработка списка

Заголовок раздела «Кейс 9. Timer Job: периодическая обработка списка»

Задача: каждую ночь найти просроченные задачи и перевести статус в «Просрочена».

Где: .portaltimer, config экземпляра с runAsUserId и tasksListId.

public async Task<object?> ExecuteAsync(TimerJobContext context, ITimerJobApi api, CancellationToken ct)
{
var listId = context.Config.GetProperty("tasksListId").GetString()!;
var fields = (JsonElement)await api.Lists.GetFieldsAsync(listId, ct);
var statusFieldId = FieldId(fields, "status");
var dueFieldId = FieldId(fields, "due_at");
var items = (JsonElement)await api.Lists.GetItemsAsync(listId, limit: 1000, ct: ct);
var updated = 0;
foreach (var item in items.EnumerateArray())
{
var dueRaw = item.GetProperty("field_values").GetProperty(dueFieldId).GetString();
if (DateOnly.TryParse(dueRaw, out var due) && due < DateOnly.FromDateTime(DateTime.Today))
{
var itemId = item.GetProperty("id").GetRawText();
await api.Lists.UpdateItemAsync(listId, itemId, new Dictionary<string, object?>
{
[statusFieldId] = "Просрочена",
}, ct);
updated++;
}
}
return new { updated };
}

Сущности: Timer Job instance; права задаются через runAsUserId (нужен пользователь с edit на список).


Кейс 10. REST: интеграция внешней системы (ERP → Portal)

Заголовок раздела «Кейс 10. REST: интеграция внешней системы (ERP → Portal)»

См. также: Внешние интеграции.

Задача: nightly job ERP создаёт или обновляет элемент справочника «Контрагенты» в Portal.

# 1. Получить UUID полей (один раз, кешировать)
GET /api/v1/lists/{listId}/fields
Authorization: Bearer …
# 2. Найти контрагента по ИНН
GET /api/v1/lists/{listId}/items?filter={"logic":"and","conditions":[{"column":"inn","operator":"eq","value":"7707083893"}]}&limit=1
# 3a. Создать, если нет
POST /api/v1/lists/{listId}/items
Content-Type: application/json
{
"fieldValues": {
"uuid-title": "ООО Ромашка",
"uuid-inn": "7707083893",
"uuid-status": "Активен"
}
}
# 3b. Или обновить (merge — только изменённые поля)
PATCH /api/v1/lists/{listId}/items/42
Content-Type: application/json
{
"fieldValues": {
"uuid-status": "Архив"
}
}

Сущности: REST API; фильтр по internal_name; запись по UUID полей.


Кейс 11. Связка module.json + код: передача id в WebPart

Заголовок раздела «Кейс 11. Связка module.json + код: передача id в WebPart»

Задача: при установке модуля страница уже содержит WebPart с привязкой к списку — без ручной настройки в UI.

Где: module.jsonpages[].zonesContent.

Источник: packages/modules/tasks/module.json

"pages": [
{
"slug": "home",
"node": { "$ref": "nodes.tasks" },
"zonesContent": [[{
"type": "webpart",
"definitionKey": "demo.tasks",
"properties": {
"tasksListId": { "$ref": "lists.tasks.id" },
"defaultViewMode": "week"
}
}]]
}
]

При установке $ref заменяется на UUID — WebPart сразу получает tasksListId в Property Pane.


СущностьProvisionerWebPartEvent ReceiverREST
Список (lists)GetListIdAsyncctx.Lists.*api.Lists.*/lists/{id}/items
БиблиотекаGetLibraryIdAsync, UploadLibraryFileAsyncчерез API файлов/libraries/{id}/files
Узелсоздаётся из module.jsonctx.Nodes.*api.Nodes.*/nodes
ПользовательEnsureLocalUserAsyncctx.Userиз payload события/admin/users
Праваpermissions в manifestпроверка ACL автоматическиот user события/permissions
МодульКейсы
demo.address-book1, 4 — иерархия lookup, чтение в WebPart
demo.tasks2, 5, 11 — person, создание из UI, pages
demo.meeting-room-booking6 — два списка, datetime, конфликты
demo.banners3 — библиотека + seed
demo.birthdays7 — cross-module requires

Скачать готовые .portalmod: korport.ru/modules.