Compare commits
2 commits
0f530dc633
...
88804a0eea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88804a0eea | ||
|
|
057ec75b26 |
23 changed files with 1403 additions and 52 deletions
133
api/openapi.yaml
133
api/openapi.yaml
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
3
migrations/0008_employee_access.down.sql
Normal file
3
migrations/0008_employee_access.down.sql
Normal 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;
|
||||
32
migrations/0008_employee_access.up.sql
Normal file
32
migrations/0008_employee_access.up.sql
Normal 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;
|
||||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krona/staff",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
707
src/operations.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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');
|
||||
|
|
|
|||
124
src/portal.ts
124
src/portal.ts
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export const SERVICE_NAME = 'krona-staff';
|
||||
export const SERVICE_VERSION = '0.2.5';
|
||||
export const SERVICE_VERSION = '0.3.0';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue