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:
@@ -6,6 +6,7 @@ import loadEnv from './config/env';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { ConfigurationModule } from './modules/configuration/configuration.module';
|
||||
import { FieldModule } from './modules/field/field.module';
|
||||
import { SalesModule } from './modules/sales/sales.module';
|
||||
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
@@ -22,6 +23,7 @@ import { UsersModule } from './modules/users/users.module';
|
||||
PrivilegesModule,
|
||||
ConfigurationModule,
|
||||
SalesModule,
|
||||
FieldModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -92,8 +92,24 @@ describe('DateTime', () => {
|
||||
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||
});
|
||||
|
||||
it('rejects date-only strings', () => {
|
||||
expect(() => DateTime.create('2026-08-20')).toThrow(InvalidDateTimeError);
|
||||
it('parses date-only strings as start of day in DEFAULT_TIMEZONE (GMT+7)', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
|
||||
const dt = DateTime.create('2026-08-20');
|
||||
|
||||
expect(dt.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0));
|
||||
});
|
||||
|
||||
it('parses date-only strings using a custom DEFAULT_TIMEZONE', () => {
|
||||
process.env.DEFAULT_TIMEZONE = 'UTC+0';
|
||||
|
||||
const dt = DateTime.create('2026-08-20');
|
||||
|
||||
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 0, 0, 0, 0));
|
||||
});
|
||||
|
||||
it('rejects invalid calendar dates on date-only strings', () => {
|
||||
expect(() => DateTime.create('2026-02-30')).toThrow(InvalidDateTimeError);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
@@ -299,4 +315,82 @@ describe('DateTime', () => {
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startOfDay', () => {
|
||||
it('returns midnight of the same calendar day in DEFAULT_TIMEZONE', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const dt = DateTime.create('2026-08-20T17:30:00+07:00');
|
||||
|
||||
const start = dt.startOfDay();
|
||||
|
||||
expect(start.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0));
|
||||
expect(start.equals(DateTime.create('2026-08-20'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mutate the original instant', () => {
|
||||
const dt = DateTime.create('2026-08-20T10:00:00Z');
|
||||
const before = dt.value;
|
||||
|
||||
dt.startOfDay();
|
||||
|
||||
expect(dt.value).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekdayName', () => {
|
||||
it('returns monday through sunday in DEFAULT_TIMEZONE', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
|
||||
expect(DateTime.create('2026-08-24').weekdayName()).toBe('monday');
|
||||
expect(DateTime.create('2026-08-25').weekdayName()).toBe('tuesday');
|
||||
expect(DateTime.create('2026-08-26').weekdayName()).toBe('wednesday');
|
||||
expect(DateTime.create('2026-08-27').weekdayName()).toBe('thursday');
|
||||
expect(DateTime.create('2026-08-28').weekdayName()).toBe('friday');
|
||||
expect(DateTime.create('2026-08-29').weekdayName()).toBe('saturday');
|
||||
expect(DateTime.create('2026-08-30').weekdayName()).toBe('sunday');
|
||||
});
|
||||
|
||||
it('uses the calendar day in DEFAULT_TIMEZONE, not UTC', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
// 2026-08-24 00:30 GMT+7 is still Sunday UTC
|
||||
const dt = DateTime.create('2026-08-24T00:30:00+07:00');
|
||||
|
||||
expect(dt.weekdayName()).toBe('monday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wholeWeeksSince', () => {
|
||||
it('returns 0 on the epoch day and through the next 6 days', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const epoch = DateTime.create('2026-01-05');
|
||||
|
||||
expect(DateTime.create('2026-01-05').wholeWeeksSince(epoch)).toBe(0);
|
||||
expect(DateTime.create('2026-01-11').wholeWeeksSince(epoch)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 at +7 days (next week)', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const epoch = DateTime.create('2026-01-05');
|
||||
|
||||
expect(DateTime.create('2026-01-12').wholeWeeksSince(epoch)).toBe(1);
|
||||
});
|
||||
|
||||
it('wraps so remainder 0 of a 1-based week index is the last cycle', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const epoch = DateTime.create('2026-01-05');
|
||||
const totalCycles = 3;
|
||||
const week2 = DateTime.create('2026-01-19'); // wholeWeeks = 2
|
||||
const cycleNumber = (week2.wholeWeeksSince(epoch) % totalCycles) + 1;
|
||||
|
||||
expect(week2.wholeWeeksSince(epoch)).toBe(2);
|
||||
expect(cycleNumber).toBe(3);
|
||||
});
|
||||
|
||||
it('returns a negative count when the date is before the epoch', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const epoch = DateTime.create('2026-01-05');
|
||||
|
||||
expect(DateTime.create('2025-12-29').wholeWeeksSince(epoch)).toBe(-1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,24 @@ const MAX_UNIX_MS = 8_640_000_000_000_000;
|
||||
const ISO_DATETIME =
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i;
|
||||
|
||||
/** Calendar date (YYYY-MM-DD), interpreted as 00:00:00 in DEFAULT_TIMEZONE. */
|
||||
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
const WEEKDAY_NAMES = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
] as const;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
||||
|
||||
export type WeekdayName = (typeof WEEKDAY_NAMES)[number];
|
||||
|
||||
/** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */
|
||||
const TZ_OFFSET =
|
||||
/^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i;
|
||||
@@ -149,6 +167,20 @@ export class DateTime {
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
const dateOnly = ISO_DATE.exec(trimmed);
|
||||
if (dateOnly) {
|
||||
return DateTime.fromParts(
|
||||
Number.parseInt(dateOnly[1], 10),
|
||||
Number.parseInt(dateOnly[2], 10),
|
||||
Number.parseInt(dateOnly[3], 10),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
resolveDefaultOffsetMinutes(),
|
||||
);
|
||||
}
|
||||
|
||||
const match = ISO_DATETIME.exec(trimmed);
|
||||
if (!match) {
|
||||
throw new InvalidDateTimeError();
|
||||
@@ -175,7 +207,7 @@ export class DateTime {
|
||||
offsetMinutes = resolveDefaultOffsetMinutes();
|
||||
}
|
||||
|
||||
const utcMs = utcMsFromParts(
|
||||
return DateTime.fromParts(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
@@ -185,8 +217,6 @@ export class DateTime {
|
||||
ms,
|
||||
offsetMinutes,
|
||||
);
|
||||
|
||||
return new DateTime(utcMs, DateTime.createToken);
|
||||
}
|
||||
|
||||
static fromUnixMs(ms: number): DateTime {
|
||||
@@ -217,6 +247,32 @@ export class DateTime {
|
||||
return formatInOffset(this.unixMs, offsetMinutes);
|
||||
}
|
||||
|
||||
startOfDay(): DateTime {
|
||||
const offsetMinutes = resolveDefaultOffsetMinutes();
|
||||
const shifted = new Date(this.unixMs + offsetMinutes * 60_000);
|
||||
return DateTime.fromParts(
|
||||
shifted.getUTCFullYear(),
|
||||
shifted.getUTCMonth() + 1,
|
||||
shifted.getUTCDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
offsetMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
weekdayName(): WeekdayName {
|
||||
const offsetMinutes = resolveDefaultOffsetMinutes();
|
||||
const shifted = new Date(this.unixMs + offsetMinutes * 60_000);
|
||||
return WEEKDAY_NAMES[shifted.getUTCDay()];
|
||||
}
|
||||
|
||||
wholeWeeksSince(epoch: DateTime): number {
|
||||
const diff = this.startOfDay().value - epoch.startOfDay().value;
|
||||
return Math.trunc(diff / MS_PER_WEEK);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.format();
|
||||
}
|
||||
@@ -224,4 +280,27 @@ export class DateTime {
|
||||
toJSON(): number {
|
||||
return this.unixMs;
|
||||
}
|
||||
|
||||
private static fromParts(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute: number,
|
||||
second: number,
|
||||
ms: number,
|
||||
offsetMinutes: number,
|
||||
): DateTime {
|
||||
const utcMs = utcMsFromParts(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
ms,
|
||||
offsetMinutes,
|
||||
);
|
||||
return new DateTime(utcMs, DateTime.createToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
/**
|
||||
* Company-wide operational settings (singleton aggregate).
|
||||
*/
|
||||
export const companySettings = pgTable('company_settings', {
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
});
|
||||
|
||||
export type CompanySettingsRow = typeof companySettings.$inferSelect;
|
||||
export type NewCompanySettingsRow = typeof companySettings.$inferInsert;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
export type StoredRouteGeometry = {
|
||||
readonly type: 'LineString';
|
||||
readonly coordinates: readonly (readonly [number, number])[];
|
||||
};
|
||||
|
||||
export const cycles = pgTable(
|
||||
'cycles',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
purpose: text('purpose').notNull(),
|
||||
cycleNumber: integer('cycle_number').notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('cycles_employee_purpose_number_live_unique')
|
||||
.on(t.employeeId, t.purpose, t.cycleNumber)
|
||||
.where(sql`${t.status} <> 'archived'`),
|
||||
index('cycles_employee_id_idx').on(t.employeeId),
|
||||
],
|
||||
);
|
||||
|
||||
export const cycleWeekdays = pgTable(
|
||||
'cycle_weekdays',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
cycleId: uuid('cycle_id')
|
||||
.notNull()
|
||||
.references(() => cycles.id, { onDelete: 'cascade' }),
|
||||
weekday: text('weekday').notNull(),
|
||||
startBranchId: uuid('start_branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
endBranchId: uuid('end_branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
routeGeometry: jsonb('route_geometry')
|
||||
.$type<StoredRouteGeometry>()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('cycle_weekdays_cycle_weekday_unique').on(t.cycleId, t.weekday),
|
||||
index('cycle_weekdays_cycle_id_idx').on(t.cycleId),
|
||||
],
|
||||
);
|
||||
|
||||
export const cycleDestinations = pgTable(
|
||||
'cycle_destinations',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
cycleWeekdayId: uuid('cycle_weekday_id')
|
||||
.notNull()
|
||||
.references(() => cycleWeekdays.id, { onDelete: 'cascade' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
sortOrder: integer('sort_order').notNull(),
|
||||
},
|
||||
(t) => [index('cycle_destinations_weekday_id_idx').on(t.cycleWeekdayId)],
|
||||
);
|
||||
|
||||
export type CycleRow = typeof cycles.$inferSelect;
|
||||
export type NewCycleRow = typeof cycles.$inferInsert;
|
||||
export type CycleWeekdayRow = typeof cycleWeekdays.$inferSelect;
|
||||
export type CycleDestinationRow = typeof cycleDestinations.$inferSelect;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { packingSlips } from './packing-slips-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { salesInvoices } from './sales-invoices-table';
|
||||
import { users } from './schema';
|
||||
import type { StoredRouteGeometry } from './cycles-table';
|
||||
|
||||
export const plans = pgTable(
|
||||
'plans',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
purpose: text('purpose').notNull(),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
startBranchId: uuid('start_branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
endBranchId: uuid('end_branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
routeGeometry: jsonb('route_geometry')
|
||||
.$type<StoredRouteGeometry>()
|
||||
.notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plans_employee_purpose_date_live_unique')
|
||||
.on(t.employeeId, t.purpose, t.date)
|
||||
.where(sql`${t.status} <> 'archived'`),
|
||||
index('plans_employee_id_idx').on(t.employeeId),
|
||||
],
|
||||
);
|
||||
|
||||
export const planDestinations = pgTable(
|
||||
'plan_destinations',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
planId: uuid('plan_id')
|
||||
.notNull()
|
||||
.references(() => plans.id, { onDelete: 'cascade' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
sortOrder: integer('sort_order').notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plan_destinations_plan_customer_unique').on(
|
||||
t.planId,
|
||||
t.customerId,
|
||||
),
|
||||
index('plan_destinations_plan_id_idx').on(t.planId),
|
||||
],
|
||||
);
|
||||
|
||||
export const planInvoices = pgTable(
|
||||
'plan_invoices',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
planId: uuid('plan_id')
|
||||
.notNull()
|
||||
.references(() => plans.id, { onDelete: 'cascade' }),
|
||||
invoiceId: uuid('invoice_id')
|
||||
.notNull()
|
||||
.references(() => salesInvoices.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plan_invoices_plan_invoice_unique').on(t.planId, t.invoiceId),
|
||||
index('plan_invoices_plan_id_idx').on(t.planId),
|
||||
],
|
||||
);
|
||||
|
||||
export const planPackingSlips = pgTable(
|
||||
'plan_packing_slips',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
planId: uuid('plan_id')
|
||||
.notNull()
|
||||
.references(() => plans.id, { onDelete: 'cascade' }),
|
||||
packingSlipId: uuid('packing_slip_id')
|
||||
.notNull()
|
||||
.references(() => packingSlips.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plan_packing_slips_plan_slip_unique').on(
|
||||
t.planId,
|
||||
t.packingSlipId,
|
||||
),
|
||||
index('plan_packing_slips_plan_id_idx').on(t.planId),
|
||||
],
|
||||
);
|
||||
|
||||
export type PlanRow = typeof plans.$inferSelect;
|
||||
export type NewPlanRow = typeof plans.$inferInsert;
|
||||
export type PlanDestinationRow = typeof planDestinations.$inferSelect;
|
||||
export type PlanInvoiceRow = typeof planInvoices.$inferSelect;
|
||||
export type PlanPackingSlipRow = typeof planPackingSlips.$inferSelect;
|
||||
@@ -210,3 +210,29 @@ export {
|
||||
type SalesPaymentInvoiceRow,
|
||||
type SalesPaymentRow,
|
||||
} from './sales-payments-table';
|
||||
|
||||
export {
|
||||
companySettings,
|
||||
type CompanySettingsRow,
|
||||
type NewCompanySettingsRow,
|
||||
} from './company-settings-table';
|
||||
export {
|
||||
cycleDestinations,
|
||||
cycleWeekdays,
|
||||
cycles,
|
||||
type CycleDestinationRow,
|
||||
type CycleRow,
|
||||
type CycleWeekdayRow,
|
||||
type NewCycleRow,
|
||||
} from './cycles-table';
|
||||
export {
|
||||
planDestinations,
|
||||
planInvoices,
|
||||
planPackingSlips,
|
||||
plans,
|
||||
type NewPlanRow,
|
||||
type PlanDestinationRow,
|
||||
type PlanInvoiceRow,
|
||||
type PlanPackingSlipRow,
|
||||
type PlanRow,
|
||||
} from './plans-table';
|
||||
|
||||
@@ -7,6 +7,7 @@ async function bootstrap() {
|
||||
const env = loadEnv();
|
||||
const app = await NestFactory.create(AppModule);
|
||||
configureApp(app);
|
||||
app.enableCors();
|
||||
await app.listen(env.PORT);
|
||||
}
|
||||
void bootstrap();
|
||||
|
||||
@@ -113,6 +113,16 @@ export class BranchesService {
|
||||
return this.toListItem(branch);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
code: string,
|
||||
): Promise<ReturnType<BranchesService['toListItem']>> {
|
||||
const branch = await this.branchesRepository.findByCode(code);
|
||||
if (!branch) {
|
||||
throw new NotFoundException('Branch not found');
|
||||
}
|
||||
return this.toListItem(branch);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
|
||||
@@ -78,6 +78,20 @@ export class CustomersRepository {
|
||||
return this.toDomain(row, contacts);
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Customer | null> {
|
||||
const rows: CustomerRow[] = await this.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.code, code))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const contacts = await this.selectContacts(this.db, row.id);
|
||||
return this.toDomain(row, contacts);
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerInput): Promise<Customer> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
|
||||
@@ -104,6 +104,16 @@ export class CustomersService {
|
||||
return this.toDetail(customer);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
code: string,
|
||||
): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||
const customer = await this.customersRepository.findByCode(code);
|
||||
if (!customer) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
return this.toDetail(customer);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
|
||||
@@ -83,6 +83,16 @@ export class EmployeesService {
|
||||
return this.toListItem(employee);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
code: string,
|
||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
const employee = await this.employeesRepository.findByCode(code);
|
||||
if (!employee) {
|
||||
throw new NotFoundException('Employee not found');
|
||||
}
|
||||
return this.toListItem(employee);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
|
||||
import type { RouteGeometry } from '../shared/route-line-string';
|
||||
import type { WeekdaysInput } from '../shared/field-fields';
|
||||
|
||||
export type CycleDestination = {
|
||||
readonly id: string;
|
||||
readonly customerId: string;
|
||||
readonly sortOrder: number;
|
||||
};
|
||||
|
||||
export type CycleWeekday = {
|
||||
readonly id: string;
|
||||
readonly weekday: WeekdayName;
|
||||
readonly startBranchId: string;
|
||||
readonly endBranchId: string;
|
||||
readonly routeGeometry: RouteGeometry;
|
||||
readonly destinations: readonly CycleDestination[];
|
||||
};
|
||||
|
||||
export type Cycle = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly purpose: FieldPurpose;
|
||||
readonly cycleNumber: number;
|
||||
readonly weekdays: readonly CycleWeekday[];
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
};
|
||||
|
||||
export type CreateCycleInput = {
|
||||
readonly employeeId: string;
|
||||
readonly purpose: FieldPurpose;
|
||||
readonly cycleNumber: number;
|
||||
readonly weekdays: WeekdaysInput;
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdateCycleInput = {
|
||||
readonly employeeId?: string;
|
||||
readonly purpose?: FieldPurpose;
|
||||
readonly cycleNumber?: number;
|
||||
readonly weekdays?: WeekdaysInput;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type ListCyclesFilters = {
|
||||
readonly employeeId?: string;
|
||||
readonly purpose?: string;
|
||||
readonly cycleNumber?: number;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly purposes?: readonly string[];
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
export type PersistableWeekday = {
|
||||
readonly weekday: WeekdayName;
|
||||
readonly startBranchId: string;
|
||||
readonly endBranchId: string;
|
||||
readonly routeGeometry: RouteGeometry;
|
||||
readonly customerIds: readonly string[];
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
|
||||
import { CyclesReadController } from './cycles-read.controller';
|
||||
import { CyclesService } from './cycles.service';
|
||||
|
||||
describe('CyclesReadController', () => {
|
||||
let controller: CyclesReadController;
|
||||
const service = { list: jest.fn(), findById: jest.fn() };
|
||||
const user = { id: 'user-1', isSuperadmin: true };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CyclesReadController],
|
||||
providers: [{ provide: CyclesService, useValue: service }],
|
||||
})
|
||||
.overrideGuard(FieldPrivilegeGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
controller = moduleRef.get(CyclesReadController);
|
||||
});
|
||||
|
||||
it('list delegates to the service', async () => {
|
||||
service.list.mockResolvedValue({ data: [], total: 0 });
|
||||
await expect(controller.list({ page: 1 }, user as never)).resolves.toEqual({
|
||||
data: [],
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('findOne delegates to the service', async () => {
|
||||
service.findById.mockResolvedValue({ id: 'cyc-1' });
|
||||
await expect(controller.findOne('cyc-1', user as never)).resolves.toEqual({
|
||||
id: 'cyc-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { RequireFieldPrivilege } from '../shared/field-privilege.decorator';
|
||||
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
|
||||
import { CyclesService } from './cycles.service';
|
||||
import { CycleDto, ListCyclesQueryDto } from './dto/cycle.dto';
|
||||
|
||||
@ApiTags('cycles')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@UseGuards(FieldPrivilegeGuard)
|
||||
@Controller('cycles')
|
||||
export class CyclesReadController {
|
||||
constructor(private readonly cyclesService: CyclesService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequireFieldPrivilege('cycle', 'view')
|
||||
@ApiOperation({ summary: 'List cycles' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/CycleDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListCyclesQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PaginationResponse<CycleDto>> {
|
||||
return this.cyclesService.list(query, user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequireFieldPrivilege('cycle', 'view')
|
||||
@ApiOperation({ summary: 'Get cycle detail' })
|
||||
@ApiOkResponse({ type: CycleDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<CycleDto> {
|
||||
return this.cyclesService.findById(id, user);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
|
||||
import { CyclesWriteController } from './cycles-write.controller';
|
||||
import { CyclesService } from './cycles.service';
|
||||
|
||||
describe('CyclesWriteController', () => {
|
||||
let controller: CyclesWriteController;
|
||||
const service = {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
importCsv: jest.fn(),
|
||||
};
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'j',
|
||||
isSuperadmin: true,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CyclesWriteController],
|
||||
providers: [{ provide: CyclesService, useValue: service }],
|
||||
})
|
||||
.overrideGuard(FieldPrivilegeGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
controller = moduleRef.get(CyclesWriteController);
|
||||
});
|
||||
|
||||
it('create passes dto fields and user id', async () => {
|
||||
service.create.mockResolvedValue({ id: 'cyc-1' });
|
||||
await controller.create(
|
||||
{
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
cycleNumber: 1,
|
||||
weekdays: {},
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
expect(service.create).toHaveBeenCalledWith({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
cycleNumber: 1,
|
||||
weekdays: {},
|
||||
status: undefined,
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('delete archives through the service', async () => {
|
||||
service.delete.mockResolvedValue(undefined);
|
||||
await controller.delete('cyc-1', user as never);
|
||||
expect(service.delete).toHaveBeenCalledWith('cyc-1', user);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from '../shared/field-fields';
|
||||
import { RequireFieldPrivilege } from '../shared/field-privilege.decorator';
|
||||
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
|
||||
import { CyclesService } from './cycles.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreateCycleDto,
|
||||
CycleDto,
|
||||
UpdateCycleDto,
|
||||
UpdateCycleStatusDto,
|
||||
} from './dto/cycle.dto';
|
||||
|
||||
@ApiTags('cycles')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@UseGuards(FieldPrivilegeGuard)
|
||||
@Controller('cycles')
|
||||
export class CyclesWriteController {
|
||||
constructor(private readonly cyclesService: CyclesService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequireFieldPrivilege('cycle', 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { file: { type: 'string', format: 'binary' } },
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import cycles from CSV' })
|
||||
@ApiOkResponse({ schema: { properties: { imported: { type: 'number' } } } })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.cyclesService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequireFieldPrivilege('cycle', 'delete')
|
||||
@ApiOperation({ summary: 'Bulk archive cycles' })
|
||||
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(
|
||||
@Body() dto: BulkIdsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<{ deleted: number }> {
|
||||
return this.cyclesService.bulkDelete(dto.ids, user);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequireFieldPrivilege('cycle', 'update')
|
||||
@ApiOperation({ summary: 'Bulk update cycle status' })
|
||||
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.cyclesService.bulkUpdateStatus(
|
||||
dto.ids,
|
||||
dto.status,
|
||||
user.id,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireFieldPrivilege('cycle', 'create')
|
||||
@ApiOperation({ summary: 'Create cycle' })
|
||||
@ApiCreatedResponse({ type: CycleDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreateCycleDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<CycleDto> {
|
||||
return this.cyclesService.create({
|
||||
employeeId: dto.employeeId,
|
||||
purpose: dto.purpose,
|
||||
cycleNumber: dto.cycleNumber,
|
||||
weekdays: dto.weekdays,
|
||||
status: dto.status,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequireFieldPrivilege('cycle', 'update')
|
||||
@ApiOperation({ summary: 'Update cycle status' })
|
||||
@ApiOkResponse({ type: CycleDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCycleStatusDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<CycleDto> {
|
||||
return this.cyclesService.updateStatus(id, dto.status, user.id, user);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequireFieldPrivilege('cycle', 'update')
|
||||
@ApiOperation({ summary: 'Update cycle (not status)' })
|
||||
@ApiOkResponse({ type: CycleDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCycleDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<CycleDto> {
|
||||
return this.cyclesService.update(
|
||||
id,
|
||||
{
|
||||
employeeId: dto.employeeId,
|
||||
purpose: dto.purpose,
|
||||
cycleNumber: dto.cycleNumber,
|
||||
weekdays: dto.weekdays,
|
||||
userId: user.id,
|
||||
},
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequireFieldPrivilege('cycle', 'delete')
|
||||
@ApiOperation({ summary: 'Archive cycle' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<void> {
|
||||
await this.cyclesService.delete(id, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE } from '../../../database/database.module';
|
||||
import { CyclesRepository } from './cycles.repository';
|
||||
|
||||
describe('CyclesRepository', () => {
|
||||
let repository: CyclesRepository;
|
||||
const limit = jest.fn();
|
||||
const where = jest.fn();
|
||||
const from = jest.fn();
|
||||
const select = jest.fn();
|
||||
const returning = jest.fn();
|
||||
const values = jest.fn();
|
||||
const insert = jest.fn();
|
||||
const set = jest.fn();
|
||||
const update = jest.fn();
|
||||
const transaction = jest.fn();
|
||||
const orderBy = jest.fn();
|
||||
const offset = jest.fn();
|
||||
const $dynamic = jest.fn();
|
||||
|
||||
const db = {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
transaction,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
||||
orderBy.mockImplementation(() => ({ limit }));
|
||||
limit.mockImplementation(() => ({ offset }));
|
||||
offset.mockResolvedValue([]);
|
||||
from.mockImplementation(() => ({ where, $dynamic }));
|
||||
$dynamic.mockReturnValue({ where });
|
||||
select.mockImplementation(() => ({ from }));
|
||||
values.mockReturnValue({ returning });
|
||||
insert.mockReturnValue({ values });
|
||||
set.mockReturnValue({ where });
|
||||
update.mockReturnValue({ set });
|
||||
returning.mockResolvedValue([]);
|
||||
transaction.mockImplementation((fn: (tx: typeof db) => unknown) => fn(db));
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [CyclesRepository, { provide: DRIZZLE, useValue: db }],
|
||||
}).compile();
|
||||
repository = moduleRef.get(CyclesRepository);
|
||||
});
|
||||
|
||||
it('extendListQuery is a passthrough hook', () => {
|
||||
const qb = { tag: 'qb' };
|
||||
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
|
||||
});
|
||||
|
||||
it('create maps unique violations to ConflictException', async () => {
|
||||
transaction.mockRejectedValue({ code: '23505' });
|
||||
await expect(
|
||||
repository.create({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
cycleNumber: 1,
|
||||
weekdays: [],
|
||||
status: Status.create('draft'),
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('archive throws when no row is updated', async () => {
|
||||
returning.mockResolvedValue([]);
|
||||
await expect(
|
||||
repository.archive('missing', 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
eq,
|
||||
ilike,
|
||||
inArray,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
SQL,
|
||||
} from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
cycleDestinations,
|
||||
cycleWeekdays,
|
||||
cycles,
|
||||
type CycleDestinationRow,
|
||||
type CycleRow,
|
||||
type CycleWeekdayRow,
|
||||
} from '../../../database/cycles-table';
|
||||
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
|
||||
import type { Cycle, ListCyclesFilters, PersistableWeekday } from './cycle';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||
|
||||
@Injectable()
|
||||
export class CyclesRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListCyclesFilters,
|
||||
): Promise<{ data: Cycle[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(cycles)
|
||||
.where(where);
|
||||
let qb = this.db.select().from(cycles).$dynamic();
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(asc(cycles.cycleNumber))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
extendListQuery<T>(qb: T, filters: ListCyclesFilters): T {
|
||||
void filters;
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Cycle | null> {
|
||||
const rows: CycleRow[] = await this.db
|
||||
.select()
|
||||
.from(cycles)
|
||||
.where(eq(cycles.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const weekdays = await this.selectWeekdays(this.db, id);
|
||||
const destinations = await this.selectDestinations(
|
||||
this.db,
|
||||
weekdays.map((weekday) => weekday.id),
|
||||
);
|
||||
return this.toDomain(row, weekdays, destinations);
|
||||
}
|
||||
|
||||
async findLiveByKey(
|
||||
employeeId: string,
|
||||
purpose: string,
|
||||
cycleNumber: number,
|
||||
excludeId?: string,
|
||||
): Promise<Cycle | null> {
|
||||
const parts: SQL[] = [
|
||||
eq(cycles.employeeId, employeeId),
|
||||
eq(cycles.purpose, purpose),
|
||||
eq(cycles.cycleNumber, cycleNumber),
|
||||
ne(cycles.status, 'archived'),
|
||||
];
|
||||
if (excludeId) {
|
||||
parts.push(ne(cycles.id, excludeId));
|
||||
}
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(cycles)
|
||||
.where(and(...parts))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row, [], []) : null;
|
||||
}
|
||||
|
||||
async listActiveByEmployeePurpose(
|
||||
employeeId: string,
|
||||
purpose: string,
|
||||
): Promise<Cycle[]> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(cycles)
|
||||
.where(
|
||||
and(
|
||||
eq(cycles.employeeId, employeeId),
|
||||
eq(cycles.purpose, purpose),
|
||||
eq(cycles.status, 'active'),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(cycles.cycleNumber));
|
||||
const result: Cycle[] = [];
|
||||
for (const row of rows) {
|
||||
const weekdays = await this.selectWeekdays(this.db, row.id);
|
||||
const destinations = await this.selectDestinations(
|
||||
this.db,
|
||||
weekdays.map((weekday) => weekday.id),
|
||||
);
|
||||
result.push(this.toDomain(row, weekdays, destinations));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
employeeId: string;
|
||||
purpose: FieldPurpose;
|
||||
cycleNumber: number;
|
||||
weekdays: readonly PersistableWeekday[];
|
||||
status: Status;
|
||||
userId: string;
|
||||
}): Promise<Cycle> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const inserted = await tx
|
||||
.insert(cycles)
|
||||
.values({
|
||||
employeeId: input.employeeId,
|
||||
purpose: input.purpose,
|
||||
cycleNumber: input.cycleNumber,
|
||||
status: input.status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: input.userId,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceWeekdays(tx, row.id, input.weekdays);
|
||||
return this.loadWithChildren(tx, row);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
employeeId: string;
|
||||
purpose: FieldPurpose;
|
||||
cycleNumber: number;
|
||||
weekdays?: readonly PersistableWeekday[];
|
||||
userId: string;
|
||||
},
|
||||
): Promise<Cycle> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Cycle not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(cycles)
|
||||
.set({
|
||||
employeeId: input.employeeId,
|
||||
purpose: input.purpose,
|
||||
cycleNumber: input.cycleNumber,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(cycles.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (input.weekdays) {
|
||||
await this.replaceWeekdays(tx, id, input.weekdays);
|
||||
}
|
||||
return this.loadWithChildren(tx, row);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Cycle> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(cycles)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(cycles.id, id))
|
||||
.returning({ id: cycles.id });
|
||||
if (updated.length === 0) {
|
||||
throw new NotFoundException('Cycle not found');
|
||||
}
|
||||
const found = await this.findById(id);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Cycle not found');
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(cycles)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(cycles.id, ids))
|
||||
.returning({ id: cycles.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async archive(id: string, userId: string): Promise<void> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(cycles)
|
||||
.set({
|
||||
status: 'archived',
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(cycles.id, id))
|
||||
.returning({ id: cycles.id });
|
||||
if (updated.length === 0) {
|
||||
throw new NotFoundException('Cycle not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkArchive(ids: string[], userId: string): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(cycles)
|
||||
.set({
|
||||
status: 'archived',
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(cycles.id, ids))
|
||||
.returning({ id: cycles.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async loadWithChildren(
|
||||
executor: QueryExecutor,
|
||||
row: CycleRow,
|
||||
): Promise<Cycle> {
|
||||
const weekdays = await this.selectWeekdays(executor, row.id);
|
||||
const destinations = await this.selectDestinations(
|
||||
executor,
|
||||
weekdays.map((weekday) => weekday.id),
|
||||
);
|
||||
return this.toDomain(row, weekdays, destinations);
|
||||
}
|
||||
|
||||
private async replaceWeekdays(
|
||||
executor: QueryExecutor,
|
||||
cycleId: string,
|
||||
weekdays: readonly PersistableWeekday[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(cycleWeekdays)
|
||||
.where(eq(cycleWeekdays.cycleId, cycleId));
|
||||
for (const weekday of weekdays) {
|
||||
const inserted = await executor
|
||||
.insert(cycleWeekdays)
|
||||
.values({
|
||||
cycleId,
|
||||
weekday: weekday.weekday,
|
||||
startBranchId: weekday.startBranchId,
|
||||
endBranchId: weekday.endBranchId,
|
||||
routeGeometry: weekday.routeGeometry,
|
||||
})
|
||||
.returning();
|
||||
const weekdayRow = inserted[0];
|
||||
if (weekday.customerIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
await executor.insert(cycleDestinations).values(
|
||||
weekday.customerIds.map((customerId, index) => ({
|
||||
cycleWeekdayId: weekdayRow.id,
|
||||
customerId,
|
||||
sortOrder: index,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async selectWeekdays(
|
||||
executor: QueryExecutor,
|
||||
cycleId: string,
|
||||
): Promise<CycleWeekdayRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(cycleWeekdays)
|
||||
.where(eq(cycleWeekdays.cycleId, cycleId))
|
||||
.orderBy(asc(cycleWeekdays.weekday));
|
||||
}
|
||||
|
||||
private async selectDestinations(
|
||||
executor: QueryExecutor,
|
||||
weekdayIds: string[],
|
||||
): Promise<CycleDestinationRow[]> {
|
||||
if (weekdayIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return executor
|
||||
.select()
|
||||
.from(cycleDestinations)
|
||||
.where(inArray(cycleDestinations.cycleWeekdayId, weekdayIds))
|
||||
.orderBy(asc(cycleDestinations.sortOrder));
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListCyclesFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.employeeId) {
|
||||
parts.push(eq(cycles.employeeId, filters.employeeId));
|
||||
}
|
||||
if (filters.purpose) {
|
||||
parts.push(eq(cycles.purpose, filters.purpose));
|
||||
} else if (filters.purposes && filters.purposes.length > 0) {
|
||||
parts.push(inArray(cycles.purpose, [...filters.purposes]));
|
||||
}
|
||||
if (filters.cycleNumber !== undefined) {
|
||||
parts.push(eq(cycles.cycleNumber, filters.cycleNumber));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(cycles.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(cycles.purpose, `%${filters.search}%`),
|
||||
sql`${cycles.cycleNumber}::text ilike ${'%' + filters.search + '%'}`,
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: CycleRow,
|
||||
weekdayRows: CycleWeekdayRow[],
|
||||
destinationRows: CycleDestinationRow[],
|
||||
): Cycle {
|
||||
const destinationsByWeekday = new Map<string, CycleDestinationRow[]>();
|
||||
for (const destination of destinationRows) {
|
||||
const list = destinationsByWeekday.get(destination.cycleWeekdayId) ?? [];
|
||||
destinationsByWeekday.set(destination.cycleWeekdayId, [
|
||||
...list,
|
||||
destination,
|
||||
]);
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employeeId,
|
||||
purpose: row.purpose as FieldPurpose,
|
||||
cycleNumber: row.cycleNumber,
|
||||
weekdays: weekdayRows.map((weekday) => ({
|
||||
id: weekday.id,
|
||||
weekday: weekday.weekday as WeekdayName,
|
||||
startBranchId: weekday.startBranchId,
|
||||
endBranchId: weekday.endBranchId,
|
||||
routeGeometry: weekday.routeGeometry,
|
||||
destinations: (destinationsByWeekday.get(weekday.id) ?? []).map(
|
||||
(destination) => ({
|
||||
id: destination.id,
|
||||
customerId: destination.customerId,
|
||||
sortOrder: destination.sortOrder,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Cycle already exists for this employee');
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Cycle references a missing record');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string } {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} 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 type { Cycle } from './cycle';
|
||||
import { CyclesRepository } from './cycles.repository';
|
||||
import { CyclesService } from './cycles.service';
|
||||
|
||||
describe('CyclesService', () => {
|
||||
let service: CyclesService;
|
||||
let repository: jest.Mocked<
|
||||
Pick<
|
||||
CyclesRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'findLiveByKey'
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'updateStatus'
|
||||
| 'bulkUpdateStatus'
|
||||
| 'archive'
|
||||
| 'bulkArchive'
|
||||
>
|
||||
>;
|
||||
const employeesService = { findById: jest.fn(), findByCode: jest.fn() };
|
||||
const branchesService = { findById: jest.fn(), findByCode: jest.fn() };
|
||||
const customersService = { findById: jest.fn(), findByCode: jest.fn() };
|
||||
const privilegesService = { checkPermission: jest.fn() };
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
isSuperadmin: true,
|
||||
};
|
||||
const geometry = {
|
||||
type: 'LineString' as const,
|
||||
coordinates: [
|
||||
[106.8, -6.2],
|
||||
[106.9, -6.3],
|
||||
[107.0, -6.4],
|
||||
] as const,
|
||||
};
|
||||
const sample: 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: 'dest-1', customerId: 'cus-1', sortOrder: 0 }],
|
||||
},
|
||||
],
|
||||
status: Status.create('draft'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const located = { id: 'x', latitude: -6.2, longitude: 106.8 };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
repository = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findLiveByKey: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
archive: jest.fn(),
|
||||
bulkArchive: jest.fn(),
|
||||
};
|
||||
employeesService.findById.mockResolvedValue({ id: 'emp-1' });
|
||||
branchesService.findById.mockResolvedValue(located);
|
||||
customersService.findById.mockResolvedValue(located);
|
||||
repository.findLiveByKey.mockResolvedValue(null);
|
||||
repository.create.mockResolvedValue(sample);
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CyclesService,
|
||||
{ provide: CyclesRepository, useValue: repository },
|
||||
{ provide: EmployeesService, useValue: employeesService },
|
||||
{ provide: BranchesService, useValue: branchesService },
|
||||
{ provide: CustomersService, useValue: customersService },
|
||||
{ provide: PrivilegesService, useValue: privilegesService },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(CyclesService);
|
||||
});
|
||||
|
||||
it('create rejects incomplete weekdays', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
cycleNumber: 1,
|
||||
weekdays: {
|
||||
monday: {
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
customerIds: [],
|
||||
},
|
||||
},
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('create rejects duplicate live keys', async () => {
|
||||
repository.findLiveByKey.mockResolvedValue(sample);
|
||||
await expect(
|
||||
service.create({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
cycleNumber: 1,
|
||||
weekdays: {
|
||||
monday: {
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
customerIds: ['cus-1'],
|
||||
},
|
||||
},
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('create persists a complete weekday and builds geometry', async () => {
|
||||
await service.create({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
cycleNumber: 1,
|
||||
weekdays: {
|
||||
monday: {
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
customerIds: ['cus-1'],
|
||||
},
|
||||
},
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const arg = repository.create.mock.calls[0][0];
|
||||
expect(arg.weekdays[0].weekday).toBe('monday');
|
||||
expect(arg.weekdays[0].routeGeometry.type).toBe('LineString');
|
||||
});
|
||||
|
||||
it('delete archives instead of hard-deleting', async () => {
|
||||
repository.findById.mockResolvedValue(sample);
|
||||
repository.archive.mockResolvedValue(undefined);
|
||||
await service.delete('cyc-1', user);
|
||||
expect(repository.archive).toHaveBeenCalledWith('cyc-1', 'user-1');
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
await expect(service.findById('missing', user)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
|
||||
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 {
|
||||
isValidCycleNumber,
|
||||
parseCsvRecord,
|
||||
parseWeekdaysInput,
|
||||
type WeekdaysInput,
|
||||
} from '../shared/field-fields';
|
||||
import {
|
||||
fieldPrivilegeKey,
|
||||
isFieldPurpose,
|
||||
WEEKDAY_NAMES,
|
||||
type FieldPurpose,
|
||||
type WeekdayName,
|
||||
} from '../shared/field-purpose';
|
||||
import {
|
||||
buildRouteLineString,
|
||||
isUsableRouteGeometry,
|
||||
} from '../shared/route-line-string';
|
||||
import type { Cycle, PersistableWeekday } from './cycle';
|
||||
import { CyclesRepository } from './cycles.repository';
|
||||
|
||||
export type ListCyclesQuery = {
|
||||
readonly employeeId?: string;
|
||||
readonly purpose?: string;
|
||||
readonly cycleNumber?: number;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const VISIBLE_FIELDS = [
|
||||
'id',
|
||||
'employeeId',
|
||||
'purpose',
|
||||
'cycleNumber',
|
||||
'weekdays',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
] as const;
|
||||
|
||||
const CSV_REQUIRED_HEADERS = [
|
||||
'employeeCode',
|
||||
'purpose',
|
||||
'cycleNumber',
|
||||
'weekday',
|
||||
'customerCodes',
|
||||
'startBranchCode',
|
||||
'endBranchCode',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class CyclesService {
|
||||
constructor(
|
||||
private readonly cyclesRepository: CyclesRepository,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly branchesService: BranchesService,
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly privilegesService: PrivilegesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListCyclesQuery,
|
||||
user: AuthUser,
|
||||
): Promise<PaginationResponse<ReturnType<CyclesService['toItem']>>> {
|
||||
const purposes = await this.allowedPurposes(user, 'view');
|
||||
if (query.purpose && !purposes.includes(query.purpose as FieldPurpose)) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.cyclesRepository.list({
|
||||
employeeId: query.employeeId,
|
||||
purpose: query.purpose,
|
||||
cycleNumber: query.cycleNumber,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
purposes: query.purpose ? undefined : purposes,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return { data: data.map((item) => this.toItem(item)), total };
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
user: AuthUser,
|
||||
): Promise<ReturnType<CyclesService['toItem']>> {
|
||||
const cycle = await this.requireCycle(id);
|
||||
await this.assertCanAccess(user, cycle.purpose, 'view');
|
||||
return this.toItem(cycle);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
employeeId: string;
|
||||
purpose: string;
|
||||
cycleNumber: number;
|
||||
weekdays: unknown;
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<CyclesService['toItem']>> {
|
||||
const created = await this.cyclesRepository.create(
|
||||
await this.toCreatePayload(input),
|
||||
);
|
||||
return this.toItem(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
employeeId?: string;
|
||||
purpose?: string;
|
||||
cycleNumber?: number;
|
||||
weekdays?: unknown;
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
user: AuthUser,
|
||||
): Promise<ReturnType<CyclesService['toItem']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
const existing = await this.requireCycle(id);
|
||||
await this.assertCanAccess(user, existing.purpose, 'update');
|
||||
const purpose = input.purpose
|
||||
? this.assertPurpose(input.purpose)
|
||||
: existing.purpose;
|
||||
const employeeId = input.employeeId ?? existing.employeeId;
|
||||
const cycleNumber = input.cycleNumber ?? existing.cycleNumber;
|
||||
await this.employeesService.findById(employeeId);
|
||||
await this.assertUnique(employeeId, purpose, cycleNumber, id);
|
||||
let weekdays: PersistableWeekday[] | undefined;
|
||||
if (input.weekdays !== undefined) {
|
||||
try {
|
||||
weekdays = await this.toPersistableWeekdays(
|
||||
parseWeekdaysInput(input.weekdays),
|
||||
);
|
||||
} catch {
|
||||
throw new BadRequestException('Weekday must be complete');
|
||||
}
|
||||
}
|
||||
const updated = await this.cyclesRepository.update(id, {
|
||||
employeeId,
|
||||
purpose,
|
||||
cycleNumber,
|
||||
weekdays,
|
||||
userId: input.userId,
|
||||
});
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
user: AuthUser,
|
||||
): Promise<ReturnType<CyclesService['toItem']>> {
|
||||
const existing = await this.requireCycle(id);
|
||||
await this.assertCanAccess(user, existing.purpose, 'update');
|
||||
const updated = await this.cyclesRepository.updateStatus(
|
||||
id,
|
||||
this.assertStatus(statusRaw),
|
||||
userId,
|
||||
);
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
user: AuthUser,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
await this.assertIdsAccessible(ids, user, 'update');
|
||||
const updated = await this.cyclesRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string, user: AuthUser): Promise<void> {
|
||||
const existing = await this.requireCycle(id);
|
||||
await this.assertCanAccess(user, existing.purpose, 'delete');
|
||||
await this.cyclesRepository.archive(id, user.id);
|
||||
}
|
||||
|
||||
async bulkDelete(
|
||||
ids: string[],
|
||||
user: AuthUser,
|
||||
): Promise<{ deleted: number }> {
|
||||
await this.assertIdsAccessible(ids, user, 'delete');
|
||||
const deleted = await this.cyclesRepository.bulkArchive(ids, user.id);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const groups = await this.parseImportGroups(csv);
|
||||
for (const group of groups) {
|
||||
await this.cyclesRepository.create(
|
||||
await this.toCreatePayload({
|
||||
employeeId: group.employeeId,
|
||||
purpose: group.purpose,
|
||||
cycleNumber: group.cycleNumber,
|
||||
weekdays: group.weekdays,
|
||||
userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { imported: groups.length };
|
||||
}
|
||||
|
||||
get visibleFields(): readonly string[] {
|
||||
return VISIBLE_FIELDS;
|
||||
}
|
||||
|
||||
toItem(cycle: Cycle) {
|
||||
return {
|
||||
id: cycle.id,
|
||||
employeeId: cycle.employeeId,
|
||||
purpose: cycle.purpose,
|
||||
cycleNumber: cycle.cycleNumber,
|
||||
weekdays: cycle.weekdays.map((weekday) => ({
|
||||
id: weekday.id,
|
||||
weekday: weekday.weekday,
|
||||
startBranchId: weekday.startBranchId,
|
||||
endBranchId: weekday.endBranchId,
|
||||
routeGeometry: weekday.routeGeometry,
|
||||
destinations: weekday.destinations.map((destination) => ({
|
||||
id: destination.id,
|
||||
customerId: destination.customerId,
|
||||
sortOrder: destination.sortOrder,
|
||||
})),
|
||||
})),
|
||||
status: cycle.status.value,
|
||||
createdAt: cycle.createdAt.value,
|
||||
updatedAt: cycle.updatedAt.value,
|
||||
createdBy: cycle.createdBy,
|
||||
updatedBy: cycle.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private async toCreatePayload(input: {
|
||||
employeeId: string;
|
||||
purpose: string;
|
||||
cycleNumber: number;
|
||||
weekdays: unknown;
|
||||
status?: string;
|
||||
userId: string;
|
||||
}) {
|
||||
if (!isValidCycleNumber(input.cycleNumber)) {
|
||||
throw new BadRequestException('Invalid cycle number');
|
||||
}
|
||||
const purpose = this.assertPurpose(input.purpose);
|
||||
await this.employeesService.findById(input.employeeId);
|
||||
await this.assertUnique(input.employeeId, purpose, input.cycleNumber);
|
||||
let weekdaysInput: WeekdaysInput;
|
||||
try {
|
||||
weekdaysInput = parseWeekdaysInput(input.weekdays);
|
||||
} catch {
|
||||
throw new BadRequestException('Weekday must be complete');
|
||||
}
|
||||
return {
|
||||
employeeId: input.employeeId,
|
||||
purpose,
|
||||
cycleNumber: input.cycleNumber,
|
||||
weekdays: await this.toPersistableWeekdays(weekdaysInput),
|
||||
status: input.status
|
||||
? this.assertStatus(input.status)
|
||||
: Status.create(Status.DEFAULT),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async toPersistableWeekdays(
|
||||
weekdays: WeekdaysInput,
|
||||
): Promise<PersistableWeekday[]> {
|
||||
const result: PersistableWeekday[] = [];
|
||||
for (const weekday of WEEKDAY_NAMES) {
|
||||
const template = weekdays[weekday];
|
||||
if (!template) {
|
||||
continue;
|
||||
}
|
||||
const start = await this.branchesService.findById(template.startBranchId);
|
||||
const end = await this.branchesService.findById(template.endBranchId);
|
||||
const customers: Array<{
|
||||
longitude: number | null;
|
||||
latitude: number | null;
|
||||
}> = [];
|
||||
for (const customerId of template.customerIds) {
|
||||
customers.push(await this.customersService.findById(customerId));
|
||||
}
|
||||
const geometry = buildRouteLineString([
|
||||
{ longitude: start.longitude, latitude: start.latitude },
|
||||
...customers.map((customer) => ({
|
||||
longitude: customer.longitude,
|
||||
latitude: customer.latitude,
|
||||
})),
|
||||
{ longitude: end.longitude, latitude: end.latitude },
|
||||
]);
|
||||
if (!isUsableRouteGeometry(geometry)) {
|
||||
throw new BadRequestException('Weekday route is incomplete');
|
||||
}
|
||||
result.push({
|
||||
weekday,
|
||||
startBranchId: template.startBranchId,
|
||||
endBranchId: template.endBranchId,
|
||||
routeGeometry: geometry,
|
||||
customerIds: template.customerIds,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async parseImportGroups(csv: string): Promise<
|
||||
Array<{
|
||||
employeeId: string;
|
||||
purpose: FieldPurpose;
|
||||
cycleNumber: number;
|
||||
weekdays: WeekdaysInput;
|
||||
}>
|
||||
> {
|
||||
const rawLines = csv.split(/\r?\n/);
|
||||
const filled = rawLines
|
||||
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||
.filter((entry) => entry.line.length > 0);
|
||||
if (filled.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||
h.trim().toLowerCase(),
|
||||
);
|
||||
const missing = CSV_REQUIRED_HEADERS.filter(
|
||||
(h) => header.indexOf(h.toLowerCase()) < 0,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException('CSV must include required headers');
|
||||
}
|
||||
const idx = (key: string) => header.indexOf(key.toLowerCase());
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
employeeId: string;
|
||||
purpose: FieldPurpose;
|
||||
cycleNumber: number;
|
||||
weekdays: WeekdaysInput;
|
||||
}
|
||||
>();
|
||||
const errors: string[] = [];
|
||||
for (let i = 1; i < filled.length; i++) {
|
||||
const cols = parseCsvRecord(filled[i].line);
|
||||
const rowNum = filled[i].lineNo;
|
||||
try {
|
||||
const employee = await this.employeesService.findByCode(
|
||||
cols[idx('employeeCode')] ?? '',
|
||||
);
|
||||
const purpose = this.assertPurpose(cols[idx('purpose')] ?? '');
|
||||
const cycleNumber = Number.parseInt(cols[idx('cycleNumber')] ?? '', 10);
|
||||
if (!isValidCycleNumber(cycleNumber)) {
|
||||
throw new BadRequestException('Invalid cycle number');
|
||||
}
|
||||
const weekdayRaw = (cols[idx('weekday')] ?? '').toLowerCase();
|
||||
if (!(WEEKDAY_NAMES as readonly string[]).includes(weekdayRaw)) {
|
||||
throw new BadRequestException('Weekday must be complete');
|
||||
}
|
||||
const weekday = weekdayRaw as WeekdayName;
|
||||
const start = await this.branchesService.findByCode(
|
||||
cols[idx('startBranchCode')] ?? '',
|
||||
);
|
||||
const end = await this.branchesService.findByCode(
|
||||
cols[idx('endBranchCode')] ?? '',
|
||||
);
|
||||
const customerCodes = (cols[idx('customerCodes')] ?? '')
|
||||
.split('|')
|
||||
.map((code) => code.trim())
|
||||
.filter((code) => code.length > 0);
|
||||
if (customerCodes.length === 0) {
|
||||
throw new BadRequestException('Weekday must be complete');
|
||||
}
|
||||
const customerIds: string[] = [];
|
||||
for (const code of customerCodes) {
|
||||
const customer = await this.customersService.findByCode(code);
|
||||
customerIds.push(customer.id);
|
||||
}
|
||||
const key = `${employee.id}:${purpose}:${cycleNumber}`;
|
||||
const current = grouped.get(key) ?? {
|
||||
employeeId: employee.id,
|
||||
purpose,
|
||||
cycleNumber,
|
||||
weekdays: {},
|
||||
};
|
||||
const existingDay = current.weekdays[weekday];
|
||||
grouped.set(key, {
|
||||
...current,
|
||||
weekdays: {
|
||||
...current.weekdays,
|
||||
[weekday]: {
|
||||
startBranchId: start.id,
|
||||
endBranchId: end.id,
|
||||
customerIds: [
|
||||
...(existingDay?.customerIds ?? []),
|
||||
...customerIds,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof BadRequestException ||
|
||||
error instanceof NotFoundException
|
||||
? error.message
|
||||
: 'invalid data';
|
||||
errors.push(`row ${rowNum}: ${reason}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
return [...grouped.values()];
|
||||
}
|
||||
|
||||
private async requireCycle(id: string): Promise<Cycle> {
|
||||
const cycle = await this.cyclesRepository.findById(id);
|
||||
if (!cycle) {
|
||||
throw new NotFoundException('Cycle not found');
|
||||
}
|
||||
return cycle;
|
||||
}
|
||||
|
||||
private async assertUnique(
|
||||
employeeId: string,
|
||||
purpose: FieldPurpose,
|
||||
cycleNumber: number,
|
||||
excludeId?: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.cyclesRepository.findLiveByKey(
|
||||
employeeId,
|
||||
purpose,
|
||||
cycleNumber,
|
||||
excludeId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException('Cycle already exists for this employee');
|
||||
}
|
||||
}
|
||||
|
||||
private async allowedPurposes(
|
||||
user: AuthUser,
|
||||
action: 'view' | 'update' | 'delete',
|
||||
): Promise<FieldPurpose[]> {
|
||||
if (user.isSuperadmin) {
|
||||
return ['sales', 'logistics'];
|
||||
}
|
||||
const allowed: FieldPurpose[] = [];
|
||||
for (const purpose of ['sales', 'logistics'] as const) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
user.id,
|
||||
fieldPrivilegeKey('cycle', purpose),
|
||||
action,
|
||||
);
|
||||
if (ok) {
|
||||
allowed.push(purpose);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
private async assertCanAccess(
|
||||
user: AuthUser,
|
||||
purpose: FieldPurpose,
|
||||
action: 'view' | 'update' | 'delete',
|
||||
): Promise<void> {
|
||||
if (user.isSuperadmin) {
|
||||
return;
|
||||
}
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
user.id,
|
||||
fieldPrivilegeKey('cycle', purpose),
|
||||
action,
|
||||
);
|
||||
if (!ok) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertIdsAccessible(
|
||||
ids: string[],
|
||||
user: AuthUser,
|
||||
action: 'update' | 'delete',
|
||||
): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const cycle = await this.requireCycle(id);
|
||||
await this.assertCanAccess(user, cycle.purpose, action);
|
||||
}
|
||||
}
|
||||
|
||||
private assertPurpose(raw: string): FieldPurpose {
|
||||
if (!isFieldPurpose(raw)) {
|
||||
throw new BadRequestException('Invalid purpose');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private assertStatus(raw: string): Status {
|
||||
try {
|
||||
return Status.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidStatusError) {
|
||||
throw new BadRequestException('Invalid status');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import { FIELD_PURPOSES, WEEKDAY_NAMES } from '../../shared/field-purpose';
|
||||
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
|
||||
|
||||
export class CycleWeekdayBodyDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
startBranchId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
endBranchId!: string;
|
||||
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
customerIds!: string[];
|
||||
}
|
||||
|
||||
export class CreateCycleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
employeeId!: string;
|
||||
|
||||
@ApiProperty({ enum: FIELD_PURPOSES })
|
||||
@IsIn([...FIELD_PURPOSES])
|
||||
purpose!: string;
|
||||
|
||||
@ApiProperty({ example: 1, minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
cycleNumber!: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'object',
|
||||
additionalProperties: { $ref: '#/components/schemas/CycleWeekdayBodyDto' },
|
||||
})
|
||||
@IsObject()
|
||||
weekdays!: Record<string, CycleWeekdayBodyDto>;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdateCycleDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
employeeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FIELD_PURPOSES })
|
||||
@IsOptional()
|
||||
@IsIn([...FIELD_PURPOSES])
|
||||
purpose?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
cycleNumber?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
weekdays?: Record<string, CycleWeekdayBodyDto>;
|
||||
}
|
||||
|
||||
export class UpdateCycleStatusDto {
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListCyclesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
employeeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FIELD_PURPOSES })
|
||||
@IsOptional()
|
||||
@IsIn([...FIELD_PURPOSES])
|
||||
purpose?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
cycleNumber?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class CycleDestinationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class CycleWeekdayDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enum: WEEKDAY_NAMES })
|
||||
weekday!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
startBranchId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
endBranchId!: string;
|
||||
|
||||
@ApiProperty({ type: RouteGeometryDto })
|
||||
routeGeometry!: RouteGeometryDto;
|
||||
|
||||
@ApiProperty({ type: [CycleDestinationDto] })
|
||||
destinations!: CycleDestinationDto[];
|
||||
}
|
||||
|
||||
export class CycleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
employeeId!: string;
|
||||
|
||||
@ApiProperty({ enum: FIELD_PURPOSES })
|
||||
purpose!: string;
|
||||
|
||||
@ApiProperty()
|
||||
cycleNumber!: number;
|
||||
|
||||
@ApiProperty({ type: [CycleWeekdayDto] })
|
||||
weekdays!: CycleWeekdayDto[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
}
|
||||
|
||||
void ValidateNested;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BranchesModule } from '../configuration/branches/branches.module';
|
||||
import { CustomersModule } from '../configuration/customers/customers.module';
|
||||
import { EmployeesModule } from '../configuration/employees/employees.module';
|
||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||
import { PackingSlipsModule } from '../sales/packing-slips/packing-slips.module';
|
||||
import { SalesInvoicesModule } from '../sales/sales-invoices/sales-invoices.module';
|
||||
import { CyclesReadController } from './cycles/cycles-read.controller';
|
||||
import { CyclesWriteController } from './cycles/cycles-write.controller';
|
||||
import { CyclesRepository } from './cycles/cycles.repository';
|
||||
import { CyclesService } from './cycles/cycles.service';
|
||||
import { PlansReadController } from './plans/plans-read.controller';
|
||||
import { PlansWriteController } from './plans/plans-write.controller';
|
||||
import { PlansRepository } from './plans/plans.repository';
|
||||
import { PlansService } from './plans/plans.service';
|
||||
import { CompanySettingsController } from './settings/company-settings.controller';
|
||||
import { CompanySettingsRepository } from './settings/company-settings.repository';
|
||||
import { CompanySettingsService } from './settings/company-settings.service';
|
||||
import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrivilegesModule,
|
||||
EmployeesModule,
|
||||
BranchesModule,
|
||||
CustomersModule,
|
||||
SalesInvoicesModule,
|
||||
PackingSlipsModule,
|
||||
],
|
||||
controllers: [
|
||||
CompanySettingsController,
|
||||
CyclesReadController,
|
||||
CyclesWriteController,
|
||||
PlansReadController,
|
||||
PlansWriteController,
|
||||
],
|
||||
providers: [
|
||||
FieldPrivilegeGuard,
|
||||
CompanySettingsRepository,
|
||||
CompanySettingsService,
|
||||
CyclesRepository,
|
||||
CyclesService,
|
||||
PlansRepository,
|
||||
PlansService,
|
||||
],
|
||||
exports: [CompanySettingsService, CyclesService, PlansService],
|
||||
})
|
||||
export class FieldModule {}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import { FIELD_PURPOSES } from '../../shared/field-purpose';
|
||||
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export class CreatePlanDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
employeeId!: string;
|
||||
|
||||
@ApiProperty({ enum: FIELD_PURPOSES })
|
||||
@IsIn([...FIELD_PURPOSES])
|
||||
purpose!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-12' })
|
||||
@IsString()
|
||||
@Matches(DATE_PATTERN)
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
startBranchId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
endBranchId!: string;
|
||||
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
customerIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
invoiceIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
packingSlipIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdatePlanDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
employeeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FIELD_PURPOSES })
|
||||
@IsOptional()
|
||||
@IsIn([...FIELD_PURPOSES])
|
||||
purpose?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(DATE_PATTERN)
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
startBranchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
endBranchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
customerIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
invoiceIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
packingSlipIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdatePlanStatusDto {
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class GeneratePlansDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
employeeId!: string;
|
||||
|
||||
@ApiProperty({ enum: FIELD_PURPOSES })
|
||||
@IsIn([...FIELD_PURPOSES])
|
||||
purpose!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-12' })
|
||||
@IsString()
|
||||
@Matches(DATE_PATTERN)
|
||||
from!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-25' })
|
||||
@IsString()
|
||||
@Matches(DATE_PATTERN)
|
||||
to!: string;
|
||||
}
|
||||
|
||||
export class AddPlanDestinationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
customerId!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
afterDestinationId?: string;
|
||||
}
|
||||
|
||||
export class ListPlansQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
employeeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FIELD_PURPOSES })
|
||||
@IsOptional()
|
||||
@IsIn([...FIELD_PURPOSES])
|
||||
purpose?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(DATE_PATTERN)
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class PlanDestinationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class PlanDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
employeeId!: string;
|
||||
|
||||
@ApiProperty({ enum: FIELD_PURPOSES })
|
||||
purpose!: string;
|
||||
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
startBranchId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
endBranchId!: string;
|
||||
|
||||
@ApiProperty({ type: RouteGeometryDto })
|
||||
routeGeometry!: RouteGeometryDto;
|
||||
|
||||
@ApiProperty({ type: [PlanDestinationDto] })
|
||||
destinations!: PlanDestinationDto[];
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
invoiceIds!: string[];
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
packingSlipIds!: string[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
}
|
||||
|
||||
void Type;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { FieldPurpose } from '../shared/field-purpose';
|
||||
import type { RouteGeometry } from '../shared/route-line-string';
|
||||
|
||||
export type PlanDestination = {
|
||||
readonly id: string;
|
||||
readonly customerId: string;
|
||||
readonly sortOrder: number;
|
||||
};
|
||||
|
||||
export type Plan = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly purpose: FieldPurpose;
|
||||
readonly date: DateTime;
|
||||
readonly startBranchId: string;
|
||||
readonly endBranchId: string;
|
||||
readonly routeGeometry: RouteGeometry;
|
||||
readonly destinations: readonly PlanDestination[];
|
||||
readonly invoiceIds: readonly string[];
|
||||
readonly packingSlipIds: readonly string[];
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
};
|
||||
|
||||
export type PersistablePlanDestination = {
|
||||
readonly customerId: string;
|
||||
};
|
||||
|
||||
export type ListPlansFilters = {
|
||||
readonly employeeId?: string;
|
||||
readonly purpose?: string;
|
||||
readonly date?: number;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly purposes?: readonly string[];
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
|
||||
import { PlansReadController } from './plans-read.controller';
|
||||
import { PlansService } from './plans.service';
|
||||
|
||||
describe('PlansReadController', () => {
|
||||
let controller: PlansReadController;
|
||||
const service = { list: jest.fn(), findById: jest.fn() };
|
||||
const user = { id: 'user-1', isSuperadmin: true };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [PlansReadController],
|
||||
providers: [{ provide: PlansService, useValue: service }],
|
||||
})
|
||||
.overrideGuard(FieldPrivilegeGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
controller = moduleRef.get(PlansReadController);
|
||||
});
|
||||
|
||||
it('list delegates to the service', async () => {
|
||||
service.list.mockResolvedValue({ data: [], total: 0 });
|
||||
await expect(controller.list({ page: 1 }, user as never)).resolves.toEqual({
|
||||
data: [],
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('findOne delegates to the service', async () => {
|
||||
service.findById.mockResolvedValue({ id: 'pln-1' });
|
||||
await expect(controller.findOne('pln-1', user as never)).resolves.toEqual({
|
||||
id: 'pln-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { RequireFieldPrivilege } from '../shared/field-privilege.decorator';
|
||||
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
|
||||
import { ListPlansQueryDto, PlanDto } from './dto/plan.dto';
|
||||
import { PlansService } from './plans.service';
|
||||
|
||||
@ApiTags('plans')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@UseGuards(FieldPrivilegeGuard)
|
||||
@Controller('plans')
|
||||
export class PlansReadController {
|
||||
constructor(private readonly plansService: PlansService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequireFieldPrivilege('plan', 'view')
|
||||
@ApiOperation({ summary: 'List plans' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/PlanDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListPlansQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PaginationResponse<PlanDto>> {
|
||||
return this.plansService.list(query, user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequireFieldPrivilege('plan', 'view')
|
||||
@ApiOperation({ summary: 'Get plan detail' })
|
||||
@ApiOkResponse({ type: PlanDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PlanDto> {
|
||||
return this.plansService.findById(id, user);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
|
||||
import { PlansWriteController } from './plans-write.controller';
|
||||
import { PlansService } from './plans.service';
|
||||
|
||||
describe('PlansWriteController', () => {
|
||||
let controller: PlansWriteController;
|
||||
const service = {
|
||||
create: jest.fn(),
|
||||
generate: jest.fn(),
|
||||
addDestination: jest.fn(),
|
||||
removeDestination: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'j',
|
||||
isSuperadmin: true,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [PlansWriteController],
|
||||
providers: [{ provide: PlansService, useValue: service }],
|
||||
})
|
||||
.overrideGuard(FieldPrivilegeGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
controller = moduleRef.get(PlansWriteController);
|
||||
});
|
||||
|
||||
it('generate delegates', async () => {
|
||||
service.generate.mockResolvedValue({ created: 2, skipped: 1 });
|
||||
await controller.generate(
|
||||
{
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
from: '2026-01-05',
|
||||
to: '2026-01-11',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
expect(service.generate).toHaveBeenCalledWith({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
from: '2026-01-05',
|
||||
to: '2026-01-11',
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('addDestination delegates', async () => {
|
||||
service.addDestination.mockResolvedValue({ id: 'pln-1' });
|
||||
await controller.addDestination(
|
||||
'pln-1',
|
||||
{ customerId: 'cus-2' },
|
||||
user as never,
|
||||
);
|
||||
expect(service.addDestination).toHaveBeenCalledWith(
|
||||
'pln-1',
|
||||
'cus-2',
|
||||
undefined,
|
||||
user,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { RequireFieldPrivilege } from '../shared/field-privilege.decorator';
|
||||
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
|
||||
import {
|
||||
AddPlanDestinationDto,
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreatePlanDto,
|
||||
GeneratePlansDto,
|
||||
PlanDto,
|
||||
UpdatePlanDto,
|
||||
UpdatePlanStatusDto,
|
||||
} from './dto/plan.dto';
|
||||
import { PlansService } from './plans.service';
|
||||
|
||||
@ApiTags('plans')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@UseGuards(FieldPrivilegeGuard)
|
||||
@Controller('plans')
|
||||
export class PlansWriteController {
|
||||
constructor(private readonly plansService: PlansService) {}
|
||||
|
||||
@Post('generate')
|
||||
@RequireFieldPrivilege('plan', 'create')
|
||||
@ApiOperation({ summary: 'Generate plans from cycles for a date range' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
created: { type: 'number' },
|
||||
skipped: { type: 'number' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
generate(
|
||||
@Body() dto: GeneratePlansDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ created: number; skipped: number }> {
|
||||
return this.plansService.generate({ ...dto, userId });
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequireFieldPrivilege('plan', 'delete')
|
||||
@ApiOperation({ summary: 'Bulk archive plans' })
|
||||
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
|
||||
bulkDelete(
|
||||
@Body() dto: BulkIdsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<{ deleted: number }> {
|
||||
return this.plansService.bulkDelete(dto.ids, user);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequireFieldPrivilege('plan', 'update')
|
||||
@ApiOperation({ summary: 'Bulk update plan status' })
|
||||
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.plansService.bulkUpdateStatus(
|
||||
dto.ids,
|
||||
dto.status,
|
||||
user.id,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireFieldPrivilege('plan', 'create')
|
||||
@ApiOperation({ summary: 'Create plan' })
|
||||
@ApiCreatedResponse({ type: PlanDto })
|
||||
create(
|
||||
@Body() dto: CreatePlanDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<PlanDto> {
|
||||
return this.plansService.create({ ...dto, userId });
|
||||
}
|
||||
|
||||
@Post(':id/destinations')
|
||||
@RequireFieldPrivilege('plan', 'update')
|
||||
@ApiOperation({ summary: 'Add a destination to a plan' })
|
||||
@ApiOkResponse({ type: PlanDto })
|
||||
addDestination(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AddPlanDestinationDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PlanDto> {
|
||||
return this.plansService.addDestination(
|
||||
id,
|
||||
dto.customerId,
|
||||
dto.afterDestinationId,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/destinations/:destinationId')
|
||||
@RequireFieldPrivilege('plan', 'update')
|
||||
@ApiOperation({ summary: 'Remove a destination from a plan' })
|
||||
@ApiOkResponse({ type: PlanDto })
|
||||
removeDestination(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('destinationId', ParseUUIDPipe) destinationId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PlanDto> {
|
||||
return this.plansService.removeDestination(id, destinationId, user);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequireFieldPrivilege('plan', 'update')
|
||||
@ApiOperation({ summary: 'Update plan status' })
|
||||
@ApiOkResponse({ type: PlanDto })
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdatePlanStatusDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PlanDto> {
|
||||
return this.plansService.updateStatus(id, dto.status, user.id, user);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequireFieldPrivilege('plan', 'update')
|
||||
@ApiOperation({ summary: 'Update plan (not status)' })
|
||||
@ApiOkResponse({ type: PlanDto })
|
||||
@ApiNotFoundResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdatePlanDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PlanDto> {
|
||||
return this.plansService.update(id, { ...dto, userId: user.id }, user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequireFieldPrivilege('plan', 'delete')
|
||||
@ApiOperation({ summary: 'Archive plan' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<void> {
|
||||
await this.plansService.delete(id, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
eq,
|
||||
ilike,
|
||||
inArray,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
SQL,
|
||||
} from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
planDestinations,
|
||||
planInvoices,
|
||||
planPackingSlips,
|
||||
plans,
|
||||
type PlanDestinationRow,
|
||||
type PlanInvoiceRow,
|
||||
type PlanPackingSlipRow,
|
||||
type PlanRow,
|
||||
} from '../../../database/plans-table';
|
||||
import type { FieldPurpose } from '../shared/field-purpose';
|
||||
import type { RouteGeometry } from '../shared/route-line-string';
|
||||
import type { ListPlansFilters, Plan } from './plan';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||
|
||||
@Injectable()
|
||||
export class PlansRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListPlansFilters,
|
||||
): Promise<{ data: Plan[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(plans)
|
||||
.where(where);
|
||||
let qb = this.db.select().from(plans).$dynamic();
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(asc(plans.date))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [], [])),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
extendListQuery<T>(qb: T, filters: ListPlansFilters): T {
|
||||
void filters;
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Plan | null> {
|
||||
const rows: PlanRow[] = await this.db
|
||||
.select()
|
||||
.from(plans)
|
||||
.where(eq(plans.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return this.loadWithChildren(this.db, row);
|
||||
}
|
||||
|
||||
async findLiveByKey(
|
||||
employeeId: string,
|
||||
purpose: string,
|
||||
date: number,
|
||||
excludeId?: string,
|
||||
): Promise<Plan | null> {
|
||||
const parts: SQL[] = [
|
||||
eq(plans.employeeId, employeeId),
|
||||
eq(plans.purpose, purpose),
|
||||
eq(plans.date, date),
|
||||
ne(plans.status, 'archived'),
|
||||
];
|
||||
if (excludeId) {
|
||||
parts.push(ne(plans.id, excludeId));
|
||||
}
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(plans)
|
||||
.where(and(...parts))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row, [], [], []) : null;
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
employeeId: string;
|
||||
purpose: FieldPurpose;
|
||||
date: DateTime;
|
||||
startBranchId: string;
|
||||
endBranchId: string;
|
||||
routeGeometry: RouteGeometry;
|
||||
customerIds: readonly string[];
|
||||
invoiceIds: readonly string[];
|
||||
packingSlipIds: readonly string[];
|
||||
status: Status;
|
||||
userId: string;
|
||||
}): Promise<Plan> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const inserted = await tx
|
||||
.insert(plans)
|
||||
.values({
|
||||
employeeId: input.employeeId,
|
||||
purpose: input.purpose,
|
||||
date: input.date.value,
|
||||
startBranchId: input.startBranchId,
|
||||
endBranchId: input.endBranchId,
|
||||
routeGeometry: input.routeGeometry,
|
||||
status: input.status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: input.userId,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceChildren(tx, row.id, input);
|
||||
return this.loadWithChildren(tx, row);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
employeeId: string;
|
||||
purpose: FieldPurpose;
|
||||
date: DateTime;
|
||||
startBranchId: string;
|
||||
endBranchId: string;
|
||||
routeGeometry: RouteGeometry;
|
||||
customerIds?: readonly string[];
|
||||
invoiceIds?: readonly string[];
|
||||
packingSlipIds?: readonly string[];
|
||||
userId: string;
|
||||
},
|
||||
): Promise<Plan> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(plans)
|
||||
.set({
|
||||
employeeId: input.employeeId,
|
||||
purpose: input.purpose,
|
||||
date: input.date.value,
|
||||
startBranchId: input.startBranchId,
|
||||
endBranchId: input.endBranchId,
|
||||
routeGeometry: input.routeGeometry,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(plans.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
await this.replaceChildren(tx, id, {
|
||||
customerIds: input.customerIds,
|
||||
invoiceIds: input.invoiceIds,
|
||||
packingSlipIds: input.packingSlipIds,
|
||||
});
|
||||
return this.loadWithChildren(tx, row);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async replaceDestinations(
|
||||
id: string,
|
||||
customerIds: readonly string[],
|
||||
routeGeometry: RouteGeometry,
|
||||
userId: string,
|
||||
): Promise<Plan> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
return this.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(plans)
|
||||
.set({
|
||||
routeGeometry,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(plans.id, id));
|
||||
await tx.delete(planDestinations).where(eq(planDestinations.planId, id));
|
||||
if (customerIds.length > 0) {
|
||||
await tx.insert(planDestinations).values(
|
||||
customerIds.map((customerId, index) => ({
|
||||
planId: id,
|
||||
customerId,
|
||||
sortOrder: index,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(plans)
|
||||
.where(eq(plans.id, id))
|
||||
.limit(1);
|
||||
return this.loadWithChildren(tx, rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Plan> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(plans)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(plans.id, id))
|
||||
.returning({ id: plans.id });
|
||||
if (updated.length === 0) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
const found = await this.findById(id);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(plans)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(plans.id, ids))
|
||||
.returning({ id: plans.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async archive(id: string, userId: string): Promise<void> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(plans)
|
||||
.set({
|
||||
status: 'archived',
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(plans.id, id))
|
||||
.returning({ id: plans.id });
|
||||
if (updated.length === 0) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkArchive(ids: string[], userId: string): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(plans)
|
||||
.set({
|
||||
status: 'archived',
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(plans.id, ids))
|
||||
.returning({ id: plans.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async loadWithChildren(
|
||||
executor: QueryExecutor,
|
||||
row: PlanRow,
|
||||
): Promise<Plan> {
|
||||
const destinations = await executor
|
||||
.select()
|
||||
.from(planDestinations)
|
||||
.where(eq(planDestinations.planId, row.id))
|
||||
.orderBy(asc(planDestinations.sortOrder));
|
||||
const invoices = await executor
|
||||
.select()
|
||||
.from(planInvoices)
|
||||
.where(eq(planInvoices.planId, row.id));
|
||||
const packingSlips = await executor
|
||||
.select()
|
||||
.from(planPackingSlips)
|
||||
.where(eq(planPackingSlips.planId, row.id));
|
||||
return this.toDomain(row, destinations, invoices, packingSlips);
|
||||
}
|
||||
|
||||
private async replaceChildren(
|
||||
executor: QueryExecutor,
|
||||
planId: string,
|
||||
input: {
|
||||
customerIds?: readonly string[];
|
||||
invoiceIds?: readonly string[];
|
||||
packingSlipIds?: readonly string[];
|
||||
},
|
||||
): Promise<void> {
|
||||
if (input.customerIds) {
|
||||
await executor
|
||||
.delete(planDestinations)
|
||||
.where(eq(planDestinations.planId, planId));
|
||||
if (input.customerIds.length > 0) {
|
||||
await executor.insert(planDestinations).values(
|
||||
input.customerIds.map((customerId, index) => ({
|
||||
planId,
|
||||
customerId,
|
||||
sortOrder: index,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (input.invoiceIds) {
|
||||
await executor
|
||||
.delete(planInvoices)
|
||||
.where(eq(planInvoices.planId, planId));
|
||||
if (input.invoiceIds.length > 0) {
|
||||
await executor
|
||||
.insert(planInvoices)
|
||||
.values(input.invoiceIds.map((invoiceId) => ({ planId, invoiceId })));
|
||||
}
|
||||
}
|
||||
if (input.packingSlipIds) {
|
||||
await executor
|
||||
.delete(planPackingSlips)
|
||||
.where(eq(planPackingSlips.planId, planId));
|
||||
if (input.packingSlipIds.length > 0) {
|
||||
await executor.insert(planPackingSlips).values(
|
||||
input.packingSlipIds.map((packingSlipId) => ({
|
||||
planId,
|
||||
packingSlipId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListPlansFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.employeeId) {
|
||||
parts.push(eq(plans.employeeId, filters.employeeId));
|
||||
}
|
||||
if (filters.purpose) {
|
||||
parts.push(eq(plans.purpose, filters.purpose));
|
||||
} else if (filters.purposes && filters.purposes.length > 0) {
|
||||
parts.push(inArray(plans.purpose, [...filters.purposes]));
|
||||
}
|
||||
if (filters.date !== undefined) {
|
||||
parts.push(eq(plans.date, filters.date));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(plans.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(plans.purpose, `%${filters.search}%`),
|
||||
sql`${plans.date}::text ilike ${'%' + filters.search + '%'}`,
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: PlanRow,
|
||||
destinationRows: PlanDestinationRow[],
|
||||
invoiceRows: PlanInvoiceRow[],
|
||||
packingSlipRows: PlanPackingSlipRow[],
|
||||
): Plan {
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employeeId,
|
||||
purpose: row.purpose as FieldPurpose,
|
||||
date: DateTime.fromUnixMs(row.date),
|
||||
startBranchId: row.startBranchId,
|
||||
endBranchId: row.endBranchId,
|
||||
routeGeometry: row.routeGeometry,
|
||||
destinations: destinationRows.map((destination) => ({
|
||||
id: destination.id,
|
||||
customerId: destination.customerId,
|
||||
sortOrder: destination.sortOrder,
|
||||
})),
|
||||
invoiceIds: invoiceRows.map((row) => row.invoiceId),
|
||||
packingSlipIds: packingSlipRows.map((row) => row.packingSlipId),
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Plan already exists for this employee');
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Plan references a missing record');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string } {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,624 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } 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 { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
|
||||
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 { CyclesRepository } from '../cycles/cycles.repository';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import {
|
||||
cycleNumberForDate,
|
||||
fieldPrivilegeKey,
|
||||
isFieldPurpose,
|
||||
noCycleMessage,
|
||||
type FieldPurpose,
|
||||
} from '../shared/field-purpose';
|
||||
import {
|
||||
buildRouteLineString,
|
||||
isUsableRouteGeometry,
|
||||
} from '../shared/route-line-string';
|
||||
import type { Plan } from './plan';
|
||||
import { PlansRepository } from './plans.repository';
|
||||
|
||||
export type ListPlansQuery = {
|
||||
readonly employeeId?: string;
|
||||
readonly purpose?: string;
|
||||
readonly date?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const VISIBLE_FIELDS = [
|
||||
'id',
|
||||
'employeeId',
|
||||
'purpose',
|
||||
'date',
|
||||
'startBranchId',
|
||||
'endBranchId',
|
||||
'routeGeometry',
|
||||
'destinations',
|
||||
'invoiceIds',
|
||||
'packingSlipIds',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
] as const;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
@Injectable()
|
||||
export class PlansService {
|
||||
constructor(
|
||||
private readonly plansRepository: PlansRepository,
|
||||
private readonly cyclesRepository: CyclesRepository,
|
||||
private readonly companySettingsService: CompanySettingsService,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly branchesService: BranchesService,
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly salesInvoicesService: SalesInvoicesService,
|
||||
private readonly packingSlipsService: PackingSlipsService,
|
||||
private readonly privilegesService: PrivilegesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListPlansQuery,
|
||||
user: AuthUser,
|
||||
): Promise<PaginationResponse<ReturnType<PlansService['toItem']>>> {
|
||||
const purposes = await this.allowedPurposes(user, 'view');
|
||||
if (query.purpose && !purposes.includes(query.purpose as FieldPurpose)) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.plansRepository.list({
|
||||
employeeId: query.employeeId,
|
||||
purpose: query.purpose,
|
||||
date: query.date
|
||||
? this.assertDate(query.date).startOfDay().value
|
||||
: undefined,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
purposes: query.purpose ? undefined : purposes,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return { data: data.map((item) => this.toItem(item)), total };
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
user: AuthUser,
|
||||
): Promise<ReturnType<PlansService['toItem']>> {
|
||||
const plan = await this.requirePlan(id);
|
||||
await this.assertCanAccess(user, plan.purpose, 'view');
|
||||
return this.toItem(plan);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
employeeId: string;
|
||||
purpose: string;
|
||||
date: string;
|
||||
startBranchId: string;
|
||||
endBranchId: string;
|
||||
customerIds: string[];
|
||||
invoiceIds?: string[];
|
||||
packingSlipIds?: string[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<PlansService['toItem']>> {
|
||||
const created = await this.plansRepository.create(
|
||||
await this.toWritePayload(input),
|
||||
);
|
||||
return this.toItem(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
employeeId?: string;
|
||||
purpose?: string;
|
||||
date?: string;
|
||||
startBranchId?: string;
|
||||
endBranchId?: string;
|
||||
customerIds?: string[];
|
||||
invoiceIds?: string[];
|
||||
packingSlipIds?: string[];
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
user: AuthUser,
|
||||
): Promise<ReturnType<PlansService['toItem']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
const existing = await this.requirePlan(id);
|
||||
await this.assertCanAccess(user, existing.purpose, 'update');
|
||||
const purpose = input.purpose
|
||||
? this.assertPurpose(input.purpose)
|
||||
: existing.purpose;
|
||||
const employeeId = input.employeeId ?? existing.employeeId;
|
||||
const date = input.date
|
||||
? this.assertDate(input.date).startOfDay()
|
||||
: existing.date;
|
||||
await this.employeesService.findById(employeeId);
|
||||
await this.assertUnique(employeeId, purpose, date.value, id);
|
||||
const startBranchId = input.startBranchId ?? existing.startBranchId;
|
||||
const endBranchId = input.endBranchId ?? existing.endBranchId;
|
||||
const customerIds =
|
||||
input.customerIds ?? existing.destinations.map((d) => d.customerId);
|
||||
const geometry = await this.buildGeometry(
|
||||
startBranchId,
|
||||
endBranchId,
|
||||
customerIds,
|
||||
);
|
||||
const attachments = this.assertAttachments(
|
||||
purpose,
|
||||
input.invoiceIds ?? [...existing.invoiceIds],
|
||||
input.packingSlipIds ?? [...existing.packingSlipIds],
|
||||
);
|
||||
if (input.invoiceIds) {
|
||||
await this.assertInvoices(input.invoiceIds);
|
||||
}
|
||||
if (input.packingSlipIds) {
|
||||
await this.assertPackingSlips(input.packingSlipIds);
|
||||
}
|
||||
const updated = await this.plansRepository.update(id, {
|
||||
employeeId,
|
||||
purpose,
|
||||
date,
|
||||
startBranchId,
|
||||
endBranchId,
|
||||
routeGeometry: geometry,
|
||||
customerIds,
|
||||
invoiceIds: attachments.invoiceIds,
|
||||
packingSlipIds: attachments.packingSlipIds,
|
||||
userId: input.userId,
|
||||
});
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
user: AuthUser,
|
||||
): Promise<ReturnType<PlansService['toItem']>> {
|
||||
const existing = await this.requirePlan(id);
|
||||
await this.assertCanAccess(user, existing.purpose, 'update');
|
||||
const updated = await this.plansRepository.updateStatus(
|
||||
id,
|
||||
this.assertStatus(statusRaw),
|
||||
userId,
|
||||
);
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
user: AuthUser,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
await this.assertIdsAccessible(ids, user, 'update');
|
||||
const updated = await this.plansRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string, user: AuthUser): Promise<void> {
|
||||
const existing = await this.requirePlan(id);
|
||||
await this.assertCanAccess(user, existing.purpose, 'delete');
|
||||
await this.plansRepository.archive(id, user.id);
|
||||
}
|
||||
|
||||
async bulkDelete(
|
||||
ids: string[],
|
||||
user: AuthUser,
|
||||
): Promise<{ deleted: number }> {
|
||||
await this.assertIdsAccessible(ids, user, 'delete');
|
||||
const deleted = await this.plansRepository.bulkArchive(ids, user.id);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async generate(input: {
|
||||
employeeId: string;
|
||||
purpose: string;
|
||||
from: string;
|
||||
to: string;
|
||||
userId: string;
|
||||
}): Promise<{ created: number; skipped: number }> {
|
||||
const purpose = this.assertPurpose(input.purpose);
|
||||
await this.employeesService.findById(input.employeeId);
|
||||
const epoch = await this.companySettingsService.requireCycleStartDate();
|
||||
const from = this.assertDate(input.from).startOfDay();
|
||||
const to = this.assertDate(input.to).startOfDay();
|
||||
if (to.value < from.value) {
|
||||
throw new BadRequestException('Invalid date range');
|
||||
}
|
||||
if (from.value < epoch.startOfDay().value) {
|
||||
throw new BadRequestException('Date is before the cycle start date');
|
||||
}
|
||||
const cycles = await this.cyclesRepository.listActiveByEmployeePurpose(
|
||||
input.employeeId,
|
||||
purpose,
|
||||
);
|
||||
if (cycles.length === 0) {
|
||||
throw new BadRequestException(noCycleMessage(purpose));
|
||||
}
|
||||
const totalCycles = Math.max(...cycles.map((cycle) => cycle.cycleNumber));
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
for (let cursor = from.value; cursor <= to.value; cursor += MS_PER_DAY) {
|
||||
const day = DateTime.fromUnixMs(cursor).startOfDay();
|
||||
const cycleNumber = cycleNumberForDate(
|
||||
day.wholeWeeksSince(epoch),
|
||||
totalCycles,
|
||||
);
|
||||
const cycle = cycles.find((item) => item.cycleNumber === cycleNumber);
|
||||
if (!cycle) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const weekday = cycle.weekdays.find(
|
||||
(item) => item.weekday === day.weekdayName(),
|
||||
);
|
||||
if (!weekday) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const existing = await this.plansRepository.findLiveByKey(
|
||||
input.employeeId,
|
||||
purpose,
|
||||
day.value,
|
||||
);
|
||||
if (existing) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await this.plansRepository.create({
|
||||
employeeId: input.employeeId,
|
||||
purpose,
|
||||
date: day,
|
||||
startBranchId: weekday.startBranchId,
|
||||
endBranchId: weekday.endBranchId,
|
||||
routeGeometry: weekday.routeGeometry,
|
||||
customerIds: weekday.destinations.map((d) => d.customerId),
|
||||
invoiceIds: [],
|
||||
packingSlipIds: [],
|
||||
status: Status.create('active'),
|
||||
userId: input.userId,
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
async addDestination(
|
||||
planId: string,
|
||||
customerId: string,
|
||||
afterDestinationId: string | undefined,
|
||||
user: AuthUser,
|
||||
): Promise<ReturnType<PlansService['toItem']>> {
|
||||
const plan = await this.requirePlan(planId);
|
||||
await this.assertCanAccess(user, plan.purpose, 'update');
|
||||
await this.customersService.findById(customerId);
|
||||
if (plan.destinations.some((d) => d.customerId === customerId)) {
|
||||
throw new ConflictException('Customer is already on this plan');
|
||||
}
|
||||
const ids = plan.destinations.map((d) => d.customerId);
|
||||
if (afterDestinationId) {
|
||||
const after = plan.destinations.find((d) => d.id === afterDestinationId);
|
||||
if (!after) {
|
||||
throw new BadRequestException('Destination is not on this plan');
|
||||
}
|
||||
const index = ids.indexOf(after.customerId);
|
||||
ids.splice(index + 1, 0, customerId);
|
||||
} else {
|
||||
ids.push(customerId);
|
||||
}
|
||||
const geometry = await this.buildGeometry(
|
||||
plan.startBranchId,
|
||||
plan.endBranchId,
|
||||
ids,
|
||||
);
|
||||
const updated = await this.plansRepository.replaceDestinations(
|
||||
planId,
|
||||
ids,
|
||||
geometry,
|
||||
user.id,
|
||||
);
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async removeDestination(
|
||||
planId: string,
|
||||
destinationId: string,
|
||||
user: AuthUser,
|
||||
): Promise<ReturnType<PlansService['toItem']>> {
|
||||
const plan = await this.requirePlan(planId);
|
||||
await this.assertCanAccess(user, plan.purpose, 'update');
|
||||
if (plan.destinations.length <= 1) {
|
||||
throw new BadRequestException(
|
||||
'A live plan must keep at least one destination',
|
||||
);
|
||||
}
|
||||
const remaining = plan.destinations.filter((d) => d.id !== destinationId);
|
||||
if (remaining.length === plan.destinations.length) {
|
||||
throw new NotFoundException('Destination not found');
|
||||
}
|
||||
const ids = remaining.map((d) => d.customerId);
|
||||
const geometry = await this.buildGeometry(
|
||||
plan.startBranchId,
|
||||
plan.endBranchId,
|
||||
ids,
|
||||
);
|
||||
const updated = await this.plansRepository.replaceDestinations(
|
||||
planId,
|
||||
ids,
|
||||
geometry,
|
||||
user.id,
|
||||
);
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
get visibleFields(): readonly string[] {
|
||||
return VISIBLE_FIELDS;
|
||||
}
|
||||
|
||||
toItem(plan: Plan) {
|
||||
return {
|
||||
id: plan.id,
|
||||
employeeId: plan.employeeId,
|
||||
purpose: plan.purpose,
|
||||
date: plan.date.value,
|
||||
startBranchId: plan.startBranchId,
|
||||
endBranchId: plan.endBranchId,
|
||||
routeGeometry: plan.routeGeometry,
|
||||
destinations: plan.destinations.map((destination) => ({
|
||||
id: destination.id,
|
||||
customerId: destination.customerId,
|
||||
sortOrder: destination.sortOrder,
|
||||
})),
|
||||
invoiceIds: [...plan.invoiceIds],
|
||||
packingSlipIds: [...plan.packingSlipIds],
|
||||
status: plan.status.value,
|
||||
createdAt: plan.createdAt.value,
|
||||
updatedAt: plan.updatedAt.value,
|
||||
createdBy: plan.createdBy,
|
||||
updatedBy: plan.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private async toWritePayload(input: {
|
||||
employeeId: string;
|
||||
purpose: string;
|
||||
date: string;
|
||||
startBranchId: string;
|
||||
endBranchId: string;
|
||||
customerIds: string[];
|
||||
invoiceIds?: string[];
|
||||
packingSlipIds?: string[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const purpose = this.assertPurpose(input.purpose);
|
||||
const date = this.assertDate(input.date).startOfDay();
|
||||
await this.employeesService.findById(input.employeeId);
|
||||
await this.assertUnique(input.employeeId, purpose, date.value);
|
||||
const geometry = await this.buildGeometry(
|
||||
input.startBranchId,
|
||||
input.endBranchId,
|
||||
input.customerIds,
|
||||
);
|
||||
const attachments = this.assertAttachments(
|
||||
purpose,
|
||||
input.invoiceIds ?? [],
|
||||
input.packingSlipIds ?? [],
|
||||
);
|
||||
await this.assertInvoices(attachments.invoiceIds);
|
||||
await this.assertPackingSlips(attachments.packingSlipIds);
|
||||
return {
|
||||
employeeId: input.employeeId,
|
||||
purpose,
|
||||
date,
|
||||
startBranchId: input.startBranchId,
|
||||
endBranchId: input.endBranchId,
|
||||
routeGeometry: geometry,
|
||||
customerIds: input.customerIds,
|
||||
invoiceIds: attachments.invoiceIds,
|
||||
packingSlipIds: attachments.packingSlipIds,
|
||||
status: input.status
|
||||
? this.assertStatus(input.status)
|
||||
: Status.create(Status.DEFAULT),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildGeometry(
|
||||
startBranchId: string,
|
||||
endBranchId: string,
|
||||
customerIds: readonly string[],
|
||||
) {
|
||||
if (customerIds.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'A working day needs at least one destination',
|
||||
);
|
||||
}
|
||||
const start = await this.branchesService.findById(startBranchId);
|
||||
const end = await this.branchesService.findById(endBranchId);
|
||||
const customers: Array<{
|
||||
longitude: number | null;
|
||||
latitude: number | null;
|
||||
}> = [];
|
||||
for (const customerId of customerIds) {
|
||||
customers.push(await this.customersService.findById(customerId));
|
||||
}
|
||||
const geometry = buildRouteLineString([
|
||||
{ longitude: start.longitude, latitude: start.latitude },
|
||||
...customers.map((customer) => ({
|
||||
longitude: customer.longitude,
|
||||
latitude: customer.latitude,
|
||||
})),
|
||||
{ longitude: end.longitude, latitude: end.latitude },
|
||||
]);
|
||||
if (!isUsableRouteGeometry(geometry)) {
|
||||
throw new BadRequestException('Route is incomplete');
|
||||
}
|
||||
return geometry;
|
||||
}
|
||||
|
||||
private assertAttachments(
|
||||
purpose: FieldPurpose,
|
||||
invoiceIds: readonly string[],
|
||||
packingSlipIds: readonly string[],
|
||||
): { invoiceIds: string[]; packingSlipIds: string[] } {
|
||||
if (purpose === 'sales' && packingSlipIds.length > 0) {
|
||||
throw new BadRequestException('Sales plans cannot include packing slips');
|
||||
}
|
||||
if (purpose === 'logistics' && invoiceIds.length > 0) {
|
||||
throw new BadRequestException('Logistics plans cannot include invoices');
|
||||
}
|
||||
return {
|
||||
invoiceIds: [...invoiceIds],
|
||||
packingSlipIds: [...packingSlipIds],
|
||||
};
|
||||
}
|
||||
|
||||
private async assertInvoices(ids: readonly string[]): Promise<void> {
|
||||
for (const id of ids) {
|
||||
await this.salesInvoicesService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPackingSlips(ids: readonly string[]): Promise<void> {
|
||||
for (const id of ids) {
|
||||
await this.packingSlipsService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async requirePlan(id: string): Promise<Plan> {
|
||||
const plan = await this.plansRepository.findById(id);
|
||||
if (!plan) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private async assertUnique(
|
||||
employeeId: string,
|
||||
purpose: FieldPurpose,
|
||||
date: number,
|
||||
excludeId?: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.plansRepository.findLiveByKey(
|
||||
employeeId,
|
||||
purpose,
|
||||
date,
|
||||
excludeId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException('Plan already exists for this employee');
|
||||
}
|
||||
}
|
||||
|
||||
private async allowedPurposes(
|
||||
user: AuthUser,
|
||||
action: 'view' | 'update' | 'delete',
|
||||
): Promise<FieldPurpose[]> {
|
||||
if (user.isSuperadmin) {
|
||||
return ['sales', 'logistics'];
|
||||
}
|
||||
const allowed: FieldPurpose[] = [];
|
||||
for (const purpose of ['sales', 'logistics'] as const) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
user.id,
|
||||
fieldPrivilegeKey('plan', purpose),
|
||||
action,
|
||||
);
|
||||
if (ok) {
|
||||
allowed.push(purpose);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
private async assertCanAccess(
|
||||
user: AuthUser,
|
||||
purpose: FieldPurpose,
|
||||
action: 'view' | 'update' | 'delete',
|
||||
): Promise<void> {
|
||||
if (user.isSuperadmin) {
|
||||
return;
|
||||
}
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
user.id,
|
||||
fieldPrivilegeKey('plan', purpose),
|
||||
action,
|
||||
);
|
||||
if (!ok) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertIdsAccessible(
|
||||
ids: string[],
|
||||
user: AuthUser,
|
||||
action: 'update' | 'delete',
|
||||
): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const plan = await this.requirePlan(id);
|
||||
await this.assertCanAccess(user, plan.purpose, action);
|
||||
}
|
||||
}
|
||||
|
||||
private assertPurpose(raw: string): FieldPurpose {
|
||||
if (!isFieldPurpose(raw)) {
|
||||
throw new BadRequestException('Invalid purpose');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private assertDate(raw: string): DateTime {
|
||||
try {
|
||||
return DateTime.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDateTimeError) {
|
||||
throw new BadRequestException('Invalid date');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private assertStatus(raw: string): Status {
|
||||
try {
|
||||
return Status.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidStatusError) {
|
||||
throw new BadRequestException('Invalid status');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
export type CompanySetting = {
|
||||
readonly id: string;
|
||||
readonly cycleStartDate: DateTime;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
};
|
||||
|
||||
export type UpsertCompanySettingInput = {
|
||||
readonly cycleStartDate: DateTime;
|
||||
readonly userId: string;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
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 { SETTINGS_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import { CompanySettingsService } from './company-settings.service';
|
||||
import {
|
||||
CompanySettingDto,
|
||||
UpdateCompanySettingDto,
|
||||
} from './dto/company-setting.dto';
|
||||
|
||||
@ApiTags('settings')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('settings')
|
||||
export class CompanySettingsController {
|
||||
constructor(
|
||||
private readonly companySettingsService: CompanySettingsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePrivilege(SETTINGS_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get company settings' })
|
||||
@ApiOkResponse({ type: CompanySettingDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
get(): Promise<CompanySettingDto> {
|
||||
return this.companySettingsService.get();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@RequirePrivilege(SETTINGS_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update company cycle start date' })
|
||||
@ApiOkResponse({ type: CompanySettingDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Body() dto: UpdateCompanySettingDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<CompanySettingDto> {
|
||||
return this.companySettingsService.update(dto.cycleStartDate, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
companySettings,
|
||||
type CompanySettingsRow,
|
||||
} from '../../../database/company-settings-table';
|
||||
import type {
|
||||
CompanySetting,
|
||||
UpsertCompanySettingInput,
|
||||
} from './company-setting';
|
||||
|
||||
@Injectable()
|
||||
export class CompanySettingsRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async find(): Promise<CompanySetting | null> {
|
||||
const rows: CompanySettingsRow[] = await this.db
|
||||
.select()
|
||||
.from(companySettings)
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async upsert(input: UpsertCompanySettingInput): Promise<CompanySetting> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const existing = await this.find();
|
||||
if (!existing) {
|
||||
const inserted = await this.db
|
||||
.insert(companySettings)
|
||||
.values({
|
||||
cycleStartDate: input.cycleStartDate.value,
|
||||
status: Status.create(Status.DEFAULT).value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: input.userId,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.returning();
|
||||
return this.toDomain(inserted[0]);
|
||||
}
|
||||
const updated = await this.db
|
||||
.update(companySettings)
|
||||
.set({
|
||||
cycleStartDate: input.cycleStartDate.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(companySettings.id, existing.id))
|
||||
.returning();
|
||||
return this.toDomain(updated[0]);
|
||||
}
|
||||
|
||||
private toDomain(row: CompanySettingsRow): CompanySetting {
|
||||
return {
|
||||
id: row.id,
|
||||
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BadRequestException, NotFoundException } 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 type { CompanySetting } from './company-setting';
|
||||
import { CompanySettingsRepository } from './company-settings.repository';
|
||||
import { CompanySettingsService } from './company-settings.service';
|
||||
|
||||
describe('CompanySettingsService', () => {
|
||||
let service: CompanySettingsService;
|
||||
let repository: jest.Mocked<
|
||||
Pick<CompanySettingsRepository, 'find' | 'upsert'>
|
||||
>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: CompanySetting = {
|
||||
id: 'set-1',
|
||||
cycleStartDate: DateTime.create('2026-01-05'),
|
||||
status: Status.create('draft'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = {
|
||||
find: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
};
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CompanySettingsService,
|
||||
{ provide: CompanySettingsRepository, useValue: repository },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(CompanySettingsService);
|
||||
});
|
||||
|
||||
it('get throws when settings are missing', async () => {
|
||||
repository.find.mockResolvedValue(null);
|
||||
await expect(service.get()).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('get maps cycleStartDate to unix ms', async () => {
|
||||
repository.find.mockResolvedValue(sample);
|
||||
const result = await service.get();
|
||||
expect(result.cycleStartDate).toBe(sample.cycleStartDate.value);
|
||||
});
|
||||
|
||||
it('update persists start of day', async () => {
|
||||
repository.upsert.mockResolvedValue(sample);
|
||||
await service.update('2026-01-05', 'user-1');
|
||||
const arg = repository.upsert.mock.calls[0][0];
|
||||
expect(arg.cycleStartDate.equals(DateTime.create('2026-01-05'))).toBe(true);
|
||||
expect(arg.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('update rejects invalid dates', async () => {
|
||||
await expect(service.update('not-a-date', 'user-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { CompanySettingsRepository } from './company-settings.repository';
|
||||
import type { CompanySetting } from './company-setting';
|
||||
|
||||
@Injectable()
|
||||
export class CompanySettingsService {
|
||||
constructor(
|
||||
private readonly companySettingsRepository: CompanySettingsRepository,
|
||||
) {}
|
||||
|
||||
async get(): Promise<ReturnType<CompanySettingsService['toItem']>> {
|
||||
const setting = await this.companySettingsRepository.find();
|
||||
if (!setting) {
|
||||
throw new NotFoundException('Settings not configured');
|
||||
}
|
||||
return this.toItem(setting);
|
||||
}
|
||||
|
||||
async update(
|
||||
cycleStartDateRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<CompanySettingsService['toItem']>> {
|
||||
const cycleStartDate = this.assertDate(cycleStartDateRaw).startOfDay();
|
||||
const saved = await this.companySettingsRepository.upsert({
|
||||
cycleStartDate,
|
||||
userId,
|
||||
});
|
||||
return this.toItem(saved);
|
||||
}
|
||||
|
||||
async requireCycleStartDate(): Promise<DateTime> {
|
||||
const setting = await this.companySettingsRepository.find();
|
||||
if (!setting) {
|
||||
throw new NotFoundException('Settings not configured');
|
||||
}
|
||||
return setting.cycleStartDate;
|
||||
}
|
||||
|
||||
toItem(setting: CompanySetting) {
|
||||
return {
|
||||
id: setting.id,
|
||||
cycleStartDate: setting.cycleStartDate.value,
|
||||
status: setting.status.value,
|
||||
createdAt: setting.createdAt.value,
|
||||
updatedAt: setting.updatedAt.value,
|
||||
createdBy: setting.createdBy,
|
||||
updatedBy: setting.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private assertDate(raw: string): DateTime {
|
||||
try {
|
||||
return DateTime.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDateTimeError) {
|
||||
throw new BadRequestException('Invalid cycle start date');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
||||
|
||||
export class UpdateCompanySettingDto {
|
||||
@ApiProperty({ example: '2026-01-05', description: 'YYYY-MM-DD' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
message: 'cycleStartDate must be a calendar date',
|
||||
})
|
||||
cycleStartDate!: string;
|
||||
}
|
||||
|
||||
export class CompanySettingDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms start of the cycle-start calendar day' })
|
||||
cycleStartDate!: number;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
isValidCycleNumber,
|
||||
parseCsvRecord,
|
||||
parseWeekdaysInput,
|
||||
} from './field-fields';
|
||||
|
||||
describe('field-fields', () => {
|
||||
it('accepts cycle numbers starting at 1', () => {
|
||||
expect(isValidCycleNumber(1)).toBe(true);
|
||||
expect(isValidCycleNumber(0)).toBe(false);
|
||||
expect(isValidCycleNumber(1.5)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats omitted weekdays as days off and requires complete present days', () => {
|
||||
const parsed = parseWeekdaysInput({
|
||||
monday: {
|
||||
startBranchId: 'b1',
|
||||
endBranchId: 'b2',
|
||||
customerIds: ['c1'],
|
||||
},
|
||||
});
|
||||
expect(parsed.monday?.customerIds).toEqual(['c1']);
|
||||
expect(parsed.sunday).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an incomplete present weekday', () => {
|
||||
expect(() =>
|
||||
parseWeekdaysInput({
|
||||
monday: { startBranchId: 'b1', endBranchId: 'b2', customerIds: [] },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('parses quoted CSV cells', () => {
|
||||
expect(parseCsvRecord('a,"b,c",d')).toEqual(['a', 'b,c', 'd']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { WeekdayName } from './field-purpose';
|
||||
import { isWeekdayName } from './field-purpose';
|
||||
|
||||
export type WeekdayTemplateInput = {
|
||||
readonly startBranchId: string;
|
||||
readonly endBranchId: string;
|
||||
readonly customerIds: readonly string[];
|
||||
};
|
||||
|
||||
export type WeekdaysInput = Partial<Record<WeekdayName, WeekdayTemplateInput>>;
|
||||
|
||||
export function isValidCycleNumber(raw: number): boolean {
|
||||
return Number.isInteger(raw) && raw >= 1;
|
||||
}
|
||||
|
||||
export function assertCompleteWeekday(
|
||||
raw: WeekdayTemplateInput | undefined,
|
||||
): WeekdayTemplateInput | undefined {
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!raw.startBranchId ||
|
||||
!raw.endBranchId ||
|
||||
!Array.isArray(raw.customerIds) ||
|
||||
raw.customerIds.length === 0
|
||||
) {
|
||||
throw new Error('incomplete');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function parseWeekdaysInput(raw: unknown): WeekdaysInput {
|
||||
if (raw === undefined || raw === null) {
|
||||
return {};
|
||||
}
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error('incomplete');
|
||||
}
|
||||
const record = raw as Record<string, WeekdayTemplateInput>;
|
||||
const result: WeekdaysInput = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!isWeekdayName(key)) {
|
||||
throw new Error('incomplete');
|
||||
}
|
||||
const complete = assertCompleteWeekday(value);
|
||||
if (complete) {
|
||||
result[key] = complete;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** RFC 4180-style record split that preserves commas inside quotes. */
|
||||
export function parseCsvRecord(line: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i += 1;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ',') {
|
||||
cells.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function isAllowedCsvUpload(file: {
|
||||
mimetype: string;
|
||||
originalname: string;
|
||||
}): boolean {
|
||||
return (
|
||||
file.mimetype.includes('csv') ||
|
||||
file.originalname.toLowerCase().endsWith('.csv')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { PrivilegeAction } from '../../privileges/privilege-action';
|
||||
import type { FieldResource } from './field-purpose';
|
||||
|
||||
export const REQUIRE_FIELD_PRIVILEGE_KEY = 'requireFieldPrivilege';
|
||||
|
||||
export type RequireFieldPrivilegeMeta = {
|
||||
readonly resource: FieldResource;
|
||||
readonly action: PrivilegeAction;
|
||||
};
|
||||
|
||||
export const RequireFieldPrivilege = (
|
||||
resource: FieldResource,
|
||||
action: PrivilegeAction,
|
||||
) =>
|
||||
SetMetadata(REQUIRE_FIELD_PRIVILEGE_KEY, {
|
||||
resource,
|
||||
action,
|
||||
} satisfies RequireFieldPrivilegeMeta);
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import type { RequireFieldPrivilegeMeta } from './field-privilege.decorator';
|
||||
import { REQUIRE_FIELD_PRIVILEGE_KEY } from './field-privilege.decorator';
|
||||
import { FieldPrivilegeGuard } from './field-privilege.guard';
|
||||
|
||||
describe('FieldPrivilegeGuard', () => {
|
||||
const checkPermission = jest.fn();
|
||||
const getAllAndOverride = jest.fn();
|
||||
const reflector = {
|
||||
getAllAndOverride,
|
||||
} as unknown as Reflector;
|
||||
|
||||
const guard = new FieldPrivilegeGuard(reflector, {
|
||||
checkPermission,
|
||||
} as never);
|
||||
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
isSuperadmin: false,
|
||||
};
|
||||
|
||||
function createContext(
|
||||
currentUser?: AuthUser,
|
||||
body?: { purpose?: string },
|
||||
query?: { purpose?: string },
|
||||
): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => jest.fn(),
|
||||
getClass: () => jest.fn(),
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: currentUser, body, query }),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('allows when no field privilege metadata', async () => {
|
||||
getAllAndOverride.mockReturnValue(undefined);
|
||||
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
||||
expect(checkPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows sales purpose when sales key is granted', async () => {
|
||||
const meta: RequireFieldPrivilegeMeta = {
|
||||
resource: 'cycle',
|
||||
action: 'create',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
checkPermission.mockImplementation((_id: string, key: string) =>
|
||||
Promise.resolve(key === 'SALES.CYCLE'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
guard.canActivate(createContext(user, { purpose: 'sales' })),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('forbids logistics purpose for a sales-only user', async () => {
|
||||
const meta: RequireFieldPrivilegeMeta = {
|
||||
resource: 'plan',
|
||||
action: 'update',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
checkPermission.mockImplementation((_id: string, key: string) =>
|
||||
Promise.resolve(key === 'SALES.PLAN'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
guard.canActivate(createContext(user, { purpose: 'logistics' })),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('allows list without purpose when either sales or logistics view is granted', async () => {
|
||||
const meta: RequireFieldPrivilegeMeta = {
|
||||
resource: 'cycle',
|
||||
action: 'view',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
checkPermission.mockImplementation((_id: string, key: string) =>
|
||||
Promise.resolve(key === 'LOGISTICS.CYCLE'),
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(createContext(user, {}, {}))).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips lookup for superadmin', async () => {
|
||||
const meta: RequireFieldPrivilegeMeta = {
|
||||
resource: 'cycle',
|
||||
action: 'delete',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
|
||||
await expect(
|
||||
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
|
||||
).resolves.toBe(true);
|
||||
expect(checkPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unauthorized when metadata present but no user', async () => {
|
||||
const meta: RequireFieldPrivilegeMeta = {
|
||||
resource: 'cycle',
|
||||
action: 'view',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
|
||||
await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads metadata from handler and class', async () => {
|
||||
getAllAndOverride.mockReturnValue(undefined);
|
||||
await guard.canActivate(createContext(user));
|
||||
expect(getAllAndOverride).toHaveBeenCalledWith(
|
||||
REQUIRE_FIELD_PRIVILEGE_KEY,
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import type { PrivilegeAction } from '../../privileges/privilege-action';
|
||||
import { PrivilegesService } from '../../privileges/privileges.service';
|
||||
import {
|
||||
fieldPrivilegeKey,
|
||||
isFieldPurpose,
|
||||
type FieldPurpose,
|
||||
type FieldResource,
|
||||
} from './field-purpose';
|
||||
import {
|
||||
REQUIRE_FIELD_PRIVILEGE_KEY,
|
||||
type RequireFieldPrivilegeMeta,
|
||||
} from './field-privilege.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class FieldPrivilegeGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly privilegesService: PrivilegesService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required = this.reflector.getAllAndOverride<
|
||||
RequireFieldPrivilegeMeta | undefined
|
||||
>(REQUIRE_FIELD_PRIVILEGE_KEY, [context.getHandler(), context.getClass()]);
|
||||
|
||||
if (!required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: AuthUser;
|
||||
body?: { purpose?: string };
|
||||
query?: { purpose?: string };
|
||||
}>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (user.isSuperadmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const purposeRaw = request.body?.purpose ?? request.query?.purpose;
|
||||
const allowed = await this.allowedPurposes(
|
||||
user.id,
|
||||
required.resource,
|
||||
required.action,
|
||||
);
|
||||
if (purposeRaw !== undefined && purposeRaw !== '') {
|
||||
if (!isFieldPurpose(purposeRaw)) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
if (!allowed.includes(purposeRaw)) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (allowed.length === 0) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async allowedPurposes(
|
||||
userId: string,
|
||||
resource: FieldResource,
|
||||
action: PrivilegeAction,
|
||||
): Promise<FieldPurpose[]> {
|
||||
const purposes: FieldPurpose[] = ['sales', 'logistics'];
|
||||
const matches: FieldPurpose[] = [];
|
||||
for (const purpose of purposes) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
userId,
|
||||
fieldPrivilegeKey(resource, purpose),
|
||||
action,
|
||||
);
|
||||
if (ok) {
|
||||
matches.push(purpose);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
cycleNumberForDate,
|
||||
fieldPrivilegeKey,
|
||||
isFieldPurpose,
|
||||
isWeekdayName,
|
||||
noCycleMessage,
|
||||
} from './field-purpose';
|
||||
|
||||
describe('field purpose helpers', () => {
|
||||
it('accepts sales and logistics only', () => {
|
||||
expect(isFieldPurpose('sales')).toBe(true);
|
||||
expect(isFieldPurpose('logistics')).toBe(true);
|
||||
expect(isFieldPurpose('delivery')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts weekday names', () => {
|
||||
expect(isWeekdayName('monday')).toBe(true);
|
||||
expect(isWeekdayName('Monday')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps resource and purpose to privilege keys', () => {
|
||||
expect(fieldPrivilegeKey('cycle', 'sales')).toBe('SALES.CYCLE');
|
||||
expect(fieldPrivilegeKey('plan', 'logistics')).toBe('LOGISTICS.PLAN');
|
||||
});
|
||||
|
||||
it('returns purpose-specific missing-cycle messages', () => {
|
||||
expect(noCycleMessage('sales')).toBe('User has no sales cycle');
|
||||
expect(noCycleMessage('logistics')).toBe('User has no logistics cycle');
|
||||
});
|
||||
|
||||
it('maps whole weeks to 1-based cycle numbers with wrap', () => {
|
||||
expect(cycleNumberForDate(0, 3)).toBe(1);
|
||||
expect(cycleNumberForDate(1, 3)).toBe(2);
|
||||
expect(cycleNumberForDate(2, 3)).toBe(3);
|
||||
expect(cycleNumberForDate(3, 3)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export const FIELD_PURPOSES = ['sales', 'logistics'] as const;
|
||||
|
||||
export type FieldPurpose = (typeof FIELD_PURPOSES)[number];
|
||||
|
||||
export function isFieldPurpose(raw: string): raw is FieldPurpose {
|
||||
return (FIELD_PURPOSES as readonly string[]).includes(raw);
|
||||
}
|
||||
|
||||
export const WEEKDAY_NAMES = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
] as const;
|
||||
|
||||
export type WeekdayName = (typeof WEEKDAY_NAMES)[number];
|
||||
|
||||
export function isWeekdayName(raw: string): raw is WeekdayName {
|
||||
return (WEEKDAY_NAMES as readonly string[]).includes(raw);
|
||||
}
|
||||
|
||||
export const SALES_CYCLE_PRIVILEGE_KEY = 'SALES.CYCLE';
|
||||
export const SALES_PLAN_PRIVILEGE_KEY = 'SALES.PLAN';
|
||||
export const LOGISTICS_CYCLE_PRIVILEGE_KEY = 'LOGISTICS.CYCLE';
|
||||
export const LOGISTICS_PLAN_PRIVILEGE_KEY = 'LOGISTICS.PLAN';
|
||||
export const SETTINGS_PRIVILEGE_KEY = 'CONFIGURATION.SETTING';
|
||||
|
||||
export type FieldResource = 'cycle' | 'plan';
|
||||
|
||||
export function fieldPrivilegeKey(
|
||||
resource: FieldResource,
|
||||
purpose: FieldPurpose,
|
||||
): string {
|
||||
if (resource === 'cycle') {
|
||||
return purpose === 'sales'
|
||||
? SALES_CYCLE_PRIVILEGE_KEY
|
||||
: LOGISTICS_CYCLE_PRIVILEGE_KEY;
|
||||
}
|
||||
return purpose === 'sales'
|
||||
? SALES_PLAN_PRIVILEGE_KEY
|
||||
: LOGISTICS_PLAN_PRIVILEGE_KEY;
|
||||
}
|
||||
|
||||
export function noCycleMessage(purpose: FieldPurpose): string {
|
||||
return purpose === 'sales'
|
||||
? 'User has no sales cycle'
|
||||
: 'User has no logistics cycle';
|
||||
}
|
||||
|
||||
export function cycleNumberForDate(
|
||||
wholeWeeksSinceEpoch: number,
|
||||
totalCycles: number,
|
||||
): number {
|
||||
return (wholeWeeksSinceEpoch % totalCycles) + 1;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RouteGeometryDto {
|
||||
@ApiProperty({ example: 'LineString' })
|
||||
type!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
},
|
||||
example: [
|
||||
[106.8456, -6.2088],
|
||||
[107.0, -6.3],
|
||||
],
|
||||
})
|
||||
coordinates!: ReadonlyArray<readonly number[]>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
buildRouteLineString,
|
||||
isUsableRouteGeometry,
|
||||
} from './route-line-string';
|
||||
|
||||
describe('buildRouteLineString', () => {
|
||||
it('preserves given order and skips unlocated points', () => {
|
||||
const geometry = buildRouteLineString([
|
||||
{ longitude: 106.8, latitude: -6.2 },
|
||||
{ longitude: null, latitude: -6.3 },
|
||||
{ longitude: 106.9, latitude: null },
|
||||
{ longitude: 107.0, latitude: -6.4 },
|
||||
{ longitude: 107.1, latitude: -6.5 },
|
||||
]);
|
||||
|
||||
expect(geometry).toEqual({
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[106.8, -6.2],
|
||||
[107.0, -6.4],
|
||||
[107.1, -6.5],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns two coordinates when only start and end locate', () => {
|
||||
const geometry = buildRouteLineString([
|
||||
{ longitude: 106.8, latitude: -6.2 },
|
||||
{ longitude: null, latitude: null },
|
||||
{ longitude: 107.0, latitude: -6.4 },
|
||||
]);
|
||||
|
||||
expect(geometry.coordinates).toEqual([
|
||||
[106.8, -6.2],
|
||||
[107.0, -6.4],
|
||||
]);
|
||||
expect(isUsableRouteGeometry(geometry)).toBe(true);
|
||||
});
|
||||
|
||||
it('is not usable with fewer than two located points', () => {
|
||||
const none = buildRouteLineString([{ longitude: null, latitude: null }]);
|
||||
const one = buildRouteLineString([{ longitude: 106.8, latitude: -6.2 }]);
|
||||
|
||||
expect(isUsableRouteGeometry(none)).toBe(false);
|
||||
expect(isUsableRouteGeometry(one)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
export type GeoPoint = {
|
||||
readonly longitude: number | null;
|
||||
readonly latitude: number | null;
|
||||
};
|
||||
|
||||
export type RouteGeometry = {
|
||||
readonly type: 'LineString';
|
||||
readonly coordinates: readonly (readonly [number, number])[];
|
||||
};
|
||||
|
||||
export function isLocatedPoint(
|
||||
point: GeoPoint,
|
||||
): point is { readonly longitude: number; readonly latitude: number } {
|
||||
return (
|
||||
typeof point.longitude === 'number' &&
|
||||
Number.isFinite(point.longitude) &&
|
||||
typeof point.latitude === 'number' &&
|
||||
Number.isFinite(point.latitude)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a LineString from points in the given order, skipping unlocated ones.
|
||||
* Coordinates are [longitude, latitude].
|
||||
*/
|
||||
export function buildRouteLineString(
|
||||
points: readonly GeoPoint[],
|
||||
): RouteGeometry {
|
||||
const coordinates: Array<readonly [number, number]> = [];
|
||||
for (const point of points) {
|
||||
if (!isLocatedPoint(point)) {
|
||||
continue;
|
||||
}
|
||||
coordinates.push([point.longitude, point.latitude]);
|
||||
}
|
||||
return {
|
||||
type: 'LineString',
|
||||
coordinates,
|
||||
};
|
||||
}
|
||||
|
||||
export function isUsableRouteGeometry(geometry: RouteGeometry): boolean {
|
||||
return geometry.coordinates.length >= 2;
|
||||
}
|
||||
Reference in New Issue
Block a user