Add field management module with database schema and validation
- Introduced `FieldModule` to manage cycles and plans, including read and write controllers. - Created database migrations for `company_settings`, `cycles`, `cycle_weekdays`, `cycle_destinations`, `plans`, `plan_destinations`, `plan_invoices`, and `plan_packing_slips` tables, including constraints and unique indexes. - Developed service and repository layers for handling cycle and plan data operations. - Added unit tests for the cycles and plans services, repositories, and controllers to ensure functionality and correctness. - Updated application module to include the new `FieldModule` for better organization.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { BranchesService } from '../../configuration/branches/branches.service';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { PrivilegesService } from '../../privileges/privileges.service';
|
||||
import { PackingSlipsService } from '../../sales/packing-slips/packing-slips.service';
|
||||
import { SalesInvoicesService } from '../../sales/sales-invoices/sales-invoices.service';
|
||||
import type { Cycle } from '../cycles/cycle';
|
||||
import { CyclesRepository } from '../cycles/cycles.repository';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import type { Plan } from './plan';
|
||||
import { PlansRepository } from './plans.repository';
|
||||
import { PlansService } from './plans.service';
|
||||
|
||||
describe('PlansService', () => {
|
||||
let service: PlansService;
|
||||
let plansRepository: jest.Mocked<
|
||||
Pick<
|
||||
PlansRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'findLiveByKey'
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'replaceDestinations'
|
||||
| 'updateStatus'
|
||||
| 'archive'
|
||||
>
|
||||
>;
|
||||
const cyclesRepository = { listActiveByEmployeePurpose: jest.fn() };
|
||||
const companySettingsService = { requireCycleStartDate: jest.fn() };
|
||||
const employeesService = { findById: jest.fn() };
|
||||
const branchesService = { findById: jest.fn() };
|
||||
const customersService = { findById: jest.fn() };
|
||||
const salesInvoicesService = { findById: jest.fn() };
|
||||
const packingSlipsService = { findById: jest.fn() };
|
||||
const privilegesService = { checkPermission: jest.fn() };
|
||||
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
isSuperadmin: true,
|
||||
};
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const geometry = {
|
||||
type: 'LineString' as const,
|
||||
coordinates: [
|
||||
[106.8, -6.2],
|
||||
[106.9, -6.3],
|
||||
[107.0, -6.4],
|
||||
] as const,
|
||||
};
|
||||
const located = { id: 'x', latitude: -6.2, longitude: 106.8 };
|
||||
|
||||
const cycle: Cycle = {
|
||||
id: 'cyc-1',
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
cycleNumber: 1,
|
||||
weekdays: [
|
||||
{
|
||||
id: 'wd-1',
|
||||
weekday: 'monday',
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
routeGeometry: geometry,
|
||||
destinations: [{ id: 'cd-1', customerId: 'cus-1', sortOrder: 0 }],
|
||||
},
|
||||
],
|
||||
status: Status.create('active'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const plan: Plan = {
|
||||
id: 'pln-1',
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
date: DateTime.create('2026-01-05'),
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
routeGeometry: geometry,
|
||||
destinations: [{ id: 'pd-1', customerId: 'cus-1', sortOrder: 0 }],
|
||||
invoiceIds: [],
|
||||
packingSlipIds: [],
|
||||
status: Status.create('active'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
plansRepository = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findLiveByKey: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
replaceDestinations: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
archive: jest.fn(),
|
||||
};
|
||||
employeesService.findById.mockResolvedValue({ id: 'emp-1' });
|
||||
branchesService.findById.mockResolvedValue(located);
|
||||
customersService.findById.mockResolvedValue(located);
|
||||
plansRepository.findLiveByKey.mockResolvedValue(null);
|
||||
plansRepository.create.mockResolvedValue(plan);
|
||||
companySettingsService.requireCycleStartDate.mockResolvedValue(
|
||||
DateTime.create('2026-01-05'),
|
||||
);
|
||||
cyclesRepository.listActiveByEmployeePurpose.mockResolvedValue([cycle]);
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PlansService,
|
||||
{ provide: PlansRepository, useValue: plansRepository },
|
||||
{ provide: CyclesRepository, useValue: cyclesRepository },
|
||||
{ provide: CompanySettingsService, useValue: companySettingsService },
|
||||
{ provide: EmployeesService, useValue: employeesService },
|
||||
{ provide: BranchesService, useValue: branchesService },
|
||||
{ provide: CustomersService, useValue: customersService },
|
||||
{ provide: SalesInvoicesService, useValue: salesInvoicesService },
|
||||
{ provide: PackingSlipsService, useValue: packingSlipsService },
|
||||
{ provide: PrivilegesService, useValue: privilegesService },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(PlansService);
|
||||
});
|
||||
|
||||
it('rejects packing slips on a sales plan', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
date: '2026-01-05',
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
customerIds: ['cus-1'],
|
||||
packingSlipIds: ['ps-1'],
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('generate copies a weekday and skips an existing plan', async () => {
|
||||
plansRepository.findLiveByKey
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(plan);
|
||||
const result = await service.generate({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
from: '2026-01-05',
|
||||
to: '2026-01-12',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.skipped).toBeGreaterThan(0);
|
||||
expect(plansRepository.create).toHaveBeenCalledTimes(1);
|
||||
const created = plansRepository.create.mock.calls[0][0];
|
||||
expect(created.invoiceIds).toEqual([]);
|
||||
expect(created.status.value).toBe('active');
|
||||
});
|
||||
|
||||
it('generate fails when the employee has no cycle', async () => {
|
||||
cyclesRepository.listActiveByEmployeePurpose.mockResolvedValue([]);
|
||||
await expect(
|
||||
service.generate({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
from: '2026-01-05',
|
||||
to: '2026-01-05',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('refuses to remove the last destination', async () => {
|
||||
plansRepository.findById.mockResolvedValue(plan);
|
||||
await expect(
|
||||
service.removeDestination('pln-1', 'pd-1', user),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('adds a destination without writing a cycle', async () => {
|
||||
plansRepository.findById.mockResolvedValue(plan);
|
||||
plansRepository.replaceDestinations.mockResolvedValue({
|
||||
...plan,
|
||||
destinations: [
|
||||
...plan.destinations,
|
||||
{ id: 'pd-2', customerId: 'cus-2', sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
await service.addDestination('pln-1', 'cus-2', undefined, user);
|
||||
expect(plansRepository.replaceDestinations).toHaveBeenCalled();
|
||||
expect(cyclesRepository.listActiveByEmployeePurpose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a duplicate destination', async () => {
|
||||
plansRepository.findById.mockResolvedValue(plan);
|
||||
await expect(
|
||||
service.addDestination('pln-1', 'cus-1', undefined, user),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user