feat: add secure attendance portal and payroll foundation
Some checks are pending
ci / verify (push) Waiting to run

This commit is contained in:
Ochenstarik 2026-09-24 15:59:38 +07:00
parent b4eb3432ae
commit 3464998168
47 changed files with 4663 additions and 35 deletions

1
.gitignore vendored
View file

@ -3,6 +3,7 @@ node_modules/
dist/
coverage/
.env
deploy/pilot/secrets/
*.log
.idea/
.vscode/

View file

@ -1,8 +1,8 @@
# Krona Staff
Архитектурная основа блока сотрудников для Krona Business Platform. Это не готовая HRM: предметные правила табеля, графиков, отпусков, расчёта зарплаты, кадровых документов и отчётности намеренно не реализованы до требований владельца.
Блок сотрудников для Krona Business Platform. Он включает терминал посещаемости и проверяемое ядро управленческого расчёта зарплаты по подтверждённым требованиям владельца. Официальный расчёт налогов и взносов, кадровые документы, отпуска и планирование графиков в текущий объём не входят.
Сейчас репозиторий содержит совместимое техническое ядро: два runtime-режима, порты платформы, PostgreSQL/RLS, инфраструктуру outbox/inbox/idempotency/audit, проверяемые OpenAPI и module manifest, health endpoints, контейнеризацию и CI.
Репозиторий сохраняет платформенные границы: два runtime-режима, порты Core, PostgreSQL/RLS, outbox/inbox/idempotency/audit, проверяемые OpenAPI и module manifest, контейнеризацию и CI. Расчёт остаётся чистой детерминированной функцией, а данные каждого tenant изолируются на уровне транзакции, внешних ключей и RLS.
## Быстрый старт
@ -24,6 +24,12 @@ docker compose up --build app postgres
Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
## Расчётный контур
Ядро в `src/domain/payroll` поддерживает почасовую и посменную оплату, проценты, гарантии, премии, сдельные позиции, общий фонд с распределением по времени присутствия, прогрессивные шкалы, ночные, сверхурочные и праздничные часы. Правила и назначения версионируются, а каждая строка ведомости сохраняет объяснение расчёта.
Требования: [REQ-001](docs/REQ-001-PAYROLL.md), каталог схем: [PAYROLL-SCHEMES-CATALOGUE](docs/PAYROLL-SCHEMES-CATALOGUE.md), платформенное решение: [ADR-002](docs/adr/ADR-002-payroll-domain-and-platform-alignment.md).
## Биометрический терминал
Windows-приложение для SLK20R находится в [apps/KronaStaffTerminal](apps/KronaStaffTerminal). Оно добавляет сотрудников, регистрирует несколько отпечатков по трём сканам, учитывает приход/уход и отправляет уведомление в Telegram и/или MAX. Инструкция по запуску и настройке находится в [README терминала](apps/KronaStaffTerminal/README.md).

View file

@ -2,7 +2,7 @@ openapi: 3.1.0
info:
title: Krona Staff API
version: 0.2.5
description: Operational API for the Krona Staff foundation. HR domain endpoints are intentionally absent until owner requirements are approved.
description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя.
license:
name: Proprietary
url: https://git.ochenstarik.ru/ochenstarik/krona-staff/src/branch/main/LICENSE
@ -47,7 +47,107 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Version' }
'400': { $ref: '#/components/responses/BadRequest' }
/session:
post:
summary: Открыть защищённую сессию администратора
operationId: createStaffSession
tags: [Authentication]
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [access_token]
properties:
access_token: { type: string, minLength: 32, maxLength: 4096, writeOnly: true }
responses:
'200':
description: Сессия открыта; токен перенесён в HttpOnly Secure cookie
'401': { description: Ключ недействителен }
'403': { description: Ключ не имеет прав администратора }
delete:
summary: Завершить сессию администратора
operationId: deleteStaffSession
tags: [Authentication]
responses:
'204': { description: Сессия завершена }
'403': { description: Запрос с постороннего сайта отклонён }
/portal:
get:
summary: Получить доступную пользователю сводку посещаемости и табеля
operationId: getStaffPortal
tags: [Attendance]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Сводка с автоматическим ограничением данными текущего сотрудника
content:
application/json:
schema: { type: object }
'403': { description: Недостаточно прав }
/attendance/events:
post:
summary: Принять идемпотентную отметку терминала
operationId: recordAttendanceEvent
tags: [Attendance]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [employee_id, event_type, occurred_at, source_event_id]
properties:
event_id: { $ref: '#/components/schemas/Ulid' }
employee_id: { $ref: '#/components/schemas/Ulid' }
event_type: { enum: [check_in, check_out] }
occurred_at: { type: string, format: date-time }
source_event_id: { type: string, minLength: 1, maxLength: 200 }
device_id: { $ref: '#/components/schemas/Ulid' }
responses:
'201': { description: Отметка принята }
'200': { description: Повтор уже принятой отметки }
'403': { description: Недостаточно прав }
/timesheets/{employeeId}/{workDate}:
put:
summary: Сохранить строку табеля
operationId: saveTimesheetEntry
tags: [Timesheet]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: employeeId
required: true
schema: { $ref: '#/components/schemas/Ulid' }
- in: path
name: workDate
required: true
schema: { type: string, format: date }
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [minutes_worked, status]
properties:
minutes_worked: { type: integer, minimum: 0, maximum: 1440 }
status: { enum: [draft, submitted, approved, rejected] }
note: { type: string, maxLength: 1000 }
responses:
'200': { description: Строка табеля сохранена }
'403': { description: Недостаточно прав }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
responses:
BadRequest:
description: Invalid request

View file

@ -265,12 +265,36 @@
<PasswordBox x:Name="UpdateTokenBox"
Header="Токен чтения приватного репозитория"
PasswordRevealMode="Peek"/>
<TextBox x:Name="UpdateSignerThumbprintBox"
Header="Отпечаток сертификата издателя"
PlaceholderText="SHA-1 или SHA-256 без пробелов"
CharacterCasing="Upper"/>
<TextBlock Text="Автоматическая установка разрешена только для установщика с действительной подписью Windows и указанным сертификатом издателя."
TextWrapping="Wrap"
Opacity="0.7"/>
<TextBlock Text="Токен нужен только пока обновления берутся из приватного Forgejo. Позже этот канал можно заменить Бизнес Платформой без изменения установщика."
TextWrapping="Wrap"
Opacity="0.7"/>
<Button Content="Проверить обновления"
HorizontalAlignment="Left"
Click="CheckForUpdates_Click"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<TextBlock Text="Локальный сервер посещаемости"
Foreground="{ThemeResource KronaTextPrimaryBrush}"
Style="{StaticResource SubtitleTextBlockStyle}"/>
<TextBox x:Name="StaffServerUrlBox"
Header="HTTPS-адрес сервера"
IsReadOnly="True"/>
<PasswordBox x:Name="StaffServerTokenBox"
Header="Ключ терминала"
PasswordRevealMode="Peek"/>
<TextBox x:Name="StaffServerCertificateBox"
Header="SHA-256 сертификата сервера"
PlaceholderText="64 шестнадцатеричных символа"
CharacterCasing="Upper"/>
<TextBlock x:Name="TerminalDeviceIdText" TextWrapping="Wrap" Opacity="0.7"/>
<TextBlock Text="Передаются только случайные ID сотрудника, события и терминала, тип прихода/ухода и время. ФИО, контакты и отпечатки не передаются. Адрес закреплён за тестовым сервером 192.168.1.81:8453."
TextWrapping="Wrap" Opacity="0.7"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Сохранить настройки"
Background="{ThemeResource KronaPrimaryBrush}"

View file

@ -14,6 +14,7 @@ public sealed partial class MainPage : Page
{
private readonly TerminalStore _store = new();
private readonly NotificationService _notifications = new();
private readonly AttendanceSyncService _attendanceSync = new();
private readonly FingerprintScanner _scanner = new();
private readonly IUpdateSource _updateSource = new ForgejoUpdateSource();
private TerminalState _state = new();
@ -27,6 +28,7 @@ public sealed partial class MainPage : Page
private AvailableUpdate? _availableUpdate;
private bool _updateCheckInProgress;
private bool _automaticCheckoutInProgress;
private bool _attendanceSyncInProgress;
private bool _adminSessionActive;
private bool _adminNavigationInProgress;
private bool _stateLoadFailed;
@ -76,7 +78,11 @@ public sealed partial class MainPage : Page
_automaticCheckoutTimer = DispatcherQueue.CreateTimer();
_automaticCheckoutTimer.Interval = TimeSpan.FromMinutes(1);
_automaticCheckoutTimer.IsRepeating = true;
_automaticCheckoutTimer.Tick += (_, _) => EnqueueTask(RunAutomaticCheckoutIfDueAsync);
_automaticCheckoutTimer.Tick += (_, _) => EnqueueTask(async () =>
{
await RunAutomaticCheckoutIfDueAsync();
await SyncPendingAttendanceAsync();
});
}
private async void Page_Loaded(object sender, RoutedEventArgs e)
@ -111,6 +117,7 @@ public sealed partial class MainPage : Page
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
}
await RunAutomaticCheckoutIfDueAsync();
await SyncPendingAttendanceAsync();
await CheckForUpdatesAsync(false);
}
@ -909,6 +916,7 @@ public sealed partial class MainPage : Page
var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee, eventType, now.ToLocalTime());
record.NotificationDelivered = delivery.Delivered;
record.NotificationError = delivery.Error;
await SyncPendingAttendanceAsync();
await _store.SaveAsync(_state);
RefreshLists();
if (delivery.Error is not null)
@ -1414,6 +1422,9 @@ public sealed partial class MainPage : Page
_state.Settings.VkGroupToken = VkTokenBox.Password.Trim();
_state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn;
_state.Settings.MaxBotToken = MaxTokenBox.Password.Trim();
_state.Settings.StaffServerUrl = StaffServerUrlBox.Text.Trim();
_state.Settings.StaffServerToken = StaffServerTokenBox.Password.Trim();
_state.Settings.StaffServerCertificateSha256 = StaffServerCertificateBox.Text.Trim();
CopyUpdateSettingsFromControls();
}
@ -1422,6 +1433,7 @@ public sealed partial class MainPage : Page
_state.Settings.UpdateChecksEnabled = UpdateChecksSwitch.IsOn;
_state.Settings.UpdateFeedUrl = UpdateFeedBox.Text.Trim();
_state.Settings.UpdateAccessToken = UpdateTokenBox.Password.Trim();
_state.Settings.UpdateTrustedSignerThumbprint = UpdateSignerThumbprintBox.Text.Trim();
}
private void LoadSettings()
@ -1441,12 +1453,36 @@ public sealed partial class MainPage : Page
VkTokenBox.Password = _state.Settings.VkGroupToken;
MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled;
MaxTokenBox.Password = _state.Settings.MaxBotToken;
StaffServerUrlBox.Text = _state.Settings.StaffServerUrl;
StaffServerTokenBox.Password = _state.Settings.StaffServerToken;
StaffServerCertificateBox.Text = _state.Settings.StaffServerCertificateSha256;
TerminalDeviceIdText.Text = $"Случайный ID терминала: {_state.Settings.TerminalDeviceId}";
UpdateChecksSwitch.IsOn = _state.Settings.UpdateChecksEnabled;
UpdateFeedBox.Text = _state.Settings.UpdateFeedUrl;
UpdateTokenBox.Password = _state.Settings.UpdateAccessToken;
UpdateSignerThumbprintBox.Text = _state.Settings.UpdateTrustedSignerThumbprint;
CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}";
}
private async Task SyncPendingAttendanceAsync()
{
if (_attendanceSyncInProgress || _stateLoadFailed) return;
_attendanceSyncInProgress = true;
try
{
await _attendanceSync.SyncPendingAsync(_state);
await _store.SaveAsync(_state);
}
catch (InvalidOperationException exception)
{
SetStatus(exception.Message, InfoBarSeverity.Warning);
}
finally
{
_attendanceSyncInProgress = false;
}
}
private void ApplyStartupSetting()
{
try

View file

@ -71,6 +71,8 @@ public sealed class AttendanceRecord
public string EventType { get; set; } = "Приход";
public bool NotificationDelivered { get; set; }
public string? NotificationError { get; set; }
public bool ServerSynced { get; set; }
public string? ServerSyncError { get; set; }
[JsonIgnore]
public string LocalTime => OccurredAtUtc.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss");
@ -107,6 +109,11 @@ public sealed class TerminalSettings
public bool UpdateChecksEnabled { get; set; } = true;
public string UpdateFeedUrl { get; set; } = "https://git.ochenstarik.ru/api/v1/repos/ochenstarik/krona-staff/releases/latest";
public string UpdateAccessToken { get; set; } = "";
public string UpdateTrustedSignerThumbprint { get; set; } = "";
public string StaffServerUrl { get; set; } = "https://192.168.1.81:8453";
public string StaffServerToken { get; set; } = "";
public string StaffServerCertificateSha256 { get; set; } = "";
public string TerminalDeviceId { get; set; } = UlidGenerator.New();
public DateTimeOffset LastUpdateCheckUtc { get; set; }
}

View file

@ -0,0 +1,90 @@
using KronaStaffTerminal.Models;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace KronaStaffTerminal.Services;
public sealed class AttendanceSyncService
{
private const string PilotHost = "192.168.1.81";
private const int PilotPort = 8453;
public async Task SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(state.Settings.StaffServerToken) ||
string.IsNullOrWhiteSpace(state.Settings.StaffServerCertificateSha256))
{
return;
}
var endpoint = BuildEndpoint(state.Settings.StaffServerUrl);
var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256);
using var handler = new HttpClientHandler { AllowAutoRedirect = false };
handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
CertificateMatches(certificate, expectedCertificate);
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(12) };
foreach (var record in state.Attendance
.Where(item => !item.ServerSynced && item.EventType is "Приход" or "Уход")
.OrderBy(item => item.OccurredAtUtc))
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer",
state.Settings.StaffServerToken);
request.Content = JsonContent.Create(new
{
event_id = record.Id,
employee_id = record.EmployeeId,
event_type = record.EventType == "Приход" ? "check_in" : "check_out",
occurred_at = record.OccurredAtUtc,
source_event_id = record.Id,
device_id = state.Settings.TerminalDeviceId
});
using var response = await client.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
record.ServerSyncError = $"HTTP {(int)response.StatusCode}";
continue;
}
record.ServerSynced = true;
record.ServerSyncError = null;
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException)
{
record.ServerSyncError = "Нет защищённой связи с локальным сервером";
}
}
}
private static Uri BuildEndpoint(string serverUrl)
{
if (!Uri.TryCreate(serverUrl.Trim().TrimEnd('/'), UriKind.Absolute, out var root) ||
root.Scheme != Uri.UriSchemeHttps ||
!string.Equals(root.Host, PilotHost, StringComparison.Ordinal) ||
root.Port != PilotPort ||
!string.IsNullOrEmpty(root.Query) ||
!string.IsNullOrEmpty(root.Fragment))
{
throw new InvalidOperationException(
$"Пилот разрешает только https://{PilotHost}:{PilotPort} без перенаправлений.");
}
return new Uri(root, "/api/v1/staff/attendance/events");
}
private static bool CertificateMatches(X509Certificate2? certificate, string expectedFingerprint)
{
if (certificate is null || expectedFingerprint.Length != 64) return false;
var actual = Convert.ToHexString(SHA256.HashData(certificate.RawData));
return CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(actual),
Convert.FromHexString(expectedFingerprint));
}
private static string NormalizeFingerprint(string value) =>
new(value.Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray());
}

View file

@ -2,7 +2,9 @@ using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Text.Json.Serialization;
using KronaStaffTerminal.Models;
@ -39,13 +41,13 @@ public sealed class ForgejoUpdateSource : IUpdateSource
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
feedUri.Scheme != Uri.UriSchemeHttps)
!IsSecureUpdateUri(feedUri))
{
throw new InvalidOperationException(
"Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
}
using var request = CreateRequest(HttpMethod.Get, feedUri, settings.UpdateAccessToken);
using var request = CreateRequest(HttpMethod.Get, feedUri, settings.UpdateAccessToken, feedUri);
using var response = await HttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
@ -79,7 +81,10 @@ public sealed class ForgejoUpdateSource : IUpdateSource
checksum is null ||
!Uri.TryCreate(installer.BrowserDownloadUrl, UriKind.Absolute, out var installerUri) ||
!Uri.TryCreate(checksum.BrowserDownloadUrl, UriKind.Absolute, out var checksumUri) ||
!Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releasePage))
!Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releasePage) ||
!IsSameUpdateOrigin(feedUri, installerUri) ||
!IsSameUpdateOrigin(feedUri, checksumUri) ||
!IsSameUpdateOrigin(feedUri, releasePage))
{
throw new InvalidDataException(
"В релизе нет установщика Windows или файла контрольной суммы.");
@ -104,6 +109,12 @@ public sealed class ForgejoUpdateSource : IUpdateSource
IProgress<double>? progress,
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) ||
!IsSecureUpdateUri(feedUri))
{
throw new InvalidOperationException("Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo.");
}
var updateDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona",
@ -119,7 +130,8 @@ public sealed class ForgejoUpdateSource : IUpdateSource
using var checksumRequest = CreateRequest(
HttpMethod.Get,
update.ChecksumUrl,
settings.UpdateAccessToken);
settings.UpdateAccessToken,
feedUri);
using var checksumResponse = await HttpClient.SendAsync(
checksumRequest,
cancellationToken);
@ -139,7 +151,8 @@ public sealed class ForgejoUpdateSource : IUpdateSource
using var installerRequest = CreateRequest(
HttpMethod.Get,
update.InstallerUrl,
settings.UpdateAccessToken);
settings.UpdateAccessToken,
feedUri);
using var installerResponse = await HttpClient.SendAsync(
installerRequest,
HttpCompletionOption.ResponseHeadersRead,
@ -182,6 +195,8 @@ public sealed class ForgejoUpdateSource : IUpdateSource
"Контрольная сумма обновления не совпала. Установка отменена.");
}
VerifyInstallerPublisher(temporaryPath, settings.UpdateTrustedSignerThumbprint);
File.Move(temporaryPath, installerPath, true);
return installerPath;
}
@ -199,8 +214,13 @@ public sealed class ForgejoUpdateSource : IUpdateSource
private static HttpRequestMessage CreateRequest(
HttpMethod method,
Uri uri,
string token)
string token,
Uri trustedOrigin)
{
if (!IsSameUpdateOrigin(trustedOrigin, uri))
{
throw new InvalidDataException("Forgejo вернул файл обновления с недоверенного адреса.");
}
var request = new HttpRequestMessage(method, uri);
if (!string.IsNullOrWhiteSpace(token))
{
@ -212,11 +232,58 @@ public sealed class ForgejoUpdateSource : IUpdateSource
private static HttpClient CreateHttpClient()
{
var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromMinutes(10)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("KronaStaffTerminal/0.2");
return client;
}
internal static bool IsSameUpdateOrigin(Uri trustedOrigin, Uri candidate) =>
IsSecureUpdateUri(trustedOrigin) &&
IsSecureUpdateUri(candidate) &&
string.Equals(trustedOrigin.IdnHost, candidate.IdnHost, StringComparison.OrdinalIgnoreCase) &&
trustedOrigin.Port == candidate.Port;
private static bool IsSecureUpdateUri(Uri uri) =>
uri.Scheme == Uri.UriSchemeHttps &&
string.IsNullOrEmpty(uri.UserInfo) &&
!string.IsNullOrWhiteSpace(uri.IdnHost);
private static void VerifyInstallerPublisher(string installerPath, string configuredThumbprint)
{
var expectedThumbprint = NormalizeThumbprint(configuredThumbprint);
if (expectedThumbprint.Length == 0)
{
throw new InvalidOperationException(
"Автоматическая установка заблокирована: не настроен отпечаток сертификата издателя обновлений.");
}
if (!AuthenticodeTrust.IsTrusted(installerPath))
{
throw new InvalidDataException(
"Цифровая подпись установщика отсутствует, повреждена или не доверена Windows.");
}
#pragma warning disable SYSLIB0057
using var certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath));
#pragma warning restore SYSLIB0057
var actualThumbprint = NormalizeThumbprint(certificate.Thumbprint ?? "");
if (!CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expectedThumbprint),
Convert.FromHexString(actualThumbprint)))
{
throw new InvalidDataException("Установщик подписан неизвестным издателем. Установка отменена.");
}
}
private static string NormalizeThumbprint(string value)
{
var normalized = new string(value.Where(Uri.IsHexDigit).ToArray()).ToUpperInvariant();
return normalized.Length is 40 or 64 ? normalized : "";
}
private static bool TryParseVersion(string? value, out Version version)
{
var normalized = (value ?? "")
@ -258,4 +325,66 @@ public sealed class ForgejoUpdateSource : IUpdateSource
[JsonPropertyName("browser_download_url")]
public string BrowserDownloadUrl { get; set; } = "";
}
private static class AuthenticodeTrust
{
private static readonly Guid VerifyAction = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
public static bool IsTrusted(string path)
{
using var fileInfo = new WinTrustFileInfo(path);
var fileInfoPointer = Marshal.AllocHGlobal(Marshal.SizeOf<WinTrustFileInfo>());
var trustDataPointer = IntPtr.Zero;
try
{
Marshal.StructureToPtr(fileInfo, fileInfoPointer, false);
var trustData = new WinTrustData(fileInfoPointer);
trustDataPointer = Marshal.AllocHGlobal(Marshal.SizeOf<WinTrustData>());
Marshal.StructureToPtr(trustData, trustDataPointer, false);
return WinVerifyTrust(new IntPtr(-1), VerifyAction, trustDataPointer) == 0;
}
finally
{
if (trustDataPointer != IntPtr.Zero) Marshal.FreeHGlobal(trustDataPointer);
Marshal.FreeHGlobal(fileInfoPointer);
}
}
[DllImport("wintrust.dll", ExactSpelling = true, CharSet = CharSet.Unicode)]
private static extern int WinVerifyTrust(
IntPtr windowHandle,
[MarshalAs(UnmanagedType.LPStruct)] Guid actionId,
IntPtr trustData);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private sealed class WinTrustFileInfo : IDisposable
{
public uint StructSize = (uint)Marshal.SizeOf<WinTrustFileInfo>();
public IntPtr FilePath;
public IntPtr FileHandle = IntPtr.Zero;
public IntPtr KnownSubject = IntPtr.Zero;
public WinTrustFileInfo(string path) => FilePath = Marshal.StringToCoTaskMemUni(path);
public void Dispose() => Marshal.FreeCoTaskMem(FilePath);
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private sealed class WinTrustData
{
public uint StructSize = (uint)Marshal.SizeOf<WinTrustData>();
public IntPtr PolicyCallbackData = IntPtr.Zero;
public IntPtr SipClientData = IntPtr.Zero;
public uint UiChoice = 2;
public uint RevocationChecks = 1;
public uint UnionChoice = 1;
public IntPtr FileInfo;
public uint StateAction = 0;
public IntPtr StateData = IntPtr.Zero;
public IntPtr UrlReference = IntPtr.Zero;
public uint ProviderFlags = 0;
public uint UiContext = 0;
public WinTrustData(IntPtr fileInfo) => FileInfo = fileInfo;
}
}
}

View file

@ -4,8 +4,10 @@ services:
environment:
NODE_ENV: ${NODE_ENV:-development}
PORT: ${PORT:-8080}
HOST: 0.0.0.0
LOG_LEVEL: ${LOG_LEVEL:-info}
STAFF_RUNTIME_MODE: ${STAFF_RUNTIME_MODE:-standalone}
STANDALONE_ACCESS: ${STANDALONE_ACCESS:-employee}
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:-local-app-only}@postgres:5432/krona_staff
PLATFORM_GATEWAY_URL: ${PLATFORM_GATEWAY_URL:-}
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}

78
deploy/pilot/compose.yaml Normal file
View file

@ -0,0 +1,78 @@
name: krona-staff-pilot
services:
app:
build:
context: ../..
environment:
NODE_ENV: development
HOST: 0.0.0.0
PORT: 8080
LOG_LEVEL: info
STAFF_RUNTIME_MODE: standalone
STANDALONE_ACCESS: employee
STANDALONE_MANAGER_TOKEN: ${STANDALONE_MANAGER_TOKEN}
STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN}
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID}
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
HTTPS_PFX_PATH: /run/secrets/staff.pfx
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE}
ports:
- '192.168.1.81:8453:8080'
volumes:
- ./secrets/staff.pfx:/run/secrets/staff.pfx:ro
user: '1000:1000'
depends_on:
migrate: { condition: service_completed_successfully }
restart: unless-stopped
read_only: true
tmpfs: [/tmp]
security_opt: ['no-new-privileges:true']
healthcheck:
test: ['CMD-SHELL', 'wget --no-check-certificate -q -O - https://127.0.0.1:8080/api/v1/staff/live || exit 1']
interval: 30s
timeout: 3s
retries: 3
db-role:
build:
context: ../..
target: build
environment:
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD}
command: ['node', 'scripts/create-app-role.mjs']
depends_on:
postgres: { condition: service_healthy }
restart: 'no'
migrate:
build:
context: ../..
target: build
environment:
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
command: ['node', 'scripts/migrate.mjs', 'up']
depends_on:
db-role: { condition: service_completed_successfully }
restart: 'no'
postgres:
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
environment:
POSTGRES_DB: krona_staff
POSTGRES_USER: krona_migrator
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U krona_migrator -d krona_staff']
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
volumes:
postgres-data:

44
deploy/pilot/setup.sh Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env sh
set -eu
cd "$(dirname "$0")"
umask 077
mkdir -p secrets
if [ ! -f .env ]; then
migration_password="$(openssl rand -hex 32)"
app_password="$(openssl rand -hex 32)"
manager_token="$(openssl rand -hex 32)"
terminal_token="$(openssl rand -hex 32)"
pfx_passphrase="$(openssl rand -hex 32)"
{
printf 'POSTGRES_MIGRATION_PASSWORD=%s\n' "$migration_password"
printf 'POSTGRES_APP_PASSWORD=%s\n' "$app_password"
printf 'STANDALONE_MANAGER_TOKEN=%s\n' "$manager_token"
printf 'STANDALONE_TERMINAL_TOKEN=%s\n' "$terminal_token"
printf 'HTTPS_PFX_PASSPHRASE=%s\n' "$pfx_passphrase"
printf 'STANDALONE_TENANT_ID=01J00000000000000000000000\n'
printf 'STANDALONE_ACTOR_ID=01J00000000000000000000001\n'
printf 'STANDALONE_TERMINAL_ACTOR_ID=01J00000000000000000000002\n'
} > .env
printf '%s\n' "$manager_token" > secrets/manager.token
printf '%s\n' "$terminal_token" > secrets/terminal.token
fi
. ./.env
if [ ! -f secrets/staff.pfx ]; then
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
-keyout secrets/staff.key -out secrets/staff.crt \
-subj '/CN=192.168.1.81' \
-addext 'subjectAltName=IP:192.168.1.81' \
-addext 'keyUsage=digitalSignature,keyEncipherment' \
-addext 'extendedKeyUsage=serverAuth'
openssl pkcs12 -export -out secrets/staff.pfx \
-inkey secrets/staff.key -in secrets/staff.crt \
-passout "pass:${HTTPS_PFX_PASSPHRASE}"
fi
openssl x509 -in secrets/staff.crt -outform DER | sha256sum | cut -d ' ' -f 1 > secrets/certificate.sha256
chmod 600 .env secrets/*
docker compose --env-file .env config >/dev/null
docker compose --env-file .env up -d --build

View file

@ -0,0 +1,19 @@
#!/usr/bin/env sh
set -eu
cd "$(dirname "$0")"
terminal_token="$(cat secrets/terminal.token)"
manager_token="$(cat secrets/manager.token)"
occurred_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
payload="$(printf '{"event_id":"01J00000000000000000000010","employee_id":"01J00000000000000000000020","event_type":"check_in","occurred_at":"%s","source_event_id":"pilot-check-1","device_id":"01J00000000000000000000030"}' "$occurred_at")"
curl -ksS \
-H "Authorization: Bearer $terminal_token" \
-H 'Content-Type: application/json' \
--data "$payload" \
https://192.168.1.81:8453/api/v1/staff/attendance/events
printf '\n'
curl -ksS \
-H "Authorization: Bearer $manager_token" \
https://192.168.1.81:8453/api/v1/staff/portal
printf '\n'

116
docs/APP-CORE.md Normal file
View file

@ -0,0 +1,116 @@
# Ядро мобильного приложения Krona Business Platform
Документ для команды, которая делает приложение (в том числе для Codex). Описывает каркас, в который модули платформы добавляют свои экраны. Staff — первый модуль; касса, финансы и остальное подключаются тем же способом.
Правило номер один: **приложение не считает деньги**. Все суммы, смены и остатки приходят с сервера уже рассчитанными, вместе с объяснением, из чего они сложились. Клиент их только показывает. Это условие проверяемости расчёта и требование [ADR-002](adr/ADR-002-payroll-domain-and-platform-alignment.md).
## 1. Стек
| Что | Решение | Почему |
| --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| Каркас | React Native + Expo (managed), TypeScript strict | тот же язык, что и модули платформы; типы API переиспользуются, сборка Android и iOS без Mac через EAS |
| Навигация | Expo Router | файловая маршрутизация, удобна для подключения экранов модулями |
| Серверное состояние | TanStack Query + persist | кэш, повторы, офлайн-чтение |
| Локальное состояние | Zustand | как во фронтенде платформы |
| Формы и валидация | React Hook Form + Zod | Zod уже используется в модулях |
| Секреты на устройстве | expo-secure-store | refresh-токен и PIN-хеш только здесь |
| Биометрия | expo-local-authentication | разблокировка, не аутентификация на сервере |
| Уведомления | expo-notifications | push; пока Core не отдаёт push, канал Telegram остаётся запасным |
| Тесты | Vitest + React Native Testing Library, Maestro для сценариев | — |
Совместимость: Android 9+, iOS 15+. Публикация: RuStore и Google Play, TestFlight/App Store — по решению владельца.
## 2. Структура
```
app/ экраны Expo Router: (auth), (tabs), modules/<module>/…
src/core/
api/ http-клиент, конверт ответа, ошибки, Idempotency-Key
auth/ сессия, refresh, PIN, биометрия, приглашения
modules/ реестр модулей: вкладки, экраны, права
ui/ дизайн-система: токены, компоненты, типографика
i18n/ ru по умолчанию
telemetry/ логи без персональных данных
src/modules/staff/ экраны и запросы модуля Staff
```
Модуль регистрируется в реестре декларативно и не правит ядро:
```ts
registerModule({
id: 'ru.krona.staff',
title: 'Работа',
requiredPermissions: ['staff.payslip.read_own'],
tabs: [{ name: 'today', icon: 'clock', screen: TodayScreen }],
routes: [{ path: 'staff/payroll/:runId', screen: PayrollRunScreen }],
});
```
Ядро показывает вкладку, только если у пользователя есть право и модуль включён у арендатора. Это зеркалит слоты платформы (`34-MODULE-SYSTEM`), чтобы приложение не расходилось с вебом.
## 3. Аутентификация
Учётными записями владеет ядро платформы, приложение не заводит своих пользователей и не хранит пароли.
**Приглашение (решение владельца).** Администратор создаёт учётку сотрудника и отправляет приглашение одним из трёх способов: письмо на почту, ссылка в мессенджер, QR-код на экране. Все три ведут на один одноразовый токен приглашения.
**Первый вход:**
1. переход по приглашению (или сканирование QR в приложении);
2. сотрудник задаёт пароль — уходит в Core, на устройстве не остаётся;
3. сотрудник задаёт PIN из 6 цифр — хранится только как хеш в secure-store, сервер о нём не знает;
4. если на телефоне есть биометрия, предлагается включить её вместо PIN.
**Дальнейшие входы:** PIN или биометрия разблокируют refresh-токен в secure-store, ядро меняет его на access-токен. Пароль нужен только при смене устройства и после отзыва сессии.
**Токены:** access-JWT живёт 15 минут и хранится только в памяти; refresh — непрозрачный, в secure-store, с привязкой к Device ID. Три неверных PIN подряд — пауза, десять — сброс локальной сессии и вход по паролю.
**Чего сегодня не хватает в ядре платформы** (проверено по коду `ochenstarik/business-platform`): поток приглашения реализован как каркас, восстановление пароля отсутствует, права модулей нельзя выдать роли. До их готовности модуль Staff в режиме `standalone` выдаёт собственные токены для пилота — это временный режим, который выключается переключением на `platform`. Собственную модель пользователей в приложении заводить нельзя ни при каких условиях.
## 4. Работа с API
- База: `https://<host>/api/v1/<module>`. Для Staff — `/api/v1/staff`.
- Заголовки каждого запроса: `Authorization: Bearer <access>`, `X-Tenant-ID`, `X-Request-Id` (ULID, генерирует клиент).
- Успешный ответ: `{"data": …, "meta": …}`. Ошибка: `{"errors": [{"code": "…", "message": "…", "field": "…"}], "meta": {"request_id": "…"}}`. Показывать пользователю `message`, в лог писать `code` и `request_id`.
- Любая мутация несёт `Idempotency-Key` (UUID v4, генерирует клиент и сохраняет до подтверждения). Повтор с тем же ключом и другим телом получает `409`.
- `401` → однократный refresh и повтор запроса; второй `401` → выход на экран входа.
- Чужой ресурс возвращает `404`, а не `403` — не показывать это как «нет прав».
- Деньги приходят как `{"amount_minor": "623100", "currency_code": "RUB"}`, строкой. Клиент **не** приводит к числу с плавающей точкой: форматирование только через хелпер `formatMoney`, который работает со строкой и делит на 100.
- Типы генерируются из `api/openapi.yaml` модуля через `openapi-typescript`; рукописные интерфейсы ответов запрещены.
## 5. Экраны Staff, версия 1
| Экран | Содержание |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Сегодня | текущая смена: точка, время прихода, идёт ли смена, предварительное начисление за день с пометкой «до подтверждения управляющим» |
| Мои смены | список за период: дата, точка, часы, статус (`ожидает подтверждения`, `подтверждена`, `отклонена`) |
| Моя зарплата | неделя: начислено, выдано, остаток. Внутри — по дням: выручка точки не показывается, показываются часы, доля фонда, почасовая, надбавка, итог |
| Детализация дня | объяснение суммы: «фонд точки ÷ 3 участника + 12 ч × 100 ₽ + надбавка 500 ₽» |
| Профиль | имя, табельный номер, точка, выход, смена PIN, включение биометрии |
Сотрудник видит только свои данные. Ни один экран не должен запрашивать данные других сотрудников; при попытке сервер ответит `404`.
## 6. Офлайн
Приложение работает в кафе с плохой связью, поэтому:
- чтение — из кэша TanStack Query с сохранением на диск, с явной пометкой «данные на такое-то время»;
- мутации (их мало: подтверждение прочтения, смена PIN) — очередь с `Idempotency-Key` и повтором;
- денежные суммы никогда не считаются на клиенте, даже офлайн: если данных нет, показывается прочерк, а не оценка.
## 7. Что делает сервер, а не приложение
- расчёт начислений, фонда, долей, гарантий, округлений;
- определение, какие смены попали в расчёт;
- проверка прав: приложение прячет кнопки, но решает всегда сервер;
- агрегаты за неделю и остаток.
## 8. Порядок работ
1. Ядро: навигация, дизайн-токены, http-клиент, обработка ошибок, реестр модулей.
2. Аутентификация: приглашение, пароль, PIN, биометрия, refresh, Device ID.
3. Модуль Staff: «Сегодня», «Мои смены», «Моя зарплата», «Детализация», «Профиль».
4. Push-уведомления: отметка о приходе, подтверждение смены, утверждение ведомости.
5. Сборка: EAS profiles (`development`, `preview`, `production`), публикация в RuStore.
Серверная часть (расчёт, ведомость, подтверждение смен, API) делается в репозитории `krona-staff` и к моменту пункта 3 отдаёт готовый контракт в `api/openapi.yaml`.

View file

@ -0,0 +1,45 @@
# Правовые ограничения расчёта
Модуль считает управленческий контур, но обязан не подталкивать клиента к незаконной схеме и предупреждать, когда расчёт расходится с Трудовым кодексом. Ниже — минимум, который закладывается в правила и в предупреждения ведомости.
Это не юридическое заключение. Перед боевым использованием состав предупреждений стоит согласовать с юристом.
## 1. Что нельзя выразить как вычет
| Норма | Содержание |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ст. 137 ТК РФ | Перечень оснований для удержаний закрытый. Штрафов в нём нет |
| ст. 138 ТК РФ | Предел удержаний — 20% выплаты, до 50% в случаях, предусмотренных законом, до 70% по исполнительным листам |
| ст. 192 ТК РФ | Дисциплинарные взыскания — только замечание, выговор, увольнение. «Штраф» как наказание незаконен |
| ст. 238, 241, 243, 244 ТК РФ | Материальная ответственность ограничена средним месячным заработком; полная — только по закону или письменному договору с работником из утверждённого перечня; взыскание сверх лимита — через суд, приказ — в течение месяца со дня установления ущерба |
Следствие для продукта: «штраф за опоздание», «за бой посуды», «за жалобу» не должны быть выразимы блоком вычета. Законная альтернатива — заранее объявленное условие премирования: премия не начисляется либо применяется понижающий множитель, и это записано в локальном нормативном акте.
С 1 сентября 2025 года (ФЗ от 07.06.2025 № 144-ФЗ, ст. 135 ТК РФ) снижение премии за дисциплинарный проступок допустимо только в месяце взыскания и не более чем на 20% месячной зарплаты, а виды премий и порядок их снижения должны быть в локальном акте.
## 2. Что обязано быть оплачено
| Норма | Требование |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ст. 152 ТК РФ | Сверхурочные: первые два часа — не менее чем в полуторном размере, дальше — не менее чем в двойном. С 1 сентября 2024 года (ФЗ от 22.04.2024 № 91-ФЗ, во исполнение постановления КС РФ от 27.06.2023 № 35-П) расчёт ведётся от всей системы оплаты труда, включая компенсационные и стимулирующие выплаты, а не от голого оклада |
| ст. 96, 154 ТК РФ | Ночные часы с 22:00 до 06:00 — надбавка не менее 20% часовой ставки. Действует постановление Правительства РФ от 04.04.2025 № 436 (прежнее № 554 от 22.07.2008 утратило силу) |
| ст. 153 ТК РФ | Работа в выходной и праздник — не менее чем в двойном размере; сдельщикам — по двойным расценкам. По желанию работника возможна одинарная оплата с отгулом |
| ст. 104 ТК РФ | При сменных графиках, где норма не соблюдается, обязателен суммированный учёт; переработка определяется по итогам учётного периода, а не по дням |
| ст. 133, 133.1 ТК РФ | Зарплата за полную норму не может быть ниже МРОТ; региональный МРОТ может быть выше федерального. При неполной норме — пропорционально отработанному |
| ст. 136 ТК РФ | Выплата не реже чем каждые полмесяца, разрыв не более 15 календарных дней. Аванс считается пропорционально фактически отработанному времени (позиция Минтруда), а не фиксированным процентом |
| ст. 351.8 ТК РФ (с 01.03.2025) | Доплата наставнику обязательна, её размер фиксируется в трудовом договоре или дополнительном соглашении, требуется письменное согласие работника |
Ответственность: ст. 5.27 КоАП РФ — нарушение трудового законодательства для юридического лица 30 000–50 000 ₽; невыплата или неполная выплата — для должностных лиц 10 000–20 000 ₽, при повторном нарушении дисквалификация от одного года до трёх лет, для юридического лица 50 000–70 000 ₽.
## 3. Как это отражается в модуле
1. **Типизированные часы.** Ночные, сверхурочные и праздничные часы — отдельные входы правила, а не «часы вообще». Без этого требования статей 152–154 невыразимы.
2. **Раздельные сущности.** Начисление, премия и удержание — разные вещи. Удержание получает основание, лимит и запись в аудите; формула его не создаёт.
3. **Предупреждения ведомости** (следующий шаг): начислено меньше МРОТ пропорционально отработанному; в смене есть ночные часы, а надбавки в правиле нет; переработка по итогам учётного периода не оплачена; удержание превышает 20% выплаты.
4. **Понижающий множитель качества** (например, ×0,9 при низкой оценке) допустим как условие премирования, объявленное заранее; тот же результат, оформленный как вычет, — нет. Разница только в оформлении, и продукт обязан подсказывать правильное.
## 4. Отдельные случаи
- **Чаевые от гостя** — дарение (ст. 572 ГК РФ), не элемент оплаты труда. Распределение общего котла — внутренняя договорённость; в расчёт зарплаты попадает как отдельный вид начисления.
- **Самозанятые и ГПХ.** «Корректировки вознаграждения» в доставке работают для ГПХ, но для штатных работников по трудовому договору такая модель незаконна. Правило, написанное для курьеров-самозанятых, нельзя механически переносить на штат.
- **Возврат и дебиторка в продажах.** Прямого удержания нет; работающая механика — не включать сделку в базу бонуса до поступления денег и уменьшать базу следующего периода при возврате.

View file

@ -0,0 +1,131 @@
# Справочник схем расчёта переменной части зарплаты
Собран по практике российского малого и среднего бизнеса (вендорские материалы iiko, r_keeper, СБИС, 1С, YCLIENTS, отраслевые публикации, вакансии). Назначение — состав блоков конструктора и отраслевые пресеты. Конкретные проценты приведены как ориентир рынка, а не как норматив: в пресетах они лишь значения по умолчанию.
## 1. Обозначения
`R` — выручка подразделения, `R_personal` — личные продажи, `H` — часы (в том числе `H_night`, `H_over`, `H_holiday`), `N` — количество единиц, `P` — план, `F` — фонд, `n` — число участников, `w` — вес участника, `G` — гарантия, `pct(x)` — процент как функция показателя.
## 2. Отрасли
### Общепит
| Роль | Формула | Ориентир рынка |
| ---------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| Официант | `ставка_смены + R_personal × pct + N_позиций_категории × ставка + премия за средний чек` | процент 1,3–5% от личного оборота; допродажа десерта 40 ₽/шт |
| Официант, порог | `if(R_personal ≥ порог, R_personal × pct, 0)` | порог включения процента, например 50 000 ₽ |
| Повар (командно) | `F × доля_по_времени + ставка_часа × H + надбавка`, `F = R × pct` | фонд 10–11% от выручки |
| Повар (личный процент) | `ставка_часа × H + R × pct` | 1–2%, в том числе от конкретной станции |
| Бармен | `ставка_смены + R_бара × pct + доля чаевых` | 2–5%; доля в общем котле 20% |
| Курьер ресторана | `ставка_часа × H_простоя + Σ(за заказ) × k_спроса` | 50–110 ₽ за доставку либо 3% от заказа |
| Администратор | `max(G, R × pct + ставка_часа × H)` | pct 2,5–8%, G около 3000 ₽ за смену |
Чаевые как отдельный фонд: официанты 75% поровну, бармен 20%, посудомойка 5%; банкет — кухня 40%, зал 50%, техперсонал 10%. Юридически чаевые от гостя — дарение, не оплата труда.
Ориентир по ФОТ: 18–27% от товарооборота, около 32% — операционный ноль.
### Розница и продажи
| Схема | Формула |
| ----------------------------- | ----------------------------------------------------------------------------------------------- |
| Ставка по достигнутому уровню | `оклад + R_personal × tier(R_personal / P, [<80% → 1,75%; <100% → 2,0%; ≥100% → 2,25%])` |
| Прогрессивная (послойная) | `оклад + progressive(R_personal, [до 80% плана → 3%; 80–100% → 5%; 100–120% → 7%; выше → 10%])` |
| Кассир | `оклад + R_точки × pct` (процент от общих продаж смены) |
| Старший смены | `оклад × коэффициент + R_смены × pct + премия KPI` |
Разница между двумя первыми схемами на плане 105% — десятки процентов премии; владельцы их регулярно путают. В конструкторе это два разных блока: «шкала» и «прогрессивная шкала».
### Услуги с мастерами
| Схема | Формула | Ориентир |
| ----------------------------------- | --------------------------------------------------- | ------------------------------------------------- |
| Чистый процент | `R_personal × pct(категория)` | 30–35% начинающий, 35–45% средний, 45–55% топ |
| Оклад плюс процент на превышение | `оклад + max(0, R_personal − порог) × pct` | оклад 25 000 ₽, порог 60 000 ₽, 40% сверх |
| Разный процент по источнику клиента | `сумма(свои) × 50% + сумма(салона) × 35%` | повышение за повторные визиты |
| Автосервис, нормо-часы | `Σ нормо-часов × ставка_разряда` | 140 / 180 / 220 ₽ за нормо-час |
| Автосервис, превышение плана | `оклад + max(0, н-ч − норма) × ставка` | норма 80 н-ч, 350 ₽ сверх |
| Мастер-приёмщик | `max(G, R_приёмки × tier(план) + бонусы) × k_CSI` | шкала 1–2,5%, множитель качества 1,05 / 1,0 / 0,9 |
| Клининг, бригада | `сумма_заказа × pct × (часы × КТУ) / Σ(часы × КТУ)` | фонд 30–40% от заказа |
### Доставка и логистика
| Роль | Формула |
| ------------ | ---------------------------------------------------------------------------------------------------------------------- |
| Пеший курьер | `Σ_заказ(база + max(0, км − порог) × ставка_км + доплата_вес) × k_спроса + чаевые` |
| Авто-курьер | `за забор + за выдачу × N + ставка_км × км + ставка_кг × кг` |
| Водитель | `ставка_км × км × k_рефрижератор + суточные × дни + надбавка_стажа + премия за безаварийность + доля экономии топлива` |
| Сборщик | `ставка_часа × H + max(0, N_строк − норма × H) × ставка_единицы` |
Особый случай, который чаще всего ломает ручные таблицы: крупный заказ оплачивается фиксированной суммой по дальности **и исключается из базы процента**. Без исключения возникает двойная оплата.
### Склад и производство
```
расценка = тарифная_ставка / норма_выработки
прямая: расценка × N
прогрессивная: расценка × min(N, норма) + расценка × k × max(0, N − норма), k = 1,5–2
косвенная: тариф_вспомогательного × средний_процент_выполнения_норм бригады
аккордная: фиксированная сумма за объём, затем делится по КТУ и часам
```
Бригадное распределение, самый практичный вариант (гарантирует тариф каждому):
```
доля_i = тариф_i × часы_i + (F − Σ тариф × часы) × (КТУ_i × часы_i) / Σ(КТУ × часы)
```
КТУ: база 1,0, диапазон в источниках 0–2; утверждается протоколом бригады; фонд не увеличивает, только перераспределяет.
### B2B-продажи
```
ЗП = твёрдый_оклад + мягкий_оклад × if(Σ(KPI_i × вес_i) ≥ 0,8, Σ(KPI_i × вес_i), 0)
+ progressive(оплаченная_маржа_за_месяц, [<80% плана → 2%; ≤100% → 4%; ≤120% → 6%; выше → 8%])
```
Соотношение фикс к переменной: 30–50% на 50–70%. База процента — оплаченная маржа, а не выручка: процент от выручки поощряет скидки. Порог отсечки KPI около 80% — отраслевой стандарт. Рекомендуется 3–5 метрик, на которые сотрудник реально влияет.
### Медицина, фитнес, образование
| Роль | Формула |
| --------------------- | -------------------------------------------------------------------------------------------- |
| Врач | `max(МРОТ_пропорционально, оклад + Σ(услуга × pct(тип)))`; ФОТ врачей 25–35% оборота клиники |
| Стоматолог | врачу 40–50% выручки по его услугам |
| Администратор клиники | `оклад + N_дошедших × ставка + if(конверсия_явки ≥ 70%, командная премия, 0)` |
| Персональный тренер | `Σ(тренировка × pct(количество тренировок))`, 30–70%, типично 35–50% |
| Групповой тренер | `ставка_занятия × N` либо `Σ(ставка + ставка_участника × N_участников)` |
| Преподаватель | `Σ(ставка_академчаса × k_группы × k_квалификации)` плюс надбавка за удержание ученика |
Правило, которое ломает наивный расчёт: по абонементу на N занятий выручка признаётся **при каждом посещении** (стоимость ÷ N), по безлимиту — равными долями за период. Мотивацию считать от факта оказания услуги, иначе при возврате абонемента возникает переплата, которую уже не вернуть.
### Охрана, диспетчеры, линейный персонал
```
ЗП = ставка_смены × S
+ ставка_часа × H_night × 20%
+ ставка_часа × (min(H_over, 2) × 0,5 + max(0, H_over − 2) × 1,0)
+ ставка_часа × H_holiday
+ премия за отсутствие нарушений
```
При графике «сутки через трое» обязателен суммированный учёт: переработка определяется по итогам учётного периода, а не по дням. Надбавки не поглощают друг друга: ночная работа в праздник оплачивается и как ночная, и как праздничная.
## 3. Что из этого поддерживает движок
Реализовано в `src/domain/payroll/rule.ts`: процент от показателя, ставка за час и за смену, ставка за единицу, агрегаты по позициям с фильтрами (количество, сумма, среднее, число уникальных значений), поштучная оценка строки по её атрибутам, шкала и прогрессивная шкала, фонд с делением по времени присутствия и весами, условия с И/ИЛИ, гарантия и потолок, округление, типизированные часы (ночные, сверхурочные, праздничные), окно агрегации шире дня (неделя, декада, месяц, период).
Пресеты в `src/domain/payroll/presets.ts`: общепит (фонд, личный процент, официант с допродажами, администратор с гарантией, почасовая, премия за порог выручки), розница и продажи (две шкалы), доставка (два варианта), услуги с мастерами, сменная работа с ночными и сверхурочными, B2B с месячной прогрессией, простая почасовая.
Не реализовано и вынесено в следующий шаг:
1. **Блок вычета.** Удержание должно быть отдельной сущностью с основанием, лимитом и следом в аудите, а не отрицательным слагаемым формулы — см. [правовые ограничения](PAYROLL-LEGAL-LIMITS.md).
2. **Фонд сверх тарифа** как готовый блок (сейчас выражается комбинацией).
3. **Линтер правил**: предупреждение о начислении ниже МРОТ пропорционально отработанному, об отсутствии оплаты ночных и сверхурочных, об отрицательных узлах с дисциплинарным основанием.
## 4. Чего рынок не подтверждает
Эти конструкции встречаются в обсуждениях, но подтверждения как отраслевой нормы нет — в пресетах они допустимы как значения по умолчанию с пометкой «настройте под себя»:
- многоступенчатая шкала процента мастера салона (практика — плоский процент по категории либо порог на превышение);
- сдельщина «за строку» у сборщиков заказов (доминирует почасовая с бонусом за выработку);
- конкретная прогрессия процента персонального тренера по порогам числа тренировок (структура уровней подтверждается, ставки — нет).

131
docs/REQ-001-PAYROLL.md Normal file
View file

@ -0,0 +1,131 @@
# REQ-001: Универсальный расчёт заработной платы
- Статус: утверждено владельцем 2026-09-23, реализуется
- Контур: управленческий учёт. Официальный расчёт (НДФЛ, страховые взносы, МРОТ, кадровые документы) — следующий этап, схема данных готовится под него заранее
- Проверочный пример: таблица владельца «Доходы и расходы 2026 год», листы «График» и «Расчет ЗП с доп. поваром»
## 1. Задача
Модуль считает переменную часть зарплаты **для любой организации**, а не для одного кафе. Главный сценарий — переход с Excel: основная масса малого бизнеса ведёт расчёт в таблицах со своими формулами, и продукт обязан принять эти формулы, а не навязать свои.
Отсюда три обязательных свойства:
1. **Формулу задаёт клиент.** Правило оплаты — не код, а настройка: собирается в визуальном конструкторе из блоков, хранится как структура, показывается человеку словами.
2. **Переход без потерь.** Историю из таблицы можно загрузить, сверить с расчётом модуля и убедиться, что суммы совпадают, прежде чем отказываться от Excel.
3. **Формулы меняются со временем.** Изменение ставки или правила создаёт новую версию; прошлые ведомости пересчёту не подлежат и воспроизводятся в точности.
Остальное — учёт смен (факт из терминала плюс подтверждение управляющего), ведомость, выплаты, остатки, мониторинг и кабинет сотрудника в мобильном приложении.
## 2. Словарь
| Термин | Значение |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Подразделение (`OrganizationUnit`) | Точка, цех, магазин, участок. К нему привязаны показатели дня и смены |
| Показатель дня | Числовая величина подразделения за день: выручка, число чеков, объём производства. Состав определяет клиент |
| Позиция | Оплачиваемая строка дня сотрудника: заказ, доставка, услуга, партия. Имеет атрибуты: сумма, дальность, тип клиента |
| Смена (`WorkSession`) | Факт работы сотрудника в подразделении за операционный день |
| Правило оплаты (`PayRule`) | Дерево блоков, по которому считается начисление. Версионируется |
| Параметр сотрудника | Значение, которое правило подставляет для конкретного человека: ставка, процент, надбавка, гарантия, план |
| Фонд дня | Сумма, которую участники смены делят между собой. Задаётся правилом подразделения |
| Ведомость (`PayrollRun`) | Расчёт за период со строками по сотруднику и дню |
| Остаток | Начислено минус выплачено нарастающим итогом. Плюс — долг перед сотрудником |
## 3. Конструктор правил
Правило собирается из блоков. Набор покрывает схемы, которые встречаются на практике:
| Блок | Назначение |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Показатель, параметр, часы, число участников | Источники значений |
| Сложение, вычитание, умножение, деление | Арифметика |
| Процент от значения | Процент от выручки, от продаж, от суммы заказов |
| Доля из фонда | Командная схема: фонд делится между участниками смены |
| Сумма и количество позиций с фильтром | Сдельщина: «сумма заказов дешевле 5000», «количество доставок» |
| За каждую позицию | Оплата за единицу, в том числе по шкале от атрибута позиции |
| Шкала по ступеням | Ставка выбирается по достигнутому уровню и применяется ко всей сумме |
| Прогрессивная шкала | Каждая ступень применяется к своему слою: 3% до 100 тыс, 5% дальше |
| Условие | Премия при выполнении условий: выручка выше порога и на смене не меньше трёх человек |
| Наибольшее, наименьшее | Гарантированный минимум и потолок выплаты |
| Округление | Шаг округления строки начисления |
| Ночные, сверхурочные, праздничные часы | Доплаты, обязательные по ТК РФ: 20% за ночные, 1,5 и 2 за переработку, двойная за праздник |
| Окно агрегации | Показатель или позиции берутся за смену, день, неделю, декаду или месяц: фонд считается на дне, а прогрессивная шкала обычно на месяце |
Полный разбор отраслевых схем — в [справочнике схем](PAYROLL-SCHEMES-CATALOGUE.md); ограничения, которые нельзя нарушать, — в [правовых ограничениях](PAYROLL-LEGAL-LIMITS.md).
Примеры правил, которые из этого собираются:
```
Повар в команде: доля из фонда + ставка × часы + надбавка
Администратор: наибольшее(процент от выручки + ставка × часы; гарантия смены)
Продавец: оплата смены + прогрессивный процент от личных продаж
Курьер: ставка × часы
+ процент от суммы заказов дешевле 5000 ₽
+ за каждый заказ дороже 5000 ₽: по шкале дальности (250 или 500 ₽)
Мастер услуг: наибольшее(50% своих клиентов + 35% клиентов салона; гарантия смены)
Премия за выручку: если выручка ≥ порога и участников ≥ 3, добавить премию
```
Каждое правило движок умеет пересказать словами — этот же текст показывается сотруднику в расшифровке суммы.
### 3.1 Фонд и неполная смена
Фонд подразделения за день задаётся отдельным правилом (например, 11% от выручки). Он распределяется **по времени присутствия**: в каждый отрезок смены фонд делится между теми, кто в этот момент работал. Если участник ушёл раньше, он получает долю только за свои часы, а остальные забирают оставшуюся часть. При равных сменах это в точности «фонд ÷ число участников». Вес участника настраивается: полный участник, половина (например, дополнительный сотрудник) или без участия.
### 3.2 Округление и хранение
Каждая строка начисления округляется с настраиваемым шагом, по умолчанию до рубля. Остаток от округления остаётся в фонде и не приписывается сотруднику. Суммы хранятся в копейках (`BIGINT`), валюта — ISO 4217.
### 3.3 Версии правил
Правило и его параметры не редактируются на месте: правка создаёт следующую версию с датой начала действия. Ведомость хранит ссылку на версию и полный след расчёта (входные показатели, фонд, доля, параметры, текст правила), поэтому любую сумму можно объяснить и воспроизвести спустя год.
## 4. Периоды, ведомость, выплаты
- Период настраивается: неделя (в том числе начиная с четверга, как в таблице владельца), две недели, месяц.
- Статусы ведомости: `draft → calculated → approved → paid`. После `approved` строки не пересчитываются; исправление — отдельной корректировкой.
- Выплаты вводятся фактической суммой; разница с начисленным автоматически становится остатком и переносится дальше.
- Реестр начислений и выплат — только на добавление, на уровне прав базы данных.
## 5. Учёт часов
- Источник — события терминала (`check_in` / `check_out`) с идемпотентным ключом.
- Смена попадает в расчёт только после подтверждения управляющим.
- Корректировка часов не меняет исходные события: правка хранится отдельно с причиной.
- Незакрытая смена помечается аномалией после нормы плюс два часа.
## 6. Переход с Excel
1. **Загрузка таблицы.** Клиент загружает свой файл; мастер определяет листы и колонки: сотрудник, дата, подразделение, часы, сумма, показатели дня.
2. **Подбор формулы.** Система предлагает пресет отрасли и подбирает параметры сотрудников по истории, после чего показывает, на скольких днях расчёт совпал с таблицей и где разошёлся.
3. **Разбор расхождений.** По каждому расхождению видно, из чего сложилась сумма модуля и какая сумма стоит в таблице. Обычно расхождения — это ручные правки в Excel или изменение ставки, не отражённое в формуле.
4. **Параллельный период.** Клиент неделю ведёт расчёт и в таблице, и в модуле, сверяет итоги и после совпадения отключает таблицу.
Проверка метода на реальной таблице владельца: правило «фонд 11% ÷ участники + 100 ₽/ч + личная надбавка» воспроизводит суммы поваров точно; администраторы считаются как «процент от выручки + почасовая, но не ниже 3000 ₽». Расхождения объясняются изменением ставок в течение года и ручными правками — что и подтверждает необходимость версий и режима сверки.
## 7. Мониторинг
Ежедневный экран по подразделению: показатели дня, кто на смене, фонд, начислено, доля фонда оплаты в выручке, неподтверждённые и незакрытые смены, дни без введённых показателей. Недельный и месячный итог — по ведомости.
## 8. Доступ
| Роль | Права |
| ----------- | ------------------------------------------------------------------------------- |
| Владелец | всё, включая утверждение ведомостей, правил и мониторинг по всем подразделениям |
| Управляющий | показатели, подтверждение смен, расчёт и выплаты по своему подразделению |
| Сотрудник | только свои смены, начисления, выплаты, остаток и расшифровка сумм |
Сотрудник получает доступ по record-level правам платформы (`scope: own`). Данные других сотрудников недоступны ни в API, ни в приложении.
## 9. Границы этапа
**Входит:** подразделения, сотрудники, показатели и позиции, конструктор правил с версиями, пресеты отраслей, приём отметок терминала, смены и подтверждение, расчёт, ведомость, выплаты, остатки, импорт из Excel и сверка, API для мобильного приложения, мониторинг.
**Не входит:** НДФЛ и страховые взносы, кадровые документы, отпуска и больничные, планирование графиков, выгрузка в 1С, официальный табель Т-13, автоматическая интеграция с кассой (предусмотрена точка расширения — показатель с источником «модуль»).
## 10. Вопросы, которые остаются к владельцу по его пресету
1. Дополнительный сотрудник и стажёр считаются в делителе фонда как половина участника? В таблице встречаются оба варианта.
2. Гарантия администратора (3000 ₽) — за смену любой длительности или пропорционально часам?
3. Премия за выручку: при каком пороге и какая сумма, и зависит ли она от числа участников смены?
4. Перенос остатка: бессрочно или закрывается в конце месяца?
5. Выплаты: только наличные или бывают переводы, и нужно ли фиксировать, кто выдал деньги?

View file

@ -4,13 +4,16 @@
Runtime modes and ports, ULID/event envelope, PostgreSQL RLS infrastructure, outbox/inbox/idempotency/audit, manifest/settings/OpenAPI validation, health/version, Docker/Compose, CI and documentation.
Подтверждённый Payroll foundation: чистый движок правил, отраслевые пресеты, недельные периоды, распределение фонда, типизированные часы, схема версионированных правил/ведомостей/выплат, append-only ledger и тесты на примерах владельца.
## Следующий этап — только после требований владельца
1. Уточнить jurisdiction, terminology, data classification and ownership.
2. Согласовать Employee Directory и Employment workflows, затем создать первую vertical slice.
3. Отдельно описать Organization Structure, Scheduling, Time & Attendance, Leave, Payroll, Documents, Audit/Reporting.
4. Получить фактический Core gateway/JWT, Module SDK, Settings/Files/Notifications/Event Bus runtime contracts и реализовать platform adapters/conformance tests.
5. Утвердить event catalog/OpenAPI changes до публикации.
6. Утвердить backup RPO/RTO/retention и провести documented restore drill.
1. Реализовать API и административный интерфейс подразделений, показателей, правил оплаты и ведомостей поверх готового доменного ядра.
2. Связать подтверждённые смены терминала с `WorkSession`, сохранив ручную корректировку и аудит.
3. Добавить импорт и сверку с таблицами владельца перед переходом на расчёт в модуле.
4. Подключить мобильный кабинет сотрудника только к серверному API — без формул и расчёта на устройстве.
5. Получить фактический Core gateway/JWT, Module SDK, Settings/Files/Notifications/Event Bus runtime contracts и реализовать platform adapters/conformance tests.
6. Отдельно согласовать официальный расчёт, Scheduling, Leave, Documents и Audit/Reporting.
7. Утвердить backup RPO/RTO/retention и провести documented restore drill.
Никакой пункт не означает автоматически готовую бизнес-функцию или срок.

View file

@ -0,0 +1,81 @@
# ADR-002: Домен расчёта зарплаты и выравнивание с контрактом платформы
- Status: Accepted for REQ-001
- Date: 2026-09-23
- Контекст требований: [REQ-001](../REQ-001-PAYROLL.md)
## Context
Владелец подтвердил требования к расчёту зарплаты (управленческий контур), мониторингу смен и мобильному кабинету сотрудника. Модуль должен остаться блоком Krona Business Platform.
Проверка контрактов платформы (`ochenstarik/business-platform`, ветка `main`) показала:
- HR, Employee и payroll в платформе не реализованы; `docs/82-TIME-ATTENDANCE.md` и `docs/83-WORK-TIME-ENGINE.md` — только спецификации со статусом `Pilot 1: Deferred`;
- `docs/06-SOURCE-OF-TRUTH.md` объявляет владельцем справочника `Employee` ядро, но в коде ядра такого справочника нет;
- сущность «торговая точка» (`Branch` / `Location`) объявлена в `08-MULTITENANCY.md` и `05-CONTEXT-MAP.md`, но в коде отсутствует; в Pilot 1 организация ровно одна на tenant;
- рантайм модулей неполон: права из манифеста нельзя выдать роли, доставка событий модулю не работает (`PLAN-RAZRABOTKI.md`, пункты С-6 и С-4);
- исторические черновики расходились с нормативным контрактом платформы по формату прав и конверту события; интеграция следует актуальному `109-PLATFORM-BLOCK-CONTRACT.md`.
## Decision
### 1. Домен
Модуль универсальный: он не знает ни про повара, ни про кафе. Расчёт строится на сущностях `OrganizationUnit` (подразделение), `Employee`, `PayRule` (правило оплаты), `WorkSession` (смена), `PayrollRun` (ведомость) со строками `PayrollLine`, плюс входные данные: показатели дня (`staff_daily_metrics`) и позиции (`staff_work_items`) — состав того и другого определяет клиент. Деньги — `BIGINT` в копейках плюс `currency_code`. Ни правило, ни его параметры не редактируются на месте: изменение создаёт новую версию с датами действия, поэтому прошлые ведомости воспроизводимы.
### 2. Правило оплаты — дерево блоков, а не текст формулы
Правило хранится структурой (JSONB) и собирается визуальным конструктором: клиенты приходят из Excel и должны переносить свои формулы сами, без программиста и без изучения синтаксиса. Из этого следует:
- **никакого `eval` и никакого разбора текста** — дерево заведомо безопасно и валидируется перед сохранением;
- то же дерево пересказывается словами (`describeRule`) и служит расшифровкой суммы для сотрудника;
- набор блоков подобран по практике отраслей: процент от показателя, ставка за час и за единицу, фонд с делением по времени присутствия, агрегаты по позициям с фильтрами, ступенчатая и прогрессивная шкалы, условные премии, гарантия и потолок, округление;
- отраслевые пресеты (общепит, розница, доставка, услуги, почасовая) — это заранее собранные деревья, а не ветки в коде.
Текстовый редактор формул может появиться позже как второй способ ввода поверх той же структуры.
### 3. Расчёт — чистая функция
Движок не обращается к базе: на вход подаётся снимок дня (показатели, позиции, участники с их правилами, часами и параметрами), на выходе — строки с полным следом расчёта (фонд, делитель, доля, параметры, входные показатели, текст правила). След сохраняется в строке ведомости: любую сумму нужно уметь объяснить спустя год, а расчёт обязан быть детерминированным и покрытым тестами на реальных днях.
### 4. Начисления и выплаты — append-only реестр
`staff_payroll_ledger` пишется только вставкой (`REVOKE UPDATE, DELETE` для роли приложения, как того требует `PILOT-1-RBAC-MATRIX` платформы). Остаток сотрудника («Перенос» в таблице) — агрегат реестра, а не изменяемое поле. Исправление ошибки — сторнирующая запись, а не правка.
### 5. Биометрия не покидает терминал
Сервер принимает от терминала только `employee_id`, тип отметки и время. Шаблоны отпечатков остаются в локальном зашифрованном хранилище терминала. Это снижает объём обрабатываемых биометрических персональных данных на сервере до нуля и соответствует `85-BIOMETRIC-DATA-SECURITY.md` платформы. Требование письменного согласия на обработку биометрии в терминале сохраняется (аудит, пункт A2).
### 6. Временная собственность на `Employee` и `OrganizationUnit`
Пока в ядре нет ни справочника сотрудников, ни филиалов, Staff ведёт их сам, но:
- `Employee.core_user_id` — nullable-ссылка на учётную запись ядра, `User != Employee`;
- таблицы содержат `external_ref` для сопоставления при переносе владения в Core;
- модуль не заводит собственные таблицы пользователей, ролей и паролей.
Когда ядро получит `Employee`/`Branch`, Staff переходит на них через adapters; это зафиксировано как обязательство, а не «потом разберёмся».
### 7. Форматы платформы: следуем коду, а не документации
- права — точечные в нормативном формате платформы `staff:<resource>:<action>`, например `staff:payroll:read`; существующие права терминала сохраняются;
- сущности в манифесте — голый PascalCase (`PayrollRun`), как требует `packages/contracts/src/entity-index.ts`;
- события — плоский конверт каталога Pilot 1 (`event_id`, `event_type`, `version`, `tenant_id`, `occurred_at`, `actor_id`, `correlation_id`, `payload`), имена вида `staff.payroll.run_approved`;
- ответы API — конверт `data`/`meta`/`errors` платформы, а не RFC 7807; идемпотентность — заголовок `Idempotency-Key`.
Расхождения документации и кода платформы вынесены владельцу отдельным списком; выбор в пользу кода сделан потому, что именно он валидирует установку модуля.
### 8. Standalone остаётся рабочим режимом
Рантайм модулей платформы сегодня не может ни выдать права модуля роли, ни доставить событие модулю. Поэтому `standalone` — не временная заглушка, а поддерживаемый режим до готовности ядра: свой JWT для мобильного приложения и терминала, свои проверки прав. В `platform` режиме те же порты отдаются адаптерам ядра. Оба режима обязаны давать одинаковый результат расчёта.
### 9. Мобильное приложение — отдельный клиент поверх версионированного API
Приложение (React Native + Expo) не получает доступ к базе и не содержит правил расчёта: только вызовы API и отображение объяснения, посчитанного сервером. Контракт для него фиксируется в `api/openapi.yaml` и описан в [APP-CORE.md](../APP-CORE.md).
## Consequences
Расчёт воспроизводим, история ставок сохраняется, ведомости прошлых периодов не «плывут» при изменении ставок. Append-only реестр исключает тихую правку денег. Цена: больше таблиц и явная работа с версиями ставок вместо редактирования одного поля.
Временное владение `Employee` и `OrganizationUnit` — сознательный долг перед контрактом платформы; он погашается миграцией на Core, когда там появятся эти сущности.
Отклонено: считать зарплату на клиенте; хранить деньги в `NUMERIC`; редактировать ставки без версий; переносить шаблоны отпечатков на сервер; ждать готовности рантайма модулей платформы, ничего не делая.

View file

@ -0,0 +1,4 @@
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO krona_staff_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO krona_staff_app;

View file

@ -0,0 +1,4 @@
ALTER DEFAULT PRIVILEGES IN SCHEMA public
REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM krona_staff_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
REVOKE USAGE, SELECT ON SEQUENCES FROM krona_staff_app;

View file

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS staff_timesheet_entries;
DROP TABLE IF EXISTS staff_attendance_events;
DROP TABLE IF EXISTS staff_employees;

View file

@ -0,0 +1,82 @@
CREATE TABLE staff_employees (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
core_user_id VARCHAR(26) CHECK (core_user_id IS NULL OR core_user_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
personnel_number TEXT NOT NULL,
display_name TEXT NOT NULL,
position TEXT NOT NULL DEFAULT '',
gender TEXT NOT NULL DEFAULT '' CHECK (gender IN ('', 'male', 'female')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'blocked', 'dismissed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, personnel_number),
UNIQUE (tenant_id, core_user_id)
);
CREATE TABLE staff_attendance_events (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
event_type TEXT NOT NULL CHECK (event_type IN ('check_in', 'check_out')),
source TEXT NOT NULL CHECK (source IN ('terminal', 'mobile_app', 'manual_entry', 'web_portal')),
source_event_id TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
device_id VARCHAR(26) CHECK (device_id IS NULL OR device_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
status TEXT NOT NULL DEFAULT 'accepted' CHECK (status IN ('accepted', 'rejected', 'review_required')),
actor_id VARCHAR(26) NOT NULL CHECK (actor_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, source, source_event_id),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees(tenant_id, id)
);
CREATE INDEX staff_attendance_employee_time_idx
ON staff_attendance_events (tenant_id, employee_id, occurred_at DESC);
CREATE TABLE staff_timesheet_entries (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
work_date DATE NOT NULL,
minutes_worked INTEGER NOT NULL DEFAULT 0 CHECK (minutes_worked BETWEEN 0 AND 1440),
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'approved', 'rejected')),
note TEXT NOT NULL DEFAULT '',
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
updated_by VARCHAR(26) NOT NULL CHECK (updated_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, employee_id, work_date),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees(tenant_id, id)
);
CREATE INDEX staff_timesheet_period_idx
ON staff_timesheet_entries (tenant_id, work_date, employee_id);
ALTER TABLE staff_employees ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_employees FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_employees_tenant_isolation ON staff_employees
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
ALTER TABLE staff_attendance_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_attendance_events FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_attendance_events_tenant_isolation ON staff_attendance_events
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (
tenant_id = current_setting('app.tenant_id', true)
AND actor_id = current_setting('app.user_id', true)
);
ALTER TABLE staff_timesheet_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_timesheet_entries FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_timesheet_entries_tenant_isolation ON staff_timesheet_entries
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (
tenant_id = current_setting('app.tenant_id', true)
AND updated_by = current_setting('app.user_id', true)
);
GRANT SELECT, INSERT, UPDATE ON staff_employees TO krona_staff_app;
GRANT SELECT, INSERT ON staff_attendance_events TO krona_staff_app;
REVOKE UPDATE, DELETE, TRUNCATE ON staff_attendance_events FROM krona_staff_app;
GRANT SELECT, INSERT, UPDATE ON staff_timesheet_entries TO krona_staff_app;

View file

@ -0,0 +1,25 @@
DROP TABLE IF EXISTS staff_payroll_ledger;
DROP TABLE IF EXISTS staff_payouts;
DROP TABLE IF EXISTS staff_payroll_lines;
DROP TABLE IF EXISTS staff_payroll_runs;
DROP TABLE IF EXISTS staff_work_sessions;
DROP TABLE IF EXISTS staff_pay_assignments;
DROP TABLE IF EXISTS staff_pay_rules;
DROP TABLE IF EXISTS staff_work_items;
DROP TABLE IF EXISTS staff_daily_metrics;
DROP TABLE IF EXISTS staff_metric_definitions;
ALTER TABLE staff_attendance_events
DROP CONSTRAINT IF EXISTS staff_attendance_events_org_unit_fk,
DROP COLUMN IF EXISTS payload,
DROP COLUMN IF EXISTS org_unit_id;
ALTER TABLE staff_employees
DROP CONSTRAINT IF EXISTS staff_employees_primary_org_unit_fk,
DROP COLUMN IF EXISTS terminated_on,
DROP COLUMN IF EXISTS hired_on,
DROP COLUMN IF EXISTS external_ref,
DROP COLUMN IF EXISTS primary_org_unit_id;
DROP TABLE IF EXISTS staff_org_unit_terms;
DROP TABLE IF EXISTS staff_org_units;

View file

@ -0,0 +1,302 @@
-- REQ-001 payroll foundation integrated after the attendance portal.
-- Money is BIGINT in minor units; rates and weights use basis points (10000 = 100%).
CREATE TABLE staff_org_units (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
code TEXT NOT NULL CHECK (code ~ '^[a-z0-9_-]{1,40}$'),
name TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Asia/Novosibirsk',
external_ref TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE (tenant_id, id)
);
CREATE UNIQUE INDEX staff_org_units_code_idx ON staff_org_units (tenant_id, code)
WHERE deleted_at IS NULL;
CREATE TABLE staff_org_unit_terms (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
org_unit_id VARCHAR(26) NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE,
pool_rule JSONB,
shift_norm_minutes INTEGER NOT NULL DEFAULT 720 CHECK (shift_norm_minutes BETWEEN 60 AND 2880),
rounding_unit_minor BIGINT NOT NULL DEFAULT 100 CHECK (rounding_unit_minor > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
CHECK (effective_to IS NULL OR effective_to >= effective_from)
);
CREATE INDEX staff_org_unit_terms_lookup_idx
ON staff_org_unit_terms (tenant_id, org_unit_id, effective_from DESC);
ALTER TABLE staff_employees
ADD COLUMN primary_org_unit_id VARCHAR(26),
ADD COLUMN external_ref TEXT,
ADD COLUMN hired_on DATE,
ADD COLUMN terminated_on DATE,
ADD CONSTRAINT staff_employees_primary_org_unit_fk
FOREIGN KEY (tenant_id, primary_org_unit_id) REFERENCES staff_org_units (tenant_id, id);
ALTER TABLE staff_attendance_events
ADD COLUMN org_unit_id VARCHAR(26),
ADD COLUMN payload JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD CONSTRAINT staff_attendance_events_org_unit_fk
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id);
CREATE TABLE staff_metric_definitions (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
key TEXT NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]{0,60}$'),
label TEXT NOT NULL,
value_kind TEXT NOT NULL DEFAULT 'money' CHECK (value_kind IN ('money', 'percent', 'number')),
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'module', 'import')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, key)
);
CREATE TABLE staff_daily_metrics (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
org_unit_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
metric_key TEXT NOT NULL CHECK (metric_key ~ '^[a-z][a-z0-9_]{0,60}$'),
value BIGINT NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
breakdown JSONB NOT NULL DEFAULT '{}'::jsonb,
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'module', 'import')),
recorded_by VARCHAR(26) NOT NULL CHECK (recorded_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, org_unit_id, business_date, metric_key),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
FOREIGN KEY (tenant_id, metric_key) REFERENCES staff_metric_definitions (tenant_id, key)
);
CREATE TABLE staff_work_items (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
org_unit_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
item_kind TEXT NOT NULL CHECK (item_kind ~ '^[a-z][a-z0-9_]{0,40}$'),
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'module', 'import')),
external_key TEXT,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id)
);
CREATE INDEX staff_work_items_day_idx ON staff_work_items (tenant_id, employee_id, business_date);
CREATE UNIQUE INDEX staff_work_items_external_idx ON staff_work_items (tenant_id, external_key)
WHERE external_key IS NOT NULL;
CREATE TABLE staff_pay_rules (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
code TEXT NOT NULL CHECK (code ~ '^[a-z][a-z0-9_]{0,40}$'),
version INTEGER NOT NULL CHECK (version > 0),
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
preset_code TEXT,
rule JSONB NOT NULL,
param_declarations JSONB NOT NULL DEFAULT '[]'::jsonb,
pool_weight_bp INTEGER NOT NULL DEFAULT 0 CHECK (pool_weight_bp BETWEEN 0 AND 20000),
effective_from DATE NOT NULL,
effective_to DATE,
approved_by VARCHAR(26) CHECK (approved_by IS NULL OR approved_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
approved_at TIMESTAMPTZ,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, code, version),
CHECK (effective_to IS NULL OR effective_to >= effective_from)
);
CREATE INDEX staff_pay_rules_lookup_idx ON staff_pay_rules (tenant_id, code, effective_from DESC);
CREATE TABLE staff_pay_assignments (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
org_unit_id VARCHAR(26),
pay_rule_id VARCHAR(26) NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE,
params JSONB NOT NULL DEFAULT '{}'::jsonb,
pool_weight_bp INTEGER CHECK (pool_weight_bp IS NULL OR pool_weight_bp BETWEEN 0 AND 20000),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
FOREIGN KEY (tenant_id, pay_rule_id) REFERENCES staff_pay_rules (tenant_id, id),
CHECK (effective_to IS NULL OR effective_to >= effective_from)
);
CREATE INDEX staff_pay_assignments_lookup_idx
ON staff_pay_assignments (tenant_id, employee_id, effective_from DESC);
CREATE TABLE staff_work_sessions (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
org_unit_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
worked_minutes INTEGER NOT NULL DEFAULT 0 CHECK (worked_minutes BETWEEN 0 AND 2880),
source TEXT NOT NULL DEFAULT 'terminal' CHECK (source IN ('terminal', 'manual')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'pending_review', 'confirmed', 'rejected')),
anomaly TEXT,
correction_note TEXT,
confirmed_by VARCHAR(26) CHECK (confirmed_by IS NULL OR confirmed_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
confirmed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, employee_id, org_unit_id, business_date),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
CHECK (ended_at IS NULL OR started_at IS NULL OR ended_at >= started_at)
);
CREATE INDEX staff_work_sessions_review_idx ON staff_work_sessions (tenant_id, business_date DESC, status);
CREATE TABLE staff_payroll_runs (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
org_unit_id VARCHAR(26),
period_start DATE NOT NULL,
period_end DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'calculated', 'approved', 'paid')),
total_accrued_minor BIGINT NOT NULL DEFAULT 0,
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
calculated_at TIMESTAMPTZ,
approved_by VARCHAR(26) CHECK (approved_by IS NULL OR approved_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
approved_at TIMESTAMPTZ,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
CHECK (period_end >= period_start)
);
CREATE UNIQUE INDEX staff_payroll_runs_period_unit_idx
ON staff_payroll_runs (tenant_id, org_unit_id, period_start) WHERE org_unit_id IS NOT NULL;
CREATE UNIQUE INDEX staff_payroll_runs_period_all_idx
ON staff_payroll_runs (tenant_id, period_start) WHERE org_unit_id IS NULL;
CREATE TABLE staff_payroll_lines (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
run_id VARCHAR(26) NOT NULL,
employee_id VARCHAR(26) NOT NULL,
org_unit_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
pay_rule_id VARCHAR(26) NOT NULL,
rule_code TEXT NOT NULL,
worked_minutes INTEGER NOT NULL DEFAULT 0,
pool_amount_minor BIGINT NOT NULL DEFAULT 0,
pool_divisor_bp INTEGER NOT NULL DEFAULT 0,
pool_share_minor BIGINT NOT NULL DEFAULT 0,
raw_amount_minor BIGINT NOT NULL DEFAULT 0,
amount_minor BIGINT NOT NULL DEFAULT 0,
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
explain JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, run_id, employee_id, org_unit_id, business_date),
FOREIGN KEY (tenant_id, run_id) REFERENCES staff_payroll_runs (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
FOREIGN KEY (tenant_id, pay_rule_id) REFERENCES staff_pay_rules (tenant_id, id)
);
CREATE INDEX staff_payroll_lines_employee_idx
ON staff_payroll_lines (tenant_id, employee_id, business_date DESC);
CREATE TABLE staff_payouts (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
run_id VARCHAR(26),
paid_on DATE NOT NULL,
amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
method TEXT NOT NULL DEFAULT 'cash' CHECK (method IN ('cash', 'transfer')),
note TEXT,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, run_id) REFERENCES staff_payroll_runs (tenant_id, id)
);
CREATE INDEX staff_payouts_employee_idx ON staff_payouts (tenant_id, employee_id, paid_on DESC);
CREATE TABLE staff_payroll_ledger (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('accrual', 'payout', 'adjustment')),
amount_minor BIGINT NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
ref_type TEXT NOT NULL CHECK (ref_type IN ('payroll_run', 'payout', 'manual')),
ref_id VARCHAR(26),
correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
note TEXT,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id)
);
CREATE INDEX staff_payroll_ledger_balance_idx
ON staff_payroll_ledger (tenant_id, employee_id, business_date);
DO $$
DECLARE target_table TEXT;
BEGIN
FOREACH target_table IN ARRAY ARRAY[
'staff_org_units', 'staff_org_unit_terms', 'staff_metric_definitions',
'staff_daily_metrics', 'staff_work_items', 'staff_pay_rules', 'staff_pay_assignments',
'staff_work_sessions', 'staff_payroll_runs', 'staff_payroll_lines', 'staff_payouts'
] LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table);
EXECUTE format(
'CREATE POLICY %I ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))'
|| ' WITH CHECK (tenant_id = current_setting(''app.tenant_id'', true))',
target_table || '_tenant_isolation', target_table
);
END LOOP;
END
$$;
ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger
FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger
FOR INSERT WITH CHECK (
tenant_id = current_setting('app.tenant_id', true)
AND created_by = current_setting('app.user_id', true)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON
staff_org_units, staff_org_unit_terms, staff_metric_definitions, staff_daily_metrics,
staff_work_items, staff_pay_rules, staff_pay_assignments, staff_work_sessions,
staff_payroll_runs, staff_payroll_lines, staff_payouts
TO krona_staff_app;
GRANT SELECT, INSERT ON staff_payroll_ledger TO krona_staff_app;
REVOKE UPDATE, DELETE, TRUNCATE ON staff_payroll_ledger FROM krona_staff_app;

View file

@ -13,10 +13,45 @@
"staff:employment:write",
"staff:sensitive:read",
"staff:sensitive:write",
"staff:audit:read"
"staff:audit:read",
"staff:attendance:read_own",
"staff:attendance:clock",
"staff:attendance:manage",
"staff:attendance:approve",
"staff:org_unit:read",
"staff:org_unit:write",
"staff:shift:read",
"staff:shift:confirm",
"staff:metric:read",
"staff:metric:write",
"staff:pay_rule:read",
"staff:pay_rule:write",
"staff:payroll:read",
"staff:payroll:calculate",
"staff:payroll:approve",
"staff:payout:write",
"staff:payslip:read_own"
],
"entities": [
"Employee",
"Employment",
"OrganizationUnit",
"AttendanceEvent",
"TimesheetEntry",
"MetricDefinition",
"PayRule",
"WorkSession",
"PayrollRun",
"Payout"
],
"events_emitted": [
"attendance.clock.in",
"attendance.clock.out",
"staff.work_session.confirmed",
"staff.payroll.run_calculated",
"staff.payroll.run_approved",
"staff.payout.registered"
],
"entities": ["Employee", "Employment", "OrganizationUnit"],
"events_emitted": [],
"events_consumed": [],
"settings_schema": "settings.schema.json",
"configurable_entities": ["Employee", "Employment", "OrganizationUnit"],

View file

@ -36,7 +36,7 @@
},
"permissions": {
"type": "array",
"items": { "type": "string", "pattern": "^staff:[a-z_]+:(read|write)$" },
"items": { "type": "string", "pattern": "^staff:[a-z_]+:[a-z_]+$" },
"uniqueItems": true
},
"entities": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },

View file

@ -25,6 +25,7 @@
"type": {
"enum": [
"text",
"integer",
"number",
"boolean",
"date",

View file

@ -13,4 +13,11 @@ for (const file of files.filter((f) => f.endsWith('.sql'))) {
if (/\b(DECIMAL|NUMERIC|REAL|DOUBLE PRECISION)\b/i.test(sql))
throw new Error(`${file}: unsafe numeric money type detected`);
}
const currentDefaultPrivileges = await readFile(
'migrations/0004_revoke_broad_default_privileges.up.sql',
'utf8',
);
if (/ALTER DEFAULT PRIVILEGES[\s\S]*?\bGRANT\b/i.test(currentDefaultPrivileges)) {
throw new Error('Runtime roles must receive explicit object grants; broad default grants are forbidden');
}
console.log(`validated ${ups.length} reversible migration(s)`);

View file

@ -0,0 +1,52 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$protectedDataAssembly = Join-Path $repositoryRoot 'artifacts\pilot-terminal\System.Security.Cryptography.ProtectedData.dll'
if (-not (Test-Path -LiteralPath $protectedDataAssembly -PathType Leaf)) {
throw "DPAPI assembly is missing: $protectedDataAssembly"
}
Add-Type -Path $protectedDataAssembly
$statePath = Join-Path $env:LOCALAPPDATA 'Krona\StaffTerminal\terminal-state.dat'
$pilotDirectory = Join-Path $env:LOCALAPPDATA 'KronaStaff\pilot'
$tokenPath = Join-Path $pilotDirectory 'terminal.token'
$certificatePath = Join-Path $pilotDirectory 'certificate.sha256'
foreach ($requiredPath in @($statePath, $tokenPath, $certificatePath)) {
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
throw "Required pilot file is missing: $requiredPath"
}
}
$entropy = [System.Text.Encoding]::UTF8.GetBytes('Krona.Staff.Terminal.v1')
$encrypted = [System.IO.File]::ReadAllBytes($statePath)
$clear = [System.Security.Cryptography.ProtectedData]::Unprotect(
$encrypted,
$entropy,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser)
$json = [System.Text.Encoding]::UTF8.GetString($clear)
$state = $json | ConvertFrom-Json
if ($null -eq $state.Settings) {
throw 'Terminal state does not contain Settings.'
}
$state.Settings | Add-Member -NotePropertyName StaffServerUrl -NotePropertyValue 'https://192.168.1.81:8453' -Force
$state.Settings | Add-Member -NotePropertyName StaffServerToken -NotePropertyValue ((Get-Content -LiteralPath $tokenPath -Raw).Trim()) -Force
$state.Settings | Add-Member -NotePropertyName StaffServerCertificateSha256 -NotePropertyValue ((Get-Content -LiteralPath $certificatePath -Raw).Trim()) -Force
$updatedJson = $state | ConvertTo-Json -Depth 30
$updatedClear = [System.Text.Encoding]::UTF8.GetBytes($updatedJson)
$updatedEncrypted = [System.Security.Cryptography.ProtectedData]::Protect(
$updatedClear,
$entropy,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser)
$backupPath = "$statePath.pilot-backup"
if (-not (Test-Path -LiteralPath $backupPath)) {
Copy-Item -LiteralPath $statePath -Destination $backupPath
}
$temporaryPath = "$statePath.pilot-tmp"
[System.IO.File]::WriteAllBytes($temporaryPath, $updatedEncrypted)
Move-Item -LiteralPath $temporaryPath -Destination $statePath -Force
Write-Output 'Pilot server settings were imported into the DPAPI-protected terminal state.'

View file

@ -16,7 +16,7 @@ try {
);
}
const statement = await client.query(
"SELECT format('ALTER ROLE krona_staff_app WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS', $1) AS sql",
"SELECT format('ALTER ROLE krona_staff_app WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS', $1::text) AS sql",
[password],
);
await client.query(statement.rows[0].sql);

View file

@ -13,6 +13,56 @@
"description": "IANA timezone; timestamps are persisted in UTC and interpreted using this tenant setting."
}
]
},
{
"key": "payroll",
"label": "Расчёт зарплаты",
"fields": [
{
"key": "week_start_day",
"type": "select",
"label": "Первый день расчётной недели",
"required": true,
"description": "Определяет начало недельного расчётного периода."
},
{
"key": "payroll_currency",
"type": "text",
"label": "Валюта расчёта",
"required": true,
"description": "Код ISO 4217; суммы хранятся в минимальных единицах валюты."
},
{
"key": "shift_autoclose_hours",
"type": "integer",
"label": "Порог незакрытой смены, часов",
"description": "После этого срока смена помечается аномалией и не попадает в расчёт без решения управляющего."
},
{
"key": "require_shift_confirmation",
"type": "boolean",
"label": "Требовать подтверждение смены управляющим",
"description": "Неподтверждённая смена не участвует в начислении."
}
]
},
{
"key": "employee_access",
"label": "Доступ сотрудников",
"fields": [
{
"key": "employee_portal_enabled",
"type": "boolean",
"label": "Показывать сотруднику собственные начисления",
"description": "Сотрудник видит только свои смены, начисления, выплаты и остаток."
},
{
"key": "show_pending_runs_to_employee",
"type": "boolean",
"label": "Показывать неутверждённую ведомость",
"description": "Если выключено, сотрудник видит суммы только после утверждения ведомости."
}
]
}
]
}

View file

@ -1,19 +1,50 @@
import { timingSafeEqual } from 'node:crypto';
import type { AppConfig } from '../config.js';
import type { IdentityPort } from '../core/ports.js';
import { parseUlid, type Ulid } from '../core/ids.js';
export class StandaloneIdentityAdapter implements IdentityPort {
constructor(private readonly config: AppConfig) {}
async resolve(_token: string | undefined, correlationId: Ulid) {
async resolve(token: string | undefined, correlationId: Ulid) {
const permissionsByAccess = {
employee: ['staff:attendance:read_own'],
manager: [
'staff:employee:read',
'staff:employee:write',
'staff:attendance:read_own',
'staff:attendance:manage',
'staff:attendance:approve',
],
terminal: ['staff:attendance:clock'],
} as const;
let access = this.config.STANDALONE_ACCESS;
let actorId = this.config.STANDALONE_ACTOR_ID;
if (this.config.STANDALONE_MANAGER_TOKEN && this.config.STANDALONE_TERMINAL_TOKEN) {
if (secureTokenEquals(token, this.config.STANDALONE_MANAGER_TOKEN)) {
access = 'manager';
} else if (secureTokenEquals(token, this.config.STANDALONE_TERMINAL_TOKEN)) {
access = 'terminal';
actorId = this.config.STANDALONE_TERMINAL_ACTOR_ID ?? actorId;
} else {
throw new Error('Invalid standalone access token');
}
}
return {
tenantId: parseUlid(this.config.STANDALONE_TENANT_ID),
actorId: parseUlid(this.config.STANDALONE_ACTOR_ID),
permissions: new Set(['staff:health:read']),
actorId: parseUlid(actorId),
permissions: new Set(['staff:health:read', ...permissionsByAccess[access]]),
correlationId,
};
}
}
function secureTokenEquals(candidate: string | undefined, expected: string): boolean {
if (!candidate) return false;
const candidateBuffer = Buffer.from(candidate, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
}
export class PlatformIdentityAdapter implements IdentityPort {
async resolve(): Promise<never> {
throw new Error(

View file

@ -1,4 +1,5 @@
import Fastify from 'fastify';
import { readFileSync } from 'node:fs';
import helmet from '@fastify/helmet';
import sensible from '@fastify/sensible';
import swagger from '@fastify/swagger';
@ -6,6 +7,8 @@ import type { AppConfig } from './config.js';
import { Database } from './infrastructure/database.js';
import { newUlid } from './core/ids.js';
import { SERVICE_NAME, SERVICE_VERSION } from './version.js';
import { registerPortalRoutes } from './portal.js';
import { portalCss, portalHtml, portalJs, portalManifest, portalServiceWorker } from './portal-ui.js';
export interface BuildAppOptions {
config: AppConfig;
@ -19,7 +22,15 @@ export async function buildApp({
config,
database = new Database(config.DATABASE_URL),
}: BuildAppOptions) {
const https = config.HTTPS_PFX_PATH
? {
pfx: readFileSync(config.HTTPS_PFX_PATH),
passphrase: config.HTTPS_PFX_PASSPHRASE,
minVersion: 'TLSv1.2' as const,
}
: undefined;
const app = Fastify({
...(https ? { https } : {}),
logger: { level: config.LOG_LEVEL },
genReqId: (request) => {
const supplied = request.headers['x-correlation-id']?.toString();
@ -110,16 +121,16 @@ export async function buildApp({
}),
);
api.get('/openapi.json', async () => app.swagger());
await registerPortalRoutes(api, config, database);
},
{ prefix: apiPrefix },
);
app.get('/', async (_request, reply) =>
reply
.type('text/html')
.send(
`<!doctype html><html lang="ru"><meta charset="utf-8"><title>Krona Staff</title><body><main><h1>Krona Staff</h1><p>Архитектурная основа модуля. Предметные функции ожидают требований владельца.</p><nav><a href="${apiPrefix}/openapi.json">OpenAPI</a> · <a href="${apiPrefix}/live">Live</a> · <a href="${apiPrefix}/ready">Ready</a> · <a href="${apiPrefix}/version">Version</a></nav></main></body></html>`,
),
);
app.get('/', async (_request, reply) => reply.redirect('/staff'));
app.get('/staff', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml));
app.get('/staff/styles.css', async (_request, reply) => reply.type('text/css; charset=utf-8').send(portalCss));
app.get('/staff/app.js', async (_request, reply) => reply.type('application/javascript; charset=utf-8').send(portalJs));
app.get('/staff/sw.js', async (_request, reply) => reply.header('service-worker-allowed', '/staff').type('application/javascript; charset=utf-8').send(portalServiceWorker));
app.get('/staff/manifest.webmanifest', async (_request, reply) => reply.type('application/manifest+json').send(portalManifest));
app.addHook('onClose', async () => database.close());
return app;

View file

@ -3,8 +3,17 @@ import { z } from 'zod';
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
HOST: z.string().min(1).default('127.0.0.1'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'),
STANDALONE_ACCESS: z.enum(['employee', 'manager', 'terminal']).default('employee'),
STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
STANDALONE_TERMINAL_ACTOR_ID: emptyStringAsUndefined(
z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/).optional(),
),
HTTPS_PFX_PATH: emptyStringAsUndefined(z.string().optional()),
HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()),
DATABASE_URL: z.string().url(),
REDIS_URL: optionalUrl(),
PLATFORM_GATEWAY_URL: optionalUrl(),
@ -27,6 +36,12 @@ export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const config = configSchema.parse(env);
if (Boolean(config.HTTPS_PFX_PATH) !== Boolean(config.HTTPS_PFX_PASSPHRASE)) {
throw new Error('HTTPS_PFX_PATH and HTTPS_PFX_PASSPHRASE must be configured together');
}
if (Boolean(config.STANDALONE_MANAGER_TOKEN) !== Boolean(config.STANDALONE_TERMINAL_TOKEN)) {
throw new Error('Both standalone manager and terminal tokens must be configured together');
}
if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') {
throw new Error('STAFF_RUNTIME_MODE=standalone is not allowed in production');
}
@ -40,5 +55,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
for (const key of required)
if (!config[key]) throw new Error(`${key} is required in platform mode`);
}
if (config.NODE_ENV === 'production' && !config.HTTPS_PFX_PATH) {
throw new Error('HTTPS is required in production');
}
return config;
}

View file

@ -0,0 +1,287 @@
/**
* Payroll calculation for REQ-001. Pure: no database, no clock, no configuration lookups.
* The rule of every employee is a block tree (see rule.ts) configured by the organization itself.
* Every amount is an integer in minor units (kopecks); every rate is basis points (10000 = 100%).
*/
import {
describeRule,
evaluateRule,
usesPool,
type AggregationPeriod,
type HourKind,
type RuleItem,
type RuleNode,
type RuleWindow,
} from './rule.js';
type PeriodWindows = Partial<Record<AggregationPeriod, RuleWindow>>;
/** Merges the unit-wide windows (metrics) with the employee ones (items). */
function mergeWindows(unit?: PeriodWindows, employee?: PeriodWindows): PeriodWindows | undefined {
if (!unit && !employee) return undefined;
const periods = new Set<AggregationPeriod>([
...(Object.keys(unit ?? {}) as AggregationPeriod[]),
...(Object.keys(employee ?? {}) as AggregationPeriod[]),
]);
const merged: PeriodWindows = {};
for (const period of periods) {
const metrics = employee?.[period]?.metrics ?? unit?.[period]?.metrics;
const items = employee?.[period]?.items ?? unit?.[period]?.items;
merged[period] = {
...(metrics ? { metrics } : {}),
...(items ? { items } : {}),
};
}
return merged;
}
export interface DayParticipant {
employeeId: string;
workedMinutes: number;
/** Code of the pay rule version applied, stored on the payroll line for traceability. */
ruleCode: string;
rule: RuleNode;
/** Employee parameters of the day: rate, percent, bonus, guarantee, plan. */
params: Readonly<Record<string, number>>;
/** Share of a pool participant: 10000 is a full participant, 5000 a half, 0 keeps them out. */
poolWeightBp?: number;
/** Priced lines of the day for piece work: orders, deliveries, checks, services. */
items?: readonly RuleItem[];
/** Minutes paid differently by law: night, overtime, holiday. A subset of workedMinutes. */
minutesByKind?: Partial<Record<Exclude<HourKind, 'worked'>, number>>;
/** Values of this employee aggregated over wider windows, for month-wide scales. */
windows?: PeriodWindows;
}
export interface DayInput {
businessDate: string;
orgUnitId: string;
/** Daily metrics of the unit, in minor units for money: revenue_minor, checks, produced_units… */
metrics: Readonly<Record<string, number>>;
/** How the daily pool is formed, for example 11% of revenue. Omit when the unit has no pool. */
poolRule?: RuleNode;
shiftNormMinutes: number;
participants: readonly DayParticipant[];
currencyCode?: string;
/** Rounding step of a payroll line; one ruble by default. */
roundingUnitMinor?: number;
/** Unit metrics aggregated over wider windows, for rules that reference a month or a week. */
windows?: PeriodWindows;
}
export interface PayrollLine {
employeeId: string;
businessDate: string;
orgUnitId: string;
ruleCode: string;
workedMinutes: number;
poolAmountMinor: number;
poolDivisorBp: number;
poolShareMinor: number;
amountMinor: number;
rawAmountMinor: number;
currencyCode: string;
explain: {
rule: string;
params: Readonly<Record<string, number>>;
metrics: Readonly<Record<string, number>>;
pool_amount_minor: number;
pool_share_minor: number;
pool_weight_bp: number;
participants: number;
worked_minutes: number;
rounding_unit_minor: number;
};
}
export interface DayResult {
businessDate: string;
orgUnitId: string;
poolAmountMinor: number;
poolDivisorBp: number;
distributedPoolMinor: number;
totalAccruedMinor: number;
currencyCode: string;
lines: PayrollLine[];
}
const DEFAULT_ROUNDING_MINOR = 100;
export function roundAmount(amountMinor: number, unitMinor = DEFAULT_ROUNDING_MINOR): number {
const unit = unitMinor > 0 ? unitMinor : 1;
const sign = amountMinor < 0 ? -1 : 1;
return sign * Math.round(Math.abs(amountMinor) / unit) * unit;
}
function poolWeightOf(participant: DayParticipant): number {
if (participant.poolWeightBp !== undefined) return participant.poolWeightBp;
return usesPool(participant.rule) ? 10_000 : 0;
}
/**
* Splits the pool over the time actually worked. In each time slice the pool rate is shared by the
* participants still present, so someone who leaves early keeps only their slices and the rest of
* the shift is shared by whoever stayed. With equal shifts this collapses to pool / participants.
*/
function distributePool(
poolMinor: number,
participants: readonly DayParticipant[],
): Map<string, number> {
const shares = new Map<string, number>();
const pool = participants.filter((p) => poolWeightOf(p) > 0 && p.workedMinutes > 0);
for (const participant of pool) shares.set(participant.employeeId, 0);
if (pool.length === 0 || poolMinor <= 0) return shares;
const span = Math.max(...pool.map((p) => p.workedMinutes));
if (span <= 0) return shares;
const boundaries = [...new Set(pool.map((p) => p.workedMinutes))].sort((a, b) => a - b);
const exact = new Map<string, number>();
for (const participant of pool) exact.set(participant.employeeId, 0);
let sliceStart = 0;
for (const boundary of boundaries) {
const sliceMinutes = boundary - sliceStart;
sliceStart = boundary;
if (sliceMinutes <= 0) continue;
const present = pool.filter((p) => p.workedMinutes >= boundary);
const weightSum = present.reduce((sum, p) => sum + poolWeightOf(p), 0);
if (weightSum <= 0) continue;
const slicePool = (poolMinor * sliceMinutes) / span;
for (const participant of present) {
const current = exact.get(participant.employeeId) ?? 0;
exact.set(
participant.employeeId,
current + (slicePool * poolWeightOf(participant)) / weightSum,
);
}
}
// Integer split with largest-remainder so the distributed shares add up to the pool exactly.
let allocated = 0;
const remainders: Array<{ employeeId: string; remainder: number }> = [];
for (const [employeeId, value] of exact) {
const floored = Math.floor(value);
shares.set(employeeId, floored);
allocated += floored;
remainders.push({ employeeId, remainder: value - floored });
}
remainders.sort((a, b) => b.remainder - a.remainder || a.employeeId.localeCompare(b.employeeId));
let rest = poolMinor - allocated;
for (const { employeeId } of remainders) {
if (rest <= 0) break;
shares.set(employeeId, (shares.get(employeeId) ?? 0) + 1);
rest -= 1;
}
return shares;
}
export function calculateDay(input: DayInput): DayResult {
const currencyCode = input.currencyCode ?? 'RUB';
const roundingUnitMinor = input.roundingUnitMinor ?? DEFAULT_ROUNDING_MINOR;
const poolParticipants = input.participants.filter(
(p) => poolWeightOf(p) > 0 && p.workedMinutes > 0,
);
const poolDivisorBp = poolParticipants.reduce((sum, p) => sum + poolWeightOf(p), 0);
const poolAmountMinor = input.poolRule
? Math.round(
evaluateRule(input.poolRule, {
metrics: input.metrics,
params: {},
minutes: 0,
participants: poolParticipants.length,
poolShareMinor: 0,
shiftNormMinutes: input.shiftNormMinutes,
}),
)
: 0;
const poolShares = distributePool(poolAmountMinor, input.participants);
const lines: PayrollLine[] = input.participants.map((participant) => {
const poolShareMinor = poolShares.get(participant.employeeId) ?? 0;
const rawAmountMinor =
participant.workedMinutes > 0
? Math.round(
evaluateRule(participant.rule, {
metrics: input.metrics,
params: participant.params,
minutes: participant.workedMinutes,
participants: poolParticipants.length,
poolShareMinor,
shiftNormMinutes: input.shiftNormMinutes,
items: participant.items ?? [],
...(participant.minutesByKind ? { minutesByKind: participant.minutesByKind } : {}),
...(() => {
const windows = mergeWindows(input.windows, participant.windows);
return windows ? { windows } : {};
})(),
}),
)
: 0;
return {
employeeId: participant.employeeId,
businessDate: input.businessDate,
orgUnitId: input.orgUnitId,
ruleCode: participant.ruleCode,
workedMinutes: participant.workedMinutes,
poolAmountMinor,
poolDivisorBp,
poolShareMinor,
rawAmountMinor,
amountMinor: roundAmount(rawAmountMinor, roundingUnitMinor),
currencyCode,
explain: {
rule: describeRule(participant.rule),
params: participant.params,
metrics: input.metrics,
pool_amount_minor: poolAmountMinor,
pool_share_minor: poolShareMinor,
pool_weight_bp: poolWeightOf(participant),
participants: poolParticipants.length,
worked_minutes: participant.workedMinutes,
rounding_unit_minor: roundingUnitMinor,
},
};
});
return {
businessDate: input.businessDate,
orgUnitId: input.orgUnitId,
poolAmountMinor,
poolDivisorBp,
distributedPoolMinor: [...poolShares.values()].reduce((sum, value) => sum + value, 0),
totalAccruedMinor: lines.reduce((sum, line) => sum + line.amountMinor, 0),
currencyCode,
lines,
};
}
export interface EmployeePeriodTotal {
employeeId: string;
accruedMinor: number;
workedMinutes: number;
days: number;
}
/** Aggregates day results into the per-employee totals shown in the weekly payroll sheet. */
export function summarizePeriod(days: readonly DayResult[]): EmployeePeriodTotal[] {
const totals = new Map<string, EmployeePeriodTotal>();
for (const day of days) {
for (const line of day.lines) {
const current = totals.get(line.employeeId) ?? {
employeeId: line.employeeId,
accruedMinor: 0,
workedMinutes: 0,
days: 0,
};
current.accruedMinor += line.amountMinor;
current.workedMinutes += line.workedMinutes;
current.days += line.workedMinutes > 0 ? 1 : 0;
totals.set(line.employeeId, current);
}
}
return [...totals.values()].sort((a, b) => a.employeeId.localeCompare(b.employeeId));
}

View file

@ -0,0 +1,59 @@
/** Payroll periods. The owner's week runs Thursday to Wednesday; the start day is configurable. */
export interface PayrollPeriod {
start: string;
end: string;
}
const DAY_MS = 86_400_000;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function toUtcDate(date: string): Date {
if (!DATE_PATTERN.test(date)) throw new Error(`Expected a YYYY-MM-DD date, received "${date}"`);
const parsed = new Date(`${date}T00:00:00.000Z`);
if (Number.isNaN(parsed.getTime())) throw new Error(`Invalid date "${date}"`);
return parsed;
}
function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
/**
* Returns the payroll week containing `date`.
* `weekStartDow` follows JavaScript numbering: 0 is Sunday, 4 is Thursday.
*/
export function payrollWeek(date: string, weekStartDow = 4): PayrollPeriod {
if (!Number.isInteger(weekStartDow) || weekStartDow < 0 || weekStartDow > 6) {
throw new Error('weekStartDow must be an integer between 0 and 6');
}
const current = toUtcDate(date);
const offset = (current.getUTCDay() - weekStartDow + 7) % 7;
const start = new Date(current.getTime() - offset * DAY_MS);
const end = new Date(start.getTime() + 6 * DAY_MS);
return { start: toIsoDate(start), end: toIsoDate(end) };
}
/** Lists the business dates of a period, inclusive. */
export function datesInPeriod(period: PayrollPeriod): string[] {
const start = toUtcDate(period.start);
const end = toUtcDate(period.end);
if (end.getTime() < start.getTime()) throw new Error('Period end precedes period start');
const dates: string[] = [];
for (let time = start.getTime(); time <= end.getTime(); time += DAY_MS) {
dates.push(toIsoDate(new Date(time)));
}
return dates;
}
export interface LedgerEntry {
kind: 'accrual' | 'payout' | 'adjustment';
amountMinor: number;
}
/**
* Balance owed to an employee: accruals are positive, payouts negative.
* A positive balance is the carry-over debt shown as "Перенос" in the owner's sheet.
*/
export function ledgerBalance(entries: readonly LedgerEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.amountMinor, 0);
}

View file

@ -0,0 +1,632 @@
/**
* Industry presets. A client starts from one of these in the visual editor, changes the numbers and
* keeps working; nothing here is hardcoded into the engine — a preset is just a prepared block tree.
*/
import type { AggregationPeriod, HourKind, ItemFilter, RuleNode, TierBand } from './rule.js';
export type ValueKind = 'money' | 'percent' | 'number';
export interface MetricDeclaration {
key: string;
label: string;
kind: ValueKind;
/** Where the value comes from: entered by a manager or produced by another module. */
source: 'manual' | 'module';
}
export interface ItemFieldDeclaration {
key: string;
label: string;
kind: ValueKind;
}
export interface ParamDeclaration {
key: string;
label: string;
kind: ValueKind;
defaultValue: number;
}
export interface RuleTemplate {
code: string;
label: string;
description: string;
rule: RuleNode;
params: ParamDeclaration[];
/** Default weight in the daily pool: 10000 is a full participant, 0 keeps the role out. */
poolWeightBp: number;
}
export interface PayrollPreset {
code: string;
label: string;
description: string;
metrics: MetricDeclaration[];
/** Fields of a priced line when the preset uses piece work. */
itemFields?: ItemFieldDeclaration[];
poolRule?: RuleNode;
shiftNormMinutes: number;
roles: RuleTemplate[];
}
// Block builders. The visual editor produces the same shapes.
export const money = (value: number): RuleNode => ({ type: 'const', value, unit: 'money' });
export const percent = (basisPoints: number): RuleNode => ({
type: 'const',
value: basisPoints,
unit: 'percent',
});
export const count = (value: number): RuleNode => ({ type: 'const', value, unit: 'number' });
export const metric = (key: string): RuleNode => ({ type: 'metric', key });
export const param = (key: string): RuleNode => ({ type: 'param', key });
export const itemField = (field: string): RuleNode => ({ type: 'itemField', field });
export const hours: RuleNode = { type: 'hours' };
export const poolShare: RuleNode = { type: 'poolShare' };
export const participants: RuleNode = { type: 'participants' };
export const add = (left: RuleNode, right: RuleNode): RuleNode => ({ type: 'add', left, right });
export const mul = (left: RuleNode, right: RuleNode): RuleNode => ({ type: 'mul', left, right });
export const percentOf = (base: RuleNode, share: RuleNode): RuleNode => ({
type: 'percentOf',
base,
percent: share,
});
export const atLeast = (value: RuleNode, floor: RuleNode): RuleNode => ({
type: 'max',
args: [value, floor],
});
export const atMost = (value: RuleNode, cap: RuleNode): RuleNode => ({
type: 'min',
args: [value, cap],
});
export const sumOfItems = (field: string, where?: ItemFilter): RuleNode =>
where ? { type: 'items', agg: 'sum', field, where } : { type: 'items', agg: 'sum', field };
export const countOfItems = (where?: ItemFilter): RuleNode =>
where ? { type: 'items', agg: 'count', where } : { type: 'items', agg: 'count' };
export const perItem = (value: RuleNode, where?: ItemFilter): RuleNode =>
where ? { type: 'perItem', value, where } : { type: 'perItem', value };
export const tier = (on: RuleNode, bands: TierBand[]): RuleNode => ({ type: 'tier', on, bands });
export const progressive = (base: RuleNode, bands: TierBand[]): RuleNode => ({
type: 'progressive',
base,
bands,
});
export const sub = (left: RuleNode, right: RuleNode): RuleNode => ({ type: 'sub', left, right });
export const hoursOf = (kind: HourKind): RuleNode => ({ type: 'hours', kind });
export const metricOver = (key: string, period: AggregationPeriod): RuleNode => ({
type: 'metric',
key,
period,
});
export const avgOfItems = (field: string, where?: ItemFilter): RuleNode =>
where ? { type: 'items', agg: 'avg', field, where } : { type: 'items', agg: 'avg', field };
export const distinctItems = (field: string, where?: ItemFilter): RuleNode =>
where
? { type: 'items', agg: 'countDistinct', field, where }
: { type: 'items', agg: 'countDistinct', field };
const hourlyPay = mul(param('rate_minor'), hours);
const rateParam = (defaultRubles: number): ParamDeclaration => ({
key: 'rate_minor',
label: 'Ставка за час',
kind: 'money',
defaultValue: defaultRubles * 100,
});
/**
* Catering: the daily pool is a share of the unit revenue, the shift splits it by time on duty, the
* administrator earns a percent of revenue with a guaranteed minimum, trainees are paid by the hour.
* Reproduced from a real owner spreadsheet and covered by tests.
*/
export const cateringPreset: PayrollPreset = {
code: 'catering_revenue_pool',
label: 'Общепит: фонд от выручки',
description:
'Фонд дня — процент от выручки точки. Участники смены делят фонд по времени присутствия, сверху — почасовая, личная надбавка и премия за выручку.',
metrics: [
{ key: 'revenue_minor', label: 'Выручка точки за день', kind: 'money', source: 'manual' },
{
key: 'personal_sales_minor',
label: 'Личные продажи сотрудника',
kind: 'money',
source: 'module',
},
],
itemFields: [
{ key: 'amount_minor', label: 'Сумма чека', kind: 'money' },
{ key: 'category', label: 'Категория позиции', kind: 'number' },
],
poolRule: percentOf(metric('revenue_minor'), percent(1100)),
shiftNormMinutes: 720,
roles: [
{
code: 'pool_member',
label: 'Участник фонда (повар)',
description: 'Доля из фонда + почасовая + личная надбавка за смену',
rule: add(add(poolShare, hourlyPay), param('bonus_minor')),
params: [
rateParam(100),
{ key: 'bonus_minor', label: 'Надбавка за смену', kind: 'money', defaultValue: 0 },
],
poolWeightBp: 10_000,
},
{
code: 'pool_member_with_target',
label: 'Участник фонда с премией за выручку',
description:
'То же, что участник фонда, плюс премия, если выручка дня выше порога и на смене хватает людей',
rule: add(add(poolShare, hourlyPay), {
type: 'if',
when: {
type: 'and',
all: [
{
type: 'compare',
op: 'gte',
left: metric('revenue_minor'),
right: param('target_minor'),
},
{ type: 'compare', op: 'gte', left: participants, right: param('target_participants') },
],
},
then: param('target_bonus_minor'),
else: count(0),
}),
params: [
rateParam(100),
{
key: 'target_minor',
label: 'Порог выручки для премии',
kind: 'money',
defaultValue: 100_000 * 100,
},
{
key: 'target_participants',
label: 'Минимум участников на смене',
kind: 'number',
defaultValue: 3,
},
{
key: 'target_bonus_minor',
label: 'Премия за достижение порога',
kind: 'money',
defaultValue: 500 * 100,
},
],
poolWeightBp: 10_000,
},
{
code: 'personal_percent',
label: 'Личный процент от выручки',
description: 'Почасовая плюс личный процент от выручки точки, без участия в фонде',
rule: add(hourlyPay, percentOf(metric('revenue_minor'), param('percent_bp'))),
params: [
rateParam(150),
{ key: 'percent_bp', label: 'Личный процент', kind: 'percent', defaultValue: 100 },
],
poolWeightBp: 0,
},
{
code: 'revenue_percent',
label: 'Процент от выручки с гарантией (администратор)',
description: 'Процент от выручки точки + почасовая, но не меньше гарантии за смену',
rule: atLeast(
add(percentOf(metric('revenue_minor'), param('percent_bp')), hourlyPay),
param('guarantee_minor'),
),
params: [
rateParam(100),
{ key: 'percent_bp', label: 'Процент от выручки', kind: 'percent', defaultValue: 250 },
{
key: 'guarantee_minor',
label: 'Гарантия за смену',
kind: 'money',
defaultValue: 3000 * 100,
},
],
poolWeightBp: 0,
},
{
code: 'waiter_upsell',
label: 'Официант: процент, допродажи и средний чек',
description:
'Оплата смены + процент от личных продаж + оплата за проданные позиции категории + премия за средний чек',
rule: add(
add(
param('shift_rate_minor'),
percentOf(metric('personal_sales_minor'), param('percent_bp')),
),
add(
mul(
countOfItems({ type: 'field', field: 'category', op: 'eq', value: 'dessert' }),
param('upsell_rate_minor'),
),
{
type: 'if',
when: {
type: 'compare',
op: 'gte',
left: avgOfItems('amount_minor'),
right: param('avg_check_target_minor'),
},
then: param('avg_check_bonus_minor'),
else: count(0),
},
),
),
params: [
{ key: 'shift_rate_minor', label: 'Оплата смены', kind: 'money', defaultValue: 2000 * 100 },
{
key: 'percent_bp',
label: 'Процент от личных продаж',
kind: 'percent',
defaultValue: 300,
},
{
key: 'upsell_rate_minor',
label: 'Оплата за проданную позицию категории',
kind: 'money',
defaultValue: 40 * 100,
},
{
key: 'avg_check_target_minor',
label: 'Целевой средний чек',
kind: 'money',
defaultValue: 1500 * 100,
},
{
key: 'avg_check_bonus_minor',
label: 'Премия за средний чек',
kind: 'money',
defaultValue: 500 * 100,
},
],
poolWeightBp: 0,
},
{
code: 'hourly',
label: 'Почасовая (стажёр, дополнительный сотрудник)',
description: 'Только ставка за час, в фонде не участвует',
rule: hourlyPay,
params: [rateParam(250)],
poolWeightBp: 0,
},
],
};
/** Retail and sales: a shift rate plus a progressive percent of personal sales. */
export const retailPreset: PayrollPreset = {
code: 'retail_progressive_percent',
label: 'Розница и продажи: оклад смены плюс прогрессивный процент',
description:
'Оплата смены и процент от личных продаж, который растёт по ступеням: до порога одна ставка, свыше — выше.',
metrics: [
{
key: 'personal_sales_minor',
label: 'Личные продажи за день',
kind: 'money',
source: 'manual',
},
],
shiftNormMinutes: 600,
roles: [
{
code: 'seller_progressive',
label: 'Продавец, прогрессивный процент',
description:
'Ступени применяются послойно: нижняя ставка к части до порога, верхняя — к превышению',
rule: add(
param('shift_rate_minor'),
progressive(metric('personal_sales_minor'), [
{ upTo: 100_000 * 100, value: percent(300) },
{ upTo: 300_000 * 100, value: percent(500) },
{ upTo: null, value: percent(700) },
]),
),
params: [
{ key: 'shift_rate_minor', label: 'Оплата смены', kind: 'money', defaultValue: 2500 * 100 },
],
poolWeightBp: 0,
},
{
code: 'seller_flat_tier',
label: 'Продавец, ставка по достигнутому уровню',
description: 'Процент выбирается по итоговой сумме продаж и применяется ко всей сумме',
rule: add(
param('shift_rate_minor'),
percentOf(
metric('personal_sales_minor'),
tier(metric('personal_sales_minor'), [
{ upTo: 100_000 * 100, value: percent(300) },
{ upTo: 300_000 * 100, value: percent(500) },
{ upTo: null, value: percent(700) },
]),
),
),
params: [
{ key: 'shift_rate_minor', label: 'Оплата смены', kind: 'money', defaultValue: 2000 * 100 },
],
poolWeightBp: 0,
},
],
};
/**
* Delivery: large orders are priced per order by distance and are excluded from the percent base,
* the rest of the orders pay a percent, and the hours are paid on top.
*/
export const deliveryPreset: PayrollPreset = {
code: 'delivery_per_order',
label: 'Доставка: за заказ плюс процент',
description:
'Крупные заказы оплачиваются фиксированной суммой по дальности и исключаются из базы процента; по остальным платится процент; часы оплачиваются отдельно.',
metrics: [],
itemFields: [
{ key: 'amount_minor', label: 'Сумма заказа', kind: 'money' },
{ key: 'distance_km', label: 'Дальность, км', kind: 'number' },
],
shiftNormMinutes: 600,
roles: [
{
code: 'courier',
label: 'Курьер',
description: 'Почасовая + процент по обычным заказам + фиксированная ставка по крупным',
rule: add(
add(
hourlyPay,
percentOf(
sumOfItems('amount_minor', {
type: 'field',
field: 'amount_minor',
op: 'lte',
value: 5_000 * 100,
}),
param('percent_bp'),
),
),
perItem(
tier(itemField('distance_km'), [
{ upTo: 5, value: money(250 * 100) },
{ upTo: null, value: money(500 * 100) },
]),
{ type: 'field', field: 'amount_minor', op: 'gt', value: 5_000 * 100 },
),
),
params: [
rateParam(150),
{
key: 'percent_bp',
label: 'Процент с обычных заказов',
kind: 'percent',
defaultValue: 500,
},
],
poolWeightBp: 0,
},
{
code: 'courier_per_delivery',
label: 'Курьер, оплата за доставку',
description: 'Фиксированная сумма за каждую доставку плюс доплата за дальние',
rule: add(
mul(countOfItems(), param('per_delivery_minor')),
perItem(param('long_distance_bonus_minor'), {
type: 'field',
field: 'distance_km',
op: 'gt',
value: 10,
}),
),
params: [
{
key: 'per_delivery_minor',
label: 'Оплата за доставку',
kind: 'money',
defaultValue: 200 * 100,
},
{
key: 'long_distance_bonus_minor',
label: 'Доплата за дальнюю доставку',
kind: 'money',
defaultValue: 100 * 100,
},
],
poolWeightBp: 0,
},
],
};
/** Services with masters: a percent of the services performed, with a guaranteed shift minimum. */
export const servicesPreset: PayrollPreset = {
code: 'services_master_percent',
label: 'Услуги: процент мастера с гарантией смены',
description: 'Процент от выполненных услуг, но не меньше гарантии за смену.',
metrics: [],
itemFields: [
{ key: 'amount_minor', label: 'Стоимость услуги', kind: 'money' },
{ key: 'is_own_client', label: 'Свой клиент', kind: 'number' },
],
shiftNormMinutes: 720,
roles: [
{
code: 'master',
label: 'Мастер',
description: 'Повышенный процент по своим клиентам, базовый — по клиентам салона',
rule: atLeast(
add(
percentOf(
sumOfItems('amount_minor', {
type: 'field',
field: 'is_own_client',
op: 'eq',
value: 1,
}),
param('own_percent_bp'),
),
percentOf(
sumOfItems('amount_minor', {
type: 'field',
field: 'is_own_client',
op: 'neq',
value: 1,
}),
param('house_percent_bp'),
),
),
param('guarantee_minor'),
),
params: [
{
key: 'own_percent_bp',
label: 'Процент по своим клиентам',
kind: 'percent',
defaultValue: 5000,
},
{
key: 'house_percent_bp',
label: 'Процент по клиентам салона',
kind: 'percent',
defaultValue: 3500,
},
{
key: 'guarantee_minor',
label: 'Гарантия за смену',
kind: 'money',
defaultValue: 2000 * 100,
},
],
poolWeightBp: 0,
},
],
};
/** The simplest case: hours multiplied by a rate. A starting point for any organization. */
export const hourlyPreset: PayrollPreset = {
code: 'simple_hourly',
label: 'Почасовая оплата',
description: 'Часы, умноженные на ставку. Подходит как отправная точка для любой организации.',
metrics: [],
shiftNormMinutes: 480,
roles: [
{
code: 'hourly',
label: 'Почасовая',
description: 'Ставка за час × отработанные часы',
rule: hourlyPay,
params: [rateParam(300)],
poolWeightBp: 0,
},
],
};
/**
* Shift work with legally regulated surcharges: night hours from 22:00 to 06:00 add at least 20%
* (ТК РФ ст. 154), overtime adds 50% for the first two hours and 100% beyond (ст. 152), holidays
* are paid double (ст. 153). Rates are surcharges on top of the shift payment.
*/
export const shiftWorkPreset: PayrollPreset = {
code: 'shift_night_overtime',
label: 'Сменная работа: ночные, сверхурочные, праздничные',
description:
'Оплата смены плюс установленные законом доплаты за ночные часы, переработку и работу в праздник. Подходит охране, диспетчерам, линейному персоналу.',
metrics: [],
shiftNormMinutes: 720,
roles: [
{
code: 'shift_worker',
label: 'Сменный сотрудник',
description: 'Смена + 20% за ночные + 50%/100% за сверхурочные + двойная за праздничные',
rule: add(
add(
param('shift_rate_minor'),
percentOf(mul(param('rate_minor'), hoursOf('night')), param('night_percent_bp')),
),
add(
add(
mul(mul(param('rate_minor'), atMost(hoursOf('overtime'), count(2))), count(0.5)),
mul(param('rate_minor'), atLeast(sub(hoursOf('overtime'), count(2)), count(0))),
),
mul(param('rate_minor'), hoursOf('holiday')),
),
),
params: [
{
key: 'shift_rate_minor',
label: 'Оплата смены',
kind: 'money',
defaultValue: 3000 * 100,
},
rateParam(250),
{
key: 'night_percent_bp',
label: 'Надбавка за ночные часы',
kind: 'percent',
defaultValue: 2000,
},
],
poolWeightBp: 0,
},
],
};
/** B2B sales: a soft salary plus a progressive percent counted over the month, not the day. */
export const b2bSalesPreset: PayrollPreset = {
code: 'b2b_sales_progressive',
label: 'Продажи B2B: оклад плюс прогрессивный процент за месяц',
description:
'Ступени считаются от результата за месяц, а не за день: до 80% плана одна ставка, дальше выше. База процента — оплаченная маржа, чтобы не поощрять скидки.',
metrics: [
{ key: 'paid_margin_minor', label: 'Оплаченная маржа', kind: 'money', source: 'module' },
],
shiftNormMinutes: 480,
roles: [
{
code: 'sales_manager',
label: 'Менеджер по продажам',
description: 'Оклад за смену + прогрессивный процент от оплаченной маржи за месяц',
rule: add(
param('shift_rate_minor'),
progressive(metricOver('paid_margin_minor', 'month'), [
{ upTo: 2_000_000 * 100, value: percent(200) },
{ upTo: 3_000_000 * 100, value: percent(400) },
{ upTo: 4_000_000 * 100, value: percent(600) },
{ upTo: null, value: percent(800) },
]),
),
params: [
{
key: 'shift_rate_minor',
label: 'Оклад за смену',
kind: 'money',
defaultValue: 4000 * 100,
},
],
poolWeightBp: 0,
},
],
};
export const payrollPresets: readonly PayrollPreset[] = [
cateringPreset,
retailPreset,
deliveryPreset,
servicesPreset,
shiftWorkPreset,
b2bSalesPreset,
hourlyPreset,
];
export function findPreset(code: string): PayrollPreset | undefined {
return payrollPresets.find((preset) => preset.code === code);
}
export function findRoleTemplate(preset: PayrollPreset, code: string): RuleTemplate | undefined {
return preset.roles.find((role) => role.code === code);
}
/** Default parameter values of a role, ready to be stored as an employee assignment. */
export function defaultParams(role: RuleTemplate): Record<string, number> {
return Object.fromEntries(
role.params.map((declaration) => [declaration.key, declaration.defaultValue]),
);
}

757
src/domain/payroll/rule.ts Normal file
View file

@ -0,0 +1,757 @@
/**
* Pay rules are stored as a tree of blocks, not as text. The visual editor builds the tree, the
* engine evaluates it, and `describeRule` turns the same tree into a sentence a person can read.
*
* Money is an integer in minor units (kopecks). Percents are basis points (10000 = 100%).
* The block set covers the schemes met in practice: percent of a metric, hourly, per-shift and
* per-unit rates, a team pool split by presence, piece work over items with filters, threshold
* scales (flat and progressive), conditional bonuses, guarantees, caps, typed hours (night,
* overtime, holiday) and aggregation windows wider than a day.
*/
export type ItemValue = number | string;
/** One priced line: an order, a delivery, a check, a service, a produced batch. */
export type RuleItem = Readonly<Record<string, ItemValue>>;
/** Which hours a block refers to. Night, overtime and holiday hours carry their own rates by law. */
export type HourKind = 'worked' | 'night' | 'overtime' | 'holiday';
/**
* Aggregation window of a metric or of an item set. The pool of a shift is a day value, while a
* progressive sales scale is usually counted over a month — a rule may mix both.
*/
export type AggregationPeriod = 'shift' | 'day' | 'week' | 'decade' | 'month' | 'period';
export interface RuleWindow {
metrics?: Readonly<Record<string, number>>;
items?: readonly RuleItem[];
}
export interface RuleContext {
/** Daily metrics of the organization unit: revenue, checks, produced units — whatever it tracks. */
metrics: Readonly<Record<string, number>>;
/** Employee parameters of the day: rate, percent, bonus, guarantee, plan. */
params: Readonly<Record<string, number>>;
/** Minutes the employee actually worked. */
minutes: number;
/** Minutes that fall into a paid-differently category, a subset of `minutes`. */
minutesByKind?: Partial<Record<Exclude<HourKind, 'worked'>, number>>;
/** How many participants shared the pool on that day at that unit. */
participants: number;
/** The share of the pool already allocated to this employee by presence in time. */
poolShareMinor: number;
/** Shift norm of the unit, in minutes. */
shiftNormMinutes: number;
/** Items attributed to this employee on that day. */
items?: readonly RuleItem[];
/** Values aggregated over wider windows, used by blocks that declare a period. */
windows?: Partial<Record<AggregationPeriod, RuleWindow>>;
}
export type Comparison = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'neq';
export interface TierBand {
/** Upper bound of the band, exclusive. `null` means "and above". */
upTo: number | null;
value: RuleNode;
}
export type ItemFilter =
| { type: 'field'; field: string; op: Comparison; value: ItemValue }
| { type: 'and'; all: ItemFilter[] }
| { type: 'or'; any: ItemFilter[] }
| { type: 'not'; filter: ItemFilter };
export type RuleCondition =
| { type: 'compare'; op: Comparison; left: RuleNode; right: RuleNode }
| { type: 'and'; all: RuleCondition[] }
| { type: 'or'; any: RuleCondition[] }
| { type: 'not'; condition: RuleCondition };
export type ItemAggregate = 'count' | 'sum' | 'avg' | 'countDistinct';
export type RuleNode =
| { type: 'const'; value: number; unit?: 'money' | 'percent' | 'number' }
| { type: 'metric'; key: string; period?: AggregationPeriod }
| { type: 'param'; key: string }
| { type: 'hours'; kind?: HourKind }
| { type: 'minutes'; kind?: HourKind }
| { type: 'participants' }
| { type: 'poolShare' }
| { type: 'shiftNorm' }
/** Field of the item currently being priced; only valid inside `perItem`. */
| { type: 'itemField'; field: string }
| { type: 'add'; left: RuleNode; right: RuleNode }
| { type: 'sub'; left: RuleNode; right: RuleNode }
| { type: 'mul'; left: RuleNode; right: RuleNode }
| { type: 'div'; left: RuleNode; right: RuleNode }
| { type: 'percentOf'; base: RuleNode; percent: RuleNode }
| { type: 'min'; args: RuleNode[] }
| { type: 'max'; args: RuleNode[] }
| { type: 'round'; value: RuleNode; unitMinor: number }
| { type: 'if'; when: RuleCondition; then: RuleNode; else: RuleNode }
/** Counts, sums, averages items or counts distinct values of a field, optionally filtered. */
| {
type: 'items';
agg: ItemAggregate;
field?: string;
where?: ItemFilter;
period?: AggregationPeriod;
}
/** Prices every matching item separately and adds the results up. */
| { type: 'perItem'; value: RuleNode; where?: ItemFilter; period?: AggregationPeriod }
/** Picks the band the value falls into: "up to 300000 — 3%, above — 5%". */
| { type: 'tier'; on: RuleNode; bands: TierBand[] }
/** Applies every band to its own layer of the base: 3% of the first 300000, 5% of the rest. */
| { type: 'progressive'; base: RuleNode; bands: TierBand[] };
const MAX_DEPTH = 32;
export class RuleError extends Error {}
function compareNumbers(op: Comparison, left: number, right: number): boolean {
switch (op) {
case 'gt':
return left > right;
case 'gte':
return left >= right;
case 'lt':
return left < right;
case 'lte':
return left <= right;
case 'eq':
return left === right;
case 'neq':
return left !== right;
}
}
function compareValues(op: Comparison, left: ItemValue, right: ItemValue): boolean {
if (typeof left === 'string' || typeof right === 'string') {
const equal = String(left) === String(right);
if (op === 'eq') return equal;
if (op === 'neq') return !equal;
return false;
}
return compareNumbers(op, left, right);
}
function matchesFilter(filter: ItemFilter, item: RuleItem): boolean {
switch (filter.type) {
case 'field':
return compareValues(filter.op, item[filter.field] ?? 0, filter.value);
case 'and':
return filter.all.every((child) => matchesFilter(child, item));
case 'or':
return filter.any.some((child) => matchesFilter(child, item));
case 'not':
return !matchesFilter(filter.filter, item);
}
}
function metricsOf(
context: RuleContext,
period?: AggregationPeriod,
): Readonly<Record<string, number>> {
if (!period || period === 'day' || period === 'shift') return context.metrics;
return context.windows?.[period]?.metrics ?? context.metrics;
}
function itemsOf(context: RuleContext, period?: AggregationPeriod): readonly RuleItem[] {
if (!period || period === 'day' || period === 'shift') return context.items ?? [];
return context.windows?.[period]?.items ?? context.items ?? [];
}
function selectItems(
context: RuleContext,
where?: ItemFilter,
period?: AggregationPeriod,
): readonly RuleItem[] {
const items = itemsOf(context, period);
return where ? items.filter((item) => matchesFilter(where, item)) : items;
}
function minutesOf(context: RuleContext, kind: HourKind = 'worked'): number {
if (kind === 'worked') return context.minutes;
return context.minutesByKind?.[kind] ?? 0;
}
function evaluateCondition(condition: RuleCondition, context: RuleContext, depth: number): boolean {
switch (condition.type) {
case 'compare':
return compareNumbers(
condition.op,
evaluateRule(condition.left, context, depth + 1),
evaluateRule(condition.right, context, depth + 1),
);
case 'and':
return condition.all.every((child) => evaluateCondition(child, context, depth + 1));
case 'or':
return condition.any.some((child) => evaluateCondition(child, context, depth + 1));
case 'not':
return !evaluateCondition(condition.condition, context, depth + 1);
}
}
function numericField(item: RuleItem, field: string): number {
const value = item[field];
return typeof value === 'number' ? value : 0;
}
function aggregateItems(
items: readonly RuleItem[],
agg: ItemAggregate,
field: string | undefined,
): number {
switch (agg) {
case 'count':
return items.length;
case 'sum':
if (!field) throw new RuleError('Для суммы по позициям не указано поле');
return items.reduce((sum, item) => sum + numericField(item, field), 0);
case 'avg': {
if (!field) throw new RuleError('Для среднего по позициям не указано поле');
if (items.length === 0) return 0;
return items.reduce((sum, item) => sum + numericField(item, field), 0) / items.length;
}
case 'countDistinct': {
if (!field) throw new RuleError('Для подсчёта уникальных значений не указано поле');
return new Set(items.map((item) => String(item[field] ?? ''))).size;
}
}
}
export function evaluateRule(node: RuleNode, context: RuleContext, depth = 0): number {
if (depth > MAX_DEPTH) throw new RuleError('Формула слишком глубоко вложена');
const evaluate = (child: RuleNode) => evaluateRule(child, context, depth + 1);
switch (node.type) {
case 'const':
return node.value;
case 'metric':
return metricsOf(context, node.period)[node.key] ?? 0;
case 'param':
return context.params[node.key] ?? 0;
case 'hours':
return minutesOf(context, node.kind) / 60;
case 'minutes':
return minutesOf(context, node.kind);
case 'participants':
return context.participants;
case 'poolShare':
return context.poolShareMinor;
case 'shiftNorm':
return context.shiftNormMinutes;
case 'itemField':
throw new RuleError('Поле позиции доступно только внутри блока «за каждую позицию»');
case 'add':
return evaluate(node.left) + evaluate(node.right);
case 'sub':
return evaluate(node.left) - evaluate(node.right);
case 'mul':
return evaluate(node.left) * evaluate(node.right);
case 'div': {
const divisor = evaluate(node.right);
return divisor === 0 ? 0 : evaluate(node.left) / divisor;
}
case 'percentOf':
return (evaluate(node.base) * evaluate(node.percent)) / 10_000;
case 'min':
if (node.args.length === 0) throw new RuleError('Блок «наименьшее» без значений');
return Math.min(...node.args.map(evaluate));
case 'max':
if (node.args.length === 0) throw new RuleError('Блок «наибольшее» без значений');
return Math.max(...node.args.map(evaluate));
case 'round': {
const unit = node.unitMinor > 0 ? node.unitMinor : 1;
const value = evaluate(node.value);
const sign = value < 0 ? -1 : 1;
return sign * Math.round(Math.abs(value) / unit) * unit;
}
case 'if':
return evaluateCondition(node.when, context, depth)
? evaluate(node.then)
: evaluate(node.else);
case 'items':
return aggregateItems(selectItems(context, node.where, node.period), node.agg, node.field);
case 'perItem': {
const items = selectItems(context, node.where, node.period);
return items.reduce(
(sum, item) => sum + evaluateItemValue(node.value, context, item, depth + 1),
0,
);
}
case 'tier': {
const value = evaluate(node.on);
const band = node.bands.find(
(candidate) => candidate.upTo === null || value < candidate.upTo,
);
return band ? evaluate(band.value) : 0;
}
case 'progressive': {
const base = evaluate(node.base);
let covered = 0;
let total = 0;
for (const band of node.bands) {
const bandTop = band.upTo === null ? base : Math.min(band.upTo, base);
const layer = bandTop - covered;
if (layer <= 0) continue;
total += (layer * evaluate(band.value)) / 10_000;
covered = bandTop;
if (covered >= base) break;
}
return total;
}
}
}
/** Evaluates a node in the context of one item, so `itemField` resolves to that item's values. */
function evaluateItemValue(
node: RuleNode,
context: RuleContext,
item: RuleItem,
depth: number,
): number {
if (depth > MAX_DEPTH) throw new RuleError('Формула слишком глубоко вложена');
if (node.type === 'itemField') return numericField(item, node.field);
const recurse = (child: RuleNode) => evaluateItemValue(child, context, item, depth + 1);
switch (node.type) {
case 'add':
return recurse(node.left) + recurse(node.right);
case 'sub':
return recurse(node.left) - recurse(node.right);
case 'mul':
return recurse(node.left) * recurse(node.right);
case 'div': {
const divisor = recurse(node.right);
return divisor === 0 ? 0 : recurse(node.left) / divisor;
}
case 'percentOf':
return (recurse(node.base) * recurse(node.percent)) / 10_000;
case 'min':
return Math.min(...node.args.map(recurse));
case 'max':
return Math.max(...node.args.map(recurse));
case 'round': {
const unit = node.unitMinor > 0 ? node.unitMinor : 1;
const value = recurse(node.value);
const sign = value < 0 ? -1 : 1;
return sign * Math.round(Math.abs(value) / unit) * unit;
}
case 'tier': {
const value = recurse(node.on);
const band = node.bands.find(
(candidate) => candidate.upTo === null || value < candidate.upTo,
);
return band ? recurse(band.value) : 0;
}
case 'if':
return evaluateItemCondition(node.when, context, item, depth)
? recurse(node.then)
: recurse(node.else);
default:
return evaluateRule(node, context, depth);
}
}
function evaluateItemCondition(
condition: RuleCondition,
context: RuleContext,
item: RuleItem,
depth: number,
): boolean {
switch (condition.type) {
case 'compare':
return compareNumbers(
condition.op,
evaluateItemValue(condition.left, context, item, depth + 1),
evaluateItemValue(condition.right, context, item, depth + 1),
);
case 'and':
return condition.all.every((child) => evaluateItemCondition(child, context, item, depth + 1));
case 'or':
return condition.any.some((child) => evaluateItemCondition(child, context, item, depth + 1));
case 'not':
return !evaluateItemCondition(condition.condition, context, item, depth + 1);
}
}
/** True when the rule takes part in the daily pool and therefore needs a share allocated to it. */
export function usesPool(node: RuleNode): boolean {
switch (node.type) {
case 'poolShare':
return true;
case 'add':
case 'sub':
case 'mul':
case 'div':
return usesPool(node.left) || usesPool(node.right);
case 'percentOf':
return usesPool(node.base) || usesPool(node.percent);
case 'min':
case 'max':
return node.args.some(usesPool);
case 'round':
return usesPool(node.value);
case 'perItem':
return usesPool(node.value);
case 'tier':
return usesPool(node.on) || node.bands.some((band) => usesPool(band.value));
case 'progressive':
return usesPool(node.base) || node.bands.some((band) => usesPool(band.value));
case 'if':
return usesPool(node.then) || usesPool(node.else);
default:
return false;
}
}
/** Aggregation windows the rule needs, so the caller knows what to load besides the day. */
export function requiredPeriods(
node: RuleNode,
found = new Set<AggregationPeriod>(),
): Set<AggregationPeriod> {
switch (node.type) {
case 'metric':
case 'items':
case 'perItem':
if (node.period) found.add(node.period);
if (node.type === 'perItem') requiredPeriods(node.value, found);
return found;
case 'add':
case 'sub':
case 'mul':
case 'div':
requiredPeriods(node.left, found);
requiredPeriods(node.right, found);
return found;
case 'percentOf':
requiredPeriods(node.base, found);
requiredPeriods(node.percent, found);
return found;
case 'min':
case 'max':
node.args.forEach((arg) => requiredPeriods(arg, found));
return found;
case 'round':
return requiredPeriods(node.value, found);
case 'tier':
requiredPeriods(node.on, found);
node.bands.forEach((band) => requiredPeriods(band.value, found));
return found;
case 'progressive':
requiredPeriods(node.base, found);
node.bands.forEach((band) => requiredPeriods(band.value, found));
return found;
case 'if':
requiredPeriods(node.then, found);
requiredPeriods(node.else, found);
return found;
default:
return found;
}
}
export type ValueUnit = 'money' | 'percent' | 'number';
export interface RuleDictionary {
metrics?: Readonly<Record<string, string>>;
params?: Readonly<Record<string, string>>;
itemFields?: Readonly<Record<string, string>>;
/** Units of the named values, so thresholds are printed as money instead of raw kopecks. */
metricUnits?: Readonly<Record<string, ValueUnit>>;
paramUnits?: Readonly<Record<string, ValueUnit>>;
itemFieldUnits?: Readonly<Record<string, ValueUnit>>;
}
function formatNumber(value: number, unit: ValueUnit | undefined): string {
if (unit === 'money') return `${(value / 100).toLocaleString('ru-RU')} ₽`;
if (unit === 'percent') return `${(value / 100).toLocaleString('ru-RU')}%`;
return value.toLocaleString('ru-RU');
}
const COMPARISON_LABELS: Record<Comparison, string> = {
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
eq: '=',
neq: '≠',
};
const PERIOD_LABELS: Record<AggregationPeriod, string> = {
shift: 'за смену',
day: 'за день',
week: 'за неделю',
decade: 'за декаду',
month: 'за месяц',
period: 'за период',
};
const HOUR_LABELS: Record<HourKind, string> = {
worked: 'часы смены',
night: 'ночные часы',
overtime: 'сверхурочные часы',
holiday: 'праздничные часы',
};
/** Unit of the value a node produces, used to print thresholds in rubles rather than kopecks. */
function unitOf(node: RuleNode, dictionary: RuleDictionary): ValueUnit | undefined {
switch (node.type) {
case 'const':
return node.unit;
case 'metric':
return dictionary.metricUnits?.[node.key];
case 'param':
return dictionary.paramUnits?.[node.key];
case 'itemField':
return dictionary.itemFieldUnits?.[node.field];
case 'items':
return node.agg === 'sum' || node.agg === 'avg'
? node.field
? dictionary.itemFieldUnits?.[node.field]
: undefined
: 'number';
default:
return undefined;
}
}
function describeFilter(filter: ItemFilter, dictionary: RuleDictionary): string {
switch (filter.type) {
case 'field': {
const label = dictionary.itemFields?.[filter.field] ?? filter.field;
const value =
typeof filter.value === 'number'
? formatNumber(filter.value, dictionary.itemFieldUnits?.[filter.field])
: filter.value;
return `${label} ${COMPARISON_LABELS[filter.op]} ${value}`;
}
case 'and':
return filter.all.map((child) => describeFilter(child, dictionary)).join(' и ');
case 'or':
return filter.any.map((child) => describeFilter(child, dictionary)).join(' или ');
case 'not':
return `не (${describeFilter(filter.filter, dictionary)})`;
}
}
function describeCondition(condition: RuleCondition, dictionary: RuleDictionary): string {
switch (condition.type) {
case 'compare':
return `${describeRule(condition.left, dictionary)} ${COMPARISON_LABELS[condition.op]} ${describeRule(condition.right, dictionary)}`;
case 'and':
return condition.all.map((child) => describeCondition(child, dictionary)).join(' и ');
case 'or':
return condition.any.map((child) => describeCondition(child, dictionary)).join(' или ');
case 'not':
return `не (${describeCondition(condition.condition, dictionary)})`;
}
}
function describeBands(
bands: readonly TierBand[],
dictionary: RuleDictionary,
unit: ValueUnit | undefined,
): string {
return bands
.map((band) =>
band.upTo === null
? `свыше — ${describeRule(band.value, dictionary)}`
: `до ${formatNumber(band.upTo, unit)} — ${describeRule(band.value, dictionary)}`,
)
.join('; ');
}
function periodSuffix(period?: AggregationPeriod): string {
return period && period !== 'day' ? ` ${PERIOD_LABELS[period]}` : '';
}
/** Renders the rule as Russian text for the editor preview and for the payslip explanation. */
export function describeRule(node: RuleNode, dictionary: RuleDictionary = {}): string {
const describe = (child: RuleNode) => describeRule(child, dictionary);
const wrap = (child: RuleNode) => {
const text = describe(child);
return child.type === 'add' || child.type === 'sub' ? `(${text})` : text;
};
switch (node.type) {
case 'const':
return formatNumber(node.value, node.unit);
case 'metric':
return `${dictionary.metrics?.[node.key] ?? node.key}${periodSuffix(node.period)}`;
case 'param':
return dictionary.params?.[node.key] ?? node.key;
case 'hours':
return HOUR_LABELS[node.kind ?? 'worked'];
case 'minutes':
return `${HOUR_LABELS[node.kind ?? 'worked']} в минутах`;
case 'participants':
return 'число участников';
case 'poolShare':
return 'доля из фонда';
case 'shiftNorm':
return 'норма смены';
case 'itemField':
return dictionary.itemFields?.[node.field] ?? node.field;
case 'add':
return `${describe(node.left)} + ${describe(node.right)}`;
case 'sub':
return `${describe(node.left)} − ${describe(node.right)}`;
case 'mul':
return `${wrap(node.left)} × ${wrap(node.right)}`;
case 'div':
return `${wrap(node.left)} ÷ ${wrap(node.right)}`;
case 'percentOf':
return `${describe(node.percent)} от «${describe(node.base)}»`;
case 'min':
return `наименьшее из (${node.args.map(describe).join('; ')})`;
case 'max':
return `наибольшее из (${node.args.map(describe).join('; ')})`;
case 'round':
return `${describe(node.value)}, округлённое до ${formatNumber(node.unitMinor, 'money')}`;
case 'if':
return `если ${describeCondition(node.when, dictionary)}, то ${describe(node.then)}, иначе ${describe(node.else)}`;
case 'items': {
const scope = node.where ? ` (${describeFilter(node.where, dictionary)})` : '';
const field = dictionary.itemFields?.[node.field ?? ''] ?? node.field ?? '';
const label =
node.agg === 'count'
? 'количество позиций'
: node.agg === 'sum'
? `сумма «${field}» по позициям`
: node.agg === 'avg'
? `среднее «${field}» по позициям`
: `количество разных значений «${field}»`;
return `${label}${scope}${periodSuffix(node.period)}`;
}
case 'perItem': {
const scope = node.where ? ` (${describeFilter(node.where, dictionary)})` : '';
return `за каждую позицию${scope}${periodSuffix(node.period)}: ${describe(node.value)}`;
}
case 'tier':
return `по шкале от «${describe(node.on)}»: ${describeBands(node.bands, dictionary, unitOf(node.on, dictionary))}`;
case 'progressive':
return `прогрессивно от «${describe(node.base)}»: ${describeBands(node.bands, dictionary, unitOf(node.base, dictionary))}`;
}
}
const KEY_PATTERN = /^[a-z][a-z0-9_]*$/;
/** Validates a rule tree before it is saved: the editor must never store something unevaluable. */
export function validateRule(node: RuleNode, depth = 0): void {
if (depth > MAX_DEPTH) throw new RuleError('Формула слишком глубоко вложена');
const child = (next: RuleNode) => validateRule(next, depth + 1);
switch (node.type) {
case 'const':
if (!Number.isFinite(node.value)) throw new RuleError('Число в блоке задано неверно');
return;
case 'metric':
case 'param':
if (!KEY_PATTERN.test(node.key)) throw new RuleError(`Недопустимый ключ «${node.key}»`);
return;
case 'itemField':
if (!KEY_PATTERN.test(node.field)) throw new RuleError(`Недопустимое поле «${node.field}»`);
return;
case 'add':
case 'sub':
case 'mul':
case 'div':
child(node.left);
child(node.right);
return;
case 'percentOf':
child(node.base);
child(node.percent);
return;
case 'min':
case 'max':
if (node.args.length === 0) throw new RuleError('Блок сравнения без значений');
node.args.forEach(child);
return;
case 'round':
if (!Number.isInteger(node.unitMinor) || node.unitMinor <= 0) {
throw new RuleError('Шаг округления должен быть целым и больше нуля');
}
child(node.value);
return;
case 'if':
validateCondition(node.when, depth + 1);
child(node.then);
child(node.else);
return;
case 'items':
if (node.agg !== 'count' && !node.field) {
throw new RuleError('Для этого агрегата не указано поле позиции');
}
if (node.field && !KEY_PATTERN.test(node.field)) {
throw new RuleError(`Недопустимое поле «${node.field}»`);
}
if (node.where) validateFilter(node.where, depth + 1);
return;
case 'perItem':
if (node.where) validateFilter(node.where, depth + 1);
child(node.value);
return;
case 'tier':
case 'progressive': {
if (node.bands.length === 0) throw new RuleError('Шкала без ступеней');
const bounded = node.bands.filter((band) => band.upTo !== null).map((band) => band.upTo!);
for (let index = 1; index < bounded.length; index += 1) {
if (bounded[index]! <= bounded[index - 1]!) {
throw new RuleError('Ступени шкалы должны идти по возрастанию');
}
}
if (node.bands.slice(0, -1).some((band) => band.upTo === null)) {
throw new RuleError('Ступень «и выше» может быть только последней');
}
child(node.type === 'tier' ? node.on : node.base);
node.bands.forEach((band) => child(band.value));
return;
}
default:
return;
}
}
function validateCondition(condition: RuleCondition, depth: number): void {
if (depth > MAX_DEPTH) throw new RuleError('Условие слишком глубоко вложено');
switch (condition.type) {
case 'compare':
validateRule(condition.left, depth + 1);
validateRule(condition.right, depth + 1);
return;
case 'and':
if (condition.all.length === 0) throw new RuleError('Условие «и» без частей');
condition.all.forEach((part) => validateCondition(part, depth + 1));
return;
case 'or':
if (condition.any.length === 0) throw new RuleError('Условие «или» без частей');
condition.any.forEach((part) => validateCondition(part, depth + 1));
return;
case 'not':
validateCondition(condition.condition, depth + 1);
return;
}
}
function validateFilter(filter: ItemFilter, depth: number): void {
if (depth > MAX_DEPTH) throw new RuleError('Фильтр слишком глубоко вложен');
switch (filter.type) {
case 'field':
if (!KEY_PATTERN.test(filter.field))
throw new RuleError(`Недопустимое поле «${filter.field}»`);
return;
case 'and':
filter.all.forEach((part) => validateFilter(part, depth + 1));
return;
case 'or':
filter.any.forEach((part) => validateFilter(part, depth + 1));
return;
case 'not':
validateFilter(filter.filter, depth + 1);
return;
}
}

82
src/portal-ui.ts Normal file

File diff suppressed because one or more lines are too long

295
src/portal.ts Normal file
View file

@ -0,0 +1,295 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { AppConfig } from './config.js';
import type { RequestContext } from './core/context.js';
import { newUlid, parseUlid } from './core/ids.js';
import type { IdentityPort } from './core/ports.js';
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
import type { Database } from './infrastructure/database.js';
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
interface AttendanceBody {
event_id?: string;
employee_id: string;
event_type: 'check_in' | 'check_out';
occurred_at: string;
source_event_id: string;
device_id?: string;
}
interface TimesheetBody {
minutes_worked: number;
status: 'draft' | 'submitted' | 'approved' | 'rejected';
note?: string;
}
interface SessionBody {
access_token: string;
}
const sessionCookieName = '__Host-krona_staff_session';
const sessionMaxAgeSeconds = 8 * 60 * 60;
export async function registerPortalRoutes(
api: FastifyInstance,
config: AppConfig,
database: Database,
) {
const identity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform'
? new PlatformIdentityAdapter()
: new StandaloneIdentityAdapter(config);
api.post<{ Body: SessionBody }>('/session', {
schema: {
body: {
type: 'object', additionalProperties: false,
required: ['access_token'],
properties: { access_token: { type: 'string', minLength: 32, maxLength: 4096 } },
},
},
}, async (request, reply) => {
assertSameOrigin(request);
if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) {
return reply.serviceUnavailable('Вход администратора ещё не настроен.');
}
const context = await resolveToken(identity, request.body.access_token, request);
if (!context.permissions.has('staff:attendance:manage')) {
return reply.forbidden('Для входа требуется ключ администратора.');
}
reply
.header('cache-control', 'no-store')
.header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds));
return { role: 'manager', expires_in: sessionMaxAgeSeconds };
});
api.delete('/session', async (request, reply) => {
assertSameOrigin(request);
reply
.header('cache-control', 'no-store')
.header('set-cookie', serializeSessionCookie('', 0));
return reply.code(204).send();
});
api.get('/portal', async (request, reply) => {
reply.header('cache-control', 'no-store');
const context = await resolveContext(identity, request);
const canManage = context.permissions.has('staff:attendance:manage');
if (!canManage && !context.permissions.has('staff:attendance:read_own')) {
return reply.forbidden('Недостаточно прав для просмотра посещаемости.');
}
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const employees = await client.query(
`SELECT e.id, e.core_user_id, e.personnel_number, e.display_name, e.position, e.gender, e.status
FROM staff_employees e
WHERE e.status <> 'dismissed' AND ($1::boolean OR e.core_user_id = $2)
ORDER BY e.display_name`,
[canManage, context.actorId],
);
const attendance = await client.query(
`SELECT a.id, a.employee_id, e.display_name, a.event_type, a.source,
a.occurred_at, a.received_at, a.status
FROM staff_attendance_events a
JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id
WHERE ($1::boolean OR e.core_user_id = $2)
ORDER BY a.occurred_at DESC LIMIT 100`,
[canManage, context.actorId],
);
const timesheet = await client.query(
`SELECT t.id, t.employee_id, e.display_name, t.work_date, t.minutes_worked,
t.status, t.note, t.revision, t.updated_at
FROM staff_timesheet_entries t
JOIN staff_employees e ON e.tenant_id = t.tenant_id AND e.id = t.employee_id
WHERE t.work_date >= (CURRENT_DATE - INTERVAL '40 days')
AND ($1::boolean OR e.core_user_id = $2)
ORDER BY t.work_date DESC, e.display_name`,
[canManage, context.actorId],
);
return {
viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' },
employees: employees.rows,
attendance: attendance.rows,
timesheet: timesheet.rows,
refreshed_at: new Date().toISOString(),
};
});
});
api.post<{ Body: AttendanceBody }>('/attendance/events', {
schema: {
body: {
type: 'object', additionalProperties: false,
required: ['employee_id', 'event_type', 'occurred_at', 'source_event_id'],
properties: {
event_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
employee_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
event_type: { enum: ['check_in', 'check_out'] },
occurred_at: { type: 'string', format: 'date-time' },
source_event_id: { type: 'string', minLength: 1, maxLength: 200 },
device_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
},
},
},
}, async (request, reply) => {
const context = await requirePermission(identity, request, 'staff:attendance:clock');
const eventId = request.body.event_id ? parseUlid(request.body.event_id) : newUlid();
const employeeId = parseUlid(request.body.employee_id);
const deviceId = request.body.device_id ? parseUlid(request.body.device_id) : null;
const occurredAt = new Date(request.body.occurred_at);
if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.');
const now = Date.now();
if (occurredAt.valueOf() > now + 5 * 60_000 || occurredAt.valueOf() < now - 180 * 86_400_000) {
return reply.badRequest('Время отметки выходит за допустимый период пилота.');
}
const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
await client.query(
`INSERT INTO staff_employees
(id, tenant_id, personnel_number, display_name, position, gender, status)
VALUES ($1, $2, $3, $4, '', '', 'active')
ON CONFLICT (id) DO NOTHING`,
[employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`],
);
const inserted = await client.query(
`INSERT INTO staff_attendance_events
(id, tenant_id, employee_id, event_type, source, source_event_id,
occurred_at, device_id, actor_id, correlation_id)
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING RETURNING id`,
[eventId, context.tenantId, employeeId, request.body.event_type,
request.body.source_event_id, occurredAt.toISOString(), deviceId,
context.actorId, context.correlationId],
);
if (inserted.rowCount === 0) return { id: eventId, duplicate: true };
const eventType = request.body.event_type === 'check_in'
? 'attendance.clock.in'
: 'attendance.clock.out';
await client.query(
`INSERT INTO staff_outbox
(id, tenant_id, event_type, event_version, subject_type, subject_id,
actor_type, actor_id, payload, correlation_id, occurred_at)
VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`,
[newUlid(), context.tenantId, eventType, employeeId, context.actorId,
{ attendance_event_id: eventId, channel: 'platform_notification' },
context.correlationId, occurredAt.toISOString()],
);
return { id: eventId, duplicate: false };
});
return reply.code(result.duplicate ? 200 : 201).send(result);
});
api.put<{ Params: { employeeId: string; workDate: string }; Body: TimesheetBody }>(
'/timesheets/:employeeId/:workDate',
{
schema: {
params: { type: 'object', required: ['employeeId', 'workDate'], properties: {
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
workDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
} },
body: { type: 'object', additionalProperties: false,
required: ['minutes_worked', 'status'], properties: {
minutes_worked: { type: 'integer', minimum: 0, maximum: 1440 },
status: { enum: ['draft', 'submitted', 'approved', 'rejected'] },
note: { type: 'string', maxLength: 1000 },
} },
},
},
async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await requirePermission(identity, request, 'staff:attendance:manage');
if (!datePattern.test(request.params.workDate)) return reply.badRequest('Некорректная дата.');
const employeeId = parseUlid(request.params.employeeId);
if (request.body.status === 'approved' && !context.permissions.has('staff:attendance:approve')) {
return reply.forbidden('Для утверждения табеля требуется отдельное право.');
}
const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const saved = await client.query(
`INSERT INTO staff_timesheet_entries
(id, tenant_id, employee_id, work_date, minutes_worked, status, note, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET
minutes_worked = EXCLUDED.minutes_worked, status = EXCLUDED.status,
note = EXCLUDED.note, revision = staff_timesheet_entries.revision + 1,
updated_by = EXCLUDED.updated_by, updated_at = now()
RETURNING id, employee_id, work_date, minutes_worked, status, note, revision, updated_at`,
[newUlid(), context.tenantId, employeeId, request.params.workDate,
request.body.minutes_worked, request.body.status, request.body.note ?? '', context.actorId],
);
const result = saved.rows[0] as Record<string, unknown>;
await client.query(
`INSERT INTO staff_audit_log
(id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id)
VALUES ($1, $2, $3, 'timesheet.entry.saved', 'TimesheetEntry', $4, $5, $6)`,
[newUlid(), context.tenantId, context.actorId, result.id, result, context.correlationId],
);
return result;
});
return reply.send(row);
},
);
}
async function resolveContext(identity: IdentityPort, request: FastifyRequest): Promise<RequestContext> {
const authorization = request.headers.authorization;
const token = authorization?.startsWith('Bearer ')
? authorization.slice(7)
: readCookie(request.headers.cookie, sessionCookieName);
return resolveToken(identity, token, request);
}
async function resolveToken(
identity: IdentityPort,
token: string | undefined,
request: FastifyRequest,
): Promise<RequestContext> {
try {
return await identity.resolve(token, parseUlid(request.id));
} catch {
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа.');
}
}
function readCookie(header: string | undefined, name: string): string | undefined {
if (!header) return undefined;
for (const part of header.split(';')) {
const separator = part.indexOf('=');
if (separator < 0 || part.slice(0, separator).trim() !== name) continue;
try {
return decodeURIComponent(part.slice(separator + 1).trim());
} catch {
return undefined;
}
}
return undefined;
}
function serializeSessionCookie(value: string, maxAge: number): string {
return `${sessionCookieName}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Strict`;
}
function assertSameOrigin(request: FastifyRequest) {
const origin = request.headers.origin;
if (!origin) return;
try {
const parsed = new URL(origin);
if (parsed.protocol !== 'https:' || parsed.host !== request.headers.host) throw new Error();
} catch {
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
}
}
function assertCookieMutationOrigin(request: FastifyRequest) {
if (!request.headers.authorization && readCookie(request.headers.cookie, sessionCookieName)) {
assertSameOrigin(request);
}
}
async function requirePermission(identity: IdentityPort, request: FastifyRequest, permission: string) {
const context = await resolveContext(identity, request);
if (!context.permissions.has(permission)) {
throw request.server.httpErrors.forbidden('Недостаточно прав для этой операции.');
}
return context;
}

View file

@ -28,4 +28,4 @@ const shutdown = (signal: string) => {
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
await app.listen({ host: '0.0.0.0', port: config.PORT });
await app.listen({ host: config.HOST, port: config.PORT });

View file

@ -46,11 +46,17 @@ describe('OpenAPI runtime contract', () => {
if (!prefix) throw new Error('OpenAPI server prefix is missing');
const documentedPaths = Object.keys(documented.paths).sort();
const runtimePaths = Object.keys(runtime.paths ?? {})
.filter((path) => path !== '/' && path !== '/openapi.json')
.filter((path) => path !== '/' && path !== '/openapi.json' && !path.startsWith('/staff'))
.sort();
expect(runtimePaths).toEqual(documentedPaths);
for (const path of documentedPaths) {
expect((await app.inject(`${prefix}${path}`)).statusCode).not.toBe(404);
const runtimePath = path.replace('{employeeId}', '01J00000000000000000000000').replace('{workDate}', '2026-09-24');
const operations = documented.paths[path] as Record<string, unknown>;
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
const method = methods.find((candidate) => candidate in operations);
if (!method) throw new Error(`No HTTP operation documented for ${path}`);
const response = await app.inject({ method, url: `${prefix}${runtimePath}` });
expect(response.statusCode).not.toBe(404);
}
await app.close();
});

View file

@ -0,0 +1,137 @@
import { describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../src/app.js';
import { loadConfig } from '../../src/config.js';
const base = {
DATABASE_URL: 'postgres://localhost/staff',
STANDALONE_TENANT_ID: '01J00000000000000000000000',
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
LOG_LEVEL: 'silent',
NODE_ENV: 'test',
} as const;
const managerToken = 'm'.repeat(64);
const terminalToken = 't'.repeat(64);
const tokenConfig = {
STANDALONE_MANAGER_TOKEN: managerToken,
STANDALONE_TERMINAL_TOKEN: terminalToken,
} as const;
function fakeDatabase(rows: Array<Array<Record<string, unknown>>>) {
const calls: Array<[string, readonly unknown[] | undefined]> = [];
const query = async (sql: string, parameters?: readonly unknown[]) => {
calls.push([sql, parameters]);
return { rows: rows.shift() ?? [], rowCount: 1 };
};
return {
calls,
database: {
ping: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
tenantTransaction: async (
_tenant: string,
_actor: string,
run: (client: { query: typeof query }) => Promise<unknown>,
) => run({ query }),
} as never,
};
}
describe('attendance portal', () => {
it('requires authentication when standalone tokens are configured', async () => {
const { database } = fakeDatabase([]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject('/api/v1/staff/portal');
expect(response.statusCode).toBe(401);
await app.close();
});
it('creates an HttpOnly manager session and accepts its secure cookie', async () => {
const { database } = fakeDatabase([[], [], []]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const login = await app.inject({
method: 'POST',
url: '/api/v1/staff/session',
payload: { access_token: managerToken },
});
const setCookie = login.headers['set-cookie'];
expect(login.statusCode).toBe(200);
expect(setCookie).toContain('__Host-krona_staff_session=');
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('Secure');
expect(setCookie).toContain('SameSite=Strict');
const response = await app.inject({
url: '/api/v1/staff/portal',
headers: { cookie: String(setCookie).split(';')[0] },
});
expect(response.statusCode).toBe(200);
expect(response.json<{ viewer: { role: string } }>().viewer.role).toBe('manager');
await app.close();
});
it('does not allow the terminal key to create a browser session', async () => {
const { database } = fakeDatabase([]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/session',
payload: { access_token: terminalToken },
});
expect(response.statusCode).toBe(403);
expect(response.headers['set-cookie']).toBeUndefined();
await app.close();
});
it('scopes an employee portal to the linked Core user', async () => {
const { database, calls } = fakeDatabase([[], [], []]);
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database });
const response = await app.inject('/api/v1/staff/portal');
expect(response.statusCode).toBe(200);
const body = response.json<{ viewer: { role: string } }>();
expect(body.viewer.role).toBe('employee');
expect(calls).toHaveLength(3);
expect(calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID)).toBe(true);
await app.close();
});
it('writes a terminal event and its outbox notification atomically', async () => {
const eventId = '01J00000000000000000000002';
const employeeId = '01J00000000000000000000003';
const { database, calls } = fakeDatabase([[], [{ id: eventId }], []]);
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), database });
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/attendance/events',
payload: { event_id: eventId, employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', source_event_id: eventId },
});
expect(response.statusCode).toBe(201);
expect(calls).toHaveLength(3);
expect(calls[0]?.[0]).toContain('staff_employees');
expect(calls[2]?.[0]).toContain('staff_outbox');
expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in');
await app.close();
});
it('does not give an employee manager access to the timesheet', async () => {
const { database } = fakeDatabase([]);
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database });
const response = await app.inject({ method: 'PUT', url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24', payload: { minutes_worked: 480, status: 'approved' } });
expect(response.statusCode).toBe(403);
await app.close();
});
});

View file

@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest';
import {
calculateDay,
roundAmount,
summarizePeriod,
type DayParticipant,
} from '../../src/domain/payroll/calculator.js';
import { datesInPeriod, ledgerBalance, payrollWeek } from '../../src/domain/payroll/period.js';
import {
cateringPreset,
defaultParams,
findRoleTemplate,
} from '../../src/domain/payroll/presets.js';
const RUB = 100;
const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!;
const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!;
const hourlyRole = findRoleTemplate(cateringPreset, 'hourly')!;
function cook(employeeId: string, hours: number, bonusRubles = 500): DayParticipant {
return {
employeeId,
workedMinutes: Math.round(hours * 60),
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), bonus_minor: bonusRubles * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
}
function admin(employeeId: string, hours: number, percentBp: number): DayParticipant {
return {
employeeId,
workedMinutes: Math.round(hours * 60),
ruleCode: percentRole.code,
rule: percentRole.rule,
params: { ...defaultParams(percentRole), percent_bp: percentBp },
poolWeightBp: percentRole.poolWeightBp,
};
}
function hourly(employeeId: string, hours: number, rateRubles: number): DayParticipant {
return {
employeeId,
workedMinutes: Math.round(hours * 60),
ruleCode: hourlyRole.code,
rule: hourlyRole.rule,
params: { rate_minor: rateRubles * RUB },
poolWeightBp: hourlyRole.poolWeightBp,
};
}
function day(businessDate: string, revenueRubles: number, participants: DayParticipant[]) {
return calculateDay({
businessDate,
orgUnitId: 'GORSKY',
metrics: { revenue_minor: revenueRubles * RUB },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
participants,
});
}
function paidRubles(result: ReturnType<typeof day>): Record<string, number> {
return Object.fromEntries(result.lines.map((line) => [line.employeeId, line.amountMinor / RUB]));
}
describe('payroll: days taken from the owner spreadsheet, calculated through the catering preset', () => {
it('07.01.2026 Горский: three cooks and an administrator', () => {
const result = day('2026-01-07', 123_569, [
cook('bekzad', 12),
cook('nurzada', 12),
cook('ksyusha', 12),
admin('vika', 12, 300),
]);
expect(paidRubles(result)).toEqual({
bekzad: 6231,
nurzada: 6231,
ksyusha: 6231,
vika: 4907,
});
});
it('08.01.2026 Горский: two cooks share the same pool', () => {
const result = day('2026-01-08', 89_647, [
cook('bekzad', 12),
cook('ksyusha', 12),
admin('vika', 12, 300),
]);
expect(paidRubles(result)).toEqual({ bekzad: 6631, ksyusha: 6631, vika: 3889 });
});
it('10.01.2026 Горский: thirteen-hour shifts, one cook without the personal bonus', () => {
const result = day('2026-01-10', 134_161, [
cook('bekzad', 13),
cook('nurzada', 13),
cook('ksyusha', 13),
cook('pulat', 13, 0),
admin('nastya', 13, 250),
]);
expect(paidRubles(result)).toEqual({
bekzad: 5489,
nurzada: 5489,
ksyusha: 5489,
pulat: 4989,
nastya: 4654,
});
});
it('12.01.2026 Горский: the administrator falls back to the guaranteed minimum', () => {
const result = day('2026-01-12', 56_620, [
cook('bekzad', 12),
cook('nurzada', 12),
admin('nastya', 12, 250),
]);
expect(paidRubles(result).nastya).toBe(3000);
});
it('sheet "Расчет ЗП с доп. поваром": a cook leaving after five hours', () => {
const result = day('2026-01-20', 103_395, [
cook('leaver', 5, 0),
cook('cook-1', 12, 0),
cook('cook-2', 12, 0),
cook('cook-3', 12, 0),
]);
expect(paidRubles(result)).toEqual({
leaver: 1685,
'cook-1': 4596,
'cook-2': 4596,
'cook-3': 4596,
});
});
it('keeps hourly staff on a flat rate and out of the pool divisor', () => {
const withoutTrainee = day('2026-02-06', 141_684, [
cook('bekzad', 12),
cook('nurzada', 12),
cook('pulat', 12, 0),
]);
const withTrainee = day('2026-02-06', 141_684, [
cook('bekzad', 12),
cook('nurzada', 12),
cook('pulat', 12, 0),
hourly('trainee', 12, 200),
]);
expect(paidRubles(withTrainee).bekzad).toBe(paidRubles(withoutTrainee).bekzad);
expect(paidRubles(withTrainee).trainee).toBe(2400);
});
it('counts an additional cook as half a participant when the organization configures it so', () => {
const extra: DayParticipant = { ...cook('extra', 12, 0), poolWeightBp: 5_000 };
const result = day('2026-02-07', 100_000, [
cook('cook-1', 12, 0),
cook('cook-2', 12, 0),
extra,
]);
const share = (id: string) =>
result.lines.find((line) => line.employeeId === id)!.poolShareMinor;
expect(share('extra') * 2).toBeCloseTo(share('cook-1'), -1);
expect(result.poolDivisorBp).toBe(25_000);
});
});
describe('payroll: invariants', () => {
it('distributes the whole pool without losing kopecks', () => {
const result = day('2026-03-01', 77_777, [cook('a', 12), cook('b', 11, 0), cook('c', 7, 0)]);
expect(result.distributedPoolMinor).toBe(result.poolAmountMinor);
});
it('pays nothing to an employee who did not work', () => {
const result = day('2026-03-02', 50_000, [cook('present', 12), cook('absent', 0)]);
expect(paidRubles(result).absent).toBe(0);
});
it('still pays the hourly part when the daily metric is missing', () => {
const result = day('2026-03-03', 0, [cook('a', 12)]);
expect(result.poolAmountMinor).toBe(0);
expect(paidRubles(result).a).toBe(1700);
});
it('rounds every line to whole rubles', () => {
const result = day('2026-03-04', 98_765, [cook('a', 12), cook('b', 12, 0)]);
for (const line of result.lines) expect(line.amountMinor % RUB).toBe(0);
});
it('rounds half away from zero', () => {
expect(roundAmount(150)).toBe(200);
expect(roundAmount(149)).toBe(100);
expect(roundAmount(-150)).toBe(-200);
});
it('keeps the unrounded amount and the inputs for the payslip explanation', () => {
const result = day('2026-03-05', 120_000, [cook('a', 12)]);
const line = result.lines[0]!;
expect(line.rawAmountMinor).not.toBe(0);
expect(line.explain.rule).toContain('доля из фонда');
expect(line.explain.metrics).toMatchObject({ revenue_minor: 12_000_000 });
expect(line.explain.pool_share_minor).toBeGreaterThan(0);
});
it('works for an organization without any pool at all', () => {
const result = calculateDay({
businessDate: '2026-03-06',
orgUnitId: 'SHOP',
metrics: {},
shiftNormMinutes: 480,
participants: [hourly('worker', 8, 300)],
});
expect(result.poolAmountMinor).toBe(0);
expect(paidRubles(result).worker).toBe(2400);
});
});
describe('payroll: periods and balances', () => {
it('uses Thursday as the first day of the payroll week', () => {
expect(payrollWeek('2026-01-02')).toEqual({ start: '2026-01-01', end: '2026-01-07' });
expect(payrollWeek('2026-01-08')).toEqual({ start: '2026-01-08', end: '2026-01-14' });
});
it('supports another week start without touching the calculation', () => {
expect(payrollWeek('2026-01-07', 1)).toEqual({ start: '2026-01-05', end: '2026-01-11' });
});
it('lists every business date of a period', () => {
expect(datesInPeriod({ start: '2026-01-01', end: '2026-01-07' })).toHaveLength(7);
});
it('carries the difference between accruals and payouts', () => {
expect(
ledgerBalance([
{ kind: 'accrual', amountMinor: 3_199_525 },
{ kind: 'payout', amountMinor: -3_190_000 },
]),
).toBe(9_525);
});
it('totals a week per employee', () => {
const days = [
day('2026-01-01', 100_397, [cook('a', 12), cook('b', 12, 0)]),
day('2026-01-02', 104_460, [cook('a', 12)]),
];
const totals = summarizePeriod(days);
expect(totals).toHaveLength(2);
expect(totals.find((total) => total.employeeId === 'a')?.days).toBe(2);
});
});

View file

@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js';
import { requiredPeriods, validateRule } from '../../src/domain/payroll/rule.js';
import {
b2bSalesPreset,
cateringPreset,
defaultParams,
findRoleTemplate,
payrollPresets,
shiftWorkPreset,
} from '../../src/domain/payroll/presets.js';
const RUB = 100;
function roleParticipant(
preset: (typeof payrollPresets)[number],
roleCode: string,
overrides: Partial<DayParticipant> & { hours: number },
): DayParticipant {
const role = findRoleTemplate(preset, roleCode)!;
const { hours, params, ...rest } = overrides;
return {
employeeId: 'employee',
workedMinutes: Math.round(hours * 60),
ruleCode: role.code,
rule: role.rule,
params: { ...defaultParams(role), ...(params ?? {}) },
poolWeightBp: role.poolWeightBp,
...rest,
};
}
function payRubles(
participant: DayParticipant,
options: {
metrics?: Record<string, number>;
windows?: Parameters<typeof calculateDay>[0]['windows'];
} = {},
): number {
const result = calculateDay({
businessDate: '2026-05-01',
orgUnitId: 'UNIT',
metrics: options.metrics ?? {},
shiftNormMinutes: 720,
participants: [participant],
...(options.windows ? { windows: options.windows } : {}),
});
return result.lines[0]!.amountMinor / RUB;
}
describe('shift work: night, overtime and holiday hours', () => {
it('adds twenty percent for night hours', () => {
const guard = roleParticipant(shiftWorkPreset, 'shift_worker', {
hours: 12,
minutesByKind: { night: 8 * 60 },
});
// 3000 ₽ shift + 20% of (250 ₽ × 8 night hours) = 3000 + 400
expect(payRubles(guard)).toBe(3400);
});
it('pays overtime at one and a half for two hours and double beyond', () => {
const guard = roleParticipant(shiftWorkPreset, 'shift_worker', {
hours: 16,
minutesByKind: { overtime: 4 * 60 },
});
// 3000 ₽ shift + 250 × 2 × 0.5 + 250 × 2 × 1 = 3000 + 250 + 500
expect(payRubles(guard)).toBe(3750);
});
it('adds a second rate for holiday hours', () => {
const guard = roleParticipant(shiftWorkPreset, 'shift_worker', {
hours: 12,
minutesByKind: { holiday: 12 * 60 },
});
expect(payRubles(guard)).toBe(3000 + 250 * 12);
});
it('pays only the shift when there are no special hours', () => {
const guard = roleParticipant(shiftWorkPreset, 'shift_worker', { hours: 12 });
expect(payRubles(guard)).toBe(3000);
});
});
describe('scales counted over a month, not over a day', () => {
const manager = () => roleParticipant(b2bSalesPreset, 'sales_manager', { hours: 8 });
it('reads the metric from the month window', () => {
const pay = payRubles(manager(), {
windows: { month: { metrics: { paid_margin_minor: 4_500_000 * RUB } } },
});
// 4000 ₽ shift + 2% of 2 000 000 + 4% of 1 000 000 + 6% of 1 000 000 + 8% of 500 000
expect(pay).toBe(4_000 + 40_000 + 40_000 + 60_000 + 40_000);
});
it('pays only the shift rate while the month window is empty', () => {
expect(payRubles(manager())).toBe(4_000);
});
it('reports which windows a rule needs', () => {
const role = findRoleTemplate(b2bSalesPreset, 'sales_manager')!;
expect([...requiredPeriods(role.rule)]).toEqual(['month']);
});
});
describe('waiter: upsell count and average check', () => {
const checks = [
{ amount_minor: 2_000 * RUB, category: 'main' },
{ amount_minor: 1_000 * RUB, category: 'dessert' },
{ amount_minor: 1_500 * RUB, category: 'dessert' },
];
it('pays a percent of personal sales, per-item upsell and the average check bonus', () => {
const waiter = roleParticipant(cateringPreset, 'waiter_upsell', { hours: 10, items: checks });
// 2000 ₽ shift + 3% of 100 000 ₽ personal sales + 2 desserts × 40 ₽ + 500 ₽ (average check 1500 ₽)
expect(payRubles(waiter, { metrics: { personal_sales_minor: 100_000 * RUB } })).toBe(
2_000 + 3_000 + 80 + 500,
);
});
it('withholds the average check bonus when the target is not met', () => {
const waiter = roleParticipant(cateringPreset, 'waiter_upsell', {
hours: 10,
items: [{ amount_minor: 900 * RUB, category: 'main' }],
});
expect(payRubles(waiter, { metrics: { personal_sales_minor: 0 } })).toBe(2_000);
});
});
describe('preset catalogue', () => {
it('ships a validated rule for every role of every preset', () => {
for (const preset of payrollPresets) {
if (preset.poolRule) validateRule(preset.poolRule);
for (const role of preset.roles) {
validateRule(role.rule);
expect(role.params.length).toBeGreaterThan(0);
}
}
});
it('covers the industries the research found', () => {
expect(payrollPresets.map((preset) => preset.code)).toEqual([
'catering_revenue_pool',
'retail_progressive_percent',
'delivery_per_order',
'services_master_percent',
'shift_night_overtime',
'b2b_sales_progressive',
'simple_hourly',
]);
});
});

View file

@ -0,0 +1,241 @@
import { describe, expect, it } from 'vitest';
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js';
import {
RuleError,
describeRule,
evaluateRule,
validateRule,
type RuleContext,
type RuleNode,
} from '../../src/domain/payroll/rule.js';
import {
cateringPreset,
defaultParams,
deliveryPreset,
findRoleTemplate,
retailPreset,
servicesPreset,
} from '../../src/domain/payroll/presets.js';
const RUB = 100;
function participantOf(
preset: typeof cateringPreset,
roleCode: string,
overrides: Partial<DayParticipant> & { hours: number },
): DayParticipant {
const role = findRoleTemplate(preset, roleCode)!;
const { hours, ...rest } = overrides;
return {
employeeId: 'employee',
workedMinutes: Math.round(hours * 60),
ruleCode: role.code,
rule: role.rule,
params: { ...defaultParams(role), ...(rest.params ?? {}) },
poolWeightBp: role.poolWeightBp,
...(rest.items ? { items: rest.items } : {}),
};
}
function amountRubles(participant: DayParticipant, metrics: Record<string, number> = {}): number {
const result = calculateDay({
businessDate: '2026-04-01',
orgUnitId: 'UNIT',
metrics,
shiftNormMinutes: 600,
participants: [participant],
});
return result.lines[0]!.amountMinor / RUB;
}
describe('pay rules: delivery, the case described by the owner', () => {
const orders = [
{ amount_minor: 3_000 * RUB, distance_km: 3 },
{ amount_minor: 7_000 * RUB, distance_km: 3 },
{ amount_minor: 9_000 * RUB, distance_km: 12 },
];
it('prices large orders per order by distance and keeps them out of the percent base', () => {
const courier = participantOf(deliveryPreset, 'courier', { hours: 8, items: orders });
// 8 h × 150 ₽ = 1200; 5% of the 3000 ₽ order = 150; 7000 ₽ order (3 km) = 250; 9000 ₽ order (12 km) = 500.
expect(amountRubles(courier)).toBe(2100);
});
it('pays a flat amount per delivery with a surcharge for the distant ones', () => {
const courier = participantOf(deliveryPreset, 'courier_per_delivery', {
hours: 8,
items: orders,
});
// three deliveries × 200 ₽ + one over 10 km × 100 ₽
expect(amountRubles(courier)).toBe(700);
});
});
describe('pay rules: progressive and flat percent scales', () => {
const sales = { personal_sales_minor: 400_000 * RUB };
it('applies a progressive scale layer by layer', () => {
const seller = participantOf(retailPreset, 'seller_progressive', { hours: 10 });
// 2500 ₽ shift + 3% of the first 100k + 5% of the next 200k + 7% of the remaining 100k
expect(amountRubles(seller, sales)).toBe(22_500);
});
it('applies a flat scale to the whole amount at the reached level', () => {
const seller = participantOf(retailPreset, 'seller_flat_tier', { hours: 10 });
expect(amountRubles(seller, sales)).toBe(30_000);
});
it('uses the lowest band when the result is below the first threshold', () => {
const seller = participantOf(retailPreset, 'seller_flat_tier', { hours: 10 });
expect(amountRubles(seller, { personal_sales_minor: 50_000 * RUB })).toBe(3_500);
});
});
describe('pay rules: conditional bonuses and guarantees', () => {
it('adds the team bonus only when both conditions hold', () => {
const rule = findRoleTemplate(cateringPreset, 'pool_member_with_target')!;
const base: DayParticipant = {
employeeId: 'cook',
workedMinutes: 720,
ruleCode: rule.code,
rule: rule.rule,
params: defaultParams(rule),
poolWeightBp: rule.poolWeightBp,
};
const withThreeCooks = calculateDay({
businessDate: '2026-04-02',
orgUnitId: 'UNIT',
metrics: { revenue_minor: 120_000 * RUB },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: 720,
participants: [base, { ...base, employeeId: 'cook-2' }, { ...base, employeeId: 'cook-3' }],
});
const withTwoCooks = calculateDay({
businessDate: '2026-04-02',
orgUnitId: 'UNIT',
metrics: { revenue_minor: 120_000 * RUB },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: 720,
participants: [base, { ...base, employeeId: 'cook-2' }],
});
const bonusPaid = withThreeCooks.lines[0]!.rawAmountMinor;
const poolPerCook = withThreeCooks.lines[0]!.poolShareMinor;
expect(bonusPaid - poolPerCook - 1200 * RUB).toBe(500 * RUB);
expect(withTwoCooks.lines[0]!.rawAmountMinor - withTwoCooks.lines[0]!.poolShareMinor).toBe(
1200 * RUB,
);
});
it('keeps a master at the shift guarantee on an empty day', () => {
const master = participantOf(servicesPreset, 'master', { hours: 12, items: [] });
expect(amountRubles(master)).toBe(2_000);
});
it('pays a master different percents for own and house clients', () => {
const master = participantOf(servicesPreset, 'master', {
hours: 12,
items: [
{ amount_minor: 20_000 * RUB, is_own_client: 1 },
{ amount_minor: 10_000 * RUB, is_own_client: 0 },
],
});
expect(amountRubles(master)).toBe(13_500);
});
});
describe('pay rules: engine behaviour', () => {
const context: RuleContext = {
metrics: { revenue_minor: 100_000 * RUB },
params: { percent_bp: 250 },
minutes: 720,
participants: 3,
poolShareMinor: 500_000,
shiftNormMinutes: 720,
items: [],
};
it('treats a missing metric or parameter as zero instead of failing', () => {
expect(evaluateRule({ type: 'metric', key: 'unknown_metric' }, context)).toBe(0);
expect(evaluateRule({ type: 'param', key: 'unknown_param' }, context)).toBe(0);
});
it('never divides by zero', () => {
const node: RuleNode = {
type: 'div',
left: { type: 'const', value: 100 },
right: { type: 'const', value: 0 },
};
expect(evaluateRule(node, context)).toBe(0);
});
it('rejects an item field used outside a per-item block', () => {
expect(() => evaluateRule({ type: 'itemField', field: 'amount_minor' }, context)).toThrow(
RuleError,
);
});
it('rejects scales whose steps are out of order', () => {
expect(() =>
validateRule({
type: 'tier',
on: { type: 'metric', key: 'revenue_minor' },
bands: [
{ upTo: 1000, value: { type: 'const', value: 1 } },
{ upTo: 500, value: { type: 'const', value: 2 } },
],
}),
).toThrow(/по возрастанию/);
});
it('rejects an open step that is not the last one', () => {
expect(() =>
validateRule({
type: 'progressive',
base: { type: 'metric', key: 'revenue_minor' },
bands: [
{ upTo: null, value: { type: 'const', value: 1 } },
{ upTo: 1000, value: { type: 'const', value: 2 } },
],
}),
).toThrow(/только последней/);
});
it('rejects keys that the editor must never produce', () => {
expect(() => validateRule({ type: 'metric', key: 'Выручка' })).toThrow(RuleError);
});
it('accepts every rule shipped in the presets', () => {
for (const preset of [cateringPreset, retailPreset, deliveryPreset, servicesPreset]) {
if (preset.poolRule) validateRule(preset.poolRule);
for (const role of preset.roles) validateRule(role.rule);
}
});
it('describes a rule in words for the editor and the payslip', () => {
const role = findRoleTemplate(cateringPreset, 'revenue_percent')!;
const text = describeRule(role.rule, {
metrics: { revenue_minor: 'выручка точки' },
params: {
percent_bp: 'личный процент',
rate_minor: 'ставка за час',
guarantee_minor: 'гарантия смены',
},
});
expect(text).toBe(
'наибольшее из (личный процент от «выручка точки» + ставка за час × часы смены; гарантия смены)',
);
});
it('describes the delivery rule with its filters', () => {
const role = findRoleTemplate(deliveryPreset, 'courier')!;
const text = describeRule(role.rule, {
params: { rate_minor: 'ставка за час', percent_bp: 'процент' },
itemFields: { amount_minor: 'сумма заказа', distance_km: 'дальность' },
itemFieldUnits: { amount_minor: 'money', distance_km: 'number' },
});
// Thresholds are printed in rubles, not in the kopecks the rule actually stores.
expect(text).toMatch(/сумма «сумма заказа» по позициям \(сумма заказа ≤ 5\s?000 ₽\)/);
expect(text).toMatch(/за каждую позицию \(сумма заказа > 5\s?000 ₽\)/);
expect(text).toContain('по шкале от «дальность»: до 5 — 250 ₽; свыше — 500 ₽');
});
});

View file

@ -20,3 +20,71 @@ describe('tenant isolation migration', () => {
expect(source).toContain("set_config('app.user_id', $2, true)");
});
});
describe('attendance portal migrations', () => {
const sql = readFileSync('migrations/0005_attendance_portal.up.sql', 'utf8');
const tables = ['staff_employees', 'staff_attendance_events', 'staff_timesheet_entries'];
it.each(tables)('%s is tenant scoped and forces RLS', (table) => {
expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
expect(sql).toMatch(new RegExp(`CREATE POLICY ${table}_[a-z_]+ ON ${table}`));
});
it('keeps raw attendance immutable for the runtime role', () => {
expect(sql).toContain('GRANT SELECT, INSERT ON staff_attendance_events TO krona_staff_app');
expect(sql).toContain(
'REVOKE UPDATE, DELETE, TRUNCATE ON staff_attendance_events FROM krona_staff_app',
);
});
});
describe('payroll foundation migration', () => {
const sql = readFileSync('migrations/0006_payroll_foundation.up.sql', 'utf8');
const tables = [
'staff_org_units',
'staff_org_unit_terms',
'staff_metric_definitions',
'staff_daily_metrics',
'staff_work_items',
'staff_pay_rules',
'staff_pay_assignments',
'staff_work_sessions',
'staff_payroll_runs',
'staff_payroll_lines',
'staff_payouts',
];
it.each(tables)('%s is tenant scoped and included in the RLS setup', (table) => {
expect(sql).toContain(`CREATE TABLE ${table}`);
expect(sql).toContain(`'${table}'`);
});
it('enables and forces RLS with a tenant policy for every listed payroll table', () => {
expect(sql).toContain("EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table)");
expect(sql).toContain("EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table)");
expect(sql).toContain("target_table || '_tenant_isolation', target_table");
});
it('uses tenant-scoped foreign keys for payroll ownership', () => {
expect(sql).toContain(
'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id)',
);
expect(sql).toContain(
'FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id)',
);
expect(sql).toContain(
'FOREIGN KEY (tenant_id, pay_rule_id) REFERENCES staff_pay_rules (tenant_id, id)',
);
});
it('keeps the money ledger append-only for the runtime role', () => {
expect(sql).toContain('CREATE TABLE staff_payroll_ledger');
expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY');
expect(sql).toContain('ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY');
expect(sql).toContain('GRANT SELECT, INSERT ON staff_payroll_ledger TO krona_staff_app');
expect(sql).toContain(
'REVOKE UPDATE, DELETE, TRUNCATE ON staff_payroll_ledger FROM krona_staff_app',
);
});
});