From 88804a0eeada74b97bf1f02c6f4a7ecb7f0a49d8 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 18:12:24 +0700 Subject: [PATCH] feat(staff): add finance calendar and employee portal --- api/openapi.yaml | 133 +++- .../KronaStaffService.csproj | 6 +- apps/KronaStaffService/KronaStaffWorker.cs | 2 +- .../KronaStaffTerminal.csproj | 8 +- .../Services/TerminalIpc.cs | 2 +- docs/TERMINAL-API-REQUIREMENTS.md | 2 +- installer/KronaStaffTerminal.nsi | 2 +- migrations/0008_employee_access.down.sql | 3 + migrations/0008_employee_access.up.sql | 32 + module.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/adapters/identity.ts | 12 +- src/app.ts | 8 +- src/operations.ts | 707 ++++++++++++++++++ src/portal-ui.ts | 62 +- src/portal.ts | 124 ++- src/version.ts | 2 +- tests/integration/health.test.ts | 2 +- 19 files changed, 1088 insertions(+), 27 deletions(-) create mode 100644 migrations/0008_employee_access.down.sql create mode 100644 migrations/0008_employee_access.up.sql create mode 100644 src/operations.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index 68d254d..31f4eaa 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Krona Staff API - version: 0.2.5 + version: 0.3.0 description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя. license: name: Proprietary @@ -255,6 +255,137 @@ paths: responses: '200': { description: Строка табеля сохранена } '403': { description: Недостаточно прав } + /employees/{employeeId}/access-token: + post: + summary: Выпустить новый одноразово показываемый ключ личного кабинета сотрудника + operationId: rotateEmployeeAccessToken + tags: [Employees] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: employeeId + required: true + schema: { $ref: '#/components/schemas/Ulid' } + responses: + '200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток } + '403': { description: Недостаточно прав } + '404': { description: Сотрудник не найден } + /org-units: + get: + summary: Получить доступные торговые точки + operationId: listStaffOrgUnits + tags: [Organization] + security: [{ bearerAuth: [] }] + responses: + '200': { description: Список доступных торговых точек } + '403': { description: Недостаточно прав } + /finance: + get: + summary: Получить доходы, расходы и дневную динамику за период + operationId: getStaffFinance + tags: [Finance] + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: from, required: true, schema: { type: string, format: date } } + - { in: query, name: to, required: true, schema: { type: string, format: date } } + responses: + '200': { description: Финансовые операции и агрегаты } + '403': { description: Недостаточно прав } + /finance/{orgUnitId}/{businessDate}/{metricKey}: + put: + summary: Сохранить сумму по финансовой категории за день + operationId: saveStaffFinanceMetric + tags: [Finance] + security: [{ bearerAuth: [] }] + parameters: + - { in: path, name: orgUnitId, required: true, schema: { $ref: '#/components/schemas/Ulid' } } + - { in: path, name: businessDate, required: true, schema: { type: string, format: date } } + - { in: path, name: metricKey, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [value_minor] + properties: + value_minor: { type: integer, minimum: 0 } + currency_code: { type: string, pattern: '^[A-Z]{3}$' } + responses: + '200': { description: Сумма сохранена } + '403': { description: Недостаточно прав } + /schedule: + get: + summary: Получить плановые смены и фактические отметки сканера + operationId: getStaffSchedule + tags: [Schedule] + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: from, required: true, schema: { type: string, format: date } } + - { in: query, name: to, required: true, schema: { type: string, format: date } } + responses: + '200': { description: Календарь плановых и фактических смен } + '403': { description: Недостаточно прав } + /schedule/{employeeId}/{businessDate}: + put: + summary: Запланировать или удалить смену сотрудника + operationId: saveStaffPlannedShift + tags: [Schedule] + security: [{ bearerAuth: [] }] + parameters: + - { in: path, name: employeeId, required: true, schema: { $ref: '#/components/schemas/Ulid' } } + - { in: path, name: businessDate, required: true, schema: { type: string, format: date } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [org_unit_id, planned_minutes] + properties: + org_unit_id: { $ref: '#/components/schemas/Ulid' } + planned_minutes: { type: integer, minimum: 0, maximum: 1440 } + start_time: { type: string } + end_time: { type: string } + note: { type: string, maxLength: 500 } + responses: + '200': { description: План смены сохранён } + '403': { description: Недостаточно прав } + /payroll: + get: + summary: Получить зарплатные итоги и расчётный лист за период + operationId: getStaffPayroll + tags: [Payroll] + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: from, required: true, schema: { type: string, format: date } } + - { in: query, name: to, required: true, schema: { type: string, format: date } } + responses: + '200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' } + '403': { description: Недостаточно прав } + /imports/owner-workbook: + post: + summary: Идемпотентно импортировать локально разобранную книгу владельца + operationId: importStaffOwnerWorkbook + tags: [Import] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [source_name] + properties: + source_name: { type: string, maxLength: 240 } + units: { type: array, items: { type: object } } + financial_entries: { type: array, items: { type: object } } + employees: { type: array, items: { type: object } } + schedule_items: { type: array, items: { type: object } } + payroll_entries: { type: array, items: { type: object } } + responses: + '200': { description: Пакет импортирован повторяемо без дублирования } + '403': { description: Недостаточно прав } components: securitySchemes: bearerAuth: diff --git a/apps/KronaStaffService/KronaStaffService.csproj b/apps/KronaStaffService/KronaStaffService.csproj index 2027942..e6fe5e7 100644 --- a/apps/KronaStaffService/KronaStaffService.csproj +++ b/apps/KronaStaffService/KronaStaffService.csproj @@ -8,9 +8,9 @@ x64 win-x64 KronaStaffService - 0.2.5 - 0.2.5.0 - 0.2.5.0 + 0.3.0 + 0.3.0.0 + 0.3.0.0 diff --git a/apps/KronaStaffService/KronaStaffWorker.cs b/apps/KronaStaffService/KronaStaffWorker.cs index 3fd8706..68ff700 100644 --- a/apps/KronaStaffService/KronaStaffWorker.cs +++ b/apps/KronaStaffService/KronaStaffWorker.cs @@ -225,7 +225,7 @@ public sealed class KronaStaffWorker : BackgroundService return new IpcServiceStatus { ServiceRunning = true, - ServiceVersion = "0.2.5", + ServiceVersion = "0.3.0", ScannerInSession0Supported = _scannerSession0Supported, ScannerDiagnostic = _scannerDiagnostic, PendingSyncCount = pendingCount, diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj index 00cea39..72a56a9 100644 --- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj +++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj @@ -17,10 +17,10 @@ enable enable x64 - 0.2.5 - 0.2.5.0 - 0.2.5.0 - 0.2.5 + 0.3.0 + 0.3.0.0 + 0.3.0.0 + 0.3.0 diff --git a/apps/KronaStaffTerminal/Services/TerminalIpc.cs b/apps/KronaStaffTerminal/Services/TerminalIpc.cs index 11729b2..24deeee 100644 --- a/apps/KronaStaffTerminal/Services/TerminalIpc.cs +++ b/apps/KronaStaffTerminal/Services/TerminalIpc.cs @@ -25,7 +25,7 @@ public sealed class IpcResponse public sealed class IpcServiceStatus { public bool ServiceRunning { get; set; } = true; - public string ServiceVersion { get; set; } = "0.2.5"; + public string ServiceVersion { get; set; } = "0.3.0"; public bool ScannerInSession0Supported { get; set; } public string ScannerDiagnostic { get; set; } = ""; public int PendingSyncCount { get; set; } diff --git a/docs/TERMINAL-API-REQUIREMENTS.md b/docs/TERMINAL-API-REQUIREMENTS.md index 815a956..8feb5f7 100644 --- a/docs/TERMINAL-API-REQUIREMENTS.md +++ b/docs/TERMINAL-API-REQUIREMENTS.md @@ -31,7 +31,7 @@ POST /api/v1/staff/attendance/events | `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` | | `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` | | `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` | -| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.2.5 (Windows NT 10.0; Win64; x64)` | +| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.0 (Windows NT 10.0; Win64; x64)` | --- diff --git a/installer/KronaStaffTerminal.nsi b/installer/KronaStaffTerminal.nsi index 7ffa56e..9102745 100644 --- a/installer/KronaStaffTerminal.nsi +++ b/installer/KronaStaffTerminal.nsi @@ -5,7 +5,7 @@ SetCompressor /SOLID lzma !include "x64.nsh" !ifndef APP_VERSION - !define APP_VERSION "0.2.5" + !define APP_VERSION "0.3.0" !endif !ifndef PUBLISH_DIR !error "PUBLISH_DIR is required" diff --git a/migrations/0008_employee_access.down.sql b/migrations/0008_employee_access.down.sql new file mode 100644 index 0000000..d01a0aa --- /dev/null +++ b/migrations/0008_employee_access.down.sql @@ -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; diff --git a/migrations/0008_employee_access.up.sql b/migrations/0008_employee_access.up.sql new file mode 100644 index 0000000..980dbbc --- /dev/null +++ b/migrations/0008_employee_access.up.sql @@ -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; diff --git a/module.json b/module.json index 1a2a33f..56109d3 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "$schema": "./schemas/module.schema.json", "id": "ru.krona.staff", "name": "Krona Staff", - "version": "0.2.5", + "version": "0.3.0", "min_platform_version": "3.2.0", "dependencies": ["ru.krona.core"], "optional_dependencies": [], diff --git a/package-lock.json b/package-lock.json index 94451ab..d336af8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@krona/staff", - "version": "0.2.5", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@krona/staff", - "version": "0.2.5", + "version": "0.3.0", "dependencies": { "@fastify/helmet": "^13.0.2", "@fastify/sensible": "^6.0.3", diff --git a/package.json b/package.json index b2a7d1a..b7de79c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@krona/staff", - "version": "0.2.5", + "version": "0.3.0", "private": true, "type": "module", "engines": { diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index 7c737e3..c422f52 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -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; diff --git a/src/app.ts b/src/app.ts index b062355..1b2c37e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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; diff --git a/src/operations.ts b/src/operations.ts new file mode 100644 index 0000000..c4a316f --- /dev/null +++ b/src/operations.ts @@ -0,0 +1,707 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { PoolClient } from 'pg'; +import type { RequestContext } from './core/context.js'; +import { newUlid, parseUlid } from './core/ids.js'; +import type { Database } from './infrastructure/database.js'; + +const datePattern = /^\d{4}-\d{2}-\d{2}$/; + +export const financialMetricDefinitions = [ + ['income_cash', 'Наличные', 'income'], + ['income_card', 'Безналичные', 'income'], + ['income_transfer', 'Переводы', 'income'], + ['income_sber_market', 'Сбер Маркет', 'income'], + ['income_yandex', 'Яндекс', 'income'], + ['income_certificate', 'Сертификаты', 'income'], + ['income_recycling', 'Масло и вторсырьё', 'income'], + ['expense_products', 'Продукты', 'expense'], + ['expense_supplies', 'Расходные товары', 'expense'], + ['expense_taxi', 'Такси', 'expense'], + ['expense_communications', 'Связь', 'expense'], + ['expense_advertising', 'Реклама', 'expense'], + ['expense_owner_payroll', 'Зарплата руководства', 'expense'], + ['expense_employee_payroll', 'Зарплата сотрудников', 'expense'], + ['expense_taxes', 'Налоги', 'expense'], + ['expense_rent', 'Аренда', 'expense'], + ['expense_utilities', 'Коммунальные услуги', 'expense'], + ['expense_other', 'Дополнительные расходы', 'expense'], + ['expense_gifts', 'Подарки', 'expense'], +] as const; + +const financialMetricKeys = new Set(financialMetricDefinitions.map(([key]) => key)); + +interface OperationsAuth { + resolve(request: FastifyRequest): Promise; + require(request: FastifyRequest, permission: string): Promise; + assertMutationOrigin(request: FastifyRequest): void; +} + +interface PeriodQuery { + from?: string; + to?: string; +} + +interface FinanceParams { + orgUnitId: string; + businessDate: string; + metricKey: string; +} + +interface FinanceBody { + value_minor: number; + currency_code?: string; +} + +interface ScheduleParams { + employeeId: string; + businessDate: string; +} + +interface ScheduleBody { + org_unit_id: string; + planned_minutes: number; + start_time?: string; + end_time?: string; + note?: string; +} + +interface ImportUnit { + code: string; + name: string; + external_ref?: string; +} + +interface ImportFinanceEntry { + org_unit_code: string; + business_date: string; + metric_key: string; + value_minor: number; + currency_code?: string; +} + +interface ImportEmployee { + external_ref: string; + personnel_number: string; + display_name: string; + position?: string; +} + +interface ImportScheduleItem { + external_key: string; + employee_external_ref: string; + org_unit_code: string; + business_date: string; + planned_minutes: number; + location_code?: string; +} + +interface ImportPayrollEntry { + external_key: string; + employee_external_ref: string; + business_date: string; + kind: 'accrual' | 'payout' | 'adjustment'; + amount_minor: number; + note?: string; +} + +interface OwnerWorkbookImportBody { + source_name: string; + units?: ImportUnit[]; + financial_entries?: ImportFinanceEntry[]; + employees?: ImportEmployee[]; + schedule_items?: ImportScheduleItem[]; + payroll_entries?: ImportPayrollEntry[]; +} + +export async function registerOperationsRoutes( + api: FastifyInstance, + database: Database, + auth: OperationsAuth, +) { + api.get('/org-units', async (request) => { + const context = await auth.resolve(request); + const canManage = context.permissions.has('staff:org_unit:read'); + return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const rows = await client.query( + `SELECT DISTINCT u.id, u.code, u.name, u.timezone + FROM staff_org_units u + WHERE u.deleted_at IS NULL + AND ( + $1::boolean + OR EXISTS ( + SELECT 1 + FROM staff_employees e + WHERE e.tenant_id = u.tenant_id + AND e.primary_org_unit_id = u.id + AND e.core_user_id = $2 + ) + ) + ORDER BY u.name`, + [canManage, context.actorId], + ); + return { org_units: rows.rows }; + }); + }); + + api.get<{ Querystring: PeriodQuery }>('/finance', { + schema: { querystring: periodSchema }, + }, async (request) => { + const context = await auth.require(request, 'staff:metric:read'); + const period = parsePeriod(request.query); + return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const entries = await client.query( + `SELECT d.id, d.org_unit_id, u.code AS org_unit_code, u.name AS org_unit_name, + d.business_date, d.metric_key, m.label, d.value::text AS value_minor, + d.currency_code + FROM staff_daily_metrics d + JOIN staff_org_units u ON u.tenant_id = d.tenant_id AND u.id = d.org_unit_id + JOIN staff_metric_definitions m + ON m.tenant_id = d.tenant_id AND m.key = d.metric_key + WHERE d.business_date BETWEEN $1 AND $2 + AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\' + OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\') + ORDER BY d.business_date, u.name, d.metric_key`, + [period.from, period.to], + ); + const daily = await client.query( + `SELECT d.business_date, d.org_unit_id, u.name AS org_unit_name, + COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'income\\_%' ESCAPE '\\'), 0)::text AS income_minor, + COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'expense\\_%' ESCAPE '\\'), 0)::text AS expense_minor + FROM staff_daily_metrics d + JOIN staff_org_units u ON u.tenant_id = d.tenant_id AND u.id = d.org_unit_id + WHERE d.business_date BETWEEN $1 AND $2 + AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\' + OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\') + GROUP BY d.business_date, d.org_unit_id, u.name + ORDER BY d.business_date, u.name`, + [period.from, period.to], + ); + const monthly = await client.query( + `SELECT to_char(date_trunc('month', d.business_date), 'YYYY-MM') AS month, + d.org_unit_id, u.name AS org_unit_name, + COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'income\\_%' ESCAPE '\\'), 0)::text AS income_minor, + COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'expense\\_%' ESCAPE '\\'), 0)::text AS expense_minor + FROM staff_daily_metrics d + JOIN staff_org_units u ON u.tenant_id = d.tenant_id AND u.id = d.org_unit_id + WHERE d.business_date BETWEEN $1 AND $2 + AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\' + OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\') + GROUP BY date_trunc('month', d.business_date), d.org_unit_id, u.name + ORDER BY month, u.name`, + [period.from, period.to], + ); + return { + period, + categories: financialMetricDefinitions.map(([key, label, kind]) => ({ key, label, kind })), + entries: entries.rows, + daily: daily.rows, + monthly: monthly.rows, + }; + }); + }); + + api.put<{ Params: FinanceParams; Body: FinanceBody }>( + '/finance/:orgUnitId/:businessDate/:metricKey', + { + schema: { + params: { + type: 'object', additionalProperties: false, + required: ['orgUnitId', 'businessDate', 'metricKey'], + properties: { + orgUnitId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + businessDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + metricKey: { type: 'string', pattern: '^[a-z][a-z0-9_]{0,60}$' }, + }, + }, + body: { + type: 'object', additionalProperties: false, required: ['value_minor'], + properties: { + value_minor: { type: 'integer', minimum: 0, maximum: 9_000_000_000_000_000 }, + currency_code: { type: 'string', pattern: '^[A-Z]{3}$' }, + }, + }, + }, + }, + async (request, reply) => { + auth.assertMutationOrigin(request); + const context = await auth.require(request, 'staff:metric:write'); + const orgUnitId = parseUlid(request.params.orgUnitId); + const businessDate = parseDate(request.params.businessDate); + if (!businessDate || !financialMetricKeys.has(request.params.metricKey)) { + return reply.badRequest('Неизвестная дата или категория.'); + } + const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + await ensureMetricDefinitions(client, context.tenantId); + const unit = await client.query( + 'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL', + [context.tenantId, orgUnitId], + ); + if (unit.rowCount === 0) return null; + const saved = await client.query( + `INSERT INTO staff_daily_metrics + (id, tenant_id, org_unit_id, business_date, metric_key, value, + currency_code, source, recorded_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'manual', $8) + ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key) + DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code, + source = 'manual', recorded_by = EXCLUDED.recorded_by, updated_at = now() + RETURNING id, org_unit_id, business_date, metric_key, + value::text AS value_minor, currency_code`, + [newUlid(), context.tenantId, orgUnitId, request.params.businessDate, + request.params.metricKey, request.body.value_minor, + request.body.currency_code ?? 'RUB', context.actorId], + ); + await refreshRevenueMetric(client, context.tenantId, context.actorId, + orgUnitId, request.params.businessDate); + const savedRow = saved.rows[0] as Record | undefined; + await writeAudit(client, context, 'finance.metric.saved', 'DailyMetric', + String(savedRow?.id), savedRow ?? {}); + return savedRow; + }); + if (!row) return reply.notFound('Торговая точка не найдена.'); + return reply.send(row); + }, + ); + + api.get<{ Querystring: PeriodQuery }>('/schedule', { + schema: { querystring: periodSchema }, + }, async (request, reply) => { + const context = await auth.resolve(request); + const canManage = context.permissions.has('staff:attendance:manage'); + if (!canManage && !context.permissions.has('staff:shift:read')) { + return reply.forbidden('Недостаточно прав для просмотра графика.'); + } + const period = parsePeriod(request.query); + return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const rows = await client.query( + `SELECT w.id, w.employee_id, e.display_name, e.position, w.org_unit_id, + u.name AS org_unit_name, w.business_date, + COALESCE((w.attributes->>'planned_minutes')::integer, 0) AS planned_minutes, + w.attributes->>'start_time' AS start_time, + w.attributes->>'end_time' AS end_time, + w.attributes->>'note' AS note, + s.started_at AS actual_started_at, s.ended_at AS actual_ended_at, + s.worked_minutes AS actual_minutes, s.status AS actual_status, + s.anomaly AS actual_anomaly + FROM staff_work_items w + JOIN staff_employees e ON e.tenant_id = w.tenant_id AND e.id = w.employee_id + JOIN staff_org_units u ON u.tenant_id = w.tenant_id AND u.id = w.org_unit_id + LEFT JOIN staff_work_sessions s + ON s.tenant_id = w.tenant_id AND s.employee_id = w.employee_id + AND s.org_unit_id = w.org_unit_id AND s.business_date = w.business_date + WHERE w.item_kind = 'planned_shift' + AND w.business_date BETWEEN $1 AND $2 + AND ($3::boolean OR e.core_user_id = $4) + ORDER BY w.business_date, e.display_name`, + [period.from, period.to, canManage, context.actorId], + ); + return { period, shifts: rows.rows }; + }); + }); + + api.put<{ Params: ScheduleParams; Body: ScheduleBody }>( + '/schedule/:employeeId/:businessDate', + { + schema: { + params: { + type: 'object', additionalProperties: false, + required: ['employeeId', 'businessDate'], + properties: { + employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + businessDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + }, + }, + body: { + type: 'object', additionalProperties: false, + required: ['org_unit_id', 'planned_minutes'], + properties: { + org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + planned_minutes: { type: 'integer', minimum: 0, maximum: 1440 }, + start_time: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }, + end_time: { type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }, + note: { type: 'string', maxLength: 500 }, + }, + }, + }, + }, + async (request, reply) => { + auth.assertMutationOrigin(request); + const context = await auth.require(request, 'staff:shift:confirm'); + const employeeId = parseUlid(request.params.employeeId); + const orgUnitId = parseUlid(request.body.org_unit_id); + if (!parseDate(request.params.businessDate)) return reply.badRequest('Некорректная дата.'); + const externalKey = `schedule:${employeeId}:${request.params.businessDate}`; + const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + if (request.body.planned_minutes === 0) { + const removed = await client.query( + `DELETE FROM staff_work_items + WHERE tenant_id = $1 AND external_key = $2 RETURNING id`, + [context.tenantId, externalKey], + ); + const removedRow = removed.rows[0] as { id?: string } | undefined; + await writeAudit(client, context, 'schedule.shift.removed', 'WorkItem', + String(removedRow?.id ?? employeeId), { business_date: request.params.businessDate }); + return { removed: (removed.rowCount ?? 0) > 0 }; + } + const exists = await client.query( + `SELECT e.id + FROM staff_employees e + JOIN staff_org_units u ON u.tenant_id = e.tenant_id + WHERE e.tenant_id = $1 AND e.id = $2 AND u.id = $3 + AND e.status = 'active' AND u.deleted_at IS NULL`, + [context.tenantId, employeeId, orgUnitId], + ); + if (exists.rowCount === 0) return null; + const attributes = { + planned_minutes: request.body.planned_minutes, + start_time: request.body.start_time ?? null, + end_time: request.body.end_time ?? null, + note: request.body.note?.trim() ?? '', + }; + const saved = await client.query( + `INSERT INTO staff_work_items + (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, + attributes, source, external_key, created_by) + VALUES ($1, $2, $3, $4, $5, 'planned_shift', $6, 'manual', $7, $8) + ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL + DO UPDATE SET org_unit_id = EXCLUDED.org_unit_id, + attributes = EXCLUDED.attributes, source = 'manual' + RETURNING id, employee_id, org_unit_id, business_date, attributes`, + [newUlid(), context.tenantId, employeeId, orgUnitId, + request.params.businessDate, attributes, externalKey, context.actorId], + ); + const savedRow = saved.rows[0] as Record | undefined; + await writeAudit(client, context, 'schedule.shift.saved', 'WorkItem', + String(savedRow?.id), savedRow ?? {}); + return savedRow; + }); + if (!result) return reply.notFound('Сотрудник или торговая точка не найдены.'); + return reply.send(result); + }, + ); + + api.get<{ Querystring: PeriodQuery }>('/payroll', { + schema: { querystring: periodSchema }, + }, async (request, reply) => { + const context = await auth.resolve(request); + const canManage = context.permissions.has('staff:payroll:read'); + if (!canManage && !context.permissions.has('staff:payslip:read_own')) { + return reply.forbidden('Недостаточно прав для просмотра зарплаты.'); + } + const period = parsePeriod(request.query); + return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const totals = await client.query( + `WITH ledger AS ( + SELECT employee_id, + COALESCE(SUM(amount_minor) FILTER (WHERE kind IN ('accrual', 'adjustment')), 0) AS accrued, + COALESCE(SUM(-amount_minor) FILTER (WHERE kind = 'payout'), 0) AS paid, + COALESCE(SUM(amount_minor), 0) AS balance + FROM staff_payroll_ledger + WHERE business_date BETWEEN $1 AND $2 + GROUP BY employee_id + ), worked AS ( + SELECT employee_id, COALESCE(SUM(worked_minutes), 0) AS worked_minutes + FROM staff_work_sessions + WHERE business_date BETWEEN $1 AND $2 AND status = 'confirmed' + GROUP BY employee_id + ) + SELECT e.id AS employee_id, e.display_name, e.position, + COALESCE(l.accrued, 0)::text AS accrued_minor, + COALESCE(l.paid, 0)::text AS paid_minor, + COALESCE(l.balance, 0)::text AS balance_minor, + COALESCE(w.worked_minutes, 0)::integer AS worked_minutes + FROM staff_employees e + LEFT JOIN ledger l ON l.employee_id = e.id + LEFT JOIN worked w ON w.employee_id = e.id + WHERE e.status <> 'dismissed' AND ($3::boolean OR e.core_user_id = $4) + ORDER BY e.display_name`, + [period.from, period.to, canManage, context.actorId], + ); + const entries = await client.query( + `SELECT l.id, l.employee_id, e.display_name, l.business_date, l.kind, + l.amount_minor::text AS amount_minor, l.currency_code, l.note, l.created_at + FROM staff_payroll_ledger l + JOIN staff_employees e ON e.tenant_id = l.tenant_id AND e.id = l.employee_id + WHERE l.business_date BETWEEN $1 AND $2 + AND ($3::boolean OR e.core_user_id = $4) + ORDER BY l.business_date DESC, e.display_name`, + [period.from, period.to, canManage, context.actorId], + ); + return { period, totals: totals.rows, entries: entries.rows }; + }); + }); + + api.post<{ Body: OwnerWorkbookImportBody }>('/imports/owner-workbook', { + schema: { + body: { + type: 'object', additionalProperties: false, required: ['source_name'], + properties: { + source_name: { type: 'string', minLength: 1, maxLength: 240 }, + units: { type: 'array', maxItems: 50, items: { type: 'object' } }, + financial_entries: { type: 'array', maxItems: 500, items: { type: 'object' } }, + employees: { type: 'array', maxItems: 250, items: { type: 'object' } }, + schedule_items: { type: 'array', maxItems: 500, items: { type: 'object' } }, + payroll_entries: { type: 'array', maxItems: 500, items: { type: 'object' } }, + }, + }, + }, + }, async (request, reply) => { + auth.assertMutationOrigin(request); + const context = await auth.require(request, 'staff:metric:write'); + const body = request.body; + if (!validateImport(body)) return reply.badRequest('Пакет импорта содержит некорректные данные.'); + const counts = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + await ensureMetricDefinitions(client, context.tenantId); + const unitIds = new Map(); + for (const unit of body.units ?? []) { + const saved = await client.query( + `INSERT INTO staff_org_units (id, tenant_id, code, name, external_ref) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL + DO UPDATE SET name = EXCLUDED.name, + external_ref = COALESCE(staff_org_units.external_ref, EXCLUDED.external_ref), + updated_at = now() + RETURNING id`, + [newUlid(), context.tenantId, unit.code, unit.name, unit.external_ref ?? null], + ); + const savedRow = saved.rows[0] as { id?: string } | undefined; + unitIds.set(unit.code, String(savedRow?.id)); + } + for (const code of new Set([ + ...(body.financial_entries ?? []).map((entry) => entry.org_unit_code), + ...(body.schedule_items ?? []).map((entry) => entry.org_unit_code), + ])) { + if (unitIds.has(code)) continue; + const row = await client.query( + 'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND code = $2 AND deleted_at IS NULL', + [context.tenantId, code], + ); + if (row.rowCount === 0) throw new Error(`Unknown organization unit: ${code}`); + const found = row.rows[0] as { id?: string } | undefined; + unitIds.set(code, String(found?.id)); + } + + const employeeIds = new Map(); + for (const employee of body.employees ?? []) { + const saved = await client.query( + `INSERT INTO staff_employees + (id, tenant_id, personnel_number, display_name, position, status, external_ref) + VALUES ($1, $2, $3, $4, $5, 'active', $6) + ON CONFLICT (tenant_id, personnel_number) + DO UPDATE SET external_ref = COALESCE(staff_employees.external_ref, EXCLUDED.external_ref), + position = CASE WHEN staff_employees.position = '' THEN EXCLUDED.position + ELSE staff_employees.position END, + updated_at = now() + RETURNING id`, + [newUlid(), context.tenantId, employee.personnel_number, + employee.display_name, employee.position?.trim() ?? '', employee.external_ref], + ); + const savedRow = saved.rows[0] as { id?: string } | undefined; + employeeIds.set(employee.external_ref, String(savedRow?.id)); + } + for (const externalRef of new Set([ + ...(body.schedule_items ?? []).map((entry) => entry.employee_external_ref), + ...(body.payroll_entries ?? []).map((entry) => entry.employee_external_ref), + ])) { + if (employeeIds.has(externalRef)) continue; + const row = await client.query( + 'SELECT id FROM staff_employees WHERE tenant_id = $1 AND external_ref = $2', + [context.tenantId, externalRef], + ); + if (row.rowCount === 0) throw new Error(`Unknown employee: ${externalRef}`); + const found = row.rows[0] as { id?: string } | undefined; + employeeIds.set(externalRef, String(found?.id)); + } + + const affectedRevenueDays = new Set(); + for (const entry of body.financial_entries ?? []) { + const orgUnitId = unitIds.get(entry.org_unit_code)!; + await client.query( + `INSERT INTO staff_daily_metrics + (id, tenant_id, org_unit_id, business_date, metric_key, value, + currency_code, breakdown, source, recorded_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'import', $9) + ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key) + DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code, + breakdown = EXCLUDED.breakdown, source = 'import', + recorded_by = EXCLUDED.recorded_by, updated_at = now()`, + [newUlid(), context.tenantId, orgUnitId, entry.business_date, entry.metric_key, + entry.value_minor, entry.currency_code ?? 'RUB', + { source_name: body.source_name }, context.actorId], + ); + if (entry.metric_key.startsWith('income_')) { + affectedRevenueDays.add(`${orgUnitId}|${entry.business_date}`); + } + } + for (const key of affectedRevenueDays) { + const [orgUnitId, businessDate] = key.split('|') as [string, string]; + await refreshRevenueMetric(client, context.tenantId, context.actorId, + orgUnitId, businessDate); + } + + for (const shift of body.schedule_items ?? []) { + const attributes = { + planned_minutes: shift.planned_minutes, + location_code: shift.location_code ?? '', + imported_from: body.source_name, + }; + await client.query( + `INSERT INTO staff_work_items + (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, + attributes, source, external_key, created_by) + VALUES ($1, $2, $3, $4, $5, 'planned_shift', $6, 'import', $7, $8) + ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL + DO UPDATE SET employee_id = EXCLUDED.employee_id, + org_unit_id = EXCLUDED.org_unit_id, + business_date = EXCLUDED.business_date, + attributes = EXCLUDED.attributes, source = 'import'`, + [newUlid(), context.tenantId, employeeIds.get(shift.employee_external_ref), + unitIds.get(shift.org_unit_code), shift.business_date, attributes, + shift.external_key, context.actorId], + ); + } + + for (const entry of body.payroll_entries ?? []) { + const amount = entry.kind === 'payout' + ? -Math.abs(entry.amount_minor) + : entry.amount_minor; + await client.query( + `INSERT INTO staff_payroll_ledger + (id, tenant_id, employee_id, business_date, kind, amount_minor, + currency_code, ref_type, correlation_id, note, created_by, external_key) + VALUES ($1, $2, $3, $4, $5, $6, 'RUB', 'manual', $7, $8, $9, $10) + ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING`, + [newUlid(), context.tenantId, employeeIds.get(entry.employee_external_ref), + entry.business_date, entry.kind, amount, context.correlationId, + entry.note?.trim() ?? `Импорт: ${body.source_name}`, + context.actorId, entry.external_key], + ); + } + const imported = { + units: body.units?.length ?? 0, + employees: body.employees?.length ?? 0, + financial_entries: body.financial_entries?.length ?? 0, + schedule_items: body.schedule_items?.length ?? 0, + payroll_entries: body.payroll_entries?.length ?? 0, + }; + await writeAudit(client, context, 'owner_workbook.imported', 'ImportBatch', + newUlid(), { source_name: body.source_name, ...imported }); + return imported; + }); + return reply.code(202).send(counts); + }); +} + +const periodSchema = { + type: 'object', + additionalProperties: false, + properties: { + from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + }, +} as const; + +function parsePeriod(query: PeriodQuery) { + const now = new Date(); + const first = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const last = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)); + const from = query.from ?? first.toISOString().slice(0, 10); + const to = query.to ?? last.toISOString().slice(0, 10); + if (!parseDate(from) || !parseDate(to) || from > to) throw new Error('Некорректный период.'); + return { from, to }; +} + +function parseDate(value: string): Date | null { + if (!datePattern.test(value)) return null; + const parsed = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value + ? null + : parsed; +} + +async function ensureMetricDefinitions(client: PoolClient, tenantId: string) { + for (const [key, label] of financialMetricDefinitions) { + await client.query( + `INSERT INTO staff_metric_definitions + (id, tenant_id, key, label, value_kind, source) + VALUES ($1, $2, $3, $4, 'money', 'manual') + ON CONFLICT (tenant_id, key) DO UPDATE SET label = EXCLUDED.label, is_active = TRUE`, + [newUlid(), tenantId, key, label], + ); + } + await client.query( + `INSERT INTO staff_metric_definitions + (id, tenant_id, key, label, value_kind, source) + VALUES ($1, $2, 'revenue_minor', 'Общая выручка', 'money', 'module') + ON CONFLICT (tenant_id, key) DO UPDATE SET label = EXCLUDED.label, is_active = TRUE`, + [newUlid(), tenantId], + ); +} + +async function refreshRevenueMetric( + client: PoolClient, + tenantId: string, + actorId: string, + orgUnitId: string, + businessDate: string, +) { + await client.query( + `INSERT INTO staff_daily_metrics + (id, tenant_id, org_unit_id, business_date, metric_key, value, + currency_code, source, recorded_by) + SELECT $1, $2, $3, $4, 'revenue_minor', + COALESCE(SUM(value) FILTER (WHERE metric_key LIKE 'income\\_%' ESCAPE '\\'), 0), + 'RUB', 'module', $5 + FROM staff_daily_metrics + WHERE tenant_id = $2 AND org_unit_id = $3 AND business_date = $4 + ON CONFLICT (tenant_id, org_unit_id, business_date, metric_key) + DO UPDATE SET value = EXCLUDED.value, source = 'module', + recorded_by = EXCLUDED.recorded_by, updated_at = now()`, + [newUlid(), tenantId, orgUnitId, businessDate, actorId], + ); +} + +async function writeAudit( + client: PoolClient, + context: RequestContext, + action: string, + entityType: string, + entityId: string, + values: unknown, +) { + await client.query( + `INSERT INTO staff_audit_log + (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [newUlid(), context.tenantId, context.actorId, action, entityType, + entityId, values, context.correlationId], + ); +} + +function validateImport(body: OwnerWorkbookImportBody): boolean { + if (!body.source_name.trim()) return false; + for (const unit of body.units ?? []) { + if (!/^[a-z0-9_-]{1,40}$/.test(unit.code) || !unit.name.trim()) return false; + } + for (const entry of body.financial_entries ?? []) { + if (!financialMetricKeys.has(entry.metric_key) + || !parseDate(entry.business_date) + || !Number.isSafeInteger(entry.value_minor) + || entry.value_minor < 0) return false; + } + for (const employee of body.employees ?? []) { + if (!employee.external_ref || !employee.personnel_number.trim() || !employee.display_name.trim()) { + return false; + } + } + for (const shift of body.schedule_items ?? []) { + if (!shift.external_key || !parseDate(shift.business_date) + || !Number.isInteger(shift.planned_minutes) + || shift.planned_minutes < 0 || shift.planned_minutes > 1440) return false; + } + for (const entry of body.payroll_entries ?? []) { + if (!entry.external_key || !parseDate(entry.business_date) + || !Number.isSafeInteger(entry.amount_minor)) return false; + } + return true; +} diff --git a/src/portal-ui.ts b/src/portal-ui.ts index 9de2d06..3aac41c 100644 --- a/src/portal-ui.ts +++ b/src/portal-ui.ts @@ -14,10 +14,10 @@ export const portalHtml = `
КKrona Staff

Защищённый кабинет

-

Вход администратора

-

Введите ключ администратора. Сотрудники, отметки и табель скрыты до успешного входа.

+

Вход в Krona Staff

+

Введите ключ руководителя или личный ключ сотрудника. После входа будут доступны только разрешённые вам данные.

- +
@@ -30,7 +30,15 @@ export const portalHtml = ` КKrona Staff
Подключение…
+
+

Личный кабинет

Рабочий день — под контролем

Отметки терминала, сотрудники и табель в одном защищённом пространстве.

Сегодня—Данные загружаются
@@ -47,8 +55,27 @@ export const portalHtml = `

Сверка терминала

Рабочие смены

ДатаСотрудникПодразделениеИнтервалЧасыСостояниеПричина правки

Учёт времени

Табель за 40 дней

Изменения сохраняет руководитель
ДатаСотрудникЧасыСтатусПримечание
+
+ + +

Карточка команды

Новый сотрудник

+

Личный кабинет

Ключ сотрудника

Скопируйте ключ сейчас. В программе хранится только его защищённый отпечаток, повторно показать ключ нельзя.

Krona Business Platform · Staff
@@ -92,5 +119,34 @@ export const portalJs = `(() => { if('serviceWorker' in navigator) navigator.serviceWorker.register('/staff/sw.js'); load(false); setInterval(()=>{if(!el('app-shell').hidden)load(true);},15000); })();`; +export const portalCssEnhanced = portalCss + ` +.section-nav{position:sticky;top:72px;z-index:4;display:flex;align-items:center;gap:8px;padding:10px max(20px,calc((100vw - 1240px)/2));border-bottom:1px solid var(--border);background:rgba(243,234,215,.96);backdrop-filter:blur(12px);overflow-x:auto}.nav-item{flex:0 0 auto;background:transparent;color:var(--muted);border:1px solid transparent}.nav-item.selected{background:var(--surface);color:var(--primary);border-color:var(--border)}.period-control{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;font-weight:700;white-space:nowrap}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{min-height:42px;border:1px solid var(--border);border-radius:8px;background:#fffdf8;color:var(--text);padding:7px 9px}.view-head{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:22px}.view-head h1{margin:4px 0 8px;font:500 clamp(30px,4vw,46px)/1.05 Georgia,serif}.view-head p{margin:0;color:var(--muted)}.finance-layout{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(260px,.7fr);gap:18px;margin-bottom:18px}.stack-form{display:grid;gap:13px}.stack-form label,.inline-form label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.inline-form{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr)) auto;align-items:end;gap:10px}.finance-chart{min-height:280px}.finance-chart svg{display:block;width:100%;height:280px}.chart-legend{display:flex;gap:16px;margin-top:10px;color:var(--muted);font-size:12px}.legend-dot{display:inline-block;width:9px;height:9px;margin-right:5px;border-radius:50%}.income-dot{background:var(--primary)}.expense-dot{background:var(--gold)}.calendar-panel{overflow:auto}.calendar-weekdays,.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(145px,1fr));min-width:1015px}.calendar-weekdays span{padding:8px 10px;color:var(--muted);font-size:12px;font-weight:750;text-align:center}.calendar-day{min-height:142px;padding:10px;border-top:1px solid var(--border);border-right:1px solid var(--border);background:var(--raised)}.calendar-day.outside{opacity:.45}.calendar-day.today{outline:2px solid var(--gold);outline-offset:-2px}.day-number{display:block;margin-bottom:8px;font-weight:750}.shift-card{margin:5px 0;padding:7px;border-left:3px solid var(--primary);border-radius:6px;background:#e8eadf;font-size:11px;line-height:1.35}.shift-card.actual{border-left-color:var(--gold);background:#f3e5cd}.shift-card strong,.shift-card span{display:block}.positive{color:var(--primary)}.negative{color:var(--danger)}.person-access{min-height:36px;padding:0 10px;margin-left:5px}.app-view[hidden]{display:none!important}@media(max-width:900px){.section-nav{top:68px;padding-inline:14px}.period-control{margin-left:8px}.finance-layout{grid-template-columns:1fr}.inline-form{grid-template-columns:repeat(2,minmax(0,1fr))}.inline-form button{grid-column:1/-1}.view-head{align-items:flex-start}.calendar-panel{padding:12px}}@media(max-width:520px){.view-head{display:block}.view-head>button{margin-top:14px}.stats{grid-template-columns:1fr}.stats article:last-child{grid-column:auto}.inline-form{grid-template-columns:1fr}.period-control{order:-1;margin-left:0}.section-nav{top:92px}.finance-chart{min-height:220px}.finance-chart svg{height:220px}} +`; + +export const portalJsEnhanced = portalJs.replace('Неверный ключ администратора.', 'Неверный ключ доступа.') + ` +(() => { + const api='/api/v1',byId=id=>document.getElementById(id),esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),rub=value=>(Number(value||0)/100).toLocaleString('ru-RU',{style:'currency',currency:'RUB',maximumFractionDigits:2}),hours=value=>(Number(value||0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1}),jsonHeaders={'content-type':'application/json'}; + let role='manager',units=[],initialized=false,active='overview-view'; + const period=()=>{const month=byId('period-month').value||new Date().toISOString().slice(0,7),start=month+'-01',d=new Date(start+'T00:00:00Z');d.setUTCMonth(d.getUTCMonth()+1);d.setUTCDate(0);return{from:start,to:d.toISOString().slice(0,10)}},query=()=>{const p=period();return'?from='+p.from+'&to='+p.to}; + async function get(path){const response=await fetch(api+path,{credentials:'same-origin'});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось загрузить данные.');return response.json()} + async function send(path,method,body){const response=await fetch(api+path,{method,credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось сохранить данные.');return response.json()} + const option=(value,label)=>''; + function fillSelects(){const unitHtml=units.map(x=>option(x.id,x.name)).join('');['finance-unit','schedule-unit'].forEach(id=>{if(byId(id))byId(id).innerHTML=unitHtml});const source=byId('new-time-employee');if(source&&byId('schedule-employee'))byId('schedule-employee').innerHTML=source.innerHTML} + async function initialize(){if(byId('app-shell').hidden||initialized)return;initialized=true;try{const state=await get('/portal');role=state.viewer&&state.viewer.role||'manager';document.querySelectorAll('.manager-only').forEach(node=>node.hidden=role!=='manager');byId('viewer').textContent=role==='employee'?'Личный кабинет сотрудника':'Кабинет руководителя';units=(await get('/org-units')).org_units||[];fillSelects();await loadActive()}catch{initialized=false}} + function show(target){active=target;document.querySelectorAll('.app-view').forEach(view=>view.hidden=view.id!==target);document.querySelectorAll('.nav-item').forEach(button=>button.classList.toggle('selected',button.dataset.target===target));loadActive()} + async function loadActive(){try{if(active==='finance-view')await loadFinance();if(active==='schedule-view')await loadSchedule();if(active==='payroll-view')await loadPayroll()}catch(error){const box=byId('message');if(box){box.textContent=error.message;box.hidden=false}}} + async function loadFinance(){if(role!=='manager')return;const data=await get('/finance'+query()),income=data.daily.reduce((sum,x)=>sum+Number(x.income_minor),0),expense=data.daily.reduce((sum,x)=>sum+Number(x.expense_minor),0);byId('finance-income').textContent=rub(income);byId('finance-expense').textContent=rub(expense);byId('finance-profit').textContent=rub(income-expense);byId('finance-count').textContent=data.entries.length+' записей';byId('finance-category').innerHTML=data.categories.map(x=>option(x.key,(x.kind==='income'?'Доход · ':'Расход · ')+x.label)).join('');byId('finance-entries').innerHTML=data.entries.length?data.entries.map(x=>''+esc(x.business_date)+''+esc(x.org_unit_name)+''+esc(x.label)+''+(x.metric_key.startsWith('income_')?'Доход':'Расход')+''+rub(x.value_minor)+'').join(''):'За месяц операций нет';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='
Добавьте выручку или расходы — здесь появится график
';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='
ДоходРасход
'} + 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+='
'+d.getUTCDate()+''+((grouped[key]||[]).map(x=>'
'+esc(x.display_name)+''+esc(x.org_unit_name)+' · план '+hours(x.planned_minutes)+' ч'+(x.actual_minutes!==null?'факт '+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'}):'')+'':'факт: нет отметки')+'
').join(''))+'
'}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=>''+esc(x.display_name)+''+esc(x.position||'—')+''+hours(x.worked_minutes)+''+rub(x.accrued_minor)+''+rub(x.paid_minor)+''+rub(x.balance_minor)+'').join(''):'Нет данных для расчёта';byId('payroll-entries').innerHTML=data.entries.length?data.entries.map(x=>''+esc(x.business_date)+''+esc(x.display_name)+''+({accrual:'Начисление',payout:'Выплата',adjustment:'Корректировка'}[x.kind]||esc(x.kind))+''+rub(Math.abs(Number(x.amount_minor)))+''+esc(x.note||'—')+'').join(''):'Операций пока нет'} + document.querySelectorAll('.nav-item').forEach(button=>button.addEventListener('click',()=>show(button.dataset.target)));byId('period-month').value=new Date().toISOString().slice(0,7);byId('period-month').addEventListener('change',loadActive);byId('finance-refresh').addEventListener('click',loadFinance);byId('schedule-refresh').addEventListener('click',loadSchedule);byId('payroll-refresh').addEventListener('click',loadPayroll);byId('finance-date').value=new Date().toISOString().slice(0,10);byId('schedule-date').value=new Date().toISOString().slice(0,10); + byId('finance-form').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{await send('/finance/'+byId('finance-unit').value+'/'+byId('finance-date').value+'/'+byId('finance-category').value,'PUT',{value_minor:Math.round(Number(byId('finance-amount').value)*100),currency_code:'RUB'});byId('finance-amount').value='';await loadFinance()}catch(error){alert(error.message)}finally{button.disabled=false}}); + byId('schedule-form').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{await send('/schedule/'+byId('schedule-employee').value+'/'+byId('schedule-date').value,'PUT',{org_unit_id:byId('schedule-unit').value,planned_minutes:Math.round(Number(byId('schedule-hours').value)*60),start_time:byId('schedule-start').value,end_time:byId('schedule-end').value});await loadSchedule()}catch(error){alert(error.message)}finally{button.disabled=false}}); + document.addEventListener('click',async event=>{const button=event.target.closest('.person-access');if(!button)return;button.disabled=true;try{const result=await send('/employees/'+button.dataset.id+'/access-token','POST',{});byId('access-token').value=result.access_token;byId('access-dialog').showModal()}catch(error){alert(error.message)}finally{button.disabled=false}});byId('access-copy').addEventListener('click',async()=>{await navigator.clipboard.writeText(byId('access-token').value);byId('access-copy').textContent='Скопировано'}); + const observer=new MutationObserver(()=>{if(role!=='manager')return;document.querySelectorAll('.edit-employee').forEach(edit=>{if(edit.parentElement.querySelector('.person-access'))return;const button=document.createElement('button');button.type='button';button.className='quiet compact person-access';button.dataset.id=edit.dataset.id;button.textContent='Личный вход';edit.insertAdjacentElement('afterend',button)});fillSelects()});observer.observe(byId('employees'),{childList:true,subtree:true});const shellObserver=new MutationObserver(()=>{if(!byId('app-shell').hidden){initialized=false;initialize()}else initialized=false});shellObserver.observe(byId('app-shell'),{attributes:true,attributeFilter:['hidden']});setTimeout(initialize,0); +})(); +`; + export const portalManifest = JSON.stringify({ name: 'Krona Staff', short_name: 'Staff', start_url: '/staff', display: 'standalone', background_color: '#F3EAD7', theme_color: '#657255', lang: 'ru' }); export const portalServiceWorker = `const CACHE='krona-staff-v3';const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest'];self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS))));self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k))))));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).pathname.startsWith('/staff'))e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));});`; +export const portalServiceWorkerEnhanced = portalServiceWorker.replace('krona-staff-v3', 'krona-staff-v4'); diff --git a/src/portal.ts b/src/portal.ts index 9fb8f63..52d66aa 100644 --- a/src/portal.ts +++ b/src/portal.ts @@ -1,3 +1,4 @@ +import { createHash, randomBytes } from 'node:crypto'; import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { AppConfig } from './config.js'; import type { RequestContext } from './core/context.js'; @@ -6,6 +7,7 @@ import type { IdentityPort } from './core/ports.js'; import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js'; import type { Database } from './infrastructure/database.js'; import { reconcileWorkSession } from './domain/attendance/work-session.js'; +import { registerOperationsRoutes } from './operations.js'; const datePattern = /^\d{4}-\d{2}-\d{2}$/; @@ -68,9 +70,10 @@ export async function registerPortalRoutes( config: AppConfig, database: Database, ) { - const identity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform' + const baseIdentity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform' ? new PlatformIdentityAdapter() : new StandaloneIdentityAdapter(config); + const identity = withStandaloneEmployeeAccess(baseIdentity, config, database); api.post<{ Body: SessionBody }>('/session', { schema: { @@ -86,13 +89,16 @@ export async function registerPortalRoutes( return reply.serviceUnavailable('Вход администратора ещё не настроен.'); } const context = await resolveToken(identity, request.body.access_token, request); - if (!context.permissions.has('staff:attendance:manage')) { - return reply.forbidden('Для входа требуется ключ администратора.'); + const role = context.permissions.has('staff:attendance:manage') ? 'manager' + : context.permissions.has('staff:attendance:read_own') ? 'employee' + : null; + if (!role) { + return reply.forbidden('Ключ не подходит для входа в кабинет.'); } reply .header('cache-control', 'no-store') .header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds)); - return { role: 'manager', expires_in: sessionMaxAgeSeconds }; + return { role, expires_in: sessionMaxAgeSeconds }; }); api.delete('/session', async (request, reply) => { @@ -276,6 +282,63 @@ export async function registerPortalRoutes( return reply.send(outcome.employee); }); + api.post<{ Params: { employeeId: string } }>('/employees/:employeeId/access-token', { + schema: { + params: { type: 'object', additionalProperties: false, required: ['employeeId'], properties: { + employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + } }, + }, + }, async (request, reply) => { + assertCookieMutationOrigin(request); + if (config.STAFF_RUNTIME_MODE !== 'standalone') { + return reply.conflict('В платформенном режиме доступом сотрудников управляет Core SSO.'); + } + const context = await requirePermission(identity, request, 'staff:employee:write'); + const employeeId = parseUlid(request.params.employeeId); + const accessToken = randomBytes(32).toString('base64url'); + const tokenHash = hashAccessToken(accessToken); + const tokenHint = accessToken.slice(-6); + const saved = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const employee = await client.query( + `SELECT id, core_user_id, display_name FROM staff_employees + WHERE tenant_id = $1 AND id = $2 AND status = 'active' FOR UPDATE`, + [context.tenantId, employeeId], + ); + if (employee.rowCount === 0) return null; + const employeeRow = employee.rows[0] as { + core_user_id?: string | null; + display_name?: string; + } | undefined; + const subjectId = String(employeeRow?.core_user_id ?? newUlid()); + await client.query( + `UPDATE staff_employees SET core_user_id = $3, updated_at = now() + WHERE tenant_id = $1 AND id = $2`, + [context.tenantId, employeeId, subjectId], + ); + await client.query( + `INSERT INTO staff_standalone_employee_access + (id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (tenant_id, employee_id) + DO UPDATE SET subject_id = EXCLUDED.subject_id, token_hash = EXCLUDED.token_hash, + token_hint = EXCLUDED.token_hint, created_by = EXCLUDED.created_by, + rotated_at = now()`, + [newUlid(), context.tenantId, employeeId, subjectId, tokenHash, tokenHint, context.actorId], + ); + await client.query( + `INSERT INTO staff_audit_log + (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) + VALUES ($1, $2, $3, 'employee.access.rotated', 'Employee', $4, $5, $6)`, + [newUlid(), context.tenantId, context.actorId, employeeId, + { subject_id: subjectId, token_hint: tokenHint }, context.correlationId], + ); + return { employee_id: employeeId, display_name: employeeRow?.display_name, + subject_id: subjectId, token_hint: tokenHint }; + }); + if (!saved) return reply.notFound('Активный сотрудник не найден.'); + return reply.send({ ...saved, access_token: accessToken }); + }); + api.post<{ Body: AttendanceBody }>('/attendance/events', { schema: { body: { @@ -637,6 +700,59 @@ export async function registerPortalRoutes( return reply.send(row); }, ); + + await registerOperationsRoutes(api, database, { + resolve: (request) => resolveContext(identity, request), + require: (request, permission) => requirePermission(identity, request, permission), + assertMutationOrigin: assertCookieMutationOrigin, + }); +} + +function withStandaloneEmployeeAccess( + baseIdentity: IdentityPort, + config: AppConfig, + database: Database, +): IdentityPort { + if (config.STAFF_RUNTIME_MODE !== 'standalone' || !config.STANDALONE_MANAGER_TOKEN) { + return baseIdentity; + } + return { + async resolve(token, correlationId) { + try { + return await baseIdentity.resolve(token, correlationId); + } catch (baseError) { + if (!token || token.length < 32) throw baseError; + const tenantId = parseUlid(config.STANDALONE_TENANT_ID); + const serviceActorId = parseUlid(config.STANDALONE_ACTOR_ID); + const tokenHash = hashAccessToken(token); + const row = await database.tenantTransaction(tenantId, serviceActorId, async (client) => { + const result = await client.query( + `SELECT subject_id + FROM staff_standalone_employee_access + WHERE tenant_id = $1 AND token_hash = $2`, + [tenantId, tokenHash], + ); + return result.rows[0] as { subject_id?: string } | undefined; + }); + if (!row?.subject_id) throw baseError; + return { + tenantId, + actorId: parseUlid(row.subject_id), + permissions: new Set([ + 'staff:health:read', + 'staff:attendance:read_own', + 'staff:shift:read', + 'staff:payslip:read_own', + ]), + correlationId, + }; + } + }, + }; +} + +function hashAccessToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); } async function resolveContext(identity: IdentityPort, request: FastifyRequest): Promise { diff --git a/src/version.ts b/src/version.ts index 1bffbe6..6359178 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ export const SERVICE_NAME = 'krona-staff'; -export const SERVICE_VERSION = '0.2.5'; +export const SERVICE_VERSION = '0.3.0'; diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts index aad256c..7012dcd 100644 --- a/tests/integration/health.test.ts +++ b/tests/integration/health.test.ts @@ -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(); });