Task Wy8h2H_7lwZa: Staff production readiness #1
3 changed files with 162 additions and 5 deletions
|
|
@ -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<PayrollRunDbRow>(
|
||||
`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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue