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,7 +219,8 @@ 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 =
|
||||||
|
(typeof row.params === 'string'
|
||||||
? (JSON.parse(row.params) as Record<string, unknown>)
|
? (JSON.parse(row.params) as Record<string, unknown>)
|
||||||
: row.params) ?? {};
|
: row.params) ?? {};
|
||||||
|
|
||||||
|
|
@ -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,7 +645,8 @@ 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 =
|
||||||
|
(typeof assignment.params === 'string'
|
||||||
? (JSON.parse(assignment.params) as Record<string, unknown>)
|
? (JSON.parse(assignment.params) as Record<string, unknown>)
|
||||||
: assignment.params) ?? {};
|
: assignment.params) ?? {};
|
||||||
|
|
||||||
|
|
@ -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()]
|
||||||
|
.map(([empId, data]) => ({
|
||||||
employee_id: empId,
|
employee_id: empId,
|
||||||
display_name: data.display_name,
|
display_name: data.display_name,
|
||||||
position: data.position,
|
position: data.position,
|
||||||
worked_minutes: data.worked_minutes,
|
worked_minutes: data.worked_minutes,
|
||||||
days: data.days,
|
days: data.days,
|
||||||
accrued_minor: data.accrued_minor.toString(),
|
accrued_minor: data.accrued_minor.toString(),
|
||||||
})).sort((a, b) => a.display_name.localeCompare(b.display_name));
|
}))
|
||||||
|
.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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -153,9 +153,12 @@ export async function registerOperationsRoutes(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
api.get<{ Querystring: PeriodQuery }>('/finance', {
|
api.get<{ Querystring: PeriodQuery }>(
|
||||||
|
'/finance',
|
||||||
|
{
|
||||||
schema: { querystring: periodSchema },
|
schema: { querystring: periodSchema },
|
||||||
}, async (request) => {
|
},
|
||||||
|
async (request) => {
|
||||||
const context = await auth.require(request, 'staff:metric:read');
|
const context = await auth.require(request, 'staff:metric:read');
|
||||||
const period = parsePeriod(request.query);
|
const period = parsePeriod(request.query);
|
||||||
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||||
|
|
@ -202,20 +205,26 @@ export async function registerOperationsRoutes(
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
period,
|
period,
|
||||||
categories: financialMetricDefinitions.map(([key, label, kind]) => ({ key, label, kind })),
|
categories: financialMetricDefinitions.map(([key, label, kind]) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
kind,
|
||||||
|
})),
|
||||||
entries: entries.rows,
|
entries: entries.rows,
|
||||||
daily: daily.rows,
|
daily: daily.rows,
|
||||||
monthly: monthly.rows,
|
monthly: monthly.rows,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
api.put<{ Params: FinanceParams; Body: FinanceBody }>(
|
api.put<{ Params: FinanceParams; Body: FinanceBody }>(
|
||||||
'/finance/:orgUnitId/:businessDate/:metricKey',
|
'/finance/:orgUnitId/:businessDate/:metricKey',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
params: {
|
params: {
|
||||||
type: 'object', additionalProperties: false,
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
required: ['orgUnitId', 'businessDate', 'metricKey'],
|
required: ['orgUnitId', 'businessDate', 'metricKey'],
|
||||||
properties: {
|
properties: {
|
||||||
orgUnitId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
orgUnitId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
|
@ -224,7 +233,9 @@ export async function registerOperationsRoutes(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
type: 'object', additionalProperties: false, required: ['value_minor'],
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['value_minor'],
|
||||||
properties: {
|
properties: {
|
||||||
value_minor: {
|
value_minor: {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
|
|
@ -244,7 +255,10 @@ export async function registerOperationsRoutes(
|
||||||
if (!businessDate || !financialMetricKeys.has(request.params.metricKey)) {
|
if (!businessDate || !financialMetricKeys.has(request.params.metricKey)) {
|
||||||
return reply.badRequest('Неизвестная дата или категория.');
|
return reply.badRequest('Неизвестная дата или категория.');
|
||||||
}
|
}
|
||||||
const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
const row = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
await ensureMetricDefinitions(client, context.tenantId);
|
await ensureMetricDefinitions(client, context.tenantId);
|
||||||
const unit = await client.query(
|
const unit = await client.query(
|
||||||
'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL',
|
'SELECT id FROM staff_org_units WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL',
|
||||||
|
|
@ -261,25 +275,47 @@ export async function registerOperationsRoutes(
|
||||||
source = 'manual', recorded_by = EXCLUDED.recorded_by, updated_at = now()
|
source = 'manual', recorded_by = EXCLUDED.recorded_by, updated_at = now()
|
||||||
RETURNING id, org_unit_id, business_date, metric_key,
|
RETURNING id, org_unit_id, business_date, metric_key,
|
||||||
value::text AS value_minor, currency_code`,
|
value::text AS value_minor, currency_code`,
|
||||||
[newUlid(), context.tenantId, orgUnitId, request.params.businessDate,
|
[
|
||||||
request.params.metricKey, request.body.value_minor,
|
newUlid(),
|
||||||
request.body.currency_code ?? 'RUB', context.actorId],
|
context.tenantId,
|
||||||
|
orgUnitId,
|
||||||
|
request.params.businessDate,
|
||||||
|
request.params.metricKey,
|
||||||
|
request.body.value_minor,
|
||||||
|
request.body.currency_code ?? 'RUB',
|
||||||
|
context.actorId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await refreshRevenueMetric(
|
||||||
|
client,
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
orgUnitId,
|
||||||
|
request.params.businessDate,
|
||||||
);
|
);
|
||||||
await refreshRevenueMetric(client, context.tenantId, context.actorId,
|
|
||||||
orgUnitId, request.params.businessDate);
|
|
||||||
const savedRow = saved.rows[0] as Record<string, unknown> | undefined;
|
const savedRow = saved.rows[0] as Record<string, unknown> | undefined;
|
||||||
await writeAudit(client, context, 'finance.metric.saved', 'DailyMetric',
|
await writeAudit(
|
||||||
String(savedRow?.id), savedRow ?? {});
|
client,
|
||||||
|
context,
|
||||||
|
'finance.metric.saved',
|
||||||
|
'DailyMetric',
|
||||||
|
String(savedRow?.id),
|
||||||
|
savedRow ?? {},
|
||||||
|
);
|
||||||
return savedRow;
|
return savedRow;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (!row) return reply.notFound('Торговая точка не найдена.');
|
if (!row) return reply.notFound('Торговая точка не найдена.');
|
||||||
return reply.send(row);
|
return reply.send(row);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
api.get<{ Querystring: PeriodQuery }>('/schedule', {
|
api.get<{ Querystring: PeriodQuery }>(
|
||||||
|
'/schedule',
|
||||||
|
{
|
||||||
schema: { querystring: periodSchema },
|
schema: { querystring: periodSchema },
|
||||||
}, async (request, reply) => {
|
},
|
||||||
|
async (request, reply) => {
|
||||||
const context = await auth.resolve(request);
|
const context = await auth.resolve(request);
|
||||||
const canManage = context.permissions.has('staff:attendance:manage');
|
const canManage = context.permissions.has('staff:attendance:manage');
|
||||||
if (!canManage && !context.permissions.has('staff:shift:read')) {
|
if (!canManage && !context.permissions.has('staff:shift:read')) {
|
||||||
|
|
@ -311,14 +347,16 @@ export async function registerOperationsRoutes(
|
||||||
);
|
);
|
||||||
return { period, shifts: rows.rows };
|
return { period, shifts: rows.rows };
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
api.put<{ Params: ScheduleParams; Body: ScheduleBody }>(
|
api.put<{ Params: ScheduleParams; Body: ScheduleBody }>(
|
||||||
'/schedule/:employeeId/:businessDate',
|
'/schedule/:employeeId/:businessDate',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
params: {
|
params: {
|
||||||
type: 'object', additionalProperties: false,
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
required: ['employeeId', 'businessDate'],
|
required: ['employeeId', 'businessDate'],
|
||||||
properties: {
|
properties: {
|
||||||
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
|
@ -326,7 +364,8 @@ export async function registerOperationsRoutes(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
type: 'object', additionalProperties: false,
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
required: ['org_unit_id', 'planned_minutes'],
|
required: ['org_unit_id', 'planned_minutes'],
|
||||||
properties: {
|
properties: {
|
||||||
org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
|
@ -345,7 +384,10 @@ export async function registerOperationsRoutes(
|
||||||
const orgUnitId = parseUlid(request.body.org_unit_id);
|
const orgUnitId = parseUlid(request.body.org_unit_id);
|
||||||
if (!parseDate(request.params.businessDate)) return reply.badRequest('Некорректная дата.');
|
if (!parseDate(request.params.businessDate)) return reply.badRequest('Некорректная дата.');
|
||||||
const externalKey = `schedule:${employeeId}:${request.params.businessDate}`;
|
const externalKey = `schedule:${employeeId}:${request.params.businessDate}`;
|
||||||
const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
const result = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
if (request.body.planned_minutes === 0) {
|
if (request.body.planned_minutes === 0) {
|
||||||
const removed = await client.query(
|
const removed = await client.query(
|
||||||
`DELETE FROM staff_work_items
|
`DELETE FROM staff_work_items
|
||||||
|
|
@ -353,8 +395,14 @@ export async function registerOperationsRoutes(
|
||||||
[context.tenantId, externalKey],
|
[context.tenantId, externalKey],
|
||||||
);
|
);
|
||||||
const removedRow = removed.rows[0] as { id?: string } | undefined;
|
const removedRow = removed.rows[0] as { id?: string } | undefined;
|
||||||
await writeAudit(client, context, 'schedule.shift.removed', 'WorkItem',
|
await writeAudit(
|
||||||
String(removedRow?.id ?? employeeId), { business_date: request.params.businessDate });
|
client,
|
||||||
|
context,
|
||||||
|
'schedule.shift.removed',
|
||||||
|
'WorkItem',
|
||||||
|
String(removedRow?.id ?? employeeId),
|
||||||
|
{ business_date: request.params.businessDate },
|
||||||
|
);
|
||||||
return { removed: (removed.rowCount ?? 0) > 0 };
|
return { removed: (removed.rowCount ?? 0) > 0 };
|
||||||
}
|
}
|
||||||
const exists = await client.query(
|
const exists = await client.query(
|
||||||
|
|
@ -381,22 +429,40 @@ export async function registerOperationsRoutes(
|
||||||
DO UPDATE SET org_unit_id = EXCLUDED.org_unit_id,
|
DO UPDATE SET org_unit_id = EXCLUDED.org_unit_id,
|
||||||
attributes = EXCLUDED.attributes, source = 'manual'
|
attributes = EXCLUDED.attributes, source = 'manual'
|
||||||
RETURNING id, employee_id, org_unit_id, business_date, attributes`,
|
RETURNING id, employee_id, org_unit_id, business_date, attributes`,
|
||||||
[newUlid(), context.tenantId, employeeId, orgUnitId,
|
[
|
||||||
request.params.businessDate, attributes, externalKey, context.actorId],
|
newUlid(),
|
||||||
|
context.tenantId,
|
||||||
|
employeeId,
|
||||||
|
orgUnitId,
|
||||||
|
request.params.businessDate,
|
||||||
|
attributes,
|
||||||
|
externalKey,
|
||||||
|
context.actorId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const savedRow = saved.rows[0] as Record<string, unknown> | undefined;
|
const savedRow = saved.rows[0] as Record<string, unknown> | undefined;
|
||||||
await writeAudit(client, context, 'schedule.shift.saved', 'WorkItem',
|
await writeAudit(
|
||||||
String(savedRow?.id), savedRow ?? {});
|
client,
|
||||||
|
context,
|
||||||
|
'schedule.shift.saved',
|
||||||
|
'WorkItem',
|
||||||
|
String(savedRow?.id),
|
||||||
|
savedRow ?? {},
|
||||||
|
);
|
||||||
return savedRow;
|
return savedRow;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (!result) return reply.notFound('Сотрудник или торговая точка не найдены.');
|
if (!result) return reply.notFound('Сотрудник или торговая точка не найдены.');
|
||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
api.get<{ Querystring: PeriodQuery }>('/payroll', {
|
api.get<{ Querystring: PeriodQuery }>(
|
||||||
|
'/payroll',
|
||||||
|
{
|
||||||
schema: { querystring: periodSchema },
|
schema: { querystring: periodSchema },
|
||||||
}, async (request, reply) => {
|
},
|
||||||
|
async (request, reply) => {
|
||||||
const context = await auth.resolve(request);
|
const context = await auth.resolve(request);
|
||||||
const canManage = context.permissions.has('staff:payroll:read');
|
const canManage = context.permissions.has('staff:payroll:read');
|
||||||
if (!canManage && !context.permissions.has('staff:payslip:read_own')) {
|
if (!canManage && !context.permissions.has('staff:payslip:read_own')) {
|
||||||
|
|
@ -443,7 +509,8 @@ export async function registerOperationsRoutes(
|
||||||
);
|
);
|
||||||
return { period, totals: totals.rows, entries: entries.rows };
|
return { period, totals: totals.rows, entries: entries.rows };
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
api.get<{ Params: { employeeId: string } }>(
|
api.get<{ Params: { employeeId: string } }>(
|
||||||
'/employees/:employeeId/pay-assignment',
|
'/employees/:employeeId/pay-assignment',
|
||||||
|
|
@ -474,7 +541,8 @@ export async function registerOperationsRoutes(
|
||||||
`SELECT id, core_user_id FROM staff_employees WHERE tenant_id = $1 AND id = $2`,
|
`SELECT id, core_user_id FROM staff_employees WHERE tenant_id = $1 AND id = $2`,
|
||||||
[context.tenantId, employeeId],
|
[context.tenantId, employeeId],
|
||||||
);
|
);
|
||||||
if (empQuery.rowCount === 0 || !empQuery.rows[0]) return reply.notFound('Сотрудник не найден.');
|
if (empQuery.rowCount === 0 || !empQuery.rows[0])
|
||||||
|
return reply.notFound('Сотрудник не найден.');
|
||||||
const emp = empQuery.rows[0];
|
const emp = empQuery.rows[0];
|
||||||
if (!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId) {
|
if (!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId) {
|
||||||
return reply.forbidden('Доступ запрещён.');
|
return reply.forbidden('Доступ запрещён.');
|
||||||
|
|
@ -503,7 +571,9 @@ export async function registerOperationsRoutes(
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['pay_rule_id'],
|
required: ['pay_rule_id'],
|
||||||
properties: {
|
properties: {
|
||||||
org_unit_id: { anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }] },
|
org_unit_id: {
|
||||||
|
anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }],
|
||||||
|
},
|
||||||
pay_rule_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
pay_rule_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
rate_minor: { type: 'integer', minimum: 0 },
|
rate_minor: { type: 'integer', minimum: 0 },
|
||||||
percent_bp: { type: 'integer', minimum: 0, maximum: 10000 },
|
percent_bp: { type: 'integer', minimum: 0, maximum: 10000 },
|
||||||
|
|
@ -521,7 +591,10 @@ export async function registerOperationsRoutes(
|
||||||
const employeeId = parseUlid(request.params.employeeId);
|
const employeeId = parseUlid(request.params.employeeId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const saved = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
const saved = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
await ensureDefaultPayRules(client, context.tenantId, context.actorId);
|
await ensureDefaultPayRules(client, context.tenantId, context.actorId);
|
||||||
return saveEmployeePayAssignment(
|
return saveEmployeePayAssignment(
|
||||||
client,
|
client,
|
||||||
|
|
@ -531,10 +604,12 @@ export async function registerOperationsRoutes(
|
||||||
employeeId,
|
employeeId,
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return reply.send(saved);
|
return reply.send(saved);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Ошибка сохранения зарплатного профиля';
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Ошибка сохранения зарплатного профиля';
|
||||||
if (message.includes('not found') || message.includes('Employee not found')) {
|
if (message.includes('not found') || message.includes('Employee not found')) {
|
||||||
return reply.notFound(message);
|
return reply.notFound(message);
|
||||||
}
|
}
|
||||||
|
|
@ -595,7 +670,11 @@ export async function registerOperationsRoutes(
|
||||||
const lastDay = new Date(Date.UTC(year, monthNum, 0)).getUTCDate();
|
const lastDay = new Date(Date.UTC(year, monthNum, 0)).getUTCDate();
|
||||||
to = `${request.body.month}-${String(lastDay).padStart(2, '0')}`;
|
to = `${request.body.month}-${String(lastDay).padStart(2, '0')}`;
|
||||||
} else if (request.body.from && request.body.to) {
|
} else if (request.body.from && request.body.to) {
|
||||||
if (!parseDate(request.body.from) || !parseDate(request.body.to) || request.body.from > request.body.to) {
|
if (
|
||||||
|
!parseDate(request.body.from) ||
|
||||||
|
!parseDate(request.body.to) ||
|
||||||
|
request.body.from > request.body.to
|
||||||
|
) {
|
||||||
return reply.badRequest('Некорректный период расчёта.');
|
return reply.badRequest('Некорректный период расчёта.');
|
||||||
}
|
}
|
||||||
from = request.body.from;
|
from = request.body.from;
|
||||||
|
|
@ -605,13 +684,17 @@ export async function registerOperationsRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
const result = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
return calculatePayrollPeriod(client, context, {
|
return calculatePayrollPeriod(client, context, {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
...(request.body.org_unit_id ? { orgUnitId: request.body.org_unit_id } : {}),
|
...(request.body.org_unit_id ? { orgUnitId: request.body.org_unit_id } : {}),
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 409) {
|
if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 409) {
|
||||||
|
|
@ -642,9 +725,13 @@ export async function registerOperationsRoutes(
|
||||||
const runId = parseUlid(request.params.runId);
|
const runId = parseUlid(request.params.runId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
const result = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
return approvePayrollRun(client, context, runId);
|
return approvePayrollRun(client, context, runId);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err && typeof err === 'object' && 'statusCode' in err) {
|
if (err && typeof err === 'object' && 'statusCode' in err) {
|
||||||
|
|
@ -686,10 +773,14 @@ export async function registerOperationsRoutes(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
api.post<{ Body: OwnerWorkbookImportBody }>('/imports/owner-workbook', {
|
api.post<{ Body: OwnerWorkbookImportBody }>(
|
||||||
|
'/imports/owner-workbook',
|
||||||
|
{
|
||||||
schema: {
|
schema: {
|
||||||
body: {
|
body: {
|
||||||
type: 'object', additionalProperties: false, required: ['source_name'],
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['source_name'],
|
||||||
properties: {
|
properties: {
|
||||||
source_name: { type: 'string', minLength: 1, maxLength: 240 },
|
source_name: { type: 'string', minLength: 1, maxLength: 240 },
|
||||||
units: { type: 'array', maxItems: 50, items: { type: 'object' } },
|
units: { type: 'array', maxItems: 50, items: { type: 'object' } },
|
||||||
|
|
@ -700,12 +791,29 @@ export async function registerOperationsRoutes(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
},
|
||||||
|
async (request, reply) => {
|
||||||
auth.assertMutationOrigin(request);
|
auth.assertMutationOrigin(request);
|
||||||
const context = await auth.require(request, 'staff:metric:write');
|
|
||||||
const body = request.body;
|
const body = request.body;
|
||||||
if (!validateImport(body)) return reply.badRequest('Пакет импорта содержит некорректные данные.');
|
if (!validateImport(body))
|
||||||
const counts = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
return reply.badRequest('Пакет импорта содержит некорректные данные.');
|
||||||
|
const requiredPermissions = new Set<string>();
|
||||||
|
if ((body.units?.length ?? 0) > 0) requiredPermissions.add('staff:org_unit:write');
|
||||||
|
if ((body.employees?.length ?? 0) > 0) requiredPermissions.add('staff:employee:write');
|
||||||
|
if ((body.financial_entries?.length ?? 0) > 0) requiredPermissions.add('staff:metric:write');
|
||||||
|
if ((body.schedule_items?.length ?? 0) > 0) requiredPermissions.add('staff:shift:confirm');
|
||||||
|
if ((body.payroll_entries?.length ?? 0) > 0) requiredPermissions.add('staff:payout:write');
|
||||||
|
if (requiredPermissions.size === 0) {
|
||||||
|
return reply.badRequest('Пакет импорта не содержит данных.');
|
||||||
|
}
|
||||||
|
let context = await auth.resolve(request);
|
||||||
|
for (const permission of requiredPermissions) {
|
||||||
|
context = await auth.require(request, permission);
|
||||||
|
}
|
||||||
|
const counts = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
await ensureMetricDefinitions(client, context.tenantId);
|
await ensureMetricDefinitions(client, context.tenantId);
|
||||||
const unitIds = new Map<string, string>();
|
const unitIds = new Map<string, string>();
|
||||||
for (const unit of body.units ?? []) {
|
for (const unit of body.units ?? []) {
|
||||||
|
|
@ -738,18 +846,64 @@ export async function registerOperationsRoutes(
|
||||||
|
|
||||||
const employeeIds = new Map<string, string>();
|
const employeeIds = new Map<string, string>();
|
||||||
for (const employee of body.employees ?? []) {
|
for (const employee of body.employees ?? []) {
|
||||||
const saved = await client.query(
|
let professionId: string | null = null;
|
||||||
|
const professionName = employee.position?.trim();
|
||||||
|
if (professionName) {
|
||||||
|
const profession = await client.query(
|
||||||
|
`INSERT INTO staff_professions (id, tenant_id, code, name)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (tenant_id, name) DO UPDATE SET updated_at = now()
|
||||||
|
RETURNING id`,
|
||||||
|
[newUlid(), context.tenantId, `import-${newUlid().toLowerCase()}`, professionName],
|
||||||
|
);
|
||||||
|
professionId =
|
||||||
|
String((profession.rows[0] as { id?: string } | undefined)?.id ?? '') || null;
|
||||||
|
}
|
||||||
|
const existing = await client.query<{ id: string }>(
|
||||||
|
`SELECT id FROM staff_employees
|
||||||
|
WHERE tenant_id = $1 AND (
|
||||||
|
external_ref = $2 OR
|
||||||
|
($2 LIKE 'xlsx:staff:%' AND external_ref LIKE 'xlsx:staff:%'
|
||||||
|
AND lower(display_name) = lower($3))
|
||||||
|
)
|
||||||
|
ORDER BY (external_ref = $2) DESC LIMIT 1 FOR UPDATE`,
|
||||||
|
[context.tenantId, employee.external_ref, employee.display_name],
|
||||||
|
);
|
||||||
|
const saved = existing.rows[0]
|
||||||
|
? await client.query(
|
||||||
|
`UPDATE staff_employees SET
|
||||||
|
display_name = $3, position = $4, profession_id = $5,
|
||||||
|
external_ref = $6, updated_at = now()
|
||||||
|
WHERE tenant_id = $1 AND id = $2 RETURNING id`,
|
||||||
|
[
|
||||||
|
context.tenantId,
|
||||||
|
existing.rows[0].id,
|
||||||
|
employee.display_name,
|
||||||
|
professionName ?? '',
|
||||||
|
professionId,
|
||||||
|
employee.external_ref,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: await client.query(
|
||||||
`INSERT INTO staff_employees
|
`INSERT INTO staff_employees
|
||||||
(id, tenant_id, personnel_number, display_name, position, status, external_ref)
|
(id, tenant_id, personnel_number, display_name, position, profession_id, status, external_ref)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'active', $6)
|
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7)
|
||||||
ON CONFLICT (tenant_id, personnel_number)
|
ON CONFLICT (tenant_id, personnel_number)
|
||||||
DO UPDATE SET external_ref = COALESCE(staff_employees.external_ref, EXCLUDED.external_ref),
|
DO UPDATE SET external_ref = EXCLUDED.external_ref,
|
||||||
position = CASE WHEN staff_employees.position = '' THEN EXCLUDED.position
|
display_name = EXCLUDED.display_name,
|
||||||
ELSE staff_employees.position END,
|
position = EXCLUDED.position,
|
||||||
|
profession_id = EXCLUDED.profession_id,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[newUlid(), context.tenantId, employee.personnel_number,
|
[
|
||||||
employee.display_name, employee.position?.trim() ?? '', employee.external_ref],
|
newUlid(),
|
||||||
|
context.tenantId,
|
||||||
|
employee.personnel_number,
|
||||||
|
employee.display_name,
|
||||||
|
professionName ?? '',
|
||||||
|
professionId,
|
||||||
|
employee.external_ref,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const savedRow = saved.rows[0] as { id?: string } | undefined;
|
const savedRow = saved.rows[0] as { id?: string } | undefined;
|
||||||
employeeIds.set(employee.external_ref, String(savedRow?.id));
|
employeeIds.set(employee.external_ref, String(savedRow?.id));
|
||||||
|
|
@ -780,9 +934,17 @@ export async function registerOperationsRoutes(
|
||||||
DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code,
|
DO UPDATE SET value = EXCLUDED.value, currency_code = EXCLUDED.currency_code,
|
||||||
breakdown = EXCLUDED.breakdown, source = 'import',
|
breakdown = EXCLUDED.breakdown, source = 'import',
|
||||||
recorded_by = EXCLUDED.recorded_by, updated_at = now()`,
|
recorded_by = EXCLUDED.recorded_by, updated_at = now()`,
|
||||||
[newUlid(), context.tenantId, orgUnitId, entry.business_date, entry.metric_key,
|
[
|
||||||
entry.value_minor, entry.currency_code ?? 'RUB',
|
newUlid(),
|
||||||
{ source_name: body.source_name }, context.actorId],
|
context.tenantId,
|
||||||
|
orgUnitId,
|
||||||
|
entry.business_date,
|
||||||
|
entry.metric_key,
|
||||||
|
entry.value_minor,
|
||||||
|
entry.currency_code ?? 'RUB',
|
||||||
|
{ source_name: body.source_name },
|
||||||
|
context.actorId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
if (entry.metric_key.startsWith('income_')) {
|
if (entry.metric_key.startsWith('income_')) {
|
||||||
affectedRevenueDays.add(`${orgUnitId}|${entry.business_date}`);
|
affectedRevenueDays.add(`${orgUnitId}|${entry.business_date}`);
|
||||||
|
|
@ -790,8 +952,13 @@ export async function registerOperationsRoutes(
|
||||||
}
|
}
|
||||||
for (const key of affectedRevenueDays) {
|
for (const key of affectedRevenueDays) {
|
||||||
const [orgUnitId, businessDate] = key.split('|') as [string, string];
|
const [orgUnitId, businessDate] = key.split('|') as [string, string];
|
||||||
await refreshRevenueMetric(client, context.tenantId, context.actorId,
|
await refreshRevenueMetric(
|
||||||
orgUnitId, businessDate);
|
client,
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
orgUnitId,
|
||||||
|
businessDate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const shift of body.schedule_items ?? []) {
|
for (const shift of body.schedule_items ?? []) {
|
||||||
|
|
@ -800,6 +967,30 @@ export async function registerOperationsRoutes(
|
||||||
location_code: shift.location_code ?? '',
|
location_code: shift.location_code ?? '',
|
||||||
imported_from: body.source_name,
|
imported_from: body.source_name,
|
||||||
};
|
};
|
||||||
|
const employeeId = employeeIds.get(shift.employee_external_ref);
|
||||||
|
const existingShift = await client.query<{ id: string }>(
|
||||||
|
`SELECT id FROM staff_work_items
|
||||||
|
WHERE tenant_id = $1 AND employee_id = $2 AND business_date = $3
|
||||||
|
AND item_kind = 'planned_shift' AND source = 'import'
|
||||||
|
AND (external_key = $4 OR external_key LIKE 'xlsx:schedule:%')
|
||||||
|
ORDER BY (external_key = $4) DESC LIMIT 1 FOR UPDATE`,
|
||||||
|
[context.tenantId, employeeId, shift.business_date, shift.external_key],
|
||||||
|
);
|
||||||
|
if (existingShift.rows[0]) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_work_items SET org_unit_id = $3, attributes = $4,
|
||||||
|
external_key = $5
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[
|
||||||
|
context.tenantId,
|
||||||
|
existingShift.rows[0].id,
|
||||||
|
unitIds.get(shift.org_unit_code),
|
||||||
|
attributes,
|
||||||
|
shift.external_key,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_work_items
|
`INSERT INTO staff_work_items
|
||||||
(id, tenant_id, employee_id, org_unit_id, business_date, item_kind,
|
(id, tenant_id, employee_id, org_unit_id, business_date, item_kind,
|
||||||
|
|
@ -810,26 +1001,53 @@ export async function registerOperationsRoutes(
|
||||||
org_unit_id = EXCLUDED.org_unit_id,
|
org_unit_id = EXCLUDED.org_unit_id,
|
||||||
business_date = EXCLUDED.business_date,
|
business_date = EXCLUDED.business_date,
|
||||||
attributes = EXCLUDED.attributes, source = 'import'`,
|
attributes = EXCLUDED.attributes, source = 'import'`,
|
||||||
[newUlid(), context.tenantId, employeeIds.get(shift.employee_external_ref),
|
[
|
||||||
unitIds.get(shift.org_unit_code), shift.business_date, attributes,
|
newUlid(),
|
||||||
shift.external_key, context.actorId],
|
context.tenantId,
|
||||||
|
employeeId,
|
||||||
|
unitIds.get(shift.org_unit_code),
|
||||||
|
shift.business_date,
|
||||||
|
attributes,
|
||||||
|
shift.external_key,
|
||||||
|
context.actorId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of body.payroll_entries ?? []) {
|
for (const entry of body.payroll_entries ?? []) {
|
||||||
const amount = entry.kind === 'payout'
|
const desiredAmount =
|
||||||
? -Math.abs(entry.amount_minor)
|
entry.kind === 'payout' ? -Math.abs(entry.amount_minor) : entry.amount_minor;
|
||||||
: entry.amount_minor;
|
const employeeId = employeeIds.get(entry.employee_external_ref);
|
||||||
|
const legacyPrefix = `xlsx:${entry.kind}:%`;
|
||||||
|
const currentPrefix = `xlsx:payroll:${entry.kind}:%`;
|
||||||
|
const existing = await client.query<{ amount_minor: string }>(
|
||||||
|
`SELECT COALESCE(sum(amount_minor), 0)::text AS amount_minor
|
||||||
|
FROM staff_payroll_ledger
|
||||||
|
WHERE tenant_id = $1 AND employee_id = $2 AND business_date = $3
|
||||||
|
AND (external_key LIKE $4 OR external_key LIKE $5)`,
|
||||||
|
[context.tenantId, employeeId, entry.business_date, legacyPrefix, currentPrefix],
|
||||||
|
);
|
||||||
|
const previousAmount = Number(existing.rows[0]?.amount_minor ?? 0);
|
||||||
|
const delta = desiredAmount - previousAmount;
|
||||||
|
if (delta === 0) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_payroll_ledger
|
`INSERT INTO staff_payroll_ledger
|
||||||
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
||||||
currency_code, ref_type, correlation_id, note, created_by, external_key)
|
currency_code, ref_type, correlation_id, note, created_by, external_key)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'RUB', 'manual', $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, 'RUB', 'manual', $7, $8, $9, $10)
|
||||||
ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING`,
|
ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING`,
|
||||||
[newUlid(), context.tenantId, employeeIds.get(entry.employee_external_ref),
|
[
|
||||||
entry.business_date, entry.kind, amount, context.correlationId,
|
newUlid(),
|
||||||
|
context.tenantId,
|
||||||
|
employeeId,
|
||||||
|
entry.business_date,
|
||||||
|
previousAmount === 0 ? entry.kind : 'adjustment',
|
||||||
|
delta,
|
||||||
|
context.correlationId,
|
||||||
entry.note?.trim() ?? `Импорт: ${body.source_name}`,
|
entry.note?.trim() ?? `Импорт: ${body.source_name}`,
|
||||||
context.actorId, entry.external_key],
|
context.actorId,
|
||||||
|
entry.external_key,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const imported = {
|
const imported = {
|
||||||
|
|
@ -839,12 +1057,16 @@ export async function registerOperationsRoutes(
|
||||||
schedule_items: body.schedule_items?.length ?? 0,
|
schedule_items: body.schedule_items?.length ?? 0,
|
||||||
payroll_entries: body.payroll_entries?.length ?? 0,
|
payroll_entries: body.payroll_entries?.length ?? 0,
|
||||||
};
|
};
|
||||||
await writeAudit(client, context, 'owner_workbook.imported', 'ImportBatch',
|
await writeAudit(client, context, 'owner_workbook.imported', 'ImportBatch', newUlid(), {
|
||||||
newUlid(), { source_name: body.source_name, ...imported });
|
source_name: body.source_name,
|
||||||
|
...imported,
|
||||||
|
});
|
||||||
return imported;
|
return imported;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return reply.code(202).send(counts);
|
return reply.code(202).send(counts);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const periodSchema = {
|
const periodSchema = {
|
||||||
|
|
@ -930,8 +1152,16 @@ async function writeAudit(
|
||||||
`INSERT INTO staff_audit_log
|
`INSERT INTO staff_audit_log
|
||||||
(id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id)
|
(id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
[newUlid(), context.tenantId, context.actorId, action, entityType,
|
[
|
||||||
entityId, values, context.correlationId],
|
newUlid(),
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
values,
|
||||||
|
context.correlationId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -941,24 +1171,40 @@ function validateImport(body: OwnerWorkbookImportBody): boolean {
|
||||||
if (!/^[a-z0-9_-]{1,40}$/.test(unit.code) || !unit.name.trim()) return false;
|
if (!/^[a-z0-9_-]{1,40}$/.test(unit.code) || !unit.name.trim()) return false;
|
||||||
}
|
}
|
||||||
for (const entry of body.financial_entries ?? []) {
|
for (const entry of body.financial_entries ?? []) {
|
||||||
if (!financialMetricKeys.has(entry.metric_key)
|
if (
|
||||||
|| !parseDate(entry.business_date)
|
!financialMetricKeys.has(entry.metric_key) ||
|
||||||
|| !Number.isSafeInteger(entry.value_minor)
|
!parseDate(entry.business_date) ||
|
||||||
|| Math.abs(entry.value_minor) > 9_000_000_000_000_000) return false;
|
!Number.isSafeInteger(entry.value_minor) ||
|
||||||
|
Math.abs(entry.value_minor) > 9_000_000_000_000_000
|
||||||
|
)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
for (const employee of body.employees ?? []) {
|
for (const employee of body.employees ?? []) {
|
||||||
if (!employee.external_ref || !employee.personnel_number.trim() || !employee.display_name.trim()) {
|
if (
|
||||||
|
!employee.external_ref ||
|
||||||
|
!employee.personnel_number.trim() ||
|
||||||
|
!employee.display_name.trim()
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const shift of body.schedule_items ?? []) {
|
for (const shift of body.schedule_items ?? []) {
|
||||||
if (!shift.external_key || !parseDate(shift.business_date)
|
if (
|
||||||
|| !Number.isInteger(shift.planned_minutes)
|
!shift.external_key ||
|
||||||
|| shift.planned_minutes < 0 || shift.planned_minutes > 1440) return false;
|
!parseDate(shift.business_date) ||
|
||||||
|
!Number.isInteger(shift.planned_minutes) ||
|
||||||
|
shift.planned_minutes < 0 ||
|
||||||
|
shift.planned_minutes > 1440
|
||||||
|
)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
for (const entry of body.payroll_entries ?? []) {
|
for (const entry of body.payroll_entries ?? []) {
|
||||||
if (!entry.external_key || !parseDate(entry.business_date)
|
if (
|
||||||
|| !Number.isSafeInteger(entry.amount_minor)) return false;
|
!entry.external_key ||
|
||||||
|
!parseDate(entry.business_date) ||
|
||||||
|
!Number.isSafeInteger(entry.amount_minor)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
660
src/portal.ts
660
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,
|
id: employeeId,
|
||||||
personnel_number: 'A-17',
|
personnel_number: 'A-17',
|
||||||
display_name: 'Анна Орлова',
|
display_name: 'Анна Орлова',
|
||||||
position: 'Администратор',
|
position: 'Администратор',
|
||||||
gender: 'female',
|
gender: 'female',
|
||||||
status: 'active',
|
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,
|
|
||||||
tenantId,
|
|
||||||
actorId,
|
|
||||||
corrId,
|
|
||||||
empId,
|
|
||||||
{
|
|
||||||
pay_rule_id: ruleId,
|
pay_rule_id: ruleId,
|
||||||
rate_minor: 12000,
|
rate_minor: 12000,
|
||||||
note: 'Главный повар',
|
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