Task Wy8h2H_7lwZa: Staff production readiness #1
3 changed files with 162 additions and 5 deletions
|
|
@ -63,6 +63,7 @@ interface EmployeePayAssignmentDbRow {
|
||||||
|
|
||||||
interface PayrollRunDbRow {
|
interface PayrollRunDbRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
org_unit_id: string | null;
|
||||||
period_start: string;
|
period_start: string;
|
||||||
period_end: string;
|
period_end: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|
@ -424,6 +425,24 @@ export async function calculatePayrollPeriod(
|
||||||
|
|
||||||
await ensureDefaultPayRules(client, tenantId, actorId);
|
await ensureDefaultPayRules(client, tenantId, actorId);
|
||||||
|
|
||||||
|
// Reject if any approved or paid run already covers or overlaps with this period
|
||||||
|
const overlappingApprovedQuery = await client.query<{ id: string; status: string }>(
|
||||||
|
`SELECT id, status FROM staff_payroll_runs
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND status IN ('approved', 'paid')
|
||||||
|
AND ($2::varchar(26) IS NULL OR org_unit_id IS NULL OR org_unit_id = $2)
|
||||||
|
AND period_start <= $4
|
||||||
|
AND period_end >= $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[tenantId, orgUnitId, from, to],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overlappingApprovedQuery.rows.length > 0) {
|
||||||
|
const err = new Error('Расчёт за этот период уже утверждён.');
|
||||||
|
(err as { statusCode?: number }).statusCode = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
// Look for an existing run for this period
|
// Look for an existing run for this period
|
||||||
const existingRunQuery = await client.query<{ id: string; status: string }>(
|
const existingRunQuery = await client.query<{ id: string; status: string }>(
|
||||||
`SELECT id, status FROM staff_payroll_runs
|
`SELECT id, status FROM staff_payroll_runs
|
||||||
|
|
@ -474,6 +493,15 @@ export async function calculatePayrollPeriod(
|
||||||
AND s.business_date BETWEEN $2 AND $3
|
AND s.business_date BETWEEN $2 AND $3
|
||||||
AND s.status = 'confirmed'
|
AND s.status = 'confirmed'
|
||||||
AND ($4::varchar(26) IS NULL OR s.org_unit_id = $4)
|
AND ($4::varchar(26) IS NULL OR s.org_unit_id = $4)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM staff_payroll_lines pl
|
||||||
|
JOIN staff_payroll_runs pr ON pr.tenant_id = pl.tenant_id AND pr.id = pl.run_id
|
||||||
|
WHERE pl.tenant_id = s.tenant_id
|
||||||
|
AND pl.employee_id = s.employee_id
|
||||||
|
AND pl.org_unit_id = s.org_unit_id
|
||||||
|
AND pl.business_date = s.business_date
|
||||||
|
AND pr.status IN ('approved', 'paid')
|
||||||
|
)
|
||||||
ORDER BY s.business_date, s.org_unit_id, s.employee_id`,
|
ORDER BY s.business_date, s.org_unit_id, s.employee_id`,
|
||||||
[tenantId, from, to, orgUnitId],
|
[tenantId, from, to, orgUnitId],
|
||||||
);
|
);
|
||||||
|
|
@ -760,12 +788,14 @@ export async function calculatePayrollPeriod(
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE staff_payroll_runs SET
|
`UPDATE staff_payroll_runs SET
|
||||||
status = 'calculated',
|
status = 'calculated',
|
||||||
total_accrued_minor = $3,
|
period_start = $3,
|
||||||
calculated_at = $4,
|
period_end = $4,
|
||||||
|
total_accrued_minor = $5,
|
||||||
|
calculated_at = $6,
|
||||||
updated_at = now(),
|
updated_at = now(),
|
||||||
revision = revision + 1
|
revision = revision + 1
|
||||||
WHERE tenant_id = $1 AND id = $2`,
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
[tenantId, runId, totalAccruedMinor.toString(), calculatedAt],
|
[tenantId, runId, from, to, totalAccruedMinor.toString(), calculatedAt],
|
||||||
);
|
);
|
||||||
|
|
||||||
await insertOutboxEvent(client, {
|
await insertOutboxEvent(client, {
|
||||||
|
|
@ -839,7 +869,7 @@ export async function approvePayrollRun(
|
||||||
|
|
||||||
const runQuery = await client.query<PayrollRunDbRow>(
|
const runQuery = await client.query<PayrollRunDbRow>(
|
||||||
`SELECT id, period_start::text AS period_start, period_end::text AS period_end,
|
`SELECT id, period_start::text AS period_start, period_end::text AS period_end,
|
||||||
status, total_accrued_minor::text AS total_accrued_minor
|
org_unit_id, status, total_accrued_minor::text AS total_accrued_minor
|
||||||
FROM staff_payroll_runs
|
FROM staff_payroll_runs
|
||||||
WHERE tenant_id = $1 AND id = $2
|
WHERE tenant_id = $1 AND id = $2
|
||||||
FOR UPDATE`,
|
FOR UPDATE`,
|
||||||
|
|
@ -859,6 +889,25 @@ export async function approvePayrollRun(
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject if any other run overlapping this period was approved or paid
|
||||||
|
const conflictingApproved = await client.query<{ id: string }>(
|
||||||
|
`SELECT id FROM staff_payroll_runs
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND id != $2
|
||||||
|
AND status IN ('approved', 'paid')
|
||||||
|
AND ($3::varchar(26) IS NULL OR org_unit_id IS NULL OR org_unit_id = $3)
|
||||||
|
AND period_start <= $5
|
||||||
|
AND period_end >= $4
|
||||||
|
LIMIT 1`,
|
||||||
|
[tenantId, runId, run.org_unit_id ?? null, run.period_start, run.period_end],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conflictingApproved.rows.length > 0) {
|
||||||
|
const err = new Error('Период расчёта пересекается с другим утверждённым расчётом.');
|
||||||
|
(err as { statusCode?: number }).statusCode = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
// Aggregate accrued lines per employee
|
// Aggregate accrued lines per employee
|
||||||
const aggregatesQuery = await client.query<{
|
const aggregatesQuery = await client.query<{
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
"K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
|
"K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
|
||||||
"K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
|
"K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
|
||||||
"K12/N3": "SSRF через push-endpoint на внутренние адреса",
|
"K12/N3": "SSRF через push-endpoint на внутренние адреса",
|
||||||
"K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)",
|
|
||||||
"N9": "перепривязка чужого push-endpoint",
|
"N9": "перепривязка чужого push-endpoint",
|
||||||
"N10": "справочники доступны терминалу",
|
"N10": "справочники доступны терминалу",
|
||||||
"N11": "импорт с неверным kind → 500",
|
"N11": "импорт с неверным kind → 500",
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,74 @@ describe('Payroll service calculation and warning engine', () => {
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow('Расчёт за этот период уже утверждён.');
|
).rejects.toThrow('Расчёт за этот период уже утверждён.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects calculation if overlapping approved run exists (K5/N4)', async () => {
|
||||||
|
const approvedRunId = newUlid();
|
||||||
|
const client = makeMockClient({
|
||||||
|
query: (sql) => {
|
||||||
|
if (
|
||||||
|
sql.includes('SELECT id, status FROM staff_payroll_runs') &&
|
||||||
|
sql.includes("status IN ('approved', 'paid')")
|
||||||
|
) {
|
||||||
|
expect(sql).toContain('period_start <= $4');
|
||||||
|
expect(sql).toContain('period_end >= $3');
|
||||||
|
return {
|
||||||
|
rows: [{ id: approvedRunId, status: 'approved' }],
|
||||||
|
rowCount: 1,
|
||||||
|
command: '',
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
calculatePayrollPeriod(client, mockContext(), {
|
||||||
|
from: '2026-05-15',
|
||||||
|
to: '2026-06-15',
|
||||||
|
orgUnitId: newUlid(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Расчёт за этот период уже утверждён.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates period_start and period_end in database when recalculating existing draft run (K5/N4)', async () => {
|
||||||
|
const existingDraftId = newUlid();
|
||||||
|
let updatedPeriodStart = '';
|
||||||
|
let updatedPeriodEnd = '';
|
||||||
|
|
||||||
|
const client = makeMockClient({
|
||||||
|
query: (sql, params) => {
|
||||||
|
if (sql.includes("status IN ('approved', 'paid')")) {
|
||||||
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
|
||||||
|
return {
|
||||||
|
rows: [{ id: existingDraftId, status: 'draft' }],
|
||||||
|
rowCount: 1,
|
||||||
|
command: '',
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('UPDATE staff_payroll_runs SET')) {
|
||||||
|
updatedPeriodStart = String(params[2]);
|
||||||
|
updatedPeriodEnd = String(params[3]);
|
||||||
|
return { rows: [], rowCount: 1, command: '', oid: 0, fields: [] };
|
||||||
|
}
|
||||||
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await calculatePayrollPeriod(client, mockContext(), {
|
||||||
|
from: '2026-05-01',
|
||||||
|
to: '2026-05-31',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedPeriodStart).toBe('2026-05-01');
|
||||||
|
expect(updatedPeriodEnd).toBe('2026-05-31');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Payroll approval and ledger idempotency', () => {
|
describe('Payroll approval and ledger idempotency', () => {
|
||||||
|
|
@ -523,6 +591,47 @@ describe('Payroll approval and ledger idempotency', () => {
|
||||||
'Утвердить можно только расчёт в статусе calculated.',
|
'Утвердить можно только расчёт в статусе calculated.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects approval if another overlapping run is already approved (K5/N4)', async () => {
|
||||||
|
const runId = newUlid();
|
||||||
|
const otherApprovedId = newUlid();
|
||||||
|
const client = makeMockClient({
|
||||||
|
query: (sql) => {
|
||||||
|
if (sql.includes('SELECT id, period_start::text AS period_start')) {
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: runId,
|
||||||
|
period_start: '2026-05-01',
|
||||||
|
period_end: '2026-05-31',
|
||||||
|
org_unit_id: null,
|
||||||
|
status: 'calculated',
|
||||||
|
total_accrued_minor: '1220000',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rowCount: 1,
|
||||||
|
command: '',
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT id FROM staff_payroll_runs') && sql.includes('id != $2')) {
|
||||||
|
return {
|
||||||
|
rows: [{ id: otherApprovedId }],
|
||||||
|
rowCount: 1,
|
||||||
|
command: '',
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(approvePayrollRun(client, mockContext(), runId)).rejects.toThrow(
|
||||||
|
'Период расчёта пересекается с другим утверждённым расчётом.',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Pay assignment management', () => {
|
describe('Pay assignment management', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue