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

FSM-кнопки

Сущности MineFlow (актив, персонал, план производства, заявка на ТМЦ…) живут по конечному автомату состояний. Для каждого статуса разрешён лишь ограниченный набор переходов — и UI должен показывать ровно те кнопки действий, что разрешает машина в текущем статусе.

Источник истины для этих переходов — та же xstate-машина, что у бэкенда (ADR-0022 — entity = source of truth, xstate = spec). SDK re-export'ит её на фронт без копий, поэтому FSM-логика UI не дрейфует от серверной.

Этот рецепт: как из статуса сущности получить список действий и отрисовать кнопки.

Два экспорта

  • availableActions(machine, status) — чистая функция (@mineflow/client-react): по машине и строковому статусу возвращает массив имён переходов (string[]).
  • useAvailableActions(machine, status) — React-хук-обёртка с useMemo поверх той же функции.

Машины и status-энумы берутся из @mineflow/api-schemas — единственного browser/RN-safe пакета с FSM (доменные barrel'ы @mineflow/eam и т.п. тянут NestJS/Prisma и под фронт не собираются).

Базовый пример

import { assetMachine } from '@mineflow/api-schemas';
import { useAvailableActions } from '@mineflow/client-react';

const ACTION_LABELS: Record<string, string> = {
SEND_TO_MAINTENANCE: 'На ТО',
CONSERVE: 'Законсервировать',
REACTIVATE: 'Расконсервировать',
COMPLETE_MAINTENANCE: 'Завершить ТО',
DECOMMISSION: 'Списать',
};

function AssetActions({ asset }: { asset: { id: string; status: string } }) {
// имена переходов, разрешённых машиной в текущем статусе
const actions = useAvailableActions(assetMachine, asset.status);
// для 'operational' → ['SEND_TO_MAINTENANCE', 'CONSERVE', 'DECOMMISSION']

return (
<>
{actions.map((action) => (
<button key={action} onClick={() => runAction(asset, action)}>
{ACTION_LABELS[action] ?? action}
</button>
))}
</>
);
}

Без React то же самое чистой функцией — удобно для не-компонентного кода (вычисление доступности, тесты):

import { assetMachine } from '@mineflow/api-schemas';
import { availableActions } from '@mineflow/client-react';

availableActions(assetMachine, 'maintenance'); // ['COMPLETE_MAINTENANCE', 'DECOMMISSION']
availableActions(assetMachine, 'decommissioned'); // [] — терминальный статус, кнопок нет
Какую машину передавать

Машина — параметр, а не вшита в хук. Берите подходящую из @mineflow/api-schemas: assetMachine, personnelMachine, productionPlanMachine, fuelSupplyRequestMachine, tmcRequestMachine и т.д. Статус — это строковый stateValue верхнего уровня машины (машины MineFlow плоские), он же значение поля status сущности из API. Полный список машин — в гайде @mineflow/api-schemas и в API-референсе.

Переходы возвращаются БЕЗ учёта guard'ов

availableActions отдаёт все имена переходов state-node, не вычисляя guard'ы (условия) xstate. Это намеренно:

  • UI должен предложить действие, даже если оно требует доп. ввода. Например, DECOMMISSION у актива требует double-approval — но кнопку всё равно надо показать, по клику открыть форму, и только потом отправить запрос.
  • Финальную проверку делает бэкенд (см. ниже), guard'ы на фронте дублировали бы серверную логику и неизбежно бы с ней разошлись.
Не блокируйте кнопку наличием guard'а

Наличие действия в списке availableActions означает «такой переход в принципе существует из этого статуса», а не «прямо сейчас точно пройдёт». Решение о фактической доступности (роль, заполненность полей, бизнес-инварианты) принимает сервер.

RBAC поверх FSM

availableActions отвечает только на вопрос «какие переходы разрешает машина статусов». Кто имеет право их выполнять — отдельная ось. Скрестите FSM с RBAC-гейтом useCan:

import { assetMachine } from '@mineflow/api-schemas';
import { useAvailableActions, useCan } from '@mineflow/client-react';

function AssetActions({ asset }: { asset: { id: string; status: string } }) {
const actions = useAvailableActions(assetMachine, asset.status);
const canManage = useCan(['Engineer', 'Mechanic']);

if (!canManage) return null;
return actions.map((action) => /* ... кнопки ... */);
}

Подробнее про роли и проверки прав — рецепт RBAC-гейтинг UI.

Имя FSM-события ≠ имя endpoint'а

Список availableActions — это имена FSM-событий (CONSERVE, COMPLETE_MAINTENANCE, …). Они не маппятся 1:1 на эндпоинты автоматически. Маппинг событие → вызов держат концентратные write-хуки в @mineflow/client-react, а не сам helper.

Для EAM-актива (ADR-0045, asset-centric) у каждого перехода статуса есть выделенный endpoint и хук:

FSM-событиеХукЗапрос
CONSERVEuseConserveAssetPATCH /eam/assets/:id/conserve (reason обяз.)
REACTIVATEuseReactivateAssetPATCH /eam/assets/:id/reactivate
COMPLETE_MAINTENANCEuseCompleteMaintenancePATCH /eam/assets/:id/complete-maintenance
DECOMMISSIONuseDecommissionAssetPOST /eam/assets/:id/decommission (double-approval)
import { assetMachine } from '@mineflow/api-schemas';
import {
useAvailableActions,
useConserveAsset,
useReactivateAsset,
useCompleteMaintenance,
useDecommissionAsset,
} from '@mineflow/client-react';

function AssetActions({ asset }: { asset: { id: string; status: string } }) {
const actions = useAvailableActions(assetMachine, asset.status);

const conserve = useConserveAsset();
const reactivate = useReactivateAsset();
const completeMaintenance = useCompleteMaintenance();
const decommission = useDecommissionAsset();

function run(action: string) {
switch (action) {
case 'CONSERVE':
return conserve.mutate({ id: asset.id, reason: 'Сезонная консервация' });
case 'REACTIVATE':
return reactivate.mutate({ id: asset.id });
case 'COMPLETE_MAINTENANCE':
return completeMaintenance.mutate({ id: asset.id });
case 'DECOMMISSION':
// double-approval: реально открыть форму с reason + подтверждениями
return decommission.mutate({ id: asset.id, body: { /* … */ } });
// SEND_TO_MAINTENANCE у актива идёт через журнал движений, см. note ниже
}
}

return actions.map((action) => (
<button key={action} onClick={() => run(action)}>
{action}
</button>
));
}
Не все события имеют прямой write-хук актива

У EAM-актива переход SEND_TO_MAINTENANCE (как и CONSERVE/REACTIVATE/COMPLETE_MAINTENANCE в исходной модели) идёт через журнал движений (POST /eam/movements), а не через прямой endpoint на /eam/assets — это by design ради аудит-журнала. То, что событие есть в availableActions, не гарантирует наличие одноимённого use<Event>Asset-хука. Сверяйтесь с гайдом @mineflow/client-react и API-референсом — какой хук дёргать на каждое событие конкретного домена.

Бэкенд — финальный арбитр перехода

availableActions управляет только видимостью кнопок. Допустимость самого перехода всегда проверяет сервер по своей машине состояний. Невалидный переход возвращает 409 с устойчивым error.code (напр. ASSET_INVALID_STATUS_TRANSITION).

Это значит:

  • даже если из-за гонки (статус устарел в кэше) пользователь нажал кнопку — сервер не даст применить неверный переход;
  • гейтить UI и обрабатывать отказ нужно по error.code, а не по тексту сообщения.
import { MineflowApiError } from '@mineflow/client-core';

conserve.mutate(
{ id: asset.id, reason: 'Сезонная консервация' },
{
onError: (err) => {
if (err instanceof MineflowApiError && err.code === 'ASSET_INVALID_STATUS_TRANSITION') {
toast('Статус изменился — обновите список');
}
},
},
);

Полная обработка MineflowApiError и каталог кодов — рецепт Ошибки.

Когда переход — это сага

Часть переходов (например утверждение сменного рапорта) на бэке запускает многошаговую сагу и отвечает 202 + sagaId. Кнопку показываете так же — по availableActions, — но за вызовом стоит saga-мутация, которая поллит статус до терминала. См. рецепт Async-саги.

Итого

  • Машина + статус → availableActions / useAvailableActions → имена переходов.
  • Машины и энумы — из @mineflow/api-schemas, та же FSM, что у бэка (anti-drift, ADR-0022).
  • Переходы — без guard'ов: показывайте кнопку, доп. ввод собирайте в форме.
  • Событие → вызов делает доменный write-хук, не helper.
  • Финальную валидацию перехода делает сервер (409 + error.code) — гейтите ошибку по коду.

Справочники: @mineflow/api-schemas · @mineflow/client-react · API-референс client-react · REST-референс.