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;
|
||||
const row = result.rows[0];
|
||||
|
||||
const rawParams = (typeof row.params === 'string'
|
||||
? (JSON.parse(row.params) as Record<string, unknown>)
|
||||
: row.params) ?? {};
|
||||
const rawParams =
|
||||
(typeof row.params === 'string'
|
||||
? (JSON.parse(row.params) as Record<string, unknown>)
|
||||
: row.params) ?? {};
|
||||
|
||||
const params: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(rawParams)) {
|
||||
|
|
@ -289,8 +290,7 @@ export async function saveEmployeePayAssignment(
|
|||
const defaultRuleWeight = Number(ruleCheck.rows[0].pool_weight_bp ?? 0);
|
||||
|
||||
const effectiveFrom = data.effective_from ?? new Date().toISOString().slice(0, 10);
|
||||
const poolWeightBp =
|
||||
data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
|
||||
const poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
|
||||
|
||||
const params: Record<string, number> = {
|
||||
rate_minor: data.rate_minor ?? 10000,
|
||||
|
|
@ -298,12 +298,45 @@ export async function saveEmployeePayAssignment(
|
|||
guarantee_minor: data.guarantee_minor ?? 0,
|
||||
};
|
||||
|
||||
await client.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended(
|
||||
scope.tenant_id || ':' || $2 || ':' || COALESCE($3::text, 'all-units'), 0))
|
||||
FROM (SELECT $1::text AS tenant_id) AS scope`,
|
||||
[tenantId, employeeId, orgUnitId],
|
||||
);
|
||||
|
||||
const existingAssignment = await client.query<{ id: string }>(
|
||||
`SELECT id FROM staff_pay_assignments
|
||||
WHERE tenant_id = $1 AND employee_id = $2 AND effective_from = $3
|
||||
AND org_unit_id IS NOT DISTINCT FROM $4::varchar(26)
|
||||
LIMIT 1`,
|
||||
[tenantId, employeeId, effectiveFrom],
|
||||
[tenantId, employeeId, effectiveFrom, orgUnitId],
|
||||
);
|
||||
const nextAssignment = await client.query<{ effective_from: string }>(
|
||||
`SELECT effective_from::text AS effective_from
|
||||
FROM staff_pay_assignments
|
||||
WHERE tenant_id = $1 AND employee_id = $2 AND effective_from > $3
|
||||
AND org_unit_id IS NOT DISTINCT FROM $4::varchar(26)
|
||||
ORDER BY effective_from ASC LIMIT 1`,
|
||||
[tenantId, employeeId, effectiveFrom, orgUnitId],
|
||||
);
|
||||
const nextEffectiveFrom = nextAssignment.rows[0]?.effective_from;
|
||||
await client.query(
|
||||
`UPDATE staff_pay_assignments
|
||||
SET effective_to = ($3::date - INTERVAL '1 day')::date
|
||||
WHERE tenant_id = $1 AND employee_id = $2 AND effective_from < $3
|
||||
AND org_unit_id IS NOT DISTINCT FROM $4::varchar(26)
|
||||
AND (effective_to IS NULL OR effective_to >= $3)`,
|
||||
[tenantId, employeeId, effectiveFrom, orgUnitId],
|
||||
);
|
||||
const effectiveTo = nextEffectiveFrom
|
||||
? new Date(`${nextEffectiveFrom}T00:00:00.000Z`).toISOString().slice(0, 10)
|
||||
: null;
|
||||
const boundedEffectiveTo = effectiveTo
|
||||
? new Date(new Date(`${effectiveTo}T00:00:00.000Z`).valueOf() - 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
: null;
|
||||
|
||||
let assignmentId: string;
|
||||
if ((existingAssignment.rowCount ?? 0) > 0 && existingAssignment.rows[0]) {
|
||||
|
|
@ -314,16 +347,27 @@ export async function saveEmployeePayAssignment(
|
|||
pay_rule_id = $4,
|
||||
params = $5,
|
||||
pool_weight_bp = $6,
|
||||
note = $7
|
||||
note = $7,
|
||||
effective_to = $8
|
||||
WHERE tenant_id = $1 AND id = $2`,
|
||||
[tenantId, assignmentId, orgUnitId, payRuleId, JSON.stringify(params), poolWeightBp, data.note ?? ''],
|
||||
[
|
||||
tenantId,
|
||||
assignmentId,
|
||||
orgUnitId,
|
||||
payRuleId,
|
||||
JSON.stringify(params),
|
||||
poolWeightBp,
|
||||
data.note ?? '',
|
||||
boundedEffectiveTo,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
assignmentId = newUlid();
|
||||
await client.query(
|
||||
`INSERT INTO staff_pay_assignments
|
||||
(id, tenant_id, employee_id, org_unit_id, pay_rule_id, effective_from, params, pool_weight_bp, note, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
(id, tenant_id, employee_id, org_unit_id, pay_rule_id, effective_from,
|
||||
effective_to, params, pool_weight_bp, note, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
assignmentId,
|
||||
tenantId,
|
||||
|
|
@ -331,6 +375,7 @@ export async function saveEmployeePayAssignment(
|
|||
orgUnitId,
|
||||
payRuleId,
|
||||
effectiveFrom,
|
||||
boundedEffectiveTo,
|
||||
JSON.stringify(params),
|
||||
poolWeightBp,
|
||||
data.note ?? '',
|
||||
|
|
@ -348,7 +393,13 @@ export async function saveEmployeePayAssignment(
|
|||
tenantId,
|
||||
actorId,
|
||||
assignmentId,
|
||||
{ employee_id: employeeId, org_unit_id: orgUnitId, pay_rule_id: payRuleId, params, pool_weight_bp: poolWeightBp },
|
||||
{
|
||||
employee_id: employeeId,
|
||||
org_unit_id: orgUnitId,
|
||||
pay_rule_id: payRuleId,
|
||||
params,
|
||||
pool_weight_bp: poolWeightBp,
|
||||
},
|
||||
correlationId,
|
||||
],
|
||||
);
|
||||
|
|
@ -532,7 +583,16 @@ export async function calculatePayrollPeriod(
|
|||
}
|
||||
|
||||
const allLines: ProcessedLine[] = [];
|
||||
const processedEmployees = new Map<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
|
||||
for (const session of sessions) {
|
||||
|
|
@ -585,9 +645,10 @@ export async function calculatePayrollPeriod(
|
|||
? (JSON.parse(assignment.rule) as RuleNode)
|
||||
: assignment.rule;
|
||||
|
||||
const rawParams = (typeof assignment.params === 'string'
|
||||
? (JSON.parse(assignment.params) as Record<string, unknown>)
|
||||
: assignment.params) ?? {};
|
||||
const rawParams =
|
||||
(typeof assignment.params === 'string'
|
||||
? (JSON.parse(assignment.params) as Record<string, unknown>)
|
||||
: assignment.params) ?? {};
|
||||
|
||||
const poolWeight =
|
||||
assignment.pool_weight_bp !== null && assignment.pool_weight_bp !== undefined
|
||||
|
|
@ -731,19 +792,26 @@ export async function calculatePayrollPeriod(
|
|||
tenantId,
|
||||
actorId,
|
||||
runId,
|
||||
{ period_start: from, period_end: to, total_accrued_minor: totalAccruedMinor.toString(), lines: allLines.length },
|
||||
{
|
||||
period_start: from,
|
||||
period_end: to,
|
||||
total_accrued_minor: totalAccruedMinor.toString(),
|
||||
lines: allLines.length,
|
||||
},
|
||||
correlationId,
|
||||
],
|
||||
);
|
||||
|
||||
const totals: EmployeeRunTotal[] = [...processedEmployees.entries()].map(([empId, data]) => ({
|
||||
employee_id: empId,
|
||||
display_name: data.display_name,
|
||||
position: data.position,
|
||||
worked_minutes: data.worked_minutes,
|
||||
days: data.days,
|
||||
accrued_minor: data.accrued_minor.toString(),
|
||||
})).sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
const totals: EmployeeRunTotal[] = [...processedEmployees.entries()]
|
||||
.map(([empId, data]) => ({
|
||||
employee_id: empId,
|
||||
display_name: data.display_name,
|
||||
position: data.position,
|
||||
worked_minutes: data.worked_minutes,
|
||||
days: data.days,
|
||||
accrued_minor: data.accrued_minor.toString(),
|
||||
}))
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
|
||||
return {
|
||||
run_id: runId,
|
||||
|
|
@ -857,7 +925,11 @@ export async function approvePayrollRun(
|
|||
tenantId,
|
||||
runId,
|
||||
actorId,
|
||||
{ period_start: run.period_start, period_end: run.period_end, total_accrued_minor: run.total_accrued_minor },
|
||||
{
|
||||
period_start: run.period_start,
|
||||
period_end: run.period_end,
|
||||
total_accrued_minor: run.total_accrued_minor,
|
||||
},
|
||||
correlationId,
|
||||
],
|
||||
);
|
||||
|
|
@ -871,7 +943,11 @@ export async function approvePayrollRun(
|
|||
tenantId,
|
||||
actorId,
|
||||
runId,
|
||||
{ period_start: run.period_start, period_end: run.period_end, total_accrued_minor: run.total_accrued_minor },
|
||||
{
|
||||
period_start: run.period_start,
|
||||
period_end: run.period_end,
|
||||
total_accrued_minor: run.total_accrued_minor,
|
||||
},
|
||||
correlationId,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
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 () => {
|
||||
const { database, calls } = fakeDatabase([[], [], []]);
|
||||
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database });
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }),
|
||||
database,
|
||||
});
|
||||
const response = await app.inject('/api/v1/staff/portal');
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json<{ viewer: { role: string } }>();
|
||||
expect(body.viewer.role).toBe('employee');
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID)).toBe(true);
|
||||
expect(
|
||||
calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID),
|
||||
).toBe(true);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('writes a terminal event and its outbox notification atomically', async () => {
|
||||
const eventId = '01J00000000000000000000002';
|
||||
const employeeId = '01J00000000000000000000003';
|
||||
const { database, calls } = fakeDatabase([[], [{ id: eventId }], []]);
|
||||
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), database });
|
||||
const { database, calls } = fakeDatabase([[{ id: employeeId }], [{ id: eventId }], []]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }),
|
||||
database,
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/staff/attendance/events',
|
||||
payload: { event_id: eventId, employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', source_event_id: eventId },
|
||||
payload: {
|
||||
event_id: eventId,
|
||||
employee_id: employeeId,
|
||||
event_type: 'check_in',
|
||||
occurred_at: '2026-09-24T01:00:00.000Z',
|
||||
source_event_id: eventId,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0]?.[0]).toContain('staff_employees');
|
||||
expect(calls[0]?.[0]).toContain("status = 'active'");
|
||||
expect(calls[2]?.[0]).toContain('staff_outbox');
|
||||
expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects an unknown terminal employee without writing attendance or outbox', async () => {
|
||||
const eventId = '01J00000000000000000000002';
|
||||
const employeeId = '01J00000000000000000000003';
|
||||
const { database, calls } = fakeDatabase([[]]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }),
|
||||
database,
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/staff/attendance/events',
|
||||
payload: {
|
||||
event_id: eventId,
|
||||
employee_id: employeeId,
|
||||
event_type: 'check_in',
|
||||
occurred_at: '2026-09-24T01:00:00.000Z',
|
||||
source_event_id: eventId,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.[0]).toContain("status = 'active'");
|
||||
expect(calls[0]?.[0]).not.toContain('INSERT INTO staff_employees');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('does not give an employee manager access to the timesheet', async () => {
|
||||
const { database } = fakeDatabase([]);
|
||||
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database });
|
||||
const response = await app.inject({ method: 'PUT', url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24', payload: { minutes_worked: 480, status: 'approved' } });
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }),
|
||||
database,
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24',
|
||||
payload: { minutes_worked: 480, status: 'approved' },
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('allows a manager to create an employee and writes an audit record', async () => {
|
||||
const employeeId = '01J00000000000000000000004';
|
||||
const { database, calls } = fakeDatabase([[{
|
||||
id: employeeId,
|
||||
personnel_number: 'A-17',
|
||||
display_name: 'Анна Орлова',
|
||||
position: 'Администратор',
|
||||
gender: 'female',
|
||||
status: 'active',
|
||||
}]]);
|
||||
const { database, calls } = fakeDatabase([
|
||||
[
|
||||
{
|
||||
id: employeeId,
|
||||
personnel_number: 'A-17',
|
||||
display_name: 'Анна Орлова',
|
||||
position: 'Администратор',
|
||||
gender: 'female',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||
database,
|
||||
|
|
@ -185,9 +236,17 @@ describe('attendance portal', () => {
|
|||
|
||||
it('updates an employee with before and after values in the audit trail', async () => {
|
||||
const employeeId = '01J00000000000000000000004';
|
||||
const before = { id: employeeId, core_user_id: null, personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', status: 'active' };
|
||||
const before = {
|
||||
id: employeeId,
|
||||
core_user_id: null,
|
||||
personnel_number: 'A-17',
|
||||
display_name: 'Анна Орлова',
|
||||
position: 'Администратор',
|
||||
gender: 'female',
|
||||
status: 'active',
|
||||
};
|
||||
const after = { ...before, position: 'Старший администратор' };
|
||||
const { database, calls } = fakeDatabase([[before], [after], []]);
|
||||
const { database, calls } = fakeDatabase([[before], [], [after], []]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||
database,
|
||||
|
|
@ -199,10 +258,10 @@ describe('attendance portal', () => {
|
|||
payload: { position: 'Старший администратор' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(calls[0]?.[0]).toContain('FOR UPDATE');
|
||||
expect(calls[2]?.[1]?.[4]).toEqual(before);
|
||||
expect(calls[2]?.[1]?.[5]).toEqual(after);
|
||||
expect(calls[3]?.[1]?.[4]).toEqual(before);
|
||||
expect(calls[3]?.[1]?.[5]).toEqual(after);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
|
|
@ -211,10 +270,37 @@ describe('attendance portal', () => {
|
|||
const orgUnitId = '01J00000000000000000000005';
|
||||
const sessionId = '01J00000000000000000000006';
|
||||
const attendanceRows = [
|
||||
{ id: '01J00000000000000000000010', employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' },
|
||||
{ id: '01J00000000000000000000011', employee_id: employeeId, event_type: 'check_out', occurred_at: '2026-09-24T09:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' },
|
||||
{
|
||||
id: '01J00000000000000000000010',
|
||||
employee_id: employeeId,
|
||||
event_type: 'check_in',
|
||||
occurred_at: '2026-09-24T01:00:00.000Z',
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: '2026-09-24',
|
||||
automatic_close_at: '2026-09-24T19:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '01J00000000000000000000011',
|
||||
employee_id: employeeId,
|
||||
event_type: 'check_out',
|
||||
occurred_at: '2026-09-24T09:00:00.000Z',
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: '2026-09-24',
|
||||
automatic_close_at: '2026-09-24T19:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const session = { id: sessionId, employee_id: employeeId, org_unit_id: orgUnitId, business_date: '2026-09-24', worked_minutes: 480, status: 'pending_review', anomaly: null, source_event_ids: attendanceRows.map((row) => row.id), auto_closed: false, revision: 1 };
|
||||
const session = {
|
||||
id: sessionId,
|
||||
employee_id: employeeId,
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: '2026-09-24',
|
||||
worked_minutes: 480,
|
||||
status: 'pending_review',
|
||||
anomaly: null,
|
||||
source_event_ids: attendanceRows.map((row) => row.id),
|
||||
auto_closed: false,
|
||||
revision: 1,
|
||||
};
|
||||
const { database, calls } = fakeDatabase([attendanceRows, [session], []]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||
|
|
@ -227,7 +313,11 @@ describe('attendance portal', () => {
|
|||
payload: { date_from: '2026-09-24', date_to: '2026-09-24' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ sessions_created_or_updated: 1, anomalous_sessions: 0, unresolved_events: 0 });
|
||||
expect(response.json()).toMatchObject({
|
||||
sessions_created_or_updated: 1,
|
||||
anomalous_sessions: 0,
|
||||
unresolved_events: 0,
|
||||
});
|
||||
expect(calls[1]?.[0]).toContain('INSERT INTO staff_work_sessions');
|
||||
expect(calls[1]?.[1]?.[7]).toBe(480);
|
||||
await app.close();
|
||||
|
|
@ -235,7 +325,21 @@ describe('attendance portal', () => {
|
|||
|
||||
it('requires a reason when confirmed hours are corrected', async () => {
|
||||
const sessionId = '01J00000000000000000000006';
|
||||
const current = { id: sessionId, employee_id: '01J00000000000000000000004', org_unit_id: '01J00000000000000000000005', business_date: '2026-09-24', started_at: '2026-09-24T01:00:00.000Z', ended_at: '2026-09-24T09:00:00.000Z', worked_minutes: 480, status: 'pending_review', anomaly: null, correction_note: '', source_event_ids: [], auto_closed: false, revision: 1 };
|
||||
const current = {
|
||||
id: sessionId,
|
||||
employee_id: '01J00000000000000000000004',
|
||||
org_unit_id: '01J00000000000000000000005',
|
||||
business_date: '2026-09-24',
|
||||
started_at: '2026-09-24T01:00:00.000Z',
|
||||
ended_at: '2026-09-24T09:00:00.000Z',
|
||||
worked_minutes: 480,
|
||||
status: 'pending_review',
|
||||
anomaly: null,
|
||||
correction_note: '',
|
||||
source_event_ids: [],
|
||||
auto_closed: false,
|
||||
revision: 1,
|
||||
};
|
||||
const { database, calls } = fakeDatabase([[current]]);
|
||||
const app = await buildApp({
|
||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
const actorA = parseUlid('01J0000000000000000000001A');
|
||||
const actorB = parseUlid('01J0000000000000000000001B');
|
||||
const eventId = newUlid();
|
||||
const professionId = newUlid();
|
||||
|
||||
beforeAll(async () => {
|
||||
const role = await database.pool.query(
|
||||
|
|
@ -23,6 +24,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
afterAll(async () => {
|
||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||
await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]);
|
||||
await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]);
|
||||
});
|
||||
await database.close();
|
||||
});
|
||||
|
|
@ -47,6 +49,27 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
);
|
||||
expect(visibleToB.rowCount).toBe(0);
|
||||
|
||||
await database.tenantTransaction(tenantA, actorA, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_professions (id, tenant_id, code, name)
|
||||
VALUES ($1, $2, 'cook', 'Повар')`,
|
||||
[professionId, tenantA],
|
||||
),
|
||||
);
|
||||
const professionsVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query('SELECT id FROM staff_professions WHERE id = $1', [professionId]),
|
||||
);
|
||||
expect(professionsVisibleToB.rowCount).toBe(0);
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_professions (id, tenant_id, code, name)
|
||||
VALUES ($1, $2, 'foreign', 'Чужая профессия')`,
|
||||
[newUlid(), tenantA],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { describe, expect, it } from 'vitest';
|
|||
import type { RequestContext } from '../../src/core/context.js';
|
||||
import { newUlid } from '../../src/core/ids.js';
|
||||
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js';
|
||||
import { cateringPreset, defaultParams, findRoleTemplate } from '../../src/domain/payroll/presets.js';
|
||||
import {
|
||||
cateringPreset,
|
||||
defaultParams,
|
||||
findRoleTemplate,
|
||||
} from '../../src/domain/payroll/presets.js';
|
||||
import {
|
||||
approvePayrollRun,
|
||||
calculatePayrollPeriod,
|
||||
|
|
@ -218,7 +222,8 @@ describe('Payroll service calculation and warning engine', () => {
|
|||
return {
|
||||
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
|
||||
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
|
||||
const params = typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
||||
const params =
|
||||
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
||||
return Promise.resolve(handlers.query(sql, params));
|
||||
},
|
||||
release: () => {},
|
||||
|
|
@ -420,7 +425,8 @@ describe('Payroll approval and ledger idempotency', () => {
|
|||
return {
|
||||
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
|
||||
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
|
||||
const params = typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
||||
const params =
|
||||
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
||||
return Promise.resolve(handlers.query(sql, params));
|
||||
},
|
||||
release: () => {},
|
||||
|
|
@ -482,7 +488,9 @@ describe('Payroll approval and ledger idempotency', () => {
|
|||
expect(result.total_accrued_minor).toBe('1220000');
|
||||
|
||||
// Verify external_key structure and ON CONFLICT clause
|
||||
expect(ledgerInsertQuery).toContain('ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING');
|
||||
expect(ledgerInsertQuery).toContain(
|
||||
'ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING',
|
||||
);
|
||||
expect(ledgerInsertParams).toContain(`payroll_run:${runId}:${empId}`);
|
||||
});
|
||||
|
||||
|
|
@ -520,6 +528,7 @@ describe('Payroll approval and ledger idempotency', () => {
|
|||
describe('Pay assignment management', () => {
|
||||
it('saves and retrieves pay assignment with default fallbacks', async () => {
|
||||
let savedParamsJson = '';
|
||||
let closedPreviousVersion = false;
|
||||
const tenantId = newUlid();
|
||||
const actorId = newUlid();
|
||||
const corrId = newUlid();
|
||||
|
|
@ -530,7 +539,8 @@ describe('Pay assignment management', () => {
|
|||
const client = {
|
||||
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
|
||||
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
|
||||
const params = typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
||||
const params =
|
||||
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
|
||||
|
||||
if (sql.includes('FROM staff_employees')) {
|
||||
return Promise.resolve({ rows: [{ id: empId }], rowCount: 1 });
|
||||
|
|
@ -539,7 +549,12 @@ describe('Pay assignment management', () => {
|
|||
return Promise.resolve({ rows: [{ id: ruleId, pool_weight_bp: 10000 }], rowCount: 1 });
|
||||
}
|
||||
if (sql.includes('INSERT INTO staff_pay_assignments')) {
|
||||
savedParamsJson = String(params[6]);
|
||||
savedParamsJson = String(params[7]);
|
||||
return Promise.resolve({ rows: [], rowCount: 1 });
|
||||
}
|
||||
if (sql.includes('SET effective_to = ($3::date')) {
|
||||
closedPreviousVersion = true;
|
||||
expect(String(params[2])).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
return Promise.resolve({ rows: [], rowCount: 1 });
|
||||
}
|
||||
if (sql.includes('FROM staff_pay_assignments a')) {
|
||||
|
|
@ -568,23 +583,17 @@ describe('Pay assignment management', () => {
|
|||
release: () => {},
|
||||
} as unknown as PoolClient;
|
||||
|
||||
const saved = await saveEmployeePayAssignment(
|
||||
client,
|
||||
tenantId,
|
||||
actorId,
|
||||
corrId,
|
||||
empId,
|
||||
{
|
||||
pay_rule_id: ruleId,
|
||||
rate_minor: 12000,
|
||||
note: 'Главный повар',
|
||||
},
|
||||
);
|
||||
const saved = await saveEmployeePayAssignment(client, tenantId, actorId, corrId, empId, {
|
||||
pay_rule_id: ruleId,
|
||||
rate_minor: 12000,
|
||||
note: 'Главный повар',
|
||||
});
|
||||
|
||||
expect(saved.rate_minor).toBe(12000);
|
||||
expect(saved.pool_weight_bp).toBe(10000);
|
||||
expect(saved.note).toBe('Главный повар');
|
||||
expect(savedParamsJson).toContain('"rate_minor":12000');
|
||||
expect(closedPreviousVersion).toBe(true);
|
||||
|
||||
const fetched = await getEmployeePayAssignment(client, tenantId, empId);
|
||||
expect(fetched).not.toBeNull();
|
||||
|
|
@ -610,23 +619,26 @@ describe('Tenant and employee isolation guarantees', () => {
|
|||
|
||||
// Ensure the employee isolation clause is present in GET /payroll
|
||||
expect(operationsSrc).toContain('($3::boolean OR e.core_user_id = $4 OR e.id = $4)');
|
||||
expect(operationsSrc).toContain('context.permissions.has(\'staff:payroll:read\')');
|
||||
expect(operationsSrc).toContain('context.permissions.has(\'staff:payslip:read_own\')');
|
||||
expect(operationsSrc).toContain("context.permissions.has('staff:payroll:read')");
|
||||
expect(operationsSrc).toContain("context.permissions.has('staff:payslip:read_own')");
|
||||
});
|
||||
|
||||
it('restricts pay-assignment access to owner employee or manager', () => {
|
||||
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
|
||||
|
||||
expect(operationsSrc).toContain('!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId');
|
||||
expect(operationsSrc).toContain(
|
||||
'!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId',
|
||||
);
|
||||
});
|
||||
|
||||
it('confirms external_key and unique index are declared in 0008_employee_access migration', () => {
|
||||
const migrationSql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8');
|
||||
expect(migrationSql).toContain('ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;');
|
||||
expect(migrationSql).toContain(
|
||||
'ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;',
|
||||
);
|
||||
expect(migrationSql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx');
|
||||
expect(migrationSql).toContain(
|
||||
'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue