feat(staff): import workbook professions and dated payroll

This commit is contained in:
Ochenstarik 2026-09-24 21:14:03 +07:00
parent 4fe2f8799a
commit 5b159f785c
9 changed files with 1935 additions and 764 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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())

View file

@ -219,9 +219,10 @@ export async function getEmployeePayAssignment(
if (result.rowCount === 0 || !result.rows[0]) return null; if (result.rowCount === 0 || !result.rows[0]) return null;
const row = result.rows[0]; const row = result.rows[0];
const rawParams = (typeof row.params === 'string' const rawParams =
? (JSON.parse(row.params) as Record<string, unknown>) (typeof row.params === 'string'
: row.params) ?? {}; ? (JSON.parse(row.params) as Record<string, unknown>)
: row.params) ?? {};
const params: Record<string, number> = {}; const params: Record<string, number> = {};
for (const [k, v] of Object.entries(rawParams)) { 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 defaultRuleWeight = Number(ruleCheck.rows[0].pool_weight_bp ?? 0);
const effectiveFrom = data.effective_from ?? new Date().toISOString().slice(0, 10); const effectiveFrom = data.effective_from ?? new Date().toISOString().slice(0, 10);
const poolWeightBp = const poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
const params: Record<string, number> = { const params: Record<string, number> = {
rate_minor: data.rate_minor ?? 10000, rate_minor: data.rate_minor ?? 10000,
@ -298,12 +298,45 @@ export async function saveEmployeePayAssignment(
guarantee_minor: data.guarantee_minor ?? 0, 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 }>( const existingAssignment = await client.query<{ id: string }>(
`SELECT id FROM staff_pay_assignments `SELECT id FROM staff_pay_assignments
WHERE tenant_id = $1 AND employee_id = $2 AND effective_from = $3 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`, 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; let assignmentId: string;
if ((existingAssignment.rowCount ?? 0) > 0 && existingAssignment.rows[0]) { if ((existingAssignment.rowCount ?? 0) > 0 && existingAssignment.rows[0]) {
@ -314,16 +347,27 @@ export async function saveEmployeePayAssignment(
pay_rule_id = $4, pay_rule_id = $4,
params = $5, params = $5,
pool_weight_bp = $6, pool_weight_bp = $6,
note = $7 note = $7,
effective_to = $8
WHERE tenant_id = $1 AND id = $2`, 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 { } else {
assignmentId = newUlid(); assignmentId = newUlid();
await client.query( await client.query(
`INSERT INTO staff_pay_assignments `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) (id, tenant_id, employee_id, org_unit_id, pay_rule_id, effective_from,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, effective_to, params, pool_weight_bp, note, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[ [
assignmentId, assignmentId,
tenantId, tenantId,
@ -331,6 +375,7 @@ export async function saveEmployeePayAssignment(
orgUnitId, orgUnitId,
payRuleId, payRuleId,
effectiveFrom, effectiveFrom,
boundedEffectiveTo,
JSON.stringify(params), JSON.stringify(params),
poolWeightBp, poolWeightBp,
data.note ?? '', data.note ?? '',
@ -348,7 +393,13 @@ export async function saveEmployeePayAssignment(
tenantId, tenantId,
actorId, actorId,
assignmentId, 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, correlationId,
], ],
); );
@ -532,7 +583,16 @@ export async function calculatePayrollPeriod(
} }
const allLines: ProcessedLine[] = []; const allLines: ProcessedLine[] = [];
const processedEmployees = new Map<string, { display_name: string; position: string; worked_minutes: number; days: number; accrued_minor: number }>(); 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 // Initialize all employees with sessions in tracking map
for (const session of sessions) { for (const session of sessions) {
@ -585,9 +645,10 @@ export async function calculatePayrollPeriod(
? (JSON.parse(assignment.rule) as RuleNode) ? (JSON.parse(assignment.rule) as RuleNode)
: assignment.rule; : assignment.rule;
const rawParams = (typeof assignment.params === 'string' const rawParams =
? (JSON.parse(assignment.params) as Record<string, unknown>) (typeof assignment.params === 'string'
: assignment.params) ?? {}; ? (JSON.parse(assignment.params) as Record<string, unknown>)
: assignment.params) ?? {};
const poolWeight = const poolWeight =
assignment.pool_weight_bp !== null && assignment.pool_weight_bp !== undefined assignment.pool_weight_bp !== null && assignment.pool_weight_bp !== undefined
@ -731,19 +792,26 @@ export async function calculatePayrollPeriod(
tenantId, tenantId,
actorId, actorId,
runId, 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, correlationId,
], ],
); );
const totals: EmployeeRunTotal[] = [...processedEmployees.entries()].map(([empId, data]) => ({ const totals: EmployeeRunTotal[] = [...processedEmployees.entries()]
employee_id: empId, .map(([empId, data]) => ({
display_name: data.display_name, employee_id: empId,
position: data.position, display_name: data.display_name,
worked_minutes: data.worked_minutes, position: data.position,
days: data.days, worked_minutes: data.worked_minutes,
accrued_minor: data.accrued_minor.toString(), days: data.days,
})).sort((a, b) => a.display_name.localeCompare(b.display_name)); accrued_minor: data.accrued_minor.toString(),
}))
.sort((a, b) => a.display_name.localeCompare(b.display_name));
return { return {
run_id: runId, run_id: runId,
@ -857,7 +925,11 @@ export async function approvePayrollRun(
tenantId, tenantId,
runId, runId,
actorId, 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, correlationId,
], ],
); );
@ -871,7 +943,11 @@ export async function approvePayrollRun(
tenantId, tenantId,
actorId, actorId,
runId, 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, correlationId,
], ],
); );

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -100,52 +100,103 @@ describe('attendance portal', () => {
it('scopes an employee portal to the linked Core user', async () => { it('scopes an employee portal to the linked Core user', async () => {
const { database, calls } = fakeDatabase([[], [], []]); 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'); const response = await app.inject('/api/v1/staff/portal');
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const body = response.json<{ viewer: { role: string } }>(); const body = response.json<{ viewer: { role: string } }>();
expect(body.viewer.role).toBe('employee'); expect(body.viewer.role).toBe('employee');
expect(calls).toHaveLength(4); 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(); await app.close();
}); });
it('writes a terminal event and its outbox notification atomically', async () => { it('writes a terminal event and its outbox notification atomically', async () => {
const eventId = '01J00000000000000000000002'; const eventId = '01J00000000000000000000002';
const employeeId = '01J00000000000000000000003'; const employeeId = '01J00000000000000000000003';
const { database, calls } = fakeDatabase([[], [{ id: eventId }], []]); const { database, calls } = fakeDatabase([[{ id: employeeId }], [{ id: eventId }], []]);
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), database }); const app = await buildApp({
config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }),
database,
});
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/api/v1/staff/attendance/events', 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(response.statusCode).toBe(201);
expect(calls).toHaveLength(3); 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]?.[0]).toContain('staff_outbox');
expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in'); expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in');
await app.close(); 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 () => { it('does not give an employee manager access to the timesheet', async () => {
const { database } = fakeDatabase([]); const { database } = fakeDatabase([]);
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database }); const app = await buildApp({
const response = await app.inject({ method: 'PUT', url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24', payload: { minutes_worked: 480, status: 'approved' } }); 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); expect(response.statusCode).toBe(403);
await app.close(); await app.close();
}); });
it('allows a manager to create an employee and writes an audit record', async () => { it('allows a manager to create an employee and writes an audit record', async () => {
const employeeId = '01J00000000000000000000004'; const employeeId = '01J00000000000000000000004';
const { database, calls } = fakeDatabase([[{ const { database, calls } = fakeDatabase([
id: employeeId, [
personnel_number: 'A-17', {
display_name: 'Анна Орлова', id: employeeId,
position: 'Администратор', personnel_number: 'A-17',
gender: 'female', display_name: 'Анна Орлова',
status: 'active', position: 'Администратор',
}]]); gender: 'female',
status: 'active',
},
],
]);
const app = await buildApp({ const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database, database,
@ -185,9 +236,17 @@ describe('attendance portal', () => {
it('updates an employee with before and after values in the audit trail', async () => { it('updates an employee with before and after values in the audit trail', async () => {
const employeeId = '01J00000000000000000000004'; 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 after = { ...before, position: 'Старший администратор' };
const { database, calls } = fakeDatabase([[before], [after], []]); const { database, calls } = fakeDatabase([[before], [], [after], []]);
const app = await buildApp({ const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database, database,
@ -199,10 +258,10 @@ describe('attendance portal', () => {
payload: { position: 'Старший администратор' }, payload: { position: 'Старший администратор' },
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(calls).toHaveLength(3); expect(calls).toHaveLength(4);
expect(calls[0]?.[0]).toContain('FOR UPDATE'); expect(calls[0]?.[0]).toContain('FOR UPDATE');
expect(calls[2]?.[1]?.[4]).toEqual(before); expect(calls[3]?.[1]?.[4]).toEqual(before);
expect(calls[2]?.[1]?.[5]).toEqual(after); expect(calls[3]?.[1]?.[5]).toEqual(after);
await app.close(); await app.close();
}); });
@ -211,10 +270,37 @@ describe('attendance portal', () => {
const orgUnitId = '01J00000000000000000000005'; const orgUnitId = '01J00000000000000000000005';
const sessionId = '01J00000000000000000000006'; const sessionId = '01J00000000000000000000006';
const attendanceRows = [ 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 { database, calls } = fakeDatabase([attendanceRows, [session], []]);
const app = await buildApp({ const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), 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' }, payload: { date_from: '2026-09-24', date_to: '2026-09-24' },
}); });
expect(response.statusCode).toBe(200); 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]?.[0]).toContain('INSERT INTO staff_work_sessions');
expect(calls[1]?.[1]?.[7]).toBe(480); expect(calls[1]?.[1]?.[7]).toBe(480);
await app.close(); await app.close();
@ -235,7 +325,21 @@ describe('attendance portal', () => {
it('requires a reason when confirmed hours are corrected', async () => { it('requires a reason when confirmed hours are corrected', async () => {
const sessionId = '01J00000000000000000000006'; 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 { database, calls } = fakeDatabase([[current]]);
const app = await buildApp({ const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),

View file

@ -12,6 +12,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
const actorA = parseUlid('01J0000000000000000000001A'); const actorA = parseUlid('01J0000000000000000000001A');
const actorB = parseUlid('01J0000000000000000000001B'); const actorB = parseUlid('01J0000000000000000000001B');
const eventId = newUlid(); const eventId = newUlid();
const professionId = newUlid();
beforeAll(async () => { beforeAll(async () => {
const role = await database.pool.query( const role = await database.pool.query(
@ -23,6 +24,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
afterAll(async () => { afterAll(async () => {
await database.tenantTransaction(tenantA, actorA, async (client) => { await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]); 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(); await database.close();
}); });
@ -47,6 +49,27 @@ describeDatabase('PostgreSQL tenant isolation', () => {
); );
expect(visibleToB.rowCount).toBe(0); 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( await expect(
database.tenantTransaction(tenantB, actorB, (client) => database.tenantTransaction(tenantB, actorB, (client) =>
client.query( client.query(

View file

@ -4,7 +4,11 @@ import { describe, expect, it } from 'vitest';
import type { RequestContext } from '../../src/core/context.js'; import type { RequestContext } from '../../src/core/context.js';
import { newUlid } from '../../src/core/ids.js'; import { newUlid } from '../../src/core/ids.js';
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.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 { import {
approvePayrollRun, approvePayrollRun,
calculatePayrollPeriod, calculatePayrollPeriod,
@ -218,7 +222,8 @@ describe('Payroll service calculation and warning engine', () => {
return { return {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => { query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text; 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)); return Promise.resolve(handlers.query(sql, params));
}, },
release: () => {}, release: () => {},
@ -420,7 +425,8 @@ describe('Payroll approval and ledger idempotency', () => {
return { return {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => { query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text; 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)); return Promise.resolve(handlers.query(sql, params));
}, },
release: () => {}, release: () => {},
@ -482,7 +488,9 @@ describe('Payroll approval and ledger idempotency', () => {
expect(result.total_accrued_minor).toBe('1220000'); expect(result.total_accrued_minor).toBe('1220000');
// Verify external_key structure and ON CONFLICT clause // 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}`); expect(ledgerInsertParams).toContain(`payroll_run:${runId}:${empId}`);
}); });
@ -520,6 +528,7 @@ describe('Payroll approval and ledger idempotency', () => {
describe('Pay assignment management', () => { describe('Pay assignment management', () => {
it('saves and retrieves pay assignment with default fallbacks', async () => { it('saves and retrieves pay assignment with default fallbacks', async () => {
let savedParamsJson = ''; let savedParamsJson = '';
let closedPreviousVersion = false;
const tenantId = newUlid(); const tenantId = newUlid();
const actorId = newUlid(); const actorId = newUlid();
const corrId = newUlid(); const corrId = newUlid();
@ -530,7 +539,8 @@ describe('Pay assignment management', () => {
const client = { const client = {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => { query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text; 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')) { if (sql.includes('FROM staff_employees')) {
return Promise.resolve({ rows: [{ id: empId }], rowCount: 1 }); 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 }); return Promise.resolve({ rows: [{ id: ruleId, pool_weight_bp: 10000 }], rowCount: 1 });
} }
if (sql.includes('INSERT INTO staff_pay_assignments')) { 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 }); return Promise.resolve({ rows: [], rowCount: 1 });
} }
if (sql.includes('FROM staff_pay_assignments a')) { if (sql.includes('FROM staff_pay_assignments a')) {
@ -568,23 +583,17 @@ describe('Pay assignment management', () => {
release: () => {}, release: () => {},
} as unknown as PoolClient; } as unknown as PoolClient;
const saved = await saveEmployeePayAssignment( const saved = await saveEmployeePayAssignment(client, tenantId, actorId, corrId, empId, {
client, pay_rule_id: ruleId,
tenantId, rate_minor: 12000,
actorId, note: 'Главный повар',
corrId, });
empId,
{
pay_rule_id: ruleId,
rate_minor: 12000,
note: 'Главный повар',
},
);
expect(saved.rate_minor).toBe(12000); expect(saved.rate_minor).toBe(12000);
expect(saved.pool_weight_bp).toBe(10000); expect(saved.pool_weight_bp).toBe(10000);
expect(saved.note).toBe('Главный повар'); expect(saved.note).toBe('Главный повар');
expect(savedParamsJson).toContain('"rate_minor":12000'); expect(savedParamsJson).toContain('"rate_minor":12000');
expect(closedPreviousVersion).toBe(true);
const fetched = await getEmployeePayAssignment(client, tenantId, empId); const fetched = await getEmployeePayAssignment(client, tenantId, empId);
expect(fetched).not.toBeNull(); expect(fetched).not.toBeNull();
@ -610,23 +619,26 @@ describe('Tenant and employee isolation guarantees', () => {
// Ensure the employee isolation clause is present in GET /payroll // 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('($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:payroll:read')");
expect(operationsSrc).toContain('context.permissions.has(\'staff:payslip:read_own\')'); expect(operationsSrc).toContain("context.permissions.has('staff:payslip:read_own')");
}); });
it('restricts pay-assignment access to owner employee or manager', () => { it('restricts pay-assignment access to owner employee or manager', () => {
const operationsSrc = readFileSync('src/operations.ts', 'utf8'); 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', () => { it('confirms external_key and unique index are declared in 0008_employee_access migration', () => {
const migrationSql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8'); 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('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx');
expect(migrationSql).toContain( expect(migrationSql).toContain(
'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;', 'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;',
); );
}); });
}); });