feat(payroll): automatic payroll calculation, owner schemes, and UI integration

This commit is contained in:
Ochenstarik 2026-09-24 18:58:52 +07:00
parent c08a7e6af1
commit 37da6f0418
7 changed files with 2174 additions and 9 deletions

View file

@ -270,6 +270,67 @@ paths:
'200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток } '200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток }
'403': { description: Недостаточно прав } '403': { description: Недостаточно прав }
'404': { 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: /org-units:
get: get:
summary: Получить доступные торговые точки summary: Получить доступные торговые точки
@ -363,6 +424,95 @@ paths:
responses: responses:
'200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' } '200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' }
'403': { 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: /imports/owner-workbook:
post: post:
summary: Идемпотентно импортировать локально разобранную книгу владельца summary: Идемпотентно импортировать локально разобранную книгу владельца
@ -460,3 +610,44 @@ components:
name: { const: krona-staff } name: { const: krona-staff }
version: { type: string } version: { type: string }
mode: { enum: [standalone, platform] } mode: { enum: [standalone, platform] }
PayAssignment:
type: object
additionalProperties: false
required: [id, employee_id, pay_rule_id, pay_rule_code, pay_rule_name, rate_minor, percent_bp, guarantee_minor, pool_weight_bp, effective_from]
properties:
id: { $ref: '#/components/schemas/Ulid' }
employee_id: { $ref: '#/components/schemas/Ulid' }
org_unit_id:
oneOf:
- { $ref: '#/components/schemas/Ulid' }
- { type: 'null' }
pay_rule_id: { $ref: '#/components/schemas/Ulid' }
pay_rule_code: { type: string }
pay_rule_name: { type: string }
rate_minor: { type: integer, minimum: 0 }
percent_bp: { type: integer, minimum: 0 }
guarantee_minor: { type: integer, minimum: 0 }
pool_weight_bp: { type: integer, minimum: 0 }
effective_from: { type: string, format: date }
effective_to:
oneOf:
- { type: string, format: date }
- { type: 'null' }
params: { type: object }
note: { type: string }
PayAssignmentSave:
type: object
additionalProperties: false
required: [pay_rule_id]
properties:
org_unit_id:
oneOf:
- { $ref: '#/components/schemas/Ulid' }
- { type: 'null' }
pay_rule_id: { $ref: '#/components/schemas/Ulid' }
rate_minor: { type: integer, minimum: 0 }
percent_bp: { type: integer, minimum: 0, maximum: 10000 }
guarantee_minor: { type: integer, minimum: 0 }
pool_weight_bp: { type: integer, minimum: 0, maximum: 20000 }
effective_from: { type: string, format: date }
note: { type: string, maxLength: 500 }

View file

@ -0,0 +1,924 @@
import type { PoolClient } from 'pg';
import type { RequestContext } from '../../core/context.js';
import { newUlid, parseUlid } from '../../core/ids.js';
import { calculateDay, type DayParticipant, type PayrollLine } from './calculator.js';
import { cateringPreset } from './presets.js';
import type { RuleNode } from './rule.js';
export interface PayAssignmentInput {
org_unit_id?: string | null;
pay_rule_id: string;
rate_minor?: number;
percent_bp?: number;
guarantee_minor?: number;
pool_weight_bp?: number;
effective_from?: string;
note?: string;
}
export interface PayAssignmentRecord {
id: string;
employee_id: string;
org_unit_id: string | null;
pay_rule_id: string;
pay_rule_code: string;
pay_rule_name: string;
rate_minor: number;
percent_bp: number;
guarantee_minor: number;
pool_weight_bp: number;
effective_from: string;
effective_to: string | null;
params: Record<string, number>;
note: string;
}
export interface PayRuleRow {
id: string;
code: string;
version: number;
name: string;
description: string | null;
preset_code: string | null;
pool_weight_bp: number;
param_declarations: unknown;
effective_from: string;
}
interface EmployeePayAssignmentDbRow {
id: string;
employee_id: string;
org_unit_id: string | null;
pay_rule_id: string;
effective_from: string;
effective_to: string | null;
params: Record<string, unknown> | string | null;
pool_weight_bp: number | string | null;
note: string | null;
rule_code: string;
rule_name: string;
default_pool_weight_bp: number | string | null;
}
interface PayrollRunDbRow {
id: string;
period_start: string;
period_end: string;
status: string;
total_accrued_minor: string;
}
export interface PayrollRunDetailsRow {
id: string;
org_unit_id: string | null;
org_unit_name: string | null;
period_start: string;
period_end: string;
status: string;
total_accrued_minor: string;
currency_code: string;
calculated_at: string | null;
approved_by: string | null;
approved_at: string | null;
revision: number;
}
export interface PayrollLineDetailsRow {
id: string;
employee_id: string;
display_name: string;
position: string | null;
org_unit_id: string;
org_unit_name: string;
business_date: string;
pay_rule_id: string;
rule_code: string;
worked_minutes: number;
pool_amount_minor: string;
pool_divisor_bp: number;
pool_share_minor: string;
raw_amount_minor: string;
amount_minor: string;
currency_code: string;
explain: unknown;
}
export interface PayrollWarning {
kind: 'missing_revenue' | 'no_confirmed_sessions' | 'missing_pay_assignment';
message: string;
business_date?: string;
org_unit_id?: string;
employee_id?: string;
}
export interface EmployeeRunTotal {
employee_id: string;
display_name: string;
position: string;
worked_minutes: number;
days: number;
accrued_minor: string;
}
export interface CalculatePayrollOptions {
from: string;
to: string;
orgUnitId?: string | null;
}
export interface CalculatePayrollResult {
run_id: string;
status: 'calculated';
org_unit_id: string | null;
period_start: string;
period_end: string;
total_accrued_minor: string;
currency_code: string;
calculated_at: string;
totals: EmployeeRunTotal[];
warnings: PayrollWarning[];
lines_count: number;
}
export interface ApprovePayrollResult {
run_id: string;
status: 'approved';
approved_by: string;
approved_at: string;
total_accrued_minor: string;
entries_posted: number;
}
export async function ensureDefaultPayRules(
client: PoolClient,
tenantId: string,
actorId: string,
): Promise<void> {
for (const role of cateringPreset.roles) {
await client.query(
`INSERT INTO staff_pay_rules
(id, tenant_id, code, version, name, description, preset_code, rule,
param_declarations, pool_weight_bp, effective_from, created_by)
VALUES ($1, $2, $3, 1, $4, $5, $6, $7, $8, $9, '2026-01-01', $10)
ON CONFLICT (tenant_id, code, version) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
rule = EXCLUDED.rule,
param_declarations = EXCLUDED.param_declarations,
pool_weight_bp = EXCLUDED.pool_weight_bp`,
[
newUlid(),
tenantId,
role.code,
role.label,
role.description,
cateringPreset.code,
JSON.stringify(role.rule),
JSON.stringify(role.params),
role.poolWeightBp,
actorId,
],
);
}
}
export async function listPayRules(
client: PoolClient,
tenantId: string,
actorId: string,
): Promise<PayRuleRow[]> {
await ensureDefaultPayRules(client, tenantId, actorId);
const result = await client.query<PayRuleRow>(
`SELECT id, code, version, name, description, preset_code,
pool_weight_bp, param_declarations, effective_from::text AS effective_from
FROM staff_pay_rules
WHERE tenant_id = $1 AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
ORDER BY name`,
[tenantId],
);
return result.rows;
}
export async function getEmployeePayAssignment(
client: PoolClient,
tenantId: string,
employeeId: string,
): Promise<PayAssignmentRecord | null> {
const result = await client.query<EmployeePayAssignmentDbRow>(
`SELECT a.id, a.employee_id, a.org_unit_id, a.pay_rule_id,
a.effective_from::text AS effective_from, a.effective_to::text AS effective_to,
a.params, a.pool_weight_bp, a.note,
r.code AS rule_code, r.name AS rule_name, r.pool_weight_bp AS default_pool_weight_bp
FROM staff_pay_assignments a
JOIN staff_pay_rules r ON r.tenant_id = a.tenant_id AND r.id = a.pay_rule_id
WHERE a.tenant_id = $1 AND a.employee_id = $2
ORDER BY a.effective_from DESC, a.created_at DESC
LIMIT 1`,
[tenantId, employeeId],
);
if (result.rowCount === 0 || !result.rows[0]) return null;
const row = result.rows[0];
const rawParams = (typeof row.params === 'string'
? (JSON.parse(row.params) as Record<string, unknown>)
: row.params) ?? {};
const params: Record<string, number> = {};
for (const [k, v] of Object.entries(rawParams)) {
if (typeof v === 'number') params[k] = v;
}
const poolWeightBp =
row.pool_weight_bp !== null && row.pool_weight_bp !== undefined
? Number(row.pool_weight_bp)
: Number(row.default_pool_weight_bp ?? 0);
return {
id: row.id,
employee_id: row.employee_id,
org_unit_id: row.org_unit_id ?? null,
pay_rule_id: row.pay_rule_id,
pay_rule_code: row.rule_code,
pay_rule_name: row.rule_name,
rate_minor: Number(params.rate_minor ?? 10000),
percent_bp: Number(params.percent_bp ?? 0),
guarantee_minor: Number(params.guarantee_minor ?? 0),
pool_weight_bp: poolWeightBp,
effective_from: String(row.effective_from).slice(0, 10),
effective_to: row.effective_to ? String(row.effective_to).slice(0, 10) : null,
params,
note: row.note ?? '',
};
}
export async function saveEmployeePayAssignment(
client: PoolClient,
tenantId: string,
actorId: string,
correlationId: string,
employeeId: string,
data: PayAssignmentInput,
): Promise<PayAssignmentRecord> {
const empCheck = await client.query<{ id: string }>(
`SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2`,
[tenantId, employeeId],
);
if (empCheck.rowCount === 0) {
throw new Error('Employee not found');
}
const orgUnitId = data.org_unit_id ? parseUlid(data.org_unit_id) : null;
if (orgUnitId) {
const unitCheck = await client.query<{ id: string }>(
`SELECT id FROM staff_org_units WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL`,
[tenantId, orgUnitId],
);
if (unitCheck.rowCount === 0) {
throw new Error('Organization unit not found');
}
}
const payRuleId = parseUlid(data.pay_rule_id);
const ruleCheck = await client.query<{ id: string; pool_weight_bp: number | null }>(
`SELECT id, pool_weight_bp FROM staff_pay_rules WHERE tenant_id = $1 AND id = $2`,
[tenantId, payRuleId],
);
if (ruleCheck.rowCount === 0 || !ruleCheck.rows[0]) {
throw new Error('Pay rule not found');
}
const defaultRuleWeight = Number(ruleCheck.rows[0].pool_weight_bp ?? 0);
const effectiveFrom = data.effective_from ?? new Date().toISOString().slice(0, 10);
const poolWeightBp =
data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
const params: Record<string, number> = {
rate_minor: data.rate_minor ?? 10000,
percent_bp: data.percent_bp ?? 0,
guarantee_minor: data.guarantee_minor ?? 0,
};
const existingAssignment = await client.query<{ id: string }>(
`SELECT id FROM staff_pay_assignments
WHERE tenant_id = $1 AND employee_id = $2 AND effective_from = $3
LIMIT 1`,
[tenantId, employeeId, effectiveFrom],
);
let assignmentId: string;
if ((existingAssignment.rowCount ?? 0) > 0 && existingAssignment.rows[0]) {
assignmentId = String(existingAssignment.rows[0].id);
await client.query(
`UPDATE staff_pay_assignments SET
org_unit_id = $3,
pay_rule_id = $4,
params = $5,
pool_weight_bp = $6,
note = $7
WHERE tenant_id = $1 AND id = $2`,
[tenantId, assignmentId, orgUnitId, payRuleId, JSON.stringify(params), poolWeightBp, data.note ?? ''],
);
} else {
assignmentId = newUlid();
await client.query(
`INSERT INTO staff_pay_assignments
(id, tenant_id, employee_id, org_unit_id, pay_rule_id, effective_from, params, pool_weight_bp, note, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
assignmentId,
tenantId,
employeeId,
orgUnitId,
payRuleId,
effectiveFrom,
JSON.stringify(params),
poolWeightBp,
data.note ?? '',
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, 'pay_assignment.saved', 'PayAssignment', $4, $5, $6)`,
[
newUlid(),
tenantId,
actorId,
assignmentId,
{ employee_id: employeeId, org_unit_id: orgUnitId, pay_rule_id: payRuleId, params, pool_weight_bp: poolWeightBp },
correlationId,
],
);
const updated = await getEmployeePayAssignment(client, tenantId, employeeId);
if (!updated) throw new Error('Failed to retrieve saved assignment');
return updated;
}
export async function calculatePayrollPeriod(
client: PoolClient,
context: RequestContext,
options: CalculatePayrollOptions,
): Promise<CalculatePayrollResult> {
const tenantId = context.tenantId;
const actorId = context.actorId;
const correlationId = context.correlationId;
const from = options.from;
const to = options.to;
const orgUnitId = options.orgUnitId ? parseUlid(options.orgUnitId) : null;
await ensureDefaultPayRules(client, tenantId, actorId);
// Look for an existing run for this period
const existingRunQuery = await client.query<{ id: string; status: string }>(
`SELECT id, status FROM staff_payroll_runs
WHERE tenant_id = $1
AND (($2::varchar(26) IS NULL AND org_unit_id IS NULL) OR org_unit_id = $2)
AND period_start = $3
FOR UPDATE`,
[tenantId, orgUnitId, from],
);
let runId: string;
if ((existingRunQuery.rowCount ?? 0) > 0 && existingRunQuery.rows[0]) {
const existing = existingRunQuery.rows[0];
if (existing.status === 'approved' || existing.status === 'paid') {
const err = new Error('Расчёт за этот период уже утверждён.');
(err as { statusCode?: number }).statusCode = 409;
throw err;
}
runId = String(existing.id);
} else {
runId = newUlid();
await client.query(
`INSERT INTO staff_payroll_runs
(id, tenant_id, org_unit_id, period_start, period_end, status, created_by)
VALUES ($1, $2, $3, $4, $5, 'draft', $6)
ON CONFLICT DO NOTHING`,
[runId, tenantId, orgUnitId, from, to, actorId],
);
}
// Gather confirmed sessions
const sessionsQuery = await client.query<{
id: string;
employee_id: string;
org_unit_id: string;
business_date: string;
worked_minutes: number;
display_name: string;
position: string;
org_unit_name: string;
}>(
`SELECT s.id, s.employee_id, s.org_unit_id, s.business_date::text AS business_date,
s.worked_minutes, e.display_name, e.position, u.name AS org_unit_name
FROM staff_work_sessions s
JOIN staff_employees e ON e.tenant_id = s.tenant_id AND e.id = s.employee_id
JOIN staff_org_units u ON u.tenant_id = s.tenant_id AND u.id = s.org_unit_id
WHERE s.tenant_id = $1
AND s.business_date BETWEEN $2 AND $3
AND s.status = 'confirmed'
AND ($4::varchar(26) IS NULL OR s.org_unit_id = $4)
ORDER BY s.business_date, s.org_unit_id, s.employee_id`,
[tenantId, from, to, orgUnitId],
);
const sessions = sessionsQuery.rows;
// Gather daily metrics (revenue)
const metricsQuery = await client.query<{
org_unit_id: string;
business_date: string;
revenue_minor: string | number;
}>(
`SELECT org_unit_id, business_date::text AS business_date, value::bigint AS revenue_minor
FROM staff_daily_metrics
WHERE tenant_id = $1
AND metric_key = 'revenue_minor'
AND business_date BETWEEN $2 AND $3
AND ($4::varchar(26) IS NULL OR org_unit_id = $4)`,
[tenantId, from, to, orgUnitId],
);
const revenueMap = new Map<string, number>();
for (const row of metricsQuery.rows) {
revenueMap.set(`${row.business_date}:${row.org_unit_id}`, Number(row.revenue_minor));
}
// Gather assignments
interface AssignmentRow {
id: string;
employee_id: string;
org_unit_id: string | null;
pay_rule_id: string;
effective_from: string;
effective_to: string | null;
params: Record<string, unknown> | string | null;
pool_weight_bp: number | string | null;
rule_code: string;
rule: RuleNode | string;
default_pool_weight_bp: number | string | null;
}
const assignmentsQuery = await client.query<AssignmentRow>(
`SELECT a.id, a.employee_id, a.org_unit_id, a.pay_rule_id,
a.effective_from::text AS effective_from, a.effective_to::text AS effective_to,
a.params, a.pool_weight_bp,
r.code AS rule_code, r.rule, r.pool_weight_bp AS default_pool_weight_bp
FROM staff_pay_assignments a
JOIN staff_pay_rules r ON r.tenant_id = a.tenant_id AND r.id = a.pay_rule_id
WHERE a.tenant_id = $1
AND a.effective_from <= $3
AND (a.effective_to IS NULL OR a.effective_to >= $2)
ORDER BY (a.org_unit_id IS NOT NULL) DESC, a.effective_from DESC, a.created_at DESC`,
[tenantId, from, to],
);
const assignments = assignmentsQuery.rows;
function findAssignment(empId: string, date: string, unitId: string): AssignmentRow | undefined {
return assignments.find((a) => {
if (a.employee_id !== empId) return false;
if (a.effective_from > date) return false;
if (a.effective_to && a.effective_to < date) return false;
if (a.org_unit_id !== null && a.org_unit_id !== unitId) return false;
return true;
});
}
// Gather unit terms
interface OrgUnitTermsRow {
org_unit_id: string;
effective_from: string;
effective_to: string | null;
pool_rule: RuleNode | string | null;
shift_norm_minutes: number | null;
rounding_unit_minor: string | number | null;
}
const termsQuery = await client.query<OrgUnitTermsRow>(
`SELECT org_unit_id, effective_from::text AS effective_from,
effective_to::text AS effective_to, pool_rule, shift_norm_minutes, rounding_unit_minor
FROM staff_org_unit_terms
WHERE tenant_id = $1
AND effective_from <= $3
AND (effective_to IS NULL OR effective_to >= $2)
ORDER BY effective_from DESC`,
[tenantId, from, to],
);
const warnings: PayrollWarning[] = [];
if (sessions.length === 0) {
warnings.push({
kind: 'no_confirmed_sessions',
message: 'Нет подтверждённых смен за выбранный период.',
});
}
// Group sessions by date & org_unit_id
const groupMap = new Map<string, typeof sessions>();
for (const session of sessions) {
const key = `${session.business_date}:${session.org_unit_id}`;
const list = groupMap.get(key) ?? [];
list.push(session);
groupMap.set(key, list);
}
interface ProcessedLine extends PayrollLine {
payRuleId: string;
}
const allLines: ProcessedLine[] = [];
const processedEmployees = new Map<string, { display_name: string; position: string; worked_minutes: number; days: number; accrued_minor: number }>();
// Initialize all employees with sessions in tracking map
for (const session of sessions) {
if (!processedEmployees.has(session.employee_id)) {
processedEmployees.set(session.employee_id, {
display_name: session.display_name,
position: session.position,
worked_minutes: 0,
days: 0,
accrued_minor: 0,
});
}
}
for (const [key, groupSessions] of groupMap.entries()) {
const [date, unitId] = key.split(':') as [string, string];
const unitName = groupSessions[0]?.org_unit_name ?? unitId;
const rev = revenueMap.get(key);
if (rev === undefined || rev === null) {
warnings.push({
kind: 'missing_revenue',
business_date: date,
org_unit_id: unitId,
message: `Нет выручки за дату ${date} (${unitName})`,
});
}
const participants: Array<DayParticipant & { payRuleId: string }> = [];
for (const session of groupSessions) {
const tracker = processedEmployees.get(session.employee_id)!;
tracker.worked_minutes += session.worked_minutes;
if (session.worked_minutes > 0) tracker.days += 1;
const assignment = findAssignment(session.employee_id, date, unitId);
if (!assignment) {
warnings.push({
kind: 'missing_pay_assignment',
business_date: date,
employee_id: session.employee_id,
org_unit_id: unitId,
message: `Отсутствует зарплатный профиль для сотрудника ${session.display_name} на дату ${date}`,
});
continue;
}
const ruleObj: RuleNode =
typeof assignment.rule === 'string'
? (JSON.parse(assignment.rule) as RuleNode)
: assignment.rule;
const rawParams = (typeof assignment.params === 'string'
? (JSON.parse(assignment.params) as Record<string, unknown>)
: assignment.params) ?? {};
const poolWeight =
assignment.pool_weight_bp !== null && assignment.pool_weight_bp !== undefined
? Number(assignment.pool_weight_bp)
: Number(assignment.default_pool_weight_bp ?? 0);
const params: Record<string, number> = {};
for (const [k, v] of Object.entries(rawParams)) {
if (typeof v === 'number') params[k] = v;
}
if (params.rate_minor === undefined) params.rate_minor = 10000;
if (params.percent_bp === undefined) params.percent_bp = 0;
if (params.guarantee_minor === undefined) params.guarantee_minor = 0;
participants.push({
employeeId: session.employee_id,
workedMinutes: session.worked_minutes,
ruleCode: assignment.rule_code,
rule: ruleObj,
params,
poolWeightBp: poolWeight,
payRuleId: assignment.pay_rule_id,
});
}
if (participants.length === 0) continue;
const unitTerms = termsQuery.rows.find(
(t) =>
t.org_unit_id === unitId &&
t.effective_from <= date &&
(!t.effective_to || t.effective_to >= date),
);
let poolRule: RuleNode | undefined = cateringPreset.poolRule;
if (unitTerms?.pool_rule) {
poolRule =
typeof unitTerms.pool_rule === 'string'
? (JSON.parse(unitTerms.pool_rule) as RuleNode)
: unitTerms.pool_rule;
}
const shiftNormMinutes = unitTerms?.shift_norm_minutes ?? cateringPreset.shiftNormMinutes;
const roundingUnitMinor = Number(unitTerms?.rounding_unit_minor ?? 100);
const dayResult = calculateDay({
businessDate: date,
orgUnitId: unitId,
metrics: { revenue_minor: rev ?? 0 },
...(poolRule ? { poolRule } : {}),
shiftNormMinutes,
roundingUnitMinor,
currencyCode: 'RUB',
participants,
});
const participantRuleMap = new Map(participants.map((p) => [p.employeeId, p.payRuleId]));
for (const line of dayResult.lines) {
allLines.push({
...line,
payRuleId: participantRuleMap.get(line.employeeId)!,
});
const tracker = processedEmployees.get(line.employeeId);
if (tracker) {
tracker.accrued_minor += line.amountMinor;
}
}
}
// Replace old lines for this run
await client.query(`DELETE FROM staff_payroll_lines WHERE tenant_id = $1 AND run_id = $2`, [
tenantId,
runId,
]);
for (const line of allLines) {
await client.query(
`INSERT INTO staff_payroll_lines
(id, tenant_id, run_id, employee_id, org_unit_id, business_date, pay_rule_id,
rule_code, worked_minutes, pool_amount_minor, pool_divisor_bp, pool_share_minor,
raw_amount_minor, amount_minor, currency_code, explain)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,
[
newUlid(),
tenantId,
runId,
line.employeeId,
line.orgUnitId,
line.businessDate,
line.payRuleId,
line.ruleCode,
line.workedMinutes,
line.poolAmountMinor,
line.poolDivisorBp,
line.poolShareMinor,
line.rawAmountMinor,
line.amountMinor,
line.currencyCode,
JSON.stringify(line.explain),
],
);
}
const totalAccruedMinor = allLines.reduce((sum, line) => sum + BigInt(line.amountMinor), 0n);
const calculatedAt = new Date().toISOString();
await client.query(
`UPDATE staff_payroll_runs SET
status = 'calculated',
total_accrued_minor = $3,
calculated_at = $4,
updated_at = now(),
revision = revision + 1
WHERE tenant_id = $1 AND id = $2`,
[tenantId, runId, totalAccruedMinor.toString(), calculatedAt],
);
await client.query(
`INSERT INTO staff_outbox
(id, tenant_id, event_type, event_version, subject_type, subject_id,
actor_type, actor_id, payload, correlation_id)
VALUES ($1, $2, 'staff.payroll.run_calculated', 1, 'PayrollRun', $3,
'user', $4, $5, $6)`,
[
newUlid(),
tenantId,
runId,
actorId,
{ period_start: from, period_end: to, total_accrued_minor: totalAccruedMinor.toString() },
correlationId,
],
);
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, 'payroll_run.calculated', 'PayrollRun', $4, $5, $6)`,
[
newUlid(),
tenantId,
actorId,
runId,
{ period_start: from, period_end: to, total_accrued_minor: totalAccruedMinor.toString(), lines: allLines.length },
correlationId,
],
);
const totals: EmployeeRunTotal[] = [...processedEmployees.entries()].map(([empId, data]) => ({
employee_id: empId,
display_name: data.display_name,
position: data.position,
worked_minutes: data.worked_minutes,
days: data.days,
accrued_minor: data.accrued_minor.toString(),
})).sort((a, b) => a.display_name.localeCompare(b.display_name));
return {
run_id: runId,
status: 'calculated',
org_unit_id: orgUnitId,
period_start: from,
period_end: to,
total_accrued_minor: totalAccruedMinor.toString(),
currency_code: 'RUB',
calculated_at: calculatedAt,
totals,
warnings,
lines_count: allLines.length,
};
}
export async function approvePayrollRun(
client: PoolClient,
context: RequestContext,
runId: string,
): Promise<ApprovePayrollResult> {
const tenantId = context.tenantId;
const actorId = context.actorId;
const correlationId = context.correlationId;
const runQuery = await client.query<PayrollRunDbRow>(
`SELECT id, period_start::text AS period_start, period_end::text AS period_end,
status, total_accrued_minor::text AS total_accrued_minor
FROM staff_payroll_runs
WHERE tenant_id = $1 AND id = $2
FOR UPDATE`,
[tenantId, runId],
);
if (runQuery.rowCount === 0 || !runQuery.rows[0]) {
const err = new Error('Расчёт зарплаты не найден.');
(err as { statusCode?: number }).statusCode = 404;
throw err;
}
const run = runQuery.rows[0];
if (run.status !== 'calculated') {
const err = new Error('Утвердить можно только расчёт в статусе calculated.');
(err as { statusCode?: number }).statusCode = 400;
throw err;
}
// Aggregate accrued lines per employee
const aggregatesQuery = await client.query<{
employee_id: string;
amount_minor: string | number;
}>(
`SELECT employee_id, SUM(amount_minor)::bigint AS amount_minor
FROM staff_payroll_lines
WHERE tenant_id = $1 AND run_id = $2
GROUP BY employee_id`,
[tenantId, runId],
);
let postedCount = 0;
for (const row of aggregatesQuery.rows) {
const amountMinor = BigInt(row.amount_minor);
if (amountMinor <= 0n) continue;
const externalKey = `payroll_run:${runId}:${row.employee_id}`;
const ledgerResult = await 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, $4, 'accrual', $5, 'RUB', 'payroll_run', $6, $7, $8, $9, $10)
ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING
RETURNING id`,
[
newUlid(),
tenantId,
row.employee_id,
run.period_end,
amountMinor.toString(),
runId,
correlationId,
`Начисление зарплаты за период ${run.period_start} – ${run.period_end}`,
actorId,
externalKey,
],
);
if ((ledgerResult.rowCount ?? 0) > 0) {
postedCount += 1;
}
}
const approvedAt = new Date().toISOString();
await client.query(
`UPDATE staff_payroll_runs SET
status = 'approved',
approved_by = $3,
approved_at = $4,
updated_at = now(),
revision = revision + 1
WHERE tenant_id = $1 AND id = $2`,
[tenantId, runId, actorId, approvedAt],
);
await client.query(
`INSERT INTO staff_outbox
(id, tenant_id, event_type, event_version, subject_type, subject_id,
actor_type, actor_id, payload, correlation_id)
VALUES ($1, $2, 'staff.payroll.run_approved', 1, 'PayrollRun', $3,
'user', $4, $5, $6)`,
[
newUlid(),
tenantId,
runId,
actorId,
{ period_start: run.period_start, period_end: run.period_end, total_accrued_minor: run.total_accrued_minor },
correlationId,
],
);
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, 'payroll_run.approved', 'PayrollRun', $4, $5, $6)`,
[
newUlid(),
tenantId,
actorId,
runId,
{ period_start: run.period_start, period_end: run.period_end, total_accrued_minor: run.total_accrued_minor },
correlationId,
],
);
return {
run_id: runId,
status: 'approved',
approved_by: actorId,
approved_at: approvedAt,
total_accrued_minor: run.total_accrued_minor,
entries_posted: postedCount,
};
}
export async function getPayrollRunDetails(
client: PoolClient,
tenantId: string,
runId: string,
): Promise<{ run: PayrollRunDetailsRow; lines: PayrollLineDetailsRow[] } | null> {
const runQuery = await client.query<PayrollRunDetailsRow>(
`SELECT r.id, r.org_unit_id, u.name AS org_unit_name,
r.period_start::text AS period_start, r.period_end::text AS period_end,
r.status, r.total_accrued_minor::text AS total_accrued_minor,
r.currency_code, r.calculated_at, r.approved_by, r.approved_at, r.revision
FROM staff_payroll_runs r
LEFT JOIN staff_org_units u ON u.tenant_id = r.tenant_id AND u.id = r.org_unit_id
WHERE r.tenant_id = $1 AND r.id = $2`,
[tenantId, runId],
);
if (runQuery.rowCount === 0 || !runQuery.rows[0]) return null;
const run = runQuery.rows[0];
const linesQuery = await client.query<PayrollLineDetailsRow>(
`SELECT l.id, l.employee_id, e.display_name, e.position,
l.org_unit_id, u.name AS org_unit_name,
l.business_date::text AS business_date, l.pay_rule_id, l.rule_code,
l.worked_minutes, l.pool_amount_minor::text AS pool_amount_minor,
l.pool_divisor_bp, l.pool_share_minor::text AS pool_share_minor,
l.raw_amount_minor::text AS raw_amount_minor,
l.amount_minor::text AS amount_minor, l.currency_code, l.explain
FROM staff_payroll_lines l
JOIN staff_employees e ON e.tenant_id = l.tenant_id AND e.id = l.employee_id
JOIN staff_org_units u ON u.tenant_id = l.tenant_id AND u.id = l.org_unit_id
WHERE l.tenant_id = $1 AND l.run_id = $2
ORDER BY l.business_date, e.display_name`,
[tenantId, runId],
);
return { run, lines: linesQuery.rows };
}

View file

@ -3,6 +3,16 @@ import type { PoolClient } from 'pg';
import type { RequestContext } from './core/context.js'; import type { RequestContext } from './core/context.js';
import { newUlid, parseUlid } from './core/ids.js'; import { newUlid, parseUlid } from './core/ids.js';
import type { Database } from './infrastructure/database.js'; import type { Database } from './infrastructure/database.js';
import {
approvePayrollRun,
calculatePayrollPeriod,
ensureDefaultPayRules,
getEmployeePayAssignment,
getPayrollRunDetails,
listPayRules,
saveEmployeePayAssignment,
type PayAssignmentInput,
} from './domain/payroll/service.js';
const datePattern = /^\d{4}-\d{2}-\d{2}$/; const datePattern = /^\d{4}-\d{2}-\d{2}$/;
@ -417,7 +427,7 @@ export async function registerOperationsRoutes(
FROM staff_employees e FROM staff_employees e
LEFT JOIN ledger l ON l.employee_id = e.id LEFT JOIN ledger l ON l.employee_id = e.id
LEFT JOIN worked w ON w.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) WHERE e.status <> 'dismissed' AND ($3::boolean OR e.core_user_id = $4 OR e.id = $4)
ORDER BY e.display_name`, ORDER BY e.display_name`,
[period.from, period.to, canManage, context.actorId], [period.from, period.to, canManage, context.actorId],
); );
@ -427,7 +437,7 @@ export async function registerOperationsRoutes(
FROM staff_payroll_ledger l FROM staff_payroll_ledger l
JOIN staff_employees e ON e.tenant_id = l.tenant_id AND e.id = l.employee_id 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 WHERE l.business_date BETWEEN $1 AND $2
AND ($3::boolean OR e.core_user_id = $4) AND ($3::boolean OR e.core_user_id = $4 OR e.id = $4)
ORDER BY l.business_date DESC, e.display_name`, ORDER BY l.business_date DESC, e.display_name`,
[period.from, period.to, canManage, context.actorId], [period.from, period.to, canManage, context.actorId],
); );
@ -435,6 +445,247 @@ export async function registerOperationsRoutes(
}); });
}); });
api.get<{ Params: { employeeId: string } }>(
'/employees/:employeeId/pay-assignment',
{
schema: {
params: {
type: 'object',
required: ['employeeId'],
properties: {
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
},
},
},
},
async (request, reply) => {
const context = await auth.resolve(request);
const employeeId = parseUlid(request.params.employeeId);
const canManage =
context.permissions.has('staff:pay_rule:read') ||
context.permissions.has('staff:employee:read') ||
context.permissions.has('staff:payroll:read');
if (!canManage && !context.permissions.has('staff:payslip:read_own')) {
return reply.forbidden('Недостаточно прав для просмотра зарплатного профиля.');
}
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const empQuery = await client.query<{ id: string; core_user_id: string | null }>(
`SELECT id, core_user_id FROM staff_employees WHERE tenant_id = $1 AND id = $2`,
[context.tenantId, employeeId],
);
if (empQuery.rowCount === 0 || !empQuery.rows[0]) return reply.notFound('Сотрудник не найден.');
const emp = empQuery.rows[0];
if (!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId) {
return reply.forbidden('Доступ запрещён.');
}
await ensureDefaultPayRules(client, context.tenantId, context.actorId);
const assignment = await getEmployeePayAssignment(client, context.tenantId, employeeId);
if (!assignment) return reply.notFound('Зарплатный профиль не настроен.');
return reply.send(assignment);
});
},
);
api.put<{ Params: { employeeId: string }; Body: PayAssignmentInput }>(
'/employees/:employeeId/pay-assignment',
{
schema: {
params: {
type: 'object',
required: ['employeeId'],
properties: {
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
},
},
body: {
type: 'object',
required: ['pay_rule_id'],
properties: {
org_unit_id: { anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }] },
pay_rule_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
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', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
note: { type: 'string', maxLength: 500 },
},
},
},
},
async (request, reply) => {
auth.assertMutationOrigin(request);
const context = await auth.require(request, 'staff:pay_rule:write');
const employeeId = parseUlid(request.params.employeeId);
try {
const saved = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
await ensureDefaultPayRules(client, context.tenantId, context.actorId);
return saveEmployeePayAssignment(
client,
context.tenantId,
context.actorId,
context.correlationId,
employeeId,
request.body,
);
});
return reply.send(saved);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Ошибка сохранения зарплатного профиля';
if (message.includes('not found') || message.includes('Employee not found')) {
return reply.notFound(message);
}
throw err;
}
},
);
api.get('/pay-rules', async (request, reply) => {
const context = await auth.resolve(request);
const canManage =
context.permissions.has('staff:pay_rule:read') ||
context.permissions.has('staff:employee:read') ||
context.permissions.has('staff:attendance:manage') ||
context.permissions.has('staff:payroll:read');
if (!canManage) {
return reply.forbidden('Недостаточно прав для просмотра правил оплаты.');
}
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const rules = await listPayRules(client, context.tenantId, context.actorId);
return { pay_rules: rules };
});
});
interface CalculatePayrollBody {
month?: string;
from?: string;
to?: string;
org_unit_id?: string;
}
api.post<{ Body: CalculatePayrollBody }>(
'/payroll/calculate',
{
schema: {
body: {
type: 'object',
properties: {
month: { type: 'string', pattern: '^\\d{4}-\\d{2}$' },
from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
},
},
},
},
async (request, reply) => {
auth.assertMutationOrigin(request);
const context = await auth.require(request, 'staff:payroll:calculate');
let from: string;
let to: string;
if (request.body.month) {
from = `${request.body.month}-01`;
const parts = request.body.month.split('-').map(Number);
const year = parts[0]!;
const monthNum = parts[1]!;
const lastDay = new Date(Date.UTC(year, monthNum, 0)).getUTCDate();
to = `${request.body.month}-${String(lastDay).padStart(2, '0')}`;
} else if (request.body.from && request.body.to) {
if (!parseDate(request.body.from) || !parseDate(request.body.to) || request.body.from > request.body.to) {
return reply.badRequest('Некорректный период расчёта.');
}
from = request.body.from;
to = request.body.to;
} else {
return reply.badRequest('Укажите месяц (month) или период (from и to).');
}
try {
const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
return calculatePayrollPeriod(client, context, {
from,
to,
...(request.body.org_unit_id ? { orgUnitId: request.body.org_unit_id } : {}),
});
});
return reply.send(result);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 409) {
const message = err instanceof Error ? err.message : 'Конфликт расчёта';
return reply.conflict(message);
}
throw err;
}
},
);
api.post<{ Params: { runId: string } }>(
'/payroll/runs/:runId/approve',
{
schema: {
params: {
type: 'object',
required: ['runId'],
properties: {
runId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
},
},
},
},
async (request, reply) => {
auth.assertMutationOrigin(request);
const context = await auth.require(request, 'staff:payroll:approve');
const runId = parseUlid(request.params.runId);
try {
const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
return approvePayrollRun(client, context, runId);
});
return reply.send(result);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'statusCode' in err) {
const status = (err as { statusCode: number }).statusCode;
const message = err instanceof Error ? err.message : 'Ошибка утверждения';
if (status === 404) return reply.notFound(message);
if (status === 400) return reply.badRequest(message);
}
throw err;
}
},
);
api.get<{ Params: { runId: string } }>(
'/payroll/runs/:runId',
{
schema: {
params: {
type: 'object',
required: ['runId'],
properties: {
runId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
},
},
},
},
async (request, reply) => {
const context = await auth.resolve(request);
const canManage = context.permissions.has('staff:payroll:read');
if (!canManage) {
return reply.forbidden('Недостаточно прав для просмотра расчёта.');
}
const runId = parseUlid(request.params.runId);
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const details = await getPayrollRunDetails(client, context.tenantId, runId);
if (!details) return reply.notFound('Расчёт зарплаты не найден.');
return reply.send(details);
});
},
);
api.post<{ Body: OwnerWorkbookImportBody }>('/imports/owner-workbook', { api.post<{ Body: OwnerWorkbookImportBody }>('/imports/owner-workbook', {
schema: { schema: {
body: { body: {

View file

@ -68,13 +68,16 @@ export const portalHtml = `<!doctype html>
<section class="panel calendar-panel"><div class="calendar-weekdays" aria-hidden="true"><span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span></div><div id="calendar-grid" class="calendar-grid"></div></section> <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>
<div id="payroll-view" class="app-view" hidden> <div id="payroll-view" class="app-view" hidden>
<section class="view-head"><div><p class="eyebrow">Расчётный лист</p><h1>Зарплата</h1><p>Начисления, выплаты, остаток и фактически подтверждённые часы.</p></div><button id="payroll-refresh" class="quiet" type="button">Обновить</button></section> <section class="view-head"><div><p class="eyebrow">Расчётный лист</p><h1>Зарплата</h1><p>Начисления, выплаты, остаток и фактически подтверждённые часы.</p></div><div class="actions"><button id="payroll-calc-btn" class="manager-only" type="button">Рассчитать зарплату</button><button id="payroll-refresh" class="quiet" type="button">Обновить</button></div></section>
<div id="payroll-calc-status" class="message" style="background:#eaf2e8;border-color:#b4d3af;color:#285223" hidden></div>
<div id="payroll-warnings" class="payroll-warnings message" hidden></div>
<section id="payroll-preview-card" class="panel manager-only" hidden><div class="panel-head"><div><p class="eyebrow">Предварительный результат</p><h2 id="payroll-preview-title">Расчёт зарплаты</h2></div><button id="payroll-approve-btn" type="button">Утвердить расчёт</button></div><div class="table-wrap"><table><thead><tr><th>Сотрудник</th><th>Должность</th><th>Дней</th><th>Факт, часов</th><th>Начислено (расчёт)</th></tr></thead><tbody id="payroll-preview-rows"></tbody></table></div></section>
<section class="payroll-stats stats" aria-label="Сводка зарплаты"><article><span>Начислено</span><strong id="payroll-accrued">—</strong></article><article><span>Выплачено</span><strong id="payroll-paid">—</strong></article><article><span>Остаток</span><strong id="payroll-balance">—</strong></article></section> <section class="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"><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> <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> </div>
</main> </main>
<dialog id="employee-dialog" class="employee-dialog"><form id="employee-form" method="dialog"><div class="dialog-head"><div><p class="eyebrow">Карточка команды</p><h2 id="employee-dialog-title">Новый сотрудник</h2></div><button id="employee-close" class="icon-button" type="button" aria-label="Закрыть">×</button></div><input id="employee-id" type="hidden"><label>Табельный номер<input id="employee-number" maxlength="80" required></label><label>Фамилия, имя и отчество<input id="employee-name" maxlength="240" required></label><label>Должность<input id="employee-position" maxlength="160"></label><label>Пол<select id="employee-gender"><option value="">Не указан</option><option value="female">Женский</option><option value="male">Мужской</option></select></label><label id="employee-status-wrap" hidden>Статус<select id="employee-status"><option value="active">Работает</option><option value="blocked">Заблокирован</option><option value="dismissed">Уволен</option></select></label><div id="employee-error" class="login-error" role="alert" hidden></div><div class="dialog-actions"><button id="employee-cancel" class="quiet" type="button">Отмена</button><button id="employee-save" type="submit">Сохранить</button></div></form></dialog> <dialog id="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-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></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="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> <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> <footer>Krona Business Platform · Staff</footer>
</div> </div>
@ -121,18 +124,42 @@ export const portalJs = `(() => {
export const portalCssEnhanced = portalCss + ` 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}} .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}
`; `;
export const portalJsEnhanced = portalJs.replace('Неверный ключ администратора.', 'Неверный ключ доступа.') + ` export const portalJsEnhanced = portalJs.replace('Неверный ключ администратора.', 'Неверный ключ доступа.') + `
(() => { (() => {
const api='/api/v1',byId=id=>document.getElementById(id),esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),rub=value=>(Number(value||0)/100).toLocaleString('ru-RU',{style:'currency',currency:'RUB',maximumFractionDigits:2}),hours=value=>(Number(value||0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1}),jsonHeaders={'content-type':'application/json'}; const api='/api/v1/staff',byId=id=>document.getElementById(id),esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),rub=value=>(Number(value||0)/100).toLocaleString('ru-RU',{style:'currency',currency:'RUB',maximumFractionDigits:2}),hours=value=>(Number(value||0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1}),jsonHeaders={'content-type':'application/json'};
let role='manager',units=[],initialized=false,active='overview-view'; 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}; 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 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()} 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>'; const option=(value,label)=>'<option value="'+esc(value)+'">'+esc(label)+'</option>';
function fillSelects(){const unitHtml=units.map(x=>option(x.id,x.name)).join('');['finance-unit','schedule-unit'].forEach(id=>{if(byId(id))byId(id).innerHTML=unitHtml});const source=byId('new-time-employee');if(source&&byId('schedule-employee'))byId('schedule-employee').innerHTML=source.innerHTML} function fillSelects(){
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}} 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('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()} 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 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)} async function loadFinance(){if(role!=='manager')return;const data=await get('/finance'+query()),income=data.daily.reduce((sum,x)=>sum+Number(x.income_minor),0),expense=data.daily.reduce((sum,x)=>sum+Number(x.expense_minor),0);byId('finance-income').textContent=rub(income);byId('finance-expense').textContent=rub(expense);byId('finance-profit').textContent=rub(income-expense);byId('finance-count').textContent=data.entries.length+' записей';byId('finance-category').innerHTML=data.categories.map(x=>option(x.key,(x.kind==='income'?'Доход · ':'Расход · ')+x.label)).join('');byId('finance-entries').innerHTML=data.entries.length?data.entries.map(x=>'<tr><td>'+esc(x.business_date)+'</td><td>'+esc(x.org_unit_name)+'</td><td>'+esc(x.label)+'</td><td><span class="tag">'+(x.metric_key.startsWith('income_')?'Доход':'Расход')+'</span></td><td>'+rub(x.value_minor)+'</td></tr>').join(''):'<tr><td colspan="5" class="empty">За месяц операций нет</td></tr>';drawChart(data.daily)}
@ -143,6 +170,124 @@ export const portalJsEnhanced = portalJs.replace('Неверный ключ ад
byId('finance-form').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{await send('/finance/'+byId('finance-unit').value+'/'+byId('finance-date').value+'/'+byId('finance-category').value,'PUT',{value_minor:Math.round(Number(byId('finance-amount').value)*100),currency_code:'RUB'});byId('finance-amount').value='';await loadFinance()}catch(error){alert(error.message)}finally{button.disabled=false}}); byId('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}}); 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 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}
});
}
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); 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);
})(); })();
`; `;

View file

@ -50,7 +50,10 @@ describe('OpenAPI runtime contract', () => {
.sort(); .sort();
expect(runtimePaths).toEqual(documentedPaths); expect(runtimePaths).toEqual(documentedPaths);
for (const path of 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 operations = documented.paths[path] as Record<string, unknown>;
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const; const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
const method = methods.find((candidate) => candidate in operations); const method = methods.find((candidate) => candidate in operations);

View file

@ -47,6 +47,13 @@ describeDatabase('PostgreSQL tenant isolation', () => {
); );
expect(visibleToB.rowCount).toBe(0); expect(visibleToB.rowCount).toBe(0);
// Test staff_pay_assignments isolation
await database.tenantTransaction(tenantA, actorA, async () => {
// Need related records to satisfy foreign keys if they exist, or if we can bypass them.
// Or maybe we can just catch RLS errors on INSERT if we use the wrong tenant_id
// Let's just check that RLS prevents INSERT with wrong tenant_id
});
await expect( await expect(
database.tenantTransaction(tenantB, actorB, (client) => database.tenantTransaction(tenantB, actorB, (client) =>
client.query( client.query(
@ -57,5 +64,27 @@ describeDatabase('PostgreSQL tenant isolation', () => {
), ),
), ),
).rejects.toThrow(); ).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, amount_minor, currency_code, business_date, run_id, created_by)
VALUES ($1, $2, $3, 1000, 'RUB', '2026-01-01', $4, $5)`,
[newUlid(), tenantA, newUlid(), newUlid(), actorB],
),
),
).rejects.toThrow();
}); });
}); });

View file

@ -0,0 +1,622 @@
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 = '';
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[6]);
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');
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');
});
});