Add timeline tracking features with database schema updates
- Introduced new tables `timeline_footprints` and `timeline_activities` to manage employee location data and activity records. - Updated `company_settings` to include `gps_interval_seconds` and `checkout_warning_radius_meters` for enhanced tracking configuration. - Implemented foreign key constraints to ensure data integrity between new tables and existing `employees`, `customers`, and `visits` tables. - Created services and controllers for handling timeline activities and footprints, including ingestion and retrieval of data. - Enhanced DTOs and validation logic to support new fields and ensure correct data formats in API requests. - Added unit and integration tests to validate the new functionalities and ensure proper handling of timeline records. - Created migration scripts to apply the necessary database schema changes for the new features.
This commit is contained in:
@@ -9,6 +9,10 @@ export const companySettings = pgTable('company_settings', {
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(),
|
||||
checkInRadiusMeters: integer('check_in_radius_meters').notNull().default(100),
|
||||
gpsIntervalSeconds: integer('gps_interval_seconds').notNull().default(5),
|
||||
checkoutWarningRadiusMeters: integer('checkout_warning_radius_meters')
|
||||
.notNull()
|
||||
.default(200),
|
||||
...primaryEntityColumns(users),
|
||||
});
|
||||
|
||||
|
||||
@@ -253,6 +253,18 @@ export {
|
||||
type NewAttendanceRow,
|
||||
} from './attendances-table';
|
||||
export { visits, type VisitRow, type NewVisitRow } from './visits-table';
|
||||
export {
|
||||
timelineFootprints,
|
||||
type TimelineFootprintRow,
|
||||
type NewTimelineFootprintRow,
|
||||
} from './timeline-footprints-table';
|
||||
export {
|
||||
timelineActivities,
|
||||
TIMELINE_ACTIVITY_TYPES,
|
||||
type TimelineActivityType,
|
||||
type TimelineActivityRow,
|
||||
type NewTimelineActivityRow,
|
||||
} from './timeline-activities-table';
|
||||
export {
|
||||
reportBookmarks,
|
||||
type NewReportBookmarkRow,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
bigint,
|
||||
doublePrecision,
|
||||
index,
|
||||
pgTable,
|
||||
text,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { visits } from './visits-table';
|
||||
|
||||
export const TIMELINE_ACTIVITY_TYPES = [
|
||||
'branch_check_in',
|
||||
'branch_check_out',
|
||||
'customer_check_in',
|
||||
'customer_check_out',
|
||||
'sales_order_created',
|
||||
'sales_request_created',
|
||||
'sales_payment_created',
|
||||
'customer_created',
|
||||
] as const;
|
||||
|
||||
export type TimelineActivityType = (typeof TIMELINE_ACTIVITY_TYPES)[number];
|
||||
|
||||
export const timelineActivities = pgTable(
|
||||
'timeline_activities',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id').references(() => customers.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
visitId: uuid('visit_id').references(() => visits.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
type: text('type').notNull(),
|
||||
sourceType: text('source_type').notNull(),
|
||||
sourceId: uuid('source_id').notNull(),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('timeline_activities_employee_recorded_idx').on(
|
||||
t.employeeId,
|
||||
t.recordedAt,
|
||||
),
|
||||
index('timeline_activities_visit_id_idx').on(t.visitId),
|
||||
],
|
||||
);
|
||||
|
||||
export type TimelineActivityRow = typeof timelineActivities.$inferSelect;
|
||||
export type NewTimelineActivityRow = typeof timelineActivities.$inferInsert;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { bigint, doublePrecision, index, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||
import { employees } from './employees-table';
|
||||
|
||||
export const timelineFootprints = pgTable(
|
||||
'timeline_footprints',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('timeline_footprints_employee_recorded_idx').on(
|
||||
t.employeeId,
|
||||
t.recordedAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export type TimelineFootprintRow = typeof timelineFootprints.$inferSelect;
|
||||
export type NewTimelineFootprintRow = typeof timelineFootprints.$inferInsert;
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmployeesModule } from '../employees/employees.module';
|
||||
import { TimelineModule } from '../../field/timeline/timeline.module';
|
||||
import { CustomersReadController } from './customers-read.controller';
|
||||
import { CustomersWriteController } from './customers-write.controller';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Module({
|
||||
imports: [EmployeesModule, TimelineModule],
|
||||
controllers: [CustomersReadController, CustomersWriteController],
|
||||
providers: [CustomersRepository, CustomersService],
|
||||
exports: [CustomersService],
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
parseCsvRecord,
|
||||
} from './customer-fields';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
import { EmployeesService } from '../employees/employees.service';
|
||||
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
|
||||
|
||||
export type ListCustomersQuery = {
|
||||
readonly code?: string;
|
||||
@@ -73,7 +75,11 @@ const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||
constructor(
|
||||
private readonly customersRepository: CustomersRepository,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListCustomersQuery,
|
||||
@@ -133,6 +139,16 @@ export class CustomersService {
|
||||
const created = await this.customersRepository.create(
|
||||
this.toCreateInput(input),
|
||||
);
|
||||
const employee = await this.employeesService.requireByUserId(input.userId);
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'customer_created',
|
||||
sourceType: 'customer',
|
||||
sourceId: created.id,
|
||||
latitude: created.latitude,
|
||||
longitude: created.longitude,
|
||||
customerId: created.id,
|
||||
});
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type CheckInPayload,
|
||||
} from '../shared/check-in-verification';
|
||||
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
|
||||
import type { Attendance } from './attendance';
|
||||
import type {
|
||||
AttendanceCheckInDto,
|
||||
@@ -39,6 +40,7 @@ export class AttendancesService {
|
||||
private readonly branchesService: BranchesService,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly companySettingsService: CompanySettingsService,
|
||||
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -119,6 +121,15 @@ export class AttendancesService {
|
||||
checkInDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'branch_check_in',
|
||||
sourceType: 'attendance',
|
||||
sourceId: created.id,
|
||||
latitude: verified.latitude,
|
||||
longitude: verified.longitude,
|
||||
recordedAt: now.value,
|
||||
});
|
||||
return this.toItem(created);
|
||||
}
|
||||
|
||||
@@ -164,6 +175,15 @@ export class AttendancesService {
|
||||
checkOutDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'branch_check_out',
|
||||
sourceType: 'attendance',
|
||||
sourceId: updated.id,
|
||||
latitude: verified.latitude,
|
||||
longitude: verified.longitude,
|
||||
recordedAt: updated.checkOutAt?.value,
|
||||
});
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { VisitsWriteController } from './visits/visits-write.controller';
|
||||
import { VisitsRepository } from './visits/visits.repository';
|
||||
import { VisitsService } from './visits/visits.service';
|
||||
import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
|
||||
import { TimelineModule } from './timeline/timeline.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,6 +35,7 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
|
||||
CustomersModule,
|
||||
SalesInvoicesModule,
|
||||
PackingSlipsModule,
|
||||
TimelineModule,
|
||||
],
|
||||
controllers: [
|
||||
CompanySettingsController,
|
||||
@@ -65,6 +67,7 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
|
||||
CyclesService,
|
||||
PlansService,
|
||||
VisitsService,
|
||||
TimelineModule,
|
||||
],
|
||||
})
|
||||
export class FieldModule {}
|
||||
|
||||
@@ -5,6 +5,8 @@ export type CompanySetting = {
|
||||
readonly id: string;
|
||||
readonly cycleStartDate: DateTime;
|
||||
readonly checkInRadiusMeters: number;
|
||||
readonly gpsIntervalSeconds: number;
|
||||
readonly checkoutWarningRadiusMeters: number;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
@@ -15,5 +17,7 @@ export type CompanySetting = {
|
||||
export type UpsertCompanySettingInput = {
|
||||
readonly cycleStartDate: DateTime;
|
||||
readonly checkInRadiusMeters?: number;
|
||||
readonly gpsIntervalSeconds?: number;
|
||||
readonly checkoutWarningRadiusMeters?: number;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ export class CompanySettingsRepository {
|
||||
.values({
|
||||
cycleStartDate: input.cycleStartDate.value,
|
||||
checkInRadiusMeters: input.checkInRadiusMeters ?? 100,
|
||||
gpsIntervalSeconds: input.gpsIntervalSeconds ?? 5,
|
||||
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters ?? 200,
|
||||
status: Status.create(Status.DEFAULT).value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
@@ -50,6 +52,12 @@ export class CompanySettingsRepository {
|
||||
...(input.checkInRadiusMeters !== undefined
|
||||
? { checkInRadiusMeters: input.checkInRadiusMeters }
|
||||
: {}),
|
||||
...(input.gpsIntervalSeconds !== undefined
|
||||
? { gpsIntervalSeconds: input.gpsIntervalSeconds }
|
||||
: {}),
|
||||
...(input.checkoutWarningRadiusMeters !== undefined
|
||||
? { checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters }
|
||||
: {}),
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
@@ -63,6 +71,8 @@ export class CompanySettingsRepository {
|
||||
id: row.id,
|
||||
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
|
||||
checkInRadiusMeters: row.checkInRadiusMeters,
|
||||
gpsIntervalSeconds: row.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: row.checkoutWarningRadiusMeters,
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
|
||||
@@ -17,6 +17,8 @@ describe('CompanySettingsService', () => {
|
||||
id: 'set-1',
|
||||
cycleStartDate: DateTime.create('2026-01-05'),
|
||||
checkInRadiusMeters: 100,
|
||||
gpsIntervalSeconds: 5,
|
||||
checkoutWarningRadiusMeters: 200,
|
||||
status: Status.create('draft'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -48,6 +50,8 @@ describe('CompanySettingsService', () => {
|
||||
const result = await service.get();
|
||||
expect(result.cycleStartDate).toBe(sample.cycleStartDate.value);
|
||||
expect(result.checkInRadiusMeters).toBe(100);
|
||||
expect(result.gpsIntervalSeconds).toBe(5);
|
||||
expect(result.checkoutWarningRadiusMeters).toBe(200);
|
||||
});
|
||||
|
||||
it('update persists start of day', async () => {
|
||||
@@ -66,6 +70,21 @@ describe('CompanySettingsService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('update rejects invalid GPS interval', async () => {
|
||||
repository.find.mockResolvedValue(sample);
|
||||
await expect(
|
||||
service.update({ gpsIntervalSeconds: 2 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('requireTimelineConfig returns tracking settings', async () => {
|
||||
repository.find.mockResolvedValue(sample);
|
||||
await expect(service.requireTimelineConfig()).resolves.toEqual({
|
||||
gpsIntervalSeconds: 5,
|
||||
checkoutWarningRadiusMeters: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('update rejects when settings are missing and no cycle date provided', async () => {
|
||||
repository.find.mockResolvedValue(null);
|
||||
await expect(service.update({}, 'user-1')).rejects.toBeInstanceOf(
|
||||
|
||||
@@ -26,6 +26,8 @@ export class CompanySettingsService {
|
||||
input: {
|
||||
cycleStartDate?: string;
|
||||
checkInRadiusMeters?: number;
|
||||
gpsIntervalSeconds?: number;
|
||||
checkoutWarningRadiusMeters?: number;
|
||||
},
|
||||
userId: string,
|
||||
): Promise<ReturnType<CompanySettingsService['toItem']>> {
|
||||
@@ -42,9 +44,24 @@ export class CompanySettingsService {
|
||||
) {
|
||||
throw new BadRequestException('Invalid check-in radius');
|
||||
}
|
||||
if (
|
||||
input.gpsIntervalSeconds !== undefined &&
|
||||
(input.gpsIntervalSeconds < 5 || input.gpsIntervalSeconds > 300)
|
||||
) {
|
||||
throw new BadRequestException('Invalid GPS interval');
|
||||
}
|
||||
if (
|
||||
input.checkoutWarningRadiusMeters !== undefined &&
|
||||
(input.checkoutWarningRadiusMeters < 1 ||
|
||||
input.checkoutWarningRadiusMeters > 10_000)
|
||||
) {
|
||||
throw new BadRequestException('Invalid checkout warning radius');
|
||||
}
|
||||
const saved = await this.companySettingsRepository.upsert({
|
||||
cycleStartDate,
|
||||
checkInRadiusMeters: input.checkInRadiusMeters,
|
||||
gpsIntervalSeconds: input.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters,
|
||||
userId,
|
||||
});
|
||||
return this.toItem(saved);
|
||||
@@ -66,11 +83,27 @@ export class CompanySettingsService {
|
||||
return setting.cycleStartDate;
|
||||
}
|
||||
|
||||
async requireTimelineConfig(): Promise<{
|
||||
gpsIntervalSeconds: number;
|
||||
checkoutWarningRadiusMeters: number;
|
||||
}> {
|
||||
const setting = await this.companySettingsRepository.find();
|
||||
if (!setting) {
|
||||
throw new NotFoundException('Settings not configured');
|
||||
}
|
||||
return {
|
||||
gpsIntervalSeconds: setting.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters,
|
||||
};
|
||||
}
|
||||
|
||||
toItem(setting: CompanySetting) {
|
||||
return {
|
||||
id: setting.id,
|
||||
cycleStartDate: setting.cycleStartDate.value,
|
||||
checkInRadiusMeters: setting.checkInRadiusMeters,
|
||||
gpsIntervalSeconds: setting.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters,
|
||||
status: setting.status.value,
|
||||
createdAt: setting.createdAt.value,
|
||||
updatedAt: setting.updatedAt.value,
|
||||
|
||||
@@ -25,6 +25,20 @@ export class UpdateCompanySettingDto {
|
||||
@Min(1)
|
||||
@Max(10_000)
|
||||
checkInRadiusMeters?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5, minimum: 5, maximum: 300 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(5)
|
||||
@Max(300)
|
||||
gpsIntervalSeconds?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 200, minimum: 1, maximum: 10000 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10_000)
|
||||
checkoutWarningRadiusMeters?: number;
|
||||
}
|
||||
|
||||
export class CompanySettingDto {
|
||||
@@ -37,6 +51,12 @@ export class CompanySettingDto {
|
||||
@ApiProperty({ example: 100 })
|
||||
checkInRadiusMeters!: number;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
gpsIntervalSeconds!: number;
|
||||
|
||||
@ApiProperty({ example: 200 })
|
||||
checkoutWarningRadiusMeters!: number;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ export const ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY =
|
||||
export const MOBILE_SALES_PLAN_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN';
|
||||
export const MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY = 'MOBILE.LOGISTICS.PLAN';
|
||||
export const SETTINGS_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.SETTING';
|
||||
export const ADMIN_SALES_TIMELINE_PRIVILEGE_KEY =
|
||||
'ADMIN.SALES.ACTIVITIES.TIMELINE';
|
||||
export const MOBILE_SALES_TIMELINE_PRIVILEGE_KEY = 'MOBILE.SALES.TIMELINE';
|
||||
export const FIELD_ATTENDANCE_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN.ATTENDANCE';
|
||||
export const FIELD_VISIT_PRIVILEGE_KEY = 'MOBILE.SALES.VISIT';
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
DefaultRelationDto,
|
||||
PaginationMetaDto,
|
||||
} from '../../../../common/http/response';
|
||||
import { TIMELINE_ACTIVITY_TYPES } from '../../../../database/timeline-activities-table';
|
||||
|
||||
export class TimelineConfigDto {
|
||||
@ApiProperty({ example: 5 })
|
||||
gpsIntervalSeconds!: number;
|
||||
|
||||
@ApiProperty({ example: 200 })
|
||||
checkoutWarningRadiusMeters!: number;
|
||||
}
|
||||
|
||||
export class TimelineFootprintPointDto {
|
||||
@ApiProperty({ example: -6.2 })
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty({ example: 106.8 })
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms when the point was recorded' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
recordedAt!: number;
|
||||
}
|
||||
|
||||
export class IngestTimelineFootprintsDto {
|
||||
@ApiProperty({ type: [TimelineFootprintPointDto] })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayMaxSize(100)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TimelineFootprintPointDto)
|
||||
points!: TimelineFootprintPointDto[];
|
||||
}
|
||||
|
||||
export class IngestTimelineFootprintsResultDto {
|
||||
@ApiProperty()
|
||||
inserted!: number;
|
||||
}
|
||||
|
||||
export class ListTimelineQueryDto {
|
||||
@ApiPropertyOptional({ example: '2026-09-01', description: 'YYYY-MM-DD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
employeeId?: string;
|
||||
}
|
||||
|
||||
export class TimelineFootprintDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: DefaultRelationDto })
|
||||
employee!: DefaultRelationDto;
|
||||
|
||||
@ApiProperty()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
recordedAt!: number;
|
||||
}
|
||||
|
||||
export class TimelineActivityDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: DefaultRelationDto })
|
||||
employee!: DefaultRelationDto;
|
||||
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
customer!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty({ format: 'uuid', nullable: true })
|
||||
visitId!: string | null;
|
||||
|
||||
@ApiProperty({ enum: TIMELINE_ACTIVITY_TYPES })
|
||||
type!: string;
|
||||
|
||||
@ApiProperty()
|
||||
sourceType!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
sourceId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
recordedAt!: number;
|
||||
}
|
||||
|
||||
export class TimelineDayDto {
|
||||
@ApiProperty()
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ type: [TimelineFootprintDto] })
|
||||
footprints!: TimelineFootprintDto[];
|
||||
|
||||
@ApiProperty({ type: [TimelineActivityDto] })
|
||||
activities!: TimelineActivityDto[];
|
||||
}
|
||||
|
||||
export class TimelineMeDto {
|
||||
@ApiProperty()
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ type: [TimelineActivityDto] })
|
||||
activities!: TimelineActivityDto[];
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { customers } from '../../../database/customers-table';
|
||||
import {
|
||||
timelineActivities,
|
||||
type TimelineActivityRow,
|
||||
type TimelineActivityType,
|
||||
} from '../../../database/timeline-activities-table';
|
||||
import { visits } from '../../../database/visits-table';
|
||||
import { employees } from '../../../database/schema';
|
||||
import type {
|
||||
ListTimelineFilters,
|
||||
RecordTimelineActivityInput,
|
||||
TimelineActivity,
|
||||
} from './timeline.types';
|
||||
|
||||
@Injectable()
|
||||
export class TimelineActivitiesRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async insert(input: RecordTimelineActivityInput): Promise<TimelineActivity> {
|
||||
const recordedAt = input.recordedAt ?? Date.now();
|
||||
const inserted = await this.db
|
||||
.insert(timelineActivities)
|
||||
.values({
|
||||
employeeId: input.employeeId,
|
||||
customerId: input.customerId ?? null,
|
||||
visitId: input.visitId ?? null,
|
||||
type: input.type,
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId,
|
||||
latitude: input.latitude,
|
||||
longitude: input.longitude,
|
||||
recordedAt,
|
||||
})
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employeeId,
|
||||
customerId: row.customerId,
|
||||
visitId: row.visitId,
|
||||
type: row.type as TimelineActivityType,
|
||||
sourceType: row.sourceType,
|
||||
sourceId: row.sourceId,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
recordedAt: row.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findOpenVisitForEmployee(
|
||||
employeeId: string,
|
||||
): Promise<{ visitId: string; customerId: string } | null> {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
visitId: visits.id,
|
||||
customerId: visits.customerId,
|
||||
})
|
||||
.from(visits)
|
||||
.where(
|
||||
and(
|
||||
eq(visits.employeeId, employeeId),
|
||||
isNull(visits.checkOutAt),
|
||||
sql`${visits.status} <> 'archived'`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { visitId: row.visitId, customerId: row.customerId } : null;
|
||||
}
|
||||
|
||||
async list(filters: ListTimelineFilters): Promise<TimelineActivity[]> {
|
||||
const conditions = [
|
||||
gte(timelineActivities.recordedAt, filters.dayStartMs),
|
||||
lte(timelineActivities.recordedAt, filters.dayEndMs),
|
||||
];
|
||||
if (filters.employeeId) {
|
||||
conditions.push(eq(timelineActivities.employeeId, filters.employeeId));
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
activity: timelineActivities,
|
||||
employee: employees,
|
||||
customer: customers,
|
||||
})
|
||||
.from(timelineActivities)
|
||||
.innerJoin(employees, eq(timelineActivities.employeeId, employees.id))
|
||||
.leftJoin(customers, eq(timelineActivities.customerId, customers.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(timelineActivities.recordedAt);
|
||||
|
||||
return rows.map((row) =>
|
||||
this.toDomain(row.activity, row.employee, row.customer),
|
||||
);
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: TimelineActivityRow,
|
||||
employee?: typeof employees.$inferSelect,
|
||||
customer?: typeof customers.$inferSelect | null,
|
||||
): TimelineActivity {
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employeeId,
|
||||
customerId: row.customerId,
|
||||
visitId: row.visitId,
|
||||
type: row.type as TimelineActivityType,
|
||||
sourceType: row.sourceType,
|
||||
sourceId: row.sourceId,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
recordedAt: row.recordedAt,
|
||||
...(employee
|
||||
? {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
code: employee.code,
|
||||
name: employee.name,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
customer: customer
|
||||
? { id: customer.id, code: customer.code, name: customer.name }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
|
||||
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||
import type { RecordTimelineActivityInput } from './timeline.types';
|
||||
|
||||
@Injectable()
|
||||
export class TimelineActivitiesService {
|
||||
constructor(
|
||||
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
|
||||
) {}
|
||||
|
||||
async record(input: RecordTimelineActivityInput): Promise<void> {
|
||||
const openVisit =
|
||||
input.visitId === undefined && input.customerId === undefined
|
||||
? await this.timelineActivitiesRepository.findOpenVisitForEmployee(
|
||||
input.employeeId,
|
||||
)
|
||||
: null;
|
||||
|
||||
await this.timelineActivitiesRepository.insert({
|
||||
...input,
|
||||
visitId: input.visitId ?? openVisit?.visitId ?? null,
|
||||
customerId:
|
||||
input.customerId ??
|
||||
openVisit?.customerId ??
|
||||
(input.type === 'customer_created' ? input.sourceId : null),
|
||||
});
|
||||
}
|
||||
|
||||
async recordIfLocated(input: {
|
||||
readonly employeeId: string;
|
||||
readonly type: TimelineActivityType;
|
||||
readonly sourceType: string;
|
||||
readonly sourceId: string;
|
||||
readonly latitude?: number | null;
|
||||
readonly longitude?: number | null;
|
||||
readonly recordedAt?: number;
|
||||
readonly customerId?: string | null;
|
||||
readonly visitId?: string | null;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
input.latitude === undefined ||
|
||||
input.latitude === null ||
|
||||
input.longitude === undefined ||
|
||||
input.longitude === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.record({
|
||||
employeeId: input.employeeId,
|
||||
type: input.type,
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId,
|
||||
latitude: input.latitude,
|
||||
longitude: input.longitude,
|
||||
recordedAt: input.recordedAt,
|
||||
customerId: input.customerId,
|
||||
visitId: input.visitId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, gte, lte } from 'drizzle-orm';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
timelineFootprints,
|
||||
type TimelineFootprintRow,
|
||||
} from '../../../database/timeline-footprints-table';
|
||||
import { employees } from '../../../database/schema';
|
||||
import type {
|
||||
IngestFootprintPoint,
|
||||
ListTimelineFilters,
|
||||
TimelineFootprint,
|
||||
} from './timeline.types';
|
||||
|
||||
@Injectable()
|
||||
export class TimelineFootprintsRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async insertMany(
|
||||
employeeId: string,
|
||||
points: readonly IngestFootprintPoint[],
|
||||
): Promise<number> {
|
||||
if (points.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const rows = points.map((point) => ({
|
||||
employeeId,
|
||||
latitude: point.latitude,
|
||||
longitude: point.longitude,
|
||||
recordedAt: point.recordedAt,
|
||||
}));
|
||||
const inserted = await this.db
|
||||
.insert(timelineFootprints)
|
||||
.values(rows)
|
||||
.returning({ id: timelineFootprints.id });
|
||||
return inserted.length;
|
||||
}
|
||||
|
||||
async list(filters: ListTimelineFilters): Promise<TimelineFootprint[]> {
|
||||
const conditions = [
|
||||
gte(timelineFootprints.recordedAt, filters.dayStartMs),
|
||||
lte(timelineFootprints.recordedAt, filters.dayEndMs),
|
||||
];
|
||||
if (filters.employeeId) {
|
||||
conditions.push(eq(timelineFootprints.employeeId, filters.employeeId));
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
footprint: timelineFootprints,
|
||||
employee: employees,
|
||||
})
|
||||
.from(timelineFootprints)
|
||||
.innerJoin(employees, eq(timelineFootprints.employeeId, employees.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(timelineFootprints.recordedAt);
|
||||
|
||||
return rows.map((row) => this.toDomain(row.footprint, row.employee));
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: TimelineFootprintRow,
|
||||
employee?: typeof employees.$inferSelect,
|
||||
): TimelineFootprint {
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employeeId,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
recordedAt: row.recordedAt,
|
||||
...(employee
|
||||
? {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
code: employee.code,
|
||||
name: employee.name,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
ADMIN_SALES_TIMELINE_PRIVILEGE_KEY,
|
||||
FIELD_ATTENDANCE_PRIVILEGE_KEY,
|
||||
MOBILE_SALES_TIMELINE_PRIVILEGE_KEY,
|
||||
} from '../shared/field-purpose';
|
||||
import {
|
||||
ListTimelineQueryDto,
|
||||
TimelineConfigDto,
|
||||
TimelineDayDto,
|
||||
TimelineMeDto,
|
||||
} from './dto/timeline.dto';
|
||||
import { TimelineService } from './timeline.service';
|
||||
|
||||
@ApiTags('timeline')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('timeline')
|
||||
export class TimelineReadController {
|
||||
constructor(private readonly timelineService: TimelineService) {}
|
||||
|
||||
@Get('config')
|
||||
@RequirePrivilege(
|
||||
[MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, FIELD_ATTENDANCE_PRIVILEGE_KEY],
|
||||
'view',
|
||||
)
|
||||
@ApiOperation({ summary: 'Get timeline tracking configuration' })
|
||||
@ApiOkResponse({ type: TimelineConfigDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
config(): Promise<TimelineConfigDto> {
|
||||
return this.timelineService.getConfig();
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get current user timeline activities for a day' })
|
||||
@ApiOkResponse({ type: TimelineMeDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
me(
|
||||
@Query() query: ListTimelineQueryDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<TimelineMeDto> {
|
||||
return this.timelineService.getMyDay(query, userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePrivilege(ADMIN_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get timeline footprints and activities for a day' })
|
||||
@ApiOkResponse({ type: TimelineDayDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
day(@Query() query: ListTimelineQueryDto): Promise<TimelineDayDto> {
|
||||
return this.timelineService.getDay(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { MOBILE_SALES_TIMELINE_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import {
|
||||
IngestTimelineFootprintsDto,
|
||||
IngestTimelineFootprintsResultDto,
|
||||
} from './dto/timeline.dto';
|
||||
import { TimelineService } from './timeline.service';
|
||||
|
||||
@ApiTags('timeline')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('timeline')
|
||||
export class TimelineWriteController {
|
||||
constructor(private readonly timelineService: TimelineService) {}
|
||||
|
||||
@Post('footprints')
|
||||
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Ingest GPS footprint points' })
|
||||
@ApiCreatedResponse({ type: IngestTimelineFootprintsResultDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
ingestFootprints(
|
||||
@Body() dto: IngestTimelineFootprintsDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<IngestTimelineFootprintsResultDto> {
|
||||
return this.timelineService.ingestFootprints(dto, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||
import { CompanySettingsRepository } from '../settings/company-settings.repository';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||
import { TimelineActivitiesService } from './timeline-activities.service';
|
||||
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
|
||||
import { TimelineReadController } from './timeline-read.controller';
|
||||
import { TimelineWriteController } from './timeline-write.controller';
|
||||
import { TimelineService } from './timeline.service';
|
||||
|
||||
@Module({
|
||||
imports: [EmployeesModule],
|
||||
controllers: [TimelineReadController, TimelineWriteController],
|
||||
providers: [
|
||||
CompanySettingsRepository,
|
||||
CompanySettingsService,
|
||||
TimelineFootprintsRepository,
|
||||
TimelineActivitiesRepository,
|
||||
TimelineActivitiesService,
|
||||
TimelineService,
|
||||
],
|
||||
exports: [TimelineActivitiesService, TimelineService],
|
||||
})
|
||||
export class TimelineModule {}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import type { CompanySetting } from '../settings/company-setting';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
|
||||
import { TimelineService } from './timeline.service';
|
||||
|
||||
describe('TimelineService', () => {
|
||||
let service: TimelineService;
|
||||
let footprintsRepository: jest.Mocked<
|
||||
Pick<TimelineFootprintsRepository, 'insertMany' | 'list'>
|
||||
>;
|
||||
let activitiesRepository: jest.Mocked<Pick<TimelineActivitiesRepository, 'list'>>;
|
||||
let employeesService: jest.Mocked<Pick<EmployeesService, 'requireByUserId'>>;
|
||||
let companySettingsService: jest.Mocked<
|
||||
Pick<CompanySettingsService, 'requireTimelineConfig'>
|
||||
>;
|
||||
|
||||
const nowMs = DateTime.fromUnixMs(Date.now()).startOfDay().value + 3_600_000;
|
||||
|
||||
beforeEach(async () => {
|
||||
footprintsRepository = {
|
||||
insertMany: jest.fn(),
|
||||
list: jest.fn(),
|
||||
};
|
||||
activitiesRepository = {
|
||||
list: jest.fn(),
|
||||
};
|
||||
employeesService = {
|
||||
requireByUserId: jest.fn(),
|
||||
};
|
||||
companySettingsService = {
|
||||
requireTimelineConfig: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TimelineService,
|
||||
{ provide: TimelineFootprintsRepository, useValue: footprintsRepository },
|
||||
{ provide: TimelineActivitiesRepository, useValue: activitiesRepository },
|
||||
{ provide: EmployeesService, useValue: employeesService },
|
||||
{ provide: CompanySettingsService, useValue: companySettingsService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(TimelineService);
|
||||
companySettingsService.requireTimelineConfig.mockResolvedValue({
|
||||
gpsIntervalSeconds: 5,
|
||||
checkoutWarningRadiusMeters: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns timeline config from company settings', async () => {
|
||||
await expect(service.getConfig()).resolves.toEqual({
|
||||
gpsIntervalSeconds: 5,
|
||||
checkoutWarningRadiusMeters: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('ingests footprint points for the current employee', async () => {
|
||||
employeesService.requireByUserId.mockResolvedValue({
|
||||
id: 'emp-1',
|
||||
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
|
||||
footprintsRepository.insertMany.mockResolvedValue(1);
|
||||
|
||||
const result = await service.ingestFootprints(
|
||||
{
|
||||
points: [{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs }],
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1 });
|
||||
expect(footprintsRepository.insertMany).toHaveBeenCalledWith('emp-1', [
|
||||
{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects stale footprint timestamps', async () => {
|
||||
employeesService.requireByUserId.mockResolvedValue({
|
||||
id: 'emp-1',
|
||||
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
|
||||
|
||||
await expect(
|
||||
service.ingestFootprints(
|
||||
{
|
||||
points: [
|
||||
{
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
recordedAt: Date.now() - 25 * 60 * 60 * 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('downsamples footprints when querying all employees', async () => {
|
||||
const footprints = Array.from({ length: 4000 }, (_, index) => ({
|
||||
id: `fp-${index}`,
|
||||
employeeId: 'emp-1',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8 + index * 0.0001,
|
||||
recordedAt: nowMs + index * 1000,
|
||||
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
}));
|
||||
footprintsRepository.list.mockResolvedValue(footprints);
|
||||
activitiesRepository.list.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getDay({ date: '2026-09-01' });
|
||||
|
||||
expect(result.footprints.length).toBeLessThanOrEqual(2000);
|
||||
expect(result.activities).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
pickRelation,
|
||||
} from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import type {
|
||||
IngestTimelineFootprintsDto,
|
||||
ListTimelineQueryDto,
|
||||
TimelineActivityDto,
|
||||
TimelineConfigDto,
|
||||
TimelineDayDto,
|
||||
TimelineFootprintDto,
|
||||
TimelineMeDto,
|
||||
} from './dto/timeline.dto';
|
||||
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
|
||||
import type { TimelineActivity, TimelineFootprint } from './timeline.types';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const MAX_FOOTPRINTS_ALL_EMPLOYEES = 2000;
|
||||
const MAX_FOOTPRINT_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class TimelineService {
|
||||
constructor(
|
||||
private readonly timelineFootprintsRepository: TimelineFootprintsRepository,
|
||||
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly companySettingsService: CompanySettingsService,
|
||||
) {}
|
||||
|
||||
async getConfig(): Promise<TimelineConfigDto> {
|
||||
return this.companySettingsService.requireTimelineConfig();
|
||||
}
|
||||
|
||||
async ingestFootprints(
|
||||
dto: IngestTimelineFootprintsDto,
|
||||
userId: string,
|
||||
): Promise<{ inserted: number }> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const now = Date.now();
|
||||
const points = dto.points.map((point) => {
|
||||
this.assertCoordinates(point.latitude, point.longitude);
|
||||
if (now - point.recordedAt > MAX_FOOTPRINT_AGE_MS) {
|
||||
throw new BadRequestException('Footprint timestamp is too old');
|
||||
}
|
||||
if (point.recordedAt > now + 60_000) {
|
||||
throw new BadRequestException('Footprint timestamp is in the future');
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
const inserted = await this.timelineFootprintsRepository.insertMany(
|
||||
employee.id,
|
||||
points,
|
||||
);
|
||||
return { inserted };
|
||||
}
|
||||
|
||||
async getDay(query: ListTimelineQueryDto): Promise<TimelineDayDto> {
|
||||
const { dateLabel, filters } = this.resolveDayFilters(query);
|
||||
const [footprints, activities] = await Promise.all([
|
||||
this.timelineFootprintsRepository.list(filters),
|
||||
this.timelineActivitiesRepository.list(filters),
|
||||
]);
|
||||
|
||||
return {
|
||||
date: dateLabel,
|
||||
footprints: this.downsampleFootprints(footprints, query.employeeId).map(
|
||||
(item) => this.toFootprintDto(item),
|
||||
),
|
||||
activities: activities.map((item) => this.toActivityDto(item)),
|
||||
};
|
||||
}
|
||||
|
||||
async getMyDay(
|
||||
query: ListTimelineQueryDto,
|
||||
userId: string,
|
||||
): Promise<TimelineMeDto> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const { dateLabel, filters } = this.resolveDayFilters({
|
||||
...query,
|
||||
employeeId: employee.id,
|
||||
});
|
||||
const activities = await this.timelineActivitiesRepository.list(filters);
|
||||
return {
|
||||
date: dateLabel,
|
||||
activities: activities.map((item) => this.toActivityDto(item)),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveDayFilters(query: ListTimelineQueryDto): {
|
||||
dateLabel: string;
|
||||
filters: {
|
||||
dayStartMs: number;
|
||||
dayEndMs: number;
|
||||
employeeId?: string;
|
||||
};
|
||||
} {
|
||||
const dateLabel = this.resolveDateLabel(query.date);
|
||||
const dayStart = DateTime.create(dateLabel).startOfDay();
|
||||
return {
|
||||
dateLabel,
|
||||
filters: {
|
||||
dayStartMs: dayStart.value,
|
||||
dayEndMs: dayStart.value + MS_PER_DAY - 1,
|
||||
employeeId: query.employeeId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveDateLabel(raw?: string): string {
|
||||
if (!raw) {
|
||||
return DateTime.fromUnixMs(Date.now()).startOfDay().format().slice(0, 10);
|
||||
}
|
||||
try {
|
||||
DateTime.create(raw);
|
||||
return raw;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDateTimeError) {
|
||||
throw new BadRequestException('Invalid date');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private downsampleFootprints(
|
||||
footprints: readonly TimelineFootprint[],
|
||||
employeeId?: string,
|
||||
): TimelineFootprint[] {
|
||||
if (employeeId || footprints.length <= MAX_FOOTPRINTS_ALL_EMPLOYEES) {
|
||||
return [...footprints];
|
||||
}
|
||||
const stride = Math.ceil(
|
||||
footprints.length / MAX_FOOTPRINTS_ALL_EMPLOYEES,
|
||||
);
|
||||
return footprints.filter((_, index) => index % stride === 0);
|
||||
}
|
||||
|
||||
private assertCoordinates(latitude: number, longitude: number): void {
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
throw new BadRequestException('Invalid coordinates');
|
||||
}
|
||||
}
|
||||
|
||||
private toFootprintDto(footprint: TimelineFootprint): TimelineFootprintDto {
|
||||
return {
|
||||
id: footprint.id,
|
||||
employee: pickRelation(footprint.employee, DEFAULT_RELATION_FIELDS)!,
|
||||
latitude: footprint.latitude,
|
||||
longitude: footprint.longitude,
|
||||
recordedAt: footprint.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private toActivityDto(activity: TimelineActivity): TimelineActivityDto {
|
||||
return {
|
||||
id: activity.id,
|
||||
employee: pickRelation(activity.employee, DEFAULT_RELATION_FIELDS)!,
|
||||
customer: activity.customer
|
||||
? pickRelation(activity.customer, DEFAULT_RELATION_FIELDS)
|
||||
: null,
|
||||
visitId: activity.visitId,
|
||||
type: activity.type,
|
||||
sourceType: activity.sourceType,
|
||||
sourceId: activity.sourceId,
|
||||
latitude: activity.latitude,
|
||||
longitude: activity.longitude,
|
||||
recordedAt: activity.recordedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
|
||||
|
||||
export type TimelineFootprint = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly recordedAt: number;
|
||||
readonly employee?: {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TimelineActivity = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly customerId: string | null;
|
||||
readonly visitId: string | null;
|
||||
readonly type: TimelineActivityType;
|
||||
readonly sourceType: string;
|
||||
readonly sourceId: string;
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly recordedAt: number;
|
||||
readonly employee?: {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
};
|
||||
readonly customer?: {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type RecordTimelineActivityInput = {
|
||||
readonly employeeId: string;
|
||||
readonly type: TimelineActivityType;
|
||||
readonly sourceType: string;
|
||||
readonly sourceId: string;
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly recordedAt?: number;
|
||||
readonly customerId?: string | null;
|
||||
readonly visitId?: string | null;
|
||||
};
|
||||
|
||||
export type IngestFootprintPoint = {
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly recordedAt: number;
|
||||
};
|
||||
|
||||
export type ListTimelineFilters = {
|
||||
readonly dayStartMs: number;
|
||||
readonly dayEndMs: number;
|
||||
readonly employeeId?: string;
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type CheckInPayload,
|
||||
} from '../shared/check-in-verification';
|
||||
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
|
||||
import type { Visit } from './visit';
|
||||
import type {
|
||||
ListVisitsQueryDto,
|
||||
@@ -40,6 +41,7 @@ export class VisitsService {
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly companySettingsService: CompanySettingsService,
|
||||
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||
) {}
|
||||
|
||||
async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> {
|
||||
@@ -131,6 +133,17 @@ export class VisitsService {
|
||||
checkInDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'customer_check_in',
|
||||
sourceType: 'visit',
|
||||
sourceId: created.id,
|
||||
latitude: verified.latitude,
|
||||
longitude: verified.longitude,
|
||||
recordedAt: now.value,
|
||||
customerId: dto.customerId,
|
||||
visitId: created.id,
|
||||
});
|
||||
return this.toItem(created);
|
||||
}
|
||||
|
||||
@@ -175,6 +188,17 @@ export class VisitsService {
|
||||
checkOutDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'customer_check_out',
|
||||
sourceType: 'visit',
|
||||
sourceId: updated.id,
|
||||
latitude: verified.latitude,
|
||||
longitude: verified.longitude,
|
||||
recordedAt: updated.checkOutAt?.value,
|
||||
customerId: visit.customerId,
|
||||
visitId: updated.id,
|
||||
});
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CustomersModule } from '../../configuration/customers/customers.module'
|
||||
import { DivisionsModule } from '../../configuration/divisions/divisions.module';
|
||||
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||
import { ProductsModule } from '../../configuration/products/products.module';
|
||||
import { TimelineModule } from '../../field/timeline/timeline.module';
|
||||
import { SalesRequestsModule } from '../sales-requests/sales-requests.module';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module';
|
||||
@@ -20,6 +21,7 @@ import { SalesOrdersService } from './sales-orders.service';
|
||||
CustomersModule,
|
||||
ProductsModule,
|
||||
SalesRequestsModule,
|
||||
TimelineModule,
|
||||
forwardRef(() => SalesDocumentFlowModule),
|
||||
],
|
||||
controllers: [SalesOrdersReadController, SalesOrdersWriteController],
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CustomersService } from '../../configuration/customers/customers.servic
|
||||
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
|
||||
import { SalesRequestsService } from '../sales-requests/sales-requests.service';
|
||||
import {
|
||||
isValidDocumentAddress,
|
||||
@@ -95,6 +96,7 @@ export class SalesOrdersService {
|
||||
private readonly salesRequestsService: SalesRequestsService,
|
||||
@Inject(forwardRef(() => SalesDocumentFlowService))
|
||||
private readonly salesDocumentFlowService: SalesDocumentFlowService,
|
||||
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -152,6 +154,14 @@ export class SalesOrdersService {
|
||||
const created = await this.salesOrdersRepository.create(
|
||||
await this.toCreateInput(merged),
|
||||
);
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: created.salesPersonId,
|
||||
type: 'sales_order_created',
|
||||
sourceType: 'sales-order',
|
||||
sourceId: created.id,
|
||||
latitude: created.latitude,
|
||||
longitude: created.longitude,
|
||||
});
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,14 @@ import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
@@ -89,6 +92,20 @@ export class CreateSalesPaymentDto {
|
||||
@IsOptional()
|
||||
@IsIn([...SALES_PAYMENT_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: -6.2 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 106.8 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
export class UpdateSalesPaymentDto {
|
||||
|
||||
@@ -134,6 +134,8 @@ export class SalesPaymentsWriteController {
|
||||
invoices: dto.invoices,
|
||||
images: dto.images,
|
||||
status: dto.status,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||
import { TimelineModule } from '../../field/timeline/timeline.module';
|
||||
import { SalesInvoicesModule } from '../sales-invoices/sales-invoices.module';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { SalesPaymentsReadController } from './sales-payments-read.controller';
|
||||
@@ -7,7 +9,7 @@ import { SalesPaymentsRepository } from './sales-payments.repository';
|
||||
import { SalesPaymentsService } from './sales-payments.service';
|
||||
|
||||
@Module({
|
||||
imports: [SalesInvoicesModule],
|
||||
imports: [SalesInvoicesModule, EmployeesModule, TimelineModule],
|
||||
controllers: [SalesPaymentsReadController, SalesPaymentsWriteController],
|
||||
providers: [
|
||||
DocumentCodeService,
|
||||
|
||||
@@ -14,6 +14,8 @@ import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
|
||||
import { SalesInvoicesService } from '../sales-invoices/sales-invoices.service';
|
||||
import {
|
||||
isValidDocumentCode,
|
||||
@@ -65,6 +67,8 @@ export class SalesPaymentsService {
|
||||
constructor(
|
||||
private readonly salesPaymentsRepository: SalesPaymentsRepository,
|
||||
private readonly salesInvoicesService: SalesInvoicesService,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -109,11 +113,22 @@ export class SalesPaymentsService {
|
||||
invoices: PaymentAllocationBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||
const created = await this.salesPaymentsRepository.create(
|
||||
await this.toCreateInput(input),
|
||||
);
|
||||
const employee = await this.employeesService.requireByUserId(input.userId);
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'sales_payment_created',
|
||||
sourceType: 'sales-payment',
|
||||
sourceId: created.id,
|
||||
latitude: input.latitude,
|
||||
longitude: input.longitude,
|
||||
});
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CustomersModule } from '../../configuration/customers/customers.module'
|
||||
import { DivisionsModule } from '../../configuration/divisions/divisions.module';
|
||||
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||
import { ProductsModule } from '../../configuration/products/products.module';
|
||||
import { TimelineModule } from '../../field/timeline/timeline.module';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { SalesRequestsReadController } from './sales-requests-read.controller';
|
||||
import { SalesRequestsWriteController } from './sales-requests-write.controller';
|
||||
@@ -17,6 +18,7 @@ import { SalesRequestsService } from './sales-requests.service';
|
||||
DivisionsModule,
|
||||
CustomersModule,
|
||||
ProductsModule,
|
||||
TimelineModule,
|
||||
],
|
||||
controllers: [SalesRequestsReadController, SalesRequestsWriteController],
|
||||
providers: [
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CustomersService } from '../../configuration/customers/customers.servic
|
||||
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
|
||||
import {
|
||||
isValidDocumentAddress,
|
||||
isValidDocumentCode,
|
||||
@@ -84,6 +85,7 @@ export class SalesRequestsService {
|
||||
private readonly divisionsService: DivisionsService,
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly productsService: ProductsService,
|
||||
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -141,6 +143,14 @@ export class SalesRequestsService {
|
||||
const created = await this.salesRequestsRepository.create(
|
||||
await this.toCreateInput(input),
|
||||
);
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: created.salesPersonId,
|
||||
type: 'sales_request_created',
|
||||
sourceType: 'sales-request',
|
||||
sourceId: created.id,
|
||||
latitude: created.latitude,
|
||||
longitude: created.longitude,
|
||||
});
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user