feat(payroll): automatic payroll calculation, owner schemes, and UI integration
This commit is contained in:
parent
c08a7e6af1
commit
37da6f0418
7 changed files with 2174 additions and 9 deletions
191
api/openapi.yaml
191
api/openapi.yaml
|
|
@ -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 }
|
||||||
|
|
|
||||||
924
src/domain/payroll/service.ts
Normal file
924
src/domain/payroll/service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
157
src/portal-ui.ts
157
src/portal-ui.ts
|
|
@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
622
tests/unit/payroll-auto-calc.test.ts
Normal file
622
tests/unit/payroll-auto-calc.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue