change price logic on the ,rule engine ui, auto generate the contrat

This commit is contained in:
marshal
2026-06-08 10:04:23 +03:00
parent 4993453993
commit 88df20be6b
76 changed files with 4907 additions and 225 deletions

View File

@@ -5,6 +5,8 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
import { ApprovalRulesService } from '../services/approval-rules.service';
@@ -35,6 +37,22 @@ export class ApprovalRulesController {
return this.service.findChain(flag === 'true');
}
@Post('reorder')
@RuleEngineManage('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder approval steps within a chain' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move an approval step up or down within its chain' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'Get an approval rule by ID' })

View File

@@ -5,6 +5,8 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoTypesService } from '../services/cargo-types.service';
@@ -32,6 +34,22 @@ export class CargoTypesController {
});
}
@Post('reorder')
@RuleEngineManage('cargo-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder cargo types by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('cargo-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a cargo type up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('cargo-types')
@ApiOperation({ summary: 'Get a cargo type by ID' })

View File

@@ -5,6 +5,8 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerTypesService } from '../services/container-types.service';
@@ -25,6 +27,22 @@ export class ContainerTypesController {
});
}
@Post('reorder')
@RuleEngineManage('container-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder container types by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('container-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a container type up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('container-types')
@ApiOperation({ summary: 'Get a container type by ID' })

View File

@@ -5,6 +5,8 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceTypesService } from '../services/service-types.service';
@@ -29,6 +31,22 @@ export class ServiceTypesController {
});
}
@Post('reorder')
@RuleEngineManage('service-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder service types by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('service-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a service type up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('service-types')
@ApiOperation({ summary: 'Get a service type by ID' })

View File

@@ -5,6 +5,8 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateYardDto } from '../dto/create-yard.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
import { YardsService } from '../services/yards.service';
@@ -26,6 +28,22 @@ export class YardsController {
});
}
@Post('reorder')
@RuleEngineManage('yards')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder yards by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('yards')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a yard up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('yards')
@ApiOperation({ summary: 'Get a yard by ID' })

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
@@ -8,10 +8,16 @@ export class CreateApprovalRuleDto {
@IsBoolean()
requiresDirectorApproval!: boolean;
@ApiProperty({ description: 'Step sequence number (1 = first, 2 = second)', minimum: 1 })
@ApiPropertyOptional({ description: 'Step sequence number (auto-assigned if omitted)', minimum: 1 })
@IsOptional()
@IsInt()
@Min(1)
stepOrder!: number;
stepOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this step ID within the same chain' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
@IsString()

View File

@@ -32,4 +32,9 @@ export class CreateCargoTypeDto {
@IsInt()
@Min(1)
displayOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this record ID' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
}

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
export class CreateContainerTypeDto {
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
@@ -40,4 +40,9 @@ export class CreateContainerTypeDto {
@IsInt()
@Min(1)
displayOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this record ID' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateServiceTypeDto {
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
@@ -48,4 +48,9 @@ export class CreateServiceTypeDto {
@IsInt()
@Min(1)
displayOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this record ID' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateYardDto {
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
@@ -22,4 +22,9 @@ export class CreateYardDto {
@IsInt()
@Min(1)
displayOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this record ID' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
export class MoveOrderDto {
@ApiProperty({ enum: ['up', 'down'] })
@IsIn(['up', 'down'])
direction!: 'up' | 'down';
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsUUID } from 'class-validator';
export class ReorderItemsDto {
@ApiProperty({ description: 'Ordered list of record IDs (new display/step order)', type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
ids!: string[];
@ApiPropertyOptional({
description: 'Approval-rules only: scope reorder to this chain',
})
@IsOptional()
@IsBoolean()
requiresDirectorApproval?: boolean;
}

View File

@@ -46,6 +46,7 @@ import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.re
import { YardsRepository } from './repositories/yards.repository';
import { ApprovalRulesService } from './services/approval-rules.service';
import { DisplayOrderService } from './services/display-order.service';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityRulesService } from './services/priority-rules.service';
@@ -126,6 +127,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ShippingLinesService,
RatesService,
ApprovalRulesService,
DisplayOrderService,
RuleEngineService,
],
exports: [

View File

@@ -315,15 +315,27 @@ export class RuleEngineService {
}
/**
* Snapshot all LIVE rates into booking_rate_snapshot for a booking.
* Snapshot only the rates used in a booking's final price.
*/
async snapshotLiveRates(bookingId: string): Promise<BookingRateSnapshot[]> {
const liveRates = await this.ratesRepo.findLiveRates();
async snapshotRates(
bookingId: string,
rates: Array<{
id: string;
rateType: string;
rateValue: number;
rateUnit: string;
currency: string;
}>,
): Promise<BookingRateSnapshot[]> {
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
const now = new Date();
const seen = new Set<string>();
const snapshots: BookingRateSnapshot[] = [];
for (const rate of liveRates) {
for (const rate of rates) {
if (seen.has(rate.id)) continue;
seen.add(rate.id);
const snapshot = snapshotRepo.create({
bookingId,
rateId: rate.id,

View File

@@ -1,17 +1,20 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
import { ApprovalRule } from '../entities/approval-rule.entity';
import {
APPROVAL_RULES_REPOSITORY,
IApprovalRulesRepository,
} from '../interfaces/approval-rules.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class ApprovalRulesService {
constructor(
@Inject(APPROVAL_RULES_REPOSITORY)
private readonly repository: IApprovalRulesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List approval rules. */
@@ -21,7 +24,7 @@ export class ApprovalRulesService {
pageSize?: number;
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.requiresDirectorApproval !== undefined) {
where.requiresDirectorApproval = filter.requiresDirectorApproval;
@@ -50,9 +53,20 @@ export class ApprovalRulesService {
/** Create an approval rule step. */
async create(dto: CreateApprovalRuleDto): Promise<ApprovalRule> {
if (dto.stepOrder !== undefined && dto.insertAfterId) {
throw new BadRequestException('Cannot set both stepOrder and insertAfterId');
}
const scopeWhere = { requiresDirectorApproval: dto.requiresDirectorApproval };
const stepOrder = await this.displayOrder.resolveCreateOrder(ApprovalRule, 'stepOrder', {
explicitOrder: dto.stepOrder,
insertAfterId: dto.insertAfterId,
scopeWhere,
});
return this.repository.create({
requiresDirectorApproval: dto.requiresDirectorApproval,
stepOrder: dto.stepOrder,
stepOrder,
requiredRole: dto.requiredRole,
actionLabel: dto.actionLabel,
blocksRole: dto.blocksRole,
@@ -72,4 +86,20 @@ export class ApprovalRulesService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
if (dto.requiresDirectorApproval === undefined) {
throw new BadRequestException('requiresDirectorApproval is required for approval rule reorder');
}
await this.displayOrder.reorderByIds(ApprovalRule, 'stepOrder', dto.ids, {
requiresDirectorApproval: dto.requiresDirectorApproval,
});
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
const rule = await this.findById(id);
await this.displayOrder.moveOne(ApprovalRule, 'stepOrder', id, direction, {
requiresDirectorApproval: rule.requiresDirectorApproval,
});
}
}

View File

@@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj
import { ILike } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity';
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../interfaces/cargo-types.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class CargoTypesService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly repository: ICargoTypesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List cargo types with pagination and optional filtering. */
@@ -28,7 +31,7 @@ export class CargoTypesService {
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
@@ -66,6 +69,12 @@ export class CargoTypesService {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
});
return this.repository.create({
code,
cargoTypeName: dto.cargoTypeName,
@@ -73,7 +82,7 @@ export class CargoTypesService {
showFreeTextBox: dto.showFreeTextBox ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
displayOrder,
});
}
@@ -95,4 +104,13 @@ export class CargoTypesService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
await this.displayOrder.reorderByIds(CargoType, 'displayOrder', dto.ids);
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
await this.findById(id);
await this.displayOrder.moveOne(CargoType, 'displayOrder', id, direction);
}
}

View File

@@ -1,18 +1,21 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerType } from '../entities/container-type.entity';
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from '../interfaces/container-types.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class ContainerTypesService {
constructor(
@Inject(CONTAINER_TYPES_REPOSITORY)
private readonly repository: IContainerTypesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List container types with pagination. */
@@ -22,7 +25,7 @@ export class ContainerTypesService {
pageSize?: number;
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
@@ -47,6 +50,12 @@ export class ContainerTypesService {
const code = generateCode(dto.label);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${code}"`);
const displayOrder = await this.displayOrder.resolveCreateOrder(ContainerType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
});
return this.repository.create({
code,
label: dto.label,
@@ -55,7 +64,7 @@ export class ContainerTypesService {
isReefer: dto.isReefer ?? false,
isOpenTop: dto.isOpenTop ?? false,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
displayOrder,
});
}
@@ -72,4 +81,13 @@ export class ContainerTypesService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
await this.displayOrder.reorderByIds(ContainerType, 'displayOrder', dto.ids);
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
await this.findById(id);
await this.displayOrder.moveOne(ContainerType, 'displayOrder', id, direction);
}
}

View File

@@ -0,0 +1,175 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityTarget, FindOptionsWhere, ObjectLiteral } from 'typeorm';
export type OrderField = 'displayOrder' | 'stepOrder';
@Injectable()
export class DisplayOrderService {
constructor(private readonly dataSource: DataSource) {}
async getMaxOrder<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
where?: FindOptionsWhere<T>,
): Promise<number> {
const repo = this.dataSource.getRepository(entity);
const qb = repo.createQueryBuilder('e').select(`MAX(e.${field})`, 'max');
if (where) {
Object.entries(where).forEach(([key, value]) => {
if (value !== undefined) {
qb.andWhere(`e.${key} = :${key}`, { [key]: value });
}
});
}
const row = await qb.getRawOne<{ max: string | null }>();
return row?.max ? Number(row.max) : 0;
}
async resolveCreateOrder<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
options: {
explicitOrder?: number;
insertAfterId?: string;
scopeWhere?: FindOptionsWhere<T>;
},
): Promise<number> {
const { explicitOrder, insertAfterId, scopeWhere } = options;
if (insertAfterId) {
if (explicitOrder !== undefined) {
throw new BadRequestException('Cannot set both explicit order and insertAfterId');
}
const repo = this.dataSource.getRepository(entity);
const after = await repo.findOne({
where: { id: insertAfterId, ...scopeWhere } as unknown as FindOptionsWhere<T>,
});
if (!after) {
throw new NotFoundException(`Record ${insertAfterId} not found in scope`);
}
const afterOrder = Number((after as Record<string, unknown>)[field]);
await this.shiftOrdersFrom(entity, field, afterOrder + 1, 1, scopeWhere);
return afterOrder + 1;
}
if (explicitOrder !== undefined) {
return explicitOrder;
}
const max = await this.getMaxOrder(entity, field, scopeWhere);
return max + 1;
}
async reorderByIds<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
ids: string[],
scopeWhere?: FindOptionsWhere<T>,
): Promise<void> {
const repo = this.dataSource.getRepository(entity);
const existing = await repo.find({
where: scopeWhere,
order: { [field]: 'ASC' } as never,
});
const scopedIds = new Set(existing.map((row) => String(row.id)));
if (ids.length !== scopedIds.size) {
throw new BadRequestException('Reorder list must include every item in scope exactly once');
}
for (const id of ids) {
if (!scopedIds.has(id)) {
throw new BadRequestException(`ID ${id} is not in the reorder scope`);
}
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
for (let i = 0; i < ids.length; i++) {
await queryRunner.manager.update(entity, ids[i], { [field]: -(i + 1) } as never);
}
for (let i = 0; i < ids.length; i++) {
await queryRunner.manager.update(entity, ids[i], { [field]: i + 1 } as never);
}
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async moveOne<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
id: string,
direction: 'up' | 'down',
scopeWhere?: FindOptionsWhere<T>,
): Promise<void> {
const repo = this.dataSource.getRepository(entity);
const items = await repo.find({
where: scopeWhere,
order: { [field]: 'ASC' } as never,
});
const index = items.findIndex((row) => String(row.id) === id);
if (index === -1) {
throw new NotFoundException(`Record ${id} not found in scope`);
}
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= items.length) {
throw new BadRequestException(`Cannot move ${direction}`);
}
const current = items[index] as Record<string, unknown>;
const neighbor = items[targetIndex] as Record<string, unknown>;
const currentOrder = Number(current[field]);
const neighborOrder = Number(neighbor[field]);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.update(entity, String(current.id), { [field]: -1 } as never);
await queryRunner.manager.update(entity, String(neighbor.id), { [field]: -2 } as never);
await queryRunner.manager.update(entity, String(current.id), { [field]: neighborOrder } as never);
await queryRunner.manager.update(entity, String(neighbor.id), { [field]: currentOrder } as never);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
private async shiftOrdersFrom<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
fromOrder: number,
delta: number,
scopeWhere?: FindOptionsWhere<T>,
): Promise<void> {
const repo = this.dataSource.getRepository(entity);
const orderColumn = repo.metadata.findColumnWithPropertyName(field)?.databaseName ?? field;
const qb = repo
.createQueryBuilder()
.update()
.set({ [field]: () => `"${orderColumn}" + ${delta}` } as never)
.where(`"${orderColumn}" >= :fromOrder`, { fromOrder });
if (scopeWhere) {
Object.entries(scopeWhere).forEach(([key, value]) => {
if (value !== undefined) {
const col = repo.metadata.findColumnWithPropertyName(key)?.databaseName ?? key;
qb.andWhere(`"${col}" = :scope_${key}`, { [`scope_${key}`]: value });
}
});
}
await qb.execute();
}
}

View File

@@ -30,7 +30,7 @@ export class RatesService {
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) } };
}
/** Return all currently LIVE rates. */

View File

@@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj
import { ILike } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceType } from '../entities/service-type.entity';
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from '../interfaces/service-types.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class ServiceTypesService {
constructor(
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly repository: IServiceTypesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List service types with pagination and optional filtering. */
@@ -27,7 +30,7 @@ export class ServiceTypesService {
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
@@ -59,6 +62,12 @@ export class ServiceTypesService {
const code = generateCode(dto.serviceName);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
});
return this.repository.create({
code,
serviceName: dto.serviceName,
@@ -69,7 +78,7 @@ export class ServiceTypesService {
includesCustoms: dto.includesCustoms ?? false,
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
displayOrder,
});
}
@@ -87,4 +96,13 @@ export class ServiceTypesService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
await this.displayOrder.reorderByIds(ServiceType, 'displayOrder', dto.ids);
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
await this.findById(id);
await this.displayOrder.moveOne(ServiceType, 'displayOrder', id, direction);
}
}

View File

@@ -1,15 +1,18 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateYardDto } from '../dto/create-yard.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
import { Yard } from '../entities/yard.entity';
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class YardsService {
constructor(
@Inject(YARDS_REPOSITORY)
private readonly repository: IYardsRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List yards with pagination. */
@@ -20,7 +23,7 @@ export class YardsService {
pageSize?: number;
}): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.country) where.country = filter.country;
@@ -46,12 +49,18 @@ export class YardsService {
const code = generateCode(dto.label);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
const displayOrder = await this.displayOrder.resolveCreateOrder(Yard, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
});
return this.repository.create({
code,
label: dto.label,
country: dto.country,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
displayOrder,
});
}
@@ -68,4 +77,13 @@ export class YardsService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
await this.displayOrder.reorderByIds(Yard, 'displayOrder', dto.ids);
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
await this.findById(id);
await this.displayOrder.moveOne(Yard, 'displayOrder', id, direction);
}
}