feat(staff): import workbook professions and dated payroll
This commit is contained in:
parent
4fe2f8799a
commit
5b159f785c
9 changed files with 1935 additions and 764 deletions
6
migrations/0009_professions.down.sql
Normal file
6
migrations/0009_professions.down.sql
Normal 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;
|
||||||
32
migrations/0009_professions.up.sql
Normal file
32
migrations/0009_professions.up.sql
Normal 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;
|
||||||
274
scripts/import-owner-workbook.py
Normal file
274
scripts/import-owner-workbook.py
Normal 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())
|
||||||
|
|
@ -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
1202
src/portal.ts
1202
src/portal.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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' }),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue