metallkart-erp/tests/backend/work-log.test.ts
louis a767aec689 feat(work-log): admin recompute endpoint + convention actualLabor (SPEC-RECOMPUTE-ACTUALLABOR-ROBUSTE)
POST /work-logs/recompute/:orderId (ADMIN-only, idempotent) repairs
ghost actualLabor after SQL-direct deletion of VERIFIED WorkLogs.
Convention documented in CLAUDE.md. 3 test-gardes (T-TRUD3-19/20/18a).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-02 00:59:33 +04:00

497 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi } from 'vitest';
import { WorkLogService } from '../../src/modules/work-log/work-log.service.js';
import { WorkerService } from '../../src/modules/work-log/worker.service.js';
function makeOperation(overrides: Record<string, any> = {}) {
return {
id: 1,
orderId: 1,
payType: 'PIECE',
baremeUnitPrice: 17.85,
baremeTotalPrice: 553.35,
quantity: 31,
unit: 'шт',
description: 'Распил прямой рез',
parentOperationId: null,
order: { id: 1, quantity: 10, status: 'IN_PRODUCTION' },
...overrides,
};
}
function makeWorker(overrides: Record<string, any> = {}) {
return { id: 1, name: 'Ivan Petrov', nameRu: 'Петров Иван', category: 'SBORSHIK', active: true, phone: null, ...overrides };
}
function mockPrisma(overrides: Record<string, any> = {}) {
return {
orderLaborOperation: {
findUnique: vi.fn().mockResolvedValue(makeOperation()),
findMany: vi.fn().mockResolvedValue([]),
},
worker: {
findUnique: vi.fn().mockResolvedValue(makeWorker()),
findMany: vi.fn().mockResolvedValue([makeWorker()]),
create: vi.fn().mockImplementation(({ data }) => ({ id: 1, ...data, createdAt: new Date(), updatedAt: new Date() })),
update: vi.fn().mockImplementation(({ data }) => ({ ...makeWorker(), ...data })),
},
workLog: {
create: vi.fn().mockImplementation(({ data }) => ({
id: 1, ...data, worker: makeWorker(), operation: { id: 1, description: 'Распил', payType: 'PIECE', unit: 'шт' },
createdAt: new Date(), updatedAt: new Date(),
})),
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockImplementation(({ data }) => ({
id: 1, orderId: 1, operationId: 1, workerId: 1, quantityDone: 5, costAmount: 89.25,
status: data.status, verifiedById: data.verifiedById, verifiedAt: data.verifiedAt,
rejectionReason: data.rejectionReason ?? null,
worker: makeWorker(), operation: { id: 1, description: 'Распил', payType: 'PIECE', unit: 'шт' },
createdAt: new Date(), updatedAt: new Date(),
})),
aggregate: vi.fn().mockResolvedValue({ _sum: { quantityDone: null, costAmount: null } }),
},
financialTracking: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
order: {
findUnique: vi.fn().mockResolvedValue({ id: 1, quantity: 10 }),
},
...overrides,
} as any;
}
describe('WorkLogService', () => {
describe('create', () => {
it('T-TRUD3-01: creates a PIECE work log with correct cost', async () => {
const prisma = mockPrisma();
const service = new WorkLogService(prisma);
const result = await service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 5,
}, 'user-id');
expect(result.workLog.costAmount).toBe(89.25); // 5 × 17.85
expect(result.workLog.quantityDone).toBe(5);
expect(result.ceilingCheck).not.toBeNull();
expect(result.ceilingCheck!.ceiling).toBe(310); // 31 × 10
expect(result.ceilingCheck!.exceeded).toBe(false);
expect(result.hourlyBudgetCheck).toBeNull();
});
it('T-TRUD3-02: creates an HOURLY work log with budget check', async () => {
const prisma = mockPrisma({
orderLaborOperation: {
findUnique: vi.fn().mockResolvedValue(makeOperation({
payType: 'HOURLY', baremeUnitPrice: 375, baremeTotalPrice: 750, quantity: 120, unit: 'мин',
})),
},
});
const service = new WorkLogService(prisma);
const result = await service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 2,
}, 'user-id');
expect(result.workLog.costAmount).toBe(750); // 2 hours × 375 ₽/h
expect(result.ceilingCheck).toBeNull(); // no ceiling for HOURLY
expect(result.hourlyBudgetCheck).not.toBeNull();
expect(result.hourlyBudgetCheck!.budget).toBe(7500); // 750 × 10
expect(result.hourlyBudgetCheck!.spent).toBe(750);
expect(result.hourlyBudgetCheck!.exceeded).toBe(false);
});
it('T-TRUD3-03: creates an AREA work log (peinture)', async () => {
const prisma = mockPrisma({
orderLaborOperation: {
findUnique: vi.fn().mockResolvedValue(makeOperation({
payType: 'AREA', baremeUnitPrice: 65, baremeTotalPrice: 1300, quantity: 20, unit: 'м²',
})),
},
});
const service = new WorkLogService(prisma);
const result = await service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 8,
}, 'user-id');
expect(result.workLog.costAmount).toBe(520); // 8 × 65
expect(result.ceilingCheck!.ceiling).toBe(200); // 20 × 10
expect(result.ceilingCheck!.exceeded).toBe(false);
});
it('T-TRUD3-04: ceiling exceeded without justification → rejects (BLOCKING)', async () => {
const prisma = mockPrisma({
workLog: {
...mockPrisma().workLog,
aggregate: vi.fn().mockResolvedValue({ _sum: { quantityDone: 300, costAmount: 5355 } }),
},
});
const service = new WorkLogService(prisma);
const err: any = await service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 15,
}, 'user-id').catch(e => e);
expect(err.statusCode).toBe(400);
expect(err.code).toBe('CEILING_EXCEEDED');
expect(err.ceilingCheck.exceeded).toBe(true);
expect(err.ceilingCheck.ceiling).toBe(310);
expect(err.ceilingCheck.cumulAfter).toBe(315);
expect(err.ceilingCheck.overBy).toBe(5);
expect(prisma.workLog.create).not.toHaveBeenCalled();
});
it('T-TRUD3-04b: ceiling exceeded WITH justification → creates', async () => {
const prisma = mockPrisma({
workLog: {
...mockPrisma().workLog,
aggregate: vi.fn().mockResolvedValue({ _sum: { quantityDone: 300, costAmount: 5355 } }),
},
});
const service = new WorkLogService(prisma);
const result = await service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 15,
excessJustification: 'Доработка по рекламации клиента',
}, 'user-id');
expect(result.ceilingCheck!.exceeded).toBe(true);
expect(result.ceilingCheck!.overBy).toBe(5);
expect(prisma.workLog.create).toHaveBeenCalled();
const createCall = prisma.workLog.create.mock.calls[0][0];
expect(createCall.data.excessJustification).toBe('Доработка по рекламации клиента');
});
it('T-TRUD3-05: hourly budget exceeded without justification → rejects (BLOCKING)', async () => {
const prisma = mockPrisma({
orderLaborOperation: {
findUnique: vi.fn().mockResolvedValue(makeOperation({
payType: 'HOURLY', baremeUnitPrice: 375, baremeTotalPrice: 750, quantity: 120, unit: 'мин',
})),
},
workLog: {
...mockPrisma().workLog,
aggregate: vi.fn().mockResolvedValue({ _sum: { quantityDone: 18, costAmount: 6750 } }),
},
});
const service = new WorkLogService(prisma);
const err: any = await service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 4,
}, 'user-id').catch(e => e);
expect(err.statusCode).toBe(400);
expect(err.code).toBe('BUDGET_EXCEEDED');
expect(err.hourlyBudgetCheck.exceeded).toBe(true);
expect(err.hourlyBudgetCheck.budget).toBe(7500);
expect(err.hourlyBudgetCheck.spent).toBe(8250);
expect(prisma.workLog.create).not.toHaveBeenCalled();
});
it('T-TRUD3-05b: hourly budget exceeded WITH justification → creates', async () => {
const prisma = mockPrisma({
orderLaborOperation: {
findUnique: vi.fn().mockResolvedValue(makeOperation({
payType: 'HOURLY', baremeUnitPrice: 375, baremeTotalPrice: 750, quantity: 120, unit: 'мин',
})),
},
workLog: {
...mockPrisma().workLog,
aggregate: vi.fn().mockResolvedValue({ _sum: { quantityDone: 18, costAmount: 6750 } }),
},
});
const service = new WorkLogService(prisma);
const result = await service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 4,
excessJustification: 'Доработка сложной конструкции',
}, 'user-id');
expect(result.hourlyBudgetCheck!.exceeded).toBe(true);
expect(prisma.workLog.create).toHaveBeenCalled();
const createCall = prisma.workLog.create.mock.calls[0][0];
expect(createCall.data.excessJustification).toBe('Доработка сложной конструкции');
});
it('T-TRUD3-05c: HOURLY costAmount = hours × hourly rate', async () => {
const prisma = mockPrisma({
orderLaborOperation: {
findUnique: vi.fn().mockResolvedValue(makeOperation({
payType: 'HOURLY', baremeUnitPrice: 375, baremeTotalPrice: 750, quantity: 120, unit: 'ч',
})),
},
});
const service = new WorkLogService(prisma);
const result = await service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 2.5,
}, 'user-id');
expect(result.workLog.costAmount).toBe(937.5); // 2.5h × 375 ₽/h
});
it('T-TRUD3-05d: no justification stored when ceiling NOT exceeded', async () => {
const prisma = mockPrisma();
const service = new WorkLogService(prisma);
await service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 5,
excessJustification: 'This should be ignored',
}, 'user-id');
const createCall = prisma.workLog.create.mock.calls[0][0];
expect(createCall.data.excessJustification).toBeNull();
});
it('T-TRUD3-06: rejects if order not in production', async () => {
const prisma = mockPrisma({
orderLaborOperation: {
findUnique: vi.fn().mockResolvedValue(makeOperation({
order: { id: 1, quantity: 10, status: 'DRAFT' },
})),
},
});
const service = new WorkLogService(prisma);
await expect(service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 5,
}, 'user-id')).rejects.toThrow('Order must be in production');
});
it('T-TRUD3-07: rejects if worker inactive', async () => {
const prisma = mockPrisma({
worker: { findUnique: vi.fn().mockResolvedValue(makeWorker({ active: false })) },
});
const service = new WorkLogService(prisma);
await expect(service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 5,
}, 'user-id')).rejects.toThrow('Worker not found or inactive');
});
it('T-TRUD3-08: rejects work on parent operation (must use children)', async () => {
const prisma = mockPrisma({
orderLaborOperation: {
findUnique: vi.fn().mockResolvedValue(makeOperation({ parentOperationId: 99 })),
},
});
const service = new WorkLogService(prisma);
await expect(service.create({
operationId: 1, workerId: 1, workDate: '2026-06-26', quantityDone: 5,
}, 'user-id')).rejects.toThrow('split children');
});
});
describe('verify', () => {
it('T-TRUD3-09: verifies PENDING log and recomputes actualLabor', async () => {
const prisma = mockPrisma({
workLog: {
...mockPrisma().workLog,
findUnique: vi.fn().mockResolvedValue({ id: 1, orderId: 1, status: 'PENDING', costAmount: 89.25 }),
aggregate: vi.fn().mockResolvedValue({ _sum: { costAmount: 89.25 } }),
},
});
const service = new WorkLogService(prisma);
const result = await service.verify(1, 'verifier-id');
expect(result.status).toBe('VERIFIED');
expect(prisma.financialTracking.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { orderId: 1 } }),
);
});
it('T-TRUD3-10: rejects verify on non-PENDING log', async () => {
const prisma = mockPrisma({
workLog: {
...mockPrisma().workLog,
findUnique: vi.fn().mockResolvedValue({ id: 1, orderId: 1, status: 'VERIFIED' }),
},
});
const service = new WorkLogService(prisma);
await expect(service.verify(1, 'user')).rejects.toThrow('Cannot verify a VERIFIED');
});
});
describe('reject', () => {
it('T-TRUD3-11: rejects with reason', async () => {
const prisma = mockPrisma({
workLog: {
...mockPrisma().workLog,
findUnique: vi.fn().mockResolvedValue({ id: 1, orderId: 1, status: 'PENDING' }),
},
});
const service = new WorkLogService(prisma);
const result = await service.reject(1, 'user', 'Количество завышено');
expect(result.status).toBe('REJECTED');
expect(result.rejectionReason).toBe('Количество завышено');
});
});
describe('recomputeActualLabor', () => {
it('T-TRUD3-12: sums VERIFIED logs and updates FinancialTracking', async () => {
const prisma = mockPrisma({
workLog: {
...mockPrisma().workLog,
aggregate: vi.fn().mockResolvedValue({ _sum: { costAmount: 1250.50 } }),
},
});
const service = new WorkLogService(prisma);
const result = await service.recomputeActualLabor(1);
expect(result).toBe(1250.50);
expect(prisma.financialTracking.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { orderId: 1 },
data: expect.objectContaining({ actualLabor: expect.anything() }),
}),
);
});
it('T-TRUD3-13: idempotent — same verify twice → same actualLabor', async () => {
const prisma = mockPrisma({
workLog: {
...mockPrisma().workLog,
aggregate: vi.fn().mockResolvedValue({ _sum: { costAmount: 500 } }),
},
});
const service = new WorkLogService(prisma);
const r1 = await service.recomputeActualLabor(1);
const r2 = await service.recomputeActualLabor(1);
expect(r1).toBe(r2);
expect(r1).toBe(500);
});
});
describe('summaryByOrder', () => {
it('T-TRUD3-14: returns operation-level summary with progress', async () => {
const op = makeOperation();
const prisma = mockPrisma({
order: { findUnique: vi.fn().mockResolvedValue({ id: 1, quantity: 10 }) },
orderLaborOperation: {
findMany: vi.fn().mockResolvedValue([{ ...op, childOperations: [] }]),
},
workLog: {
...mockPrisma().workLog,
findMany: vi.fn().mockResolvedValue([
{ operationId: 1, quantityDone: 100, costAmount: 1785, status: 'VERIFIED' },
{ operationId: 1, quantityDone: 50, costAmount: 892.5, status: 'PENDING' },
]),
},
});
const service = new WorkLogService(prisma);
const result = await service.summaryByOrder(1);
expect(result.operations).toHaveLength(1);
const summary = result.operations[0];
expect(summary.ceiling).toBe(310); // 31 × 10
expect(summary.quantityDone).toBe(150);
expect(summary.quantityVerified).toBe(100);
expect(summary.costLogged).toBe(2677.5);
expect(summary.costVerified).toBe(1785);
expect(summary.costPending).toBe(892.5);
expect(summary.progressPercent).toBe(32.26); // VERIFIED only: 100/310 * 100
});
it('T-TRUD3-14b: HOURLY progress uses cost-based percentage', async () => {
const op = makeOperation({ payType: 'HOURLY', baremeUnitPrice: 375, baremeTotalPrice: 625, quantity: 10 });
const prisma = mockPrisma({
order: { findUnique: vi.fn().mockResolvedValue({ id: 1, quantity: 5 }) },
orderLaborOperation: {
findMany: vi.fn().mockResolvedValue([{ ...op, childOperations: [] }]),
},
workLog: {
...mockPrisma().workLog,
findMany: vi.fn().mockResolvedValue([
{ operationId: 1, quantityDone: 3, costAmount: 1125, status: 'VERIFIED' },
{ operationId: 1, quantityDone: 1, costAmount: 375, status: 'PENDING' },
]),
},
});
const service = new WorkLogService(prisma);
const result = await service.summaryByOrder(1);
const summary = result.operations[0];
// baremeTotal = 625 * 5 = 3125, costVerified = 1125
expect(summary.baremeTotal).toBe(3125);
expect(summary.progressPercent).toBe(36); // 1125/3125 * 100 = 36%
expect(summary.costPending).toBe(375);
});
});
});
describe('WorkerService', () => {
it('T-TRUD3-15: creates worker', async () => {
const prisma = mockPrisma();
const service = new WorkerService(prisma);
const worker = await service.create({ name: 'Ivan', nameRu: 'Иван', category: 'SVARCSHIK' });
expect(worker.name).toBe('Ivan');
expect(worker.category).toBe('SVARCSHIK');
});
it('T-TRUD3-16: deactivates worker', async () => {
const prisma = mockPrisma();
const service = new WorkerService(prisma);
const worker = await service.deactivate(1);
expect(worker.active).toBe(false);
});
});
describe('recompute admin endpoint — ghost repair', () => {
it('T-TRUD3-19: recompute after VERIFIED deletion repairs actualLabor', async () => {
let aggregateSum = 500;
const prisma = mockPrisma({
workLog: {
...mockPrisma().workLog,
findUnique: vi.fn().mockResolvedValue({ id: 1, orderId: 1, status: 'PENDING', costAmount: 500 }),
aggregate: vi.fn().mockImplementation(() => ({ _sum: { costAmount: aggregateSum } })),
},
});
const service = new WorkLogService(prisma);
const r1 = await service.recomputeActualLabor(1);
expect(r1).toBe(500);
aggregateSum = 0;
const r2 = await service.recomputeActualLabor(1);
expect(r2).toBe(0);
expect(prisma.financialTracking.updateMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: { orderId: 1 },
data: expect.objectContaining({ actualLabor: expect.anything() }),
}),
);
});
it('T-TRUD3-20: recompute is idempotent (N calls → same result)', async () => {
const prisma = mockPrisma({
workLog: {
...mockPrisma().workLog,
aggregate: vi.fn().mockResolvedValue({ _sum: { costAmount: 1234.56 } }),
},
});
const service = new WorkLogService(prisma);
const results = await Promise.all([
service.recomputeActualLabor(1),
service.recomputeActualLabor(1),
service.recomputeActualLabor(1),
]);
expect(results).toEqual([1234.56, 1234.56, 1234.56]);
});
});
describe('wiring proof', () => {
it('T-TRUD3-17: workLogRoutes registered in server.ts', async () => {
const fs = await import('fs');
const content = fs.readFileSync('src/server.ts', 'utf-8');
expect(content).toContain('workLogRoutes');
expect(content).toContain("work-log/work-log.routes");
});
it('T-TRUD3-18a: recompute admin endpoint exists in routes', async () => {
const fs = await import('fs');
const content = fs.readFileSync('src/modules/work-log/work-log.routes.ts', 'utf-8');
expect(content).toContain("'/work-logs/recompute/:orderId'");
expect(content).toContain('recomputeActualLabor');
expect(content).toContain("authorize(['ADMIN']");
});
it('T-TRUD3-18: verify triggers recomputeActualLabor (actualLabor integration)', async () => {
const fs = await import('fs');
const content = fs.readFileSync('src/modules/work-log/work-log.service.ts', 'utf-8');
expect(content).toContain('recomputeActualLabor');
// Verify calls recomputeActualLabor
const verifyMethod = content.slice(content.indexOf('async verify('));
expect(verifyMethod).toContain('this.recomputeActualLabor');
});
});