Compare commits

...

2 commits

Author SHA1 Message Date
Ochenstarik
88804a0eea feat(staff): add finance calendar and employee portal 2026-09-24 18:12:24 +07:00
Ochenstarik
057ec75b26 fix(terminal): handle locked scans consistently 2026-09-24 18:09:42 +07:00
23 changed files with 1403 additions and 52 deletions

View file

@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Krona Staff API
version: 0.2.5
version: 0.3.0
description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя.
license:
name: Proprietary
@ -255,6 +255,137 @@ paths:
responses:
'200': { description: Строка табеля сохранена }
'403': { description: Недостаточно прав }
/employees/{employeeId}/access-token:
post:
summary: Выпустить новый одноразово показываемый ключ личного кабинета сотрудника
operationId: rotateEmployeeAccessToken
tags: [Employees]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: employeeId
required: true
schema: { $ref: '#/components/schemas/Ulid' }
responses:
'200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток }
'403': { description: Недостаточно прав }
'404': { description: Сотрудник не найден }
/org-units:
get:
summary: Получить доступные торговые точки
operationId: listStaffOrgUnits
tags: [Organization]
security: [{ bearerAuth: [] }]
responses:
'200': { description: Список доступных торговых точек }
'403': { description: Недостаточно прав }
/finance:
get:
summary: Получить доходы, расходы и дневную динамику за период
operationId: getStaffFinance
tags: [Finance]
security: [{ bearerAuth: [] }]
parameters:
- { in: query, name: from, required: true, schema: { type: string, format: date } }
- { in: query, name: to, required: true, schema: { type: string, format: date } }
responses:
'200': { description: Финансовые операции и агрегаты }
'403': { description: Недостаточно прав }
/finance/{orgUnitId}/{businessDate}/{metricKey}:
put:
summary: Сохранить сумму по финансовой категории за день
operationId: saveStaffFinanceMetric
tags: [Finance]
security: [{ bearerAuth: [] }]
parameters:
- { 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:
required: true
content:
application/json:
schema:
type: object
required: [value_minor]
properties:
value_minor: { type: integer, minimum: 0 }
currency_code: { type: string, pattern: '^[A-Z]{3}$' }
responses:
'200': { description: Сумма сохранена }
'403': { description: Недостаточно прав }
/schedule:
get:
summary: Получить плановые смены и фактические отметки сканера
operationId: getStaffSchedule
tags: [Schedule]
security: [{ bearerAuth: [] }]
parameters:
- { in: query, name: from, required: true, schema: { type: string, format: date } }
- { in: query, name: to, required: true, schema: { type: string, format: date } }
responses:
'200': { description: Календарь плановых и фактических смен }
'403': { description: Недостаточно прав }
/schedule/{employeeId}/{businessDate}:
put:
summary: Запланировать или удалить смену сотрудника
operationId: saveStaffPlannedShift
tags: [Schedule]
security: [{ bearerAuth: [] }]
parameters:
- { 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
content:
application/json:
schema:
type: object
required: [org_unit_id, planned_minutes]
properties:
org_unit_id: { $ref: '#/components/schemas/Ulid' }
planned_minutes: { type: integer, minimum: 0, maximum: 1440 }
start_time: { type: string }
end_time: { type: string }
note: { type: string, maxLength: 500 }
responses:
'200': { description: План смены сохранён }
'403': { description: Недостаточно прав }
/payroll:
get:
summary: Получить зарплатные итоги и расчётный лист за период
operationId: getStaffPayroll
tags: [Payroll]
security: [{ bearerAuth: [] }]
parameters:
- { in: query, name: from, required: true, schema: { type: string, format: date } }
- { in: query, name: to, required: true, schema: { type: string, format: date } }
responses:
'200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' }
'403': { description: Недостаточно прав }
/imports/owner-workbook:
post:
summary: Идемпотентно импортировать локально разобранную книгу владельца
operationId: importStaffOwnerWorkbook
tags: [Import]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [source_name]
properties:
source_name: { type: string, maxLength: 240 }
units: { type: array, items: { type: object } }
financial_entries: { type: array, items: { type: object } }
employees: { type: array, items: { type: object } }
schedule_items: { type: array, items: { type: object } }
payroll_entries: { type: array, items: { type: object } }
responses:
'200': { description: Пакет импортирован повторяемо без дублирования }
'403': { description: Недостаточно прав }
components:
securitySchemes:
bearerAuth:

View file

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

View file

@ -225,7 +225,7 @@ public sealed class KronaStaffWorker : BackgroundService
return new IpcServiceStatus
{
ServiceRunning = true,
ServiceVersion = "0.2.5",
ServiceVersion = "0.3.0",
ScannerInSession0Supported = _scannerSession0Supported,
ScannerDiagnostic = _scannerDiagnostic,
PendingSyncCount = pendingCount,

View file

@ -220,4 +220,168 @@ public class AttendanceTrackerTests
Assert.Empty(checkedOutRecords);
}
[Fact]
public async Task ProcessScan_Locked_RegularEmployee_FirstScan_RecordsAttendanceAndKeepsLocked()
{
var (state, emp) = CreateTestState();
var tracker = new AttendanceTracker();
var now = DateTimeOffset.UtcNow;
var result = await tracker.ProcessScanAsync(
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
Assert.True(result.Success);
Assert.False(result.ShouldUnlock);
Assert.NotNull(result.AttendanceResult.Record);
Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
Assert.Equal("Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным.", result.LockScreenMessage);
Assert.Single(state.Attendance);
}
[Fact]
public async Task ProcessScan_Locked_Administrator_FirstScan_RecordsAttendanceAndUnlocks()
{
var (state, emp) = CreateTestState();
emp.IsAdministrator = true;
var tracker = new AttendanceTracker();
var now = DateTimeOffset.UtcNow;
var result = await tracker.ProcessScanAsync(
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
Assert.True(result.Success);
Assert.True(result.ShouldUnlock);
Assert.NotNull(result.AttendanceResult.Record);
Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
Assert.Null(result.LockScreenMessage);
Assert.Contains(emp.DisplayName, result.StatusMessage);
Assert.Single(state.Attendance);
}
[Fact]
public async Task ProcessScan_Locked_Administrator_SecondScanAfterGuard_RecordsCheckOutAndUnlocks()
{
var (state, emp) = CreateTestState();
emp.IsAdministrator = true;
var tracker = new AttendanceTracker();
var now = DateTimeOffset.UtcNow;
// First scan: check-in
await tracker.ProcessScanAsync(
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
// Second scan after 35 seconds (guard is 30s)
var result = await tracker.ProcessScanAsync(
state, emp, 0, now.AddSeconds(35), isApplicationLocked: true, _ => Task.CompletedTask);
Assert.True(result.Success);
Assert.True(result.ShouldUnlock);
Assert.NotNull(result.AttendanceResult.Record);
Assert.Equal("Уход", result.AttendanceResult.Record.EventType);
Assert.Null(result.LockScreenMessage);
Assert.Equal(2, state.Attendance.Count);
}
[Fact]
public async Task ProcessScan_Locked_RegularEmployee_DuplicateScan_RejectsDuplicateAndKeepsLocked()
{
var (state, emp) = CreateTestState();
var tracker = new AttendanceTracker();
var now = DateTimeOffset.UtcNow;
// First scan
await tracker.ProcessScanAsync(
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
// Immediate repeat scan 5s later
var result = await tracker.ProcessScanAsync(
state, emp, 0, now.AddSeconds(5), isApplicationLocked: true, _ => Task.CompletedTask);
Assert.False(result.Success);
Assert.False(result.ShouldUnlock);
Assert.True(result.AttendanceResult.IsDuplicateSuppressed);
Assert.Equal(25, result.AttendanceResult.RemainingSeconds);
Assert.Equal("Повторный скан отклонён. Повторите через 25 сек.", result.LockScreenMessage);
Assert.Single(state.Attendance);
}
[Fact]
public async Task ProcessScan_Locked_Administrator_DuplicateScan_RejectsDuplicateAndUnlocks()
{
var (state, emp) = CreateTestState();
emp.IsAdministrator = true;
var tracker = new AttendanceTracker();
var now = DateTimeOffset.UtcNow;
// First scan
await tracker.ProcessScanAsync(
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
// Immediate repeat scan 5s later
var result = await tracker.ProcessScanAsync(
state, emp, 0, now.AddSeconds(5), isApplicationLocked: true, _ => Task.CompletedTask);
Assert.False(result.Success);
Assert.True(result.ShouldUnlock);
Assert.True(result.AttendanceResult.IsDuplicateSuppressed);
Assert.Equal(25, result.AttendanceResult.RemainingSeconds);
Assert.Null(result.LockScreenMessage);
Assert.Single(state.Attendance);
}
[Fact]
public async Task ProcessScan_Locked_BlockedEmployee_DoesNotRecordAttendance()
{
var (state, emp) = CreateTestState();
emp.IsBlocked = true;
var tracker = new AttendanceTracker();
var now = DateTimeOffset.UtcNow;
var result = await tracker.ProcessScanAsync(
state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
Assert.False(result.Success);
Assert.False(result.ShouldUnlock);
Assert.False(result.AttendanceResult.Success);
Assert.Equal("Этот сотрудник заблокирован.", result.LockScreenMessage);
Assert.Empty(state.Attendance);
}
[Fact]
public async Task ProcessScan_Unlocked_RegularEmployee_RecordsAttendanceWithoutUnlock()
{
var (state, emp) = CreateTestState();
var tracker = new AttendanceTracker();
var now = DateTimeOffset.UtcNow;
var result = await tracker.ProcessScanAsync(
state, emp, 0, now, isApplicationLocked: false, _ => Task.CompletedTask);
Assert.True(result.Success);
Assert.False(result.ShouldUnlock);
Assert.NotNull(result.AttendanceResult.Record);
Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
Assert.Null(result.LockScreenMessage);
Assert.Single(state.Attendance);
}
[Fact]
public async Task ProcessScan_Unlocked_Administrator_RecordsAttendanceWithoutUnlock()
{
var (state, emp) = CreateTestState();
emp.IsAdministrator = true;
var tracker = new AttendanceTracker();
var now = DateTimeOffset.UtcNow;
var result = await tracker.ProcessScanAsync(
state, emp, 0, now, isApplicationLocked: false, _ => Task.CompletedTask);
Assert.True(result.Success);
Assert.False(result.ShouldUnlock);
Assert.NotNull(result.AttendanceResult.Record);
Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
Assert.Null(result.LockScreenMessage);
Assert.Single(state.Attendance);
}
}

View file

@ -123,6 +123,9 @@ public partial class App : Application
public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon();
public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1) =>
_trayService?.ShowNotificationBalloon(title, message, infoFlags);
public void UpdateTray(TrayState state, int pendingCount, string? statusText = null) =>
_trayService?.UpdateState(state, pendingCount, statusText);

View file

@ -17,10 +17,10 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Version>0.2.5</Version>
<AssemblyVersion>0.2.5.0</AssemblyVersion>
<FileVersion>0.2.5.0</FileVersion>
<InformationalVersion>0.2.5</InformationalVersion>
<Version>0.3.0</Version>
<AssemblyVersion>0.3.0.0</AssemblyVersion>
<FileVersion>0.3.0.0</FileVersion>
<InformationalVersion>0.3.0</InformationalVersion>
</PropertyGroup>
<ItemGroup>
@ -73,6 +73,7 @@
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.6.1" />
<PackageReference Include="System.Windows.Extensions" Version="10.0.12" />
</ItemGroup>
<!--
@ -97,14 +98,8 @@
<_WinUiAsset Include="$(TargetDir)Assets\**\*" />
<_WinUiStyle Include="$(TargetDir)Styles\**\*" />
</ItemGroup>
<Copy SourceFiles="@(_WinUiRootResource)"
DestinationFolder="$(PublishDir)"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WinUiAsset)"
DestinationFiles="@(_WinUiAsset->'$(PublishDir)Assets\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WinUiStyle)"
DestinationFiles="@(_WinUiStyle->'$(PublishDir)Styles\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WinUiRootResource)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WinUiAsset)" DestinationFiles="@(_WinUiAsset->'$(PublishDir)Assets\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WinUiStyle)" DestinationFiles="@(_WinUiStyle->'$(PublishDir)Styles\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>

View file

@ -963,19 +963,7 @@ public sealed partial class MainPage : Page
else SetStatus("Сотрудник заблокирован. Отметка не создана.", InfoBarSeverity.Warning);
return;
}
if (_applicationLocked)
{
if (!employee.IsAdministrator)
{
await RecordAttendanceAsync(employee, score);
LockScreenMessage.Text = "Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным.";
return;
}
_attendanceSuppressedFingerprintId = fingerprintId;
_attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3);
UnlockApplication($"employee:{employee.Id}", $"Вход выполнен по отпечатку администратора {employee.DisplayName}.");
return;
}
if (_adminAuthCompletion is not null)
{
if (employee.IsAdministrator)
@ -1004,30 +992,57 @@ public sealed partial class MainPage : Page
}
_attendanceSuppressedFingerprintId = null;
_attendanceSuppressedUntilUtc = default;
if (_applicationLocked)
{
var scanResult = await RecordAttendanceAsync(employee, score);
if (scanResult.ShouldUnlock)
{
_attendanceSuppressedFingerprintId = fingerprintId;
_attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3);
UnlockApplication($"employee:{employee.Id}", scanResult.StatusMessage);
return;
}
LockScreenMessage.Text = scanResult.LockScreenMessage;
return;
}
await RecordAttendanceAsync(employee, score);
}
private async Task RecordAttendanceAsync(EmployeeRecord employee, int score)
private async Task<ScanRecognitionResult> RecordAttendanceAsync(EmployeeRecord employee, int score)
{
var now = DateTimeOffset.UtcNow;
var result = await _attendanceTracker.TryRecordAttendanceAsync(
var scanResult = await _attendanceTracker.ProcessScanAsync(
_state,
employee,
_scanner.ReaderIndex,
now,
_applicationLocked,
state => _store.SaveAsync(state));
var result = scanResult.AttendanceResult;
if (!result.Success)
{
if (result.IsDuplicateSuppressed)
{
PlayDuplicateSoundSafe();
ShowTrayNotification(
"Krona Staff",
$"Повторный скан отклонён. Повторите через {result.RemainingSeconds} сек.");
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек.";
SetStatus(result.Message, InfoBarSeverity.Informational);
}
return;
return scanResult;
}
var record = result.Record!;
PlaySuccessSoundSafe();
ShowTrayNotification(
"Krona Staff",
$"{TerminalDisplayName(employee)}: {record.EventType} в {now.ToLocalTime():HH:mm}");
RefreshLists();
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{record.EventType} отмечен в {now.ToLocalTime():HH:mm}";
SetStatus($"{record.EventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
@ -1046,6 +1061,47 @@ public sealed partial class MainPage : Page
{
SetStatus($"{record.EventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
}
return scanResult;
}
private static void PlaySuccessSoundSafe()
{
try
{
System.Media.SystemSounds.Asterisk.Play();
}
catch (Exception exception)
{
CrashLogService.Write(exception);
}
}
private static void PlayDuplicateSoundSafe()
{
try
{
System.Media.SystemSounds.Exclamation.Play();
}
catch (Exception exception)
{
CrashLogService.Write(exception);
}
}
private static void ShowTrayNotification(string title, string message)
{
try
{
if (Application.Current is App app)
{
app.ShowNotificationBalloon(title, message);
}
}
catch (Exception exception)
{
CrashLogService.Write(exception);
}
}
private async Task RunAutomaticCheckoutIfDueAsync()

View file

@ -9,6 +9,13 @@ public sealed record AttendanceRecordResult(
bool IsDuplicateSuppressed,
int RemainingSeconds = 0);
public sealed record ScanRecognitionResult(
bool Success,
AttendanceRecordResult AttendanceResult,
bool ShouldUnlock,
string? LockScreenMessage,
string StatusMessage);
public sealed class AttendanceTracker
{
private readonly SemaphoreSlim _gate = new(1, 1);
@ -77,6 +84,71 @@ public sealed class AttendanceTracker
}
}
public async Task<ScanRecognitionResult> ProcessScanAsync(
TerminalState state,
EmployeeRecord employee,
int readerIndex,
DateTimeOffset nowUtc,
bool isApplicationLocked,
Func<TerminalState, Task> saveStateCallback)
{
if (employee.IsBlocked)
{
return new ScanRecognitionResult(
Success: false,
AttendanceResult: new AttendanceRecordResult(
Success: false,
Record: null,
Message: "Сотрудник заблокирован. Отметка не создана.",
IsDuplicateSuppressed: false),
ShouldUnlock: false,
LockScreenMessage: isApplicationLocked ? "Этот сотрудник заблокирован." : null,
StatusMessage: "Сотрудник заблокирован. Отметка не создана.");
}
var attendanceResult = await TryRecordAttendanceAsync(
state,
employee,
readerIndex,
nowUtc,
saveStateCallback);
if (isApplicationLocked)
{
if (!employee.IsAdministrator)
{
var lockMessage = attendanceResult.Success
? "Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным."
: attendanceResult.IsDuplicateSuppressed
? $"Повторный скан отклонён. Повторите через {attendanceResult.RemainingSeconds} сек."
: "Отметка не создана. Интерфейс остаётся заблокированным.";
return new ScanRecognitionResult(
Success: attendanceResult.Success,
AttendanceResult: attendanceResult,
ShouldUnlock: false,
LockScreenMessage: lockMessage,
StatusMessage: attendanceResult.Message);
}
// Administrator scan in locked state:
// Attendance is recorded AND the interface should be unlocked
return new ScanRecognitionResult(
Success: attendanceResult.Success,
AttendanceResult: attendanceResult,
ShouldUnlock: true,
LockScreenMessage: null,
StatusMessage: $"Вход выполнен по отпечатку администратора {employee.DisplayName}.");
}
return new ScanRecognitionResult(
Success: attendanceResult.Success,
AttendanceResult: attendanceResult,
ShouldUnlock: false,
LockScreenMessage: null,
StatusMessage: attendanceResult.Message);
}
public async Task<List<AttendanceRecord>> RunScheduledCheckoutAsync(
TerminalState state,
DateTimeOffset nowUtc,

View file

@ -25,7 +25,7 @@ public sealed class IpcResponse
public sealed class IpcServiceStatus
{
public bool ServiceRunning { get; set; } = true;
public string ServiceVersion { get; set; } = "0.2.5";
public string ServiceVersion { get; set; } = "0.3.0";
public bool ScannerInSession0Supported { get; set; }
public string ScannerDiagnostic { get; set; } = "";
public int PendingSyncCount { get; set; }

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.2.5 (Windows NT 10.0; Win64; x64)` |
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.0 (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.2.5"
!define APP_VERSION "0.3.0"
!endif
!ifndef PUBLISH_DIR
!error "PUBLISH_DIR is required"

View file

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS staff_standalone_employee_access;
DROP INDEX IF EXISTS staff_payroll_ledger_external_key_idx;
ALTER TABLE staff_payroll_ledger DROP COLUMN IF EXISTS external_key;

View file

@ -0,0 +1,32 @@
-- Temporary standalone identity mapping. Core remains the owner of users and SSO in platform mode.
-- Raw access tokens are never stored; only a SHA-256 digest and a short non-secret hint are kept.
CREATE TABLE staff_standalone_employee_access (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
subject_id VARCHAR(26) NOT NULL CHECK (subject_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
token_hash CHAR(64) NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
token_hint VARCHAR(8) NOT NULL,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rotated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, employee_id),
UNIQUE (tenant_id, subject_id),
UNIQUE (tenant_id, token_hash),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE
);
ALTER TABLE staff_standalone_employee_access ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_standalone_employee_access FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_standalone_employee_access_tenant_isolation
ON staff_standalone_employee_access
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_standalone_employee_access TO krona_staff_app;
ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;
CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx
ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;

View file

@ -2,7 +2,7 @@
"$schema": "./schemas/module.schema.json",
"id": "ru.krona.staff",
"name": "Krona Staff",
"version": "0.2.5",
"version": "0.3.0",
"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.2.5",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@krona/staff",
"version": "0.2.5",
"version": "0.3.0",
"dependencies": {
"@fastify/helmet": "^13.0.2",
"@fastify/sensible": "^6.0.3",

View file

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

View file

@ -7,7 +7,7 @@ export class StandaloneIdentityAdapter implements IdentityPort {
constructor(private readonly config: AppConfig) {}
async resolve(token: string | undefined, correlationId: Ulid) {
const permissionsByAccess = {
employee: ['staff:attendance:read_own'],
employee: ['staff:attendance:read_own', 'staff:shift:read', 'staff:payslip:read_own'],
manager: [
'staff:employee:read',
'staff:employee:write',
@ -16,6 +16,16 @@ export class StandaloneIdentityAdapter implements IdentityPort {
'staff:attendance:approve',
'staff:shift:read',
'staff:shift:confirm',
'staff:org_unit:read',
'staff:org_unit:write',
'staff:metric:read',
'staff:metric:write',
'staff:pay_rule:read',
'staff:pay_rule:write',
'staff:payroll:read',
'staff:payroll:calculate',
'staff:payroll:approve',
'staff:payout:write',
],
terminal: ['staff:attendance:clock'],
} as const;

View file

@ -8,7 +8,13 @@ import { Database } from './infrastructure/database.js';
import { newUlid } from './core/ids.js';
import { SERVICE_NAME, SERVICE_VERSION } from './version.js';
import { registerPortalRoutes } from './portal.js';
import { portalCss, portalHtml, portalJs, portalManifest, portalServiceWorker } from './portal-ui.js';
import {
portalCssEnhanced as portalCss,
portalHtml,
portalJsEnhanced as portalJs,
portalManifest,
portalServiceWorkerEnhanced as portalServiceWorker,
} from './portal-ui.js';
export interface BuildAppOptions {
config: AppConfig;

707
src/operations.ts Normal file
View file

@ -0,0 +1,707 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { PoolClient } from 'pg';
import type { RequestContext } from './core/context.js';
import { newUlid, parseUlid } from './core/ids.js';
import type { Database } from './infrastructure/database.js';
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
export const financialMetricDefinitions = [
['income_cash', 'Наличные', 'income'],
['income_card', 'Безналичные', 'income'],
['income_transfer', 'Переводы', 'income'],
['income_sber_market', 'Сбер Маркет', 'income'],
['income_yandex', 'Яндекс', 'income'],
['income_certificate', 'Сертификаты', 'income'],
['income_recycling', 'Масло и вторсырьё', 'income'],
['expense_products', 'Продукты', 'expense'],
['expense_supplies', 'Расходные товары', 'expense'],
['expense_taxi', 'Такси', 'expense'],
['expense_communications', 'Связь', 'expense'],
['expense_advertising', 'Реклама', 'expense'],
['expense_owner_payroll', 'Зарплата руководства', 'expense'],
['expense_employee_payroll', 'Зарплата сотрудников', 'expense'],
['expense_taxes', 'Налоги', 'expense'],
['expense_rent', 'Аренда', 'expense'],
['expense_utilities', 'Коммунальные услуги', 'expense'],
['expense_other', 'Дополнительные расходы', 'expense'],
['expense_gifts', 'Подарки', 'expense'],
] as const;
const financialMetricKeys = new Set<string>(financialMetricDefinitions.map(([key]) => key));
interface OperationsAuth {
resolve(request: FastifyRequest): Promise<RequestContext>;
require(request: FastifyRequest, permission: string): Promise<RequestContext>;
assertMutationOrigin(request: FastifyRequest): void;
}
interface PeriodQuery {
from?: string;
to?: string;
}
interface FinanceParams {
orgUnitId: string;
businessDate: string;
metricKey: string;
}
interface FinanceBody {
value_minor: number;
currency_code?: string;
}
interface ScheduleParams {
employeeId: string;
businessDate: string;
}
interface ScheduleBody {
org_unit_id: string;
planned_minutes: number;
start_time?: string;
end_time?: string;
note?: string;
}
interface ImportUnit {
code: string;
name: string;
external_ref?: string;
}
interface ImportFinanceEntry {
org_unit_code: string;
business_date: string;
metric_key: string;
value_minor: number;
currency_code?: string;
}
interface ImportEmployee {
external_ref: string;
personnel_number: string;
display_name: string;
position?: string;
}
interface ImportScheduleItem {
external_key: string;
employee_external_ref: string;
org_unit_code: string;
business_date: string;
planned_minutes: number;
location_code?: string;
}
interface ImportPayrollEntry {
external_key: string;
employee_external_ref: string;
business_date: string;
kind: 'accrual' | 'payout' | 'adjustment';
amount_minor: number;
note?: string;
}
interface OwnerWorkbookImportBody {
source_name: string;
units?: ImportUnit[];
financial_entries?: ImportFinanceEntry[];
employees?: ImportEmployee[];
schedule_items?: ImportScheduleItem[];
payroll_entries?: ImportPayrollEntry[];
}
export async function registerOperationsRoutes(
api: FastifyInstance,
database: Database,
auth: OperationsAuth,
) {
api.get('/org-units', async (request) => {
const context = await auth.resolve(request);
const canManage = context.permissions.has('staff:org_unit:read');
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const rows = await client.query(
`SELECT DISTINCT u.id, u.code, u.name, u.timezone
FROM staff_org_units u
WHERE u.deleted_at IS NULL
AND (
$1::boolean
OR EXISTS (
SELECT 1
FROM staff_employees e
WHERE e.tenant_id = u.tenant_id
AND e.primary_org_unit_id = u.id
AND e.core_user_id = $2
)
)
ORDER BY u.name`,
[canManage, context.actorId],
);
return { org_units: rows.rows };
});
});
api.get<{ Querystring: PeriodQuery }>('/finance', {
schema: { querystring: periodSchema },
}, async (request) => {
const context = await auth.require(request, 'staff:metric:read');
const period = parsePeriod(request.query);
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const entries = await client.query(
`SELECT d.id, d.org_unit_id, u.code AS org_unit_code, u.name AS org_unit_name,
d.business_date, d.metric_key, m.label, d.value::text AS value_minor,
d.currency_code
FROM staff_daily_metrics d
JOIN staff_org_units u ON u.tenant_id = d.tenant_id AND u.id = d.org_unit_id
JOIN staff_metric_definitions m
ON m.tenant_id = d.tenant_id AND m.key = d.metric_key
WHERE d.business_date BETWEEN $1 AND $2
AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\'
OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\')
ORDER BY d.business_date, u.name, d.metric_key`,
[period.from, period.to],
);
const daily = await client.query(
`SELECT d.business_date, d.org_unit_id, u.name AS org_unit_name,
COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'income\\_%' ESCAPE '\\'), 0)::text AS income_minor,
COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'expense\\_%' ESCAPE '\\'), 0)::text AS expense_minor
FROM staff_daily_metrics d
JOIN staff_org_units u ON u.tenant_id = d.tenant_id AND u.id = d.org_unit_id
WHERE d.business_date BETWEEN $1 AND $2
AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\'
OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\')
GROUP BY d.business_date, d.org_unit_id, u.name
ORDER BY d.business_date, u.name`,
[period.from, period.to],
);
const monthly = await client.query(
`SELECT to_char(date_trunc('month', d.business_date), 'YYYY-MM') AS month,
d.org_unit_id, u.name AS org_unit_name,
COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'income\\_%' ESCAPE '\\'), 0)::text AS income_minor,
COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'expense\\_%' ESCAPE '\\'), 0)::text AS expense_minor
FROM staff_daily_metrics d
JOIN staff_org_units u ON u.tenant_id = d.tenant_id AND u.id = d.org_unit_id
WHERE d.business_date BETWEEN $1 AND $2
AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\'
OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\')
GROUP BY date_trunc('month', d.business_date), d.org_unit_id, u.name
ORDER BY month, u.name`,
[period.from, period.to],
);
return {
period,
categories: financialMetricDefinitions.map(([key, label, kind]) => ({ key, label, kind })),
entries: entries.rows,
daily: daily.rows,
monthly: monthly.rows,
};
});
});
api.put<{ Params: FinanceParams; Body: FinanceBody }>(
'/finance/:orgUnitId/:businessDate/:metricKey',
{
schema: {
params: {
type: 'object', additionalProperties: false,
required: ['orgUnitId', 'businessDate', 'metricKey'],
properties: {
orgUnitId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
businessDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
metricKey: { type: 'string', pattern: '^[a-z][a-z0-9_]{0,60}$' },
},
},
body: {
type: 'object', additionalProperties: false, required: ['value_minor'],
properties: {
value_minor: { type: 'integer', minimum: 0, maximum: 9_000_000_000_000_000 },
currency_code: { type: 'string', pattern: '^[A-Z]{3}$' },
},
},
},
},
async (request, reply) => {
auth.assertMutationOrigin(request);
const context = await auth.require(request, 'staff:metric:write');
const orgUnitId = parseUlid(request.params.orgUnitId);
const businessDate = parseDate(request.params.businessDate);
if (!businessDate || !financialMetricKeys.has(request.params.metricKey)) {
return reply.badRequest('Неизвестная дата или категория.');
}
const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
await ensureMetricDefinitions(client, context.tenantId);
const unit = await client.query(
'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL',
[context.tenantId, orgUnitId],
);
if (unit.rowCount === 0) return null;
const saved = await client.query(
`INSERT INTO staff_daily_metrics
(id, tenant_id, org_unit_id, business_date, metric_key, value,
currency_code, source, recorded_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'manual', $8)
ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key)
DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code,
source = 'manual', recorded_by = EXCLUDED.recorded_by, updated_at = now()
RETURNING id, org_unit_id, business_date, metric_key,
value::text AS value_minor, currency_code`,
[newUlid(), context.tenantId, orgUnitId, request.params.businessDate,
request.params.metricKey, request.body.value_minor,
request.body.currency_code ?? 'RUB', context.actorId],
);
await refreshRevenueMetric(client, context.tenantId, context.actorId,
orgUnitId, request.params.businessDate);
const savedRow = saved.rows[0] as Record<string, unknown> | undefined;
await writeAudit(client, context, 'finance.metric.saved', 'DailyMetric',
String(savedRow?.id), savedRow ?? {});
return savedRow;
});
if (!row) return reply.notFound('Торговая точка не найдена.');
return reply.send(row);
},
);
api.get<{ Querystring: PeriodQuery }>('/schedule', {
schema: { querystring: periodSchema },
}, async (request, reply) => {
const context = await auth.resolve(request);
const canManage = context.permissions.has('staff:attendance:manage');
if (!canManage && !context.permissions.has('staff:shift:read')) {
return reply.forbidden('Недостаточно прав для просмотра графика.');
}
const period = parsePeriod(request.query);
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const rows = await client.query(
`SELECT w.id, w.employee_id, e.display_name, e.position, w.org_unit_id,
u.name AS org_unit_name, w.business_date,
COALESCE((w.attributes->>'planned_minutes')::integer, 0) AS planned_minutes,
w.attributes->>'start_time' AS start_time,
w.attributes->>'end_time' AS end_time,
w.attributes->>'note' AS note,
s.started_at AS actual_started_at, s.ended_at AS actual_ended_at,
s.worked_minutes AS actual_minutes, s.status AS actual_status,
s.anomaly AS actual_anomaly
FROM staff_work_items w
JOIN staff_employees e ON e.tenant_id = w.tenant_id AND e.id = w.employee_id
JOIN staff_org_units u ON u.tenant_id = w.tenant_id AND u.id = w.org_unit_id
LEFT JOIN staff_work_sessions s
ON s.tenant_id = w.tenant_id AND s.employee_id = w.employee_id
AND s.org_unit_id = w.org_unit_id AND s.business_date = w.business_date
WHERE w.item_kind = 'planned_shift'
AND w.business_date BETWEEN $1 AND $2
AND ($3::boolean OR e.core_user_id = $4)
ORDER BY w.business_date, e.display_name`,
[period.from, period.to, canManage, context.actorId],
);
return { period, shifts: rows.rows };
});
});
api.put<{ Params: ScheduleParams; Body: ScheduleBody }>(
'/schedule/:employeeId/:businessDate',
{
schema: {
params: {
type: 'object', additionalProperties: false,
required: ['employeeId', 'businessDate'],
properties: {
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
businessDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
},
},
body: {
type: 'object', additionalProperties: false,
required: ['org_unit_id', 'planned_minutes'],
properties: {
org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
planned_minutes: { type: 'integer', minimum: 0, maximum: 1440 },
start_time: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' },
end_time: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' },
note: { type: 'string', maxLength: 500 },
},
},
},
},
async (request, reply) => {
auth.assertMutationOrigin(request);
const context = await auth.require(request, 'staff:shift:confirm');
const employeeId = parseUlid(request.params.employeeId);
const orgUnitId = parseUlid(request.body.org_unit_id);
if (!parseDate(request.params.businessDate)) return reply.badRequest('Некорректная дата.');
const externalKey = `schedule:${employeeId}:${request.params.businessDate}`;
const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
if (request.body.planned_minutes === 0) {
const removed = await client.query(
`DELETE FROM staff_work_items
WHERE tenant_id = $1 AND external_key = $2 RETURNING id`,
[context.tenantId, externalKey],
);
const removedRow = removed.rows[0] as { id?: string } | undefined;
await writeAudit(client, context, 'schedule.shift.removed', 'WorkItem',
String(removedRow?.id ?? employeeId), { business_date: request.params.businessDate });
return { removed: (removed.rowCount ?? 0) > 0 };
}
const exists = await client.query(
`SELECT e.id
FROM staff_employees e
JOIN staff_org_units u ON u.tenant_id = e.tenant_id
WHERE e.tenant_id = $1 AND e.id = $2 AND u.id = $3
AND e.status = 'active' AND u.deleted_at IS NULL`,
[context.tenantId, employeeId, orgUnitId],
);
if (exists.rowCount === 0) return null;
const attributes = {
planned_minutes: request.body.planned_minutes,
start_time: request.body.start_time ?? null,
end_time: request.body.end_time ?? null,
note: request.body.note?.trim() ?? '',
};
const saved = await client.query(
`INSERT INTO staff_work_items
(id, tenant_id, employee_id, org_unit_id, business_date, item_kind,
attributes, source, external_key, created_by)
VALUES ($1, $2, $3, $4, $5, 'planned_shift', $6, 'manual', $7, $8)
ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL
DO UPDATE SET org_unit_id = EXCLUDED.org_unit_id,
attributes = EXCLUDED.attributes, source = 'manual'
RETURNING id, employee_id, org_unit_id, business_date, attributes`,
[newUlid(), context.tenantId, employeeId, orgUnitId,
request.params.businessDate, attributes, externalKey, context.actorId],
);
const savedRow = saved.rows[0] as Record<string, unknown> | undefined;
await writeAudit(client, context, 'schedule.shift.saved', 'WorkItem',
String(savedRow?.id), savedRow ?? {});
return savedRow;
});
if (!result) return reply.notFound('Сотрудник или торговая точка не найдены.');
return reply.send(result);
},
);
api.get<{ Querystring: PeriodQuery }>('/payroll', {
schema: { querystring: periodSchema },
}, async (request, reply) => {
const context = await auth.resolve(request);
const canManage = context.permissions.has('staff:payroll:read');
if (!canManage && !context.permissions.has('staff:payslip:read_own')) {
return reply.forbidden('Недостаточно прав для просмотра зарплаты.');
}
const period = parsePeriod(request.query);
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const totals = await client.query(
`WITH ledger AS (
SELECT employee_id,
COALESCE(SUM(amount_minor) FILTER (WHERE kind IN ('accrual', 'adjustment')), 0) AS accrued,
COALESCE(SUM(-amount_minor) FILTER (WHERE kind = 'payout'), 0) AS paid,
COALESCE(SUM(amount_minor), 0) AS balance
FROM staff_payroll_ledger
WHERE business_date BETWEEN $1 AND $2
GROUP BY employee_id
), worked AS (
SELECT employee_id, COALESCE(SUM(worked_minutes), 0) AS worked_minutes
FROM staff_work_sessions
WHERE business_date BETWEEN $1 AND $2 AND status = 'confirmed'
GROUP BY employee_id
)
SELECT e.id AS employee_id, e.display_name, e.position,
COALESCE(l.accrued, 0)::text AS accrued_minor,
COALESCE(l.paid, 0)::text AS paid_minor,
COALESCE(l.balance, 0)::text AS balance_minor,
COALESCE(w.worked_minutes, 0)::integer AS worked_minutes
FROM staff_employees e
LEFT JOIN ledger l ON l.employee_id = e.id
LEFT JOIN worked w ON w.employee_id = e.id
WHERE e.status <> 'dismissed' AND ($3::boolean OR e.core_user_id = $4)
ORDER BY e.display_name`,
[period.from, period.to, canManage, context.actorId],
);
const entries = await client.query(
`SELECT l.id, l.employee_id, e.display_name, l.business_date, l.kind,
l.amount_minor::text AS amount_minor, l.currency_code, l.note, l.created_at
FROM staff_payroll_ledger l
JOIN staff_employees e ON e.tenant_id = l.tenant_id AND e.id = l.employee_id
WHERE l.business_date BETWEEN $1 AND $2
AND ($3::boolean OR e.core_user_id = $4)
ORDER BY l.business_date DESC, e.display_name`,
[period.from, period.to, canManage, context.actorId],
);
return { period, totals: totals.rows, entries: entries.rows };
});
});
api.post<{ Body: OwnerWorkbookImportBody }>('/imports/owner-workbook', {
schema: {
body: {
type: 'object', additionalProperties: false, required: ['source_name'],
properties: {
source_name: { type: 'string', minLength: 1, maxLength: 240 },
units: { type: 'array', maxItems: 50, items: { type: 'object' } },
financial_entries: { type: 'array', maxItems: 500, items: { type: 'object' } },
employees: { type: 'array', maxItems: 250, items: { type: 'object' } },
schedule_items: { type: 'array', maxItems: 500, items: { type: 'object' } },
payroll_entries: { type: 'array', maxItems: 500, items: { type: 'object' } },
},
},
},
}, async (request, reply) => {
auth.assertMutationOrigin(request);
const context = await auth.require(request, 'staff:metric:write');
const body = request.body;
if (!validateImport(body)) return reply.badRequest('Пакет импорта содержит некорректные данные.');
const counts = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
await ensureMetricDefinitions(client, context.tenantId);
const unitIds = new Map<string, string>();
for (const unit of body.units ?? []) {
const saved = await client.query(
`INSERT INTO staff_org_units (id, tenant_id, code, name, external_ref)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL
DO UPDATE SET name = EXCLUDED.name,
external_ref = COALESCE(staff_org_units.external_ref, EXCLUDED.external_ref),
updated_at = now()
RETURNING id`,
[newUlid(), context.tenantId, unit.code, unit.name, unit.external_ref ?? null],
);
const savedRow = saved.rows[0] as { id?: string } | undefined;
unitIds.set(unit.code, String(savedRow?.id));
}
for (const code of new Set([
...(body.financial_entries ?? []).map((entry) => entry.org_unit_code),
...(body.schedule_items ?? []).map((entry) => entry.org_unit_code),
])) {
if (unitIds.has(code)) continue;
const row = await client.query(
'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND code = $2 AND deleted_at IS NULL',
[context.tenantId, code],
);
if (row.rowCount === 0) throw new Error(`Unknown organization unit: ${code}`);
const found = row.rows[0] as { id?: string } | undefined;
unitIds.set(code, String(found?.id));
}
const employeeIds = new Map<string, string>();
for (const employee of body.employees ?? []) {
const saved = await client.query(
`INSERT INTO staff_employees
(id, tenant_id, personnel_number, display_name, position, status, external_ref)
VALUES ($1, $2, $3, $4, $5, 'active', $6)
ON CONFLICT (tenant_id, personnel_number)
DO UPDATE SET external_ref = COALESCE(staff_employees.external_ref, EXCLUDED.external_ref),
position = CASE WHEN staff_employees.position = '' THEN EXCLUDED.position
ELSE staff_employees.position END,
updated_at = now()
RETURNING id`,
[newUlid(), context.tenantId, employee.personnel_number,
employee.display_name, employee.position?.trim() ?? '', employee.external_ref],
);
const savedRow = saved.rows[0] as { id?: string } | undefined;
employeeIds.set(employee.external_ref, String(savedRow?.id));
}
for (const externalRef of new Set([
...(body.schedule_items ?? []).map((entry) => entry.employee_external_ref),
...(body.payroll_entries ?? []).map((entry) => entry.employee_external_ref),
])) {
if (employeeIds.has(externalRef)) continue;
const row = await client.query(
'SELECT id FROM staff_employees WHERE tenant_id = $1 AND external_ref = $2',
[context.tenantId, externalRef],
);
if (row.rowCount === 0) throw new Error(`Unknown employee: ${externalRef}`);
const found = row.rows[0] as { id?: string } | undefined;
employeeIds.set(externalRef, String(found?.id));
}
const affectedRevenueDays = new Set<string>();
for (const entry of body.financial_entries ?? []) {
const orgUnitId = unitIds.get(entry.org_unit_code)!;
await client.query(
`INSERT INTO staff_daily_metrics
(id, tenant_id, org_unit_id, business_date, metric_key, value,
currency_code, breakdown, source, recorded_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'import', $9)
ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key)
DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code,
breakdown = EXCLUDED.breakdown, source = 'import',
recorded_by = EXCLUDED.recorded_by, updated_at = now()`,
[newUlid(), context.tenantId, orgUnitId, entry.business_date, entry.metric_key,
entry.value_minor, entry.currency_code ?? 'RUB',
{ source_name: body.source_name }, context.actorId],
);
if (entry.metric_key.startsWith('income_')) {
affectedRevenueDays.add(`${orgUnitId}|${entry.business_date}`);
}
}
for (const key of affectedRevenueDays) {
const [orgUnitId, businessDate] = key.split('|') as [string, string];
await refreshRevenueMetric(client, context.tenantId, context.actorId,
orgUnitId, businessDate);
}
for (const shift of body.schedule_items ?? []) {
const attributes = {
planned_minutes: shift.planned_minutes,
location_code: shift.location_code ?? '',
imported_from: body.source_name,
};
await client.query(
`INSERT INTO staff_work_items
(id, tenant_id, employee_id, org_unit_id, business_date, item_kind,
attributes, source, external_key, created_by)
VALUES ($1, $2, $3, $4, $5, 'planned_shift', $6, 'import', $7, $8)
ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL
DO UPDATE SET employee_id = EXCLUDED.employee_id,
org_unit_id = EXCLUDED.org_unit_id,
business_date = EXCLUDED.business_date,
attributes = EXCLUDED.attributes, source = 'import'`,
[newUlid(), context.tenantId, employeeIds.get(shift.employee_external_ref),
unitIds.get(shift.org_unit_code), shift.business_date, attributes,
shift.external_key, context.actorId],
);
}
for (const entry of body.payroll_entries ?? []) {
const amount = entry.kind === 'payout'
? -Math.abs(entry.amount_minor)
: entry.amount_minor;
await client.query(
`INSERT INTO staff_payroll_ledger
(id, tenant_id, employee_id, business_date, kind, amount_minor,
currency_code, ref_type, correlation_id, note, created_by, external_key)
VALUES ($1, $2, $3, $4, $5, $6, 'RUB', 'manual', $7, $8, $9, $10)
ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING`,
[newUlid(), context.tenantId, employeeIds.get(entry.employee_external_ref),
entry.business_date, entry.kind, amount, context.correlationId,
entry.note?.trim() ?? `Импорт: ${body.source_name}`,
context.actorId, entry.external_key],
);
}
const imported = {
units: body.units?.length ?? 0,
employees: body.employees?.length ?? 0,
financial_entries: body.financial_entries?.length ?? 0,
schedule_items: body.schedule_items?.length ?? 0,
payroll_entries: body.payroll_entries?.length ?? 0,
};
await writeAudit(client, context, 'owner_workbook.imported', 'ImportBatch',
newUlid(), { source_name: body.source_name, ...imported });
return imported;
});
return reply.code(202).send(counts);
});
}
const periodSchema = {
type: 'object',
additionalProperties: false,
properties: {
from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
},
} as const;
function parsePeriod(query: PeriodQuery) {
const now = new Date();
const first = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const last = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
const from = query.from ?? first.toISOString().slice(0, 10);
const to = query.to ?? last.toISOString().slice(0, 10);
if (!parseDate(from) || !parseDate(to) || from > to) throw new Error('Некорректный период.');
return { from, to };
}
function parseDate(value: string): Date | null {
if (!datePattern.test(value)) return null;
const parsed = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value
? null
: parsed;
}
async function ensureMetricDefinitions(client: PoolClient, tenantId: string) {
for (const [key, label] of financialMetricDefinitions) {
await client.query(
`INSERT INTO staff_metric_definitions
(id, tenant_id, key, label, value_kind, source)
VALUES ($1, $2, $3, $4, 'money', 'manual')
ON CONFLICT (tenant_id, key) DO UPDATE SET label = EXCLUDED.label, is_active = TRUE`,
[newUlid(), tenantId, key, label],
);
}
await client.query(
`INSERT INTO staff_metric_definitions
(id, tenant_id, key, label, value_kind, source)
VALUES ($1, $2, 'revenue_minor', 'Общая выручка', 'money', 'module')
ON CONFLICT (tenant_id, key) DO UPDATE SET label = EXCLUDED.label, is_active = TRUE`,
[newUlid(), tenantId],
);
}
async function refreshRevenueMetric(
client: PoolClient,
tenantId: string,
actorId: string,
orgUnitId: string,
businessDate: string,
) {
await client.query(
`INSERT INTO staff_daily_metrics
(id, tenant_id, org_unit_id, business_date, metric_key, value,
currency_code, source, recorded_by)
SELECT $1, $2, $3, $4, 'revenue_minor',
COALESCE(SUM(value) FILTER (WHERE metric_key LIKE 'income\\_%' ESCAPE '\\'), 0),
'RUB', 'module', $5
FROM staff_daily_metrics
WHERE tenant_id = $2 AND org_unit_id = $3 AND business_date = $4
ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key)
DO UPDATE SET value = EXCLUDED.value, source = 'module',
recorded_by = EXCLUDED.recorded_by, updated_at = now()`,
[newUlid(), tenantId, orgUnitId, businessDate, actorId],
);
}
async function writeAudit(
client: PoolClient,
context: RequestContext,
action: string,
entityType: string,
entityId: string,
values: unknown,
) {
await client.query(
`INSERT INTO staff_audit_log
(id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[newUlid(), context.tenantId, context.actorId, action, entityType,
entityId, values, context.correlationId],
);
}
function validateImport(body: OwnerWorkbookImportBody): boolean {
if (!body.source_name.trim()) return false;
for (const unit of body.units ?? []) {
if (!/^[a-z0-9_-]{1,40}$/.test(unit.code) || !unit.name.trim()) return false;
}
for (const entry of body.financial_entries ?? []) {
if (!financialMetricKeys.has(entry.metric_key)
|| !parseDate(entry.business_date)
|| !Number.isSafeInteger(entry.value_minor)
|| entry.value_minor < 0) return false;
}
for (const employee of body.employees ?? []) {
if (!employee.external_ref || !employee.personnel_number.trim() || !employee.display_name.trim()) {
return false;
}
}
for (const shift of body.schedule_items ?? []) {
if (!shift.external_key || !parseDate(shift.business_date)
|| !Number.isInteger(shift.planned_minutes)
|| shift.planned_minutes < 0 || shift.planned_minutes > 1440) return false;
}
for (const entry of body.payroll_entries ?? []) {
if (!entry.external_key || !parseDate(entry.business_date)
|| !Number.isSafeInteger(entry.amount_minor)) return false;
}
return true;
}

View file

@ -14,10 +14,10 @@ export const portalHtml = `<!doctype html>
<div class="auth-card">
<div class="auth-brand"><span class="mark">К</span><span>Krona <b>Staff</b></span></div>
<p class="eyebrow">Защищённый кабинет</p>
<h1 id="login-title">Вход администратора</h1>
<p class="auth-copy">Введите ключ администратора. Сотрудники, отметки и табель скрыты до успешного входа.</p>
<h1 id="login-title">Вход в Krona Staff</h1>
<p class="auth-copy">Введите ключ руководителя или личный ключ сотрудника. После входа будут доступны только разрешённые вам данные.</p>
<form id="login-form">
<label for="login-token">Ключ администратора</label>
<label for="login-token">Ключ доступа</label>
<input id="login-token" type="password" autocomplete="current-password" required minlength="32" autofocus>
<button id="login-submit" type="submit">Войти</button>
</form>
@ -30,7 +30,15 @@ export const portalHtml = `<!doctype html>
<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>
</header>
<nav class="section-nav" aria-label="Разделы кабинета">
<button class="nav-item selected" type="button" data-target="overview-view">Обзор</button>
<button class="nav-item manager-only" type="button" data-target="finance-view">Доходы и расходы</button>
<button class="nav-item" type="button" data-target="schedule-view">Календарь</button>
<button class="nav-item" type="button" data-target="payroll-view">Зарплата</button>
<label class="period-control">Месяц <input id="period-month" type="month" aria-label="Месяц данных"></label>
</nav>
<main>
<div id="overview-view" class="app-view">
<section class="hero">
<div><p class="eyebrow" id="viewer">Личный кабинет</p><h1 id="greeting">Рабочий день — под контролем</h1><p>Отметки терминала, сотрудники и табель в одном защищённом пространстве.</p></div>
<div class="today"><span>Сегодня</span><strong id="today">—</strong><small id="last-refresh">Данные загружаются</small></div>
@ -47,8 +55,27 @@ export const portalHtml = `<!doctype html>
</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>
</div>
<div id="finance-view" class="app-view" hidden>
<section class="view-head"><div><p class="eyebrow">Управленческий учёт</p><h1>Доходы и расходы</h1><p>Выручка по каналам, расходы по категориям и прибыль за выбранный месяц.</p></div><button id="finance-refresh" class="quiet" type="button">Обновить</button></section>
<section class="finance-stats stats" aria-label="Финансовая сводка"><article><span>Доход</span><strong id="finance-income">—</strong></article><article><span>Расход</span><strong id="finance-expense">—</strong></article><article><span>Прибыль</span><strong id="finance-profit">—</strong></article></section>
<section class="finance-layout"><article class="panel chart-panel"><div class="panel-head"><div><p class="eyebrow">Динамика по дням</p><h2>Выручка и расходы</h2></div><span>₽</span></div><div id="finance-chart" class="finance-chart" role="img" aria-label="График доходов и расходов"></div></article><aside class="panel manager-only"><div class="panel-head"><div><p class="eyebrow">Новая операция</p><h2>Внести сумму</h2></div></div><form id="finance-form" class="stack-form"><label>Дата<input id="finance-date" type="date" required></label><label>Торговая точка<select id="finance-unit" required></select></label><label>Категория<select id="finance-category" required></select></label><label>Сумма, ₽<input id="finance-amount" type="number" inputmode="decimal" min="0" step="0.01" required></label><button type="submit">Сохранить</button></form></aside></section>
<section class="panel"><div class="panel-head"><div><p class="eyebrow">Операции</p><h2>Данные за месяц</h2></div><span id="finance-count">0 записей</span></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Точка</th><th>Категория</th><th>Тип</th><th>Сумма</th></tr></thead><tbody id="finance-entries"></tbody></table></div></section>
</div>
<div id="schedule-view" class="app-view" hidden>
<section class="view-head"><div><p class="eyebrow">План и факт</p><h1>Календарь смен</h1><p>Плановые часы рядом с фактическими приходами и уходами со сканера.</p></div><button id="schedule-refresh" class="quiet" type="button">Обновить</button></section>
<section class="panel manager-only"><form id="schedule-form" class="inline-form"><label>Сотрудник<select id="schedule-employee" required></select></label><label>Точка<select id="schedule-unit" required></select></label><label>Дата<input id="schedule-date" type="date" required></label><label>Часы<input id="schedule-hours" type="number" inputmode="decimal" min="0" max="24" step="0.5" value="12" required></label><label>Начало<input id="schedule-start" type="time" value="10:00"></label><label>Конец<input id="schedule-end" type="time" value="22:00"></label><button type="submit">Запланировать</button></form></section>
<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="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>
<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>
<script src="/staff/app.js" defer></script>
@ -92,5 +119,34 @@ 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 + `
.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}}
`;
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 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 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)}
function drawChart(rows){const sums={};rows.forEach(x=>{const key=String(x.business_date).slice(0,10);sums[key]||={income:0,expense:0};sums[key].income+=Number(x.income_minor);sums[key].expense+=Number(x.expense_minor)});const points=Object.entries(sums).sort((a,b)=>a[0].localeCompare(b[0]));if(!points.length){byId('finance-chart').innerHTML='<div class="empty">Добавьте выручку или расходы — здесь появится график</div>';return}const width=900,height=250,pad=28,max=Math.max(1,...points.flatMap(x=>[x[1].income,x[1].expense])),x=i=>pad+(points.length===1?0:i*(width-pad*2)/(points.length-1)),y=v=>height-pad-v*(height-pad*2)/max,path=kind=>points.map((p,i)=>(i?'L':'M')+x(i).toFixed(1)+' '+y(p[1][kind]).toFixed(1)).join(' ');byId('finance-chart').innerHTML='<svg viewBox="0 0 '+width+' '+height+'" aria-hidden="true"><path d="'+path('income')+'" fill="none" stroke="#657255" stroke-width="4"/><path d="'+path('expense')+'" fill="none" stroke="#B78335" stroke-width="4"/></svg><div class="chart-legend"><span><i class="legend-dot income-dot"></i>Доход</span><span><i class="legend-dot expense-dot"></i>Расход</span></div>'}
async function loadSchedule(){const data=await get('/schedule'+query()),p=period(),start=new Date(p.from+'T00:00:00Z'),end=new Date(p.to+'T00:00:00Z'),first=new Date(start),last=new Date(end),grouped={};first.setUTCDate(first.getUTCDate()-((first.getUTCDay()+6)%7));last.setUTCDate(last.getUTCDate()+(7-last.getUTCDay())%7);(data.shifts||[]).forEach(x=>(grouped[String(x.business_date).slice(0,10)]||=[]).push(x));let html='';for(const d=new Date(first);d<=last;d.setUTCDate(d.getUTCDate()+1)){const key=d.toISOString().slice(0,10),inside=key>=p.from&&key<=p.to,today=key===new Date().toISOString().slice(0,10);html+='<article class="calendar-day '+(!inside?'outside ':'')+(today?'today':'')+'"><span class="day-number">'+d.getUTCDate()+'</span>'+((grouped[key]||[]).map(x=>'<div class="shift-card"><strong>'+esc(x.display_name)+'</strong><span>'+esc(x.org_unit_name)+' · план '+hours(x.planned_minutes)+' ч</span>'+(x.actual_minutes!==null?'<span class="actual">факт '+hours(x.actual_minutes)+' ч'+(x.actual_started_at?' · '+new Date(x.actual_started_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}):'')+(x.actual_ended_at?'–'+new Date(x.actual_ended_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}):'')+'</span>':'<span>факт: нет отметки</span>')+'</div>').join(''))+'</article>'}byId('calendar-grid').innerHTML=html;fillSelects()}
async function loadPayroll(){const data=await get('/payroll'+query()),accrued=data.totals.reduce((s,x)=>s+Number(x.accrued_minor),0),paid=data.totals.reduce((s,x)=>s+Number(x.paid_minor),0),balance=data.totals.reduce((s,x)=>s+Number(x.balance_minor),0);byId('payroll-accrued').textContent=rub(accrued);byId('payroll-paid').textContent=rub(paid);byId('payroll-balance').textContent=rub(balance);byId('payroll-rows').innerHTML=data.totals.length?data.totals.map(x=>'<tr><td>'+esc(x.display_name)+'</td><td>'+esc(x.position||'—')+'</td><td>'+hours(x.worked_minutes)+'</td><td>'+rub(x.accrued_minor)+'</td><td>'+rub(x.paid_minor)+'</td><td class="'+(Number(x.balance_minor)>=0?'positive':'negative')+'">'+rub(x.balance_minor)+'</td></tr>').join(''):'<tr><td colspan="6" class="empty">Нет данных для расчёта</td></tr>';byId('payroll-entries').innerHTML=data.entries.length?data.entries.map(x=>'<tr><td>'+esc(x.business_date)+'</td><td>'+esc(x.display_name)+'</td><td>'+({accrual:'Начисление',payout:'Выплата',adjustment:'Корректировка'}[x.kind]||esc(x.kind))+'</td><td>'+rub(Math.abs(Number(x.amount_minor)))+'</td><td>'+esc(x.note||'—')+'</td></tr>').join(''):'<tr><td colspan="5" class="empty">Операций пока нет</td></tr>'}
document.querySelectorAll('.nav-item').forEach(button=>button.addEventListener('click',()=>show(button.dataset.target)));byId('period-month').value=new Date().toISOString().slice(0,7);byId('period-month').addEventListener('change',loadActive);byId('finance-refresh').addEventListener('click',loadFinance);byId('schedule-refresh').addEventListener('click',loadSchedule);byId('payroll-refresh').addEventListener('click',loadPayroll);byId('finance-date').value=new Date().toISOString().slice(0,10);byId('schedule-date').value=new Date().toISOString().slice(0,10);
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='Скопировано'});
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 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');

View file

@ -1,3 +1,4 @@
import { createHash, randomBytes } from 'node:crypto';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { AppConfig } from './config.js';
import type { RequestContext } from './core/context.js';
@ -6,6 +7,7 @@ import type { IdentityPort } from './core/ports.js';
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
import type { Database } from './infrastructure/database.js';
import { reconcileWorkSession } from './domain/attendance/work-session.js';
import { registerOperationsRoutes } from './operations.js';
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
@ -68,9 +70,10 @@ export async function registerPortalRoutes(
config: AppConfig,
database: Database,
) {
const identity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform'
const baseIdentity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform'
? new PlatformIdentityAdapter()
: new StandaloneIdentityAdapter(config);
const identity = withStandaloneEmployeeAccess(baseIdentity, config, database);
api.post<{ Body: SessionBody }>('/session', {
schema: {
@ -86,13 +89,16 @@ export async function registerPortalRoutes(
return reply.serviceUnavailable('Вход администратора ещё не настроен.');
}
const context = await resolveToken(identity, request.body.access_token, request);
if (!context.permissions.has('staff:attendance:manage')) {
return reply.forbidden('Для входа требуется ключ администратора.');
const role = context.permissions.has('staff:attendance:manage') ? 'manager'
: context.permissions.has('staff:attendance:read_own') ? 'employee'
: null;
if (!role) {
return reply.forbidden('Ключ не подходит для входа в кабинет.');
}
reply
.header('cache-control', 'no-store')
.header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds));
return { role: 'manager', expires_in: sessionMaxAgeSeconds };
return { role, expires_in: sessionMaxAgeSeconds };
});
api.delete('/session', async (request, reply) => {
@ -276,6 +282,63 @@ export async function registerPortalRoutes(
return reply.send(outcome.employee);
});
api.post<{ Params: { employeeId: string } }>('/employees/:employeeId/access-token', {
schema: {
params: { type: 'object', additionalProperties: false, required: ['employeeId'], properties: {
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
} },
},
}, async (request, reply) => {
assertCookieMutationOrigin(request);
if (config.STAFF_RUNTIME_MODE !== 'standalone') {
return reply.conflict('В платформенном режиме доступом сотрудников управляет Core SSO.');
}
const context = await requirePermission(identity, request, 'staff:employee:write');
const employeeId = parseUlid(request.params.employeeId);
const accessToken = randomBytes(32).toString('base64url');
const tokenHash = hashAccessToken(accessToken);
const tokenHint = accessToken.slice(-6);
const saved = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const employee = await client.query(
`SELECT id, core_user_id, display_name FROM staff_employees
WHERE tenant_id = $1 AND id = $2 AND status = 'active' FOR UPDATE`,
[context.tenantId, employeeId],
);
if (employee.rowCount === 0) return null;
const employeeRow = employee.rows[0] as {
core_user_id?: string | null;
display_name?: string;
} | undefined;
const subjectId = String(employeeRow?.core_user_id ?? newUlid());
await client.query(
`UPDATE staff_employees SET core_user_id = $3, updated_at = now()
WHERE tenant_id = $1 AND id = $2`,
[context.tenantId, employeeId, subjectId],
);
await client.query(
`INSERT INTO staff_standalone_employee_access
(id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (tenant_id, employee_id)
DO UPDATE SET subject_id = EXCLUDED.subject_id, token_hash = EXCLUDED.token_hash,
token_hint = EXCLUDED.token_hint, created_by = EXCLUDED.created_by,
rotated_at = now()`,
[newUlid(), context.tenantId, employeeId, subjectId, tokenHash, tokenHint, context.actorId],
);
await client.query(
`INSERT INTO staff_audit_log
(id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id)
VALUES ($1, $2, $3, 'employee.access.rotated', 'Employee', $4, $5, $6)`,
[newUlid(), context.tenantId, context.actorId, employeeId,
{ subject_id: subjectId, token_hint: tokenHint }, context.correlationId],
);
return { employee_id: employeeId, display_name: employeeRow?.display_name,
subject_id: subjectId, token_hint: tokenHint };
});
if (!saved) return reply.notFound('Активный сотрудник не найден.');
return reply.send({ ...saved, access_token: accessToken });
});
api.post<{ Body: AttendanceBody }>('/attendance/events', {
schema: {
body: {
@ -637,6 +700,59 @@ export async function registerPortalRoutes(
return reply.send(row);
},
);
await registerOperationsRoutes(api, database, {
resolve: (request) => resolveContext(identity, request),
require: (request, permission) => requirePermission(identity, request, permission),
assertMutationOrigin: assertCookieMutationOrigin,
});
}
function withStandaloneEmployeeAccess(
baseIdentity: IdentityPort,
config: AppConfig,
database: Database,
): IdentityPort {
if (config.STAFF_RUNTIME_MODE !== 'standalone' || !config.STANDALONE_MANAGER_TOKEN) {
return baseIdentity;
}
return {
async resolve(token, correlationId) {
try {
return await baseIdentity.resolve(token, correlationId);
} catch (baseError) {
if (!token || token.length < 32) throw baseError;
const tenantId = parseUlid(config.STANDALONE_TENANT_ID);
const serviceActorId = parseUlid(config.STANDALONE_ACTOR_ID);
const tokenHash = hashAccessToken(token);
const row = await database.tenantTransaction(tenantId, serviceActorId, async (client) => {
const result = await client.query(
`SELECT subject_id
FROM staff_standalone_employee_access
WHERE tenant_id = $1 AND token_hash = $2`,
[tenantId, tokenHash],
);
return result.rows[0] as { subject_id?: string } | undefined;
});
if (!row?.subject_id) throw baseError;
return {
tenantId,
actorId: parseUlid(row.subject_id),
permissions: new Set([
'staff:health:read',
'staff:attendance:read_own',
'staff:shift:read',
'staff:payslip:read_own',
]),
correlationId,
};
}
},
};
}
function hashAccessToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
async function resolveContext(identity: IdentityPort, request: FastifyRequest): Promise<RequestContext> {

View file

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

View file

@ -24,7 +24,7 @@ describe('operational endpoints', () => {
mode: 'standalone',
});
expect((await app.inject('/live')).statusCode).toBe(404);
expect((await app.inject('/api/v1/staff/payroll')).statusCode).toBe(404);
expect((await app.inject('/api/v1/staff/not-a-route')).statusCode).toBe(404);
await app.close();
});