feat: add secure attendance portal and payroll foundation
Some checks are pending
ci / verify (push) Waiting to run
Some checks are pending
ci / verify (push) Waiting to run
This commit is contained in:
parent
b4eb3432ae
commit
3464998168
47 changed files with 4663 additions and 35 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,6 +3,7 @@ node_modules/
|
|||
dist/
|
||||
coverage/
|
||||
.env
|
||||
deploy/pilot/secrets/
|
||||
*.log
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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).
|
||||
|
|
|
|||
102
api/openapi.yaml
102
api/openapi.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
90
apps/KronaStaffTerminal/Services/AttendanceSyncService.cs
Normal file
90
apps/KronaStaffTerminal/Services/AttendanceSyncService.cs
Normal 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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
78
deploy/pilot/compose.yaml
Normal 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
44
deploy/pilot/setup.sh
Normal 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
|
||||
19
deploy/pilot/smoke-test.sh
Normal file
19
deploy/pilot/smoke-test.sh
Normal 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
116
docs/APP-CORE.md
Normal 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`.
|
||||
45
docs/PAYROLL-LEGAL-LIMITS.md
Normal file
45
docs/PAYROLL-LEGAL-LIMITS.md
Normal 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 ГК РФ), не элемент оплаты труда. Распределение общего котла — внутренняя договорённость; в расчёт зарплаты попадает как отдельный вид начисления.
|
||||
- **Самозанятые и ГПХ.** «Корректировки вознаграждения» в доставке работают для ГПХ, но для штатных работников по трудовому договору такая модель незаконна. Правило, написанное для курьеров-самозанятых, нельзя механически переносить на штат.
|
||||
- **Возврат и дебиторка в продажах.** Прямого удержания нет; работающая механика — не включать сделку в базу бонуса до поступления денег и уменьшать базу следующего периода при возврате.
|
||||
131
docs/PAYROLL-SCHEMES-CATALOGUE.md
Normal file
131
docs/PAYROLL-SCHEMES-CATALOGUE.md
Normal 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
131
docs/REQ-001-PAYROLL.md
Normal 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. Выплаты: только наличные или бывают переводы, и нужно ли фиксировать, кто выдал деньги?
|
||||
|
|
@ -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.
|
||||
|
||||
Никакой пункт не означает автоматически готовую бизнес-функцию или срок.
|
||||
|
|
|
|||
81
docs/adr/ADR-002-payroll-domain-and-platform-alignment.md
Normal file
81
docs/adr/ADR-002-payroll-domain-and-platform-alignment.md
Normal 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`; редактировать ставки без версий; переносить шаблоны отпечатков на сервер; ждать готовности рантайма модулей платформы, ничего не делая.
|
||||
4
migrations/0004_revoke_broad_default_privileges.down.sql
Normal file
4
migrations/0004_revoke_broad_default_privileges.down.sql
Normal 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;
|
||||
4
migrations/0004_revoke_broad_default_privileges.up.sql
Normal file
4
migrations/0004_revoke_broad_default_privileges.up.sql
Normal 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;
|
||||
3
migrations/0005_attendance_portal.down.sql
Normal file
3
migrations/0005_attendance_portal.down.sql
Normal 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;
|
||||
82
migrations/0005_attendance_portal.up.sql
Normal file
82
migrations/0005_attendance_portal.up.sql
Normal 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;
|
||||
25
migrations/0006_payroll_foundation.down.sql
Normal file
25
migrations/0006_payroll_foundation.down.sql
Normal 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;
|
||||
302
migrations/0006_payroll_foundation.up.sql
Normal file
302
migrations/0006_payroll_foundation.up.sql
Normal 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;
|
||||
41
module.json
41
module.json
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"type": {
|
||||
"enum": [
|
||||
"text",
|
||||
"integer",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
|
|
|
|||
52
scripts/configure-terminal-pilot.ps1
Normal file
52
scripts/configure-terminal-pilot.ps1
Normal 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.'
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": "Если выключено, сотрудник видит суммы только после утверждения ведомости."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
25
src/app.ts
25
src/app.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
287
src/domain/payroll/calculator.ts
Normal file
287
src/domain/payroll/calculator.ts
Normal 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));
|
||||
}
|
||||
59
src/domain/payroll/period.ts
Normal file
59
src/domain/payroll/period.ts
Normal 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);
|
||||
}
|
||||
632
src/domain/payroll/presets.ts
Normal file
632
src/domain/payroll/presets.ts
Normal 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
757
src/domain/payroll/rule.ts
Normal 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
82
src/portal-ui.ts
Normal file
File diff suppressed because one or more lines are too long
295
src/portal.ts
Normal file
295
src/portal.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
137
tests/integration/portal.test.ts
Normal file
137
tests/integration/portal.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
246
tests/unit/payroll-calculator.test.ts
Normal file
246
tests/unit/payroll-calculator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
150
tests/unit/payroll-industry.test.ts
Normal file
150
tests/unit/payroll-industry.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
241
tests/unit/payroll-rule.test.ts
Normal file
241
tests/unit/payroll-rule.test.ts
Normal 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 ₽');
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue