diff --git a/src/domain/payroll/service.ts b/src/domain/payroll/service.ts index 9cd51a6..2988d63 100644 --- a/src/domain/payroll/service.ts +++ b/src/domain/payroll/service.ts @@ -63,6 +63,7 @@ interface EmployeePayAssignmentDbRow { interface PayrollRunDbRow { id: string; + org_unit_id: string | null; period_start: string; period_end: string; status: string; @@ -424,6 +425,24 @@ export async function calculatePayrollPeriod( 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 const existingRunQuery = await client.query<{ id: string; status: string }>( `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.status = 'confirmed' 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`, [tenantId, from, to, orgUnitId], ); @@ -760,12 +788,14 @@ export async function calculatePayrollPeriod( await client.query( `UPDATE staff_payroll_runs SET status = 'calculated', - total_accrued_minor = $3, - calculated_at = $4, + period_start = $3, + period_end = $4, + total_accrued_minor = $5, + calculated_at = $6, updated_at = now(), revision = revision + 1 WHERE tenant_id = $1 AND id = $2`, - [tenantId, runId, totalAccruedMinor.toString(), calculatedAt], + [tenantId, runId, from, to, totalAccruedMinor.toString(), calculatedAt], ); await insertOutboxEvent(client, { @@ -839,7 +869,7 @@ export async function approvePayrollRun( const runQuery = await client.query( `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 WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, @@ -859,6 +889,25 @@ export async function approvePayrollRun( 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 const aggregatesQuery = await client.query<{ employee_id: string; diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json index 7fc1c9d..c20050f 100644 --- a/tests/e2e/known-defects.json +++ b/tests/e2e/known-defects.json @@ -4,7 +4,6 @@ "K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL", "K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)", "K12/N3": "SSRF через push-endpoint на внутренние адреса", - "K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)", "N9": "перепривязка чужого push-endpoint", "N10": "справочники доступны терминалу", "N11": "импорт с неверным kind → 500", diff --git a/tests/unit/payroll-auto-calc.test.ts b/tests/unit/payroll-auto-calc.test.ts index c943574..1f0282c 100644 --- a/tests/unit/payroll-auto-calc.test.ts +++ b/tests/unit/payroll-auto-calc.test.ts @@ -416,6 +416,74 @@ describe('Payroll service calculation and warning engine', () => { }), ).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', () => { @@ -523,6 +591,47 @@ describe('Payroll approval and ledger idempotency', () => { 'Утвердить можно только расчёт в статусе 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', () => {