Compare commits

...

5 commits

32 changed files with 4598 additions and 775 deletions

View file

@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Krona Staff API
version: 0.3.0
version: 0.3.1
description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя.
license:
name: Proprietary
@ -107,6 +107,39 @@ paths:
schema: { $ref: '#/components/schemas/Employee' }
'403': { description: Недостаточно прав }
'409': { description: Табельный номер уже используется }
/professions:
get:
summary: Получить кадровый справочник профессий
operationId: listStaffProfessions
tags: [Employees]
security: [{ bearerAuth: [] }]
responses:
'200': { description: Tenant profession catalogue }
'401': { description: Требуется авторизация }
post:
summary: Добавить профессию в кадровый справочник
operationId: createStaffProfession
tags: [Employees]
security: [{ bearerAuth: [] }]
responses:
'201': { description: Profession created }
'403': { description: Недостаточно прав }
'409': { description: Профессия уже существует }
/professions/{professionId}:
parameters:
- in: path
name: professionId
required: true
schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }
patch:
summary: Изменить профессию или её статус
operationId: updateStaffProfession
tags: [Employees]
security: [{ bearerAuth: [] }]
responses:
'200': { description: Profession updated }
'403': { description: Недостаточно прав }
'404': { description: Профессия не найдена }
/employees/{employeeId}:
patch:
summary: Изменить карточку или статус сотрудника
@ -177,7 +210,8 @@ paths:
date_to: { type: string, format: date }
business_day_start_hour: { type: integer, minimum: 0, maximum: 23, default: 4 }
automatic_close_hour: { type: integer, minimum: 0, maximum: 23, default: 2 }
open_shift_threshold_minutes: { type: integer, minimum: 60, maximum: 2880, default: 840 }
open_shift_threshold_minutes:
{ type: integer, minimum: 60, maximum: 2880, default: 840 }
responses:
'200':
description: Итоги сверки, включая события без назначенного подразделения
@ -185,7 +219,14 @@ paths:
application/json:
schema:
type: object
required: [groups, sessions_created_or_updated, confirmed_sessions_skipped, anomalous_sessions, unresolved_events]
required:
[
groups,
sessions_created_or_updated,
confirmed_sessions_skipped,
anomalous_sessions,
unresolved_events,
]
properties:
groups: { type: integer, minimum: 0 }
sessions_created_or_updated: { type: integer, minimum: 0 }
@ -270,6 +311,67 @@ paths:
'200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток }
'403': { description: Недостаточно прав }
'404': { description: Сотрудник не найден }
/employees/{employeeId}/pay-assignment:
get:
summary: Получить зарплатный профиль сотрудника
operationId: getEmployeePayAssignment
tags: [Payroll]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: employeeId
required: true
schema: { $ref: '#/components/schemas/Ulid' }
responses:
'200':
description: Зарплатный профиль сотрудника
content:
application/json:
schema: { $ref: '#/components/schemas/PayAssignment' }
'403': { description: Недостаточно прав }
'404': { description: Сотрудник или зарплатный профиль не найден }
put:
summary: Сохранить зарплатный профиль сотрудника
operationId: saveEmployeePayAssignment
tags: [Payroll]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: employeeId
required: true
schema: { $ref: '#/components/schemas/Ulid' }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/PayAssignmentSave' }
responses:
'200':
description: Зарплатный профиль сохранён
content:
application/json:
schema: { $ref: '#/components/schemas/PayAssignment' }
'403': { description: Недостаточно прав }
'404': { description: 'Сотрудник, точка или схема не найдены' }
/pay-rules:
get:
summary: Получить список схем расчёта зарплаты
operationId: listStaffPayRules
tags: [Payroll]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Список доступных схем расчёта
content:
application/json:
schema:
type: object
required: [pay_rules]
properties:
pay_rules:
type: array
items: { type: object }
'403': { description: Недостаточно прав }
/org-units:
get:
summary: Получить доступные торговые точки
@ -298,7 +400,12 @@ paths:
tags: [Finance]
security: [{ bearerAuth: [] }]
parameters:
- { in: path, name: orgUnitId, required: true, schema: { $ref: '#/components/schemas/Ulid' } }
- {
in: path,
name: orgUnitId,
required: true,
schema: { $ref: '#/components/schemas/Ulid' },
}
- { in: path, name: businessDate, required: true, schema: { type: string, format: date } }
- { in: path, name: metricKey, required: true, schema: { type: string } }
requestBody:
@ -309,7 +416,8 @@ paths:
type: object
required: [value_minor]
properties:
value_minor: { type: integer, minimum: -9000000000000000, maximum: 9000000000000000 }
value_minor:
{ type: integer, minimum: -9000000000000000, maximum: 9000000000000000 }
currency_code: { type: string, pattern: '^[A-Z]{3}$' }
responses:
'200': { description: Сумма сохранена }
@ -333,7 +441,12 @@ paths:
tags: [Schedule]
security: [{ bearerAuth: [] }]
parameters:
- { in: path, name: employeeId, required: true, schema: { $ref: '#/components/schemas/Ulid' } }
- {
in: path,
name: employeeId,
required: true,
schema: { $ref: '#/components/schemas/Ulid' },
}
- { in: path, name: businessDate, required: true, schema: { type: string, format: date } }
requestBody:
required: true
@ -363,6 +476,97 @@ paths:
responses:
'200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' }
'403': { description: Недостаточно прав }
/payroll/calculate:
post:
summary: Рассчитать зарплату за период
operationId: calculateStaffPayroll
tags: [Payroll]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
month: { type: string, pattern: '^\\d{4}-\\d{2}$' }
from: { type: string, format: date }
to: { type: string, format: date }
org_unit_id: { $ref: '#/components/schemas/Ulid' }
responses:
'200':
description: Предварительный результат расчёта
content:
application/json:
schema:
type: object
required:
[run_id, status, period_start, period_end, total_accrued_minor, totals, warnings]
properties:
run_id: { $ref: '#/components/schemas/Ulid' }
status: { const: calculated }
period_start: { type: string, format: date }
period_end: { type: string, format: date }
total_accrued_minor: { type: string }
totals: { type: array, items: { type: object } }
warnings: { type: array, items: { type: object } }
'400': { description: Некорректный период }
'403': { description: Недостаточно прав }
'409': { description: Расчёт за этот период уже утверждён }
/payroll/runs/{runId}/approve:
post:
summary: Утвердить расчёт зарплаты и записать начисления в реестр
operationId: approveStaffPayrollRun
tags: [Payroll]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: runId
required: true
schema: { $ref: '#/components/schemas/Ulid' }
responses:
'200':
description: Расчёт утверждён и проведён в реестр
content:
application/json:
schema:
type: object
required:
[run_id, status, approved_by, approved_at, total_accrued_minor, entries_posted]
properties:
run_id: { $ref: '#/components/schemas/Ulid' }
status: { const: approved }
approved_by: { $ref: '#/components/schemas/Ulid' }
approved_at: { type: string, format: date-time }
total_accrued_minor: { type: string }
entries_posted: { type: integer }
'400': { description: Расчёт не в статусе calculated }
'403': { description: Недостаточно прав }
'404': { description: Расчёт не найден }
/payroll/runs/{runId}:
get:
summary: Получить подробности расчёта зарплаты
operationId: getStaffPayrollRun
tags: [Payroll]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: runId
required: true
schema: { $ref: '#/components/schemas/Ulid' }
responses:
'200':
description: Детали расчёта со строками
content:
application/json:
schema:
type: object
required: [run, lines]
properties:
run: { type: object }
lines: { type: array, items: { type: object } }
'403': { description: Недостаточно прав }
'404': { description: Расчёт не найден }
/imports/owner-workbook:
post:
summary: Идемпотентно импортировать локально разобранную книгу владельца
@ -460,3 +664,56 @@ components:
name: { const: krona-staff }
version: { type: string }
mode: { enum: [standalone, platform] }
PayAssignment:
type: object
additionalProperties: false
required:
[
id,
employee_id,
pay_rule_id,
pay_rule_code,
pay_rule_name,
rate_minor,
percent_bp,
guarantee_minor,
pool_weight_bp,
effective_from,
]
properties:
id: { $ref: '#/components/schemas/Ulid' }
employee_id: { $ref: '#/components/schemas/Ulid' }
org_unit_id:
oneOf:
- { $ref: '#/components/schemas/Ulid' }
- { type: 'null' }
pay_rule_id: { $ref: '#/components/schemas/Ulid' }
pay_rule_code: { type: string }
pay_rule_name: { type: string }
rate_minor: { type: integer, minimum: 0 }
percent_bp: { type: integer, minimum: 0 }
guarantee_minor: { type: integer, minimum: 0 }
pool_weight_bp: { type: integer, minimum: 0 }
effective_from: { type: string, format: date }
effective_to:
oneOf:
- { type: string, format: date }
- { type: 'null' }
params: { type: object }
note: { type: string }
PayAssignmentSave:
type: object
additionalProperties: false
required: [pay_rule_id]
properties:
org_unit_id:
oneOf:
- { $ref: '#/components/schemas/Ulid' }
- { type: 'null' }
pay_rule_id: { $ref: '#/components/schemas/Ulid' }
rate_minor: { type: integer, minimum: 0 }
percent_bp: { type: integer, minimum: 0, maximum: 10000 }
guarantee_minor: { type: integer, minimum: 0 }
pool_weight_bp: { type: integer, minimum: 0, maximum: 20000 }
effective_from: { type: string, format: date }
note: { type: string, maxLength: 500 }

View file

@ -8,9 +8,9 @@
<PlatformTarget>x64</PlatformTarget>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
<RootNamespace>KronaStaffService</RootNamespace>
<Version>0.3.0</Version>
<AssemblyVersion>0.3.0.0</AssemblyVersion>
<FileVersion>0.3.0.0</FileVersion>
<Version>0.3.1</Version>
<AssemblyVersion>0.3.1.0</AssemblyVersion>
<FileVersion>0.3.1.0</FileVersion>
</PropertyGroup>
<ItemGroup>

View file

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Text.Json;
using KronaStaffTerminal.Models;
using KronaStaffTerminal.Services;
@ -13,6 +14,7 @@ public sealed class KronaStaffWorker : BackgroundService
private readonly AttendanceSyncService _syncService;
private readonly ILicenseStatusPort _licensePort;
private readonly SemaphoreSlim _syncGate = new(1, 1);
private readonly ConcurrentDictionary<string, long> _acceptedIpcNonces = new();
private TerminalIpcServer? _ipcServer;
private bool _scannerSession0Supported;
private string _scannerDiagnostic = "";
@ -132,6 +134,17 @@ public sealed class KronaStaffWorker : BackgroundService
{
try
{
var authenticationState = await _store.LoadAsync();
var now = DateTimeOffset.UtcNow;
if (!IpcRequestAuthenticator.Verify(
request, authenticationState.Settings.IpcAuthenticationKey, now))
{
return new IpcResponse { Success = false, ErrorMessage = "Unauthorized request" };
}
foreach (var nonce in _acceptedIpcNonces.Where(item => item.Value < now.AddMinutes(-1).ToUnixTimeSeconds()))
_acceptedIpcNonces.TryRemove(nonce.Key, out _);
if (!_acceptedIpcNonces.TryAdd(request.Nonce, request.IssuedAtUnixSeconds))
return new IpcResponse { Success = false, ErrorMessage = "Replayed request" };
switch (request.Command)
{
case "Ping":
@ -169,10 +182,30 @@ public sealed class KronaStaffWorker : BackgroundService
return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record JSON" };
}
if (!System.Text.RegularExpressions.Regex.IsMatch(record.Id, "^[0-9A-HJKMNP-TV-Z]{26}$") ||
!System.Text.RegularExpressions.Regex.IsMatch(record.EmployeeId, "^[0-9A-HJKMNP-TV-Z]{26}$") ||
record.EventType is not ("Приход" or "Уход" or "Уход автоматически") ||
record.OccurredAtUtc < DateTimeOffset.UtcNow.AddDays(-180) ||
record.OccurredAtUtc > DateTimeOffset.UtcNow.AddMinutes(5) ||
record.ReaderIndex < 0)
{
return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record" };
}
await _syncGate.WaitAsync();
try
{
var state = await _store.LoadAsync();
var employee = state.Employees.FirstOrDefault(item => item.Id == record.EmployeeId);
if (employee is null || employee.IsBlocked)
{
return new IpcResponse { Success = false, ErrorMessage = "Employee is unavailable" };
}
record.EmployeeName = employee.DisplayName;
record.ServerSynced = false;
record.ServerSyncError = null;
record.NotificationDelivered = false;
record.NotificationError = null;
if (state.Attendance.All(a => a.Id != record.Id))
{
state.Attendance.Insert(0, record);
@ -225,7 +258,7 @@ public sealed class KronaStaffWorker : BackgroundService
return new IpcServiceStatus
{
ServiceRunning = true,
ServiceVersion = "0.3.0",
ServiceVersion = "0.3.1",
ScannerInSession0Supported = _scannerSession0Supported,
ScannerDiagnostic = _scannerDiagnostic,
PendingSyncCount = pendingCount,

View file

@ -0,0 +1,28 @@
using KronaStaffTerminal.Services;
using Xunit;
namespace KronaStaffTerminal.Tests;
public sealed class CsvCellEncoderTests
{
[Theory]
[InlineData("=HYPERLINK(\"https://example.invalid\")")]
[InlineData("+cmd")]
[InlineData("-1+1")]
[InlineData("@SUM(A1:A2)")]
[InlineData(" =SUM(A1:A2)")]
[InlineData("\t=SUM(A1:A2)")]
[InlineData("\r=SUM(A1:A2)")]
public void Encode_neutralizes_spreadsheet_formulas(string value)
{
var encoded = CsvCellEncoder.Encode(value);
Assert.StartsWith("\"'", encoded);
}
[Fact]
public void Encode_preserves_regular_unicode_and_escapes_quotes()
{
Assert.Equal("\"Иван \"\"Повар\"\"; смена\"", CsvCellEncoder.Encode("Иван \"Повар\"; смена"));
}
}

View file

@ -0,0 +1,44 @@
using KronaStaffTerminal.Services;
using Xunit;
namespace KronaStaffTerminal.Tests;
public sealed class IpcRequestAuthenticatorTests
{
private static readonly string Key = Convert.ToBase64String(new byte[32].Select((_, index) => (byte)index).ToArray());
[Fact]
public void Verify_accepts_fresh_signed_request()
{
var now = DateTimeOffset.UtcNow;
var request = new IpcRequest
{
Command = "Ping",
IssuedAtUnixSeconds = now.ToUnixTimeSeconds(),
Nonce = "00112233445566778899AABBCCDDEEFF"
};
request.Signature = IpcRequestAuthenticator.Sign(request, Key);
Assert.True(IpcRequestAuthenticator.Verify(request, Key, now));
}
[Fact]
public void Verify_rejects_tampering_and_stale_requests()
{
var now = DateTimeOffset.UtcNow;
var request = new IpcRequest
{
Command = "EnqueueAttendance",
Payload = "{}",
IssuedAtUnixSeconds = now.AddMinutes(-2).ToUnixTimeSeconds(),
Nonce = "00112233445566778899AABBCCDDEEFF"
};
request.Signature = IpcRequestAuthenticator.Sign(request, Key);
Assert.False(IpcRequestAuthenticator.Verify(request, Key, now));
request.IssuedAtUnixSeconds = now.ToUnixTimeSeconds();
request.Signature = IpcRequestAuthenticator.Sign(request, Key);
request.Payload = "{\"forged\":true}";
Assert.False(IpcRequestAuthenticator.Verify(request, Key, now));
}
}

View file

@ -35,6 +35,7 @@
<Compile Include="..\KronaStaffTerminal\Services\AttendanceSyncService.cs" Link="Services\AttendanceSyncService.cs" />
<Compile Include="..\KronaStaffTerminal\Services\AttendanceTracker.cs" Link="Services\AttendanceTracker.cs" />
<Compile Include="..\KronaStaffTerminal\Services\CrashLogService.cs" Link="Services\CrashLogService.cs" />
<Compile Include="..\KronaStaffTerminal\Services\CsvCellEncoder.cs" Link="Services\CsvCellEncoder.cs" />
<Compile Include="..\KronaStaffTerminal\Services\FingerprintScanner.cs" Link="Services\FingerprintScanner.cs" />
<Compile Include="..\KronaStaffTerminal\Services\LicenseStatus.cs" Link="Services\LicenseStatus.cs" />
<Compile Include="..\KronaStaffTerminal\Services\TerminalIpc.cs" Link="Services\TerminalIpc.cs" />

View file

@ -17,10 +17,10 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Version>0.3.0</Version>
<AssemblyVersion>0.3.0.0</AssemblyVersion>
<FileVersion>0.3.0.0</FileVersion>
<InformationalVersion>0.3.0</InformationalVersion>
<Version>0.3.1</Version>
<AssemblyVersion>0.3.1.0</AssemblyVersion>
<FileVersion>0.3.1.0</FileVersion>
<InformationalVersion>0.3.1</InformationalVersion>
</PropertyGroup>
<ItemGroup>

View file

@ -1240,12 +1240,12 @@ public sealed partial class MainPage : Page
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
{
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
csv.Append(Csv(item.EmployeeName)).Append(';')
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
.Append(Csv(employee?.Position ?? "")).Append(';')
.Append(Csv(item.EventType)).Append(';')
.Append(Csv(item.LocalTime)).Append(';')
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
csv.Append(CsvCellEncoder.Encode(item.EmployeeName)).Append(';')
.Append(CsvCellEncoder.Encode(employee?.PersonnelNumber)).Append(';')
.Append(CsvCellEncoder.Encode(employee?.Position)).Append(';')
.Append(CsvCellEncoder.Encode(item.EventType)).Append(';')
.Append(CsvCellEncoder.Encode(item.LocalTime)).Append(';')
.Append(CsvCellEncoder.Encode(item.DeliveryStatus)).Append("\r\n");
}
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}");
@ -1261,8 +1261,6 @@ public sealed partial class MainPage : Page
}
}
private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\"";
public async Task<bool> AuthorizeExitAsync()
{
if (_stateLoadFailed)

View file

@ -1,3 +1,4 @@
using System.Security.Cryptography;
using System.Text.Json.Serialization;
namespace KronaStaffTerminal.Models;
@ -108,6 +109,7 @@ public sealed class AttendanceRecord
public sealed class TerminalSettings
{
public string IpcAuthenticationKey { get; set; } = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
public int ReaderIndex { get; set; }
public bool StartWithWindows { get; set; }
public string AdminPinSalt { get; set; } = "";

View file

@ -0,0 +1,20 @@
namespace KronaStaffTerminal.Services;
public static class CsvCellEncoder
{
public static string Encode(string? value)
{
var safe = value ?? string.Empty;
var firstContent = safe.AsSpan().TrimStart();
if (firstContent.Length > 0 && firstContent[0] is '=' or '+' or '-' or '@')
{
safe = "'" + safe;
}
else if (safe.Length > 0 && safe[0] is '\t' or '\r' or '\n')
{
safe = "'" + safe;
}
return $"\"{safe.Replace("\"", "\"\"")}\"";
}
}

View file

@ -1,5 +1,6 @@
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text;
using System.Text.Json;
@ -11,6 +12,9 @@ public sealed class IpcRequest
{
public string Command { get; set; } = "";
public string? Payload { get; set; }
public long IssuedAtUnixSeconds { get; set; }
public string Nonce { get; set; } = "";
public string Signature { get; set; } = "";
}
public sealed class IpcResponse
@ -25,7 +29,7 @@ public sealed class IpcResponse
public sealed class IpcServiceStatus
{
public bool ServiceRunning { get; set; } = true;
public string ServiceVersion { get; set; } = "0.3.0";
public string ServiceVersion { get; set; } = "0.3.1";
public bool ScannerInSession0Supported { get; set; }
public string ScannerDiagnostic { get; set; } = "";
public int PendingSyncCount { get; set; }
@ -42,6 +46,8 @@ public static class TerminalIpcConstants
public sealed class TerminalIpcClient
{
private readonly TerminalStore _store = new();
public async Task<bool> IsServiceAvailableAsync(CancellationToken cancellationToken = default)
{
var response = await SendCommandAsync("Ping", null, cancellationToken);
@ -81,7 +87,15 @@ public sealed class TerminalIpcClient
await pipe.ConnectAsync(timeoutCts.Token);
var request = new IpcRequest { Command = command, Payload = payload };
var state = await _store.LoadAsync();
var request = new IpcRequest
{
Command = command,
Payload = payload,
IssuedAtUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
Nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16))
};
request.Signature = IpcRequestAuthenticator.Sign(request, state.Settings.IpcAuthenticationKey);
var json = JsonSerializer.Serialize(request);
var bytes = Encoding.UTF8.GetBytes(json);
var lengthPrefix = BitConverter.GetBytes(bytes.Length);
@ -173,7 +187,7 @@ public sealed class TerminalIpcServer : IDisposable
var read = await stream.ReadAsync(reqLenBytes, token);
if (read < 4) return;
var reqLen = BitConverter.ToInt32(reqLenBytes);
if (reqLen is <= 0 or > 10 * 1024 * 1024) return;
if (reqLen is <= 0 or > 256 * 1024) return;
var reqBytes = new byte[reqLen];
var totalRead = 0;
@ -212,11 +226,11 @@ public sealed class TerminalIpcServer : IDisposable
var pipeSecurity = new PipeSecurity();
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null);
var interactiveSid = new SecurityIdentifier(WellKnownSidType.InteractiveSid, null);
pipeSecurity.AddAccessRule(new PipeAccessRule(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, PipeAccessRights.FullControl, AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(usersSid, PipeAccessRights.ReadWrite, AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(interactiveSid, PipeAccessRights.ReadWrite, AccessControlType.Allow));
return NamedPipeServerStreamAcl.Create(
@"global\" + TerminalIpcConstants.PipeName,
@ -237,3 +251,29 @@ public sealed class TerminalIpcServer : IDisposable
_cancellation?.Dispose();
}
}
public static class IpcRequestAuthenticator
{
public static string Sign(IpcRequest request, string key)
{
var canonical = $"{request.Command}\n{request.IssuedAtUnixSeconds}\n{request.Nonce}\n{request.Payload ?? string.Empty}";
using var hmac = new HMACSHA256(Convert.FromBase64String(key));
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)));
}
public static bool Verify(IpcRequest request, string key, DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(key) || request.Nonce.Length != 32) return false;
if (Math.Abs(now.ToUnixTimeSeconds() - request.IssuedAtUnixSeconds) > 60) return false;
try
{
var actual = Convert.FromBase64String(request.Signature);
var expected = Convert.FromBase64String(Sign(request, key));
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch (FormatException)
{
return false;
}
}
}

View file

@ -10,6 +10,7 @@ namespace KronaStaffTerminal.Services;
public sealed class TerminalStore
{
public const string OperatorGroupName = "KronaStaffTerminalOperators";
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private const string RegistryPath = @"Software\Krona\StaffTerminal";
@ -67,13 +68,17 @@ public sealed class TerminalStore
"Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных.");
}
return new TerminalState();
return await InitializeFreshStateAsync();
}
try
{
if (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{
EnsureDirectoryWithRestrictedAcl(Path.GetDirectoryName(_filePath)!);
}
var state = await ReadStateAsync(sourcePath);
var migrated = MigrateEmployees(state);
var migrated = MigrateEmployees(state) | MigrateSecurity(state);
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{
await SaveAsync(state);
@ -92,6 +97,7 @@ public sealed class TerminalStore
try
{
File.Move(_filePath, corruptPath, false);
ApplyRestrictedFileAcl(corruptPath);
}
catch (Exception moveException)
{
@ -208,6 +214,43 @@ public sealed class TerminalStore
return changed;
}
private static bool MigrateSecurity(TerminalState state)
{
if (!string.IsNullOrWhiteSpace(state.Settings.IpcAuthenticationKey)) return false;
state.Settings.IpcAuthenticationKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
return true;
}
private async Task<TerminalState> InitializeFreshStateAsync()
{
var directory = Path.GetDirectoryName(_filePath)!;
EnsureDirectoryWithRestrictedAcl(directory);
var lockPath = _filePath + ".init.lock";
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
FileStream? initializationLock = null;
while (initializationLock is null)
{
try
{
initializationLock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite,
FileShare.None, 1, FileOptions.WriteThrough);
}
catch (IOException) when (DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(100);
}
}
await using (initializationLock)
{
ApplyRestrictedFileAcl(lockPath);
if (File.Exists(_filePath)) return await LoadAsync();
var state = new TerminalState();
await SaveAsync(state);
return state;
}
}
public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false)
{
await _saveGate.WaitAsync();
@ -239,6 +282,7 @@ public sealed class TerminalStore
File.Copy(_filePath, previousStatePath, true);
}
File.Move(temporaryPath, _filePath, true);
ApplyRestrictedFileAcl(_filePath);
MarkInitialized();
if (previousStatePath is not null)
{
@ -260,44 +304,62 @@ public sealed class TerminalStore
private static void EnsureDirectoryWithRestrictedAcl(string directoryPath)
{
if (Directory.Exists(directoryPath)) return;
Directory.CreateDirectory(directoryPath);
var identities = AllowedIdentities(directoryPath).ToArray();
var directorySecurity = new DirectorySecurity();
directorySecurity.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
foreach (var identity in identities)
{
directorySecurity.AddAccessRule(new FileSystemAccessRule(
identity,
FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
}
new DirectoryInfo(directoryPath).SetAccessControl(directorySecurity);
foreach (var stateFile in Directory.EnumerateFiles(directoryPath, "terminal-state.dat*"))
ApplyRestrictedFileAcl(stateFile);
}
private static IEnumerable<IdentityReference> AllowedIdentities(string? existingDirectory = null)
{
yield return new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
yield return new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
SecurityIdentifier? operatorSid = null;
try
{
var directorySecurity = new DirectorySecurity();
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null);
directorySecurity.AddAccessRule(new FileSystemAccessRule(
systemSid,
FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
directorySecurity.AddAccessRule(new FileSystemAccessRule(
adminSid,
FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
directorySecurity.AddAccessRule(new FileSystemAccessRule(
usersSid,
FileSystemRights.ReadAndExecute | FileSystemRights.Modify | FileSystemRights.Write,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
FileSystemAclExtensions.CreateDirectory(directorySecurity, directoryPath);
operatorSid = (SecurityIdentifier)new NTAccount(Environment.MachineName, OperatorGroupName)
.Translate(typeof(SecurityIdentifier));
}
catch
catch (IdentityNotMappedException) { }
if (operatorSid is not null) yield return operatorSid;
using var current = WindowsIdentity.GetCurrent();
if (current.User is not null && !current.User.IsWellKnown(WellKnownSidType.LocalSystemSid))
yield return current.User;
if (existingDirectory is null || !Directory.Exists(existingDirectory)) yield break;
var existing = new DirectoryInfo(existingDirectory).GetAccessControl(AccessControlSections.Access);
foreach (FileSystemAccessRule rule in existing.GetAccessRules(
includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier)))
{
Directory.CreateDirectory(directoryPath);
if (rule.AccessControlType != AccessControlType.Allow ||
rule.IdentityReference is not SecurityIdentifier sid || !sid.IsAccountSid()) continue;
yield return sid;
}
}
private static void ApplyRestrictedFileAcl(string path)
{
var security = new FileSecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
foreach (var identity in AllowedIdentities(Path.GetDirectoryName(path)))
security.AddAccessRule(new FileSystemAccessRule(identity, FileSystemRights.FullControl,
AccessControlType.Allow));
new FileInfo(path).SetAccessControl(security);
}
private bool ShouldCreateRecoveryPoint(bool requested)
{
var latestBackup = BackupPaths().First();

View file

@ -31,7 +31,7 @@ POST /api/v1/staff/attendance/events
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.0 (Windows NT 10.0; Win64; x64)` |
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.1 (Windows NT 10.0; Win64; x64)` |
---

View file

@ -5,7 +5,7 @@ SetCompressor /SOLID lzma
!include "x64.nsh"
!ifndef APP_VERSION
!define APP_VERSION "0.3.0"
!define APP_VERSION "0.3.1"
!endif
!ifndef PUBLISH_DIR
!error "PUBLISH_DIR is required"
@ -21,6 +21,7 @@ SetCompressor /SOLID lzma
!define APP_EXE "KronaStaffTerminal.exe"
!define SERVICE_EXE "KronaStaffService.exe"
!define SERVICE_NAME "KronaStaffService"
!define OPERATOR_GROUP "KronaStaffTerminalOperators"
!define APP_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\KronaStaffTerminal"
!define DRIVER_URL "https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows"
@ -88,6 +89,32 @@ Section "Krona Staff Terminal" SecMain
SetOverwrite try
File /r "${PUBLISH_DIR}\*"
DetailPrint "Настройка защищённой локальной группы терминала..."
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}"'
Pop $0
StrCmp $0 "0" group_ready
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" /add'
Pop $0
StrCmp $0 "0" group_ready
MessageBox MB_ICONSTOP "Не удалось создать защищённую группу ${OPERATOR_GROUP}. Установка остановлена."
Abort
group_ready:
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" "$USERNAME" /delete'
Pop $0
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" "$USERNAME" /add'
Pop $0
StrCmp $0 "0" membership_ready
MessageBox MB_ICONSTOP "Не удалось выдать текущему пользователю доступ к терминалу. Установка остановлена."
Abort
membership_ready:
CreateDirectory "$COMMONAPPDATA\Krona\StaffTerminal"
nsExec::ExecToLog 'icacls.exe "$COMMONAPPDATA\Krona\StaffTerminal" /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" "$USERDOMAIN\$USERNAME:(OI)(CI)M"'
Pop $0
StrCmp $0 "0" acl_ready
MessageBox MB_ICONSTOP "Не удалось защитить хранилище терминала. Установка остановлена."
Abort
acl_ready:
DetailPrint "Настройка службы ${SERVICE_NAME}..."
ExecWait 'sc.exe create ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto DisplayName= "Krona Staff Service"'
ExecWait 'sc.exe config ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto'
@ -160,8 +187,8 @@ Section "Uninstall"
IfSilent keep_data
MessageBox MB_YESNO|MB_ICONQUESTION "Сохранить локальную базу данных и журнал терминала (%ProgramData%\Krona\StaffTerminal)?$\r$\n$\r$\nНажмите 'Да', чтобы сохранить данные для будущей установки.$\r$\nНажмите 'Нет', чтобы полностью удалить данные с устройства." IDYES keep_data
RMDir /r "$APPDATA\Krona\StaffTerminal"
RMDir "$APPDATA\Krona"
RMDir /r "$COMMONAPPDATA\Krona\StaffTerminal"
RMDir "$COMMONAPPDATA\Krona"
keep_data:
Delete "$DESKTOP\Krona Staff Terminal.lnk"

View file

@ -0,0 +1,6 @@
DROP INDEX IF EXISTS staff_pay_assignments_effective_version_idx;
DROP INDEX IF EXISTS staff_employees_external_ref_idx;
DROP INDEX IF EXISTS staff_employees_profession_idx;
ALTER TABLE staff_employees DROP CONSTRAINT IF EXISTS staff_employees_profession_fk;
ALTER TABLE staff_employees DROP COLUMN IF EXISTS profession_id;
DROP TABLE IF EXISTS staff_professions;

View file

@ -0,0 +1,32 @@
CREATE TABLE staff_professions (
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 VARCHAR(80) NOT NULL,
name VARCHAR(160) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, code),
UNIQUE (tenant_id, name)
);
ALTER TABLE staff_professions ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_professions FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_professions_tenant_isolation ON staff_professions
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
ALTER TABLE staff_employees ADD COLUMN profession_id VARCHAR(26);
ALTER TABLE staff_employees ADD CONSTRAINT staff_employees_profession_fk
FOREIGN KEY (tenant_id, profession_id) REFERENCES staff_professions (tenant_id, id);
CREATE INDEX staff_employees_profession_idx ON staff_employees (tenant_id, profession_id);
CREATE UNIQUE INDEX staff_employees_external_ref_idx
ON staff_employees (tenant_id, external_ref) WHERE external_ref IS NOT NULL;
CREATE UNIQUE INDEX staff_pay_assignments_effective_version_idx
ON staff_pay_assignments
(tenant_id, employee_id, COALESCE(org_unit_id, ''), effective_from);
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_professions TO krona_staff_app;

View file

@ -2,7 +2,7 @@
"$schema": "./schemas/module.schema.json",
"id": "ru.krona.staff",
"name": "Krona Staff",
"version": "0.3.0",
"version": "0.3.1",
"min_platform_version": "3.2.0",
"dependencies": ["ru.krona.core"],
"optional_dependencies": [],

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@krona/staff",
"version": "0.3.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@krona/staff",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"@fastify/helmet": "^13.0.2",
"@fastify/sensible": "^6.0.3",

View file

@ -1,6 +1,6 @@
{
"name": "@krona/staff",
"version": "0.3.0",
"version": "0.3.1",
"private": true,
"type": "module",
"engines": {

View file

@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""Read the owner's workbook and import raw 2026 facts into Krona Staff.
The workbook is never modified. Personal data stays in memory and is sent only to
the explicitly supplied HTTPS Staff endpoint.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
from collections import defaultdict
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
import openpyxl
FINANCE_COLUMNS = {
3: "income_cash",
4: "income_card",
5: "income_transfer",
6: "income_sber_market",
7: "income_yandex",
8: "income_certificate",
10: "income_recycling",
12: "expense_products",
13: "expense_supplies",
14: "expense_taxi",
15: "expense_communications",
16: "expense_advertising",
17: "expense_owner_payroll",
18: "expense_taxes",
19: "expense_rent",
20: "expense_utilities",
21: "expense_other",
22: "expense_gifts",
}
UNIT_SHEETS = {"Горский": ("gorsky", "Горский"), "Красный": ("krasny", "Красный")}
POINT_CODES = {"Г": "gorsky", "К": "krasny", "Ф": "krasny"}
PROFESSIONS = {
"Повар": "Повар",
"Доп Повар": "Дополнительный повар",
"Стажер": "Стажёр",
"Админ": "Администратор",
}
def money_minor(value: object) -> int | None:
if not isinstance(value, (int, float, Decimal)):
return None
return int((Decimal(str(value)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def normalized_name(value: object) -> str:
return " ".join(str(value or "").split())
def stable_ref(prefix: str, value: str) -> str:
return prefix + hashlib.sha256(value.encode("utf-8")).hexdigest()[:24]
def extract(path: Path) -> tuple[dict[str, list[dict]], dict[str, object]]:
workbook = openpyxl.load_workbook(path, data_only=True, read_only=False)
units = [{"code": code, "name": name, "external_ref": f"xlsx-unit:{code}"}
for code, name in UNIT_SHEETS.values()]
finance: list[dict] = []
monthly = defaultdict(lambda: {"income_minor": 0, "expense_minor": 0})
for sheet_name, (unit_code, _) in UNIT_SHEETS.items():
sheet = workbook[sheet_name]
for row in range(2, sheet.max_row + 1):
business_date = sheet.cell(row, 1).value
if not isinstance(business_date, datetime) or business_date.year != 2026:
continue
date_text = business_date.date().isoformat()
for column, metric_key in FINANCE_COLUMNS.items():
value_minor = money_minor(sheet.cell(row, column).value)
if value_minor is None or value_minor == 0:
continue
finance.append({
"org_unit_code": unit_code,
"business_date": date_text,
"metric_key": metric_key,
"value_minor": value_minor,
"currency_code": "RUB",
})
bucket = monthly[date_text[:7]]
bucket["income_minor" if metric_key.startswith("income_") else "expense_minor"] += value_minor
graph = workbook["График"]
date_columns = {
column: graph.cell(1, column).value.date().isoformat()
for column in range(4, graph.max_column + 1)
if isinstance(graph.cell(1, column).value, datetime)
and graph.cell(1, column).value.year == 2026
}
employee_blocks: list[tuple[int, str, str]] = []
professions_by_name: dict[str, set[str]] = defaultdict(set)
for row in range(2, graph.max_row):
if graph.cell(row, 3).value != "Часов":
continue
name = normalized_name(graph.cell(row, 2).value)
profession = PROFESSIONS.get(normalized_name(graph.cell(row - 1, 1).value))
if not name or not profession:
continue
employee_blocks.append((row, name, profession))
professions_by_name[name].add(profession)
names = sorted(professions_by_name)
employees = []
refs: dict[str, str] = {}
for name in names:
identity_hash = hashlib.sha256(name.casefold().encode("utf-8")).hexdigest()[:16]
reference = f"xlsx:staff:{identity_hash}"
refs[name] = reference
professions = professions_by_name[name]
priority = ("Администратор", "Повар", "Дополнительный повар", "Стажёр")
primary = next(role for role in priority if role in professions)
employees.append({
"external_ref": reference,
"personnel_number": f"XLSX-{identity_hash.upper()}",
"display_name": name,
"position": primary,
})
schedule_totals: dict[tuple[str, str, str], int] = defaultdict(int)
schedule_locations: dict[tuple[str, str, str], set[str]] = defaultdict(set)
payroll_totals: dict[tuple[str, str, str], int] = defaultdict(int)
payout_totals: dict[tuple[str, str, str], int] = defaultdict(int)
inferred_points = 0
for row, name, _profession in employee_blocks:
for column, date_text in date_columns.items():
hours = graph.cell(row, column).value
if not isinstance(hours, (int, float)) or hours <= 0:
continue
point_code = normalized_name(graph.cell(row - 1, column).value)
unit_code = POINT_CODES.get(point_code)
if unit_code is None:
inferred_points += 1
lowered = name.casefold()
unit_code = "krasny" if ("крас" in lowered or "кр." in lowered) else "gorsky"
identity = (refs[name], date_text, unit_code)
schedule_totals[identity] += int(
Decimal(str(hours * 60)).quantize(Decimal("1")))
schedule_locations[identity].add(point_code or "inferred")
salary_minor = money_minor(graph.cell(row + 1, column).value)
if salary_minor:
payroll_totals[identity] += salary_minor
payout_minor = money_minor(graph.cell(row + 2, column).value)
if payout_minor:
payout_totals[identity] += abs(payout_minor)
schedule = [{
"external_key": stable_ref("xlsx:shift:", "|".join(identity)),
"employee_external_ref": identity[0],
"org_unit_code": identity[2],
"business_date": identity[1],
"planned_minutes": minutes,
"location_code": ",".join(sorted(schedule_locations[identity])),
} for identity, minutes in sorted(schedule_totals.items())]
payroll = [{
"external_key": stable_ref("xlsx:payroll:accrual:", "|".join(identity)),
"employee_external_ref": identity[0],
"business_date": identity[1],
"kind": "accrual",
"amount_minor": amount,
"note": "Импорт начисления из листа «График»",
} for identity, amount in sorted(payroll_totals.items())]
payroll.extend({
"external_key": stable_ref("xlsx:payroll:payout:", "|".join(identity)),
"employee_external_ref": identity[0],
"business_date": identity[1],
"kind": "payout",
"amount_minor": amount,
"note": "Импорт выплаты из строки «Долг» листа «График»",
} for identity, amount in sorted(payout_totals.items()))
summary = workbook["Сводная"]
for unit_code, row in (("gorsky", 31), ("krasny", 43)):
for column in range(3, 15):
period_end = summary.cell(2, column).value
amount = money_minor(summary.cell(row, column).value)
if not isinstance(period_end, datetime) or period_end.year != 2026 or not amount:
continue
business_date = period_end.date().isoformat()
finance.append({
"org_unit_code": unit_code,
"business_date": business_date,
"metric_key": "expense_employee_payroll",
"value_minor": amount,
"currency_code": "RUB",
})
monthly[business_date[:7]]["expense_minor"] += amount
report = {
"year": 2026,
"units": len(units),
"employees": len(employees),
"financial_entries": len(finance),
"schedule_items": len(schedule),
"payroll_entries": len(payroll),
"inferred_point_codes": inferred_points,
"monthly": dict(sorted(monthly.items())),
"totals": {
"income_minor": sum(item["income_minor"] for item in monthly.values()),
"expense_minor": sum(item["expense_minor"] for item in monthly.values()),
},
}
return {
"units": units,
"employees": employees,
"financial_entries": finance,
"schedule_items": schedule,
"payroll_entries": payroll,
}, report
def chunks(values: list[dict], size: int = 500):
for offset in range(0, len(values), size):
yield values[offset:offset + size]
def post(url: str, token: str, payload: dict, allow_self_signed: bool) -> dict:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
url.rstrip("/") + "/api/v1/staff/imports/owner-workbook",
data=body,
method="POST",
headers={"authorization": f"Bearer {token}", "content-type": "application/json"},
)
context = ssl._create_unverified_context() if allow_self_signed else ssl.create_default_context()
with urllib.request.urlopen(request, context=context, timeout=90) as response:
return json.loads(response.read().decode("utf-8"))
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("workbook", type=Path)
parser.add_argument("--server", default=os.getenv("KRONA_STAFF_SERVER", ""))
parser.add_argument("--token", default=os.getenv("KRONA_STAFF_TOKEN", ""))
parser.add_argument("--allow-self-signed", action="store_true")
parser.add_argument("--apply", action="store_true", help="Send data; otherwise only validate and report")
args = parser.parse_args()
payload, report = extract(args.workbook)
print(json.dumps(report, ensure_ascii=False, indent=2))
if not args.apply:
return 0
if not args.server.lower().startswith("https://"):
raise SystemExit("--server must be an https:// URL")
if len(args.token) < 32:
raise SystemExit("Provide --token or KRONA_STAFF_TOKEN")
source = f"{args.workbook.name}:2026"
batches = [
{"units": payload["units"], "employees": payload["employees"]},
*({"financial_entries": part} for part in chunks(payload["financial_entries"])),
*({"schedule_items": part} for part in chunks(payload["schedule_items"])),
*({"payroll_entries": part} for part in chunks(payload["payroll_entries"])),
]
for number, batch in enumerate(batches, start=1):
result = post(args.server, args.token, {"source_name": source, **batch}, args.allow_self_signed)
print(f"Imported batch {number}/{len(batches)}: {result}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
export const portalHtml = `<!doctype html>
<html lang="ru">
<html lang="ru" data-omni-theme="noble-green" data-omni-density="workspace">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -7,7 +7,7 @@ export const portalHtml = `<!doctype html>
<title>Krona Staff</title>
<link rel="manifest" href="/staff/manifest.webmanifest">
<link rel="stylesheet" href="/staff/styles.css">
<style>[hidden]{display:none!important}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:radial-gradient(circle at 25% 10%,rgba(183,131,53,.18),transparent 24rem),linear-gradient(135deg,#f7f0e3,#e8dcc5)}.auth-card{width:min(100%,460px);padding:42px;background:rgba(252,248,239,.96);border:1px solid #d9c9ac;border-radius:20px;box-shadow:0 24px 70px rgba(48,42,35,.16)}.auth-brand{display:flex;align-items:center;gap:12px;margin-bottom:42px;font-size:20px}.auth-card h1{margin:6px 0 12px;font:500 38px/1.08 Georgia,serif}.auth-copy{margin:0 0 26px;color:#756a5b;line-height:1.55}.auth-card form{display:grid;gap:10px}.auth-card label{font-size:13px;font-weight:700}.auth-card input{width:100%;min-height:50px;padding:0 14px;border:1px solid #cbbb9e;border-radius:10px;background:#fffdf8;color:#302a23}.auth-card button{margin-top:6px;min-height:50px}.auth-card small{display:block;margin-top:20px;color:#756a5b;line-height:1.45}.login-error{margin-top:16px;padding:12px 14px;border:1px solid #dfb2aa;border-radius:10px;background:#fff1ee;color:#78382d}@media(max-width:520px){.auth-screen{padding:16px}.auth-card{padding:28px 22px}.auth-card h1{font-size:32px}.auth-brand{margin-bottom:32px}}</style>
<style>[hidden]{display:none!important}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:var(--omni-bg-workspace)}.auth-card{width:min(100%,460px);padding:42px;background:var(--omni-surface-raised);border:1px solid var(--omni-border);border-radius:var(--omni-radius-xl);box-shadow:var(--omni-shadow-strong)}.auth-brand{display:flex;align-items:center;gap:12px;margin-bottom:42px;font-size:20px}.auth-card h1{margin:6px 0 12px;font:500 38px/1.08 var(--omni-font-serif)}.auth-copy{margin:0 0 26px;color:var(--omni-text-secondary);line-height:1.55}.auth-card form{display:grid;gap:10px}.auth-card label{font-size:13px;font-weight:700}.auth-card input{width:100%;min-height:50px;padding:0 14px;border:1px solid var(--omni-border-strong);border-radius:var(--omni-radius-md);background:var(--omni-surface-raised);color:var(--omni-text-primary)}.auth-card button{margin-top:6px;min-height:50px}.auth-card small{display:block;margin-top:20px;color:var(--omni-text-secondary);line-height:1.45}.login-error{margin-top:16px;padding:12px 14px;border:1px solid var(--omni-danger);border-radius:var(--omni-radius-md);background:var(--omni-danger-soft);color:var(--omni-danger)}@media(max-width:520px){.auth-screen{padding:16px}.auth-card{padding:28px 22px}.auth-card h1{font-size:32px}.auth-brand{margin-bottom:32px}}</style>
</head>
<body>
<section id="auth-screen" class="auth-screen" aria-labelledby="login-title">
@ -28,7 +28,7 @@ export const portalHtml = `<!doctype html>
<div id="app-shell" hidden>
<header class="topbar">
<a class="brand" href="/staff" aria-label="Krona Staff"><span class="mark">К</span><span>Krona <b>Staff</b></span></a>
<div class="actions"><span id="connection" class="status">Подключение…</span><button id="notify" class="quiet" type="button">Уведомления</button><button id="logout" class="quiet" type="button">Выйти</button><button id="refresh" type="button">Обновить</button></div>
<div class="actions"><span id="connection" class="status">Подключение…</span><label class="theme-control">Тема<select id="theme-select" aria-label="Тема интерфейса"><option value="noble-green">Зелёная</option><option value="parchment-light">Пергамент</option><option value="graphite-forest-dark">Тёмная</option></select></label><button id="notify" class="quiet" type="button">Уведомления</button><button id="logout" class="quiet" type="button">Выйти</button><button id="refresh" type="button">Обновить</button></div>
</header>
<nav class="section-nav" aria-label="Разделы кабинета">
<button class="nav-item selected" type="button" data-target="overview-view">Обзор</button>
@ -51,7 +51,7 @@ export const portalHtml = `<!doctype html>
</section>
<section class="layout">
<article class="panel attendance-panel"><div class="panel-head"><div><p class="eyebrow">Последние события</p><h2>Отметки терминала</h2></div><span class="live">● в реальном времени</span></div><div id="attendance" class="feed"></div></article>
<aside class="panel people-panel"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><button id="add-employee" class="quiet compact" type="button">Добавить</button></div><div id="employees" class="people"></div></aside>
<aside class="panel people-panel"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><div class="actions"><button id="manage-professions" class="quiet compact" type="button">Профессии</button><button id="add-employee" class="quiet compact" type="button">Добавить</button></div></div><div id="employees" class="people"></div></aside>
</section>
<section class="panel sessions-panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Сверка терминала</p><h2>Рабочие смены</h2></div><button id="reconcile-sessions" class="quiet" type="button">Сверить 7 дней</button></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Подразделение</th><th>Интервал</th><th>Часы</th><th>Состояние</th><th>Причина правки</th><th></th></tr></thead><tbody id="work-sessions"></tbody></table></div></section>
<section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Учёт времени</p><h2>Табель за 40 дней</h2></div><span id="timesheet-help">Изменения сохраняет руководитель</span></div><form id="timesheet-create" class="timesheet-create"><label>Сотрудник<select id="new-time-employee" required></select></label><label>Дата<input id="new-time-date" type="date" required></label><label>Часы<input id="new-time-hours" type="number" min="0" max="24" step="0.25" value="8" required></label><label>Статус<select id="new-time-status"><option value="draft">Черновик</option><option value="submitted">На проверке</option><option value="approved">Утверждён</option><option value="rejected">Возвращён</option></select></label><label class="grow">Примечание<input id="new-time-note" maxlength="1000" placeholder="Необязательно"></label><button type="submit">Добавить строку</button></form><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Часы</th><th>Статус</th><th>Примечание</th><th></th></tr></thead><tbody id="timesheet"></tbody></table></div></section>
@ -68,13 +68,18 @@ export const portalHtml = `<!doctype html>
<section class="panel calendar-panel"><div class="calendar-weekdays" aria-hidden="true"><span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span></div><div id="calendar-grid" class="calendar-grid"></div></section>
</div>
<div id="payroll-view" class="app-view" hidden>
<section class="view-head"><div><p class="eyebrow">Расчётный лист</p><h1>Зарплата</h1><p>Начисления, выплаты, остаток и фактически подтверждённые часы.</p></div><button id="payroll-refresh" class="quiet" type="button">Обновить</button></section>
<section class="view-head"><div><p class="eyebrow">Расчётный лист</p><h1>Зарплата</h1><p>Начисления, выплаты, остаток и фактически подтверждённые часы.</p></div><div class="actions"><button id="payroll-calc-btn" class="manager-only" type="button">Рассчитать зарплату</button><button id="payroll-refresh" class="quiet" type="button">Обновить</button></div></section>
<div id="payroll-calc-status" class="message" style="background:#eaf2e8;border-color:#b4d3af;color:#285223" hidden></div>
<div id="payroll-warnings" class="payroll-warnings message" hidden></div>
<section id="payroll-preview-card" class="panel manager-only" hidden><div class="panel-head"><div><p class="eyebrow">Предварительный результат</p><h2 id="payroll-preview-title">Расчёт зарплаты</h2></div><button id="payroll-approve-btn" type="button">Утвердить расчёт</button></div><div class="table-wrap"><table><thead><tr><th>Сотрудник</th><th>Должность</th><th>Дней</th><th>Факт, часов</th><th>Начислено (расчёт)</th></tr></thead><tbody id="payroll-preview-rows"></tbody></table></div></section>
<section class="payroll-stats stats" aria-label="Сводка зарплаты"><article><span>Начислено</span><strong id="payroll-accrued">—</strong></article><article><span>Выплачено</span><strong id="payroll-paid">—</strong></article><article><span>Остаток</span><strong id="payroll-balance">—</strong></article></section>
<section class="panel"><div class="panel-head"><div><p class="eyebrow">Сотрудники</p><h2>Расчёт за месяц</h2></div><span>По подтверждённым сменам и начислениям</span></div><div class="table-wrap"><table><thead><tr><th>Сотрудник</th><th>Должность</th><th>Факт, часов</th><th>Начислено</th><th>Выплачено</th><th>Остаток</th></tr></thead><tbody id="payroll-rows"></tbody></table></div></section>
<section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">История</p><h2>Начисления и выплаты</h2></div></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Операция</th><th>Сумма</th><th>Примечание</th></tr></thead><tbody id="payroll-entries"></tbody></table></div></section>
</div>
</main>
<dialog id="employee-dialog" class="employee-dialog"><form id="employee-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Карточка команды</p><h2 id="employee-dialog-title">Новый сотрудник</h2></div><button id="employee-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><input id="employee-id" type="hidden"><label>Табельный номер<input id="employee-number" maxlength="80" required></label><label>Фамилия, имя и отчество<input id="employee-name" maxlength="240" required></label><label>Должность<input id="employee-position" maxlength="160"></label><label>Пол<select id="employee-gender"><option value="">Не указан</option><option value="female">Женский</option><option value="male">Мужской</option></select></label><label id="employee-status-wrap" hidden>Статус<select id="employee-status"><option value="active">Работает</option><option value="blocked">Заблокирован</option><option value="dismissed">Уволен</option></select></label><div id="employee-error" class="login-error" role="alert" hidden></div><div class="dialog-actions"><button id="employee-cancel" class="quiet" type="button">Отмена</button><button id="employee-save" type="submit">Сохранить</button></div></form></dialog>
<input id="employee-position" type="hidden">
<dialog id="employee-dialog" class="employee-dialog"><form id="employee-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Карточка команды</p><h2 id="employee-dialog-title">Новый сотрудник</h2></div><button id="employee-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><input id="employee-id" type="hidden"><label>Табельный номер<input id="employee-number" maxlength="80" required></label><label>Фамилия, имя и отчество<input id="employee-name" maxlength="240" required></label><label>Профессия<select id="employee-profession"><option value="">Не указана</option></select></label><p class="role-note">Роль доступа: сотрудник. Роли и права платформы управляются в Krona Core отдельно от кадровой профессии.</p><label>Пол<select id="employee-gender"><option value="">Не указан</option><option value="female">Женский</option><option value="male">Мужской</option></select></label><label id="employee-status-wrap" hidden>Статус<select id="employee-status"><option value="active">Работает</option><option value="blocked">Заблокирован</option><option value="dismissed">Уволен</option></select></label><div id="employee-pay-section" class="pay-profile-section manager-only"><p class="eyebrow">Зарплатный профиль</p><label>Торговая точка<select id="employee-pay-unit"></select></label><label>Схема расчёта<select id="employee-pay-rule"></select></label><div class="form-row-2"><label>Ставка в час, ₽<input id="employee-pay-rate" type="number" min="0" step="1" value="100"></label><label>Процент от выручки, %<input id="employee-pay-percent" type="number" min="0" max="100" step="0.1" value="0"></label></div><div class="form-row-2"><label>Гарантия за смену, ₽<input id="employee-pay-guarantee" type="number" min="0" step="100" value="0"></label><label>Доля в фонде, %<input id="employee-pay-pool-weight" type="number" min="0" max="200" step="10" value="100"></label></div><label>Действует с даты<input id="employee-pay-effective" type="date"></label><small>Новая версия действует с выбранной даты; предыдущая автоматически закрывается днём ранее.</small></div><div id="employee-error" class="login-error" role="alert" hidden></div><div class="dialog-actions"><button id="employee-cancel" class="quiet" type="button">Отмена</button><button id="employee-save" type="submit">Сохранить</button></div></form></dialog>
<dialog id="profession-dialog" class="employee-dialog"><form id="profession-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Кадровый справочник</p><h2>Профессии</h2></div><button id="profession-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><label>Новая профессия<input id="profession-name" maxlength="160" required placeholder="Например, Повар"></label><button type="submit">Добавить профессию</button><div id="profession-list" class="people"></div><div id="profession-error" class="login-error" role="alert" hidden></div></form></dialog>
<dialog id="access-dialog" class="employee-dialog"><form method="dialog"><div class="dialog-head"><div><p class="eyebrow">Личный кабинет</p><h2>Ключ сотрудника</h2></div><button id="access-close" class="icon-button" type="submit" aria-label="Закрыть">×</button></div><p>Скопируйте ключ сейчас. В программе хранится только его защищённый отпечаток, повторно показать ключ нельзя.</p><label>Ключ доступа<input id="access-token" readonly></label><div class="dialog-actions"><button id="access-copy" type="button">Скопировать</button><button class="quiet" type="submit">Закрыть</button></div></form></dialog>
<footer>Krona Business Platform · Staff</footer>
</div>
@ -119,20 +124,55 @@ export const portalJs = `(() => {
if('serviceWorker' in navigator) navigator.serviceWorker.register('/staff/sw.js'); load(false); setInterval(()=>{if(!el('app-shell').hidden)load(true);},15000);
})();`;
export const portalCssEnhanced = portalCss + `
export const portalCssEnhanced =
portalCss +
`
[data-omni-theme="parchment-light"]{--omni-bg-app:#EFE3CC;--omni-bg-workspace:#F3EAD7;--omni-surface:#F8F2E6;--omni-surface-raised:#FCF8EF;--omni-surface-muted:#E9DDC5;--omni-surface-hover:#EDE0C7;--omni-text-primary:#302A23;--omni-text-secondary:#756A5B;--omni-text-inverse:#FFF;--omni-border:#D9C9AC;--omni-border-strong:#CBB99B;--omni-border-focus:#9A743D;--omni-primary:#9A743D;--omni-primary-hover:#7D5B2D;--omni-primary-soft:#EADBC1;--omni-gold:#B78335;--omni-gold-soft:#EFE0C2;--omni-danger:#A44E3F;--omni-danger-soft:#F1DDD5;--omni-info:#5F7480;--omni-shadow-medium:0 6px 18px rgb(72 55 34/.10);--omni-shadow-strong:0 14px 36px rgb(52 39 24/.16)}
[data-omni-theme="noble-green"]{--omni-bg-app:#EDE5D3;--omni-bg-workspace:#F2EAD9;--omni-surface:#F8F2E6;--omni-surface-raised:#FCF8EF;--omni-surface-muted:#E6DBC5;--omni-surface-hover:#E3E9DE;--omni-text-primary:#2E302A;--omni-text-secondary:#6B6C60;--omni-text-inverse:#FFF;--omni-border:#D2CCB9;--omni-border-strong:#9DAA98;--omni-border-focus:#315F46;--omni-primary:#315F46;--omni-primary-hover:#294F3B;--omni-primary-soft:#DDE7DB;--omni-gold:#B78335;--omni-gold-soft:#EFE0C2;--omni-danger:#A44E3F;--omni-danger-soft:#F1DDD5;--omni-info:#527286;--omni-shadow-medium:0 6px 18px rgb(72 55 34/.10);--omni-shadow-strong:0 14px 36px rgb(52 39 24/.16)}
[data-omni-theme="graphite-forest-dark"]{--omni-bg-app:#171A18;--omni-bg-workspace:#1D211E;--omni-surface:#252A26;--omni-surface-raised:#2B312C;--omni-surface-muted:#212621;--omni-surface-hover:#2B342D;--omni-text-primary:#EEE7DA;--omni-text-secondary:#B7B3A8;--omni-text-inverse:#171A18;--omni-border:#39423A;--omni-border-strong:#465249;--omni-border-focus:#6F9A79;--omni-primary:#527D60;--omni-primary-hover:#648E70;--omni-primary-soft:#29382E;--omni-gold:#C99A4D;--omni-gold-soft:#403522;--omni-danger:#C06B5F;--omni-danger-soft:#432B28;--omni-info:#7693A0;--omni-shadow-medium:0 8px 22px rgb(0 0 0/.24);--omni-shadow-strong:0 16px 40px rgb(0 0 0/.34)}
:root{--omni-radius-sm:6px;--omni-radius-md:8px;--omni-radius-lg:12px;--omni-radius-xl:16px;--omni-font-sans:Inter,"Segoe UI",system-ui,sans-serif;--omni-font-serif:Georgia,serif;--omni-warning:#7A5B16;--omni-warning-soft:#F7EAC9;--bg:var(--omni-bg-app);--workspace:var(--omni-bg-workspace);--surface:var(--omni-surface);--raised:var(--omni-surface-raised);--text:var(--omni-text-primary);--muted:var(--omni-text-secondary);--border:var(--omni-border);--primary:var(--omni-primary);--gold:var(--omni-gold);--danger:var(--omni-danger);--info:var(--omni-info);--shadow:var(--omni-shadow-medium)}
body,.auth-screen{background:var(--omni-bg-workspace);color:var(--omni-text-primary)}.topbar,.section-nav{background:color-mix(in srgb,var(--omni-surface) 94%,transparent)}.hero{background:linear-gradient(120deg,var(--omni-primary-active,var(--omni-primary)),var(--omni-primary))}.stats article,.panel,.employee-dialog{background:var(--omni-surface);border-color:var(--omni-border);box-shadow:var(--omni-shadow-medium)}button{background:var(--omni-primary);color:var(--omni-text-inverse)}button:hover{background:var(--omni-primary-hover);filter:none}.quiet{background:transparent;color:var(--omni-primary);border-color:var(--omni-border)}input,select,.timesheet-create input,.timesheet-create select,.employee-dialog input,.employee-dialog select,.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{background:var(--omni-surface-raised);color:var(--omni-text-primary);border-color:var(--omni-border)}button:focus-visible,input:focus-visible,select:focus-visible{outline-color:var(--omni-border-focus)}.theme-control{display:flex;align-items:center;gap:6px;color:var(--omni-text-secondary);font-size:12px}.theme-control select{min-height:36px;border:1px solid var(--omni-border);border-radius:var(--omni-radius-md);padding:0 8px}.role-note{margin:0;padding:10px 12px;border-radius:var(--omni-radius-md);background:var(--omni-primary-soft);color:var(--omni-text-secondary);font-size:12px;line-height:1.45}.payroll-warnings{background:var(--omni-warning-soft)!important;color:var(--omni-warning)!important}.message,.login-error{background:var(--omni-danger-soft);color:var(--omni-danger);border-color:var(--omni-danger)}
.section-nav{position:sticky;top:72px;z-index:4;display:flex;align-items:center;gap:8px;padding:10px max(20px,calc((100vw - 1240px)/2));border-bottom:1px solid var(--border);background:rgba(243,234,215,.96);backdrop-filter:blur(12px);overflow-x:auto}.nav-item{flex:0 0 auto;background:transparent;color:var(--muted);border:1px solid transparent}.nav-item.selected{background:var(--surface);color:var(--primary);border-color:var(--border)}.period-control{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;font-weight:700;white-space:nowrap}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{min-height:42px;border:1px solid var(--border);border-radius:8px;background:#fffdf8;color:var(--text);padding:7px 9px}.view-head{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:22px}.view-head h1{margin:4px 0 8px;font:500 clamp(30px,4vw,46px)/1.05 Georgia,serif}.view-head p{margin:0;color:var(--muted)}.finance-layout{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(260px,.7fr);gap:18px;margin-bottom:18px}.stack-form{display:grid;gap:13px}.stack-form label,.inline-form label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.inline-form{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr)) auto;align-items:end;gap:10px}.finance-chart{min-height:280px}.finance-chart svg{display:block;width:100%;height:280px}.chart-legend{display:flex;gap:16px;margin-top:10px;color:var(--muted);font-size:12px}.legend-dot{display:inline-block;width:9px;height:9px;margin-right:5px;border-radius:50%}.income-dot{background:var(--primary)}.expense-dot{background:var(--gold)}.calendar-panel{overflow:auto}.calendar-weekdays,.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(145px,1fr));min-width:1015px}.calendar-weekdays span{padding:8px 10px;color:var(--muted);font-size:12px;font-weight:750;text-align:center}.calendar-day{min-height:142px;padding:10px;border-top:1px solid var(--border);border-right:1px solid var(--border);background:var(--raised)}.calendar-day.outside{opacity:.45}.calendar-day.today{outline:2px solid var(--gold);outline-offset:-2px}.day-number{display:block;margin-bottom:8px;font-weight:750}.shift-card{margin:5px 0;padding:7px;border-left:3px solid var(--primary);border-radius:6px;background:#e8eadf;font-size:11px;line-height:1.35}.shift-card.actual{border-left-color:var(--gold);background:#f3e5cd}.shift-card strong,.shift-card span{display:block}.positive{color:var(--primary)}.negative{color:var(--danger)}.person-access{min-height:36px;padding:0 10px;margin-left:5px}.app-view[hidden]{display:none!important}@media(max-width:900px){.section-nav{top:68px;padding-inline:14px}.period-control{margin-left:8px}.finance-layout{grid-template-columns:1fr}.inline-form{grid-template-columns:repeat(2,minmax(0,1fr))}.inline-form button{grid-column:1/-1}.view-head{align-items:flex-start}.calendar-panel{padding:12px}}@media(max-width:520px){.view-head{display:block}.view-head>button{margin-top:14px}.stats{grid-template-columns:1fr}.stats article:last-child{grid-column:auto}.inline-form{grid-template-columns:1fr}.period-control{order:-1;margin-left:0}.section-nav{top:92px}.finance-chart{min-height:220px}.finance-chart svg{height:220px}}
.form-row-2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.pay-profile-section{display:grid;gap:10px;padding:14px;background:var(--raised);border:1px solid var(--border);border-radius:10px;margin-top:6px}
.payroll-warnings{background:#fff8e6!important;border:1px solid #e2cb91!important;color:#6b5314!important;margin-bottom:18px}
.payroll-warnings ul{margin:6px 0 0 18px;padding:0}
.payroll-warnings li{margin-bottom:4px}
.section-nav{background:color-mix(in srgb,var(--omni-surface) 94%,transparent)}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{background:var(--omni-surface-raised)}.shift-card{background:var(--omni-primary-soft)}.shift-card.actual{background:var(--omni-gold-soft)}.payroll-warnings{background:var(--omni-warning-soft)!important;border-color:color-mix(in srgb,var(--omni-warning) 35%,transparent)!important;color:var(--omni-warning)!important}[data-omni-theme="graphite-forest-dark"]{--omni-warning:#E0B96C;--omni-warning-soft:#403522}
`;
export const portalJsEnhanced = portalJs.replace('Неверный ключ администратора.', 'Неверный ключ доступа.') + `
export const portalJsEnhanced =
portalJs.replace('Неверный ключ администратора.', 'Неверный ключ доступа.') +
`
(() => {
const api='/api/v1',byId=id=>document.getElementById(id),esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),rub=value=>(Number(value||0)/100).toLocaleString('ru-RU',{style:'currency',currency:'RUB',maximumFractionDigits:2}),hours=value=>(Number(value||0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1}),jsonHeaders={'content-type':'application/json'};
let role='manager',units=[],initialized=false,active='overview-view';
const api='/api/v1/staff',byId=id=>document.getElementById(id),esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),rub=value=>(Number(value||0)/100).toLocaleString('ru-RU',{style:'currency',currency:'RUB',maximumFractionDigits:2}),hours=value=>(Number(value||0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1}),jsonHeaders={'content-type':'application/json'};
let role='manager',units=[],initialized=false,active='overview-view',payRulesCache=[],currentRunId=null;
const period=()=>{const month=byId('period-month').value||new Date().toISOString().slice(0,7),start=month+'-01',d=new Date(start+'T00:00:00Z');d.setUTCMonth(d.getUTCMonth()+1);d.setUTCDate(0);return{from:start,to:d.toISOString().slice(0,10)}},query=()=>{const p=period();return'?from='+p.from+'&to='+p.to};
async function get(path){const response=await fetch(api+path,{credentials:'same-origin'});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось загрузить данные.');return response.json()}
async function send(path,method,body){const response=await fetch(api+path,{method,credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось сохранить данные.');return response.json()}
const option=(value,label)=>'<option value="'+esc(value)+'">'+esc(label)+'</option>';
function fillSelects(){const unitHtml=units.map(x=>option(x.id,x.name)).join('');['finance-unit','schedule-unit'].forEach(id=>{if(byId(id))byId(id).innerHTML=unitHtml});const source=byId('new-time-employee');if(source&&byId('schedule-employee'))byId('schedule-employee').innerHTML=source.innerHTML}
async function initialize(){if(byId('app-shell').hidden||initialized)return;initialized=true;try{const state=await get('/portal');role=state.viewer&&state.viewer.role||'manager';document.querySelectorAll('.manager-only').forEach(node=>node.hidden=role!=='manager');byId('viewer').textContent=role==='employee'?'Личный кабинет сотрудника':'Кабинет руководителя';units=(await get('/org-units')).org_units||[];fillSelects();await loadActive()}catch{initialized=false}}
function fillSelects(){
const unitHtml=units.map(x=>option(x.id,x.name)).join('');
['finance-unit','schedule-unit'].forEach(id=>{if(byId(id))byId(id).innerHTML=unitHtml});
if(byId('employee-pay-unit')) byId('employee-pay-unit').innerHTML='<option value="">Все точки</option>'+unitHtml;
if(byId('employee-pay-rule')&&payRulesCache.length) byId('employee-pay-rule').innerHTML=payRulesCache.map(r=>option(r.id,r.name)).join('');
const source=byId('new-time-employee');if(source&&byId('schedule-employee'))byId('schedule-employee').innerHTML=source.innerHTML;
}
async function initialize(){
if(byId('app-shell').hidden||initialized)return;
initialized=true;
try{
const state=await get('/portal');
role=state.viewer&&state.viewer.role||'manager';
document.querySelectorAll('.manager-only').forEach(node=>node.hidden=role!=='manager');
byId('manage-professions').hidden=role!=='manager';
byId('viewer').textContent=role==='employee'?'Личный кабинет сотрудника':'Кабинет руководителя';
units=(await get('/org-units')).org_units||[];
try{payRulesCache=(await get('/pay-rules')).pay_rules||[]}catch{}
fillSelects();
await loadActive();
}catch{initialized=false}
}
function show(target){active=target;document.querySelectorAll('.app-view').forEach(view=>view.hidden=view.id!==target);document.querySelectorAll('.nav-item').forEach(button=>button.classList.toggle('selected',button.dataset.target===target));loadActive()}
async function loadActive(){try{if(active==='finance-view')await loadFinance();if(active==='schedule-view')await loadSchedule();if(active==='payroll-view')await loadPayroll()}catch(error){const box=byId('message');if(box){box.textContent=error.message;box.hidden=false}}}
async function loadFinance(){if(role!=='manager')return;const data=await get('/finance'+query()),income=data.daily.reduce((sum,x)=>sum+Number(x.income_minor),0),expense=data.daily.reduce((sum,x)=>sum+Number(x.expense_minor),0);byId('finance-income').textContent=rub(income);byId('finance-expense').textContent=rub(expense);byId('finance-profit').textContent=rub(income-expense);byId('finance-count').textContent=data.entries.length+' записей';byId('finance-category').innerHTML=data.categories.map(x=>option(x.key,(x.kind==='income'?'Доход · ':'Расход · ')+x.label)).join('');byId('finance-entries').innerHTML=data.entries.length?data.entries.map(x=>'<tr><td>'+esc(x.business_date)+'</td><td>'+esc(x.org_unit_name)+'</td><td>'+esc(x.label)+'</td><td><span class="tag">'+(x.metric_key.startsWith('income_')?'Доход':'Расход')+'</span></td><td>'+rub(x.value_minor)+'</td></tr>').join(''):'<tr><td colspan="5" class="empty">За месяц операций нет</td></tr>';drawChart(data.daily)}
@ -143,10 +183,162 @@ export const portalJsEnhanced = portalJs.replace('Неверный ключ ад
byId('finance-form').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{await send('/finance/'+byId('finance-unit').value+'/'+byId('finance-date').value+'/'+byId('finance-category').value,'PUT',{value_minor:Math.round(Number(byId('finance-amount').value)*100),currency_code:'RUB'});byId('finance-amount').value='';await loadFinance()}catch(error){alert(error.message)}finally{button.disabled=false}});
byId('schedule-form').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{await send('/schedule/'+byId('schedule-employee').value+'/'+byId('schedule-date').value,'PUT',{org_unit_id:byId('schedule-unit').value,planned_minutes:Math.round(Number(byId('schedule-hours').value)*60),start_time:byId('schedule-start').value,end_time:byId('schedule-end').value});await loadSchedule()}catch(error){alert(error.message)}finally{button.disabled=false}});
document.addEventListener('click',async event=>{const button=event.target.closest('.person-access');if(!button)return;button.disabled=true;try{const result=await send('/employees/'+button.dataset.id+'/access-token','POST',{});byId('access-token').value=result.access_token;byId('access-dialog').showModal()}catch(error){alert(error.message)}finally{button.disabled=false}});byId('access-copy').addEventListener('click',async()=>{await navigator.clipboard.writeText(byId('access-token').value);byId('access-copy').textContent='Скопировано'});
document.addEventListener('click',async event=>{
const editBtn=event.target.closest('.edit-employee');
if(!editBtn||role!=='manager')return;
const empId=editBtn.dataset.id;
if(byId('employee-pay-section')) byId('employee-pay-section').hidden=false;
try{
const a=await get('/employees/'+empId+'/pay-assignment');
byId('employee-pay-unit').value=a.org_unit_id||'';
byId('employee-pay-rule').value=a.pay_rule_id;
byId('employee-pay-rate').value=Math.round(Number(a.rate_minor||0)/100);
byId('employee-pay-percent').value=(Number(a.percent_bp||0)/100).toFixed(1);
byId('employee-pay-guarantee').value=Math.round(Number(a.guarantee_minor||0)/100);
byId('employee-pay-pool-weight').value=Math.round(Number(a.pool_weight_bp||0)/100);
byId('employee-pay-effective').value=a.effective_from||'2026-01-01';
}catch{
if(payRulesCache.length>0) byId('employee-pay-rule').value=payRulesCache[0].id;
byId('employee-pay-unit').value='';
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=0;
byId('employee-pay-guarantee').value=0;
byId('employee-pay-pool-weight').value=100;
byId('employee-pay-effective').value='2026-01-01';
}
});
if(byId('add-employee')){
byId('add-employee').addEventListener('click',()=>{
if(byId('employee-pay-section')) byId('employee-pay-section').hidden=false;
if(payRulesCache.length>0) byId('employee-pay-rule').value=payRulesCache[0].id;
byId('employee-pay-unit').value='';
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=0;
byId('employee-pay-guarantee').value=0;
byId('employee-pay-pool-weight').value=100;
byId('employee-pay-effective').value='2026-01-01';
});
}
if(byId('employee-pay-rule')){
byId('employee-pay-rule').addEventListener('change',()=>{
const rule=payRulesCache.find(r=>r.id===byId('employee-pay-rule').value);
if(rule){
if(rule.code==='pool_member'){
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=0;
byId('employee-pay-guarantee').value=0;
byId('employee-pay-pool-weight').value=100;
}else if(rule.code==='revenue_percent'){
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=2.5;
byId('employee-pay-guarantee').value=3000;
byId('employee-pay-pool-weight').value=0;
}else if(rule.code==='hourly'){
byId('employee-pay-rate').value=100;
byId('employee-pay-percent').value=0;
byId('employee-pay-guarantee').value=0;
byId('employee-pay-pool-weight').value=0;
}
}
});
}
if(byId('employee-form')){
byId('employee-form').addEventListener('submit',async()=>{
const empId=byId('employee-id').value;
if(role==='manager'&&empId&&byId('employee-pay-rule')&&byId('employee-pay-rule').value){
try{
await send('/employees/'+empId+'/pay-assignment','PUT',{
org_unit_id:byId('employee-pay-unit').value||null,
pay_rule_id:byId('employee-pay-rule').value,
rate_minor:Math.round(Number(byId('employee-pay-rate').value||0)*100),
percent_bp:Math.round(Number(byId('employee-pay-percent').value||0)*100),
guarantee_minor:Math.round(Number(byId('employee-pay-guarantee').value||0)*100),
pool_weight_bp:Math.round(Number(byId('employee-pay-pool-weight').value||0)*100),
effective_from:byId('employee-pay-effective').value||'2026-01-01'
});
}catch(e){console.error('Pay assignment save error:',e)}
}
});
}
if(byId('payroll-calc-btn')){
byId('payroll-calc-btn').addEventListener('click',async()=>{
const btn=byId('payroll-calc-btn');
btn.disabled=true;
byId('payroll-warnings').hidden=true;
byId('payroll-calc-status').hidden=true;
try{
const month=byId('period-month').value||new Date().toISOString().slice(0,7);
const res=await send('/payroll/calculate','POST',{month});
currentRunId=res.run_id;
if(res.warnings&&res.warnings.length){
byId('payroll-warnings').innerHTML='<strong>Предупреждения при расчёте:</strong><ul>'+res.warnings.map(w=>'<li>'+esc(w.message)+'</li>').join('')+'</ul>';
byId('payroll-warnings').hidden=false;
}
byId('payroll-preview-title').textContent='Предварительный результат: '+rub(res.total_accrued_minor);
byId('payroll-preview-rows').innerHTML=res.totals.length?res.totals.map(x=>'<tr><td>'+esc(x.display_name)+'</td><td>'+esc(x.position||'—')+'</td><td>'+x.days+'</td><td>'+hours(x.worked_minutes)+'</td><td>'+rub(x.accrued_minor)+'</td></tr>').join(''):'<tr><td colspan="5" class="empty">Нет начислений</td></tr>';
byId('payroll-preview-card').hidden=false;
}catch(err){alert(err.message||'Ошибка расчёта зарплаты')}finally{btn.disabled=false}
});
}
const themeSelect=byId('theme-select');
const allowedThemes=['noble-green','parchment-light','graphite-forest-dark'];
const savedTheme=localStorage.getItem('krona-staff-theme');
if(savedTheme&&allowedThemes.includes(savedTheme))document.documentElement.dataset.omniTheme=savedTheme;
themeSelect.value=document.documentElement.dataset.omniTheme||'noble-green';
themeSelect.addEventListener('change',()=>{document.documentElement.dataset.omniTheme=themeSelect.value;localStorage.setItem('krona-staff-theme',themeSelect.value)});
let professionsCache=[];
async function loadProfessions(){
const result=await get('/professions');
professionsCache=result.professions||[];
const select=byId('employee-profession');
const current=select.value;
select.innerHTML='<option value="">Не указана</option>'+professionsCache.filter(x=>x.status==='active').map(x=>option(x.id,x.name)).join('');
if([...select.options].some(x=>x.value===current))select.value=current;
byId('profession-list').innerHTML=professionsCache.length?professionsCache.map(x=>'<div class="person"><span class="avatar">'+esc(x.name).slice(0,1).toUpperCase()+'</span><div><strong>'+esc(x.name)+'</strong><small>'+(x.status==='active'?'Активна':'В архиве')+'</small></div></div>').join(''):'<div class="empty">Профессии ещё не добавлены</div>';
}
byId('employee-profession').addEventListener('change',()=>{const selected=byId('employee-profession').selectedOptions[0];byId('employee-position').value=selected&&selected.value?selected.textContent:''});
document.addEventListener('click',event=>{const target=event.target.closest?.('.edit-employee,#add-employee');if(!target)return;setTimeout(async()=>{await loadProfessions();const position=byId('employee-position').value;const match=professionsCache.find(x=>x.name===position);byId('employee-profession').value=match?.id||''},0)},true);
byId('manage-professions').addEventListener('click',async()=>{byId('profession-error').hidden=true;await loadProfessions();byId('profession-dialog').showModal()});
byId('profession-close').addEventListener('click',()=>byId('profession-dialog').close());
byId('profession-form').addEventListener('submit',async event=>{event.preventDefault();const name=byId('profession-name').value.trim();if(!name)return;try{const created=await send('/professions','POST',{name});byId('profession-name').value='';await loadProfessions();byId('employee-profession').value=created.id;byId('employee-position').value=created.name}catch(error){byId('profession-error').textContent=error.message||'Не удалось сохранить профессию.';byId('profession-error').hidden=false}});
if(byId('payroll-approve-btn')){
byId('payroll-approve-btn').addEventListener('click',async()=>{
if(!currentRunId)return;
const btn=byId('payroll-approve-btn');
btn.disabled=true;
try{
const res=await send('/payroll/runs/'+currentRunId+'/approve','POST',{});
byId('payroll-preview-card').hidden=true;
byId('payroll-calc-status').textContent='Расчёт успешно утверждён! Начислено: '+rub(res.total_accrued_minor)+' ('+res.entries_posted+' начислений проведено в реестр).';
byId('payroll-calc-status').hidden=false;
await loadPayroll();
}catch(err){alert(err.message||'Ошибка утверждения расчёта')}finally{btn.disabled=false}
});
}
const observer=new MutationObserver(()=>{if(role!=='manager')return;document.querySelectorAll('.edit-employee').forEach(edit=>{if(edit.parentElement.querySelector('.person-access'))return;const button=document.createElement('button');button.type='button';button.className='quiet compact person-access';button.dataset.id=edit.dataset.id;button.textContent='Личный вход';edit.insertAdjacentElement('afterend',button)});fillSelects()});observer.observe(byId('employees'),{childList:true,subtree:true});const shellObserver=new MutationObserver(()=>{if(!byId('app-shell').hidden){initialized=false;initialize()}else initialized=false});shellObserver.observe(byId('app-shell'),{attributes:true,attributeFilter:['hidden']});setTimeout(initialize,0);
})();
`;
export const portalManifest = JSON.stringify({ name: 'Krona Staff', short_name: 'Staff', start_url: '/staff', display: 'standalone', background_color: '#F3EAD7', theme_color: '#657255', lang: 'ru' });
export const portalManifest = JSON.stringify({
name: 'Krona Staff',
short_name: 'Staff',
start_url: '/staff',
display: 'standalone',
background_color: '#F3EAD7',
theme_color: '#657255',
lang: 'ru',
});
export const portalServiceWorker = `const CACHE='krona-staff-v3';const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest'];self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS))));self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k))))));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).pathname.startsWith('/staff'))e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));});`;
export const portalServiceWorkerEnhanced = portalServiceWorker.replace('krona-staff-v3', 'krona-staff-v4');
export const portalServiceWorkerEnhanced = portalServiceWorker.replace(
'krona-staff-v3',
'krona-staff-v4',
);

File diff suppressed because it is too large Load diff

View file

@ -1,2 +1,2 @@
export const SERVICE_NAME = 'krona-staff';
export const SERVICE_VERSION = '0.3.0';
export const SERVICE_VERSION = '0.3.1';

View file

@ -50,7 +50,10 @@ describe('OpenAPI runtime contract', () => {
.sort();
expect(runtimePaths).toEqual(documentedPaths);
for (const path of documentedPaths) {
const runtimePath = path.replace('{employeeId}', '01J00000000000000000000000').replace('{workDate}', '2026-09-24');
const runtimePath = path
.replace('{employeeId}', '01J00000000000000000000000')
.replace('{workDate}', '2026-09-24')
.replace('{runId}', '01J00000000000000000000000');
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);

View file

@ -100,52 +100,103 @@ describe('attendance portal', () => {
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 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(4);
expect(calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID)).toBe(true);
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 { database, calls } = fakeDatabase([[{ id: employeeId }], [{ 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 },
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[0]?.[0]).toContain("status = 'active'");
expect(calls[2]?.[0]).toContain('staff_outbox');
expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in');
await app.close();
});
it('rejects an unknown terminal employee without writing attendance or outbox', async () => {
const eventId = '01J00000000000000000000002';
const employeeId = '01J00000000000000000000003';
const { database, calls } = fakeDatabase([[]]);
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(404);
expect(calls).toHaveLength(1);
expect(calls[0]?.[0]).toContain("status = 'active'");
expect(calls[0]?.[0]).not.toContain('INSERT INTO staff_employees');
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' } });
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();
});
it('allows a manager to create an employee and writes an audit record', async () => {
const employeeId = '01J00000000000000000000004';
const { database, calls } = fakeDatabase([[{
id: employeeId,
personnel_number: 'A-17',
display_name: 'Анна Орлова',
position: 'Администратор',
gender: 'female',
status: 'active',
}]]);
const { database, calls } = fakeDatabase([
[
{
id: employeeId,
personnel_number: 'A-17',
display_name: 'Анна Орлова',
position: 'Администратор',
gender: 'female',
status: 'active',
},
],
]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
@ -185,9 +236,17 @@ describe('attendance portal', () => {
it('updates an employee with before and after values in the audit trail', async () => {
const employeeId = '01J00000000000000000000004';
const before = { id: employeeId, core_user_id: null, personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', status: 'active' };
const before = {
id: employeeId,
core_user_id: null,
personnel_number: 'A-17',
display_name: 'Анна Орлова',
position: 'Администратор',
gender: 'female',
status: 'active',
};
const after = { ...before, position: 'Старший администратор' };
const { database, calls } = fakeDatabase([[before], [after], []]);
const { database, calls } = fakeDatabase([[before], [], [after], []]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
@ -199,10 +258,10 @@ describe('attendance portal', () => {
payload: { position: 'Старший администратор' },
});
expect(response.statusCode).toBe(200);
expect(calls).toHaveLength(3);
expect(calls).toHaveLength(4);
expect(calls[0]?.[0]).toContain('FOR UPDATE');
expect(calls[2]?.[1]?.[4]).toEqual(before);
expect(calls[2]?.[1]?.[5]).toEqual(after);
expect(calls[3]?.[1]?.[4]).toEqual(before);
expect(calls[3]?.[1]?.[5]).toEqual(after);
await app.close();
});
@ -211,10 +270,37 @@ describe('attendance portal', () => {
const orgUnitId = '01J00000000000000000000005';
const sessionId = '01J00000000000000000000006';
const attendanceRows = [
{ id: '01J00000000000000000000010', employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' },
{ id: '01J00000000000000000000011', employee_id: employeeId, event_type: 'check_out', occurred_at: '2026-09-24T09:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' },
{
id: '01J00000000000000000000010',
employee_id: employeeId,
event_type: 'check_in',
occurred_at: '2026-09-24T01:00:00.000Z',
org_unit_id: orgUnitId,
business_date: '2026-09-24',
automatic_close_at: '2026-09-24T19:00:00.000Z',
},
{
id: '01J00000000000000000000011',
employee_id: employeeId,
event_type: 'check_out',
occurred_at: '2026-09-24T09:00:00.000Z',
org_unit_id: orgUnitId,
business_date: '2026-09-24',
automatic_close_at: '2026-09-24T19:00:00.000Z',
},
];
const session = { id: sessionId, employee_id: employeeId, org_unit_id: orgUnitId, business_date: '2026-09-24', worked_minutes: 480, status: 'pending_review', anomaly: null, source_event_ids: attendanceRows.map((row) => row.id), auto_closed: false, revision: 1 };
const session = {
id: sessionId,
employee_id: employeeId,
org_unit_id: orgUnitId,
business_date: '2026-09-24',
worked_minutes: 480,
status: 'pending_review',
anomaly: null,
source_event_ids: attendanceRows.map((row) => row.id),
auto_closed: false,
revision: 1,
};
const { database, calls } = fakeDatabase([attendanceRows, [session], []]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
@ -227,7 +313,11 @@ describe('attendance portal', () => {
payload: { date_from: '2026-09-24', date_to: '2026-09-24' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ sessions_created_or_updated: 1, anomalous_sessions: 0, unresolved_events: 0 });
expect(response.json()).toMatchObject({
sessions_created_or_updated: 1,
anomalous_sessions: 0,
unresolved_events: 0,
});
expect(calls[1]?.[0]).toContain('INSERT INTO staff_work_sessions');
expect(calls[1]?.[1]?.[7]).toBe(480);
await app.close();
@ -235,7 +325,21 @@ describe('attendance portal', () => {
it('requires a reason when confirmed hours are corrected', async () => {
const sessionId = '01J00000000000000000000006';
const current = { id: sessionId, employee_id: '01J00000000000000000000004', org_unit_id: '01J00000000000000000000005', business_date: '2026-09-24', started_at: '2026-09-24T01:00:00.000Z', ended_at: '2026-09-24T09:00:00.000Z', worked_minutes: 480, status: 'pending_review', anomaly: null, correction_note: '', source_event_ids: [], auto_closed: false, revision: 1 };
const current = {
id: sessionId,
employee_id: '01J00000000000000000000004',
org_unit_id: '01J00000000000000000000005',
business_date: '2026-09-24',
started_at: '2026-09-24T01:00:00.000Z',
ended_at: '2026-09-24T09:00:00.000Z',
worked_minutes: 480,
status: 'pending_review',
anomaly: null,
correction_note: '',
source_event_ids: [],
auto_closed: false,
revision: 1,
};
const { database, calls } = fakeDatabase([[current]]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),

View file

@ -12,6 +12,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
const actorA = parseUlid('01J0000000000000000000001A');
const actorB = parseUlid('01J0000000000000000000001B');
const eventId = newUlid();
const professionId = newUlid();
beforeAll(async () => {
const role = await database.pool.query(
@ -23,6 +24,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
afterAll(async () => {
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]);
await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]);
});
await database.close();
});
@ -47,6 +49,27 @@ describeDatabase('PostgreSQL tenant isolation', () => {
);
expect(visibleToB.rowCount).toBe(0);
await database.tenantTransaction(tenantA, actorA, (client) =>
client.query(
`INSERT INTO staff_professions (id, tenant_id, code, name)
VALUES ($1, $2, 'cook', 'Повар')`,
[professionId, tenantA],
),
);
const professionsVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_professions WHERE id = $1', [professionId]),
);
expect(professionsVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_professions (id, tenant_id, code, name)
VALUES ($1, $2, 'foreign', 'Чужая профессия')`,
[newUlid(), tenantA],
),
),
).rejects.toThrow();
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
@ -57,5 +80,27 @@ describeDatabase('PostgreSQL tenant isolation', () => {
),
),
).rejects.toThrow();
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_pay_assignments
(id, tenant_id, employee_id, pay_rule_id, effective_from, params, pool_weight_bp, created_by)
VALUES ($1, $2, $3, $4, '2026-01-01', '{}'::jsonb, 0, $5)`,
[newUlid(), tenantA, newUlid(), newUlid(), actorB],
),
),
).rejects.toThrow();
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_payroll_ledger
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, ref_id, correlation_id, note, created_by, external_key)
VALUES ($1, $2, $3, '2026-01-01', 'accrual', 1000, 'RUB', 'payroll_run', $4, $5, 'test', $6, $7)`,
[newUlid(), tenantA, newUlid(), newUlid(), newUlid(), actorB, newUlid()],
),
),
).rejects.toThrow();
});
});

View file

@ -24,4 +24,11 @@ describe('container deployment boundaries', () => {
expect(compose.services.migrate?.depends_on).toHaveProperty('db-role');
expect(compose.services.app?.depends_on).toHaveProperty('migrate');
});
it('removes only the machine terminal state after explicit uninstall confirmation', () => {
const installer = readFileSync('installer/KronaStaffTerminal.nsi', 'utf8');
expect(installer).toContain('RMDir /r "$COMMONAPPDATA\\Krona\\StaffTerminal"');
expect(installer).not.toContain('RMDir /r "$APPDATA\\Krona\\StaffTerminal"');
expect(installer).not.toContain('RMDir /r "$COMMONAPPDATA\\Krona"');
});
});

View file

@ -0,0 +1,644 @@
import { readFileSync } from 'node:fs';
import type { PoolClient, QueryResult, QueryResultRow } from 'pg';
import { describe, expect, it } from 'vitest';
import type { RequestContext } from '../../src/core/context.js';
import { newUlid } from '../../src/core/ids.js';
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js';
import {
cateringPreset,
defaultParams,
findRoleTemplate,
} from '../../src/domain/payroll/presets.js';
import {
approvePayrollRun,
calculatePayrollPeriod,
getEmployeePayAssignment,
saveEmployeePayAssignment,
} from '../../src/domain/payroll/service.js';
const RUB = 100;
const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!;
const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!;
function mockContext(overrides?: Partial<RequestContext>): RequestContext {
return {
tenantId: newUlid(),
actorId: newUlid(),
correlationId: newUlid(),
permissions: new Set(['staff:payroll:write', 'staff:payroll:read', 'staff:pay_rule:manage']),
...overrides,
};
}
describe('Owner spreadsheet payroll schemes', () => {
it('calculates 11% pool for 3 cooks (100 ₽/h) + 2.5% admin with 3000 ₽ guarantee on 150k revenue', () => {
const revenueRub = 150_000;
const revenueMinor = revenueRub * RUB;
// 3 cooks working full 12-hour shifts
const cook1: DayParticipant = {
employeeId: 'COOK1',
workedMinutes: 720,
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp, // 10000
};
const cook2: DayParticipant = {
employeeId: 'COOK2',
workedMinutes: 720,
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
const cook3: DayParticipant = {
employeeId: 'COOK3',
workedMinutes: 720,
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
// 1 admin working 12 hours with 2.5% revenue + 100 ₽/h and 3000 ₽ guarantee
const admin: DayParticipant = {
employeeId: 'ADMIN',
workedMinutes: 720,
ruleCode: percentRole.code,
rule: percentRole.rule,
params: {
rate_minor: 100 * RUB,
percent_bp: 250, // 2.5%
guarantee_minor: 3_000 * RUB,
},
poolWeightBp: percentRole.poolWeightBp, // 0
};
const dayResult = calculateDay({
businessDate: '2026-05-15',
orgUnitId: 'UNIT1',
metrics: { revenue_minor: revenueMinor },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
roundingUnitMinor: 100,
currencyCode: 'RUB',
participants: [cook1, cook2, cook3, admin],
});
// Total pool = 11% of 150,000 ₽ = 16,500 ₽ (1,650,000 kopecks)
expect(dayResult.poolAmountMinor).toBe(1_650_000);
// 3 cooks share pool equally: 16,500 / 3 = 5,500 ₽
// Hourly: 12h * 100 ₽/h = 1,200 ₽
// Total per cook: 5,500 + 1,200 = 6,700 ₽ = 670,000 kopecks
const lineCook1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
const lineCook2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
const lineCook3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
expect(lineCook1.poolShareMinor).toBe(550_000);
expect(lineCook1.amountMinor).toBe(670_000);
expect(lineCook2.poolShareMinor).toBe(550_000);
expect(lineCook2.amountMinor).toBe(670_000);
expect(lineCook3.poolShareMinor).toBe(550_000);
expect(lineCook3.amountMinor).toBe(670_000);
// Admin: 2.5% of 150,000 ₽ (3,750 ₽) + hourly (1,200 ₽) = 4,950 ₽ (495,000 kopecks), exceeding 3,000 ₽ guarantee
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
expect(lineAdmin.poolShareMinor).toBe(0);
expect(lineAdmin.amountMinor).toBe(495_000);
// Total daily payroll: 6,700 * 3 + 4,950 = 25,050 ₽ (2,505,000 kopecks)
const totalDay = dayResult.lines.reduce((sum, l) => sum + l.amountMinor, 0);
expect(totalDay).toBe(2_505_000);
});
it('correctly redistributes pool when a cook leaves early (time-slice model)', () => {
const revenueMinor = 150_000 * RUB;
// Cook 1 leaves after 6 hours (half shift)
const cook1: DayParticipant = {
employeeId: 'COOK1',
workedMinutes: 360, // 6h
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
// Cook 2 and Cook 3 work full 12h shifts
const cook2: DayParticipant = {
employeeId: 'COOK2',
workedMinutes: 720, // 12h
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
const cook3: DayParticipant = {
employeeId: 'COOK3',
workedMinutes: 720, // 12h
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
const dayResult = calculateDay({
businessDate: '2026-05-15',
orgUnitId: 'UNIT1',
metrics: { revenue_minor: revenueMinor },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
roundingUnitMinor: 100,
currencyCode: 'RUB',
participants: [cook1, cook2, cook3],
});
// Pool = 16,500 ₽ (1,650,000 kopecks)
expect(dayResult.poolAmountMinor).toBe(1_650_000);
// Time-slice redistribution:
// First 6 hours: 8,250 ₽ split among 3 cooks = 2,750 ₽ each
// Last 6 hours: 8,250 ₽ split among remaining 2 cooks = 4,125 ₽ each
// Cook 1 pool share = 2,750 ₽ (275,000 kopecks)
// Cook 2 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
// Cook 3 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
const line1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
const line2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
const line3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
expect(line1.poolShareMinor).toBe(275_000);
expect(line2.poolShareMinor).toBe(687_500);
expect(line3.poolShareMinor).toBe(687_500);
// The entire pool of 16,500 ₽ is completely distributed without loss
expect(line1.poolShareMinor + line2.poolShareMinor + line3.poolShareMinor).toBe(1_650_000);
// Cook 1: 2,750 + 6h * 100 ₽ = 3,350 ₽ (335,000 kopecks)
// Cook 2 & 3: 6,875 + 12h * 100 ₽ = 8,075 ₽ (807,500 kopecks)
expect(line1.amountMinor).toBe(335_000);
expect(line2.amountMinor).toBe(807_500);
expect(line3.amountMinor).toBe(807_500);
});
it('triggers admin guaranteed minimum when revenue percentage is below threshold', () => {
// Low revenue: 50,000 ₽. 2.5% = 1,250 ₽ + 12h * 100 ₽ = 2,450 ₽, below guarantee of 3,000 ₽
const revenueMinor = 50_000 * RUB;
const admin: DayParticipant = {
employeeId: 'ADMIN',
workedMinutes: 720,
ruleCode: percentRole.code,
rule: percentRole.rule,
params: {
rate_minor: 100 * RUB,
percent_bp: 250,
guarantee_minor: 3_000 * RUB,
},
poolWeightBp: 0,
};
const dayResult = calculateDay({
businessDate: '2026-05-15',
orgUnitId: 'UNIT1',
metrics: { revenue_minor: revenueMinor },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
roundingUnitMinor: 100,
currencyCode: 'RUB',
participants: [admin],
});
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
expect(lineAdmin.amountMinor).toBe(3_000 * RUB); // exactly 300,000 kopecks
});
});
describe('Payroll service calculation and warning engine', () => {
function makeMockClient(handlers: {
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
}): PoolClient {
return {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
const params =
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
return Promise.resolve(handlers.query(sql, params));
},
release: () => {},
} as unknown as PoolClient;
}
const defaultRole = cateringPreset.roles.find((r) => r.code === 'pool_member')!;
it('calculates payroll period from confirmed sessions and revenue metrics', async () => {
const executedQueries: string[] = [];
const unitId = newUlid();
const empId = newUlid();
const ruleId = newUlid();
const client = makeMockClient({
query: (sql) => {
executedQueries.push(sql);
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
if (sql.includes('FROM staff_work_sessions s')) {
// Confirmed work sessions
return {
rows: [
{
id: newUlid(),
employee_id: empId,
org_unit_id: unitId,
business_date: '2026-05-01',
worked_minutes: 720,
display_name: 'Иван Поваров',
position: 'Повар',
org_unit_name: 'Кухня Центр',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_daily_metrics')) {
return {
rows: [
{
org_unit_id: unitId,
business_date: '2026-05-01',
revenue_minor: '10000000', // 100,000 ₽
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_pay_assignments a')) {
return {
rows: [
{
id: newUlid(),
employee_id: empId,
org_unit_id: unitId,
pay_rule_id: ruleId,
effective_from: '2026-01-01',
effective_to: null,
params: { rate_minor: 10000 },
pool_weight_bp: 10000,
rule_code: 'pool_member',
rule: defaultRole.rule,
default_pool_weight_bp: 10000,
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_org_unit_terms')) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
const result = await calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-01',
to: '2026-05-01',
orgUnitId: unitId,
});
expect(result.status).toBe('calculated');
expect(result.warnings).toHaveLength(0);
expect(result.totals).toHaveLength(1);
const firstTotal = result.totals[0]!;
expect(firstTotal.display_name).toBe('Иван Поваров');
expect(firstTotal.worked_minutes).toBe(720);
// 11% of 100,000 ₽ = 11,000 ₽ pool + 12 * 100 ₽ hourly = 12,200 ₽ = 1,220,000 kopecks
expect(firstTotal.accrued_minor).toBe('1220000');
expect(result.total_accrued_minor).toBe('1220000');
// Check that staff_payroll_lines insertion occurred
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_payroll_lines'))).toBe(true);
// Check outbox and audit log
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_outbox'))).toBe(true);
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_audit_log'))).toBe(true);
});
it('detects warnings for missing revenue, unassigned employees, and empty shifts', async () => {
const unitId = newUlid();
const empId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
if (sql.includes('FROM staff_work_sessions s')) {
return {
rows: [
{
id: newUlid(),
employee_id: empId,
org_unit_id: unitId,
business_date: '2026-05-02',
worked_minutes: 600,
display_name: 'Петр Безпрофилев',
position: 'Курьер',
org_unit_name: 'Кухня Центр',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
// Return NO metrics (missing revenue) and NO assignments
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
const result = await calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-02',
to: '2026-05-02',
});
expect(result.warnings.some((w) => w.kind === 'missing_revenue')).toBe(true);
expect(result.warnings.some((w) => w.kind === 'missing_pay_assignment')).toBe(true);
});
it('warns when no confirmed sessions exist for the period', async () => {
const client = makeMockClient({
query: () => ({ rows: [], rowCount: 0, command: '', oid: 0, fields: [] }),
});
const result = await calculatePayrollPeriod(client, mockContext(), {
from: '2026-06-01',
to: '2026-06-30',
});
expect(result.warnings.some((w) => w.kind === 'no_confirmed_sessions')).toBe(true);
expect(result.totals).toHaveLength(0);
expect(result.total_accrued_minor).toBe('0');
});
it('rejects recalculation if run is already approved', async () => {
const runId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return {
rows: [{ id: runId, status: 'approved' }],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
await expect(
calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-01',
to: '2026-05-31',
}),
).rejects.toThrow('Расчёт за этот период уже утверждён.');
});
});
describe('Payroll approval and ledger idempotency', () => {
function makeMockClient(handlers: {
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
}): PoolClient {
return {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
const params =
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
return Promise.resolve(handlers.query(sql, params));
},
release: () => {},
} as unknown as PoolClient;
}
it('posts accrual lines to ledger with external_key and marks run approved', async () => {
let ledgerInsertQuery = '';
let ledgerInsertParams: unknown[] = [];
const runId = newUlid();
const empId = newUlid();
const client = makeMockClient({
query: (sql, params) => {
if (sql.includes('SELECT id, period_start::text AS period_start')) {
return {
rows: [
{
id: runId,
period_start: '2026-05-01',
period_end: '2026-05-31',
status: 'calculated',
total_accrued_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_payroll_lines')) {
return {
rows: [
{
employee_id: empId,
amount_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('INSERT INTO staff_payroll_ledger')) {
ledgerInsertQuery = sql;
ledgerInsertParams = params;
return { rows: [{ id: newUlid() }], rowCount: 1, command: '', oid: 0, fields: [] };
}
return { rows: [], rowCount: 1, command: '', oid: 0, fields: [] };
},
});
const result = await approvePayrollRun(client, mockContext(), runId);
expect(result.status).toBe('approved');
expect(result.entries_posted).toBe(1);
expect(result.total_accrued_minor).toBe('1220000');
// Verify external_key structure and ON CONFLICT clause
expect(ledgerInsertQuery).toContain(
'ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING',
);
expect(ledgerInsertParams).toContain(`payroll_run:${runId}:${empId}`);
});
it('rejects approval if run is not in calculated status', async () => {
const runId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, period_start::text AS period_start')) {
return {
rows: [
{
id: runId,
period_start: '2026-05-01',
period_end: '2026-05-31',
status: 'approved',
total_accrued_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
await expect(approvePayrollRun(client, mockContext(), runId)).rejects.toThrow(
'Утвердить можно только расчёт в статусе calculated.',
);
});
});
describe('Pay assignment management', () => {
it('saves and retrieves pay assignment with default fallbacks', async () => {
let savedParamsJson = '';
let closedPreviousVersion = false;
const tenantId = newUlid();
const actorId = newUlid();
const corrId = newUlid();
const empId = newUlid();
const ruleId = newUlid();
const assignId = newUlid();
const client = {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
const params =
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
if (sql.includes('FROM staff_employees')) {
return Promise.resolve({ rows: [{ id: empId }], rowCount: 1 });
}
if (sql.includes('FROM staff_pay_rules WHERE tenant_id')) {
return Promise.resolve({ rows: [{ id: ruleId, pool_weight_bp: 10000 }], rowCount: 1 });
}
if (sql.includes('INSERT INTO staff_pay_assignments')) {
savedParamsJson = String(params[7]);
return Promise.resolve({ rows: [], rowCount: 1 });
}
if (sql.includes('SET effective_to = ($3::date')) {
closedPreviousVersion = true;
expect(String(params[2])).toMatch(/^\d{4}-\d{2}-\d{2}$/);
return Promise.resolve({ rows: [], rowCount: 1 });
}
if (sql.includes('FROM staff_pay_assignments a')) {
return Promise.resolve({
rows: [
{
id: assignId,
employee_id: empId,
org_unit_id: null,
pay_rule_id: ruleId,
effective_from: '2026-05-01',
effective_to: null,
params: { rate_minor: 12000, percent_bp: 0, guarantee_minor: 0 },
pool_weight_bp: 10000,
note: 'Главный повар',
rule_code: 'pool_member',
rule_name: 'Повар (доля от фонда)',
default_pool_weight_bp: 10000,
},
],
rowCount: 1,
});
}
return Promise.resolve({ rows: [], rowCount: 0 });
},
release: () => {},
} as unknown as PoolClient;
const saved = await saveEmployeePayAssignment(client, tenantId, actorId, corrId, empId, {
pay_rule_id: ruleId,
rate_minor: 12000,
note: 'Главный повар',
});
expect(saved.rate_minor).toBe(12000);
expect(saved.pool_weight_bp).toBe(10000);
expect(saved.note).toBe('Главный повар');
expect(savedParamsJson).toContain('"rate_minor":12000');
expect(closedPreviousVersion).toBe(true);
const fetched = await getEmployeePayAssignment(client, tenantId, empId);
expect(fetched).not.toBeNull();
expect(fetched?.rate_minor).toBe(12000);
});
});
describe('Tenant and employee isolation guarantees', () => {
it('enforces tenant_id in every database query in payroll service', () => {
const serviceSrc = readFileSync('src/domain/payroll/service.ts', 'utf8');
// All SELECTs, INSERTs, UPDATEs, DELETEs in service.ts must contain tenant_id
const sqlStatements = serviceSrc.match(/`[^`]*(?:SELECT|INSERT|UPDATE|DELETE)[^`]*`/g) ?? [];
expect(sqlStatements.length).toBeGreaterThan(5);
for (const sql of sqlStatements) {
expect(sql).toContain('tenant_id');
}
});
it('restricts /payroll query to actor employee or requires manager permissions', () => {
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
// Ensure the employee isolation clause is present in GET /payroll
expect(operationsSrc).toContain('($3::boolean OR e.core_user_id = $4 OR e.id = $4)');
expect(operationsSrc).toContain("context.permissions.has('staff:payroll:read')");
expect(operationsSrc).toContain("context.permissions.has('staff:payslip:read_own')");
});
it('restricts pay-assignment access to owner employee or manager', () => {
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
expect(operationsSrc).toContain(
'!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId',
);
});
it('confirms external_key and unique index are declared in 0008_employee_access migration', () => {
const migrationSql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8');
expect(migrationSql).toContain(
'ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;',
);
expect(migrationSql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx');
expect(migrationSql).toContain(
'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;',
);
});
});

View file

@ -0,0 +1,58 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
describe('security boundary regressions', () => {
it('requires section-specific permissions before owner workbook writes', () => {
const source = readFileSync('src/operations.ts', 'utf8');
const permissionCheck = source.indexOf("requiredPermissions.add('staff:org_unit:write')");
const transaction = source.indexOf(
'const counts = await database.tenantTransaction',
permissionCheck,
);
expect(permissionCheck).toBeGreaterThan(0);
expect(source.slice(permissionCheck, transaction)).toContain('staff:employee:write');
expect(source.slice(permissionCheck, transaction)).toContain('staff:metric:write');
expect(source.slice(permissionCheck, transaction)).toContain('staff:shift:confirm');
expect(source.slice(permissionCheck, transaction)).toContain('staff:payout:write');
expect(source.slice(permissionCheck, transaction)).toContain('await auth.require');
});
it('does not grant broad authenticated-user access to state or IPC', () => {
const store = readFileSync('apps/KronaStaffTerminal/Services/TerminalStore.cs', 'utf8');
const ipc = readFileSync('apps/KronaStaffTerminal/Services/TerminalIpc.cs', 'utf8');
expect(store).not.toContain('WellKnownSidType.AuthenticatedUserSid');
expect(store).not.toContain('catch\n {\n Directory.CreateDirectory');
expect(ipc).not.toContain('WellKnownSidType.AuthenticatedUserSid');
expect(ipc).toContain('IpcRequestAuthenticator');
expect(store).toContain('return await InitializeFreshStateAsync()');
expect(store).toContain('FileShare.None');
expect(store).toContain('await SaveAsync(state)');
});
it('enforces active employee status in standalone token resolution', () => {
const portal = readFileSync('src/portal.ts', 'utf8');
expect(portal).toContain("employee.status = 'active'");
const patchRoute = portal.indexOf('api.patch<{ Params: { employeeId: string }');
const accessRoute = portal.indexOf("'/employees/:employeeId/access-token'", patchRoute);
expect(portal.slice(patchRoute, accessRoute)).toContain(
'DELETE FROM staff_standalone_employee_access',
);
});
it('versions payroll formulas independently per organization unit', () => {
const service = readFileSync('src/domain/payroll/service.ts', 'utf8');
const migration = readFileSync('migrations/0009_professions.up.sql', 'utf8');
expect(service.match(/org_unit_id IS NOT DISTINCT FROM \$4::varchar\(26\)/g)).toHaveLength(3);
expect(service).toContain('pg_advisory_xact_lock');
expect(migration).toContain('staff_pay_assignments_effective_version_idx');
});
it('uses stable external identities for repeatable workbook imports', () => {
const importer = readFileSync('scripts/import-owner-workbook.py', 'utf8');
const migration = readFileSync('migrations/0009_professions.up.sql', 'utf8');
expect(importer).toContain('xlsx:staff:');
expect(importer).not.toContain('enumerate(names');
expect(importer).not.toContain('{row}|{date_text}');
expect(migration).toContain('staff_employees_external_ref_idx');
});
});

View file

@ -78,6 +78,32 @@ describe('payroll foundation migration', () => {
);
});
const payrollTables = [
'staff_pay_rules',
'staff_pay_assignments',
'staff_payroll_runs',
'staff_payroll_lines',
'staff_payroll_ledger',
];
it.each(payrollTables)('verifies %s has tenant_id column', (table) => {
const create = sql.slice(
sql.indexOf(`CREATE TABLE ${table}`),
sql.indexOf(';', sql.indexOf(`CREATE TABLE ${table}`)),
);
expect(create).toContain('tenant_id VARCHAR(26) NOT NULL');
});
it('enforces tenant isolation and RLS on staff_payroll_ledger with select and insert policies', () => {
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('CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger');
expect(sql).toContain("FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true))");
expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger');
expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)");
expect(sql).toContain("created_by = current_setting('app.user_id', true)");
});
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');
@ -88,3 +114,28 @@ describe('payroll foundation migration', () => {
);
});
});
describe('employee access migration', () => {
const sql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8');
it('declares external_key and staff_payroll_ledger_external_key_idx index on staff_payroll_ledger', () => {
expect(sql).toContain('ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;');
expect(sql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx');
expect(sql).toContain(
'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;',
);
});
it('protects standalone employee access with tenant isolation and RLS', () => {
const create = sql.slice(
sql.indexOf('CREATE TABLE staff_standalone_employee_access'),
sql.indexOf(';', sql.indexOf('CREATE TABLE staff_standalone_employee_access')),
);
expect(create).toContain('tenant_id VARCHAR(26) NOT NULL');
expect(sql).toContain('ALTER TABLE staff_standalone_employee_access ENABLE ROW LEVEL SECURITY');
expect(sql).toContain('ALTER TABLE staff_standalone_employee_access FORCE ROW LEVEL SECURITY');
expect(sql).toContain('CREATE POLICY staff_standalone_employee_access_tenant_isolation');
expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)");
});
});