Enhance pagination and ordering capabilities in API responses
- Updated pagination-response and read-write-controllers documentation to include `orderBy` and `orderType` parameters for sorting results. - Introduced new `order-clause` module to handle ordering logic, including validation for order types and columns. - Enhanced `PaginationQueryDto` to support ordering fields in API requests. - Updated various repository and service classes to implement ordering in database queries. - Added unit tests for new ordering functionality and ensured existing tests cover the updated behavior. - Refactored related DTOs to include user and code relations for better data representation in responses.
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
DefaultRelation,
|
||||
UserRelation,
|
||||
} from '../../../common/http/response';
|
||||
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';
|
||||
@@ -7,6 +11,7 @@ import type { WeekdaysInput } from '../shared/field-fields';
|
||||
export type CycleDestination = {
|
||||
readonly id: string;
|
||||
readonly customerId: string;
|
||||
readonly customer: DefaultRelation | null;
|
||||
readonly sortOrder: number;
|
||||
};
|
||||
|
||||
@@ -15,6 +20,8 @@ export type CycleWeekday = {
|
||||
readonly weekday: WeekdayName;
|
||||
readonly startBranchId: string;
|
||||
readonly endBranchId: string;
|
||||
readonly startBranch: DefaultRelation | null;
|
||||
readonly endBranch: DefaultRelation | null;
|
||||
readonly routeGeometry: RouteGeometry;
|
||||
readonly destinations: readonly CycleDestination[];
|
||||
};
|
||||
@@ -22,6 +29,7 @@ export type CycleWeekday = {
|
||||
export type Cycle = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly employee: DefaultRelation | null;
|
||||
readonly purpose: FieldPurpose;
|
||||
readonly cycleNumber: number;
|
||||
readonly weekdays: readonly CycleWeekday[];
|
||||
@@ -30,6 +38,8 @@ export type Cycle = {
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type CreateCycleInput = {
|
||||
@@ -55,6 +65,8 @@ export type ListCyclesFilters = {
|
||||
readonly cycleNumber?: number;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly purposes?: readonly string[];
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
sql,
|
||||
SQL,
|
||||
} from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import {
|
||||
catalogRelationFromMap,
|
||||
loadBranchRelationMap,
|
||||
loadCustomerRelationMap,
|
||||
loadEmployeeRelationMap,
|
||||
} from '../../../database/load-catalog-refs';
|
||||
import { attachAuditUsers } from '../../../database/load-user-refs';
|
||||
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';
|
||||
@@ -30,6 +38,16 @@ import {
|
||||
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
|
||||
import type { Cycle, ListCyclesFilters, PersistableWeekday } from './cycle';
|
||||
|
||||
const CYCLE_ORDER_COLUMNS = {
|
||||
id: cycles.id,
|
||||
employeeId: cycles.employeeId,
|
||||
purpose: cycles.purpose,
|
||||
cycleNumber: cycles.cycleNumber,
|
||||
status: cycles.status,
|
||||
createdAt: cycles.createdAt,
|
||||
updatedAt: cycles.updatedAt,
|
||||
};
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||
|
||||
@Injectable()
|
||||
@@ -48,11 +66,15 @@ export class CyclesRepository {
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(asc(cycles.cycleNumber))
|
||||
.orderBy(
|
||||
...toOrderClauses(CYCLE_ORDER_COLUMNS, filters, [
|
||||
{ column: 'cycleNumber', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
||||
data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -77,7 +99,7 @@ export class CyclesRepository {
|
||||
this.db,
|
||||
weekdays.map((weekday) => weekday.id),
|
||||
);
|
||||
return this.toDomain(row, weekdays, destinations);
|
||||
return this.hydrateOne(row, weekdays, destinations);
|
||||
}
|
||||
|
||||
async findLiveByKey(
|
||||
@@ -101,7 +123,7 @@ export class CyclesRepository {
|
||||
.where(and(...parts))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row, [], []) : null;
|
||||
return row ? this.hydrateOne(row, [], []) : null;
|
||||
}
|
||||
|
||||
async listActiveByEmployeePurpose(
|
||||
@@ -126,7 +148,7 @@ export class CyclesRepository {
|
||||
this.db,
|
||||
weekdays.map((weekday) => weekday.id),
|
||||
);
|
||||
result.push(this.toDomain(row, weekdays, destinations));
|
||||
result.push(await this.hydrateOne(row, weekdays, destinations));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -291,7 +313,7 @@ export class CyclesRepository {
|
||||
executor,
|
||||
weekdays.map((weekday) => weekday.id),
|
||||
);
|
||||
return this.toDomain(row, weekdays, destinations);
|
||||
return this.hydrateOne(row, weekdays, destinations);
|
||||
}
|
||||
|
||||
private async replaceWeekdays(
|
||||
@@ -406,11 +428,14 @@ export class CyclesRepository {
|
||||
weekday: weekday.weekday as WeekdayName,
|
||||
startBranchId: weekday.startBranchId,
|
||||
endBranchId: weekday.endBranchId,
|
||||
startBranch: null,
|
||||
endBranch: null,
|
||||
routeGeometry: weekday.routeGeometry,
|
||||
destinations: (destinationsByWeekday.get(weekday.id) ?? []).map(
|
||||
(destination) => ({
|
||||
id: destination.id,
|
||||
customerId: destination.customerId,
|
||||
customer: null,
|
||||
sortOrder: destination.sortOrder,
|
||||
}),
|
||||
),
|
||||
@@ -420,9 +445,59 @@ export class CyclesRepository {
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
employee: null,
|
||||
createdByUser: { id: row.createdBy, username: '' },
|
||||
updatedByUser: { id: row.updatedBy, username: '' },
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrate(items: Cycle[]): Promise<Cycle[]> {
|
||||
const withAudit = await attachAuditUsers(this.db, items);
|
||||
const branchIds = withAudit.flatMap((item) =>
|
||||
item.weekdays.flatMap((weekday) => [
|
||||
weekday.startBranchId,
|
||||
weekday.endBranchId,
|
||||
]),
|
||||
);
|
||||
const customerIds = withAudit.flatMap((item) =>
|
||||
item.weekdays.flatMap((weekday) =>
|
||||
weekday.destinations.map((destination) => destination.customerId),
|
||||
),
|
||||
);
|
||||
const [employees, branches, customers] = await Promise.all([
|
||||
loadEmployeeRelationMap(
|
||||
this.db,
|
||||
withAudit.map((item) => item.employeeId),
|
||||
),
|
||||
loadBranchRelationMap(this.db, branchIds),
|
||||
loadCustomerRelationMap(this.db, customerIds),
|
||||
]);
|
||||
return withAudit.map((item) => ({
|
||||
...item,
|
||||
employee: catalogRelationFromMap(employees, item.employeeId),
|
||||
weekdays: item.weekdays.map((weekday) => ({
|
||||
...weekday,
|
||||
startBranch: catalogRelationFromMap(branches, weekday.startBranchId),
|
||||
endBranch: catalogRelationFromMap(branches, weekday.endBranchId),
|
||||
destinations: weekday.destinations.map((destination) => ({
|
||||
...destination,
|
||||
customer: catalogRelationFromMap(customers, destination.customerId),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
private async hydrateOne(
|
||||
row: CycleRow,
|
||||
weekdays: CycleWeekdayRow[],
|
||||
destinations: CycleDestinationRow[],
|
||||
): Promise<Cycle> {
|
||||
const [item] = await this.hydrate([
|
||||
this.toDomain(row, weekdays, destinations),
|
||||
]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
|
||||
@@ -62,8 +62,17 @@ describe('CyclesService', () => {
|
||||
weekday: 'monday',
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
|
||||
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
|
||||
routeGeometry: geometry,
|
||||
destinations: [{ id: 'dest-1', customerId: 'cus-1', sortOrder: 0 }],
|
||||
destinations: [
|
||||
{
|
||||
id: 'dest-1',
|
||||
customerId: 'cus-1',
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
status: Status.create('draft'),
|
||||
@@ -71,6 +80,9 @@ describe('CyclesService', () => {
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
const located = { id: 'x', latitude: -6.2, longitude: 106.8 };
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
} 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 {
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
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';
|
||||
@@ -40,6 +45,8 @@ export type ListCyclesQuery = {
|
||||
readonly cycleNumber?: number;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
@@ -93,6 +100,8 @@ export class CyclesService {
|
||||
cycleNumber: query.cycleNumber,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
purposes: query.purpose ? undefined : purposes,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
@@ -237,26 +246,26 @@ export class CyclesService {
|
||||
toItem(cycle: Cycle) {
|
||||
return {
|
||||
id: cycle.id,
|
||||
employeeId: cycle.employeeId,
|
||||
employee: pickRelation(cycle.employee, DEFAULT_RELATION_FIELDS),
|
||||
purpose: cycle.purpose,
|
||||
cycleNumber: cycle.cycleNumber,
|
||||
weekdays: cycle.weekdays.map((weekday) => ({
|
||||
id: weekday.id,
|
||||
weekday: weekday.weekday,
|
||||
startBranchId: weekday.startBranchId,
|
||||
endBranchId: weekday.endBranchId,
|
||||
startBranch: pickRelation(weekday.startBranch, DEFAULT_RELATION_FIELDS),
|
||||
endBranch: pickRelation(weekday.endBranch, DEFAULT_RELATION_FIELDS),
|
||||
routeGeometry: weekday.routeGeometry,
|
||||
destinations: weekday.destinations.map((destination) => ({
|
||||
id: destination.id,
|
||||
customerId: destination.customerId,
|
||||
customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS),
|
||||
sortOrder: destination.sortOrder,
|
||||
})),
|
||||
})),
|
||||
status: cycle.status.value,
|
||||
createdAt: cycle.createdAt.value,
|
||||
updatedAt: cycle.updatedAt.value,
|
||||
createdBy: cycle.createdBy,
|
||||
updatedBy: cycle.updatedBy,
|
||||
createdBy: pickUserRelation(cycle.createdByUser),
|
||||
updatedBy: pickUserRelation(cycle.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
DefaultRelationDto,
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} 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';
|
||||
@@ -143,8 +147,8 @@ export class CycleDestinationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
customer!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
@@ -157,11 +161,11 @@ export class CycleWeekdayDto {
|
||||
@ApiProperty({ enum: WEEKDAY_NAMES })
|
||||
weekday!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
startBranchId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
startBranch!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
endBranchId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
endBranch!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty({ type: RouteGeometryDto })
|
||||
routeGeometry!: RouteGeometryDto;
|
||||
@@ -174,8 +178,8 @@ export class CycleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
employeeId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
employee!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty({ enum: FIELD_PURPOSES })
|
||||
purpose!: string;
|
||||
@@ -195,11 +199,11 @@ export class CycleDto {
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
|
||||
void ValidateNested;
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
IsUUID,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
CodeRelationDto,
|
||||
DefaultRelationDto,
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} 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';
|
||||
@@ -199,8 +204,8 @@ export class PlanDestinationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
customer!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
@@ -210,8 +215,8 @@ export class PlanDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
employeeId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
employee!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty({ enum: FIELD_PURPOSES })
|
||||
purpose!: string;
|
||||
@@ -219,11 +224,11 @@ export class PlanDto {
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
startBranchId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
startBranch!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
endBranchId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
endBranch!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty({ type: RouteGeometryDto })
|
||||
routeGeometry!: RouteGeometryDto;
|
||||
@@ -231,11 +236,11 @@ export class PlanDto {
|
||||
@ApiProperty({ type: [PlanDestinationDto] })
|
||||
destinations!: PlanDestinationDto[];
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
invoiceIds!: string[];
|
||||
@ApiProperty({ type: [CodeRelationDto] })
|
||||
invoices!: CodeRelationDto[];
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
packingSlipIds!: string[];
|
||||
@ApiProperty({ type: [CodeRelationDto] })
|
||||
packingSlips!: CodeRelationDto[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
status!: string;
|
||||
@@ -246,11 +251,11 @@ export class PlanDto {
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
|
||||
void Type;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
CodeRelation,
|
||||
DefaultRelation,
|
||||
UserRelation,
|
||||
} from '../../../common/http/response';
|
||||
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';
|
||||
@@ -6,25 +11,33 @@ import type { RouteGeometry } from '../shared/route-line-string';
|
||||
export type PlanDestination = {
|
||||
readonly id: string;
|
||||
readonly customerId: string;
|
||||
readonly customer: DefaultRelation | null;
|
||||
readonly sortOrder: number;
|
||||
};
|
||||
|
||||
export type Plan = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly employee: DefaultRelation | null;
|
||||
readonly purpose: FieldPurpose;
|
||||
readonly date: DateTime;
|
||||
readonly startBranchId: string;
|
||||
readonly endBranchId: string;
|
||||
readonly startBranch: DefaultRelation | null;
|
||||
readonly endBranch: DefaultRelation | null;
|
||||
readonly routeGeometry: RouteGeometry;
|
||||
readonly destinations: readonly PlanDestination[];
|
||||
readonly invoiceIds: readonly string[];
|
||||
readonly packingSlipIds: readonly string[];
|
||||
readonly invoices: readonly CodeRelation[];
|
||||
readonly packingSlips: readonly CodeRelation[];
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type PersistablePlanDestination = {
|
||||
@@ -37,6 +50,8 @@ export type ListPlansFilters = {
|
||||
readonly date?: number;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly purposes?: readonly string[];
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
|
||||
@@ -16,6 +16,16 @@ import {
|
||||
sql,
|
||||
SQL,
|
||||
} from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import {
|
||||
catalogRelationFromMap,
|
||||
loadBranchRelationMap,
|
||||
loadCustomerRelationMap,
|
||||
loadEmployeeRelationMap,
|
||||
loadPackingSlipRelationMap,
|
||||
loadSalesInvoiceRelationMap,
|
||||
} from '../../../database/load-catalog-refs';
|
||||
import { attachAuditUsers } from '../../../database/load-user-refs';
|
||||
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';
|
||||
@@ -33,6 +43,16 @@ import type { FieldPurpose } from '../shared/field-purpose';
|
||||
import type { RouteGeometry } from '../shared/route-line-string';
|
||||
import type { ListPlansFilters, Plan } from './plan';
|
||||
|
||||
const PLAN_ORDER_COLUMNS = {
|
||||
id: plans.id,
|
||||
employeeId: plans.employeeId,
|
||||
purpose: plans.purpose,
|
||||
date: plans.date,
|
||||
status: plans.status,
|
||||
createdAt: plans.createdAt,
|
||||
updatedAt: plans.updatedAt,
|
||||
};
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||
|
||||
@Injectable()
|
||||
@@ -51,11 +71,17 @@ export class PlansRepository {
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(asc(plans.date))
|
||||
.orderBy(
|
||||
...toOrderClauses(PLAN_ORDER_COLUMNS, filters, [
|
||||
{ column: 'date', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [], [])),
|
||||
data: await this.hydrate(
|
||||
rows.map((row) => this.toDomain(row, [], [], [])),
|
||||
),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -99,7 +125,7 @@ export class PlansRepository {
|
||||
.where(and(...parts))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row, [], [], []) : null;
|
||||
return row ? this.hydrateOne(row, [], [], []) : null;
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
@@ -327,7 +353,7 @@ export class PlansRepository {
|
||||
.select()
|
||||
.from(planPackingSlips)
|
||||
.where(eq(planPackingSlips.planId, row.id));
|
||||
return this.toDomain(row, destinations, invoices, packingSlips);
|
||||
return this.hydrateOne(row, destinations, invoices, packingSlips);
|
||||
}
|
||||
|
||||
private async replaceChildren(
|
||||
@@ -423,21 +449,86 @@ export class PlansRepository {
|
||||
startBranchId: row.startBranchId,
|
||||
endBranchId: row.endBranchId,
|
||||
routeGeometry: row.routeGeometry,
|
||||
startBranch: null,
|
||||
endBranch: null,
|
||||
employee: null,
|
||||
destinations: destinationRows.map((destination) => ({
|
||||
id: destination.id,
|
||||
customerId: destination.customerId,
|
||||
customer: null,
|
||||
sortOrder: destination.sortOrder,
|
||||
})),
|
||||
invoiceIds: invoiceRows.map((row) => row.invoiceId),
|
||||
packingSlipIds: packingSlipRows.map((row) => row.packingSlipId),
|
||||
invoiceIds: invoiceRows.map((invoice) => invoice.invoiceId),
|
||||
packingSlipIds: packingSlipRows.map((slip) => slip.packingSlipId),
|
||||
invoices: [],
|
||||
packingSlips: [],
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
createdByUser: { id: row.createdBy, username: '' },
|
||||
updatedByUser: { id: row.updatedBy, username: '' },
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrate(items: Plan[]): Promise<Plan[]> {
|
||||
const withAudit = await attachAuditUsers(this.db, items);
|
||||
const [employees, branches, customers, invoices, packing] =
|
||||
await Promise.all([
|
||||
loadEmployeeRelationMap(
|
||||
this.db,
|
||||
withAudit.map((item) => item.employeeId),
|
||||
),
|
||||
loadBranchRelationMap(
|
||||
this.db,
|
||||
withAudit.flatMap((item) => [item.startBranchId, item.endBranchId]),
|
||||
),
|
||||
loadCustomerRelationMap(
|
||||
this.db,
|
||||
withAudit.flatMap((item) =>
|
||||
item.destinations.map((destination) => destination.customerId),
|
||||
),
|
||||
),
|
||||
loadSalesInvoiceRelationMap(
|
||||
this.db,
|
||||
withAudit.flatMap((item) => item.invoiceIds),
|
||||
),
|
||||
loadPackingSlipRelationMap(
|
||||
this.db,
|
||||
withAudit.flatMap((item) => item.packingSlipIds),
|
||||
),
|
||||
]);
|
||||
return withAudit.map((item) => ({
|
||||
...item,
|
||||
employee: catalogRelationFromMap(employees, item.employeeId),
|
||||
startBranch: catalogRelationFromMap(branches, item.startBranchId),
|
||||
endBranch: catalogRelationFromMap(branches, item.endBranchId),
|
||||
destinations: item.destinations.map((destination) => ({
|
||||
...destination,
|
||||
customer: catalogRelationFromMap(customers, destination.customerId),
|
||||
})),
|
||||
invoices: item.invoiceIds
|
||||
.map((id) => invoices.get(id))
|
||||
.filter((value): value is NonNullable<typeof value> => Boolean(value)),
|
||||
packingSlips: item.packingSlipIds
|
||||
.map((id) => packing.get(id))
|
||||
.filter((value): value is NonNullable<typeof value> => Boolean(value)),
|
||||
}));
|
||||
}
|
||||
|
||||
private async hydrateOne(
|
||||
row: PlanRow,
|
||||
destinations: PlanDestinationRow[],
|
||||
invoices: PlanInvoiceRow[],
|
||||
packingSlips: PlanPackingSlipRow[],
|
||||
): Promise<Plan> {
|
||||
const [item] = await this.hydrate([
|
||||
this.toDomain(row, destinations, invoices, packingSlips),
|
||||
]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
|
||||
@@ -68,8 +68,17 @@ describe('PlansService', () => {
|
||||
weekday: 'monday',
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
|
||||
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
|
||||
routeGeometry: geometry,
|
||||
destinations: [{ id: 'cd-1', customerId: 'cus-1', sortOrder: 0 }],
|
||||
destinations: [
|
||||
{
|
||||
id: 'cd-1',
|
||||
customerId: 'cus-1',
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
status: Status.create('active'),
|
||||
@@ -77,6 +86,9 @@ describe('PlansService', () => {
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
const plan: Plan = {
|
||||
@@ -87,14 +99,28 @@ describe('PlansService', () => {
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
routeGeometry: geometry,
|
||||
destinations: [{ id: 'pd-1', customerId: 'cus-1', sortOrder: 0 }],
|
||||
destinations: [
|
||||
{
|
||||
id: 'pd-1',
|
||||
customerId: 'cus-1',
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
invoiceIds: [],
|
||||
packingSlipIds: [],
|
||||
invoices: [],
|
||||
packingSlips: [],
|
||||
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
|
||||
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
|
||||
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
status: Status.create('active'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -196,7 +222,12 @@ describe('PlansService', () => {
|
||||
...plan,
|
||||
destinations: [
|
||||
...plan.destinations,
|
||||
{ id: 'pd-2', customerId: 'cus-2', sortOrder: 1 },
|
||||
{
|
||||
id: 'pd-2',
|
||||
customerId: 'cus-2',
|
||||
customer: { id: 'cus-2', code: 'C2', name: 'Beta' },
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
await service.addDestination('pln-1', 'cus-2', undefined, user);
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
} 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 {
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
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';
|
||||
@@ -40,6 +45,8 @@ export type ListPlansQuery = {
|
||||
readonly date?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
@@ -96,6 +103,8 @@ export class PlansService {
|
||||
: undefined,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
purposes: query.purpose ? undefined : purposes,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
@@ -390,24 +399,24 @@ export class PlansService {
|
||||
toItem(plan: Plan) {
|
||||
return {
|
||||
id: plan.id,
|
||||
employeeId: plan.employeeId,
|
||||
employee: pickRelation(plan.employee, DEFAULT_RELATION_FIELDS),
|
||||
purpose: plan.purpose,
|
||||
date: plan.date.value,
|
||||
startBranchId: plan.startBranchId,
|
||||
endBranchId: plan.endBranchId,
|
||||
startBranch: pickRelation(plan.startBranch, DEFAULT_RELATION_FIELDS),
|
||||
endBranch: pickRelation(plan.endBranch, DEFAULT_RELATION_FIELDS),
|
||||
routeGeometry: plan.routeGeometry,
|
||||
destinations: plan.destinations.map((destination) => ({
|
||||
id: destination.id,
|
||||
customerId: destination.customerId,
|
||||
customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS),
|
||||
sortOrder: destination.sortOrder,
|
||||
})),
|
||||
invoiceIds: [...plan.invoiceIds],
|
||||
packingSlipIds: [...plan.packingSlipIds],
|
||||
invoices: [...plan.invoices],
|
||||
packingSlips: [...plan.packingSlips],
|
||||
status: plan.status.value,
|
||||
createdAt: plan.createdAt.value,
|
||||
updatedAt: plan.updatedAt.value,
|
||||
createdBy: plan.createdBy,
|
||||
updatedBy: plan.updatedBy,
|
||||
createdBy: pickUserRelation(plan.createdByUser),
|
||||
updatedBy: pickUserRelation(plan.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user