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:
shancheas
2026-08-27 13:09:41 +07:00
parent 790725e227
commit 4c45a4371e
85 changed files with 1824 additions and 365 deletions
+12
View File
@@ -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;
+81 -6
View File
@@ -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 };
+16 -7
View File
@@ -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),
};
}
+17 -13
View File
@@ -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;
+22 -17
View File
@@ -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;
+15
View File
@@ -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;
+97 -6
View File
@@ -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') {
+34 -3
View File
@@ -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);
+18 -9
View File
@@ -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),
};
}