fix(payroll): prevent duplicate shift accruals across overlapping runs (K5/N4)
Some checks failed
ci / verify (pull_request) Has been cancelled
ci / format (pull_request) Has been cancelled
ci / e2e (pull_request) Has been cancelled
ci / windows (pull_request) Has been cancelled
ci / docker-build (pull_request) Has been cancelled

This commit is contained in:
KAgent 2026-09-29 13:29:55 +07:00
parent 29428cee08
commit 7c038c4b78
3 changed files with 162 additions and 5 deletions

View file

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

View file

@ -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",

View file

@ -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', () => {