diff --git a/api/openapi.yaml b/api/openapi.yaml index 7566696..2d2908d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -270,6 +270,67 @@ paths: '200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток } '403': { description: Недостаточно прав } '404': { description: Сотрудник не найден } + /employees/{employeeId}/pay-assignment: + get: + summary: Получить зарплатный профиль сотрудника + operationId: getEmployeePayAssignment + tags: [Payroll] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: employeeId + required: true + schema: { $ref: '#/components/schemas/Ulid' } + responses: + '200': + description: Зарплатный профиль сотрудника + content: + application/json: + schema: { $ref: '#/components/schemas/PayAssignment' } + '403': { description: Недостаточно прав } + '404': { description: Сотрудник или зарплатный профиль не найден } + put: + summary: Сохранить зарплатный профиль сотрудника + operationId: saveEmployeePayAssignment + tags: [Payroll] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: employeeId + required: true + schema: { $ref: '#/components/schemas/Ulid' } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PayAssignmentSave' } + responses: + '200': + description: Зарплатный профиль сохранён + content: + application/json: + schema: { $ref: '#/components/schemas/PayAssignment' } + '403': { description: Недостаточно прав } + '404': { description: 'Сотрудник, точка или схема не найдены' } + /pay-rules: + get: + summary: Получить список схем расчёта зарплаты + operationId: listStaffPayRules + tags: [Payroll] + security: [{ bearerAuth: [] }] + responses: + '200': + description: Список доступных схем расчёта + content: + application/json: + schema: + type: object + required: [pay_rules] + properties: + pay_rules: + type: array + items: { type: object } + '403': { description: Недостаточно прав } /org-units: get: summary: Получить доступные торговые точки @@ -363,6 +424,95 @@ paths: responses: '200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' } '403': { description: Недостаточно прав } + /payroll/calculate: + post: + summary: Рассчитать зарплату за период + operationId: calculateStaffPayroll + tags: [Payroll] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + month: { type: string, pattern: '^\\d{4}-\\d{2}$' } + from: { type: string, format: date } + to: { type: string, format: date } + org_unit_id: { $ref: '#/components/schemas/Ulid' } + responses: + '200': + description: Предварительный результат расчёта + content: + application/json: + schema: + type: object + required: [run_id, status, period_start, period_end, total_accrued_minor, totals, warnings] + properties: + run_id: { $ref: '#/components/schemas/Ulid' } + status: { const: calculated } + period_start: { type: string, format: date } + period_end: { type: string, format: date } + total_accrued_minor: { type: string } + totals: { type: array, items: { type: object } } + warnings: { type: array, items: { type: object } } + '400': { description: Некорректный период } + '403': { description: Недостаточно прав } + '409': { description: Расчёт за этот период уже утверждён } + /payroll/runs/{runId}/approve: + post: + summary: Утвердить расчёт зарплаты и записать начисления в реестр + operationId: approveStaffPayrollRun + tags: [Payroll] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: runId + required: true + schema: { $ref: '#/components/schemas/Ulid' } + responses: + '200': + description: Расчёт утверждён и проведён в реестр + content: + application/json: + schema: + type: object + required: [run_id, status, approved_by, approved_at, total_accrued_minor, entries_posted] + properties: + run_id: { $ref: '#/components/schemas/Ulid' } + status: { const: approved } + approved_by: { $ref: '#/components/schemas/Ulid' } + approved_at: { type: string, format: date-time } + total_accrued_minor: { type: string } + entries_posted: { type: integer } + '400': { description: Расчёт не в статусе calculated } + '403': { description: Недостаточно прав } + '404': { description: Расчёт не найден } + /payroll/runs/{runId}: + get: + summary: Получить подробности расчёта зарплаты + operationId: getStaffPayrollRun + tags: [Payroll] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: runId + required: true + schema: { $ref: '#/components/schemas/Ulid' } + responses: + '200': + description: Детали расчёта со строками + content: + application/json: + schema: + type: object + required: [run, lines] + properties: + run: { type: object } + lines: { type: array, items: { type: object } } + '403': { description: Недостаточно прав } + '404': { description: Расчёт не найден } /imports/owner-workbook: post: summary: Идемпотентно импортировать локально разобранную книгу владельца @@ -460,3 +610,44 @@ components: name: { const: krona-staff } version: { type: string } mode: { enum: [standalone, platform] } + PayAssignment: + type: object + additionalProperties: false + required: [id, employee_id, pay_rule_id, pay_rule_code, pay_rule_name, rate_minor, percent_bp, guarantee_minor, pool_weight_bp, effective_from] + properties: + id: { $ref: '#/components/schemas/Ulid' } + employee_id: { $ref: '#/components/schemas/Ulid' } + org_unit_id: + oneOf: + - { $ref: '#/components/schemas/Ulid' } + - { type: 'null' } + pay_rule_id: { $ref: '#/components/schemas/Ulid' } + pay_rule_code: { type: string } + pay_rule_name: { type: string } + rate_minor: { type: integer, minimum: 0 } + percent_bp: { type: integer, minimum: 0 } + guarantee_minor: { type: integer, minimum: 0 } + pool_weight_bp: { type: integer, minimum: 0 } + effective_from: { type: string, format: date } + effective_to: + oneOf: + - { type: string, format: date } + - { type: 'null' } + params: { type: object } + note: { type: string } + PayAssignmentSave: + type: object + additionalProperties: false + required: [pay_rule_id] + properties: + org_unit_id: + oneOf: + - { $ref: '#/components/schemas/Ulid' } + - { type: 'null' } + pay_rule_id: { $ref: '#/components/schemas/Ulid' } + rate_minor: { type: integer, minimum: 0 } + percent_bp: { type: integer, minimum: 0, maximum: 10000 } + guarantee_minor: { type: integer, minimum: 0 } + pool_weight_bp: { type: integer, minimum: 0, maximum: 20000 } + effective_from: { type: string, format: date } + note: { type: string, maxLength: 500 } diff --git a/src/domain/payroll/service.ts b/src/domain/payroll/service.ts new file mode 100644 index 0000000..448c40e --- /dev/null +++ b/src/domain/payroll/service.ts @@ -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; + 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 | 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 { + 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 { + await ensureDefaultPayRules(client, tenantId, actorId); + const result = await client.query( + `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 { + const result = await client.query( + `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) + : row.params) ?? {}; + + const params: Record = {}; + 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 { + 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 = { + 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 { + 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(); + 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 | 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( + `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( + `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(); + 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(); + + // 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 = []; + + 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) + : 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 = {}; + 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 { + const tenantId = context.tenantId; + const actorId = context.actorId; + const correlationId = context.correlationId; + + const runQuery = await client.query( + `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( + `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( + `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 }; +} diff --git a/src/operations.ts b/src/operations.ts index aaf4f92..6014332 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -3,6 +3,16 @@ import type { PoolClient } from 'pg'; import type { RequestContext } from './core/context.js'; import { newUlid, parseUlid } from './core/ids.js'; import type { Database } from './infrastructure/database.js'; +import { + approvePayrollRun, + calculatePayrollPeriod, + ensureDefaultPayRules, + getEmployeePayAssignment, + getPayrollRunDetails, + listPayRules, + saveEmployeePayAssignment, + type PayAssignmentInput, +} from './domain/payroll/service.js'; const datePattern = /^\d{4}-\d{2}-\d{2}$/; @@ -417,7 +427,7 @@ export async function registerOperationsRoutes( FROM staff_employees e LEFT JOIN ledger l ON l.employee_id = e.id LEFT JOIN worked w ON w.employee_id = e.id - WHERE e.status <> 'dismissed' AND ($3::boolean OR e.core_user_id = $4) + WHERE e.status <> 'dismissed' AND ($3::boolean OR e.core_user_id = $4 OR e.id = $4) ORDER BY e.display_name`, [period.from, period.to, canManage, context.actorId], ); @@ -427,7 +437,7 @@ export async function registerOperationsRoutes( FROM staff_payroll_ledger l JOIN staff_employees e ON e.tenant_id = l.tenant_id AND e.id = l.employee_id WHERE l.business_date BETWEEN $1 AND $2 - AND ($3::boolean OR e.core_user_id = $4) + AND ($3::boolean OR e.core_user_id = $4 OR e.id = $4) ORDER BY l.business_date DESC, e.display_name`, [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', { schema: { body: { diff --git a/src/portal-ui.ts b/src/portal-ui.ts index 3aac41c..af96721 100644 --- a/src/portal-ui.ts +++ b/src/portal-ui.ts @@ -68,13 +68,16 @@ export const portalHtml = `
-

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

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

+

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

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

Зарплатный профиль

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

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

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

Krona Business Platform · Staff
@@ -121,18 +124,42 @@ export const portalJs = `(() => { 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}} +.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('Неверный ключ администратора.', 'Неверный ключ доступа.') + ` (() => { - const api='/api/v1',byId=id=>document.getElementById(id),esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),rub=value=>(Number(value||0)/100).toLocaleString('ru-RU',{style:'currency',currency:'RUB',maximumFractionDigits:2}),hours=value=>(Number(value||0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1}),jsonHeaders={'content-type':'application/json'}; - let role='manager',units=[],initialized=false,active='overview-view'; + const api='/api/v1/staff',byId=id=>document.getElementById(id),esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),rub=value=>(Number(value||0)/100).toLocaleString('ru-RU',{style:'currency',currency:'RUB',maximumFractionDigits:2}),hours=value=>(Number(value||0)/60).toLocaleString('ru-RU',{maximumFractionDigits:1}),jsonHeaders={'content-type':'application/json'}; + let role='manager',units=[],initialized=false,active='overview-view',payRulesCache=[],currentRunId=null; const period=()=>{const month=byId('period-month').value||new Date().toISOString().slice(0,7),start=month+'-01',d=new Date(start+'T00:00:00Z');d.setUTCMonth(d.getUTCMonth()+1);d.setUTCDate(0);return{from:start,to:d.toISOString().slice(0,10)}},query=()=>{const p=period();return'?from='+p.from+'&to='+p.to}; async function get(path){const response=await fetch(api+path,{credentials:'same-origin'});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось загрузить данные.');return response.json()} async function send(path,method,body){const response=await fetch(api+path,{method,credentials:'same-origin',headers:jsonHeaders,body:JSON.stringify(body)});if(!response.ok)throw new Error((await response.json().catch(()=>({}))).message||'Не удалось сохранить данные.');return response.json()} const option=(value,label)=>''; - function fillSelects(){const unitHtml=units.map(x=>option(x.id,x.name)).join('');['finance-unit','schedule-unit'].forEach(id=>{if(byId(id))byId(id).innerHTML=unitHtml});const source=byId('new-time-employee');if(source&&byId('schedule-employee'))byId('schedule-employee').innerHTML=source.innerHTML} - async function initialize(){if(byId('app-shell').hidden||initialized)return;initialized=true;try{const state=await get('/portal');role=state.viewer&&state.viewer.role||'manager';document.querySelectorAll('.manager-only').forEach(node=>node.hidden=role!=='manager');byId('viewer').textContent=role==='employee'?'Личный кабинет сотрудника':'Кабинет руководителя';units=(await get('/org-units')).org_units||[];fillSelects();await loadActive()}catch{initialized=false}} + function fillSelects(){ + const unitHtml=units.map(x=>option(x.id,x.name)).join(''); + ['finance-unit','schedule-unit'].forEach(id=>{if(byId(id))byId(id).innerHTML=unitHtml}); + if(byId('employee-pay-unit')) byId('employee-pay-unit').innerHTML=''+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()} async function loadActive(){try{if(active==='finance-view')await loadFinance();if(active==='schedule-view')await loadSchedule();if(active==='payroll-view')await loadPayroll()}catch(error){const box=byId('message');if(box){box.textContent=error.message;box.hidden=false}}} async function loadFinance(){if(role!=='manager')return;const data=await get('/finance'+query()),income=data.daily.reduce((sum,x)=>sum+Number(x.income_minor),0),expense=data.daily.reduce((sum,x)=>sum+Number(x.expense_minor),0);byId('finance-income').textContent=rub(income);byId('finance-expense').textContent=rub(expense);byId('finance-profit').textContent=rub(income-expense);byId('finance-count').textContent=data.entries.length+' записей';byId('finance-category').innerHTML=data.categories.map(x=>option(x.key,(x.kind==='income'?'Доход · ':'Расход · ')+x.label)).join('');byId('finance-entries').innerHTML=data.entries.length?data.entries.map(x=>''+esc(x.business_date)+''+esc(x.org_unit_name)+''+esc(x.label)+''+(x.metric_key.startsWith('income_')?'Доход':'Расход')+''+rub(x.value_minor)+'').join(''):'За месяц операций нет';drawChart(data.daily)} @@ -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('schedule-form').addEventListener('submit',async event=>{event.preventDefault();const button=event.submitter;button.disabled=true;try{await send('/schedule/'+byId('schedule-employee').value+'/'+byId('schedule-date').value,'PUT',{org_unit_id:byId('schedule-unit').value,planned_minutes:Math.round(Number(byId('schedule-hours').value)*60),start_time:byId('schedule-start').value,end_time:byId('schedule-end').value});await loadSchedule()}catch(error){alert(error.message)}finally{button.disabled=false}}); document.addEventListener('click',async event=>{const button=event.target.closest('.person-access');if(!button)return;button.disabled=true;try{const result=await send('/employees/'+button.dataset.id+'/access-token','POST',{});byId('access-token').value=result.access_token;byId('access-dialog').showModal()}catch(error){alert(error.message)}finally{button.disabled=false}});byId('access-copy').addEventListener('click',async()=>{await navigator.clipboard.writeText(byId('access-token').value);byId('access-copy').textContent='Скопировано'}); + + document.addEventListener('click',async event=>{ + const editBtn=event.target.closest('.edit-employee'); + if(!editBtn||role!=='manager')return; + const empId=editBtn.dataset.id; + if(byId('employee-pay-section')) byId('employee-pay-section').hidden=false; + try{ + const a=await get('/employees/'+empId+'/pay-assignment'); + byId('employee-pay-unit').value=a.org_unit_id||''; + byId('employee-pay-rule').value=a.pay_rule_id; + byId('employee-pay-rate').value=Math.round(Number(a.rate_minor||0)/100); + byId('employee-pay-percent').value=(Number(a.percent_bp||0)/100).toFixed(1); + byId('employee-pay-guarantee').value=Math.round(Number(a.guarantee_minor||0)/100); + byId('employee-pay-pool-weight').value=Math.round(Number(a.pool_weight_bp||0)/100); + byId('employee-pay-effective').value=a.effective_from||'2026-01-01'; + }catch{ + if(payRulesCache.length>0) byId('employee-pay-rule').value=payRulesCache[0].id; + byId('employee-pay-unit').value=''; + byId('employee-pay-rate').value=100; + byId('employee-pay-percent').value=0; + byId('employee-pay-guarantee').value=0; + byId('employee-pay-pool-weight').value=100; + byId('employee-pay-effective').value='2026-01-01'; + } + }); + + if(byId('add-employee')){ + byId('add-employee').addEventListener('click',()=>{ + if(byId('employee-pay-section')) byId('employee-pay-section').hidden=false; + if(payRulesCache.length>0) byId('employee-pay-rule').value=payRulesCache[0].id; + byId('employee-pay-unit').value=''; + byId('employee-pay-rate').value=100; + byId('employee-pay-percent').value=0; + byId('employee-pay-guarantee').value=0; + byId('employee-pay-pool-weight').value=100; + byId('employee-pay-effective').value='2026-01-01'; + }); + } + + if(byId('employee-pay-rule')){ + byId('employee-pay-rule').addEventListener('change',()=>{ + const rule=payRulesCache.find(r=>r.id===byId('employee-pay-rule').value); + if(rule){ + if(rule.code==='pool_member'){ + byId('employee-pay-rate').value=100; + byId('employee-pay-percent').value=0; + byId('employee-pay-guarantee').value=0; + byId('employee-pay-pool-weight').value=100; + }else if(rule.code==='revenue_percent'){ + byId('employee-pay-rate').value=100; + byId('employee-pay-percent').value=2.5; + byId('employee-pay-guarantee').value=3000; + byId('employee-pay-pool-weight').value=0; + }else if(rule.code==='hourly'){ + byId('employee-pay-rate').value=100; + byId('employee-pay-percent').value=0; + byId('employee-pay-guarantee').value=0; + byId('employee-pay-pool-weight').value=0; + } + } + }); + } + + if(byId('employee-form')){ + byId('employee-form').addEventListener('submit',async()=>{ + const empId=byId('employee-id').value; + if(role==='manager'&&empId&&byId('employee-pay-rule')&&byId('employee-pay-rule').value){ + try{ + await send('/employees/'+empId+'/pay-assignment','PUT',{ + org_unit_id:byId('employee-pay-unit').value||null, + pay_rule_id:byId('employee-pay-rule').value, + rate_minor:Math.round(Number(byId('employee-pay-rate').value||0)*100), + percent_bp:Math.round(Number(byId('employee-pay-percent').value||0)*100), + guarantee_minor:Math.round(Number(byId('employee-pay-guarantee').value||0)*100), + pool_weight_bp:Math.round(Number(byId('employee-pay-pool-weight').value||0)*100), + effective_from:byId('employee-pay-effective').value||'2026-01-01' + }); + }catch(e){console.error('Pay assignment save error:',e)} + } + }); + } + + if(byId('payroll-calc-btn')){ + byId('payroll-calc-btn').addEventListener('click',async()=>{ + const btn=byId('payroll-calc-btn'); + btn.disabled=true; + byId('payroll-warnings').hidden=true; + byId('payroll-calc-status').hidden=true; + try{ + const month=byId('period-month').value||new Date().toISOString().slice(0,7); + const res=await send('/payroll/calculate','POST',{month}); + currentRunId=res.run_id; + if(res.warnings&&res.warnings.length){ + byId('payroll-warnings').innerHTML='Предупреждения при расчёте:
    '+res.warnings.map(w=>'
  • '+esc(w.message)+'
  • ').join('')+'
'; + 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=>''+esc(x.display_name)+''+esc(x.position||'—')+''+x.days+''+hours(x.worked_minutes)+''+rub(x.accrued_minor)+'').join(''):'Нет начислений'; + 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); })(); `; diff --git a/tests/integration/openapi-runtime.test.ts b/tests/integration/openapi-runtime.test.ts index ece494a..38cc92b 100644 --- a/tests/integration/openapi-runtime.test.ts +++ b/tests/integration/openapi-runtime.test.ts @@ -50,7 +50,10 @@ describe('OpenAPI runtime contract', () => { .sort(); expect(runtimePaths).toEqual(documentedPaths); for (const path of documentedPaths) { - const runtimePath = path.replace('{employeeId}', '01J00000000000000000000000').replace('{workDate}', '2026-09-24'); + const runtimePath = path + .replace('{employeeId}', '01J00000000000000000000000') + .replace('{workDate}', '2026-09-24') + .replace('{runId}', '01J00000000000000000000000'); const operations = documented.paths[path] as Record; const methods = ['get', 'post', 'put', 'patch', 'delete'] as const; const method = methods.find((candidate) => candidate in operations); diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts index 40ec9ed..a815ccd 100644 --- a/tests/integration/tenant-isolation.test.ts +++ b/tests/integration/tenant-isolation.test.ts @@ -47,6 +47,13 @@ describeDatabase('PostgreSQL tenant isolation', () => { ); 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( database.tenantTransaction(tenantB, actorB, (client) => client.query( @@ -57,5 +64,27 @@ describeDatabase('PostgreSQL tenant isolation', () => { ), ), ).rejects.toThrow(); + + await expect( + database.tenantTransaction(tenantB, actorB, (client) => + client.query( + `INSERT INTO staff_pay_assignments + (id, tenant_id, employee_id, pay_rule_id, effective_from, params, pool_weight_bp, created_by) + VALUES ($1, $2, $3, $4, '2026-01-01', '{}'::jsonb, 0, $5)`, + [newUlid(), tenantA, newUlid(), newUlid(), actorB], + ), + ), + ).rejects.toThrow(); + + await expect( + database.tenantTransaction(tenantB, actorB, (client) => + client.query( + `INSERT INTO staff_payroll_ledger + (id, tenant_id, employee_id, 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(); }); }); diff --git a/tests/unit/payroll-auto-calc.test.ts b/tests/unit/payroll-auto-calc.test.ts new file mode 100644 index 0000000..ba743f7 --- /dev/null +++ b/tests/unit/payroll-auto-calc.test.ts @@ -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 { + 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; + }): 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; + }): 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'); + }); +});