From 5b159f785cf739d811c59484b6972ea896b7048b Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 21:14:03 +0700 Subject: [PATCH] feat(staff): import workbook professions and dated payroll --- migrations/0009_professions.down.sql | 6 + migrations/0009_professions.up.sql | 32 + scripts/import-owner-workbook.py | 274 +++++ src/domain/payroll/service.ts | 128 ++- src/operations.ts | 820 ++++++++----- src/portal.ts | 1202 +++++++++++++------- tests/integration/portal.test.ts | 156 ++- tests/integration/tenant-isolation.test.ts | 23 + tests/unit/payroll-auto-calc.test.ts | 58 +- 9 files changed, 1935 insertions(+), 764 deletions(-) create mode 100644 migrations/0009_professions.down.sql create mode 100644 migrations/0009_professions.up.sql create mode 100644 scripts/import-owner-workbook.py diff --git a/migrations/0009_professions.down.sql b/migrations/0009_professions.down.sql new file mode 100644 index 0000000..7648db6 --- /dev/null +++ b/migrations/0009_professions.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS staff_pay_assignments_effective_version_idx; +DROP INDEX IF EXISTS staff_employees_external_ref_idx; +DROP INDEX IF EXISTS staff_employees_profession_idx; +ALTER TABLE staff_employees DROP CONSTRAINT IF EXISTS staff_employees_profession_fk; +ALTER TABLE staff_employees DROP COLUMN IF EXISTS profession_id; +DROP TABLE IF EXISTS staff_professions; diff --git a/migrations/0009_professions.up.sql b/migrations/0009_professions.up.sql new file mode 100644 index 0000000..b5cd03c --- /dev/null +++ b/migrations/0009_professions.up.sql @@ -0,0 +1,32 @@ +CREATE TABLE staff_professions ( + id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), + tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), + code VARCHAR(80) NOT NULL, + name VARCHAR(160) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, code), + UNIQUE (tenant_id, name) +); + +ALTER TABLE staff_professions ENABLE ROW LEVEL SECURITY; +ALTER TABLE staff_professions FORCE ROW LEVEL SECURITY; +CREATE POLICY staff_professions_tenant_isolation ON staff_professions + USING (tenant_id = current_setting('app.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('app.tenant_id', true)); + +ALTER TABLE staff_employees ADD COLUMN profession_id VARCHAR(26); +ALTER TABLE staff_employees ADD CONSTRAINT staff_employees_profession_fk + FOREIGN KEY (tenant_id, profession_id) REFERENCES staff_professions (tenant_id, id); +CREATE INDEX staff_employees_profession_idx ON staff_employees (tenant_id, profession_id); + +CREATE UNIQUE INDEX staff_employees_external_ref_idx + ON staff_employees (tenant_id, external_ref) WHERE external_ref IS NOT NULL; + +CREATE UNIQUE INDEX staff_pay_assignments_effective_version_idx + ON staff_pay_assignments + (tenant_id, employee_id, COALESCE(org_unit_id, ''), effective_from); + +GRANT SELECT, INSERT, UPDATE, DELETE ON staff_professions TO krona_staff_app; diff --git a/scripts/import-owner-workbook.py b/scripts/import-owner-workbook.py new file mode 100644 index 0000000..f442c29 --- /dev/null +++ b/scripts/import-owner-workbook.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Read the owner's workbook and import raw 2026 facts into Krona Staff. + +The workbook is never modified. Personal data stays in memory and is sent only to +the explicitly supplied HTTPS Staff endpoint. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import ssl +import sys +import urllib.error +import urllib.request +from collections import defaultdict +from datetime import datetime +from decimal import Decimal, ROUND_HALF_UP +from pathlib import Path + +import openpyxl + +FINANCE_COLUMNS = { + 3: "income_cash", + 4: "income_card", + 5: "income_transfer", + 6: "income_sber_market", + 7: "income_yandex", + 8: "income_certificate", + 10: "income_recycling", + 12: "expense_products", + 13: "expense_supplies", + 14: "expense_taxi", + 15: "expense_communications", + 16: "expense_advertising", + 17: "expense_owner_payroll", + 18: "expense_taxes", + 19: "expense_rent", + 20: "expense_utilities", + 21: "expense_other", + 22: "expense_gifts", +} +UNIT_SHEETS = {"Горский": ("gorsky", "Горский"), "Красный": ("krasny", "Красный")} +POINT_CODES = {"Г": "gorsky", "К": "krasny", "Ф": "krasny"} +PROFESSIONS = { + "Повар": "Повар", + "Доп Повар": "Дополнительный повар", + "Стажер": "Стажёр", + "Админ": "Администратор", +} + + +def money_minor(value: object) -> int | None: + if not isinstance(value, (int, float, Decimal)): + return None + return int((Decimal(str(value)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + +def normalized_name(value: object) -> str: + return " ".join(str(value or "").split()) + + +def stable_ref(prefix: str, value: str) -> str: + return prefix + hashlib.sha256(value.encode("utf-8")).hexdigest()[:24] + + +def extract(path: Path) -> tuple[dict[str, list[dict]], dict[str, object]]: + workbook = openpyxl.load_workbook(path, data_only=True, read_only=False) + units = [{"code": code, "name": name, "external_ref": f"xlsx-unit:{code}"} + for code, name in UNIT_SHEETS.values()] + finance: list[dict] = [] + monthly = defaultdict(lambda: {"income_minor": 0, "expense_minor": 0}) + + for sheet_name, (unit_code, _) in UNIT_SHEETS.items(): + sheet = workbook[sheet_name] + for row in range(2, sheet.max_row + 1): + business_date = sheet.cell(row, 1).value + if not isinstance(business_date, datetime) or business_date.year != 2026: + continue + date_text = business_date.date().isoformat() + for column, metric_key in FINANCE_COLUMNS.items(): + value_minor = money_minor(sheet.cell(row, column).value) + if value_minor is None or value_minor == 0: + continue + finance.append({ + "org_unit_code": unit_code, + "business_date": date_text, + "metric_key": metric_key, + "value_minor": value_minor, + "currency_code": "RUB", + }) + bucket = monthly[date_text[:7]] + bucket["income_minor" if metric_key.startswith("income_") else "expense_minor"] += value_minor + + graph = workbook["График"] + date_columns = { + column: graph.cell(1, column).value.date().isoformat() + for column in range(4, graph.max_column + 1) + if isinstance(graph.cell(1, column).value, datetime) + and graph.cell(1, column).value.year == 2026 + } + employee_blocks: list[tuple[int, str, str]] = [] + professions_by_name: dict[str, set[str]] = defaultdict(set) + for row in range(2, graph.max_row): + if graph.cell(row, 3).value != "Часов": + continue + name = normalized_name(graph.cell(row, 2).value) + profession = PROFESSIONS.get(normalized_name(graph.cell(row - 1, 1).value)) + if not name or not profession: + continue + employee_blocks.append((row, name, profession)) + professions_by_name[name].add(profession) + + names = sorted(professions_by_name) + employees = [] + refs: dict[str, str] = {} + for name in names: + identity_hash = hashlib.sha256(name.casefold().encode("utf-8")).hexdigest()[:16] + reference = f"xlsx:staff:{identity_hash}" + refs[name] = reference + professions = professions_by_name[name] + priority = ("Администратор", "Повар", "Дополнительный повар", "Стажёр") + primary = next(role for role in priority if role in professions) + employees.append({ + "external_ref": reference, + "personnel_number": f"XLSX-{identity_hash.upper()}", + "display_name": name, + "position": primary, + }) + + schedule_totals: dict[tuple[str, str, str], int] = defaultdict(int) + schedule_locations: dict[tuple[str, str, str], set[str]] = defaultdict(set) + payroll_totals: dict[tuple[str, str, str], int] = defaultdict(int) + payout_totals: dict[tuple[str, str, str], int] = defaultdict(int) + inferred_points = 0 + for row, name, _profession in employee_blocks: + for column, date_text in date_columns.items(): + hours = graph.cell(row, column).value + if not isinstance(hours, (int, float)) or hours <= 0: + continue + point_code = normalized_name(graph.cell(row - 1, column).value) + unit_code = POINT_CODES.get(point_code) + if unit_code is None: + inferred_points += 1 + lowered = name.casefold() + unit_code = "krasny" if ("крас" in lowered or "кр." in lowered) else "gorsky" + identity = (refs[name], date_text, unit_code) + schedule_totals[identity] += int( + Decimal(str(hours * 60)).quantize(Decimal("1"))) + schedule_locations[identity].add(point_code or "inferred") + salary_minor = money_minor(graph.cell(row + 1, column).value) + if salary_minor: + payroll_totals[identity] += salary_minor + payout_minor = money_minor(graph.cell(row + 2, column).value) + if payout_minor: + payout_totals[identity] += abs(payout_minor) + + schedule = [{ + "external_key": stable_ref("xlsx:shift:", "|".join(identity)), + "employee_external_ref": identity[0], + "org_unit_code": identity[2], + "business_date": identity[1], + "planned_minutes": minutes, + "location_code": ",".join(sorted(schedule_locations[identity])), + } for identity, minutes in sorted(schedule_totals.items())] + payroll = [{ + "external_key": stable_ref("xlsx:payroll:accrual:", "|".join(identity)), + "employee_external_ref": identity[0], + "business_date": identity[1], + "kind": "accrual", + "amount_minor": amount, + "note": "Импорт начисления из листа «График»", + } for identity, amount in sorted(payroll_totals.items())] + payroll.extend({ + "external_key": stable_ref("xlsx:payroll:payout:", "|".join(identity)), + "employee_external_ref": identity[0], + "business_date": identity[1], + "kind": "payout", + "amount_minor": amount, + "note": "Импорт выплаты из строки «Долг» листа «График»", + } for identity, amount in sorted(payout_totals.items())) + + summary = workbook["Сводная"] + for unit_code, row in (("gorsky", 31), ("krasny", 43)): + for column in range(3, 15): + period_end = summary.cell(2, column).value + amount = money_minor(summary.cell(row, column).value) + if not isinstance(period_end, datetime) or period_end.year != 2026 or not amount: + continue + business_date = period_end.date().isoformat() + finance.append({ + "org_unit_code": unit_code, + "business_date": business_date, + "metric_key": "expense_employee_payroll", + "value_minor": amount, + "currency_code": "RUB", + }) + monthly[business_date[:7]]["expense_minor"] += amount + + report = { + "year": 2026, + "units": len(units), + "employees": len(employees), + "financial_entries": len(finance), + "schedule_items": len(schedule), + "payroll_entries": len(payroll), + "inferred_point_codes": inferred_points, + "monthly": dict(sorted(monthly.items())), + "totals": { + "income_minor": sum(item["income_minor"] for item in monthly.values()), + "expense_minor": sum(item["expense_minor"] for item in monthly.values()), + }, + } + return { + "units": units, + "employees": employees, + "financial_entries": finance, + "schedule_items": schedule, + "payroll_entries": payroll, + }, report + + +def chunks(values: list[dict], size: int = 500): + for offset in range(0, len(values), size): + yield values[offset:offset + size] + + +def post(url: str, token: str, payload: dict, allow_self_signed: bool) -> dict: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + request = urllib.request.Request( + url.rstrip("/") + "/api/v1/staff/imports/owner-workbook", + data=body, + method="POST", + headers={"authorization": f"Bearer {token}", "content-type": "application/json"}, + ) + context = ssl._create_unverified_context() if allow_self_signed else ssl.create_default_context() + with urllib.request.urlopen(request, context=context, timeout=90) as response: + return json.loads(response.read().decode("utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("workbook", type=Path) + parser.add_argument("--server", default=os.getenv("KRONA_STAFF_SERVER", "")) + parser.add_argument("--token", default=os.getenv("KRONA_STAFF_TOKEN", "")) + parser.add_argument("--allow-self-signed", action="store_true") + parser.add_argument("--apply", action="store_true", help="Send data; otherwise only validate and report") + args = parser.parse_args() + + payload, report = extract(args.workbook) + print(json.dumps(report, ensure_ascii=False, indent=2)) + if not args.apply: + return 0 + if not args.server.lower().startswith("https://"): + raise SystemExit("--server must be an https:// URL") + if len(args.token) < 32: + raise SystemExit("Provide --token or KRONA_STAFF_TOKEN") + + source = f"{args.workbook.name}:2026" + batches = [ + {"units": payload["units"], "employees": payload["employees"]}, + *({"financial_entries": part} for part in chunks(payload["financial_entries"])), + *({"schedule_items": part} for part in chunks(payload["schedule_items"])), + *({"payroll_entries": part} for part in chunks(payload["payroll_entries"])), + ] + for number, batch in enumerate(batches, start=1): + result = post(args.server, args.token, {"source_name": source, **batch}, args.allow_self_signed) + print(f"Imported batch {number}/{len(batches)}: {result}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/domain/payroll/service.ts b/src/domain/payroll/service.ts index 448c40e..4759974 100644 --- a/src/domain/payroll/service.ts +++ b/src/domain/payroll/service.ts @@ -219,9 +219,10 @@ export async function getEmployeePayAssignment( if (result.rowCount === 0 || !result.rows[0]) return null; const row = result.rows[0]; - const rawParams = (typeof row.params === 'string' - ? (JSON.parse(row.params) as Record) - : row.params) ?? {}; + const rawParams = + (typeof row.params === 'string' + ? (JSON.parse(row.params) as Record) + : row.params) ?? {}; const params: Record = {}; for (const [k, v] of Object.entries(rawParams)) { @@ -289,8 +290,7 @@ export async function saveEmployeePayAssignment( 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 poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight; const params: Record = { rate_minor: data.rate_minor ?? 10000, @@ -298,12 +298,45 @@ export async function saveEmployeePayAssignment( guarantee_minor: data.guarantee_minor ?? 0, }; + await client.query( + `SELECT pg_advisory_xact_lock(hashtextextended( + scope.tenant_id || ':' || $2 || ':' || COALESCE($3::text, 'all-units'), 0)) + FROM (SELECT $1::text AS tenant_id) AS scope`, + [tenantId, employeeId, orgUnitId], + ); + 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 + AND org_unit_id IS NOT DISTINCT FROM $4::varchar(26) LIMIT 1`, - [tenantId, employeeId, effectiveFrom], + [tenantId, employeeId, effectiveFrom, orgUnitId], ); + const nextAssignment = await client.query<{ effective_from: string }>( + `SELECT effective_from::text AS effective_from + FROM staff_pay_assignments + WHERE tenant_id = $1 AND employee_id = $2 AND effective_from > $3 + AND org_unit_id IS NOT DISTINCT FROM $4::varchar(26) + ORDER BY effective_from ASC LIMIT 1`, + [tenantId, employeeId, effectiveFrom, orgUnitId], + ); + const nextEffectiveFrom = nextAssignment.rows[0]?.effective_from; + await client.query( + `UPDATE staff_pay_assignments + SET effective_to = ($3::date - INTERVAL '1 day')::date + WHERE tenant_id = $1 AND employee_id = $2 AND effective_from < $3 + AND org_unit_id IS NOT DISTINCT FROM $4::varchar(26) + AND (effective_to IS NULL OR effective_to >= $3)`, + [tenantId, employeeId, effectiveFrom, orgUnitId], + ); + const effectiveTo = nextEffectiveFrom + ? new Date(`${nextEffectiveFrom}T00:00:00.000Z`).toISOString().slice(0, 10) + : null; + const boundedEffectiveTo = effectiveTo + ? new Date(new Date(`${effectiveTo}T00:00:00.000Z`).valueOf() - 86_400_000) + .toISOString() + .slice(0, 10) + : null; let assignmentId: string; if ((existingAssignment.rowCount ?? 0) > 0 && existingAssignment.rows[0]) { @@ -314,16 +347,27 @@ export async function saveEmployeePayAssignment( pay_rule_id = $4, params = $5, pool_weight_bp = $6, - note = $7 + note = $7, + effective_to = $8 WHERE tenant_id = $1 AND id = $2`, - [tenantId, assignmentId, orgUnitId, payRuleId, JSON.stringify(params), poolWeightBp, data.note ?? ''], + [ + tenantId, + assignmentId, + orgUnitId, + payRuleId, + JSON.stringify(params), + poolWeightBp, + data.note ?? '', + boundedEffectiveTo, + ], ); } 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)`, + (id, tenant_id, employee_id, org_unit_id, pay_rule_id, effective_from, + effective_to, params, pool_weight_bp, note, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [ assignmentId, tenantId, @@ -331,6 +375,7 @@ export async function saveEmployeePayAssignment( orgUnitId, payRuleId, effectiveFrom, + boundedEffectiveTo, JSON.stringify(params), poolWeightBp, data.note ?? '', @@ -348,7 +393,13 @@ export async function saveEmployeePayAssignment( tenantId, actorId, assignmentId, - { employee_id: employeeId, org_unit_id: orgUnitId, pay_rule_id: payRuleId, params, pool_weight_bp: poolWeightBp }, + { + employee_id: employeeId, + org_unit_id: orgUnitId, + pay_rule_id: payRuleId, + params, + pool_weight_bp: poolWeightBp, + }, correlationId, ], ); @@ -532,7 +583,16 @@ export async function calculatePayrollPeriod( } const allLines: ProcessedLine[] = []; - const processedEmployees = new Map(); + 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) { @@ -585,9 +645,10 @@ export async function calculatePayrollPeriod( ? (JSON.parse(assignment.rule) as RuleNode) : assignment.rule; - const rawParams = (typeof assignment.params === 'string' - ? (JSON.parse(assignment.params) as Record) - : assignment.params) ?? {}; + const rawParams = + (typeof assignment.params === 'string' + ? (JSON.parse(assignment.params) as Record) + : assignment.params) ?? {}; const poolWeight = assignment.pool_weight_bp !== null && assignment.pool_weight_bp !== undefined @@ -731,19 +792,26 @@ export async function calculatePayrollPeriod( tenantId, actorId, runId, - { period_start: from, period_end: to, total_accrued_minor: totalAccruedMinor.toString(), lines: allLines.length }, + { + 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)); + 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, @@ -857,7 +925,11 @@ export async function approvePayrollRun( tenantId, runId, actorId, - { period_start: run.period_start, period_end: run.period_end, total_accrued_minor: run.total_accrued_minor }, + { + period_start: run.period_start, + period_end: run.period_end, + total_accrued_minor: run.total_accrued_minor, + }, correlationId, ], ); @@ -871,7 +943,11 @@ export async function approvePayrollRun( tenantId, actorId, runId, - { period_start: run.period_start, period_end: run.period_end, total_accrued_minor: run.total_accrued_minor }, + { + period_start: run.period_start, + period_end: run.period_end, + total_accrued_minor: run.total_accrued_minor, + }, correlationId, ], ); diff --git a/src/operations.ts b/src/operations.ts index 6014332..c93bae0 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -153,14 +153,17 @@ export async function registerOperationsRoutes( }); }); - api.get<{ Querystring: PeriodQuery }>('/finance', { - schema: { querystring: periodSchema }, - }, async (request) => { - const context = await auth.require(request, 'staff:metric:read'); - const period = parsePeriod(request.query); - return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - const entries = await client.query( - `SELECT d.id, d.org_unit_id, u.code AS org_unit_code, u.name AS org_unit_name, + api.get<{ Querystring: PeriodQuery }>( + '/finance', + { + schema: { querystring: periodSchema }, + }, + async (request) => { + const context = await auth.require(request, 'staff:metric:read'); + const period = parsePeriod(request.query); + return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const entries = await client.query( + `SELECT d.id, d.org_unit_id, u.code AS org_unit_code, u.name AS org_unit_name, d.business_date, d.metric_key, m.label, d.value::text AS value_minor, d.currency_code FROM staff_daily_metrics d @@ -171,10 +174,10 @@ export async function registerOperationsRoutes( AND (d.metric_key LIKE 'income\\_%' ESCAPE '\\' OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\') ORDER BY d.business_date, u.name, d.metric_key`, - [period.from, period.to], - ); - const daily = await client.query( - `SELECT d.business_date, d.org_unit_id, u.name AS org_unit_name, + [period.from, period.to], + ); + const daily = await client.query( + `SELECT d.business_date, d.org_unit_id, u.name AS org_unit_name, COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'income\\_%' ESCAPE '\\'), 0)::text AS income_minor, COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'expense\\_%' ESCAPE '\\'), 0)::text AS expense_minor FROM staff_daily_metrics d @@ -184,10 +187,10 @@ export async function registerOperationsRoutes( OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\') GROUP BY d.business_date, d.org_unit_id, u.name ORDER BY d.business_date, u.name`, - [period.from, period.to], - ); - const monthly = await client.query( - `SELECT to_char(date_trunc('month', d.business_date), 'YYYY-MM') AS month, + [period.from, period.to], + ); + const monthly = await client.query( + `SELECT to_char(date_trunc('month', d.business_date), 'YYYY-MM') AS month, d.org_unit_id, u.name AS org_unit_name, COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'income\\_%' ESCAPE '\\'), 0)::text AS income_minor, COALESCE(SUM(d.value) FILTER (WHERE d.metric_key LIKE 'expense\\_%' ESCAPE '\\'), 0)::text AS expense_minor @@ -198,24 +201,30 @@ export async function registerOperationsRoutes( OR d.metric_key LIKE 'expense\\_%' ESCAPE '\\') GROUP BY date_trunc('month', d.business_date), d.org_unit_id, u.name ORDER BY month, u.name`, - [period.from, period.to], - ); - return { - period, - categories: financialMetricDefinitions.map(([key, label, kind]) => ({ key, label, kind })), - entries: entries.rows, - daily: daily.rows, - monthly: monthly.rows, - }; - }); - }); + [period.from, period.to], + ); + return { + period, + categories: financialMetricDefinitions.map(([key, label, kind]) => ({ + key, + label, + kind, + })), + entries: entries.rows, + daily: daily.rows, + monthly: monthly.rows, + }; + }); + }, + ); api.put<{ Params: FinanceParams; Body: FinanceBody }>( '/finance/:orgUnitId/:businessDate/:metricKey', { schema: { params: { - type: 'object', additionalProperties: false, + type: 'object', + additionalProperties: false, required: ['orgUnitId', 'businessDate', 'metricKey'], properties: { orgUnitId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, @@ -224,7 +233,9 @@ export async function registerOperationsRoutes( }, }, body: { - type: 'object', additionalProperties: false, required: ['value_minor'], + type: 'object', + additionalProperties: false, + required: ['value_minor'], properties: { value_minor: { type: 'integer', @@ -244,15 +255,18 @@ export async function registerOperationsRoutes( if (!businessDate || !financialMetricKeys.has(request.params.metricKey)) { return reply.badRequest('Неизвестная дата или категория.'); } - const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - await ensureMetricDefinitions(client, context.tenantId); - const unit = await client.query( - 'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL', - [context.tenantId, orgUnitId], - ); - if (unit.rowCount === 0) return null; - const saved = await client.query( - `INSERT INTO staff_daily_metrics + const row = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + await ensureMetricDefinitions(client, context.tenantId); + const unit = await client.query( + 'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL', + [context.tenantId, orgUnitId], + ); + if (unit.rowCount === 0) return null; + const saved = await client.query( + `INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, currency_code, source, recorded_by) VALUES ($1, $2, $3, $4, $5, $6, $7, 'manual', $8) @@ -261,34 +275,56 @@ export async function registerOperationsRoutes( source = 'manual', recorded_by = EXCLUDED.recorded_by, updated_at = now() RETURNING id, org_unit_id, business_date, metric_key, value::text AS value_minor, currency_code`, - [newUlid(), context.tenantId, orgUnitId, request.params.businessDate, - request.params.metricKey, request.body.value_minor, - request.body.currency_code ?? 'RUB', context.actorId], - ); - await refreshRevenueMetric(client, context.tenantId, context.actorId, - orgUnitId, request.params.businessDate); - const savedRow = saved.rows[0] as Record | undefined; - await writeAudit(client, context, 'finance.metric.saved', 'DailyMetric', - String(savedRow?.id), savedRow ?? {}); - return savedRow; - }); + [ + newUlid(), + context.tenantId, + orgUnitId, + request.params.businessDate, + request.params.metricKey, + request.body.value_minor, + request.body.currency_code ?? 'RUB', + context.actorId, + ], + ); + await refreshRevenueMetric( + client, + context.tenantId, + context.actorId, + orgUnitId, + request.params.businessDate, + ); + const savedRow = saved.rows[0] as Record | undefined; + await writeAudit( + client, + context, + 'finance.metric.saved', + 'DailyMetric', + String(savedRow?.id), + savedRow ?? {}, + ); + return savedRow; + }, + ); if (!row) return reply.notFound('Торговая точка не найдена.'); return reply.send(row); }, ); - api.get<{ Querystring: PeriodQuery }>('/schedule', { - schema: { querystring: periodSchema }, - }, async (request, reply) => { - const context = await auth.resolve(request); - const canManage = context.permissions.has('staff:attendance:manage'); - if (!canManage && !context.permissions.has('staff:shift:read')) { - return reply.forbidden('Недостаточно прав для просмотра графика.'); - } - const period = parsePeriod(request.query); - return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - const rows = await client.query( - `SELECT w.id, w.employee_id, e.display_name, e.position, w.org_unit_id, + api.get<{ Querystring: PeriodQuery }>( + '/schedule', + { + schema: { querystring: periodSchema }, + }, + async (request, reply) => { + const context = await auth.resolve(request); + const canManage = context.permissions.has('staff:attendance:manage'); + if (!canManage && !context.permissions.has('staff:shift:read')) { + return reply.forbidden('Недостаточно прав для просмотра графика.'); + } + const period = parsePeriod(request.query); + return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const rows = await client.query( + `SELECT w.id, w.employee_id, e.display_name, e.position, w.org_unit_id, u.name AS org_unit_name, w.business_date, COALESCE((w.attributes->>'planned_minutes')::integer, 0) AS planned_minutes, w.attributes->>'start_time' AS start_time, @@ -307,18 +343,20 @@ export async function registerOperationsRoutes( AND w.business_date BETWEEN $1 AND $2 AND ($3::boolean OR e.core_user_id = $4) ORDER BY w.business_date, e.display_name`, - [period.from, period.to, canManage, context.actorId], - ); - return { period, shifts: rows.rows }; - }); - }); + [period.from, period.to, canManage, context.actorId], + ); + return { period, shifts: rows.rows }; + }); + }, + ); api.put<{ Params: ScheduleParams; Body: ScheduleBody }>( '/schedule/:employeeId/:businessDate', { schema: { params: { - type: 'object', additionalProperties: false, + type: 'object', + additionalProperties: false, required: ['employeeId', 'businessDate'], properties: { employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, @@ -326,7 +364,8 @@ export async function registerOperationsRoutes( }, }, body: { - type: 'object', additionalProperties: false, + type: 'object', + additionalProperties: false, required: ['org_unit_id', 'planned_minutes'], properties: { org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, @@ -345,35 +384,44 @@ export async function registerOperationsRoutes( const orgUnitId = parseUlid(request.body.org_unit_id); if (!parseDate(request.params.businessDate)) return reply.badRequest('Некорректная дата.'); const externalKey = `schedule:${employeeId}:${request.params.businessDate}`; - const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - if (request.body.planned_minutes === 0) { - const removed = await client.query( - `DELETE FROM staff_work_items + const result = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + if (request.body.planned_minutes === 0) { + const removed = await client.query( + `DELETE FROM staff_work_items WHERE tenant_id = $1 AND external_key = $2 RETURNING id`, - [context.tenantId, externalKey], - ); - const removedRow = removed.rows[0] as { id?: string } | undefined; - await writeAudit(client, context, 'schedule.shift.removed', 'WorkItem', - String(removedRow?.id ?? employeeId), { business_date: request.params.businessDate }); - return { removed: (removed.rowCount ?? 0) > 0 }; - } - const exists = await client.query( - `SELECT e.id + [context.tenantId, externalKey], + ); + const removedRow = removed.rows[0] as { id?: string } | undefined; + await writeAudit( + client, + context, + 'schedule.shift.removed', + 'WorkItem', + String(removedRow?.id ?? employeeId), + { business_date: request.params.businessDate }, + ); + return { removed: (removed.rowCount ?? 0) > 0 }; + } + const exists = await client.query( + `SELECT e.id FROM staff_employees e JOIN staff_org_units u ON u.tenant_id = e.tenant_id WHERE e.tenant_id = $1 AND e.id = $2 AND u.id = $3 AND e.status = 'active' AND u.deleted_at IS NULL`, - [context.tenantId, employeeId, orgUnitId], - ); - if (exists.rowCount === 0) return null; - const attributes = { - planned_minutes: request.body.planned_minutes, - start_time: request.body.start_time ?? null, - end_time: request.body.end_time ?? null, - note: request.body.note?.trim() ?? '', - }; - const saved = await client.query( - `INSERT INTO staff_work_items + [context.tenantId, employeeId, orgUnitId], + ); + if (exists.rowCount === 0) return null; + const attributes = { + planned_minutes: request.body.planned_minutes, + start_time: request.body.start_time ?? null, + end_time: request.body.end_time ?? null, + note: request.body.note?.trim() ?? '', + }; + const saved = await client.query( + `INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, attributes, source, external_key, created_by) VALUES ($1, $2, $3, $4, $5, 'planned_shift', $6, 'manual', $7, $8) @@ -381,31 +429,49 @@ export async function registerOperationsRoutes( DO UPDATE SET org_unit_id = EXCLUDED.org_unit_id, attributes = EXCLUDED.attributes, source = 'manual' RETURNING id, employee_id, org_unit_id, business_date, attributes`, - [newUlid(), context.tenantId, employeeId, orgUnitId, - request.params.businessDate, attributes, externalKey, context.actorId], - ); - const savedRow = saved.rows[0] as Record | undefined; - await writeAudit(client, context, 'schedule.shift.saved', 'WorkItem', - String(savedRow?.id), savedRow ?? {}); - return savedRow; - }); + [ + newUlid(), + context.tenantId, + employeeId, + orgUnitId, + request.params.businessDate, + attributes, + externalKey, + context.actorId, + ], + ); + const savedRow = saved.rows[0] as Record | undefined; + await writeAudit( + client, + context, + 'schedule.shift.saved', + 'WorkItem', + String(savedRow?.id), + savedRow ?? {}, + ); + return savedRow; + }, + ); if (!result) return reply.notFound('Сотрудник или торговая точка не найдены.'); return reply.send(result); }, ); - api.get<{ Querystring: PeriodQuery }>('/payroll', { - schema: { querystring: periodSchema }, - }, async (request, reply) => { - const context = await auth.resolve(request); - const canManage = context.permissions.has('staff:payroll:read'); - if (!canManage && !context.permissions.has('staff:payslip:read_own')) { - return reply.forbidden('Недостаточно прав для просмотра зарплаты.'); - } - const period = parsePeriod(request.query); - return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - const totals = await client.query( - `WITH ledger AS ( + api.get<{ Querystring: PeriodQuery }>( + '/payroll', + { + schema: { querystring: periodSchema }, + }, + async (request, reply) => { + const context = await auth.resolve(request); + const canManage = context.permissions.has('staff:payroll:read'); + if (!canManage && !context.permissions.has('staff:payslip:read_own')) { + return reply.forbidden('Недостаточно прав для просмотра зарплаты.'); + } + const period = parsePeriod(request.query); + return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const totals = await client.query( + `WITH ledger AS ( SELECT employee_id, COALESCE(SUM(amount_minor) FILTER (WHERE kind IN ('accrual', 'adjustment')), 0) AS accrued, COALESCE(SUM(-amount_minor) FILTER (WHERE kind = 'payout'), 0) AS paid, @@ -429,21 +495,22 @@ export async function registerOperationsRoutes( LEFT JOIN worked w ON w.employee_id = e.id WHERE e.status <> 'dismissed' AND ($3::boolean OR e.core_user_id = $4 OR e.id = $4) ORDER BY e.display_name`, - [period.from, period.to, canManage, context.actorId], - ); - const entries = await client.query( - `SELECT l.id, l.employee_id, e.display_name, l.business_date, l.kind, + [period.from, period.to, canManage, context.actorId], + ); + const entries = await client.query( + `SELECT l.id, l.employee_id, e.display_name, l.business_date, l.kind, l.amount_minor::text AS amount_minor, l.currency_code, l.note, l.created_at FROM staff_payroll_ledger l JOIN staff_employees e ON e.tenant_id = l.tenant_id AND e.id = l.employee_id WHERE l.business_date BETWEEN $1 AND $2 AND ($3::boolean OR e.core_user_id = $4 OR e.id = $4) ORDER BY l.business_date DESC, e.display_name`, - [period.from, period.to, canManage, context.actorId], - ); - return { period, totals: totals.rows, entries: entries.rows }; - }); - }); + [period.from, period.to, canManage, context.actorId], + ); + return { period, totals: totals.rows, entries: entries.rows }; + }); + }, + ); api.get<{ Params: { employeeId: string } }>( '/employees/:employeeId/pay-assignment', @@ -474,7 +541,8 @@ export async function registerOperationsRoutes( `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('Сотрудник не найден.'); + 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('Доступ запрещён.'); @@ -503,7 +571,9 @@ export async function registerOperationsRoutes( type: 'object', required: ['pay_rule_id'], properties: { - org_unit_id: { anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }] }, + 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 }, @@ -521,20 +591,25 @@ export async function registerOperationsRoutes( 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, - ); - }); + 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 : 'Ошибка сохранения зарплатного профиля'; + const message = + err instanceof Error ? err.message : 'Ошибка сохранения зарплатного профиля'; if (message.includes('not found') || message.includes('Employee not found')) { return reply.notFound(message); } @@ -595,7 +670,11 @@ export async function registerOperationsRoutes( 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) { + if ( + !parseDate(request.body.from) || + !parseDate(request.body.to) || + request.body.from > request.body.to + ) { return reply.badRequest('Некорректный период расчёта.'); } from = request.body.from; @@ -605,13 +684,17 @@ export async function registerOperationsRoutes( } 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 } : {}), - }); - }); + 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) { @@ -642,9 +725,13 @@ export async function registerOperationsRoutes( const runId = parseUlid(request.params.runId); try { - const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - return approvePayrollRun(client, context, runId); - }); + 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) { @@ -686,93 +773,160 @@ export async function registerOperationsRoutes( }, ); - api.post<{ Body: OwnerWorkbookImportBody }>('/imports/owner-workbook', { - schema: { - body: { - type: 'object', additionalProperties: false, required: ['source_name'], - properties: { - source_name: { type: 'string', minLength: 1, maxLength: 240 }, - units: { type: 'array', maxItems: 50, items: { type: 'object' } }, - financial_entries: { type: 'array', maxItems: 500, items: { type: 'object' } }, - employees: { type: 'array', maxItems: 250, items: { type: 'object' } }, - schedule_items: { type: 'array', maxItems: 500, items: { type: 'object' } }, - payroll_entries: { type: 'array', maxItems: 500, items: { type: 'object' } }, + api.post<{ Body: OwnerWorkbookImportBody }>( + '/imports/owner-workbook', + { + schema: { + body: { + type: 'object', + additionalProperties: false, + required: ['source_name'], + properties: { + source_name: { type: 'string', minLength: 1, maxLength: 240 }, + units: { type: 'array', maxItems: 50, items: { type: 'object' } }, + financial_entries: { type: 'array', maxItems: 500, items: { type: 'object' } }, + employees: { type: 'array', maxItems: 250, items: { type: 'object' } }, + schedule_items: { type: 'array', maxItems: 500, items: { type: 'object' } }, + payroll_entries: { type: 'array', maxItems: 500, items: { type: 'object' } }, + }, }, }, }, - }, async (request, reply) => { - auth.assertMutationOrigin(request); - const context = await auth.require(request, 'staff:metric:write'); - const body = request.body; - if (!validateImport(body)) return reply.badRequest('Пакет импорта содержит некорректные данные.'); - const counts = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - await ensureMetricDefinitions(client, context.tenantId); - const unitIds = new Map(); - for (const unit of body.units ?? []) { - const saved = await client.query( - `INSERT INTO staff_org_units (id, tenant_id, code, name, external_ref) + async (request, reply) => { + auth.assertMutationOrigin(request); + const body = request.body; + if (!validateImport(body)) + return reply.badRequest('Пакет импорта содержит некорректные данные.'); + const requiredPermissions = new Set(); + if ((body.units?.length ?? 0) > 0) requiredPermissions.add('staff:org_unit:write'); + if ((body.employees?.length ?? 0) > 0) requiredPermissions.add('staff:employee:write'); + if ((body.financial_entries?.length ?? 0) > 0) requiredPermissions.add('staff:metric:write'); + if ((body.schedule_items?.length ?? 0) > 0) requiredPermissions.add('staff:shift:confirm'); + if ((body.payroll_entries?.length ?? 0) > 0) requiredPermissions.add('staff:payout:write'); + if (requiredPermissions.size === 0) { + return reply.badRequest('Пакет импорта не содержит данных.'); + } + let context = await auth.resolve(request); + for (const permission of requiredPermissions) { + context = await auth.require(request, permission); + } + const counts = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + await ensureMetricDefinitions(client, context.tenantId); + const unitIds = new Map(); + for (const unit of body.units ?? []) { + const saved = await client.query( + `INSERT INTO staff_org_units (id, tenant_id, code, name, external_ref) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO UPDATE SET name = EXCLUDED.name, external_ref = COALESCE(staff_org_units.external_ref, EXCLUDED.external_ref), updated_at = now() RETURNING id`, - [newUlid(), context.tenantId, unit.code, unit.name, unit.external_ref ?? null], - ); - const savedRow = saved.rows[0] as { id?: string } | undefined; - unitIds.set(unit.code, String(savedRow?.id)); - } - for (const code of new Set([ - ...(body.financial_entries ?? []).map((entry) => entry.org_unit_code), - ...(body.schedule_items ?? []).map((entry) => entry.org_unit_code), - ])) { - if (unitIds.has(code)) continue; - const row = await client.query( - 'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND code = $2 AND deleted_at IS NULL', - [context.tenantId, code], - ); - if (row.rowCount === 0) throw new Error(`Unknown organization unit: ${code}`); - const found = row.rows[0] as { id?: string } | undefined; - unitIds.set(code, String(found?.id)); - } + [newUlid(), context.tenantId, unit.code, unit.name, unit.external_ref ?? null], + ); + const savedRow = saved.rows[0] as { id?: string } | undefined; + unitIds.set(unit.code, String(savedRow?.id)); + } + for (const code of new Set([ + ...(body.financial_entries ?? []).map((entry) => entry.org_unit_code), + ...(body.schedule_items ?? []).map((entry) => entry.org_unit_code), + ])) { + if (unitIds.has(code)) continue; + const row = await client.query( + 'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND code = $2 AND deleted_at IS NULL', + [context.tenantId, code], + ); + if (row.rowCount === 0) throw new Error(`Unknown organization unit: ${code}`); + const found = row.rows[0] as { id?: string } | undefined; + unitIds.set(code, String(found?.id)); + } - const employeeIds = new Map(); - for (const employee of body.employees ?? []) { - const saved = await client.query( - `INSERT INTO staff_employees - (id, tenant_id, personnel_number, display_name, position, status, external_ref) - VALUES ($1, $2, $3, $4, $5, 'active', $6) - ON CONFLICT (tenant_id, personnel_number) - DO UPDATE SET external_ref = COALESCE(staff_employees.external_ref, EXCLUDED.external_ref), - position = CASE WHEN staff_employees.position = '' THEN EXCLUDED.position - ELSE staff_employees.position END, - updated_at = now() - RETURNING id`, - [newUlid(), context.tenantId, employee.personnel_number, - employee.display_name, employee.position?.trim() ?? '', employee.external_ref], - ); - const savedRow = saved.rows[0] as { id?: string } | undefined; - employeeIds.set(employee.external_ref, String(savedRow?.id)); - } - for (const externalRef of new Set([ - ...(body.schedule_items ?? []).map((entry) => entry.employee_external_ref), - ...(body.payroll_entries ?? []).map((entry) => entry.employee_external_ref), - ])) { - if (employeeIds.has(externalRef)) continue; - const row = await client.query( - 'SELECT id FROM staff_employees WHERE tenant_id = $1 AND external_ref = $2', - [context.tenantId, externalRef], - ); - if (row.rowCount === 0) throw new Error(`Unknown employee: ${externalRef}`); - const found = row.rows[0] as { id?: string } | undefined; - employeeIds.set(externalRef, String(found?.id)); - } + const employeeIds = new Map(); + for (const employee of body.employees ?? []) { + let professionId: string | null = null; + const professionName = employee.position?.trim(); + if (professionName) { + const profession = await client.query( + `INSERT INTO staff_professions (id, tenant_id, code, name) + VALUES ($1, $2, $3, $4) + ON CONFLICT (tenant_id, name) DO UPDATE SET updated_at = now() + RETURNING id`, + [newUlid(), context.tenantId, `import-${newUlid().toLowerCase()}`, professionName], + ); + professionId = + String((profession.rows[0] as { id?: string } | undefined)?.id ?? '') || null; + } + const existing = await client.query<{ id: string }>( + `SELECT id FROM staff_employees + WHERE tenant_id = $1 AND ( + external_ref = $2 OR + ($2 LIKE 'xlsx:staff:%' AND external_ref LIKE 'xlsx:staff:%' + AND lower(display_name) = lower($3)) + ) + ORDER BY (external_ref = $2) DESC LIMIT 1 FOR UPDATE`, + [context.tenantId, employee.external_ref, employee.display_name], + ); + const saved = existing.rows[0] + ? await client.query( + `UPDATE staff_employees SET + display_name = $3, position = $4, profession_id = $5, + external_ref = $6, updated_at = now() + WHERE tenant_id = $1 AND id = $2 RETURNING id`, + [ + context.tenantId, + existing.rows[0].id, + employee.display_name, + professionName ?? '', + professionId, + employee.external_ref, + ], + ) + : await client.query( + `INSERT INTO staff_employees + (id, tenant_id, personnel_number, display_name, position, profession_id, status, external_ref) + VALUES ($1, $2, $3, $4, $5, $6, 'active', $7) + ON CONFLICT (tenant_id, personnel_number) + DO UPDATE SET external_ref = EXCLUDED.external_ref, + display_name = EXCLUDED.display_name, + position = EXCLUDED.position, + profession_id = EXCLUDED.profession_id, + updated_at = now() + RETURNING id`, + [ + newUlid(), + context.tenantId, + employee.personnel_number, + employee.display_name, + professionName ?? '', + professionId, + employee.external_ref, + ], + ); + const savedRow = saved.rows[0] as { id?: string } | undefined; + employeeIds.set(employee.external_ref, String(savedRow?.id)); + } + for (const externalRef of new Set([ + ...(body.schedule_items ?? []).map((entry) => entry.employee_external_ref), + ...(body.payroll_entries ?? []).map((entry) => entry.employee_external_ref), + ])) { + if (employeeIds.has(externalRef)) continue; + const row = await client.query( + 'SELECT id FROM staff_employees WHERE tenant_id = $1 AND external_ref = $2', + [context.tenantId, externalRef], + ); + if (row.rowCount === 0) throw new Error(`Unknown employee: ${externalRef}`); + const found = row.rows[0] as { id?: string } | undefined; + employeeIds.set(externalRef, String(found?.id)); + } - const affectedRevenueDays = new Set(); - for (const entry of body.financial_entries ?? []) { - const orgUnitId = unitIds.get(entry.org_unit_code)!; - await client.query( - `INSERT INTO staff_daily_metrics + const affectedRevenueDays = new Set(); + for (const entry of body.financial_entries ?? []) { + const orgUnitId = unitIds.get(entry.org_unit_code)!; + await client.query( + `INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, currency_code, breakdown, source, recorded_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'import', $9) @@ -780,71 +934,139 @@ export async function registerOperationsRoutes( DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code, breakdown = EXCLUDED.breakdown, source = 'import', recorded_by = EXCLUDED.recorded_by, updated_at = now()`, - [newUlid(), context.tenantId, orgUnitId, entry.business_date, entry.metric_key, - entry.value_minor, entry.currency_code ?? 'RUB', - { source_name: body.source_name }, context.actorId], - ); - if (entry.metric_key.startsWith('income_')) { - affectedRevenueDays.add(`${orgUnitId}|${entry.business_date}`); - } - } - for (const key of affectedRevenueDays) { - const [orgUnitId, businessDate] = key.split('|') as [string, string]; - await refreshRevenueMetric(client, context.tenantId, context.actorId, - orgUnitId, businessDate); - } + [ + newUlid(), + context.tenantId, + orgUnitId, + entry.business_date, + entry.metric_key, + entry.value_minor, + entry.currency_code ?? 'RUB', + { source_name: body.source_name }, + context.actorId, + ], + ); + if (entry.metric_key.startsWith('income_')) { + affectedRevenueDays.add(`${orgUnitId}|${entry.business_date}`); + } + } + for (const key of affectedRevenueDays) { + const [orgUnitId, businessDate] = key.split('|') as [string, string]; + await refreshRevenueMetric( + client, + context.tenantId, + context.actorId, + orgUnitId, + businessDate, + ); + } - for (const shift of body.schedule_items ?? []) { - const attributes = { - planned_minutes: shift.planned_minutes, - location_code: shift.location_code ?? '', - imported_from: body.source_name, - }; - await client.query( - `INSERT INTO staff_work_items - (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, - attributes, source, external_key, created_by) + for (const shift of body.schedule_items ?? []) { + const attributes = { + planned_minutes: shift.planned_minutes, + location_code: shift.location_code ?? '', + imported_from: body.source_name, + }; + const employeeId = employeeIds.get(shift.employee_external_ref); + const existingShift = await client.query<{ id: string }>( + `SELECT id FROM staff_work_items + WHERE tenant_id = $1 AND employee_id = $2 AND business_date = $3 + AND item_kind = 'planned_shift' AND source = 'import' + AND (external_key = $4 OR external_key LIKE 'xlsx:schedule:%') + ORDER BY (external_key = $4) DESC LIMIT 1 FOR UPDATE`, + [context.tenantId, employeeId, shift.business_date, shift.external_key], + ); + if (existingShift.rows[0]) { + await client.query( + `UPDATE staff_work_items SET org_unit_id = $3, attributes = $4, + external_key = $5 + WHERE tenant_id = $1 AND id = $2`, + [ + context.tenantId, + existingShift.rows[0].id, + unitIds.get(shift.org_unit_code), + attributes, + shift.external_key, + ], + ); + continue; + } + await client.query( + `INSERT INTO staff_work_items + (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, + attributes, source, external_key, created_by) VALUES ($1, $2, $3, $4, $5, 'planned_shift', $6, 'import', $7, $8) ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO UPDATE SET employee_id = EXCLUDED.employee_id, org_unit_id = EXCLUDED.org_unit_id, business_date = EXCLUDED.business_date, attributes = EXCLUDED.attributes, source = 'import'`, - [newUlid(), context.tenantId, employeeIds.get(shift.employee_external_ref), - unitIds.get(shift.org_unit_code), shift.business_date, attributes, - shift.external_key, context.actorId], - ); - } + [ + newUlid(), + context.tenantId, + employeeId, + unitIds.get(shift.org_unit_code), + shift.business_date, + attributes, + shift.external_key, + context.actorId, + ], + ); + } - for (const entry of body.payroll_entries ?? []) { - const amount = entry.kind === 'payout' - ? -Math.abs(entry.amount_minor) - : entry.amount_minor; - await client.query( - `INSERT INTO staff_payroll_ledger + for (const entry of body.payroll_entries ?? []) { + const desiredAmount = + entry.kind === 'payout' ? -Math.abs(entry.amount_minor) : entry.amount_minor; + const employeeId = employeeIds.get(entry.employee_external_ref); + const legacyPrefix = `xlsx:${entry.kind}:%`; + const currentPrefix = `xlsx:payroll:${entry.kind}:%`; + const existing = await client.query<{ amount_minor: string }>( + `SELECT COALESCE(sum(amount_minor), 0)::text AS amount_minor + FROM staff_payroll_ledger + WHERE tenant_id = $1 AND employee_id = $2 AND business_date = $3 + AND (external_key LIKE $4 OR external_key LIKE $5)`, + [context.tenantId, employeeId, entry.business_date, legacyPrefix, currentPrefix], + ); + const previousAmount = Number(existing.rows[0]?.amount_minor ?? 0); + const delta = desiredAmount - previousAmount; + if (delta === 0) continue; + await client.query( + `INSERT INTO staff_payroll_ledger (id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, correlation_id, note, created_by, external_key) VALUES ($1, $2, $3, $4, $5, $6, 'RUB', 'manual', $7, $8, $9, $10) ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING`, - [newUlid(), context.tenantId, employeeIds.get(entry.employee_external_ref), - entry.business_date, entry.kind, amount, context.correlationId, - entry.note?.trim() ?? `Импорт: ${body.source_name}`, - context.actorId, entry.external_key], - ); - } - const imported = { - units: body.units?.length ?? 0, - employees: body.employees?.length ?? 0, - financial_entries: body.financial_entries?.length ?? 0, - schedule_items: body.schedule_items?.length ?? 0, - payroll_entries: body.payroll_entries?.length ?? 0, - }; - await writeAudit(client, context, 'owner_workbook.imported', 'ImportBatch', - newUlid(), { source_name: body.source_name, ...imported }); - return imported; - }); - return reply.code(202).send(counts); - }); + [ + newUlid(), + context.tenantId, + employeeId, + entry.business_date, + previousAmount === 0 ? entry.kind : 'adjustment', + delta, + context.correlationId, + entry.note?.trim() ?? `Импорт: ${body.source_name}`, + context.actorId, + entry.external_key, + ], + ); + } + const imported = { + units: body.units?.length ?? 0, + employees: body.employees?.length ?? 0, + financial_entries: body.financial_entries?.length ?? 0, + schedule_items: body.schedule_items?.length ?? 0, + payroll_entries: body.payroll_entries?.length ?? 0, + }; + await writeAudit(client, context, 'owner_workbook.imported', 'ImportBatch', newUlid(), { + source_name: body.source_name, + ...imported, + }); + return imported; + }, + ); + return reply.code(202).send(counts); + }, + ); } const periodSchema = { @@ -930,8 +1152,16 @@ async function writeAudit( `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - [newUlid(), context.tenantId, context.actorId, action, entityType, - entityId, values, context.correlationId], + [ + newUlid(), + context.tenantId, + context.actorId, + action, + entityType, + entityId, + values, + context.correlationId, + ], ); } @@ -941,24 +1171,40 @@ function validateImport(body: OwnerWorkbookImportBody): boolean { if (!/^[a-z0-9_-]{1,40}$/.test(unit.code) || !unit.name.trim()) return false; } for (const entry of body.financial_entries ?? []) { - if (!financialMetricKeys.has(entry.metric_key) - || !parseDate(entry.business_date) - || !Number.isSafeInteger(entry.value_minor) - || Math.abs(entry.value_minor) > 9_000_000_000_000_000) return false; + if ( + !financialMetricKeys.has(entry.metric_key) || + !parseDate(entry.business_date) || + !Number.isSafeInteger(entry.value_minor) || + Math.abs(entry.value_minor) > 9_000_000_000_000_000 + ) + return false; } for (const employee of body.employees ?? []) { - if (!employee.external_ref || !employee.personnel_number.trim() || !employee.display_name.trim()) { + if ( + !employee.external_ref || + !employee.personnel_number.trim() || + !employee.display_name.trim() + ) { return false; } } for (const shift of body.schedule_items ?? []) { - if (!shift.external_key || !parseDate(shift.business_date) - || !Number.isInteger(shift.planned_minutes) - || shift.planned_minutes < 0 || shift.planned_minutes > 1440) return false; + if ( + !shift.external_key || + !parseDate(shift.business_date) || + !Number.isInteger(shift.planned_minutes) || + shift.planned_minutes < 0 || + shift.planned_minutes > 1440 + ) + return false; } for (const entry of body.payroll_entries ?? []) { - if (!entry.external_key || !parseDate(entry.business_date) - || !Number.isSafeInteger(entry.amount_minor)) return false; + if ( + !entry.external_key || + !parseDate(entry.business_date) || + !Number.isSafeInteger(entry.amount_minor) + ) + return false; } return true; } diff --git a/src/portal.ts b/src/portal.ts index 52d66aa..5335036 100644 --- a/src/portal.ts +++ b/src/portal.ts @@ -31,6 +31,7 @@ interface EmployeeBody { personnel_number: string; display_name: string; position?: string; + profession_id?: string; gender?: 'male' | 'female' | ''; } @@ -38,10 +39,16 @@ interface EmployeePatchBody { personnel_number?: string; display_name?: string; position?: string; + profession_id?: string | null; gender?: 'male' | 'female' | ''; status?: 'active' | 'blocked' | 'dismissed'; } +interface ProfessionBody { + name: string; + status?: 'active' | 'archived'; +} + interface ReconcileSessionsBody { date_from: string; date_to: string; @@ -70,42 +77,51 @@ export async function registerPortalRoutes( config: AppConfig, database: Database, ) { - const baseIdentity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform' - ? new PlatformIdentityAdapter() - : new StandaloneIdentityAdapter(config); + const baseIdentity: IdentityPort = + config.STAFF_RUNTIME_MODE === 'platform' + ? new PlatformIdentityAdapter() + : new StandaloneIdentityAdapter(config); const identity = withStandaloneEmployeeAccess(baseIdentity, config, database); - api.post<{ Body: SessionBody }>('/session', { - schema: { - body: { - type: 'object', additionalProperties: false, - required: ['access_token'], - properties: { access_token: { type: 'string', minLength: 32, maxLength: 4096 } }, + api.post<{ Body: SessionBody }>( + '/session', + { + schema: { + body: { + type: 'object', + additionalProperties: false, + required: ['access_token'], + properties: { access_token: { type: 'string', minLength: 32, maxLength: 4096 } }, + }, }, }, - }, async (request, reply) => { - assertSameOrigin(request); - if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) { - return reply.serviceUnavailable('Вход администратора ещё не настроен.'); - } - const context = await resolveToken(identity, request.body.access_token, request); - const role = context.permissions.has('staff:attendance:manage') ? 'manager' - : context.permissions.has('staff:attendance:read_own') ? 'employee' - : null; - if (!role) { - return reply.forbidden('Ключ не подходит для входа в кабинет.'); - } - reply - .header('cache-control', 'no-store') - .header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds)); - return { role, expires_in: sessionMaxAgeSeconds }; - }); + async (request, reply) => { + assertSameOrigin(request); + if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) { + return reply.serviceUnavailable('Вход администратора ещё не настроен.'); + } + const context = await resolveToken(identity, request.body.access_token, request); + const role = context.permissions.has('staff:attendance:manage') + ? 'manager' + : context.permissions.has('staff:attendance:read_own') + ? 'employee' + : null; + if (!role) { + return reply.forbidden('Ключ не подходит для входа в кабинет.'); + } + reply + .header('cache-control', 'no-store') + .header( + 'set-cookie', + serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds), + ); + return { role, expires_in: sessionMaxAgeSeconds }; + }, + ); api.delete('/session', async (request, reply) => { assertSameOrigin(request); - reply - .header('cache-control', 'no-store') - .header('set-cookie', serializeSessionCookie('', 0)); + reply.header('cache-control', 'no-store').header('set-cookie', serializeSessionCookie('', 0)); return reply.code(204).send(); }); @@ -119,8 +135,10 @@ export async function registerPortalRoutes( return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const employees = await client.query( - `SELECT e.id, e.core_user_id, e.personnel_number, e.display_name, e.position, e.gender, e.status + `SELECT e.id, e.core_user_id, e.personnel_number, e.display_name, e.position, + e.profession_id, p.name AS profession_name, e.gender, e.status FROM staff_employees e + LEFT JOIN staff_professions p ON p.tenant_id = e.tenant_id AND p.id = e.profession_id WHERE e.status <> 'dismissed' AND ($1::boolean OR e.core_user_id = $2) ORDER BY e.display_name`, [canManage, context.actorId], @@ -167,274 +185,537 @@ export async function registerPortalRoutes( }); }); - api.post<{ Body: EmployeeBody }>('/employees', { - schema: { - body: { - type: 'object', additionalProperties: false, - required: ['personnel_number', 'display_name'], - properties: { - personnel_number: { type: 'string', minLength: 1, maxLength: 80 }, - display_name: { type: 'string', minLength: 1, maxLength: 240 }, - position: { type: 'string', maxLength: 160 }, - gender: { enum: ['', 'male', 'female'] }, - }, - }, - }, - }, async (request, reply) => { - assertCookieMutationOrigin(request); - const context = await requirePermission(identity, request, 'staff:employee:write'); - const employeeId = newUlid(); - const personnelNumber = request.body.personnel_number.trim(); - const displayName = request.body.display_name.trim(); - if (!personnelNumber || !displayName) return reply.badRequest('Табельный номер и имя обязательны.'); - - const employee = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - const saved = await client.query( - `INSERT INTO staff_employees - (id, tenant_id, personnel_number, display_name, position, gender, status) - VALUES ($1, $2, $3, $4, $5, $6, 'active') - ON CONFLICT (tenant_id, personnel_number) DO NOTHING - RETURNING id, core_user_id, personnel_number, display_name, position, gender, status`, - [employeeId, context.tenantId, personnelNumber, displayName, - request.body.position?.trim() ?? '', request.body.gender ?? ''], + api.get('/professions', async (request) => { + const context = await resolveContext(identity, request); + return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { + const result = await client.query( + `SELECT id, code, name, status, created_at, updated_at + FROM staff_professions ORDER BY status, name`, ); - if (saved.rowCount === 0) return null; - const result = saved.rows[0] as Record; - await client.query( - `INSERT INTO staff_audit_log - (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) - VALUES ($1, $2, $3, 'employee.created', 'Employee', $4, $5, $6)`, - [newUlid(), context.tenantId, context.actorId, employeeId, result, context.correlationId], - ); - return result; + return { professions: result.rows }; }); - if (!employee) return reply.conflict('Сотрудник с таким табельным номером уже существует.'); - return reply.code(201).send(employee); }); - api.patch<{ Params: { employeeId: string }; Body: EmployeePatchBody }>('/employees/:employeeId', { - schema: { - params: { type: 'object', required: ['employeeId'], properties: { - employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, - } }, - body: { - type: 'object', additionalProperties: false, minProperties: 1, - properties: { - personnel_number: { type: 'string', minLength: 1, maxLength: 80 }, - display_name: { type: 'string', minLength: 1, maxLength: 240 }, - position: { type: 'string', maxLength: 160 }, - gender: { enum: ['', 'male', 'female'] }, - status: { enum: ['active', 'blocked', 'dismissed'] }, + api.post<{ Body: ProfessionBody }>( + '/professions', + { + schema: { + body: { + type: 'object', + additionalProperties: false, + required: ['name'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 160 }, + }, }, }, }, - }, async (request, reply) => { - assertCookieMutationOrigin(request); - const context = await requirePermission(identity, request, 'staff:employee:write'); - const employeeId = parseUlid(request.params.employeeId); - const personnelNumber = request.body.personnel_number?.trim(); - const displayName = request.body.display_name?.trim(); - if (personnelNumber === '' || displayName === '') return reply.badRequest('Табельный номер и имя не могут быть пустыми.'); - - const outcome = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - const current = await client.query( - `SELECT id, core_user_id, personnel_number, display_name, position, gender, status - FROM staff_employees WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, - [context.tenantId, employeeId], + async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:employee:write'); + const name = request.body.name.trim(); + if (!name) return reply.badRequest('Название профессии обязательно.'); + const id = newUlid(); + const saved = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + const result = await client.query( + `INSERT INTO staff_professions (id, tenant_id, code, name) + VALUES ($1, $2, $3, $4) + ON CONFLICT (tenant_id, name) DO NOTHING + RETURNING id, code, name, status`, + [id, context.tenantId, `profession-${id.toLowerCase()}`, name], + ); + return result.rows[0] as Record | undefined; + }, ); - if (current.rowCount === 0) return { kind: 'missing' as const }; - if (personnelNumber) { - const duplicate = await client.query( - `SELECT id FROM staff_employees + if (!saved) return reply.conflict('Такая профессия уже существует.'); + return reply.code(201).send(saved); + }, + ); + + api.patch<{ Params: { professionId: string }; Body: ProfessionBody }>( + '/professions/:professionId', + { + schema: { + params: { + type: 'object', + required: ['professionId'], + properties: { + professionId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + }, + }, + body: { + type: 'object', + additionalProperties: false, + minProperties: 1, + properties: { + name: { type: 'string', minLength: 1, maxLength: 160 }, + status: { enum: ['active', 'archived'] }, + }, + }, + }, + }, + async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:employee:write'); + const professionId = parseUlid(request.params.professionId); + const name = request.body.name?.trim(); + const saved = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + const result = await client.query( + `UPDATE staff_professions SET name = COALESCE($3, name), + status = COALESCE($4, status), updated_at = now() + WHERE tenant_id = $1 AND id = $2 + RETURNING id, code, name, status`, + [context.tenantId, professionId, name, request.body.status], + ); + return result.rows[0] as Record | undefined; + }, + ); + if (!saved) return reply.notFound('Профессия не найдена.'); + return reply.send(saved); + }, + ); + + api.post<{ Body: EmployeeBody }>( + '/employees', + { + schema: { + body: { + type: 'object', + additionalProperties: false, + required: ['personnel_number', 'display_name'], + properties: { + personnel_number: { type: 'string', minLength: 1, maxLength: 80 }, + display_name: { type: 'string', minLength: 1, maxLength: 240 }, + position: { type: 'string', maxLength: 160 }, + profession_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + gender: { enum: ['', 'male', 'female'] }, + }, + }, + }, + }, + async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:employee:write'); + const employeeId = newUlid(); + const personnelNumber = request.body.personnel_number.trim(); + const displayName = request.body.display_name.trim(); + if (!personnelNumber || !displayName) + return reply.badRequest('Табельный номер и имя обязательны.'); + + const employee = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + const saved = await client.query( + `INSERT INTO staff_employees + (id, tenant_id, personnel_number, display_name, position, profession_id, gender, status) + VALUES ($1, $2, $3, $4, $5, + COALESCE($6, (SELECT id FROM staff_professions + WHERE tenant_id = $2 AND name = $5 AND status = 'active' LIMIT 1)), + $7, 'active') + ON CONFLICT (tenant_id, personnel_number) DO NOTHING + RETURNING id, core_user_id, personnel_number, display_name, position, profession_id, gender, status`, + [ + employeeId, + context.tenantId, + personnelNumber, + displayName, + request.body.position?.trim() ?? '', + request.body.profession_id ?? null, + request.body.gender ?? '', + ], + ); + if (saved.rowCount === 0) return null; + const result = saved.rows[0] as Record; + await client.query( + `INSERT INTO staff_audit_log + (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) + VALUES ($1, $2, $3, 'employee.created', 'Employee', $4, $5, $6)`, + [ + newUlid(), + context.tenantId, + context.actorId, + employeeId, + result, + context.correlationId, + ], + ); + return result; + }, + ); + if (!employee) return reply.conflict('Сотрудник с таким табельным номером уже существует.'); + return reply.code(201).send(employee); + }, + ); + + api.patch<{ Params: { employeeId: string }; Body: EmployeePatchBody }>( + '/employees/:employeeId', + { + schema: { + params: { + type: 'object', + required: ['employeeId'], + properties: { + employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + }, + }, + body: { + type: 'object', + additionalProperties: false, + minProperties: 1, + properties: { + personnel_number: { type: 'string', minLength: 1, maxLength: 80 }, + display_name: { type: 'string', minLength: 1, maxLength: 240 }, + position: { type: 'string', maxLength: 160 }, + profession_id: { + anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }], + }, + gender: { enum: ['', 'male', 'female'] }, + status: { enum: ['active', 'blocked', 'dismissed'] }, + }, + }, + }, + }, + async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:employee:write'); + const employeeId = parseUlid(request.params.employeeId); + const personnelNumber = request.body.personnel_number?.trim(); + const displayName = request.body.display_name?.trim(); + const hasPosition = Object.prototype.hasOwnProperty.call(request.body, 'position'); + const hasProfession = Object.prototype.hasOwnProperty.call(request.body, 'profession_id'); + if (personnelNumber === '' || displayName === '') + return reply.badRequest('Табельный номер и имя не могут быть пустыми.'); + + const outcome = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + const current = await client.query( + `SELECT id, core_user_id, personnel_number, display_name, position, profession_id, gender, status + FROM staff_employees WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, + [context.tenantId, employeeId], + ); + if (current.rowCount === 0) return { kind: 'missing' as const }; + if (personnelNumber) { + const duplicate = await client.query( + `SELECT id FROM staff_employees WHERE tenant_id = $1 AND personnel_number = $2 AND id <> $3 LIMIT 1`, - [context.tenantId, personnelNumber, employeeId], - ); - if ((duplicate.rowCount ?? 0) > 0) return { kind: 'conflict' as const }; - } - const saved = await client.query( - `UPDATE staff_employees SET + [context.tenantId, personnelNumber, employeeId], + ); + if ((duplicate.rowCount ?? 0) > 0) return { kind: 'conflict' as const }; + } + let nextPosition = request.body.position?.trim(); + let nextProfessionId = request.body.profession_id; + let professionTouched = hasProfession || hasPosition; + if (hasProfession && nextProfessionId) { + const profession = await client.query<{ id: string; name: string }>( + `SELECT id, name FROM staff_professions + WHERE tenant_id = $1 AND id = $2 AND status = 'active'`, + [context.tenantId, nextProfessionId], + ); + if (!profession.rows[0]) return { kind: 'invalid_profession' as const }; + nextProfessionId = profession.rows[0].id; + nextPosition = profession.rows[0].name; + } else if (hasProfession && nextProfessionId === null) { + nextPosition ??= ''; + } else if (hasPosition) { + const profession = await client.query<{ id: string }>( + `SELECT id FROM staff_professions + WHERE tenant_id = $1 AND name = $2 AND status = 'active' LIMIT 1`, + [context.tenantId, nextPosition ?? ''], + ); + nextProfessionId = profession.rows[0]?.id ?? null; + } else { + professionTouched = false; + } + const saved = await client.query( + `UPDATE staff_employees SET personnel_number = COALESCE($3, personnel_number), display_name = COALESCE($4, display_name), - position = COALESCE($5, position), - gender = COALESCE($6, gender), - status = COALESCE($7, status), + position = CASE WHEN $5::boolean THEN $6 ELSE position END, + profession_id = CASE WHEN $7::boolean THEN $8::varchar(26) ELSE profession_id END, + gender = COALESCE($9, gender), + status = COALESCE($10, status), updated_at = now() WHERE tenant_id = $1 AND id = $2 - RETURNING id, core_user_id, personnel_number, display_name, position, gender, status`, - [context.tenantId, employeeId, personnelNumber, displayName, - request.body.position?.trim(), request.body.gender, request.body.status], - ); - const result = saved.rows[0] as Record; - await client.query( - `INSERT INTO staff_audit_log + RETURNING id, core_user_id, personnel_number, display_name, position, profession_id, gender, status`, + [ + context.tenantId, + employeeId, + personnelNumber, + displayName, + hasPosition || hasProfession, + nextPosition, + professionTouched, + nextProfessionId, + request.body.gender, + request.body.status, + ], + ); + const result = saved.rows[0] as Record; + if (result.status !== 'active') { + await client.query( + `DELETE FROM staff_standalone_employee_access + WHERE tenant_id = $1 AND employee_id = $2`, + [context.tenantId, employeeId], + ); + } + await client.query( + `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, old_values, new_values, correlation_id) VALUES ($1, $2, $3, 'employee.updated', 'Employee', $4, $5, $6, $7)`, - [newUlid(), context.tenantId, context.actorId, employeeId, - current.rows[0], result, context.correlationId], + [ + newUlid(), + context.tenantId, + context.actorId, + employeeId, + current.rows[0], + result, + context.correlationId, + ], + ); + return { kind: 'saved' as const, employee: result }; + }, ); - return { kind: 'saved' as const, employee: result }; - }); - if (outcome.kind === 'missing') return reply.notFound('Сотрудник не найден.'); - if (outcome.kind === 'conflict') { - return reply.conflict('Сотрудник с таким табельным номером уже существует.'); - } - return reply.send(outcome.employee); - }); - - api.post<{ Params: { employeeId: string } }>('/employees/:employeeId/access-token', { - schema: { - params: { type: 'object', additionalProperties: false, required: ['employeeId'], properties: { - employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, - } }, + if (outcome.kind === 'missing') return reply.notFound('Сотрудник не найден.'); + if (outcome.kind === 'conflict') { + return reply.conflict('Сотрудник с таким табельным номером уже существует.'); + } + if (outcome.kind === 'invalid_profession') + return reply.badRequest('Профессия не найдена или отключена.'); + return reply.send(outcome.employee); }, - }, async (request, reply) => { - assertCookieMutationOrigin(request); - if (config.STAFF_RUNTIME_MODE !== 'standalone') { - return reply.conflict('В платформенном режиме доступом сотрудников управляет Core SSO.'); - } - const context = await requirePermission(identity, request, 'staff:employee:write'); - const employeeId = parseUlid(request.params.employeeId); - const accessToken = randomBytes(32).toString('base64url'); - const tokenHash = hashAccessToken(accessToken); - const tokenHint = accessToken.slice(-6); - const saved = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - const employee = await client.query( - `SELECT id, core_user_id, display_name FROM staff_employees + ); + + api.post<{ Params: { employeeId: string } }>( + '/employees/:employeeId/access-token', + { + schema: { + params: { + type: 'object', + additionalProperties: false, + required: ['employeeId'], + properties: { + employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + }, + }, + }, + }, + async (request, reply) => { + assertCookieMutationOrigin(request); + if (config.STAFF_RUNTIME_MODE !== 'standalone') { + return reply.conflict('В платформенном режиме доступом сотрудников управляет Core SSO.'); + } + const context = await requirePermission(identity, request, 'staff:employee:write'); + const employeeId = parseUlid(request.params.employeeId); + const accessToken = randomBytes(32).toString('base64url'); + const tokenHash = hashAccessToken(accessToken); + const tokenHint = accessToken.slice(-6); + const saved = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + const employee = await client.query( + `SELECT id, core_user_id, display_name FROM staff_employees WHERE tenant_id = $1 AND id = $2 AND status = 'active' FOR UPDATE`, - [context.tenantId, employeeId], - ); - if (employee.rowCount === 0) return null; - const employeeRow = employee.rows[0] as { - core_user_id?: string | null; - display_name?: string; - } | undefined; - const subjectId = String(employeeRow?.core_user_id ?? newUlid()); - await client.query( - `UPDATE staff_employees SET core_user_id = $3, updated_at = now() + [context.tenantId, employeeId], + ); + if (employee.rowCount === 0) return null; + const employeeRow = employee.rows[0] as + | { + core_user_id?: string | null; + display_name?: string; + } + | undefined; + const subjectId = String(employeeRow?.core_user_id ?? newUlid()); + await client.query( + `UPDATE staff_employees SET core_user_id = $3, updated_at = now() WHERE tenant_id = $1 AND id = $2`, - [context.tenantId, employeeId, subjectId], - ); - await client.query( - `INSERT INTO staff_standalone_employee_access + [context.tenantId, employeeId, subjectId], + ); + await client.query( + `INSERT INTO staff_standalone_employee_access (id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (tenant_id, employee_id) DO UPDATE SET subject_id = EXCLUDED.subject_id, token_hash = EXCLUDED.token_hash, token_hint = EXCLUDED.token_hint, created_by = EXCLUDED.created_by, rotated_at = now()`, - [newUlid(), context.tenantId, employeeId, subjectId, tokenHash, tokenHint, context.actorId], - ); - await client.query( - `INSERT INTO staff_audit_log + [ + newUlid(), + context.tenantId, + employeeId, + subjectId, + tokenHash, + tokenHint, + context.actorId, + ], + ); + await client.query( + `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) VALUES ($1, $2, $3, 'employee.access.rotated', 'Employee', $4, $5, $6)`, - [newUlid(), context.tenantId, context.actorId, employeeId, - { subject_id: subjectId, token_hint: tokenHint }, context.correlationId], + [ + newUlid(), + context.tenantId, + context.actorId, + employeeId, + { subject_id: subjectId, token_hint: tokenHint }, + context.correlationId, + ], + ); + return { + employee_id: employeeId, + display_name: employeeRow?.display_name, + subject_id: subjectId, + token_hint: tokenHint, + }; + }, ); - return { employee_id: employeeId, display_name: employeeRow?.display_name, - subject_id: subjectId, token_hint: tokenHint }; - }); - if (!saved) return reply.notFound('Активный сотрудник не найден.'); - return reply.send({ ...saved, access_token: accessToken }); - }); + if (!saved) return reply.notFound('Активный сотрудник не найден.'); + return reply.send({ ...saved, access_token: accessToken }); + }, + ); - api.post<{ Body: AttendanceBody }>('/attendance/events', { - schema: { - body: { - type: 'object', additionalProperties: false, - required: ['employee_id', 'event_type', 'occurred_at', 'source_event_id'], - properties: { - event_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, - employee_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, - event_type: { enum: ['check_in', 'check_out'] }, - occurred_at: { type: 'string', format: 'date-time' }, - source_event_id: { type: 'string', minLength: 1, maxLength: 200 }, - device_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, - org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + api.post<{ Body: AttendanceBody }>( + '/attendance/events', + { + schema: { + body: { + type: 'object', + additionalProperties: false, + required: ['employee_id', 'event_type', 'occurred_at', 'source_event_id'], + properties: { + event_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + employee_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + event_type: { enum: ['check_in', 'check_out'] }, + occurred_at: { type: 'string', format: 'date-time' }, + source_event_id: { type: 'string', minLength: 1, maxLength: 200 }, + device_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + }, }, }, }, - }, async (request, reply) => { - const context = await requirePermission(identity, request, 'staff:attendance:clock'); - const eventId = request.body.event_id ? parseUlid(request.body.event_id) : newUlid(); - const employeeId = parseUlid(request.body.employee_id); - const deviceId = request.body.device_id ? parseUlid(request.body.device_id) : null; - const orgUnitId = request.body.org_unit_id ? parseUlid(request.body.org_unit_id) : null; - const occurredAt = new Date(request.body.occurred_at); - if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.'); - const now = Date.now(); - if (occurredAt.valueOf() > now + 5 * 60_000 || occurredAt.valueOf() < now - 180 * 86_400_000) { - return reply.badRequest('Время отметки выходит за допустимый период пилота.'); - } + async (request, reply) => { + const context = await requirePermission(identity, request, 'staff:attendance:clock'); + const eventId = request.body.event_id ? parseUlid(request.body.event_id) : newUlid(); + const employeeId = parseUlid(request.body.employee_id); + const deviceId = request.body.device_id ? parseUlid(request.body.device_id) : null; + const orgUnitId = request.body.org_unit_id ? parseUlid(request.body.org_unit_id) : null; + const occurredAt = new Date(request.body.occurred_at); + if (Number.isNaN(occurredAt.valueOf())) + return reply.badRequest('Некорректное время отметки.'); + const now = Date.now(); + if ( + occurredAt.valueOf() > now + 5 * 60_000 || + occurredAt.valueOf() < now - 180 * 86_400_000 + ) { + return reply.badRequest('Время отметки выходит за допустимый период пилота.'); + } - const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - await client.query( - `INSERT INTO staff_employees - (id, tenant_id, personnel_number, display_name, position, gender, status) - VALUES ($1, $2, $3, $4, '', '', 'active') - ON CONFLICT (id) DO NOTHING`, - [employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`], - ); - const inserted = await client.query( - `INSERT INTO staff_attendance_events + const result = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + const employee = await client.query( + `SELECT id FROM staff_employees + WHERE tenant_id = $1 AND id = $2 AND status = 'active'`, + [context.tenantId, employeeId], + ); + if (employee.rowCount === 0) return null; + const inserted = await client.query( + `INSERT INTO staff_attendance_events (id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, org_unit_id, actor_id, correlation_id) VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9, $10) ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING RETURNING id`, - [eventId, context.tenantId, employeeId, request.body.event_type, - request.body.source_event_id, occurredAt.toISOString(), deviceId, orgUnitId, - context.actorId, context.correlationId], - ); - if (inserted.rowCount === 0) return { id: eventId, duplicate: true }; + [ + eventId, + context.tenantId, + employeeId, + request.body.event_type, + request.body.source_event_id, + occurredAt.toISOString(), + deviceId, + orgUnitId, + context.actorId, + context.correlationId, + ], + ); + if (inserted.rowCount === 0) return { id: eventId, duplicate: true }; - const eventType = request.body.event_type === 'check_in' - ? 'attendance.clock.in' - : 'attendance.clock.out'; - await client.query( - `INSERT INTO staff_outbox + const eventType = + request.body.event_type === 'check_in' ? 'attendance.clock.in' : 'attendance.clock.out'; + 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, occurred_at) VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`, - [newUlid(), context.tenantId, eventType, employeeId, context.actorId, - { attendance_event_id: eventId, channel: 'platform_notification' }, - context.correlationId, occurredAt.toISOString()], + [ + newUlid(), + context.tenantId, + eventType, + employeeId, + context.actorId, + { attendance_event_id: eventId, channel: 'platform_notification' }, + context.correlationId, + occurredAt.toISOString(), + ], + ); + return { id: eventId, duplicate: false }; + }, ); - return { id: eventId, duplicate: false }; - }); - return reply.code(result.duplicate ? 200 : 201).send(result); - }); + if (!result) return reply.notFound('Активный сотрудник не найден.'); + return reply.code(result.duplicate ? 200 : 201).send(result); + }, + ); - api.post<{ Body: ReconcileSessionsBody }>('/work-sessions/reconcile', { - schema: { - body: { - type: 'object', additionalProperties: false, - required: ['date_from', 'date_to'], - properties: { - date_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, - date_to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, - business_day_start_hour: { type: 'integer', minimum: 0, maximum: 23 }, - automatic_close_hour: { type: 'integer', minimum: 0, maximum: 23 }, - open_shift_threshold_minutes: { type: 'integer', minimum: 60, maximum: 2880 }, + api.post<{ Body: ReconcileSessionsBody }>( + '/work-sessions/reconcile', + { + schema: { + body: { + type: 'object', + additionalProperties: false, + required: ['date_from', 'date_to'], + properties: { + date_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + date_to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + business_day_start_hour: { type: 'integer', minimum: 0, maximum: 23 }, + automatic_close_hour: { type: 'integer', minimum: 0, maximum: 23 }, + open_shift_threshold_minutes: { type: 'integer', minimum: 60, maximum: 2880 }, + }, }, }, }, - }, async (request, reply) => { - assertCookieMutationOrigin(request); - const context = await requirePermission(identity, request, 'staff:shift:confirm'); - const from = parseDateOnly(request.body.date_from); - const to = parseDateOnly(request.body.date_to); - if (!from || !to || to < from) return reply.badRequest('Некорректный период сверки.'); - const periodDays = Math.floor((to.valueOf() - from.valueOf()) / 86_400_000) + 1; - if (periodDays > 40) return reply.badRequest('За один раз можно сверить не более 40 дней.'); - const businessDayStartHour = request.body.business_day_start_hour ?? 4; - const automaticCloseHour = request.body.automatic_close_hour ?? 2; - const thresholdMinutes = request.body.open_shift_threshold_minutes ?? 14 * 60; - const now = new Date().toISOString(); + async (request, reply) => { + assertCookieMutationOrigin(request); + const context = await requirePermission(identity, request, 'staff:shift:confirm'); + const from = parseDateOnly(request.body.date_from); + const to = parseDateOnly(request.body.date_to); + if (!from || !to || to < from) return reply.badRequest('Некорректный период сверки.'); + const periodDays = Math.floor((to.valueOf() - from.valueOf()) / 86_400_000) + 1; + if (periodDays > 40) return reply.badRequest('За один раз можно сверить не более 40 дней.'); + const businessDayStartHour = request.body.business_day_start_hour ?? 4; + const automaticCloseHour = request.body.automatic_close_hour ?? 2; + const thresholdMinutes = request.body.open_shift_threshold_minutes ?? 14 * 60; + const now = new Date().toISOString(); - const summary = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - const attendance = await client.query( - `WITH categorized AS ( + const summary = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + const attendance = await client.query( + `WITH categorized AS ( SELECT a.id, a.employee_id, a.event_type, a.occurred_at, COALESCE(a.org_unit_id, e.primary_org_unit_id) AS org_unit_id, COALESCE(u.timezone, 'UTC') AS timezone, @@ -455,49 +736,58 @@ export async function registerPortalRoutes( FROM categorized WHERE business_date BETWEEN $1::date AND $2::date ORDER BY employee_id, org_unit_id NULLS LAST, business_date, occurred_at, id`, - [request.body.date_from, request.body.date_to, businessDayStartHour, automaticCloseHour], - ); + [ + request.body.date_from, + request.body.date_to, + businessDayStartHour, + automaticCloseHour, + ], + ); - type AttendanceRow = { - id: string; employee_id: string; event_type: 'check_in' | 'check_out'; - occurred_at: Date | string; org_unit_id: string | null; business_date: string; - automatic_close_at: Date | string; - }; - const grouped = new Map(); - let unresolvedEvents = 0; - for (const row of attendance.rows as AttendanceRow[]) { - if (!row.org_unit_id) { - unresolvedEvents += 1; - continue; - } - const key = `${row.employee_id}:${row.org_unit_id}:${row.business_date}`; - const group = grouped.get(key) ?? []; - group.push(row); - grouped.set(key, group); - } + type AttendanceRow = { + id: string; + employee_id: string; + event_type: 'check_in' | 'check_out'; + occurred_at: Date | string; + org_unit_id: string | null; + business_date: string; + automatic_close_at: Date | string; + }; + const grouped = new Map(); + let unresolvedEvents = 0; + for (const row of attendance.rows as AttendanceRow[]) { + if (!row.org_unit_id) { + unresolvedEvents += 1; + continue; + } + const key = `${row.employee_id}:${row.org_unit_id}:${row.business_date}`; + const group = grouped.get(key) ?? []; + group.push(row); + grouped.set(key, group); + } - let createdOrUpdated = 0; - let confirmedSkipped = 0; - let anomalous = 0; - for (const rows of grouped.values()) { - const first = rows[0]; - if (!first?.org_unit_id) continue; - const candidate = reconcileWorkSession( - rows.map((row) => ({ - id: row.id, - type: row.event_type, - occurredAt: toIsoInstant(row.occurred_at), - })), - { - now, - openShiftThresholdMinutes: thresholdMinutes, - automaticCloseAt: toIsoInstant(first.automatic_close_at), - }, - ); - if (!candidate) continue; - if (candidate.anomalies.length > 0) anomalous += 1; - const saved = await client.query( - `INSERT INTO staff_work_sessions + let createdOrUpdated = 0; + let confirmedSkipped = 0; + let anomalous = 0; + for (const rows of grouped.values()) { + const first = rows[0]; + if (!first?.org_unit_id) continue; + const candidate = reconcileWorkSession( + rows.map((row) => ({ + id: row.id, + type: row.event_type, + occurredAt: toIsoInstant(row.occurred_at), + })), + { + now, + openShiftThresholdMinutes: thresholdMinutes, + automaticCloseAt: toIsoInstant(first.automatic_close_at), + }, + ); + if (!candidate) continue; + if (candidate.anomalies.length > 0) anomalous += 1; + const saved = await client.query( + `INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, source, status, anomaly, source_event_ids, auto_closed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'terminal', $9, $10, $11, $12) @@ -514,83 +804,118 @@ export async function registerPortalRoutes( WHERE staff_work_sessions.status <> 'confirmed' RETURNING id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, anomaly, source_event_ids, auto_closed, revision`, - [newUlid(), context.tenantId, first.employee_id, first.org_unit_id, - first.business_date, candidate.startedAt, candidate.endedAt, candidate.workedMinutes, - candidate.status, candidate.anomalies.join(',') || null, - JSON.stringify(candidate.attendanceEventIds), candidate.anomalies.includes('auto_closed')], - ); - if (saved.rowCount === 0) { - confirmedSkipped += 1; - continue; - } - createdOrUpdated += 1; - const result = saved.rows[0] as Record; - await client.query( - `INSERT INTO staff_audit_log + [ + newUlid(), + context.tenantId, + first.employee_id, + first.org_unit_id, + first.business_date, + candidate.startedAt, + candidate.endedAt, + candidate.workedMinutes, + candidate.status, + candidate.anomalies.join(',') || null, + JSON.stringify(candidate.attendanceEventIds), + candidate.anomalies.includes('auto_closed'), + ], + ); + if (saved.rowCount === 0) { + confirmedSkipped += 1; + continue; + } + createdOrUpdated += 1; + const result = saved.rows[0] as Record; + 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, 'work_session.reconciled', 'WorkSession', $4, $5, $6)`, - [newUlid(), context.tenantId, context.actorId, result.id, result, context.correlationId], - ); - } - return { - groups: grouped.size, - sessions_created_or_updated: createdOrUpdated, - confirmed_sessions_skipped: confirmedSkipped, - anomalous_sessions: anomalous, - unresolved_events: unresolvedEvents, - }; - }); - return reply.send(summary); - }); + [ + newUlid(), + context.tenantId, + context.actorId, + result.id, + result, + context.correlationId, + ], + ); + } + return { + groups: grouped.size, + sessions_created_or_updated: createdOrUpdated, + confirmed_sessions_skipped: confirmedSkipped, + anomalous_sessions: anomalous, + unresolved_events: unresolvedEvents, + }; + }, + ); + return reply.send(summary); + }, + ); api.patch<{ Params: { sessionId: string }; Body: ConfirmSessionBody }>( '/work-sessions/:sessionId', { schema: { - params: { type: 'object', required: ['sessionId'], properties: { - sessionId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, - } }, - body: { type: 'object', additionalProperties: false, required: ['status'], properties: { - status: { enum: ['confirmed', 'rejected'] }, - worked_minutes: { type: 'integer', minimum: 0, maximum: 2880 }, - started_at: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, - ended_at: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, - correction_note: { type: 'string', maxLength: 1000 }, - } }, + params: { + type: 'object', + required: ['sessionId'], + properties: { + sessionId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + }, + }, + body: { + type: 'object', + additionalProperties: false, + required: ['status'], + properties: { + status: { enum: ['confirmed', 'rejected'] }, + worked_minutes: { type: 'integer', minimum: 0, maximum: 2880 }, + started_at: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, + ended_at: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, + correction_note: { type: 'string', maxLength: 1000 }, + }, + }, }, }, async (request, reply) => { assertCookieMutationOrigin(request); const context = await requirePermission(identity, request, 'staff:shift:confirm'); const sessionId = parseUlid(request.params.sessionId); - const outcome = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - const currentQuery = await client.query( - `SELECT id, employee_id, org_unit_id, business_date, started_at, ended_at, + const outcome = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + const currentQuery = await client.query( + `SELECT id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, anomaly, correction_note, source_event_ids, auto_closed, revision FROM staff_work_sessions WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, - [context.tenantId, sessionId], - ); - if (currentQuery.rowCount === 0) return { kind: 'missing' as const }; - const current = currentQuery.rows[0] as Record; - const currentStartedAt = parseDatabaseOptionalInstant(current.started_at); - const currentEndedAt = parseDatabaseOptionalInstant(current.ended_at); - const startedAt = request.body.started_at === undefined - ? currentStartedAt - : parseOptionalInstant(request.body.started_at); - const endedAt = request.body.ended_at === undefined - ? currentEndedAt - : parseOptionalInstant(request.body.ended_at); - const workedMinutes = request.body.worked_minutes ?? Number(current.worked_minutes); - const changed = workedMinutes !== Number(current.worked_minutes) - || startedAt !== currentStartedAt || endedAt !== currentEndedAt; - const correctionNote = request.body.correction_note?.trim() ?? ''; - if (changed && !correctionNote) return { kind: 'note_required' as const }; - if (startedAt && endedAt && new Date(endedAt) < new Date(startedAt)) { - return { kind: 'invalid_interval' as const }; - } - const saved = await client.query( - `UPDATE staff_work_sessions SET + [context.tenantId, sessionId], + ); + if (currentQuery.rowCount === 0) return { kind: 'missing' as const }; + const current = currentQuery.rows[0] as Record; + const currentStartedAt = parseDatabaseOptionalInstant(current.started_at); + const currentEndedAt = parseDatabaseOptionalInstant(current.ended_at); + const startedAt = + request.body.started_at === undefined + ? currentStartedAt + : parseOptionalInstant(request.body.started_at); + const endedAt = + request.body.ended_at === undefined + ? currentEndedAt + : parseOptionalInstant(request.body.ended_at); + const workedMinutes = request.body.worked_minutes ?? Number(current.worked_minutes); + const changed = + workedMinutes !== Number(current.worked_minutes) || + startedAt !== currentStartedAt || + endedAt !== currentEndedAt; + const correctionNote = request.body.correction_note?.trim() ?? ''; + if (changed && !correctionNote) return { kind: 'note_required' as const }; + if (startedAt && endedAt && new Date(endedAt) < new Date(startedAt)) { + return { kind: 'invalid_interval' as const }; + } + const saved = await client.query( + `UPDATE staff_work_sessions SET started_at = $3, ended_at = $4, worked_minutes = $5, status = $6, correction_note = $7, confirmed_by = CASE WHEN $6 = 'confirmed' THEN $8 ELSE NULL END, confirmed_at = CASE WHEN $6 = 'confirmed' THEN now() ELSE NULL END, @@ -599,21 +924,36 @@ export async function registerPortalRoutes( RETURNING id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, anomaly, correction_note, source_event_ids, auto_closed, confirmed_by, confirmed_at, revision`, - [context.tenantId, sessionId, startedAt, endedAt, workedMinutes, - request.body.status, correctionNote, context.actorId], - ); - const result = saved.rows[0] as Record; - await client.query( - `INSERT INTO staff_audit_log + [ + context.tenantId, + sessionId, + startedAt, + endedAt, + workedMinutes, + request.body.status, + correctionNote, + context.actorId, + ], + ); + const result = saved.rows[0] as Record; + await client.query( + `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, old_values, new_values, correlation_id) VALUES ($1, $2, $3, 'work_session.reviewed', 'WorkSession', $4, $5, $6, $7)`, - [newUlid(), context.tenantId, context.actorId, sessionId, - current, result, context.correlationId], - ); - if (request.body.status === 'confirmed') { - await client.query( - `INSERT INTO staff_timesheet_entries + [ + newUlid(), + context.tenantId, + context.actorId, + sessionId, + current, + result, + context.correlationId, + ], + ); + if (request.body.status === 'confirmed') { + await client.query( + `INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, minutes_worked, status, note, updated_by) VALUES ($1, $2, $3, $4, $5, 'submitted', $6, $7) ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET @@ -623,22 +963,39 @@ export async function registerPortalRoutes( note = EXCLUDED.note, revision = staff_timesheet_entries.revision + 1, updated_by = EXCLUDED.updated_by, updated_at = now()`, - [newUlid(), context.tenantId, current.employee_id, current.business_date, - workedMinutes, correctionNote, context.actorId], - ); - await client.query( - `INSERT INTO staff_outbox + [ + newUlid(), + context.tenantId, + current.employee_id, + current.business_date, + workedMinutes, + correctionNote, + context.actorId, + ], + ); + 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.work_session.confirmed', 1, 'WorkSession', $3, 'user', $4, $5, $6)`, - [newUlid(), context.tenantId, sessionId, context.actorId, - { employee_id: current.employee_id, business_date: current.business_date, - worked_minutes: workedMinutes }, context.correlationId], - ); - } - return { kind: 'saved' as const, session: result }; - }); + [ + newUlid(), + context.tenantId, + sessionId, + context.actorId, + { + employee_id: current.employee_id, + business_date: current.business_date, + worked_minutes: workedMinutes, + }, + context.correlationId, + ], + ); + } + return { kind: 'saved' as const, session: result }; + }, + ); if (outcome.kind === 'missing') return reply.notFound('Смена не найдена.'); if (outcome.kind === 'note_required') { return reply.badRequest('Для ручной корректировки смены укажите причину.'); @@ -654,16 +1011,24 @@ export async function registerPortalRoutes( '/timesheets/:employeeId/:workDate', { schema: { - params: { type: 'object', required: ['employeeId', 'workDate'], properties: { - employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, - workDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, - } }, - body: { type: 'object', additionalProperties: false, - required: ['minutes_worked', 'status'], properties: { + params: { + type: 'object', + required: ['employeeId', 'workDate'], + properties: { + employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, + workDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, + }, + }, + body: { + type: 'object', + additionalProperties: false, + required: ['minutes_worked', 'status'], + properties: { minutes_worked: { type: 'integer', minimum: 0, maximum: 1440 }, status: { enum: ['draft', 'submitted', 'approved', 'rejected'] }, note: { type: 'string', maxLength: 1000 }, - } }, + }, + }, }, }, async (request, reply) => { @@ -671,13 +1036,19 @@ export async function registerPortalRoutes( const context = await requirePermission(identity, request, 'staff:attendance:manage'); if (!datePattern.test(request.params.workDate)) return reply.badRequest('Некорректная дата.'); const employeeId = parseUlid(request.params.employeeId); - if (request.body.status === 'approved' && !context.permissions.has('staff:attendance:approve')) { + if ( + request.body.status === 'approved' && + !context.permissions.has('staff:attendance:approve') + ) { return reply.forbidden('Для утверждения табеля требуется отдельное право.'); } - const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => { - const saved = await client.query( - `INSERT INTO staff_timesheet_entries + const row = await database.tenantTransaction( + context.tenantId, + context.actorId, + async (client) => { + const saved = await client.query( + `INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, minutes_worked, status, note, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET @@ -685,18 +1056,34 @@ export async function registerPortalRoutes( note = EXCLUDED.note, revision = staff_timesheet_entries.revision + 1, updated_by = EXCLUDED.updated_by, updated_at = now() RETURNING id, employee_id, work_date, minutes_worked, status, note, revision, updated_at`, - [newUlid(), context.tenantId, employeeId, request.params.workDate, - request.body.minutes_worked, request.body.status, request.body.note ?? '', context.actorId], - ); - const result = saved.rows[0] as Record; - await client.query( - `INSERT INTO staff_audit_log + [ + newUlid(), + context.tenantId, + employeeId, + request.params.workDate, + request.body.minutes_worked, + request.body.status, + request.body.note ?? '', + context.actorId, + ], + ); + const result = saved.rows[0] as Record; + 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, 'timesheet.entry.saved', 'TimesheetEntry', $4, $5, $6)`, - [newUlid(), context.tenantId, context.actorId, result.id, result, context.correlationId], - ); - return result; - }); + [ + newUlid(), + context.tenantId, + context.actorId, + result.id, + result, + context.correlationId, + ], + ); + return result; + }, + ); return reply.send(row); }, ); @@ -727,9 +1114,13 @@ function withStandaloneEmployeeAccess( const tokenHash = hashAccessToken(token); const row = await database.tenantTransaction(tenantId, serviceActorId, async (client) => { const result = await client.query( - `SELECT subject_id - FROM staff_standalone_employee_access - WHERE tenant_id = $1 AND token_hash = $2`, + `SELECT access.subject_id + FROM staff_standalone_employee_access access + JOIN staff_employees employee + ON employee.tenant_id = access.tenant_id + AND employee.id = access.employee_id + WHERE access.tenant_id = $1 AND access.token_hash = $2 + AND employee.status = 'active'`, [tenantId, tokenHash], ); return result.rows[0] as { subject_id?: string } | undefined; @@ -755,7 +1146,10 @@ function hashAccessToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); } -async function resolveContext(identity: IdentityPort, request: FastifyRequest): Promise { +async function resolveContext( + identity: IdentityPort, + request: FastifyRequest, +): Promise { const authorization = request.headers.authorization; const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) @@ -810,7 +1204,11 @@ function assertCookieMutationOrigin(request: FastifyRequest) { } } -async function requirePermission(identity: IdentityPort, request: FastifyRequest, permission: string) { +async function requirePermission( + identity: IdentityPort, + request: FastifyRequest, + permission: string, +) { const context = await resolveContext(identity, request); if (!context.permissions.has(permission)) { throw request.server.httpErrors.forbidden('Недостаточно прав для этой операции.'); diff --git a/tests/integration/portal.test.ts b/tests/integration/portal.test.ts index 11e064c..f11045a 100644 --- a/tests/integration/portal.test.ts +++ b/tests/integration/portal.test.ts @@ -100,52 +100,103 @@ describe('attendance portal', () => { it('scopes an employee portal to the linked Core user', async () => { const { database, calls } = fakeDatabase([[], [], []]); - const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database }); + const app = await buildApp({ + config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), + database, + }); const response = await app.inject('/api/v1/staff/portal'); expect(response.statusCode).toBe(200); const body = response.json<{ viewer: { role: string } }>(); expect(body.viewer.role).toBe('employee'); expect(calls).toHaveLength(4); - expect(calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID)).toBe(true); + expect( + calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID), + ).toBe(true); await app.close(); }); it('writes a terminal event and its outbox notification atomically', async () => { const eventId = '01J00000000000000000000002'; const employeeId = '01J00000000000000000000003'; - const { database, calls } = fakeDatabase([[], [{ id: eventId }], []]); - const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), database }); + const { database, calls } = fakeDatabase([[{ id: employeeId }], [{ id: eventId }], []]); + const app = await buildApp({ + config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), + database, + }); const response = await app.inject({ method: 'POST', url: '/api/v1/staff/attendance/events', - payload: { event_id: eventId, employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', source_event_id: eventId }, + payload: { + event_id: eventId, + employee_id: employeeId, + event_type: 'check_in', + occurred_at: '2026-09-24T01:00:00.000Z', + source_event_id: eventId, + }, }); expect(response.statusCode).toBe(201); expect(calls).toHaveLength(3); - expect(calls[0]?.[0]).toContain('staff_employees'); + expect(calls[0]?.[0]).toContain("status = 'active'"); expect(calls[2]?.[0]).toContain('staff_outbox'); expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in'); await app.close(); }); + it('rejects an unknown terminal employee without writing attendance or outbox', async () => { + const eventId = '01J00000000000000000000002'; + const employeeId = '01J00000000000000000000003'; + const { database, calls } = fakeDatabase([[]]); + const app = await buildApp({ + config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), + database, + }); + const response = await app.inject({ + method: 'POST', + url: '/api/v1/staff/attendance/events', + payload: { + event_id: eventId, + employee_id: employeeId, + event_type: 'check_in', + occurred_at: '2026-09-24T01:00:00.000Z', + source_event_id: eventId, + }, + }); + expect(response.statusCode).toBe(404); + expect(calls).toHaveLength(1); + expect(calls[0]?.[0]).toContain("status = 'active'"); + expect(calls[0]?.[0]).not.toContain('INSERT INTO staff_employees'); + await app.close(); + }); + it('does not give an employee manager access to the timesheet', async () => { const { database } = fakeDatabase([]); - const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database }); - const response = await app.inject({ method: 'PUT', url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24', payload: { minutes_worked: 480, status: 'approved' } }); + const app = await buildApp({ + config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), + database, + }); + const response = await app.inject({ + method: 'PUT', + url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24', + payload: { minutes_worked: 480, status: 'approved' }, + }); expect(response.statusCode).toBe(403); await app.close(); }); it('allows a manager to create an employee and writes an audit record', async () => { const employeeId = '01J00000000000000000000004'; - const { database, calls } = fakeDatabase([[{ - id: employeeId, - personnel_number: 'A-17', - display_name: 'Анна Орлова', - position: 'Администратор', - gender: 'female', - status: 'active', - }]]); + const { database, calls } = fakeDatabase([ + [ + { + id: employeeId, + personnel_number: 'A-17', + display_name: 'Анна Орлова', + position: 'Администратор', + gender: 'female', + status: 'active', + }, + ], + ]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), database, @@ -185,9 +236,17 @@ describe('attendance portal', () => { it('updates an employee with before and after values in the audit trail', async () => { const employeeId = '01J00000000000000000000004'; - const before = { id: employeeId, core_user_id: null, personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', status: 'active' }; + const before = { + id: employeeId, + core_user_id: null, + personnel_number: 'A-17', + display_name: 'Анна Орлова', + position: 'Администратор', + gender: 'female', + status: 'active', + }; const after = { ...before, position: 'Старший администратор' }; - const { database, calls } = fakeDatabase([[before], [after], []]); + const { database, calls } = fakeDatabase([[before], [], [after], []]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), database, @@ -199,10 +258,10 @@ describe('attendance portal', () => { payload: { position: 'Старший администратор' }, }); expect(response.statusCode).toBe(200); - expect(calls).toHaveLength(3); + expect(calls).toHaveLength(4); expect(calls[0]?.[0]).toContain('FOR UPDATE'); - expect(calls[2]?.[1]?.[4]).toEqual(before); - expect(calls[2]?.[1]?.[5]).toEqual(after); + expect(calls[3]?.[1]?.[4]).toEqual(before); + expect(calls[3]?.[1]?.[5]).toEqual(after); await app.close(); }); @@ -211,10 +270,37 @@ describe('attendance portal', () => { const orgUnitId = '01J00000000000000000000005'; const sessionId = '01J00000000000000000000006'; const attendanceRows = [ - { id: '01J00000000000000000000010', employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' }, - { id: '01J00000000000000000000011', employee_id: employeeId, event_type: 'check_out', occurred_at: '2026-09-24T09:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' }, + { + id: '01J00000000000000000000010', + employee_id: employeeId, + event_type: 'check_in', + occurred_at: '2026-09-24T01:00:00.000Z', + org_unit_id: orgUnitId, + business_date: '2026-09-24', + automatic_close_at: '2026-09-24T19:00:00.000Z', + }, + { + id: '01J00000000000000000000011', + employee_id: employeeId, + event_type: 'check_out', + occurred_at: '2026-09-24T09:00:00.000Z', + org_unit_id: orgUnitId, + business_date: '2026-09-24', + automatic_close_at: '2026-09-24T19:00:00.000Z', + }, ]; - const session = { id: sessionId, employee_id: employeeId, org_unit_id: orgUnitId, business_date: '2026-09-24', worked_minutes: 480, status: 'pending_review', anomaly: null, source_event_ids: attendanceRows.map((row) => row.id), auto_closed: false, revision: 1 }; + const session = { + id: sessionId, + employee_id: employeeId, + org_unit_id: orgUnitId, + business_date: '2026-09-24', + worked_minutes: 480, + status: 'pending_review', + anomaly: null, + source_event_ids: attendanceRows.map((row) => row.id), + auto_closed: false, + revision: 1, + }; const { database, calls } = fakeDatabase([attendanceRows, [session], []]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), @@ -227,7 +313,11 @@ describe('attendance portal', () => { payload: { date_from: '2026-09-24', date_to: '2026-09-24' }, }); expect(response.statusCode).toBe(200); - expect(response.json()).toMatchObject({ sessions_created_or_updated: 1, anomalous_sessions: 0, unresolved_events: 0 }); + expect(response.json()).toMatchObject({ + sessions_created_or_updated: 1, + anomalous_sessions: 0, + unresolved_events: 0, + }); expect(calls[1]?.[0]).toContain('INSERT INTO staff_work_sessions'); expect(calls[1]?.[1]?.[7]).toBe(480); await app.close(); @@ -235,7 +325,21 @@ describe('attendance portal', () => { it('requires a reason when confirmed hours are corrected', async () => { const sessionId = '01J00000000000000000000006'; - const current = { id: sessionId, employee_id: '01J00000000000000000000004', org_unit_id: '01J00000000000000000000005', business_date: '2026-09-24', started_at: '2026-09-24T01:00:00.000Z', ended_at: '2026-09-24T09:00:00.000Z', worked_minutes: 480, status: 'pending_review', anomaly: null, correction_note: '', source_event_ids: [], auto_closed: false, revision: 1 }; + const current = { + id: sessionId, + employee_id: '01J00000000000000000000004', + org_unit_id: '01J00000000000000000000005', + business_date: '2026-09-24', + started_at: '2026-09-24T01:00:00.000Z', + ended_at: '2026-09-24T09:00:00.000Z', + worked_minutes: 480, + status: 'pending_review', + anomaly: null, + correction_note: '', + source_event_ids: [], + auto_closed: false, + revision: 1, + }; const { database, calls } = fakeDatabase([[current]]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts index 4ca441b..45e1d07 100644 --- a/tests/integration/tenant-isolation.test.ts +++ b/tests/integration/tenant-isolation.test.ts @@ -12,6 +12,7 @@ describeDatabase('PostgreSQL tenant isolation', () => { const actorA = parseUlid('01J0000000000000000000001A'); const actorB = parseUlid('01J0000000000000000000001B'); const eventId = newUlid(); + const professionId = newUlid(); beforeAll(async () => { const role = await database.pool.query( @@ -23,6 +24,7 @@ describeDatabase('PostgreSQL tenant isolation', () => { afterAll(async () => { await database.tenantTransaction(tenantA, actorA, async (client) => { await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]); + await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]); }); await database.close(); }); @@ -47,6 +49,27 @@ describeDatabase('PostgreSQL tenant isolation', () => { ); expect(visibleToB.rowCount).toBe(0); + await database.tenantTransaction(tenantA, actorA, (client) => + client.query( + `INSERT INTO staff_professions (id, tenant_id, code, name) + VALUES ($1, $2, 'cook', 'Повар')`, + [professionId, tenantA], + ), + ); + const professionsVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) => + client.query('SELECT id FROM staff_professions WHERE id = $1', [professionId]), + ); + expect(professionsVisibleToB.rowCount).toBe(0); + await expect( + database.tenantTransaction(tenantB, actorB, (client) => + client.query( + `INSERT INTO staff_professions (id, tenant_id, code, name) + VALUES ($1, $2, 'foreign', 'Чужая профессия')`, + [newUlid(), tenantA], + ), + ), + ).rejects.toThrow(); + await expect( database.tenantTransaction(tenantB, actorB, (client) => client.query( diff --git a/tests/unit/payroll-auto-calc.test.ts b/tests/unit/payroll-auto-calc.test.ts index 1d0eef2..c943574 100644 --- a/tests/unit/payroll-auto-calc.test.ts +++ b/tests/unit/payroll-auto-calc.test.ts @@ -4,7 +4,11 @@ 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 { + cateringPreset, + defaultParams, + findRoleTemplate, +} from '../../src/domain/payroll/presets.js'; import { approvePayrollRun, calculatePayrollPeriod, @@ -218,7 +222,8 @@ describe('Payroll service calculation and warning engine', () => { 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 ?? []); + const params = + typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []); return Promise.resolve(handlers.query(sql, params)); }, release: () => {}, @@ -420,7 +425,8 @@ describe('Payroll approval and ledger idempotency', () => { 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 ?? []); + const params = + typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []); return Promise.resolve(handlers.query(sql, params)); }, release: () => {}, @@ -482,7 +488,9 @@ describe('Payroll approval and ledger idempotency', () => { 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(ledgerInsertQuery).toContain( + 'ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING', + ); expect(ledgerInsertParams).toContain(`payroll_run:${runId}:${empId}`); }); @@ -520,6 +528,7 @@ describe('Payroll approval and ledger idempotency', () => { describe('Pay assignment management', () => { it('saves and retrieves pay assignment with default fallbacks', async () => { let savedParamsJson = ''; + let closedPreviousVersion = false; const tenantId = newUlid(); const actorId = newUlid(); const corrId = newUlid(); @@ -530,7 +539,8 @@ describe('Pay assignment management', () => { 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 ?? []); + const params = + typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []); if (sql.includes('FROM staff_employees')) { return Promise.resolve({ rows: [{ id: empId }], rowCount: 1 }); @@ -539,7 +549,12 @@ describe('Pay assignment management', () => { return Promise.resolve({ rows: [{ id: ruleId, pool_weight_bp: 10000 }], rowCount: 1 }); } if (sql.includes('INSERT INTO staff_pay_assignments')) { - savedParamsJson = String(params[6]); + savedParamsJson = String(params[7]); + return Promise.resolve({ rows: [], rowCount: 1 }); + } + if (sql.includes('SET effective_to = ($3::date')) { + closedPreviousVersion = true; + expect(String(params[2])).toMatch(/^\d{4}-\d{2}-\d{2}$/); return Promise.resolve({ rows: [], rowCount: 1 }); } if (sql.includes('FROM staff_pay_assignments a')) { @@ -568,23 +583,17 @@ describe('Pay assignment management', () => { release: () => {}, } as unknown as PoolClient; - const saved = await saveEmployeePayAssignment( - client, - tenantId, - actorId, - corrId, - empId, - { - pay_rule_id: ruleId, - rate_minor: 12000, - note: 'Главный повар', - }, - ); + const saved = await saveEmployeePayAssignment(client, tenantId, actorId, corrId, empId, { + pay_rule_id: ruleId, + rate_minor: 12000, + note: 'Главный повар', + }); expect(saved.rate_minor).toBe(12000); expect(saved.pool_weight_bp).toBe(10000); expect(saved.note).toBe('Главный повар'); expect(savedParamsJson).toContain('"rate_minor":12000'); + expect(closedPreviousVersion).toBe(true); const fetched = await getEmployeePayAssignment(client, tenantId, empId); expect(fetched).not.toBeNull(); @@ -610,23 +619,26 @@ describe('Tenant and employee isolation guarantees', () => { // 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\')'); + 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'); + expect(operationsSrc).toContain( + '!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId', + ); }); it('confirms external_key and unique index are declared in 0008_employee_access migration', () => { const migrationSql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8'); - expect(migrationSql).toContain('ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;'); + expect(migrationSql).toContain( + 'ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;', + ); expect(migrationSql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx'); expect(migrationSql).toContain( 'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;', ); }); }); -