Compare commits
10 commits
0f530dc633
...
f74aa6d7f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f74aa6d7f5 | ||
|
|
9ea9a004ed | ||
|
|
5b159f785c | ||
|
|
4fe2f8799a | ||
|
|
96a28f575f | ||
|
|
37da6f0418 | ||
|
|
c08a7e6af1 | ||
|
|
1007f929c2 | ||
|
|
88804a0eea | ||
|
|
057ec75b26 |
40 changed files with 5682 additions and 480 deletions
394
api/openapi.yaml
394
api/openapi.yaml
|
|
@ -1,7 +1,7 @@
|
|||
openapi: 3.1.0
|
||||
info:
|
||||
title: Krona Staff API
|
||||
version: 0.2.5
|
||||
version: 0.3.1
|
||||
description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя.
|
||||
license:
|
||||
name: Proprietary
|
||||
|
|
@ -107,6 +107,39 @@ paths:
|
|||
schema: { $ref: '#/components/schemas/Employee' }
|
||||
'403': { description: Недостаточно прав }
|
||||
'409': { description: Табельный номер уже используется }
|
||||
/professions:
|
||||
get:
|
||||
summary: Получить кадровый справочник профессий
|
||||
operationId: listStaffProfessions
|
||||
tags: [Employees]
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses:
|
||||
'200': { description: Tenant profession catalogue }
|
||||
'401': { description: Требуется авторизация }
|
||||
post:
|
||||
summary: Добавить профессию в кадровый справочник
|
||||
operationId: createStaffProfession
|
||||
tags: [Employees]
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses:
|
||||
'201': { description: Profession created }
|
||||
'403': { description: Недостаточно прав }
|
||||
'409': { description: Профессия уже существует }
|
||||
/professions/{professionId}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: professionId
|
||||
required: true
|
||||
schema: { type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }
|
||||
patch:
|
||||
summary: Изменить профессию или её статус
|
||||
operationId: updateStaffProfession
|
||||
tags: [Employees]
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses:
|
||||
'200': { description: Profession updated }
|
||||
'403': { description: Недостаточно прав }
|
||||
'404': { description: Профессия не найдена }
|
||||
/employees/{employeeId}:
|
||||
patch:
|
||||
summary: Изменить карточку или статус сотрудника
|
||||
|
|
@ -177,7 +210,8 @@ paths:
|
|||
date_to: { type: string, format: date }
|
||||
business_day_start_hour: { type: integer, minimum: 0, maximum: 23, default: 4 }
|
||||
automatic_close_hour: { type: integer, minimum: 0, maximum: 23, default: 2 }
|
||||
open_shift_threshold_minutes: { type: integer, minimum: 60, maximum: 2880, default: 840 }
|
||||
open_shift_threshold_minutes:
|
||||
{ type: integer, minimum: 60, maximum: 2880, default: 840 }
|
||||
responses:
|
||||
'200':
|
||||
description: Итоги сверки, включая события без назначенного подразделения
|
||||
|
|
@ -185,7 +219,14 @@ paths:
|
|||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [groups, sessions_created_or_updated, confirmed_sessions_skipped, anomalous_sessions, unresolved_events]
|
||||
required:
|
||||
[
|
||||
groups,
|
||||
sessions_created_or_updated,
|
||||
confirmed_sessions_skipped,
|
||||
anomalous_sessions,
|
||||
unresolved_events,
|
||||
]
|
||||
properties:
|
||||
groups: { type: integer, minimum: 0 }
|
||||
sessions_created_or_updated: { type: integer, minimum: 0 }
|
||||
|
|
@ -255,6 +296,300 @@ 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: Сотрудник не найден }
|
||||
/employees/{employeeId}/pay-assignment:
|
||||
get:
|
||||
summary: Получить зарплатный профиль сотрудника
|
||||
operationId: getEmployeePayAssignment
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: employeeId
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
responses:
|
||||
'200':
|
||||
description: Зарплатный профиль сотрудника
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/PayAssignment' }
|
||||
'403': { description: Недостаточно прав }
|
||||
'404': { description: Сотрудник или зарплатный профиль не найден }
|
||||
put:
|
||||
summary: Сохранить зарплатный профиль сотрудника
|
||||
operationId: saveEmployeePayAssignment
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: employeeId
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/PayAssignmentSave' }
|
||||
responses:
|
||||
'200':
|
||||
description: Зарплатный профиль сохранён
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/PayAssignment' }
|
||||
'403': { description: Недостаточно прав }
|
||||
'404': { description: 'Сотрудник, точка или схема не найдены' }
|
||||
/pay-rules:
|
||||
get:
|
||||
summary: Получить список схем расчёта зарплаты
|
||||
operationId: listStaffPayRules
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses:
|
||||
'200':
|
||||
description: Список доступных схем расчёта
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [pay_rules]
|
||||
properties:
|
||||
pay_rules:
|
||||
type: array
|
||||
items: { type: object }
|
||||
'403': { description: Недостаточно прав }
|
||||
/org-units:
|
||||
get:
|
||||
summary: Получить доступные торговые точки
|
||||
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: -9000000000000000, maximum: 9000000000000000 }
|
||||
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: Недостаточно прав }
|
||||
/payroll/calculate:
|
||||
post:
|
||||
summary: Рассчитать зарплату за период
|
||||
operationId: calculateStaffPayroll
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
month: { type: string, pattern: '^\\d{4}-\\d{2}$' }
|
||||
from: { type: string, format: date }
|
||||
to: { type: string, format: date }
|
||||
org_unit_id: { $ref: '#/components/schemas/Ulid' }
|
||||
responses:
|
||||
'200':
|
||||
description: Предварительный результат расчёта
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
[run_id, status, period_start, period_end, total_accrued_minor, totals, warnings]
|
||||
properties:
|
||||
run_id: { $ref: '#/components/schemas/Ulid' }
|
||||
status: { const: calculated }
|
||||
period_start: { type: string, format: date }
|
||||
period_end: { type: string, format: date }
|
||||
total_accrued_minor: { type: string }
|
||||
totals: { type: array, items: { type: object } }
|
||||
warnings: { type: array, items: { type: object } }
|
||||
'400': { description: Некорректный период }
|
||||
'403': { description: Недостаточно прав }
|
||||
'409': { description: Расчёт за этот период уже утверждён }
|
||||
/payroll/runs/{runId}/approve:
|
||||
post:
|
||||
summary: Утвердить расчёт зарплаты и записать начисления в реестр
|
||||
operationId: approveStaffPayrollRun
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: runId
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
responses:
|
||||
'200':
|
||||
description: Расчёт утверждён и проведён в реестр
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
[run_id, status, approved_by, approved_at, total_accrued_minor, entries_posted]
|
||||
properties:
|
||||
run_id: { $ref: '#/components/schemas/Ulid' }
|
||||
status: { const: approved }
|
||||
approved_by: { $ref: '#/components/schemas/Ulid' }
|
||||
approved_at: { type: string, format: date-time }
|
||||
total_accrued_minor: { type: string }
|
||||
entries_posted: { type: integer }
|
||||
'400': { description: Расчёт не в статусе calculated }
|
||||
'403': { description: Недостаточно прав }
|
||||
'404': { description: Расчёт не найден }
|
||||
/payroll/runs/{runId}:
|
||||
get:
|
||||
summary: Получить подробности расчёта зарплаты
|
||||
operationId: getStaffPayrollRun
|
||||
tags: [Payroll]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: runId
|
||||
required: true
|
||||
schema: { $ref: '#/components/schemas/Ulid' }
|
||||
responses:
|
||||
'200':
|
||||
description: Детали расчёта со строками
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [run, lines]
|
||||
properties:
|
||||
run: { type: object }
|
||||
lines: { type: array, items: { type: object } }
|
||||
'403': { description: Недостаточно прав }
|
||||
'404': { description: Расчёт не найден }
|
||||
/imports/owner-workbook:
|
||||
post:
|
||||
summary: Идемпотентно импортировать локально разобранную книгу владельца
|
||||
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:
|
||||
|
|
@ -329,3 +664,56 @@ components:
|
|||
name: { const: krona-staff }
|
||||
version: { type: string }
|
||||
mode: { enum: [standalone, platform] }
|
||||
PayAssignment:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[
|
||||
id,
|
||||
employee_id,
|
||||
pay_rule_id,
|
||||
pay_rule_code,
|
||||
pay_rule_name,
|
||||
rate_minor,
|
||||
percent_bp,
|
||||
guarantee_minor,
|
||||
pool_weight_bp,
|
||||
effective_from,
|
||||
]
|
||||
properties:
|
||||
id: { $ref: '#/components/schemas/Ulid' }
|
||||
employee_id: { $ref: '#/components/schemas/Ulid' }
|
||||
org_unit_id:
|
||||
oneOf:
|
||||
- { $ref: '#/components/schemas/Ulid' }
|
||||
- { type: 'null' }
|
||||
pay_rule_id: { $ref: '#/components/schemas/Ulid' }
|
||||
pay_rule_code: { type: string }
|
||||
pay_rule_name: { type: string }
|
||||
rate_minor: { type: integer, minimum: 0 }
|
||||
percent_bp: { type: integer, minimum: 0 }
|
||||
guarantee_minor: { type: integer, minimum: 0 }
|
||||
pool_weight_bp: { type: integer, minimum: 0 }
|
||||
effective_from: { type: string, format: date }
|
||||
effective_to:
|
||||
oneOf:
|
||||
- { type: string, format: date }
|
||||
- { type: 'null' }
|
||||
params: { type: object }
|
||||
note: { type: string }
|
||||
PayAssignmentSave:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [pay_rule_id]
|
||||
properties:
|
||||
org_unit_id:
|
||||
oneOf:
|
||||
- { $ref: '#/components/schemas/Ulid' }
|
||||
- { type: 'null' }
|
||||
pay_rule_id: { $ref: '#/components/schemas/Ulid' }
|
||||
rate_minor: { type: integer, minimum: 0 }
|
||||
percent_bp: { type: integer, minimum: 0, maximum: 10000 }
|
||||
guarantee_minor: { type: integer, minimum: 0 }
|
||||
pool_weight_bp: { type: integer, minimum: 0, maximum: 20000 }
|
||||
effective_from: { type: string, format: date }
|
||||
note: { type: string, maxLength: 500 }
|
||||
|
|
|
|||
|
|
@ -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.1</Version>
|
||||
<AssemblyVersion>0.3.1.0</AssemblyVersion>
|
||||
<FileVersion>0.3.1.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using KronaStaffTerminal.Models;
|
||||
using KronaStaffTerminal.Services;
|
||||
|
|
@ -13,6 +14,7 @@ public sealed class KronaStaffWorker : BackgroundService
|
|||
private readonly AttendanceSyncService _syncService;
|
||||
private readonly ILicenseStatusPort _licensePort;
|
||||
private readonly SemaphoreSlim _syncGate = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, long> _acceptedIpcNonces = new();
|
||||
private TerminalIpcServer? _ipcServer;
|
||||
private bool _scannerSession0Supported;
|
||||
private string _scannerDiagnostic = "";
|
||||
|
|
@ -132,6 +134,17 @@ public sealed class KronaStaffWorker : BackgroundService
|
|||
{
|
||||
try
|
||||
{
|
||||
var authenticationState = await _store.LoadAsync();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (!IpcRequestAuthenticator.Verify(
|
||||
request, authenticationState.Settings.IpcAuthenticationKey, now))
|
||||
{
|
||||
return new IpcResponse { Success = false, ErrorMessage = "Unauthorized request" };
|
||||
}
|
||||
foreach (var nonce in _acceptedIpcNonces.Where(item => item.Value < now.AddMinutes(-1).ToUnixTimeSeconds()))
|
||||
_acceptedIpcNonces.TryRemove(nonce.Key, out _);
|
||||
if (!_acceptedIpcNonces.TryAdd(request.Nonce, request.IssuedAtUnixSeconds))
|
||||
return new IpcResponse { Success = false, ErrorMessage = "Replayed request" };
|
||||
switch (request.Command)
|
||||
{
|
||||
case "Ping":
|
||||
|
|
@ -169,10 +182,30 @@ public sealed class KronaStaffWorker : BackgroundService
|
|||
return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record JSON" };
|
||||
}
|
||||
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(record.Id, "^[0-9A-HJKMNP-TV-Z]{26}$") ||
|
||||
!System.Text.RegularExpressions.Regex.IsMatch(record.EmployeeId, "^[0-9A-HJKMNP-TV-Z]{26}$") ||
|
||||
record.EventType is not ("Приход" or "Уход" or "Уход автоматически") ||
|
||||
record.OccurredAtUtc < DateTimeOffset.UtcNow.AddDays(-180) ||
|
||||
record.OccurredAtUtc > DateTimeOffset.UtcNow.AddMinutes(5) ||
|
||||
record.ReaderIndex < 0)
|
||||
{
|
||||
return new IpcResponse { Success = false, ErrorMessage = "Invalid attendance record" };
|
||||
}
|
||||
|
||||
await _syncGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var state = await _store.LoadAsync();
|
||||
var employee = state.Employees.FirstOrDefault(item => item.Id == record.EmployeeId);
|
||||
if (employee is null || employee.IsBlocked)
|
||||
{
|
||||
return new IpcResponse { Success = false, ErrorMessage = "Employee is unavailable" };
|
||||
}
|
||||
record.EmployeeName = employee.DisplayName;
|
||||
record.ServerSynced = false;
|
||||
record.ServerSyncError = null;
|
||||
record.NotificationDelivered = false;
|
||||
record.NotificationError = null;
|
||||
if (state.Attendance.All(a => a.Id != record.Id))
|
||||
{
|
||||
state.Attendance.Insert(0, record);
|
||||
|
|
@ -225,7 +258,7 @@ public sealed class KronaStaffWorker : BackgroundService
|
|||
return new IpcServiceStatus
|
||||
{
|
||||
ServiceRunning = true,
|
||||
ServiceVersion = "0.2.5",
|
||||
ServiceVersion = "0.3.1",
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs
Normal file
28
apps/KronaStaffTerminal.Tests/CsvCellEncoderTests.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using KronaStaffTerminal.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace KronaStaffTerminal.Tests;
|
||||
|
||||
public sealed class CsvCellEncoderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("=HYPERLINK(\"https://example.invalid\")")]
|
||||
[InlineData("+cmd")]
|
||||
[InlineData("-1+1")]
|
||||
[InlineData("@SUM(A1:A2)")]
|
||||
[InlineData(" =SUM(A1:A2)")]
|
||||
[InlineData("\t=SUM(A1:A2)")]
|
||||
[InlineData("\r=SUM(A1:A2)")]
|
||||
public void Encode_neutralizes_spreadsheet_formulas(string value)
|
||||
{
|
||||
var encoded = CsvCellEncoder.Encode(value);
|
||||
|
||||
Assert.StartsWith("\"'", encoded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_preserves_regular_unicode_and_escapes_quotes()
|
||||
{
|
||||
Assert.Equal("\"Иван \"\"Повар\"\"; смена\"", CsvCellEncoder.Encode("Иван \"Повар\"; смена"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
using KronaStaffTerminal.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace KronaStaffTerminal.Tests;
|
||||
|
||||
public sealed class IpcRequestAuthenticatorTests
|
||||
{
|
||||
private static readonly string Key = Convert.ToBase64String(new byte[32].Select((_, index) => (byte)index).ToArray());
|
||||
|
||||
[Fact]
|
||||
public void Verify_accepts_fresh_signed_request()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var request = new IpcRequest
|
||||
{
|
||||
Command = "Ping",
|
||||
IssuedAtUnixSeconds = now.ToUnixTimeSeconds(),
|
||||
Nonce = "00112233445566778899AABBCCDDEEFF"
|
||||
};
|
||||
request.Signature = IpcRequestAuthenticator.Sign(request, Key);
|
||||
|
||||
Assert.True(IpcRequestAuthenticator.Verify(request, Key, now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_rejects_tampering_and_stale_requests()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var request = new IpcRequest
|
||||
{
|
||||
Command = "EnqueueAttendance",
|
||||
Payload = "{}",
|
||||
IssuedAtUnixSeconds = now.AddMinutes(-2).ToUnixTimeSeconds(),
|
||||
Nonce = "00112233445566778899AABBCCDDEEFF"
|
||||
};
|
||||
request.Signature = IpcRequestAuthenticator.Sign(request, Key);
|
||||
Assert.False(IpcRequestAuthenticator.Verify(request, Key, now));
|
||||
|
||||
request.IssuedAtUnixSeconds = now.ToUnixTimeSeconds();
|
||||
request.Signature = IpcRequestAuthenticator.Sign(request, Key);
|
||||
request.Payload = "{\"forged\":true}";
|
||||
Assert.False(IpcRequestAuthenticator.Verify(request, Key, now));
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
<Compile Include="..\KronaStaffTerminal\Services\AttendanceSyncService.cs" Link="Services\AttendanceSyncService.cs" />
|
||||
<Compile Include="..\KronaStaffTerminal\Services\AttendanceTracker.cs" Link="Services\AttendanceTracker.cs" />
|
||||
<Compile Include="..\KronaStaffTerminal\Services\CrashLogService.cs" Link="Services\CrashLogService.cs" />
|
||||
<Compile Include="..\KronaStaffTerminal\Services\CsvCellEncoder.cs" Link="Services\CsvCellEncoder.cs" />
|
||||
<Compile Include="..\KronaStaffTerminal\Services\FingerprintScanner.cs" Link="Services\FingerprintScanner.cs" />
|
||||
<Compile Include="..\KronaStaffTerminal\Services\LicenseStatus.cs" Link="Services\LicenseStatus.cs" />
|
||||
<Compile Include="..\KronaStaffTerminal\Services\TerminalIpc.cs" Link="Services\TerminalIpc.cs" />
|
||||
|
|
|
|||
|
|
@ -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.1</Version>
|
||||
<AssemblyVersion>0.3.1.0</AssemblyVersion>
|
||||
<FileVersion>0.3.1.0</FileVersion>
|
||||
<InformationalVersion>0.3.1</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()
|
||||
|
|
@ -1184,12 +1240,12 @@ public sealed partial class MainPage : Page
|
|||
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
|
||||
{
|
||||
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
|
||||
csv.Append(Csv(item.EmployeeName)).Append(';')
|
||||
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
|
||||
.Append(Csv(employee?.Position ?? "")).Append(';')
|
||||
.Append(Csv(item.EventType)).Append(';')
|
||||
.Append(Csv(item.LocalTime)).Append(';')
|
||||
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
|
||||
csv.Append(CsvCellEncoder.Encode(item.EmployeeName)).Append(';')
|
||||
.Append(CsvCellEncoder.Encode(employee?.PersonnelNumber)).Append(';')
|
||||
.Append(CsvCellEncoder.Encode(employee?.Position)).Append(';')
|
||||
.Append(CsvCellEncoder.Encode(item.EventType)).Append(';')
|
||||
.Append(CsvCellEncoder.Encode(item.LocalTime)).Append(';')
|
||||
.Append(CsvCellEncoder.Encode(item.DeliveryStatus)).Append("\r\n");
|
||||
}
|
||||
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
|
||||
AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}");
|
||||
|
|
@ -1205,8 +1261,6 @@ public sealed partial class MainPage : Page
|
|||
}
|
||||
}
|
||||
|
||||
private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
|
||||
public async Task<bool> AuthorizeExitAsync()
|
||||
{
|
||||
if (_stateLoadFailed)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace KronaStaffTerminal.Models;
|
||||
|
|
@ -108,6 +109,7 @@ public sealed class AttendanceRecord
|
|||
|
||||
public sealed class TerminalSettings
|
||||
{
|
||||
public string IpcAuthenticationKey { get; set; } = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
public int ReaderIndex { get; set; }
|
||||
public bool StartWithWindows { get; set; }
|
||||
public string AdminPinSalt { get; set; } = "";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
20
apps/KronaStaffTerminal/Services/CsvCellEncoder.cs
Normal file
20
apps/KronaStaffTerminal/Services/CsvCellEncoder.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
namespace KronaStaffTerminal.Services;
|
||||
|
||||
public static class CsvCellEncoder
|
||||
{
|
||||
public static string Encode(string? value)
|
||||
{
|
||||
var safe = value ?? string.Empty;
|
||||
var firstContent = safe.AsSpan().TrimStart();
|
||||
if (firstContent.Length > 0 && firstContent[0] is '=' or '+' or '-' or '@')
|
||||
{
|
||||
safe = "'" + safe;
|
||||
}
|
||||
else if (safe.Length > 0 && safe[0] is '\t' or '\r' or '\n')
|
||||
{
|
||||
safe = "'" + safe;
|
||||
}
|
||||
|
||||
return $"\"{safe.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
|
@ -11,6 +12,9 @@ public sealed class IpcRequest
|
|||
{
|
||||
public string Command { get; set; } = "";
|
||||
public string? Payload { get; set; }
|
||||
public long IssuedAtUnixSeconds { get; set; }
|
||||
public string Nonce { get; set; } = "";
|
||||
public string Signature { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class IpcResponse
|
||||
|
|
@ -25,7 +29,7 @@ public sealed class IpcResponse
|
|||
public sealed class IpcServiceStatus
|
||||
{
|
||||
public bool ServiceRunning { get; set; } = true;
|
||||
public string ServiceVersion { get; set; } = "0.2.5";
|
||||
public string ServiceVersion { get; set; } = "0.3.1";
|
||||
public bool ScannerInSession0Supported { get; set; }
|
||||
public string ScannerDiagnostic { get; set; } = "";
|
||||
public int PendingSyncCount { get; set; }
|
||||
|
|
@ -42,6 +46,8 @@ public static class TerminalIpcConstants
|
|||
|
||||
public sealed class TerminalIpcClient
|
||||
{
|
||||
private readonly TerminalStore _store = new();
|
||||
|
||||
public async Task<bool> IsServiceAvailableAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await SendCommandAsync("Ping", null, cancellationToken);
|
||||
|
|
@ -81,7 +87,15 @@ public sealed class TerminalIpcClient
|
|||
|
||||
await pipe.ConnectAsync(timeoutCts.Token);
|
||||
|
||||
var request = new IpcRequest { Command = command, Payload = payload };
|
||||
var state = await _store.LoadAsync();
|
||||
var request = new IpcRequest
|
||||
{
|
||||
Command = command,
|
||||
Payload = payload,
|
||||
IssuedAtUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
Nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16))
|
||||
};
|
||||
request.Signature = IpcRequestAuthenticator.Sign(request, state.Settings.IpcAuthenticationKey);
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var lengthPrefix = BitConverter.GetBytes(bytes.Length);
|
||||
|
|
@ -173,7 +187,7 @@ public sealed class TerminalIpcServer : IDisposable
|
|||
var read = await stream.ReadAsync(reqLenBytes, token);
|
||||
if (read < 4) return;
|
||||
var reqLen = BitConverter.ToInt32(reqLenBytes);
|
||||
if (reqLen is <= 0 or > 10 * 1024 * 1024) return;
|
||||
if (reqLen is <= 0 or > 256 * 1024) return;
|
||||
|
||||
var reqBytes = new byte[reqLen];
|
||||
var totalRead = 0;
|
||||
|
|
@ -212,11 +226,11 @@ public sealed class TerminalIpcServer : IDisposable
|
|||
var pipeSecurity = new PipeSecurity();
|
||||
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null);
|
||||
var interactiveSid = new SecurityIdentifier(WellKnownSidType.InteractiveSid, null);
|
||||
|
||||
pipeSecurity.AddAccessRule(new PipeAccessRule(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
pipeSecurity.AddAccessRule(new PipeAccessRule(usersSid, PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
||||
pipeSecurity.AddAccessRule(new PipeAccessRule(interactiveSid, PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
||||
|
||||
return NamedPipeServerStreamAcl.Create(
|
||||
@"global\" + TerminalIpcConstants.PipeName,
|
||||
|
|
@ -237,3 +251,29 @@ public sealed class TerminalIpcServer : IDisposable
|
|||
_cancellation?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static class IpcRequestAuthenticator
|
||||
{
|
||||
public static string Sign(IpcRequest request, string key)
|
||||
{
|
||||
var canonical = $"{request.Command}\n{request.IssuedAtUnixSeconds}\n{request.Nonce}\n{request.Payload ?? string.Empty}";
|
||||
using var hmac = new HMACSHA256(Convert.FromBase64String(key));
|
||||
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)));
|
||||
}
|
||||
|
||||
public static bool Verify(IpcRequest request, string key, DateTimeOffset now)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || request.Nonce.Length != 32) return false;
|
||||
if (Math.Abs(now.ToUnixTimeSeconds() - request.IssuedAtUnixSeconds) > 60) return false;
|
||||
try
|
||||
{
|
||||
var actual = Convert.FromBase64String(request.Signature);
|
||||
var expected = Convert.FromBase64String(Sign(request, key));
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ namespace KronaStaffTerminal.Services;
|
|||
|
||||
public sealed class TerminalStore
|
||||
{
|
||||
public const string OperatorGroupName = "KronaStaffTerminalOperators";
|
||||
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
private const string RegistryPath = @"Software\Krona\StaffTerminal";
|
||||
|
|
@ -67,13 +68,17 @@ public sealed class TerminalStore
|
|||
"Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных.");
|
||||
}
|
||||
|
||||
return new TerminalState();
|
||||
return await InitializeFreshStateAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
EnsureDirectoryWithRestrictedAcl(Path.GetDirectoryName(_filePath)!);
|
||||
}
|
||||
var state = await ReadStateAsync(sourcePath);
|
||||
var migrated = MigrateEmployees(state);
|
||||
var migrated = MigrateEmployees(state) | MigrateSecurity(state);
|
||||
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await SaveAsync(state);
|
||||
|
|
@ -92,6 +97,7 @@ public sealed class TerminalStore
|
|||
try
|
||||
{
|
||||
File.Move(_filePath, corruptPath, false);
|
||||
ApplyRestrictedFileAcl(corruptPath);
|
||||
}
|
||||
catch (Exception moveException)
|
||||
{
|
||||
|
|
@ -208,6 +214,43 @@ public sealed class TerminalStore
|
|||
return changed;
|
||||
}
|
||||
|
||||
private static bool MigrateSecurity(TerminalState state)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(state.Settings.IpcAuthenticationKey)) return false;
|
||||
state.Settings.IpcAuthenticationKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<TerminalState> InitializeFreshStateAsync()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_filePath)!;
|
||||
EnsureDirectoryWithRestrictedAcl(directory);
|
||||
var lockPath = _filePath + ".init.lock";
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
|
||||
FileStream? initializationLock = null;
|
||||
while (initializationLock is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
initializationLock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite,
|
||||
FileShare.None, 1, FileOptions.WriteThrough);
|
||||
}
|
||||
catch (IOException) when (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
await using (initializationLock)
|
||||
{
|
||||
ApplyRestrictedFileAcl(lockPath);
|
||||
if (File.Exists(_filePath)) return await LoadAsync();
|
||||
var state = new TerminalState();
|
||||
await SaveAsync(state);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false)
|
||||
{
|
||||
await _saveGate.WaitAsync();
|
||||
|
|
@ -239,6 +282,7 @@ public sealed class TerminalStore
|
|||
File.Copy(_filePath, previousStatePath, true);
|
||||
}
|
||||
File.Move(temporaryPath, _filePath, true);
|
||||
ApplyRestrictedFileAcl(_filePath);
|
||||
MarkInitialized();
|
||||
if (previousStatePath is not null)
|
||||
{
|
||||
|
|
@ -260,44 +304,62 @@ public sealed class TerminalStore
|
|||
|
||||
private static void EnsureDirectoryWithRestrictedAcl(string directoryPath)
|
||||
{
|
||||
if (Directory.Exists(directoryPath)) return;
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
var identities = AllowedIdentities(directoryPath).ToArray();
|
||||
var directorySecurity = new DirectorySecurity();
|
||||
directorySecurity.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
foreach (var identity in identities)
|
||||
{
|
||||
directorySecurity.AddAccessRule(new FileSystemAccessRule(
|
||||
identity,
|
||||
FileSystemRights.FullControl,
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
||||
PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
}
|
||||
new DirectoryInfo(directoryPath).SetAccessControl(directorySecurity);
|
||||
foreach (var stateFile in Directory.EnumerateFiles(directoryPath, "terminal-state.dat*"))
|
||||
ApplyRestrictedFileAcl(stateFile);
|
||||
}
|
||||
|
||||
private static IEnumerable<IdentityReference> AllowedIdentities(string? existingDirectory = null)
|
||||
{
|
||||
yield return new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
yield return new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
SecurityIdentifier? operatorSid = null;
|
||||
try
|
||||
{
|
||||
var directorySecurity = new DirectorySecurity();
|
||||
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null);
|
||||
|
||||
directorySecurity.AddAccessRule(new FileSystemAccessRule(
|
||||
systemSid,
|
||||
FileSystemRights.FullControl,
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
||||
PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
|
||||
directorySecurity.AddAccessRule(new FileSystemAccessRule(
|
||||
adminSid,
|
||||
FileSystemRights.FullControl,
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
||||
PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
|
||||
directorySecurity.AddAccessRule(new FileSystemAccessRule(
|
||||
usersSid,
|
||||
FileSystemRights.ReadAndExecute | FileSystemRights.Modify | FileSystemRights.Write,
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
||||
PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
|
||||
FileSystemAclExtensions.CreateDirectory(directorySecurity, directoryPath);
|
||||
operatorSid = (SecurityIdentifier)new NTAccount(Environment.MachineName, OperatorGroupName)
|
||||
.Translate(typeof(SecurityIdentifier));
|
||||
}
|
||||
catch
|
||||
catch (IdentityNotMappedException) { }
|
||||
if (operatorSid is not null) yield return operatorSid;
|
||||
|
||||
using var current = WindowsIdentity.GetCurrent();
|
||||
if (current.User is not null && !current.User.IsWellKnown(WellKnownSidType.LocalSystemSid))
|
||||
yield return current.User;
|
||||
|
||||
if (existingDirectory is null || !Directory.Exists(existingDirectory)) yield break;
|
||||
var existing = new DirectoryInfo(existingDirectory).GetAccessControl(AccessControlSections.Access);
|
||||
foreach (FileSystemAccessRule rule in existing.GetAccessRules(
|
||||
includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier)))
|
||||
{
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
if (rule.AccessControlType != AccessControlType.Allow ||
|
||||
rule.IdentityReference is not SecurityIdentifier sid || !sid.IsAccountSid()) continue;
|
||||
yield return sid;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyRestrictedFileAcl(string path)
|
||||
{
|
||||
var security = new FileSecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
foreach (var identity in AllowedIdentities(Path.GetDirectoryName(path)))
|
||||
security.AddAccessRule(new FileSystemAccessRule(identity, FileSystemRights.FullControl,
|
||||
AccessControlType.Allow));
|
||||
new FileInfo(path).SetAccessControl(security);
|
||||
}
|
||||
|
||||
private bool ShouldCreateRecoveryPoint(bool requested)
|
||||
{
|
||||
var latestBackup = BackupPaths().First();
|
||||
|
|
|
|||
|
|
@ -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.1 (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.1"
|
||||
!endif
|
||||
!ifndef PUBLISH_DIR
|
||||
!error "PUBLISH_DIR is required"
|
||||
|
|
@ -21,6 +21,7 @@ SetCompressor /SOLID lzma
|
|||
!define APP_EXE "KronaStaffTerminal.exe"
|
||||
!define SERVICE_EXE "KronaStaffService.exe"
|
||||
!define SERVICE_NAME "KronaStaffService"
|
||||
!define OPERATOR_GROUP "KronaStaffTerminalOperators"
|
||||
!define APP_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\KronaStaffTerminal"
|
||||
!define DRIVER_URL "https://www.zkteco.com/en/Biometrics_Module_SDK/ZKFinger-SDK-for-Windows"
|
||||
|
||||
|
|
@ -88,6 +89,32 @@ Section "Krona Staff Terminal" SecMain
|
|||
SetOverwrite try
|
||||
File /r "${PUBLISH_DIR}\*"
|
||||
|
||||
DetailPrint "Настройка защищённой локальной группы терминала..."
|
||||
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}"'
|
||||
Pop $0
|
||||
StrCmp $0 "0" group_ready
|
||||
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" /add'
|
||||
Pop $0
|
||||
StrCmp $0 "0" group_ready
|
||||
MessageBox MB_ICONSTOP "Не удалось создать защищённую группу ${OPERATOR_GROUP}. Установка остановлена."
|
||||
Abort
|
||||
group_ready:
|
||||
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" "$USERNAME" /delete'
|
||||
Pop $0
|
||||
nsExec::ExecToLog 'net.exe localgroup "${OPERATOR_GROUP}" "$USERNAME" /add'
|
||||
Pop $0
|
||||
StrCmp $0 "0" membership_ready
|
||||
MessageBox MB_ICONSTOP "Не удалось выдать текущему пользователю доступ к терминалу. Установка остановлена."
|
||||
Abort
|
||||
membership_ready:
|
||||
CreateDirectory "$COMMONAPPDATA\Krona\StaffTerminal"
|
||||
nsExec::ExecToLog 'icacls.exe "$COMMONAPPDATA\Krona\StaffTerminal" /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" "$USERDOMAIN\$USERNAME:(OI)(CI)M"'
|
||||
Pop $0
|
||||
StrCmp $0 "0" acl_ready
|
||||
MessageBox MB_ICONSTOP "Не удалось защитить хранилище терминала. Установка остановлена."
|
||||
Abort
|
||||
acl_ready:
|
||||
|
||||
DetailPrint "Настройка службы ${SERVICE_NAME}..."
|
||||
ExecWait 'sc.exe create ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto DisplayName= "Krona Staff Service"'
|
||||
ExecWait 'sc.exe config ${SERVICE_NAME} binPath= "\"$INSTDIR\${SERVICE_EXE}\"" start= auto'
|
||||
|
|
@ -160,8 +187,8 @@ Section "Uninstall"
|
|||
|
||||
IfSilent keep_data
|
||||
MessageBox MB_YESNO|MB_ICONQUESTION "Сохранить локальную базу данных и журнал терминала (%ProgramData%\Krona\StaffTerminal)?$\r$\n$\r$\nНажмите 'Да', чтобы сохранить данные для будущей установки.$\r$\nНажмите 'Нет', чтобы полностью удалить данные с устройства." IDYES keep_data
|
||||
RMDir /r "$APPDATA\Krona\StaffTerminal"
|
||||
RMDir "$APPDATA\Krona"
|
||||
RMDir /r "$COMMONAPPDATA\Krona\StaffTerminal"
|
||||
RMDir "$COMMONAPPDATA\Krona"
|
||||
keep_data:
|
||||
|
||||
Delete "$DESKTOP\Krona Staff Terminal.lnk"
|
||||
|
|
|
|||
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;
|
||||
6
migrations/0009_professions.down.sql
Normal file
6
migrations/0009_professions.down.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
DROP INDEX IF EXISTS staff_pay_assignments_effective_version_idx;
|
||||
DROP INDEX IF EXISTS staff_employees_external_ref_idx;
|
||||
DROP INDEX IF EXISTS staff_employees_profession_idx;
|
||||
ALTER TABLE staff_employees DROP CONSTRAINT IF EXISTS staff_employees_profession_fk;
|
||||
ALTER TABLE staff_employees DROP COLUMN IF EXISTS profession_id;
|
||||
DROP TABLE IF EXISTS staff_professions;
|
||||
32
migrations/0009_professions.up.sql
Normal file
32
migrations/0009_professions.up.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
CREATE TABLE staff_professions (
|
||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
code VARCHAR(80) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, id),
|
||||
UNIQUE (tenant_id, code),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
ALTER TABLE staff_professions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_professions FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_professions_tenant_isolation ON staff_professions
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
|
||||
ALTER TABLE staff_employees ADD COLUMN profession_id VARCHAR(26);
|
||||
ALTER TABLE staff_employees ADD CONSTRAINT staff_employees_profession_fk
|
||||
FOREIGN KEY (tenant_id, profession_id) REFERENCES staff_professions (tenant_id, id);
|
||||
CREATE INDEX staff_employees_profession_idx ON staff_employees (tenant_id, profession_id);
|
||||
|
||||
CREATE UNIQUE INDEX staff_employees_external_ref_idx
|
||||
ON staff_employees (tenant_id, external_ref) WHERE external_ref IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX staff_pay_assignments_effective_version_idx
|
||||
ON staff_pay_assignments
|
||||
(tenant_id, employee_id, COALESCE(org_unit_id, ''), effective_from);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_professions TO krona_staff_app;
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "./schemas/module.schema.json",
|
||||
"id": "ru.krona.staff",
|
||||
"name": "Krona Staff",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.1",
|
||||
"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.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@krona/staff",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.1",
|
||||
"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.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
296
scripts/import-owner-workbook.py
Normal file
296
scripts/import-owner-workbook.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read the owner's workbook and import raw 2026 facts into Krona Staff.
|
||||
|
||||
The workbook is never modified. Personal data stays in memory and is sent only to
|
||||
the explicitly supplied HTTPS Staff endpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
FINANCE_COLUMNS = {
|
||||
3: "income_cash",
|
||||
4: "income_card",
|
||||
5: "income_transfer",
|
||||
6: "income_sber_market",
|
||||
7: "income_yandex",
|
||||
8: "income_certificate",
|
||||
10: "income_recycling",
|
||||
12: "expense_products",
|
||||
13: "expense_supplies",
|
||||
14: "expense_taxi",
|
||||
15: "expense_communications",
|
||||
16: "expense_advertising",
|
||||
17: "expense_owner_payroll",
|
||||
18: "expense_taxes",
|
||||
19: "expense_rent",
|
||||
20: "expense_utilities",
|
||||
21: "expense_other",
|
||||
22: "expense_gifts",
|
||||
}
|
||||
UNIT_SHEETS = {"Горский": ("gorsky", "Горский"), "Красный": ("krasny", "Красный")}
|
||||
POINT_CODES = {"Г": "gorsky", "К": "krasny", "Ф": "krasny"}
|
||||
PROFESSIONS = {
|
||||
"Повар": "Повар",
|
||||
"Доп Повар": "Дополнительный повар",
|
||||
"Стажер": "Стажёр",
|
||||
"Админ": "Администратор",
|
||||
}
|
||||
|
||||
|
||||
def money_minor(value: object) -> int | None:
|
||||
if not isinstance(value, (int, float, Decimal)):
|
||||
return None
|
||||
return int((Decimal(str(value)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def normalized_name(value: object) -> str:
|
||||
return " ".join(str(value or "").split())
|
||||
|
||||
|
||||
def stable_ref(prefix: str, value: str) -> str:
|
||||
return prefix + hashlib.sha256(value.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
|
||||
def extract(path: Path) -> tuple[dict[str, list[dict]], dict[str, object]]:
|
||||
workbook = openpyxl.load_workbook(path, data_only=True, read_only=False)
|
||||
units = [{"code": code, "name": name, "external_ref": f"xlsx-unit:{code}"}
|
||||
for code, name in UNIT_SHEETS.values()]
|
||||
finance: list[dict] = []
|
||||
monthly = defaultdict(lambda: {"income_minor": 0, "expense_minor": 0})
|
||||
|
||||
for sheet_name, (unit_code, _) in UNIT_SHEETS.items():
|
||||
sheet = workbook[sheet_name]
|
||||
for row in range(2, sheet.max_row + 1):
|
||||
business_date = sheet.cell(row, 1).value
|
||||
if not isinstance(business_date, datetime) or business_date.year != 2026:
|
||||
continue
|
||||
date_text = business_date.date().isoformat()
|
||||
for column, metric_key in FINANCE_COLUMNS.items():
|
||||
value_minor = money_minor(sheet.cell(row, column).value)
|
||||
if value_minor is None or value_minor == 0:
|
||||
continue
|
||||
finance.append({
|
||||
"org_unit_code": unit_code,
|
||||
"business_date": date_text,
|
||||
"metric_key": metric_key,
|
||||
"value_minor": value_minor,
|
||||
"currency_code": "RUB",
|
||||
})
|
||||
bucket = monthly[date_text[:7]]
|
||||
bucket["income_minor" if metric_key.startswith("income_") else "expense_minor"] += value_minor
|
||||
|
||||
graph = workbook["График"]
|
||||
date_columns = {
|
||||
column: graph.cell(1, column).value.date().isoformat()
|
||||
for column in range(4, graph.max_column + 1)
|
||||
if isinstance(graph.cell(1, column).value, datetime)
|
||||
and graph.cell(1, column).value.year == 2026
|
||||
}
|
||||
employee_blocks: list[tuple[int, str, str]] = []
|
||||
professions_by_name: dict[str, set[str]] = defaultdict(set)
|
||||
for row in range(2, graph.max_row):
|
||||
if graph.cell(row, 3).value != "Часов":
|
||||
continue
|
||||
name = normalized_name(graph.cell(row, 2).value)
|
||||
profession = PROFESSIONS.get(normalized_name(graph.cell(row - 1, 1).value))
|
||||
if not name or not profession:
|
||||
continue
|
||||
employee_blocks.append((row, name, profession))
|
||||
professions_by_name[name].add(profession)
|
||||
|
||||
names = sorted(professions_by_name)
|
||||
employees = []
|
||||
refs: dict[str, str] = {}
|
||||
for name in names:
|
||||
identity_hash = hashlib.sha256(name.casefold().encode("utf-8")).hexdigest()[:16]
|
||||
reference = f"xlsx:staff:{identity_hash}"
|
||||
refs[name] = reference
|
||||
professions = professions_by_name[name]
|
||||
priority = ("Администратор", "Повар", "Дополнительный повар", "Стажёр")
|
||||
primary = next(role for role in priority if role in professions)
|
||||
employees.append({
|
||||
"external_ref": reference,
|
||||
"personnel_number": f"XLSX-{identity_hash.upper()}",
|
||||
"display_name": name,
|
||||
"position": primary,
|
||||
})
|
||||
|
||||
schedule_totals: dict[tuple[str, str, str], int] = defaultdict(int)
|
||||
schedule_locations: dict[tuple[str, str, str], set[str]] = defaultdict(set)
|
||||
payroll_totals: dict[tuple[str, str, str], int] = defaultdict(int)
|
||||
payout_totals: dict[tuple[str, str, str], int] = defaultdict(int)
|
||||
inferred_points = 0
|
||||
for row, name, _profession in employee_blocks:
|
||||
for column, date_text in date_columns.items():
|
||||
hours = graph.cell(row, column).value
|
||||
if not isinstance(hours, (int, float)) or hours <= 0:
|
||||
continue
|
||||
point_code = normalized_name(graph.cell(row - 1, column).value)
|
||||
unit_code = POINT_CODES.get(point_code)
|
||||
if unit_code is None:
|
||||
inferred_points += 1
|
||||
lowered = name.casefold()
|
||||
unit_code = "krasny" if ("крас" in lowered or "кр." in lowered) else "gorsky"
|
||||
identity = (refs[name], date_text, unit_code)
|
||||
schedule_totals[identity] += int(
|
||||
Decimal(str(hours * 60)).quantize(Decimal("1")))
|
||||
schedule_locations[identity].add(point_code or "inferred")
|
||||
salary_minor = money_minor(graph.cell(row + 1, column).value)
|
||||
if salary_minor:
|
||||
payroll_totals[identity] += salary_minor
|
||||
payout_minor = money_minor(graph.cell(row + 2, column).value)
|
||||
if payout_minor:
|
||||
payout_totals[identity] += abs(payout_minor)
|
||||
|
||||
schedule = [{
|
||||
"external_key": stable_ref("xlsx:shift:", "|".join(identity)),
|
||||
"employee_external_ref": identity[0],
|
||||
"org_unit_code": identity[2],
|
||||
"business_date": identity[1],
|
||||
"planned_minutes": minutes,
|
||||
"location_code": ",".join(sorted(schedule_locations[identity])),
|
||||
} for identity, minutes in sorted(schedule_totals.items())]
|
||||
payroll = [{
|
||||
"external_key": stable_ref("xlsx:payroll:accrual:", "|".join(identity)),
|
||||
"employee_external_ref": identity[0],
|
||||
"business_date": identity[1],
|
||||
"kind": "accrual",
|
||||
"amount_minor": amount,
|
||||
"note": "Импорт начисления из листа «График»",
|
||||
} for identity, amount in sorted(payroll_totals.items())]
|
||||
payroll.extend({
|
||||
"external_key": stable_ref("xlsx:payroll:payout:", "|".join(identity)),
|
||||
"employee_external_ref": identity[0],
|
||||
"business_date": identity[1],
|
||||
"kind": "payout",
|
||||
"amount_minor": amount,
|
||||
"note": "Импорт выплаты из строки «Долг» листа «График»",
|
||||
} for identity, amount in sorted(payout_totals.items()))
|
||||
|
||||
summary = workbook["Сводная"]
|
||||
for unit_code, row in (("gorsky", 31), ("krasny", 43)):
|
||||
for column in range(3, 15):
|
||||
period_end = summary.cell(2, column).value
|
||||
amount = money_minor(summary.cell(row, column).value)
|
||||
if not isinstance(period_end, datetime) or period_end.year != 2026 or not amount:
|
||||
continue
|
||||
business_date = period_end.date().isoformat()
|
||||
finance.append({
|
||||
"org_unit_code": unit_code,
|
||||
"business_date": business_date,
|
||||
"metric_key": "expense_employee_payroll",
|
||||
"value_minor": amount,
|
||||
"currency_code": "RUB",
|
||||
})
|
||||
monthly[business_date[:7]]["expense_minor"] += amount
|
||||
|
||||
report = {
|
||||
"year": 2026,
|
||||
"units": len(units),
|
||||
"employees": len(employees),
|
||||
"financial_entries": len(finance),
|
||||
"schedule_items": len(schedule),
|
||||
"payroll_entries": len(payroll),
|
||||
"inferred_point_codes": inferred_points,
|
||||
"monthly": dict(sorted(monthly.items())),
|
||||
"totals": {
|
||||
"income_minor": sum(item["income_minor"] for item in monthly.values()),
|
||||
"expense_minor": sum(item["expense_minor"] for item in monthly.values()),
|
||||
},
|
||||
}
|
||||
return {
|
||||
"units": units,
|
||||
"employees": employees,
|
||||
"financial_entries": finance,
|
||||
"schedule_items": schedule,
|
||||
"payroll_entries": payroll,
|
||||
}, report
|
||||
|
||||
|
||||
def chunks(values: list[dict], size: int = 500):
|
||||
for offset in range(0, len(values), size):
|
||||
yield values[offset:offset + size]
|
||||
|
||||
|
||||
def api_request(url: str, token: str, path: str, method: str,
|
||||
payload: dict | None, allow_self_signed: bool) -> dict:
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url.rstrip("/") + path,
|
||||
data=body,
|
||||
method=method,
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
**({"content-type": "application/json"} if body is not None else {}),
|
||||
},
|
||||
)
|
||||
context = ssl._create_unverified_context() if allow_self_signed else ssl.create_default_context()
|
||||
with urllib.request.urlopen(request, context=context, timeout=90) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def post(url: str, token: str, payload: dict, allow_self_signed: bool) -> dict:
|
||||
return api_request(url, token, "/api/v1/staff/imports/owner-workbook", "POST",
|
||||
payload, allow_self_signed)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("workbook", type=Path)
|
||||
parser.add_argument("--server", default=os.getenv("KRONA_STAFF_SERVER", ""))
|
||||
parser.add_argument("--token", default=os.getenv("KRONA_STAFF_TOKEN", ""))
|
||||
parser.add_argument("--allow-self-signed", action="store_true")
|
||||
parser.add_argument("--apply", action="store_true", help="Send data; otherwise only validate and report")
|
||||
args = parser.parse_args()
|
||||
|
||||
payload, report = extract(args.workbook)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
if not args.apply:
|
||||
return 0
|
||||
if not args.server.lower().startswith("https://"):
|
||||
raise SystemExit("--server must be an https:// URL")
|
||||
if len(args.token) < 32:
|
||||
raise SystemExit("Provide --token or KRONA_STAFF_TOKEN")
|
||||
|
||||
profession_response = api_request(
|
||||
args.server, args.token, "/api/v1/staff/professions", "GET", None,
|
||||
args.allow_self_signed)
|
||||
existing_professions = {
|
||||
item["name"] for item in profession_response.get("professions", [])
|
||||
}
|
||||
for profession_name in sorted(set(PROFESSIONS.values()) - existing_professions):
|
||||
code = "xlsx-" + hashlib.sha256(
|
||||
profession_name.casefold().encode("utf-8")).hexdigest()[:12]
|
||||
api_request(args.server, args.token, "/api/v1/staff/professions", "POST",
|
||||
{"code": code, "name": profession_name}, args.allow_self_signed)
|
||||
print(f"Created profession: {profession_name}")
|
||||
|
||||
source = f"{args.workbook.name}:2026"
|
||||
batches = [
|
||||
{"units": payload["units"], "employees": payload["employees"]},
|
||||
*({"financial_entries": part} for part in chunks(payload["financial_entries"])),
|
||||
*({"schedule_items": part} for part in chunks(payload["schedule_items"])),
|
||||
*({"payroll_entries": part} for part in chunks(payload["payroll_entries"])),
|
||||
]
|
||||
for number, batch in enumerate(batches, start=1):
|
||||
result = post(args.server, args.token, {"source_name": source, **batch}, args.allow_self_signed)
|
||||
print(f"Imported batch {number}/{len(batches)}: {result}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
1000
src/domain/payroll/service.ts
Normal file
1000
src/domain/payroll/service.ts
Normal file
File diff suppressed because it is too large
Load diff
1210
src/operations.ts
Normal file
1210
src/operations.ts
Normal file
File diff suppressed because it is too large
Load diff
266
src/portal-ui.ts
266
src/portal-ui.ts
|
|
@ -1,5 +1,5 @@
|
|||
export const portalHtml = `<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="ru" data-omni-theme="noble-green" data-omni-density="workspace">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
|
@ -7,17 +7,17 @@ export const portalHtml = `<!doctype html>
|
|||
<title>Krona Staff</title>
|
||||
<link rel="manifest" href="/staff/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/staff/styles.css">
|
||||
<style>[hidden]{display:none!important}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:radial-gradient(circle at 25% 10%,rgba(183,131,53,.18),transparent 24rem),linear-gradient(135deg,#f7f0e3,#e8dcc5)}.auth-card{width:min(100%,460px);padding:42px;background:rgba(252,248,239,.96);border:1px solid #d9c9ac;border-radius:20px;box-shadow:0 24px 70px rgba(48,42,35,.16)}.auth-brand{display:flex;align-items:center;gap:12px;margin-bottom:42px;font-size:20px}.auth-card h1{margin:6px 0 12px;font:500 38px/1.08 Georgia,serif}.auth-copy{margin:0 0 26px;color:#756a5b;line-height:1.55}.auth-card form{display:grid;gap:10px}.auth-card label{font-size:13px;font-weight:700}.auth-card input{width:100%;min-height:50px;padding:0 14px;border:1px solid #cbbb9e;border-radius:10px;background:#fffdf8;color:#302a23}.auth-card button{margin-top:6px;min-height:50px}.auth-card small{display:block;margin-top:20px;color:#756a5b;line-height:1.45}.login-error{margin-top:16px;padding:12px 14px;border:1px solid #dfb2aa;border-radius:10px;background:#fff1ee;color:#78382d}@media(max-width:520px){.auth-screen{padding:16px}.auth-card{padding:28px 22px}.auth-card h1{font-size:32px}.auth-brand{margin-bottom:32px}}</style>
|
||||
<style>[hidden]{display:none!important}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:var(--omni-bg-workspace)}.auth-card{width:min(100%,460px);padding:42px;background:var(--omni-surface-raised);border:1px solid var(--omni-border);border-radius:var(--omni-radius-xl);box-shadow:var(--omni-shadow-strong)}.auth-brand{display:flex;align-items:center;gap:12px;margin-bottom:42px;font-size:20px}.auth-card h1{margin:6px 0 12px;font:500 38px/1.08 var(--omni-font-serif)}.auth-copy{margin:0 0 26px;color:var(--omni-text-secondary);line-height:1.55}.auth-card form{display:grid;gap:10px}.auth-card label{font-size:13px;font-weight:700}.auth-card input{width:100%;min-height:50px;padding:0 14px;border:1px solid var(--omni-border-strong);border-radius:var(--omni-radius-md);background:var(--omni-surface-raised);color:var(--omni-text-primary)}.auth-card button{margin-top:6px;min-height:50px}.auth-card small{display:block;margin-top:20px;color:var(--omni-text-secondary);line-height:1.45}.login-error{margin-top:16px;padding:12px 14px;border:1px solid var(--omni-danger);border-radius:var(--omni-radius-md);background:var(--omni-danger-soft);color:var(--omni-danger)}@media(max-width:520px){.auth-screen{padding:16px}.auth-card{padding:28px 22px}.auth-card h1{font-size:32px}.auth-brand{margin-bottom:32px}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<section id="auth-screen" class="auth-screen" aria-labelledby="login-title">
|
||||
<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>
|
||||
|
|
@ -28,9 +28,17 @@ export const portalHtml = `<!doctype html>
|
|||
<div id="app-shell" hidden>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/staff" aria-label="Krona Staff"><span class="mark">К</span><span>Krona <b>Staff</b></span></a>
|
||||
<div class="actions"><span id="connection" class="status">Подключение…</span><button id="notify" class="quiet" type="button">Уведомления</button><button id="logout" class="quiet" type="button">Выйти</button><button id="refresh" type="button">Обновить</button></div>
|
||||
<div class="actions"><span id="connection" class="status">Подключение…</span><label class="theme-control">Тема<select id="theme-select" aria-label="Тема интерфейса"><option value="noble-green">Зелёная</option><option value="parchment-light">Пергамент</option><option value="graphite-forest-dark">Тёмная</option></select></label><button id="notify" class="quiet" type="button">Уведомления</button><button id="logout" class="quiet" type="button">Выйти</button><button id="refresh" type="button">Обновить</button></div>
|
||||
</header>
|
||||
<nav class="section-nav" aria-label="Разделы кабинета">
|
||||
<button class="nav-item selected" type="button" data-target="overview-view">Обзор</button>
|
||||
<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>
|
||||
|
|
@ -43,12 +51,36 @@ export const portalHtml = `<!doctype html>
|
|||
</section>
|
||||
<section class="layout">
|
||||
<article class="panel attendance-panel"><div class="panel-head"><div><p class="eyebrow">Последние события</p><h2>Отметки терминала</h2></div><span class="live">● в реальном времени</span></div><div id="attendance" class="feed"></div></article>
|
||||
<aside class="panel people-panel"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><button id="add-employee" class="quiet compact" type="button">Добавить</button></div><div id="employees" class="people"></div></aside>
|
||||
<aside class="panel people-panel"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><div class="actions"><button id="manage-professions" class="quiet compact" type="button">Профессии</button><button id="add-employee" class="quiet compact" type="button">Добавить</button></div></div><div id="employees" class="people"></div></aside>
|
||||
</section>
|
||||
<section class="panel sessions-panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Сверка терминала</p><h2>Рабочие смены</h2></div><button id="reconcile-sessions" class="quiet" type="button">Сверить 7 дней</button></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Подразделение</th><th>Интервал</th><th>Часы</th><th>Состояние</th><th>Причина правки</th><th></th></tr></thead><tbody id="work-sessions"></tbody></table></div></section>
|
||||
<section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Учёт времени</p><h2>Табель за 40 дней</h2></div><span id="timesheet-help">Изменения сохраняет руководитель</span></div><form id="timesheet-create" class="timesheet-create"><label>Сотрудник<select id="new-time-employee" required></select></label><label>Дата<input id="new-time-date" type="date" required></label><label>Часы<input id="new-time-hours" type="number" min="0" max="24" step="0.25" value="8" required></label><label>Статус<select id="new-time-status"><option value="draft">Черновик</option><option value="submitted">На проверке</option><option value="approved">Утверждён</option><option value="rejected">Возвращён</option></select></label><label class="grow">Примечание<input id="new-time-note" maxlength="1000" placeholder="Необязательно"></label><button type="submit">Добавить строку</button></form><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Часы</th><th>Статус</th><th>Примечание</th><th></th></tr></thead><tbody id="timesheet"></tbody></table></div></section>
|
||||
</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><div class="actions"><button id="payroll-calc-btn" class="manager-only" type="button">Рассчитать зарплату</button><button id="payroll-refresh" class="quiet" type="button">Обновить</button></div></section>
|
||||
<div id="payroll-calc-status" class="message" style="background:#eaf2e8;border-color:#b4d3af;color:#285223" hidden></div>
|
||||
<div id="payroll-warnings" class="payroll-warnings message" hidden></div>
|
||||
<section id="payroll-preview-card" class="panel manager-only" hidden><div class="panel-head"><div><p class="eyebrow">Предварительный результат</p><h2 id="payroll-preview-title">Расчёт зарплаты</h2></div><button id="payroll-approve-btn" type="button">Утвердить расчёт</button></div><div class="table-wrap"><table><thead><tr><th>Сотрудник</th><th>Должность</th><th>Дней</th><th>Факт, часов</th><th>Начислено (расчёт)</th></tr></thead><tbody id="payroll-preview-rows"></tbody></table></div></section>
|
||||
<section class="payroll-stats stats" aria-label="Сводка зарплаты"><article><span>Начислено</span><strong id="payroll-accrued">—</strong></article><article><span>Выплачено</span><strong id="payroll-paid">—</strong></article><article><span>Остаток</span><strong id="payroll-balance">—</strong></article></section>
|
||||
<section class="panel"><div class="panel-head"><div><p class="eyebrow">Сотрудники</p><h2>Расчёт за месяц</h2></div><span>По подтверждённым сменам и начислениям</span></div><div class="table-wrap"><table><thead><tr><th>Сотрудник</th><th>Должность</th><th>Факт, часов</th><th>Начислено</th><th>Выплачено</th><th>Остаток</th></tr></thead><tbody id="payroll-rows"></tbody></table></div></section>
|
||||
<section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">История</p><h2>Начисления и выплаты</h2></div></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Операция</th><th>Сумма</th><th>Примечание</th></tr></thead><tbody id="payroll-entries"></tbody></table></div></section>
|
||||
</div>
|
||||
</main>
|
||||
<dialog id="employee-dialog" class="employee-dialog"><form id="employee-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Карточка команды</p><h2 id="employee-dialog-title">Новый сотрудник</h2></div><button id="employee-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><input id="employee-id" type="hidden"><label>Табельный номер<input id="employee-number" maxlength="80" required></label><label>Фамилия, имя и отчество<input id="employee-name" maxlength="240" required></label><label>Должность<input id="employee-position" maxlength="160"></label><label>Пол<select id="employee-gender"><option value="">Не указан</option><option value="female">Женский</option><option value="male">Мужской</option></select></label><label id="employee-status-wrap" hidden>Статус<select id="employee-status"><option value="active">Работает</option><option value="blocked">Заблокирован</option><option value="dismissed">Уволен</option></select></label><div id="employee-error" class="login-error" role="alert" hidden></div><div class="dialog-actions"><button id="employee-cancel" class="quiet" type="button">Отмена</button><button id="employee-save" type="submit">Сохранить</button></div></form></dialog>
|
||||
<input id="employee-position" type="hidden">
|
||||
<dialog id="employee-dialog" class="employee-dialog"><form id="employee-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Карточка команды</p><h2 id="employee-dialog-title">Новый сотрудник</h2></div><button id="employee-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><input id="employee-id" type="hidden"><label>Табельный номер<input id="employee-number" maxlength="80" required></label><label>Фамилия, имя и отчество<input id="employee-name" maxlength="240" required></label><label>Профессия<select id="employee-profession"><option value="">Не указана</option></select></label><p class="role-note">Роль доступа: сотрудник. Роли и права платформы управляются в Krona Core отдельно от кадровой профессии.</p><label>Пол<select id="employee-gender"><option value="">Не указан</option><option value="female">Женский</option><option value="male">Мужской</option></select></label><label id="employee-status-wrap" hidden>Статус<select id="employee-status"><option value="active">Работает</option><option value="blocked">Заблокирован</option><option value="dismissed">Уволен</option></select></label><div id="employee-pay-section" class="pay-profile-section manager-only"><p class="eyebrow">Зарплатный профиль</p><label>Торговая точка<select id="employee-pay-unit"></select></label><label>Схема расчёта<select id="employee-pay-rule"></select></label><div class="form-row-2"><label>Ставка в час, ₽<input id="employee-pay-rate" type="number" min="0" step="1" value="100"></label><label>Процент от выручки, %<input id="employee-pay-percent" type="number" min="0" max="100" step="0.1" value="0"></label></div><div class="form-row-2"><label>Гарантия за смену, ₽<input id="employee-pay-guarantee" type="number" min="0" step="100" value="0"></label><label>Доля в фонде, %<input id="employee-pay-pool-weight" type="number" min="0" max="200" step="10" value="100"></label></div><label>Действует с даты<input id="employee-pay-effective" type="date"></label><small>Новая версия действует с выбранной даты; предыдущая автоматически закрывается днём ранее.</small></div><div id="employee-error" class="login-error" role="alert" hidden></div><div class="dialog-actions"><button id="employee-cancel" class="quiet" type="button">Отмена</button><button id="employee-save" type="submit">Сохранить</button></div></form></dialog>
|
||||
<dialog id="profession-dialog" class="employee-dialog"><form id="profession-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Кадровый справочник</p><h2>Профессии</h2></div><button id="profession-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><label>Новая профессия<input id="profession-name" maxlength="160" required placeholder="Например, Повар"></label><button type="submit">Добавить профессию</button><div id="profession-list" class="people"></div><div id="profession-error" class="login-error" role="alert" hidden></div></form></dialog>
|
||||
<dialog id="access-dialog" class="employee-dialog"><form method="dialog"><div class="dialog-head"><div><p class="eyebrow">Личный кабинет</p><h2>Ключ сотрудника</h2></div><button id="access-close" class="icon-button" type="submit" aria-label="Закрыть">×</button></div><p>Скопируйте ключ сейчас. В программе хранится только его защищённый отпечаток, повторно показать ключ нельзя.</p><label>Ключ доступа<input id="access-token" readonly></label><div class="dialog-actions"><button id="access-copy" type="button">Скопировать</button><button class="quiet" type="submit">Закрыть</button></div></form></dialog>
|
||||
<footer>Krona Business Platform · Staff</footer>
|
||||
</div>
|
||||
<script src="/staff/app.js" defer></script>
|
||||
|
|
@ -92,5 +124,221 @@ 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 portalManifest = JSON.stringify({ name: 'Krona Staff', short_name: 'Staff', start_url: '/staff', display: 'standalone', background_color: '#F3EAD7', theme_color: '#657255', lang: 'ru' });
|
||||
export const portalCssEnhanced =
|
||||
portalCss +
|
||||
`
|
||||
[data-omni-theme="parchment-light"]{--omni-bg-app:#EFE3CC;--omni-bg-workspace:#F3EAD7;--omni-surface:#F8F2E6;--omni-surface-raised:#FCF8EF;--omni-surface-muted:#E9DDC5;--omni-surface-hover:#EDE0C7;--omni-text-primary:#302A23;--omni-text-secondary:#756A5B;--omni-text-inverse:#FFF;--omni-border:#D9C9AC;--omni-border-strong:#CBB99B;--omni-border-focus:#9A743D;--omni-primary:#9A743D;--omni-primary-hover:#7D5B2D;--omni-primary-soft:#EADBC1;--omni-gold:#B78335;--omni-gold-soft:#EFE0C2;--omni-danger:#A44E3F;--omni-danger-soft:#F1DDD5;--omni-info:#5F7480;--omni-shadow-medium:0 6px 18px rgb(72 55 34/.10);--omni-shadow-strong:0 14px 36px rgb(52 39 24/.16)}
|
||||
[data-omni-theme="noble-green"]{--omni-bg-app:#EDE5D3;--omni-bg-workspace:#F2EAD9;--omni-surface:#F8F2E6;--omni-surface-raised:#FCF8EF;--omni-surface-muted:#E6DBC5;--omni-surface-hover:#E3E9DE;--omni-text-primary:#2E302A;--omni-text-secondary:#6B6C60;--omni-text-inverse:#FFF;--omni-border:#D2CCB9;--omni-border-strong:#9DAA98;--omni-border-focus:#315F46;--omni-primary:#315F46;--omni-primary-hover:#294F3B;--omni-primary-soft:#DDE7DB;--omni-gold:#B78335;--omni-gold-soft:#EFE0C2;--omni-danger:#A44E3F;--omni-danger-soft:#F1DDD5;--omni-info:#527286;--omni-shadow-medium:0 6px 18px rgb(72 55 34/.10);--omni-shadow-strong:0 14px 36px rgb(52 39 24/.16)}
|
||||
[data-omni-theme="graphite-forest-dark"]{--omni-bg-app:#171A18;--omni-bg-workspace:#1D211E;--omni-surface:#252A26;--omni-surface-raised:#2B312C;--omni-surface-muted:#212621;--omni-surface-hover:#2B342D;--omni-text-primary:#EEE7DA;--omni-text-secondary:#B7B3A8;--omni-text-inverse:#171A18;--omni-border:#39423A;--omni-border-strong:#465249;--omni-border-focus:#6F9A79;--omni-primary:#527D60;--omni-primary-hover:#648E70;--omni-primary-soft:#29382E;--omni-gold:#C99A4D;--omni-gold-soft:#403522;--omni-danger:#C06B5F;--omni-danger-soft:#432B28;--omni-info:#7693A0;--omni-shadow-medium:0 8px 22px rgb(0 0 0/.24);--omni-shadow-strong:0 16px 40px rgb(0 0 0/.34)}
|
||||
:root{--omni-radius-sm:6px;--omni-radius-md:8px;--omni-radius-lg:12px;--omni-radius-xl:16px;--omni-font-sans:Inter,"Segoe UI",system-ui,sans-serif;--omni-font-serif:Georgia,serif;--omni-warning:#7A5B16;--omni-warning-soft:#F7EAC9;--bg:var(--omni-bg-app);--workspace:var(--omni-bg-workspace);--surface:var(--omni-surface);--raised:var(--omni-surface-raised);--text:var(--omni-text-primary);--muted:var(--omni-text-secondary);--border:var(--omni-border);--primary:var(--omni-primary);--gold:var(--omni-gold);--danger:var(--omni-danger);--info:var(--omni-info);--shadow:var(--omni-shadow-medium)}
|
||||
body,.auth-screen{background:var(--omni-bg-workspace);color:var(--omni-text-primary)}.topbar,.section-nav{background:color-mix(in srgb,var(--omni-surface) 94%,transparent)}.hero{background:linear-gradient(120deg,var(--omni-primary-active,var(--omni-primary)),var(--omni-primary))}.stats article,.panel,.employee-dialog{background:var(--omni-surface);border-color:var(--omni-border);box-shadow:var(--omni-shadow-medium)}button{background:var(--omni-primary);color:var(--omni-text-inverse)}button:hover{background:var(--omni-primary-hover);filter:none}.quiet{background:transparent;color:var(--omni-primary);border-color:var(--omni-border)}input,select,.timesheet-create input,.timesheet-create select,.employee-dialog input,.employee-dialog select,.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{background:var(--omni-surface-raised);color:var(--omni-text-primary);border-color:var(--omni-border)}button:focus-visible,input:focus-visible,select:focus-visible{outline-color:var(--omni-border-focus)}.theme-control{display:flex;align-items:center;gap:6px;color:var(--omni-text-secondary);font-size:12px}.theme-control select{min-height:36px;border:1px solid var(--omni-border);border-radius:var(--omni-radius-md);padding:0 8px}.role-note{margin:0;padding:10px 12px;border-radius:var(--omni-radius-md);background:var(--omni-primary-soft);color:var(--omni-text-secondary);font-size:12px;line-height:1.45}.payroll-warnings{background:var(--omni-warning-soft)!important;color:var(--omni-warning)!important}.message,.login-error{background:var(--omni-danger-soft);color:var(--omni-danger);border-color:var(--omni-danger)}
|
||||
.section-nav{position:sticky;top:72px;z-index:4;display:flex;align-items:center;gap:8px;padding:10px max(20px,calc((100vw - 1240px)/2));border-bottom:1px solid var(--border);background:rgba(243,234,215,.96);backdrop-filter:blur(12px);overflow-x:auto}.nav-item{flex:0 0 auto;background:transparent;color:var(--muted);border:1px solid transparent}.nav-item.selected{background:var(--surface);color:var(--primary);border-color:var(--border)}.period-control{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;font-weight:700;white-space:nowrap}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{min-height:42px;border:1px solid var(--border);border-radius:8px;background:#fffdf8;color:var(--text);padding:7px 9px}.view-head{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:22px}.view-head h1{margin:4px 0 8px;font:500 clamp(30px,4vw,46px)/1.05 Georgia,serif}.view-head p{margin:0;color:var(--muted)}.finance-layout{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(260px,.7fr);gap:18px;margin-bottom:18px}.stack-form{display:grid;gap:13px}.stack-form label,.inline-form label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.inline-form{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr)) auto;align-items:end;gap:10px}.finance-chart{min-height:280px}.finance-chart svg{display:block;width:100%;height:280px}.chart-legend{display:flex;gap:16px;margin-top:10px;color:var(--muted);font-size:12px}.legend-dot{display:inline-block;width:9px;height:9px;margin-right:5px;border-radius:50%}.income-dot{background:var(--primary)}.expense-dot{background:var(--gold)}.calendar-panel{overflow:auto}.calendar-weekdays,.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(145px,1fr));min-width:1015px}.calendar-weekdays span{padding:8px 10px;color:var(--muted);font-size:12px;font-weight:750;text-align:center}.calendar-day{min-height:142px;padding:10px;border-top:1px solid var(--border);border-right:1px solid var(--border);background:var(--raised)}.calendar-day.outside{opacity:.45}.calendar-day.today{outline:2px solid var(--gold);outline-offset:-2px}.day-number{display:block;margin-bottom:8px;font-weight:750}.shift-card{margin:5px 0;padding:7px;border-left:3px solid var(--primary);border-radius:6px;background:#e8eadf;font-size:11px;line-height:1.35}.shift-card.actual{border-left-color:var(--gold);background:#f3e5cd}.shift-card strong,.shift-card span{display:block}.positive{color:var(--primary)}.negative{color:var(--danger)}.person-access{min-height:36px;padding:0 10px;margin-left:5px}.app-view[hidden]{display:none!important}@media(max-width:900px){.section-nav{top:68px;padding-inline:14px}.period-control{margin-left:8px}.finance-layout{grid-template-columns:1fr}.inline-form{grid-template-columns:repeat(2,minmax(0,1fr))}.inline-form button{grid-column:1/-1}.view-head{align-items:flex-start}.calendar-panel{padding:12px}}@media(max-width:520px){.view-head{display:block}.view-head>button{margin-top:14px}.stats{grid-template-columns:1fr}.stats article:last-child{grid-column:auto}.inline-form{grid-template-columns:1fr}.period-control{order:-1;margin-left:0}.section-nav{top:92px}.finance-chart{min-height:220px}.finance-chart svg{height:220px}}
|
||||
.form-row-2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.pay-profile-section{display:grid;gap:10px;padding:14px;background:var(--raised);border:1px solid var(--border);border-radius:10px;margin-top:6px}
|
||||
.payroll-warnings{background:#fff8e6!important;border:1px solid #e2cb91!important;color:#6b5314!important;margin-bottom:18px}
|
||||
.payroll-warnings ul{margin:6px 0 0 18px;padding:0}
|
||||
.payroll-warnings li{margin-bottom:4px}
|
||||
.section-nav{background:color-mix(in srgb,var(--omni-surface) 94%,transparent)}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{background:var(--omni-surface-raised)}.shift-card{background:var(--omni-primary-soft)}.shift-card.actual{background:var(--omni-gold-soft)}.payroll-warnings{background:var(--omni-warning-soft)!important;border-color:color-mix(in srgb,var(--omni-warning) 35%,transparent)!important;color:var(--omni-warning)!important}[data-omni-theme="graphite-forest-dark"]{--omni-warning:#E0B96C;--omni-warning-soft:#403522}
|
||||
`;
|
||||
|
||||
export const portalJsEnhanced =
|
||||
portalJs.replace('Неверный ключ администратора.', 'Неверный ключ доступа.') +
|
||||
`
|
||||
(() => {
|
||||
const api='/api/v1/staff',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',payRulesCache=[],currentRunId=null;
|
||||
const period=()=>{const month=byId('period-month').value||new Date().toISOString().slice(0,7),start=month+'-01',d=new Date(start+'T00:00:00Z');d.setUTCMonth(d.getUTCMonth()+1);d.setUTCDate(0);return{from:start,to:d.toISOString().slice(0,10)}},query=()=>{const p=period();return'?from='+p.from+'&to='+p.to};
|
||||
async function get(path){const response=await fetch(api+path,{credentials:'same-origin'});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось загрузить данные.');return response.json()}
|
||||
async function send(path,method,body){const response=await fetch(api+path,{method,credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось сохранить данные.');return response.json()}
|
||||
const option=(value,label)=>'<option value="'+esc(value)+'">'+esc(label)+'</option>';
|
||||
function fillSelects(){
|
||||
const unitHtml=units.map(x=>option(x.id,x.name)).join('');
|
||||
['finance-unit','schedule-unit'].forEach(id=>{if(byId(id))byId(id).innerHTML=unitHtml});
|
||||
if(byId('employee-pay-unit')) byId('employee-pay-unit').innerHTML='<option value="">Все точки</option>'+unitHtml;
|
||||
if(byId('employee-pay-rule')&&payRulesCache.length) byId('employee-pay-rule').innerHTML=payRulesCache.map(r=>option(r.id,r.name)).join('');
|
||||
const source=byId('new-time-employee');if(source&&byId('schedule-employee'))byId('schedule-employee').innerHTML=source.innerHTML;
|
||||
}
|
||||
async function initialize(){
|
||||
if(byId('app-shell').hidden||initialized)return;
|
||||
initialized=true;
|
||||
try{
|
||||
const state=await get('/portal');
|
||||
role=state.viewer&&state.viewer.role||'manager';
|
||||
document.querySelectorAll('.manager-only').forEach(node=>node.hidden=role!=='manager');
|
||||
byId('manage-professions').hidden=role!=='manager';
|
||||
byId('viewer').textContent=role==='employee'?'Личный кабинет сотрудника':'Кабинет руководителя';
|
||||
units=(await get('/org-units')).org_units||[];
|
||||
try{payRulesCache=(await get('/pay-rules')).pay_rules||[]}catch{}
|
||||
fillSelects();
|
||||
await loadActive();
|
||||
}catch{initialized=false}
|
||||
}
|
||||
function show(target){active=target;document.querySelectorAll('.app-view').forEach(view=>view.hidden=view.id!==target);document.querySelectorAll('.nav-item').forEach(button=>button.classList.toggle('selected',button.dataset.target===target));loadActive()}
|
||||
async function loadActive(){try{if(active==='finance-view')await loadFinance();if(active==='schedule-view')await loadSchedule();if(active==='payroll-view')await loadPayroll()}catch(error){const box=byId('message');if(box){box.textContent=error.message;box.hidden=false}}}
|
||||
async function loadFinance(){if(role!=='manager')return;const data=await get('/finance'+query()),income=data.daily.reduce((sum,x)=>sum+Number(x.income_minor),0),expense=data.daily.reduce((sum,x)=>sum+Number(x.expense_minor),0);byId('finance-income').textContent=rub(income);byId('finance-expense').textContent=rub(expense);byId('finance-profit').textContent=rub(income-expense);byId('finance-count').textContent=data.entries.length+' записей';byId('finance-category').innerHTML=data.categories.map(x=>option(x.key,(x.kind==='income'?'Доход · ':'Расход · ')+x.label)).join('');byId('finance-entries').innerHTML=data.entries.length?data.entries.map(x=>'<tr><td>'+esc(x.business_date)+'</td><td>'+esc(x.org_unit_name)+'</td><td>'+esc(x.label)+'</td><td><span class="tag">'+(x.metric_key.startsWith('income_')?'Доход':'Расход')+'</span></td><td>'+rub(x.value_minor)+'</td></tr>').join(''):'<tr><td colspan="5" class="empty">За месяц операций нет</td></tr>';drawChart(data.daily)}
|
||||
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='Скопировано'});
|
||||
|
||||
document.addEventListener('click',async event=>{
|
||||
const editBtn=event.target.closest('.edit-employee');
|
||||
if(!editBtn||role!=='manager')return;
|
||||
const empId=editBtn.dataset.id;
|
||||
if(byId('employee-pay-section')) byId('employee-pay-section').hidden=false;
|
||||
try{
|
||||
const a=await get('/employees/'+empId+'/pay-assignment');
|
||||
byId('employee-pay-unit').value=a.org_unit_id||'';
|
||||
byId('employee-pay-rule').value=a.pay_rule_id;
|
||||
byId('employee-pay-rate').value=Math.round(Number(a.rate_minor||0)/100);
|
||||
byId('employee-pay-percent').value=(Number(a.percent_bp||0)/100).toFixed(1);
|
||||
byId('employee-pay-guarantee').value=Math.round(Number(a.guarantee_minor||0)/100);
|
||||
byId('employee-pay-pool-weight').value=Math.round(Number(a.pool_weight_bp||0)/100);
|
||||
byId('employee-pay-effective').value=a.effective_from||'2026-01-01';
|
||||
}catch{
|
||||
if(payRulesCache.length>0) byId('employee-pay-rule').value=payRulesCache[0].id;
|
||||
byId('employee-pay-unit').value='';
|
||||
byId('employee-pay-rate').value=100;
|
||||
byId('employee-pay-percent').value=0;
|
||||
byId('employee-pay-guarantee').value=0;
|
||||
byId('employee-pay-pool-weight').value=100;
|
||||
byId('employee-pay-effective').value='2026-01-01';
|
||||
}
|
||||
});
|
||||
|
||||
if(byId('add-employee')){
|
||||
byId('add-employee').addEventListener('click',()=>{
|
||||
if(byId('employee-pay-section')) byId('employee-pay-section').hidden=false;
|
||||
if(payRulesCache.length>0) byId('employee-pay-rule').value=payRulesCache[0].id;
|
||||
byId('employee-pay-unit').value='';
|
||||
byId('employee-pay-rate').value=100;
|
||||
byId('employee-pay-percent').value=0;
|
||||
byId('employee-pay-guarantee').value=0;
|
||||
byId('employee-pay-pool-weight').value=100;
|
||||
byId('employee-pay-effective').value='2026-01-01';
|
||||
});
|
||||
}
|
||||
|
||||
if(byId('employee-pay-rule')){
|
||||
byId('employee-pay-rule').addEventListener('change',()=>{
|
||||
const rule=payRulesCache.find(r=>r.id===byId('employee-pay-rule').value);
|
||||
if(rule){
|
||||
if(rule.code==='pool_member'){
|
||||
byId('employee-pay-rate').value=100;
|
||||
byId('employee-pay-percent').value=0;
|
||||
byId('employee-pay-guarantee').value=0;
|
||||
byId('employee-pay-pool-weight').value=100;
|
||||
}else if(rule.code==='revenue_percent'){
|
||||
byId('employee-pay-rate').value=100;
|
||||
byId('employee-pay-percent').value=2.5;
|
||||
byId('employee-pay-guarantee').value=3000;
|
||||
byId('employee-pay-pool-weight').value=0;
|
||||
}else if(rule.code==='hourly'){
|
||||
byId('employee-pay-rate').value=100;
|
||||
byId('employee-pay-percent').value=0;
|
||||
byId('employee-pay-guarantee').value=0;
|
||||
byId('employee-pay-pool-weight').value=0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if(byId('employee-form')){
|
||||
byId('employee-form').addEventListener('submit',async()=>{
|
||||
const empId=byId('employee-id').value;
|
||||
if(role==='manager'&&empId&&byId('employee-pay-rule')&&byId('employee-pay-rule').value){
|
||||
try{
|
||||
await send('/employees/'+empId+'/pay-assignment','PUT',{
|
||||
org_unit_id:byId('employee-pay-unit').value||null,
|
||||
pay_rule_id:byId('employee-pay-rule').value,
|
||||
rate_minor:Math.round(Number(byId('employee-pay-rate').value||0)*100),
|
||||
percent_bp:Math.round(Number(byId('employee-pay-percent').value||0)*100),
|
||||
guarantee_minor:Math.round(Number(byId('employee-pay-guarantee').value||0)*100),
|
||||
pool_weight_bp:Math.round(Number(byId('employee-pay-pool-weight').value||0)*100),
|
||||
effective_from:byId('employee-pay-effective').value||'2026-01-01'
|
||||
});
|
||||
}catch(e){console.error('Pay assignment save error:',e)}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if(byId('payroll-calc-btn')){
|
||||
byId('payroll-calc-btn').addEventListener('click',async()=>{
|
||||
const btn=byId('payroll-calc-btn');
|
||||
btn.disabled=true;
|
||||
byId('payroll-warnings').hidden=true;
|
||||
byId('payroll-calc-status').hidden=true;
|
||||
try{
|
||||
const month=byId('period-month').value||new Date().toISOString().slice(0,7);
|
||||
const res=await send('/payroll/calculate','POST',{month});
|
||||
currentRunId=res.run_id;
|
||||
if(res.warnings&&res.warnings.length){
|
||||
byId('payroll-warnings').innerHTML='<strong>Предупреждения при расчёте:</strong><ul>'+res.warnings.map(w=>'<li>'+esc(w.message)+'</li>').join('')+'</ul>';
|
||||
byId('payroll-warnings').hidden=false;
|
||||
}
|
||||
byId('payroll-preview-title').textContent='Предварительный результат: '+rub(res.total_accrued_minor);
|
||||
byId('payroll-preview-rows').innerHTML=res.totals.length?res.totals.map(x=>'<tr><td>'+esc(x.display_name)+'</td><td>'+esc(x.position||'—')+'</td><td>'+x.days+'</td><td>'+hours(x.worked_minutes)+'</td><td>'+rub(x.accrued_minor)+'</td></tr>').join(''):'<tr><td colspan="5" class="empty">Нет начислений</td></tr>';
|
||||
byId('payroll-preview-card').hidden=false;
|
||||
}catch(err){alert(err.message||'Ошибка расчёта зарплаты')}finally{btn.disabled=false}
|
||||
});
|
||||
}
|
||||
|
||||
const themeSelect=byId('theme-select');
|
||||
const allowedThemes=['noble-green','parchment-light','graphite-forest-dark'];
|
||||
const savedTheme=localStorage.getItem('krona-staff-theme');
|
||||
if(savedTheme&&allowedThemes.includes(savedTheme))document.documentElement.dataset.omniTheme=savedTheme;
|
||||
themeSelect.value=document.documentElement.dataset.omniTheme||'noble-green';
|
||||
themeSelect.addEventListener('change',()=>{document.documentElement.dataset.omniTheme=themeSelect.value;localStorage.setItem('krona-staff-theme',themeSelect.value)});
|
||||
|
||||
let professionsCache=[];
|
||||
async function loadProfessions(){
|
||||
const result=await get('/professions');
|
||||
professionsCache=result.professions||[];
|
||||
const select=byId('employee-profession');
|
||||
const current=select.value;
|
||||
select.innerHTML='<option value="">Не указана</option>'+professionsCache.filter(x=>x.status==='active').map(x=>option(x.id,x.name)).join('');
|
||||
if([...select.options].some(x=>x.value===current))select.value=current;
|
||||
byId('profession-list').innerHTML=professionsCache.length?professionsCache.map(x=>'<div class="person"><span class="avatar">'+esc(x.name).slice(0,1).toUpperCase()+'</span><div><strong>'+esc(x.name)+'</strong><small>'+(x.status==='active'?'Активна':'В архиве')+'</small></div></div>').join(''):'<div class="empty">Профессии ещё не добавлены</div>';
|
||||
}
|
||||
byId('employee-profession').addEventListener('change',()=>{const selected=byId('employee-profession').selectedOptions[0];byId('employee-position').value=selected&&selected.value?selected.textContent:''});
|
||||
document.addEventListener('click',event=>{const target=event.target.closest?.('.edit-employee,#add-employee');if(!target)return;setTimeout(async()=>{await loadProfessions();const position=byId('employee-position').value;const match=professionsCache.find(x=>x.name===position);byId('employee-profession').value=match?.id||''},0)},true);
|
||||
byId('manage-professions').addEventListener('click',async()=>{byId('profession-error').hidden=true;await loadProfessions();byId('profession-dialog').showModal()});
|
||||
byId('profession-close').addEventListener('click',()=>byId('profession-dialog').close());
|
||||
byId('profession-form').addEventListener('submit',async event=>{event.preventDefault();const name=byId('profession-name').value.trim();if(!name)return;try{const created=await send('/professions','POST',{name});byId('profession-name').value='';await loadProfessions();byId('employee-profession').value=created.id;byId('employee-position').value=created.name}catch(error){byId('profession-error').textContent=error.message||'Не удалось сохранить профессию.';byId('profession-error').hidden=false}});
|
||||
|
||||
if(byId('payroll-approve-btn')){
|
||||
byId('payroll-approve-btn').addEventListener('click',async()=>{
|
||||
if(!currentRunId)return;
|
||||
const btn=byId('payroll-approve-btn');
|
||||
btn.disabled=true;
|
||||
try{
|
||||
const res=await send('/payroll/runs/'+currentRunId+'/approve','POST',{});
|
||||
byId('payroll-preview-card').hidden=true;
|
||||
byId('payroll-calc-status').textContent='Расчёт успешно утверждён! Начислено: '+rub(res.total_accrued_minor)+' ('+res.entries_posted+' начислений проведено в реестр).';
|
||||
byId('payroll-calc-status').hidden=false;
|
||||
await loadPayroll();
|
||||
}catch(err){alert(err.message||'Ошибка утверждения расчёта')}finally{btn.disabled=false}
|
||||
});
|
||||
}
|
||||
|
||||
const observer=new MutationObserver(()=>{if(role!=='manager')return;document.querySelectorAll('.edit-employee').forEach(edit=>{if(edit.parentElement.querySelector('.person-access'))return;const button=document.createElement('button');button.type='button';button.className='quiet compact person-access';button.dataset.id=edit.dataset.id;button.textContent='Личный вход';edit.insertAdjacentElement('afterend',button)});fillSelects()});observer.observe(byId('employees'),{childList:true,subtree:true});const shellObserver=new MutationObserver(()=>{if(!byId('app-shell').hidden){initialized=false;initialize()}else initialized=false});shellObserver.observe(byId('app-shell'),{attributes:true,attributeFilter:['hidden']});setTimeout(initialize,0);
|
||||
})();
|
||||
`;
|
||||
|
||||
export const portalManifest = JSON.stringify({
|
||||
name: 'Krona Staff',
|
||||
short_name: 'Staff',
|
||||
start_url: '/staff',
|
||||
display: 'standalone',
|
||||
background_color: '#F3EAD7',
|
||||
theme_color: '#657255',
|
||||
lang: 'ru',
|
||||
});
|
||||
export const 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',
|
||||
);
|
||||
|
|
|
|||
1216
src/portal.ts
1216
src/portal.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,2 +1,2 @@
|
|||
export const SERVICE_NAME = 'krona-staff';
|
||||
export const SERVICE_VERSION = '0.2.5';
|
||||
export const SERVICE_VERSION = '0.3.1';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,10 @@ describe('OpenAPI runtime contract', () => {
|
|||
.sort();
|
||||
expect(runtimePaths).toEqual(documentedPaths);
|
||||
for (const path of documentedPaths) {
|
||||
const runtimePath = path.replace('{employeeId}', '01J00000000000000000000000').replace('{workDate}', '2026-09-24');
|
||||
const runtimePath = path
|
||||
.replace('{employeeId}', '01J00000000000000000000000')
|
||||
.replace('{workDate}', '2026-09-24')
|
||||
.replace('{runId}', '01J00000000000000000000000');
|
||||
const operations = documented.paths[path] as Record<string, unknown>;
|
||||
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
||||
const method = methods.find((candidate) => candidate in operations);
|
||||
|
|
|
|||
|
|
@ -100,52 +100,103 @@ describe('attendance portal', () => {
|
|||
|
||||
it('scopes an employee portal to the linked Core user', async () => {
|
||||
const { database, calls } = fakeDatabase([[], [], []]);
|
||||
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database });
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }),
|
||||
database,
|
||||
});
|
||||
const response = await app.inject('/api/v1/staff/portal');
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json<{ viewer: { role: string } }>();
|
||||
expect(body.viewer.role).toBe('employee');
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID)).toBe(true);
|
||||
expect(
|
||||
calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID),
|
||||
).toBe(true);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('writes a terminal event and its outbox notification atomically', async () => {
|
||||
const eventId = '01J00000000000000000000002';
|
||||
const employeeId = '01J00000000000000000000003';
|
||||
const { database, calls } = fakeDatabase([[], [{ id: eventId }], []]);
|
||||
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), database });
|
||||
const { database, calls } = fakeDatabase([[{ id: employeeId }], [{ id: eventId }], []]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }),
|
||||
database,
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/staff/attendance/events',
|
||||
payload: { event_id: eventId, employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', source_event_id: eventId },
|
||||
payload: {
|
||||
event_id: eventId,
|
||||
employee_id: employeeId,
|
||||
event_type: 'check_in',
|
||||
occurred_at: '2026-09-24T01:00:00.000Z',
|
||||
source_event_id: eventId,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0]?.[0]).toContain('staff_employees');
|
||||
expect(calls[0]?.[0]).toContain("status = 'active'");
|
||||
expect(calls[2]?.[0]).toContain('staff_outbox');
|
||||
expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects an unknown terminal employee without writing attendance or outbox', async () => {
|
||||
const eventId = '01J00000000000000000000002';
|
||||
const employeeId = '01J00000000000000000000003';
|
||||
const { database, calls } = fakeDatabase([[]]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }),
|
||||
database,
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/staff/attendance/events',
|
||||
payload: {
|
||||
event_id: eventId,
|
||||
employee_id: employeeId,
|
||||
event_type: 'check_in',
|
||||
occurred_at: '2026-09-24T01:00:00.000Z',
|
||||
source_event_id: eventId,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.[0]).toContain("status = 'active'");
|
||||
expect(calls[0]?.[0]).not.toContain('INSERT INTO staff_employees');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('does not give an employee manager access to the timesheet', async () => {
|
||||
const { database } = fakeDatabase([]);
|
||||
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database });
|
||||
const response = await app.inject({ method: 'PUT', url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24', payload: { minutes_worked: 480, status: 'approved' } });
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }),
|
||||
database,
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24',
|
||||
payload: { minutes_worked: 480, status: 'approved' },
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('allows a manager to create an employee and writes an audit record', async () => {
|
||||
const employeeId = '01J00000000000000000000004';
|
||||
const { database, calls } = fakeDatabase([[{
|
||||
id: employeeId,
|
||||
personnel_number: 'A-17',
|
||||
display_name: 'Анна Орлова',
|
||||
position: 'Администратор',
|
||||
gender: 'female',
|
||||
status: 'active',
|
||||
}]]);
|
||||
const { database, calls } = fakeDatabase([
|
||||
[
|
||||
{
|
||||
id: employeeId,
|
||||
personnel_number: 'A-17',
|
||||
display_name: 'Анна Орлова',
|
||||
position: 'Администратор',
|
||||
gender: 'female',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||
database,
|
||||
|
|
@ -185,9 +236,17 @@ describe('attendance portal', () => {
|
|||
|
||||
it('updates an employee with before and after values in the audit trail', async () => {
|
||||
const employeeId = '01J00000000000000000000004';
|
||||
const before = { id: employeeId, core_user_id: null, personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', status: 'active' };
|
||||
const before = {
|
||||
id: employeeId,
|
||||
core_user_id: null,
|
||||
personnel_number: 'A-17',
|
||||
display_name: 'Анна Орлова',
|
||||
position: 'Администратор',
|
||||
gender: 'female',
|
||||
status: 'active',
|
||||
};
|
||||
const after = { ...before, position: 'Старший администратор' };
|
||||
const { database, calls } = fakeDatabase([[before], [after], []]);
|
||||
const { database, calls } = fakeDatabase([[before], [], [after], []]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||
database,
|
||||
|
|
@ -199,10 +258,10 @@ describe('attendance portal', () => {
|
|||
payload: { position: 'Старший администратор' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(calls[0]?.[0]).toContain('FOR UPDATE');
|
||||
expect(calls[2]?.[1]?.[4]).toEqual(before);
|
||||
expect(calls[2]?.[1]?.[5]).toEqual(after);
|
||||
expect(calls[3]?.[1]?.[4]).toEqual(before);
|
||||
expect(calls[3]?.[1]?.[5]).toEqual(after);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
|
|
@ -211,10 +270,37 @@ describe('attendance portal', () => {
|
|||
const orgUnitId = '01J00000000000000000000005';
|
||||
const sessionId = '01J00000000000000000000006';
|
||||
const attendanceRows = [
|
||||
{ id: '01J00000000000000000000010', employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' },
|
||||
{ id: '01J00000000000000000000011', employee_id: employeeId, event_type: 'check_out', occurred_at: '2026-09-24T09:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' },
|
||||
{
|
||||
id: '01J00000000000000000000010',
|
||||
employee_id: employeeId,
|
||||
event_type: 'check_in',
|
||||
occurred_at: '2026-09-24T01:00:00.000Z',
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: '2026-09-24',
|
||||
automatic_close_at: '2026-09-24T19:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '01J00000000000000000000011',
|
||||
employee_id: employeeId,
|
||||
event_type: 'check_out',
|
||||
occurred_at: '2026-09-24T09:00:00.000Z',
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: '2026-09-24',
|
||||
automatic_close_at: '2026-09-24T19:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const session = { id: sessionId, employee_id: employeeId, org_unit_id: orgUnitId, business_date: '2026-09-24', worked_minutes: 480, status: 'pending_review', anomaly: null, source_event_ids: attendanceRows.map((row) => row.id), auto_closed: false, revision: 1 };
|
||||
const session = {
|
||||
id: sessionId,
|
||||
employee_id: employeeId,
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: '2026-09-24',
|
||||
worked_minutes: 480,
|
||||
status: 'pending_review',
|
||||
anomaly: null,
|
||||
source_event_ids: attendanceRows.map((row) => row.id),
|
||||
auto_closed: false,
|
||||
revision: 1,
|
||||
};
|
||||
const { database, calls } = fakeDatabase([attendanceRows, [session], []]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||
|
|
@ -227,7 +313,11 @@ describe('attendance portal', () => {
|
|||
payload: { date_from: '2026-09-24', date_to: '2026-09-24' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ sessions_created_or_updated: 1, anomalous_sessions: 0, unresolved_events: 0 });
|
||||
expect(response.json()).toMatchObject({
|
||||
sessions_created_or_updated: 1,
|
||||
anomalous_sessions: 0,
|
||||
unresolved_events: 0,
|
||||
});
|
||||
expect(calls[1]?.[0]).toContain('INSERT INTO staff_work_sessions');
|
||||
expect(calls[1]?.[1]?.[7]).toBe(480);
|
||||
await app.close();
|
||||
|
|
@ -235,7 +325,21 @@ describe('attendance portal', () => {
|
|||
|
||||
it('requires a reason when confirmed hours are corrected', async () => {
|
||||
const sessionId = '01J00000000000000000000006';
|
||||
const current = { id: sessionId, employee_id: '01J00000000000000000000004', org_unit_id: '01J00000000000000000000005', business_date: '2026-09-24', started_at: '2026-09-24T01:00:00.000Z', ended_at: '2026-09-24T09:00:00.000Z', worked_minutes: 480, status: 'pending_review', anomaly: null, correction_note: '', source_event_ids: [], auto_closed: false, revision: 1 };
|
||||
const current = {
|
||||
id: sessionId,
|
||||
employee_id: '01J00000000000000000000004',
|
||||
org_unit_id: '01J00000000000000000000005',
|
||||
business_date: '2026-09-24',
|
||||
started_at: '2026-09-24T01:00:00.000Z',
|
||||
ended_at: '2026-09-24T09:00:00.000Z',
|
||||
worked_minutes: 480,
|
||||
status: 'pending_review',
|
||||
anomaly: null,
|
||||
correction_note: '',
|
||||
source_event_ids: [],
|
||||
auto_closed: false,
|
||||
revision: 1,
|
||||
};
|
||||
const { database, calls } = fakeDatabase([[current]]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
const actorA = parseUlid('01J0000000000000000000001A');
|
||||
const actorB = parseUlid('01J0000000000000000000001B');
|
||||
const eventId = newUlid();
|
||||
const professionId = newUlid();
|
||||
|
||||
beforeAll(async () => {
|
||||
const role = await database.pool.query(
|
||||
|
|
@ -23,6 +24,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
afterAll(async () => {
|
||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||
await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]);
|
||||
await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]);
|
||||
});
|
||||
await database.close();
|
||||
});
|
||||
|
|
@ -47,6 +49,27 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
);
|
||||
expect(visibleToB.rowCount).toBe(0);
|
||||
|
||||
await database.tenantTransaction(tenantA, actorA, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_professions (id, tenant_id, code, name)
|
||||
VALUES ($1, $2, 'cook', 'Повар')`,
|
||||
[professionId, tenantA],
|
||||
),
|
||||
);
|
||||
const professionsVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query('SELECT id FROM staff_professions WHERE id = $1', [professionId]),
|
||||
);
|
||||
expect(professionsVisibleToB.rowCount).toBe(0);
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_professions (id, tenant_id, code, name)
|
||||
VALUES ($1, $2, 'foreign', 'Чужая профессия')`,
|
||||
[newUlid(), tenantA],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
|
|
@ -57,5 +80,27 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_pay_assignments
|
||||
(id, tenant_id, employee_id, pay_rule_id, effective_from, params, pool_weight_bp, created_by)
|
||||
VALUES ($1, $2, $3, $4, '2026-01-01', '{}'::jsonb, 0, $5)`,
|
||||
[newUlid(), tenantA, newUlid(), newUlid(), actorB],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_payroll_ledger
|
||||
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, ref_id, correlation_id, note, created_by, external_key)
|
||||
VALUES ($1, $2, $3, '2026-01-01', 'accrual', 1000, 'RUB', 'payroll_run', $4, $5, 'test', $6, $7)`,
|
||||
[newUlid(), tenantA, newUlid(), newUlid(), newUlid(), actorB, newUlid()],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,4 +24,11 @@ describe('container deployment boundaries', () => {
|
|||
expect(compose.services.migrate?.depends_on).toHaveProperty('db-role');
|
||||
expect(compose.services.app?.depends_on).toHaveProperty('migrate');
|
||||
});
|
||||
|
||||
it('removes only the machine terminal state after explicit uninstall confirmation', () => {
|
||||
const installer = readFileSync('installer/KronaStaffTerminal.nsi', 'utf8');
|
||||
expect(installer).toContain('RMDir /r "$COMMONAPPDATA\\Krona\\StaffTerminal"');
|
||||
expect(installer).not.toContain('RMDir /r "$APPDATA\\Krona\\StaffTerminal"');
|
||||
expect(installer).not.toContain('RMDir /r "$COMMONAPPDATA\\Krona"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
644
tests/unit/payroll-auto-calc.test.ts
Normal file
644
tests/unit/payroll-auto-calc.test.ts
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import type { PoolClient, QueryResult, QueryResultRow } from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RequestContext } from '../../src/core/context.js';
|
||||
import { newUlid } from '../../src/core/ids.js';
|
||||
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js';
|
||||
import {
|
||||
cateringPreset,
|
||||
defaultParams,
|
||||
findRoleTemplate,
|
||||
} from '../../src/domain/payroll/presets.js';
|
||||
import {
|
||||
approvePayrollRun,
|
||||
calculatePayrollPeriod,
|
||||
getEmployeePayAssignment,
|
||||
saveEmployeePayAssignment,
|
||||
} from '../../src/domain/payroll/service.js';
|
||||
|
||||
const RUB = 100;
|
||||
|
||||
const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!;
|
||||
const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!;
|
||||
|
||||
function mockContext(overrides?: Partial<RequestContext>): RequestContext {
|
||||
return {
|
||||
tenantId: newUlid(),
|
||||
actorId: newUlid(),
|
||||
correlationId: newUlid(),
|
||||
permissions: new Set(['staff:payroll:write', 'staff:payroll:read', 'staff:pay_rule:manage']),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Owner spreadsheet payroll schemes', () => {
|
||||
it('calculates 11% pool for 3 cooks (100 ₽/h) + 2.5% admin with 3000 ₽ guarantee on 150k revenue', () => {
|
||||
const revenueRub = 150_000;
|
||||
const revenueMinor = revenueRub * RUB;
|
||||
|
||||
// 3 cooks working full 12-hour shifts
|
||||
const cook1: DayParticipant = {
|
||||
employeeId: 'COOK1',
|
||||
workedMinutes: 720,
|
||||
ruleCode: poolRole.code,
|
||||
rule: poolRole.rule,
|
||||
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
||||
poolWeightBp: poolRole.poolWeightBp, // 10000
|
||||
};
|
||||
const cook2: DayParticipant = {
|
||||
employeeId: 'COOK2',
|
||||
workedMinutes: 720,
|
||||
ruleCode: poolRole.code,
|
||||
rule: poolRole.rule,
|
||||
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
||||
poolWeightBp: poolRole.poolWeightBp,
|
||||
};
|
||||
const cook3: DayParticipant = {
|
||||
employeeId: 'COOK3',
|
||||
workedMinutes: 720,
|
||||
ruleCode: poolRole.code,
|
||||
rule: poolRole.rule,
|
||||
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
||||
poolWeightBp: poolRole.poolWeightBp,
|
||||
};
|
||||
|
||||
// 1 admin working 12 hours with 2.5% revenue + 100 ₽/h and 3000 ₽ guarantee
|
||||
const admin: DayParticipant = {
|
||||
employeeId: 'ADMIN',
|
||||
workedMinutes: 720,
|
||||
ruleCode: percentRole.code,
|
||||
rule: percentRole.rule,
|
||||
params: {
|
||||
rate_minor: 100 * RUB,
|
||||
percent_bp: 250, // 2.5%
|
||||
guarantee_minor: 3_000 * RUB,
|
||||
},
|
||||
poolWeightBp: percentRole.poolWeightBp, // 0
|
||||
};
|
||||
|
||||
const dayResult = calculateDay({
|
||||
businessDate: '2026-05-15',
|
||||
orgUnitId: 'UNIT1',
|
||||
metrics: { revenue_minor: revenueMinor },
|
||||
poolRule: cateringPreset.poolRule!,
|
||||
shiftNormMinutes: cateringPreset.shiftNormMinutes,
|
||||
roundingUnitMinor: 100,
|
||||
currencyCode: 'RUB',
|
||||
participants: [cook1, cook2, cook3, admin],
|
||||
});
|
||||
|
||||
// Total pool = 11% of 150,000 ₽ = 16,500 ₽ (1,650,000 kopecks)
|
||||
expect(dayResult.poolAmountMinor).toBe(1_650_000);
|
||||
|
||||
// 3 cooks share pool equally: 16,500 / 3 = 5,500 ₽
|
||||
// Hourly: 12h * 100 ₽/h = 1,200 ₽
|
||||
// Total per cook: 5,500 + 1,200 = 6,700 ₽ = 670,000 kopecks
|
||||
const lineCook1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
|
||||
const lineCook2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
|
||||
const lineCook3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
|
||||
|
||||
expect(lineCook1.poolShareMinor).toBe(550_000);
|
||||
expect(lineCook1.amountMinor).toBe(670_000);
|
||||
expect(lineCook2.poolShareMinor).toBe(550_000);
|
||||
expect(lineCook2.amountMinor).toBe(670_000);
|
||||
expect(lineCook3.poolShareMinor).toBe(550_000);
|
||||
expect(lineCook3.amountMinor).toBe(670_000);
|
||||
|
||||
// Admin: 2.5% of 150,000 ₽ (3,750 ₽) + hourly (1,200 ₽) = 4,950 ₽ (495,000 kopecks), exceeding 3,000 ₽ guarantee
|
||||
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
|
||||
expect(lineAdmin.poolShareMinor).toBe(0);
|
||||
expect(lineAdmin.amountMinor).toBe(495_000);
|
||||
|
||||
// Total daily payroll: 6,700 * 3 + 4,950 = 25,050 ₽ (2,505,000 kopecks)
|
||||
const totalDay = dayResult.lines.reduce((sum, l) => sum + l.amountMinor, 0);
|
||||
expect(totalDay).toBe(2_505_000);
|
||||
});
|
||||
|
||||
it('correctly redistributes pool when a cook leaves early (time-slice model)', () => {
|
||||
const revenueMinor = 150_000 * RUB;
|
||||
|
||||
// Cook 1 leaves after 6 hours (half shift)
|
||||
const cook1: DayParticipant = {
|
||||
employeeId: 'COOK1',
|
||||
workedMinutes: 360, // 6h
|
||||
ruleCode: poolRole.code,
|
||||
rule: poolRole.rule,
|
||||
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
||||
poolWeightBp: poolRole.poolWeightBp,
|
||||
};
|
||||
// Cook 2 and Cook 3 work full 12h shifts
|
||||
const cook2: DayParticipant = {
|
||||
employeeId: 'COOK2',
|
||||
workedMinutes: 720, // 12h
|
||||
ruleCode: poolRole.code,
|
||||
rule: poolRole.rule,
|
||||
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
||||
poolWeightBp: poolRole.poolWeightBp,
|
||||
};
|
||||
const cook3: DayParticipant = {
|
||||
employeeId: 'COOK3',
|
||||
workedMinutes: 720, // 12h
|
||||
ruleCode: poolRole.code,
|
||||
rule: poolRole.rule,
|
||||
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
|
||||
poolWeightBp: poolRole.poolWeightBp,
|
||||
};
|
||||
|
||||
const dayResult = calculateDay({
|
||||
businessDate: '2026-05-15',
|
||||
orgUnitId: 'UNIT1',
|
||||
metrics: { revenue_minor: revenueMinor },
|
||||
poolRule: cateringPreset.poolRule!,
|
||||
shiftNormMinutes: cateringPreset.shiftNormMinutes,
|
||||
roundingUnitMinor: 100,
|
||||
currencyCode: 'RUB',
|
||||
participants: [cook1, cook2, cook3],
|
||||
});
|
||||
|
||||
// Pool = 16,500 ₽ (1,650,000 kopecks)
|
||||
expect(dayResult.poolAmountMinor).toBe(1_650_000);
|
||||
|
||||
// Time-slice redistribution:
|
||||
// First 6 hours: 8,250 ₽ split among 3 cooks = 2,750 ₽ each
|
||||
// Last 6 hours: 8,250 ₽ split among remaining 2 cooks = 4,125 ₽ each
|
||||
// Cook 1 pool share = 2,750 ₽ (275,000 kopecks)
|
||||
// Cook 2 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
|
||||
// Cook 3 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
|
||||
const line1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
|
||||
const line2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
|
||||
const line3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
|
||||
|
||||
expect(line1.poolShareMinor).toBe(275_000);
|
||||
expect(line2.poolShareMinor).toBe(687_500);
|
||||
expect(line3.poolShareMinor).toBe(687_500);
|
||||
|
||||
// The entire pool of 16,500 ₽ is completely distributed without loss
|
||||
expect(line1.poolShareMinor + line2.poolShareMinor + line3.poolShareMinor).toBe(1_650_000);
|
||||
|
||||
// Cook 1: 2,750 + 6h * 100 ₽ = 3,350 ₽ (335,000 kopecks)
|
||||
// Cook 2 & 3: 6,875 + 12h * 100 ₽ = 8,075 ₽ (807,500 kopecks)
|
||||
expect(line1.amountMinor).toBe(335_000);
|
||||
expect(line2.amountMinor).toBe(807_500);
|
||||
expect(line3.amountMinor).toBe(807_500);
|
||||
});
|
||||
|
||||
it('triggers admin guaranteed minimum when revenue percentage is below threshold', () => {
|
||||
// Low revenue: 50,000 ₽. 2.5% = 1,250 ₽ + 12h * 100 ₽ = 2,450 ₽, below guarantee of 3,000 ₽
|
||||
const revenueMinor = 50_000 * RUB;
|
||||
|
||||
const admin: DayParticipant = {
|
||||
employeeId: 'ADMIN',
|
||||
workedMinutes: 720,
|
||||
ruleCode: percentRole.code,
|
||||
rule: percentRole.rule,
|
||||
params: {
|
||||
rate_minor: 100 * RUB,
|
||||
percent_bp: 250,
|
||||
guarantee_minor: 3_000 * RUB,
|
||||
},
|
||||
poolWeightBp: 0,
|
||||
};
|
||||
|
||||
const dayResult = calculateDay({
|
||||
businessDate: '2026-05-15',
|
||||
orgUnitId: 'UNIT1',
|
||||
metrics: { revenue_minor: revenueMinor },
|
||||
poolRule: cateringPreset.poolRule!,
|
||||
shiftNormMinutes: cateringPreset.shiftNormMinutes,
|
||||
roundingUnitMinor: 100,
|
||||
currencyCode: 'RUB',
|
||||
participants: [admin],
|
||||
});
|
||||
|
||||
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
|
||||
expect(lineAdmin.amountMinor).toBe(3_000 * RUB); // exactly 300,000 kopecks
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payroll service calculation and warning engine', () => {
|
||||
function makeMockClient(handlers: {
|
||||
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
|
||||
}): PoolClient {
|
||||
return {
|
||||
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
|
||||
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
|
||||
const params =
|
||||
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
||||
return Promise.resolve(handlers.query(sql, params));
|
||||
},
|
||||
release: () => {},
|
||||
} as unknown as PoolClient;
|
||||
}
|
||||
|
||||
const defaultRole = cateringPreset.roles.find((r) => r.code === 'pool_member')!;
|
||||
|
||||
it('calculates payroll period from confirmed sessions and revenue metrics', async () => {
|
||||
const executedQueries: string[] = [];
|
||||
const unitId = newUlid();
|
||||
const empId = newUlid();
|
||||
const ruleId = newUlid();
|
||||
|
||||
const client = makeMockClient({
|
||||
query: (sql) => {
|
||||
executedQueries.push(sql);
|
||||
|
||||
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
|
||||
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||
}
|
||||
if (sql.includes('FROM staff_work_sessions s')) {
|
||||
// Confirmed work sessions
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: newUlid(),
|
||||
employee_id: empId,
|
||||
org_unit_id: unitId,
|
||||
business_date: '2026-05-01',
|
||||
worked_minutes: 720,
|
||||
display_name: 'Иван Поваров',
|
||||
position: 'Повар',
|
||||
org_unit_name: 'Кухня Центр',
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
command: '',
|
||||
oid: 0,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
if (sql.includes('FROM staff_daily_metrics')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
org_unit_id: unitId,
|
||||
business_date: '2026-05-01',
|
||||
revenue_minor: '10000000', // 100,000 ₽
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
command: '',
|
||||
oid: 0,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
if (sql.includes('FROM staff_pay_assignments a')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: newUlid(),
|
||||
employee_id: empId,
|
||||
org_unit_id: unitId,
|
||||
pay_rule_id: ruleId,
|
||||
effective_from: '2026-01-01',
|
||||
effective_to: null,
|
||||
params: { rate_minor: 10000 },
|
||||
pool_weight_bp: 10000,
|
||||
rule_code: 'pool_member',
|
||||
rule: defaultRole.rule,
|
||||
default_pool_weight_bp: 10000,
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
command: '',
|
||||
oid: 0,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
if (sql.includes('FROM staff_org_unit_terms')) {
|
||||
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||
}
|
||||
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await calculatePayrollPeriod(client, mockContext(), {
|
||||
from: '2026-05-01',
|
||||
to: '2026-05-01',
|
||||
orgUnitId: unitId,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('calculated');
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(result.totals).toHaveLength(1);
|
||||
const firstTotal = result.totals[0]!;
|
||||
expect(firstTotal.display_name).toBe('Иван Поваров');
|
||||
expect(firstTotal.worked_minutes).toBe(720);
|
||||
// 11% of 100,000 ₽ = 11,000 ₽ pool + 12 * 100 ₽ hourly = 12,200 ₽ = 1,220,000 kopecks
|
||||
expect(firstTotal.accrued_minor).toBe('1220000');
|
||||
expect(result.total_accrued_minor).toBe('1220000');
|
||||
|
||||
// Check that staff_payroll_lines insertion occurred
|
||||
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_payroll_lines'))).toBe(true);
|
||||
// Check outbox and audit log
|
||||
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_outbox'))).toBe(true);
|
||||
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_audit_log'))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects warnings for missing revenue, unassigned employees, and empty shifts', async () => {
|
||||
const unitId = newUlid();
|
||||
const empId = newUlid();
|
||||
|
||||
const client = makeMockClient({
|
||||
query: (sql) => {
|
||||
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
|
||||
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||
}
|
||||
if (sql.includes('FROM staff_work_sessions s')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: newUlid(),
|
||||
employee_id: empId,
|
||||
org_unit_id: unitId,
|
||||
business_date: '2026-05-02',
|
||||
worked_minutes: 600,
|
||||
display_name: 'Петр Безпрофилев',
|
||||
position: 'Курьер',
|
||||
org_unit_name: 'Кухня Центр',
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
command: '',
|
||||
oid: 0,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
// Return NO metrics (missing revenue) and NO assignments
|
||||
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await calculatePayrollPeriod(client, mockContext(), {
|
||||
from: '2026-05-02',
|
||||
to: '2026-05-02',
|
||||
});
|
||||
|
||||
expect(result.warnings.some((w) => w.kind === 'missing_revenue')).toBe(true);
|
||||
expect(result.warnings.some((w) => w.kind === 'missing_pay_assignment')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when no confirmed sessions exist for the period', async () => {
|
||||
const client = makeMockClient({
|
||||
query: () => ({ rows: [], rowCount: 0, command: '', oid: 0, fields: [] }),
|
||||
});
|
||||
|
||||
const result = await calculatePayrollPeriod(client, mockContext(), {
|
||||
from: '2026-06-01',
|
||||
to: '2026-06-30',
|
||||
});
|
||||
|
||||
expect(result.warnings.some((w) => w.kind === 'no_confirmed_sessions')).toBe(true);
|
||||
expect(result.totals).toHaveLength(0);
|
||||
expect(result.total_accrued_minor).toBe('0');
|
||||
});
|
||||
|
||||
it('rejects recalculation if run is already approved', async () => {
|
||||
const runId = newUlid();
|
||||
const client = makeMockClient({
|
||||
query: (sql) => {
|
||||
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
|
||||
return {
|
||||
rows: [{ id: runId, status: 'approved' }],
|
||||
rowCount: 1,
|
||||
command: '',
|
||||
oid: 0,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
calculatePayrollPeriod(client, mockContext(), {
|
||||
from: '2026-05-01',
|
||||
to: '2026-05-31',
|
||||
}),
|
||||
).rejects.toThrow('Расчёт за этот период уже утверждён.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payroll approval and ledger idempotency', () => {
|
||||
function makeMockClient(handlers: {
|
||||
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
|
||||
}): PoolClient {
|
||||
return {
|
||||
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
|
||||
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
|
||||
const params =
|
||||
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
||||
return Promise.resolve(handlers.query(sql, params));
|
||||
},
|
||||
release: () => {},
|
||||
} as unknown as PoolClient;
|
||||
}
|
||||
|
||||
it('posts accrual lines to ledger with external_key and marks run approved', async () => {
|
||||
let ledgerInsertQuery = '';
|
||||
let ledgerInsertParams: unknown[] = [];
|
||||
const runId = newUlid();
|
||||
const empId = newUlid();
|
||||
|
||||
const client = makeMockClient({
|
||||
query: (sql, params) => {
|
||||
if (sql.includes('SELECT id, period_start::text AS period_start')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: runId,
|
||||
period_start: '2026-05-01',
|
||||
period_end: '2026-05-31',
|
||||
status: 'calculated',
|
||||
total_accrued_minor: '1220000',
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
command: '',
|
||||
oid: 0,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
if (sql.includes('FROM staff_payroll_lines')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
employee_id: empId,
|
||||
amount_minor: '1220000',
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
command: '',
|
||||
oid: 0,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
if (sql.includes('INSERT INTO staff_payroll_ledger')) {
|
||||
ledgerInsertQuery = sql;
|
||||
ledgerInsertParams = params;
|
||||
return { rows: [{ id: newUlid() }], rowCount: 1, command: '', oid: 0, fields: [] };
|
||||
}
|
||||
return { rows: [], rowCount: 1, command: '', oid: 0, fields: [] };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await approvePayrollRun(client, mockContext(), runId);
|
||||
|
||||
expect(result.status).toBe('approved');
|
||||
expect(result.entries_posted).toBe(1);
|
||||
expect(result.total_accrued_minor).toBe('1220000');
|
||||
|
||||
// Verify external_key structure and ON CONFLICT clause
|
||||
expect(ledgerInsertQuery).toContain(
|
||||
'ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING',
|
||||
);
|
||||
expect(ledgerInsertParams).toContain(`payroll_run:${runId}:${empId}`);
|
||||
});
|
||||
|
||||
it('rejects approval if run is not in calculated status', async () => {
|
||||
const runId = newUlid();
|
||||
const client = makeMockClient({
|
||||
query: (sql) => {
|
||||
if (sql.includes('SELECT id, period_start::text AS period_start')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: runId,
|
||||
period_start: '2026-05-01',
|
||||
period_end: '2026-05-31',
|
||||
status: 'approved',
|
||||
total_accrued_minor: '1220000',
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
command: '',
|
||||
oid: 0,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(approvePayrollRun(client, mockContext(), runId)).rejects.toThrow(
|
||||
'Утвердить можно только расчёт в статусе calculated.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pay assignment management', () => {
|
||||
it('saves and retrieves pay assignment with default fallbacks', async () => {
|
||||
let savedParamsJson = '';
|
||||
let closedPreviousVersion = false;
|
||||
const tenantId = newUlid();
|
||||
const actorId = newUlid();
|
||||
const corrId = newUlid();
|
||||
const empId = newUlid();
|
||||
const ruleId = newUlid();
|
||||
const assignId = newUlid();
|
||||
|
||||
const client = {
|
||||
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
|
||||
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
|
||||
const params =
|
||||
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
||||
|
||||
if (sql.includes('FROM staff_employees')) {
|
||||
return Promise.resolve({ rows: [{ id: empId }], rowCount: 1 });
|
||||
}
|
||||
if (sql.includes('FROM staff_pay_rules WHERE tenant_id')) {
|
||||
return Promise.resolve({ rows: [{ id: ruleId, pool_weight_bp: 10000 }], rowCount: 1 });
|
||||
}
|
||||
if (sql.includes('INSERT INTO staff_pay_assignments')) {
|
||||
savedParamsJson = String(params[7]);
|
||||
return Promise.resolve({ rows: [], rowCount: 1 });
|
||||
}
|
||||
if (sql.includes('SET effective_to = ($3::date')) {
|
||||
closedPreviousVersion = true;
|
||||
expect(String(params[2])).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
return Promise.resolve({ rows: [], rowCount: 1 });
|
||||
}
|
||||
if (sql.includes('FROM staff_pay_assignments a')) {
|
||||
return Promise.resolve({
|
||||
rows: [
|
||||
{
|
||||
id: assignId,
|
||||
employee_id: empId,
|
||||
org_unit_id: null,
|
||||
pay_rule_id: ruleId,
|
||||
effective_from: '2026-05-01',
|
||||
effective_to: null,
|
||||
params: { rate_minor: 12000, percent_bp: 0, guarantee_minor: 0 },
|
||||
pool_weight_bp: 10000,
|
||||
note: 'Главный повар',
|
||||
rule_code: 'pool_member',
|
||||
rule_name: 'Повар (доля от фонда)',
|
||||
default_pool_weight_bp: 10000,
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
},
|
||||
release: () => {},
|
||||
} as unknown as PoolClient;
|
||||
|
||||
const saved = await saveEmployeePayAssignment(client, tenantId, actorId, corrId, empId, {
|
||||
pay_rule_id: ruleId,
|
||||
rate_minor: 12000,
|
||||
note: 'Главный повар',
|
||||
});
|
||||
|
||||
expect(saved.rate_minor).toBe(12000);
|
||||
expect(saved.pool_weight_bp).toBe(10000);
|
||||
expect(saved.note).toBe('Главный повар');
|
||||
expect(savedParamsJson).toContain('"rate_minor":12000');
|
||||
expect(closedPreviousVersion).toBe(true);
|
||||
|
||||
const fetched = await getEmployeePayAssignment(client, tenantId, empId);
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched?.rate_minor).toBe(12000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tenant and employee isolation guarantees', () => {
|
||||
it('enforces tenant_id in every database query in payroll service', () => {
|
||||
const serviceSrc = readFileSync('src/domain/payroll/service.ts', 'utf8');
|
||||
|
||||
// All SELECTs, INSERTs, UPDATEs, DELETEs in service.ts must contain tenant_id
|
||||
const sqlStatements = serviceSrc.match(/`[^`]*(?:SELECT|INSERT|UPDATE|DELETE)[^`]*`/g) ?? [];
|
||||
expect(sqlStatements.length).toBeGreaterThan(5);
|
||||
|
||||
for (const sql of sqlStatements) {
|
||||
expect(sql).toContain('tenant_id');
|
||||
}
|
||||
});
|
||||
|
||||
it('restricts /payroll query to actor employee or requires manager permissions', () => {
|
||||
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
|
||||
|
||||
// Ensure the employee isolation clause is present in GET /payroll
|
||||
expect(operationsSrc).toContain('($3::boolean OR e.core_user_id = $4 OR e.id = $4)');
|
||||
expect(operationsSrc).toContain("context.permissions.has('staff:payroll:read')");
|
||||
expect(operationsSrc).toContain("context.permissions.has('staff:payslip:read_own')");
|
||||
});
|
||||
|
||||
it('restricts pay-assignment access to owner employee or manager', () => {
|
||||
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
|
||||
|
||||
expect(operationsSrc).toContain(
|
||||
'!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId',
|
||||
);
|
||||
});
|
||||
|
||||
it('confirms external_key and unique index are declared in 0008_employee_access migration', () => {
|
||||
const migrationSql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8');
|
||||
expect(migrationSql).toContain(
|
||||
'ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;',
|
||||
);
|
||||
expect(migrationSql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx');
|
||||
expect(migrationSql).toContain(
|
||||
'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;',
|
||||
);
|
||||
});
|
||||
});
|
||||
58
tests/unit/security-boundaries.test.ts
Normal file
58
tests/unit/security-boundaries.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('security boundary regressions', () => {
|
||||
it('requires section-specific permissions before owner workbook writes', () => {
|
||||
const source = readFileSync('src/operations.ts', 'utf8');
|
||||
const permissionCheck = source.indexOf("requiredPermissions.add('staff:org_unit:write')");
|
||||
const transaction = source.indexOf(
|
||||
'const counts = await database.tenantTransaction',
|
||||
permissionCheck,
|
||||
);
|
||||
expect(permissionCheck).toBeGreaterThan(0);
|
||||
expect(source.slice(permissionCheck, transaction)).toContain('staff:employee:write');
|
||||
expect(source.slice(permissionCheck, transaction)).toContain('staff:metric:write');
|
||||
expect(source.slice(permissionCheck, transaction)).toContain('staff:shift:confirm');
|
||||
expect(source.slice(permissionCheck, transaction)).toContain('staff:payout:write');
|
||||
expect(source.slice(permissionCheck, transaction)).toContain('await auth.require');
|
||||
});
|
||||
|
||||
it('does not grant broad authenticated-user access to state or IPC', () => {
|
||||
const store = readFileSync('apps/KronaStaffTerminal/Services/TerminalStore.cs', 'utf8');
|
||||
const ipc = readFileSync('apps/KronaStaffTerminal/Services/TerminalIpc.cs', 'utf8');
|
||||
expect(store).not.toContain('WellKnownSidType.AuthenticatedUserSid');
|
||||
expect(store).not.toContain('catch\n {\n Directory.CreateDirectory');
|
||||
expect(ipc).not.toContain('WellKnownSidType.AuthenticatedUserSid');
|
||||
expect(ipc).toContain('IpcRequestAuthenticator');
|
||||
expect(store).toContain('return await InitializeFreshStateAsync()');
|
||||
expect(store).toContain('FileShare.None');
|
||||
expect(store).toContain('await SaveAsync(state)');
|
||||
});
|
||||
|
||||
it('enforces active employee status in standalone token resolution', () => {
|
||||
const portal = readFileSync('src/portal.ts', 'utf8');
|
||||
expect(portal).toContain("employee.status = 'active'");
|
||||
const patchRoute = portal.indexOf('api.patch<{ Params: { employeeId: string }');
|
||||
const accessRoute = portal.indexOf("'/employees/:employeeId/access-token'", patchRoute);
|
||||
expect(portal.slice(patchRoute, accessRoute)).toContain(
|
||||
'DELETE FROM staff_standalone_employee_access',
|
||||
);
|
||||
});
|
||||
|
||||
it('versions payroll formulas independently per organization unit', () => {
|
||||
const service = readFileSync('src/domain/payroll/service.ts', 'utf8');
|
||||
const migration = readFileSync('migrations/0009_professions.up.sql', 'utf8');
|
||||
expect(service.match(/org_unit_id IS NOT DISTINCT FROM \$4::varchar\(26\)/g)).toHaveLength(3);
|
||||
expect(service).toContain('pg_advisory_xact_lock');
|
||||
expect(migration).toContain('staff_pay_assignments_effective_version_idx');
|
||||
});
|
||||
|
||||
it('uses stable external identities for repeatable workbook imports', () => {
|
||||
const importer = readFileSync('scripts/import-owner-workbook.py', 'utf8');
|
||||
const migration = readFileSync('migrations/0009_professions.up.sql', 'utf8');
|
||||
expect(importer).toContain('xlsx:staff:');
|
||||
expect(importer).not.toContain('enumerate(names');
|
||||
expect(importer).not.toContain('{row}|{date_text}');
|
||||
expect(migration).toContain('staff_employees_external_ref_idx');
|
||||
});
|
||||
});
|
||||
|
|
@ -78,6 +78,32 @@ describe('payroll foundation migration', () => {
|
|||
);
|
||||
});
|
||||
|
||||
const payrollTables = [
|
||||
'staff_pay_rules',
|
||||
'staff_pay_assignments',
|
||||
'staff_payroll_runs',
|
||||
'staff_payroll_lines',
|
||||
'staff_payroll_ledger',
|
||||
];
|
||||
|
||||
it.each(payrollTables)('verifies %s has tenant_id column', (table) => {
|
||||
const create = sql.slice(
|
||||
sql.indexOf(`CREATE TABLE ${table}`),
|
||||
sql.indexOf(';', sql.indexOf(`CREATE TABLE ${table}`)),
|
||||
);
|
||||
expect(create).toContain('tenant_id VARCHAR(26) NOT NULL');
|
||||
});
|
||||
|
||||
it('enforces tenant isolation and RLS on staff_payroll_ledger with select and insert policies', () => {
|
||||
expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY');
|
||||
expect(sql).toContain('ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY');
|
||||
expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger');
|
||||
expect(sql).toContain("FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true))");
|
||||
expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger');
|
||||
expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)");
|
||||
expect(sql).toContain("created_by = current_setting('app.user_id', true)");
|
||||
});
|
||||
|
||||
it('keeps the money ledger append-only for the runtime role', () => {
|
||||
expect(sql).toContain('CREATE TABLE staff_payroll_ledger');
|
||||
expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY');
|
||||
|
|
@ -88,3 +114,28 @@ describe('payroll foundation migration', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('employee access migration', () => {
|
||||
const sql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8');
|
||||
|
||||
it('declares external_key and staff_payroll_ledger_external_key_idx index on staff_payroll_ledger', () => {
|
||||
expect(sql).toContain('ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;');
|
||||
expect(sql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx');
|
||||
expect(sql).toContain(
|
||||
'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;',
|
||||
);
|
||||
});
|
||||
|
||||
it('protects standalone employee access with tenant isolation and RLS', () => {
|
||||
const create = sql.slice(
|
||||
sql.indexOf('CREATE TABLE staff_standalone_employee_access'),
|
||||
sql.indexOf(';', sql.indexOf('CREATE TABLE staff_standalone_employee_access')),
|
||||
);
|
||||
expect(create).toContain('tenant_id VARCHAR(26) NOT NULL');
|
||||
expect(sql).toContain('ALTER TABLE staff_standalone_employee_access ENABLE ROW LEVEL SECURITY');
|
||||
expect(sql).toContain('ALTER TABLE staff_standalone_employee_access FORCE ROW LEVEL SECURITY');
|
||||
expect(sql).toContain('CREATE POLICY staff_standalone_employee_access_tenant_isolation');
|
||||
expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue