mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
add permissions and fix issues
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ docker-compose.override.yml
|
||||
# cypress e2e artifacts
|
||||
e2e/**/cypress/videos/
|
||||
e2e/**/cypress/screenshots/
|
||||
e2e/**/cypress/reports/
|
||||
e2e/**/cypress/downloads/
|
||||
|
||||
# e2e launcher state (ports of the running stack)
|
||||
|
||||
@@ -26,8 +26,23 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
export const TrainSchedulingView = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||
|
||||
export const TrainSchedulingManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
||||
// Granular train-scheduling actions replace the retired coarse manage:
|
||||
// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…),
|
||||
// cancel a schedule, reschedule (+ maintenance), and manage global rules.
|
||||
export const TrainSchedulingCreate = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.create);
|
||||
|
||||
export const TrainSchedulingUpdate = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.update);
|
||||
|
||||
export const TrainSchedulingCancel = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
|
||||
|
||||
export const TrainSchedulingReschedule = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.reschedule);
|
||||
|
||||
export const TrainSchedulingRulesManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
|
||||
|
||||
/**
|
||||
* Fleet guards take an optional granular per-resource key (locomotives:create,
|
||||
|
||||
@@ -13,9 +13,22 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
|
||||
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
|
||||
// endpoint carries the specific action it performs — create on POST-new,
|
||||
// update on PATCH / reorder / move-order, delete on DELETE.
|
||||
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -526,6 +526,8 @@ export class ContractsController {
|
||||
contractId: view.bookingId,
|
||||
reference: view.reference,
|
||||
status: view.status,
|
||||
// Drives the per-freight-type sign permission on the client.
|
||||
freightType: contract.freightType,
|
||||
templateKey: view.templateKey,
|
||||
title: view.template.title,
|
||||
html,
|
||||
@@ -577,19 +579,21 @@ export class ContractsController {
|
||||
@Post(':id/contract/sign')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
signContract(
|
||||
async signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Each staff signing role maps to the permission that step already requires;
|
||||
// customers sign their own contract with no permission key.
|
||||
if (dto.role !== 'CUSTOMER') {
|
||||
// Each staff signing role maps to the permission that step already
|
||||
// requires; the STAFF counter-signature is split per freight type, so a
|
||||
// bulk signer cannot counter-sign a container contract (and vice versa).
|
||||
const contract = await this.contractsService.findById(id);
|
||||
const signRolePermission: Record<string, string> = {
|
||||
STAFF: FREIGHT_PERMS.contracts.signStaff,
|
||||
STAFF: forFreightType(FREIGHT_PERMS.contracts.signStaff, contract.freightType),
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
if (dto.role !== 'CUSTOMER') {
|
||||
assertFreightPermission(user, signRolePermission[dto.role]);
|
||||
}
|
||||
return this.transitionService.sign(id, dto, {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -41,7 +41,7 @@ export class ApprovalRulesController {
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('approval-rules')
|
||||
@RuleEngineUpdate('approval-rules')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Bulk reorder approval steps within a chain' })
|
||||
reorder(@Body() dto: ReorderItemsDto) {
|
||||
@@ -49,7 +49,7 @@ export class ApprovalRulesController {
|
||||
}
|
||||
|
||||
@Post(':id/move-order')
|
||||
@RuleEngineManage('approval-rules')
|
||||
@RuleEngineUpdate('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) {
|
||||
@@ -64,21 +64,21 @@ export class ApprovalRulesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('approval-rules')
|
||||
@RuleEngineCreate('approval-rules')
|
||||
@ApiOperation({ summary: 'Create an approval rule step' })
|
||||
create(@Body() dto: CreateApprovalRuleDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('approval-rules')
|
||||
@RuleEngineUpdate('approval-rules')
|
||||
@ApiOperation({ summary: 'Update an approval rule' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('approval-rules')
|
||||
@RuleEngineDelete('approval-rules')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete an approval rule' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -26,7 +26,7 @@ export class CargoTypesController {
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('cargo-types')
|
||||
@RuleEngineUpdate('cargo-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Bulk reorder cargo types by ID list' })
|
||||
reorder(@Body() dto: ReorderItemsDto) {
|
||||
@@ -34,7 +34,7 @@ export class CargoTypesController {
|
||||
}
|
||||
|
||||
@Post(':id/move-order')
|
||||
@RuleEngineManage('cargo-types')
|
||||
@RuleEngineUpdate('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) {
|
||||
@@ -49,21 +49,21 @@ export class CargoTypesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('cargo-types')
|
||||
@RuleEngineCreate('cargo-types')
|
||||
@ApiOperation({ summary: 'Create a cargo type' })
|
||||
create(@Body() dto: CreateCargoTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('cargo-types')
|
||||
@RuleEngineUpdate('cargo-types')
|
||||
@ApiOperation({ summary: 'Update a cargo type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('cargo-types')
|
||||
@RuleEngineDelete('cargo-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a cargo type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -26,7 +26,7 @@ export class ContainerTypesController {
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('container-types')
|
||||
@RuleEngineUpdate('container-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Bulk reorder container types by ID list' })
|
||||
reorder(@Body() dto: ReorderItemsDto) {
|
||||
@@ -34,7 +34,7 @@ export class ContainerTypesController {
|
||||
}
|
||||
|
||||
@Post(':id/move-order')
|
||||
@RuleEngineManage('container-types')
|
||||
@RuleEngineUpdate('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) {
|
||||
@@ -49,21 +49,21 @@ export class ContainerTypesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('container-types')
|
||||
@RuleEngineCreate('container-types')
|
||||
@ApiOperation({ summary: 'Create a container type' })
|
||||
create(@Body() dto: CreateContainerTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('container-types')
|
||||
@RuleEngineUpdate('container-types')
|
||||
@ApiOperation({ summary: 'Update a container type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('container-types')
|
||||
@RuleEngineDelete('container-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a container type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -49,14 +49,14 @@ export class PriorityConfigsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('priority-configs')
|
||||
@RuleEngineCreate('priority-configs')
|
||||
@ApiOperation({ summary: 'Create a priority config' })
|
||||
create(@Body() dto: CreatePriorityConfigDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@RuleEngineUpdate('priority-configs')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Bulk reorder priority configs by ID list' })
|
||||
reorder(@Body() dto: ReorderItemsDto) {
|
||||
@@ -64,7 +64,7 @@ export class PriorityConfigsController {
|
||||
}
|
||||
|
||||
@Post(':id/move-order')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@RuleEngineUpdate('priority-configs')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Move a priority config up or down in display order' })
|
||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||
@@ -72,14 +72,14 @@ export class PriorityConfigsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@RuleEngineUpdate('priority-configs')
|
||||
@ApiOperation({ summary: 'Update a priority config' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityConfigDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@RuleEngineDelete('priority-configs')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a priority config' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||
import {
|
||||
DecidePriorityRuleChangeDto,
|
||||
@@ -32,7 +32,7 @@ export class PriorityRuleChangeRequestsController {
|
||||
constructor(private readonly service: PriorityRuleChangeRequestsService) {}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('priority-configs')
|
||||
@RuleEngineCreate('priority-configs')
|
||||
@ApiOperation({ summary: 'Submit a priority-rule change for approval' })
|
||||
submit(
|
||||
@Body() dto: SubmitPriorityRuleChangeDto,
|
||||
@@ -50,7 +50,7 @@ export class PriorityRuleChangeRequestsController {
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@RuleEngineUpdate('priority-configs')
|
||||
@ApiOperation({ summary: 'Approve and apply a pending change' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -63,7 +63,7 @@ export class PriorityRuleChangeRequestsController {
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@RuleEngineUpdate('priority-configs')
|
||||
@ApiOperation({ summary: 'Reject a pending change' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||
import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineApprove, RuleEngineCreate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto';
|
||||
import { RateChangeStatus } from '../entities/rate-change-request.entity';
|
||||
import { RateChangeRequestsService } from '../services/rate-change-requests.service';
|
||||
@@ -21,7 +21,7 @@ export class RateChangeRequestsController {
|
||||
constructor(private readonly service: RateChangeRequestsService) {}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('rates')
|
||||
@RuleEngineCreate('rates')
|
||||
@ApiOperation({ summary: 'Propose a change to a LIVE rate' })
|
||||
submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.submit(dto, user?.id);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -44,7 +44,7 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('rates')
|
||||
@RuleEngineCreate('rates')
|
||||
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
||||
create(
|
||||
@Body() dto: CreateRateDto,
|
||||
@@ -54,21 +54,21 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('rates')
|
||||
@RuleEngineUpdate('rates')
|
||||
@ApiOperation({ summary: 'Update a DRAFT rate' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@RuleEngineManage('rates')
|
||||
@RuleEngineUpdate('rates')
|
||||
@ApiOperation({ summary: 'Submit rate for CEO approval' })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.submitForApproval(id);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@RuleEngineManage('rates')
|
||||
@RuleEngineUpdate('rates')
|
||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -80,7 +80,7 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('rates')
|
||||
@RuleEngineDelete('rates')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a rate' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||
@@ -26,7 +26,7 @@ export class ServiceTypesController {
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('service-types')
|
||||
@RuleEngineUpdate('service-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Bulk reorder service types by ID list' })
|
||||
reorder(@Body() dto: ReorderItemsDto) {
|
||||
@@ -34,7 +34,7 @@ export class ServiceTypesController {
|
||||
}
|
||||
|
||||
@Post(':id/move-order')
|
||||
@RuleEngineManage('service-types')
|
||||
@RuleEngineUpdate('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) {
|
||||
@@ -49,21 +49,21 @@ export class ServiceTypesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('service-types')
|
||||
@RuleEngineCreate('service-types')
|
||||
@ApiOperation({ summary: 'Create a service type' })
|
||||
create(@Body() dto: CreateServiceTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('service-types')
|
||||
@RuleEngineUpdate('service-types')
|
||||
@ApiOperation({ summary: 'Update a service type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('service-types')
|
||||
@RuleEngineDelete('service-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a service type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||
@@ -31,21 +31,21 @@ export class ShippingLinesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('shipping-lines')
|
||||
@RuleEngineCreate('shipping-lines')
|
||||
@ApiOperation({ summary: 'Create a shipping line' })
|
||||
create(@Body() dto: CreateShippingLineDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('shipping-lines')
|
||||
@RuleEngineUpdate('shipping-lines')
|
||||
@ApiOperation({ summary: 'Update a shipping line' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('shipping-lines')
|
||||
@RuleEngineDelete('shipping-lines')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a shipping line' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
||||
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -30,21 +30,21 @@ export class WeightLimitRulesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('weight-limit-rules')
|
||||
@RuleEngineCreate('weight-limit-rules')
|
||||
@ApiOperation({ summary: 'Create a weight limit rule' })
|
||||
create(@Body() dto: CreateWeightLimitRuleDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('weight-limit-rules')
|
||||
@RuleEngineUpdate('weight-limit-rules')
|
||||
@ApiOperation({ summary: 'Update a weight limit rule' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('weight-limit-rules')
|
||||
@RuleEngineDelete('weight-limit-rules')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
|
||||
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -40,21 +40,21 @@ export class YardDistancesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('yard-distances')
|
||||
@RuleEngineCreate('yard-distances')
|
||||
@ApiOperation({ summary: 'Create a yard distance' })
|
||||
create(@Body() dto: CreateYardDistanceDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('yard-distances')
|
||||
@RuleEngineUpdate('yard-distances')
|
||||
@ApiOperation({ summary: 'Update a yard distance' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('yard-distances')
|
||||
@RuleEngineDelete('yard-distances')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a yard distance' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||
@@ -28,7 +28,7 @@ export class YardsController {
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('yards')
|
||||
@RuleEngineUpdate('yards')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Bulk reorder yards by ID list' })
|
||||
reorder(@Body() dto: ReorderItemsDto) {
|
||||
@@ -36,7 +36,7 @@ export class YardsController {
|
||||
}
|
||||
|
||||
@Post(':id/move-order')
|
||||
@RuleEngineManage('yards')
|
||||
@RuleEngineUpdate('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) {
|
||||
@@ -51,21 +51,21 @@ export class YardsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('yards')
|
||||
@RuleEngineCreate('yards')
|
||||
@ApiOperation({ summary: 'Create a yard' })
|
||||
create(@Body() dto: CreateYardDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('yards')
|
||||
@RuleEngineUpdate('yards')
|
||||
@ApiOperation({ summary: 'Update a yard' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('yards')
|
||||
@RuleEngineDelete('yards')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a yard' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
|
||||
import { TrainSchedulingManage } from '../../common/booking-guards';
|
||||
import { TrainSchedulingReschedule } from '../../common/booking-guards';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
@@ -18,7 +18,7 @@ export class SchedulingRescheduleController {
|
||||
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
|
||||
|
||||
@Post('preview')
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingReschedule()
|
||||
@ApiOperation({ summary: 'Preview reschedule / government preempt plan' })
|
||||
preview(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -28,7 +28,7 @@ export class SchedulingRescheduleController {
|
||||
}
|
||||
|
||||
@Post('execute')
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingReschedule()
|
||||
@ApiOperation({ summary: 'Execute a confirmed reschedule plan' })
|
||||
execute(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -50,7 +50,7 @@ export class SchedulingMaintenanceController {
|
||||
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
|
||||
|
||||
@Post('maintenance')
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingReschedule()
|
||||
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
|
||||
maintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
|
||||
import {
|
||||
TrainSchedulingManage,
|
||||
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
TrainSchedulingCancel,
|
||||
TrainSchedulingCreate,
|
||||
TrainSchedulingReschedule,
|
||||
TrainSchedulingRulesManage,
|
||||
TrainSchedulingUpdate,
|
||||
TrainSchedulingView,
|
||||
} from "../../common/booking-guards";
|
||||
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
||||
@@ -114,7 +109,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Patch("global-rules")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingRulesManage()
|
||||
@ApiOperation({ summary: "Update global train scheduling rules (singleton)" })
|
||||
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||||
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
|
||||
@@ -181,7 +176,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/adjust-consist")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
|
||||
@@ -283,21 +278,21 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("container/schedules")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingCreate()
|
||||
@ApiOperation({ summary: "Create a container train schedule" })
|
||||
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||
}
|
||||
|
||||
@Post("bulk/schedules")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingCreate()
|
||||
@ApiOperation({ summary: "Create a bulk train schedule" })
|
||||
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/assign-bookings")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary: "Assign bookings to a train schedule (mixed-capable)",
|
||||
})
|
||||
@@ -309,7 +304,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("container/schedules/:id/assign-bookings")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Assign container bookings to a train schedule" })
|
||||
assignContainerBookings(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -323,7 +318,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("bulk/schedules/:id/assign-bookings")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Assign bulk bookings to a train schedule" })
|
||||
assignBulkBookings(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -337,7 +332,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Delete("schedules/:id/bookings/:bookingId")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Unassign a booking from a train schedule" })
|
||||
unassignBooking(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -352,7 +347,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Delete("schedules/:id/wagons/:trainSetWagonId")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Remove an empty wagon slot from a train" })
|
||||
removeWagonSlot(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -365,7 +360,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/container-items/:itemId")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Update a container number on a wagon slot" })
|
||||
updateContainerItem(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -376,7 +371,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/wagons/:wagonId/move-load")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)",
|
||||
@@ -397,7 +392,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/assign-unassigned-booking")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Assign one linked unallocated booking to wagons (preserves existing assignments)",
|
||||
@@ -429,7 +424,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/import-loading-status")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)",
|
||||
@@ -442,7 +437,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/loading-status")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)",
|
||||
@@ -455,21 +450,21 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/pin-wagons")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
|
||||
pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
|
||||
return this.trainSchedulingService.pinWagons(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/finalize")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Finalize a draft train schedule" })
|
||||
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.finalizeSchedule(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/dispatch")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Dispatch a scheduled train" })
|
||||
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
@@ -496,7 +491,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/accept")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
|
||||
@@ -519,7 +514,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/load")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
|
||||
@@ -532,7 +527,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/unload")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
|
||||
@@ -545,7 +540,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/load")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
|
||||
})
|
||||
@@ -557,7 +552,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/unload")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
|
||||
@@ -577,7 +572,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/documents")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Upload/check an import Djibouti-side document" })
|
||||
uploadImportDjiboutiDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -587,7 +582,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/gatepass-granted")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" })
|
||||
grantImportDjiboutiGatepass(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -597,7 +592,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/ready-for-loading")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Mark import train ready for loading at Djibouti" })
|
||||
markImportReadyForLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -607,7 +602,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/loaded-on-train")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" })
|
||||
confirmImportLoadedOnTrain(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -617,7 +612,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/confirm-loading")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)",
|
||||
@@ -630,7 +625,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/depart")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Depart loaded import train from Djibouti" })
|
||||
departImportFromDjibouti(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -640,7 +635,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/load-list")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Generate import load list / marshalling document summary" })
|
||||
generateImportLoadList(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -680,7 +675,7 @@ export class TrainSchedulingController {
|
||||
// ---- batch / booking-window staff actions ----
|
||||
|
||||
@Post("schedules/:id/run-batch")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Manually run the batch fill for a schedule" })
|
||||
async runBatch(@Param("id", ParseUUIDPipe) id: string) {
|
||||
await this.bookingBatchService.fillSchedule(id);
|
||||
@@ -688,7 +683,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/run-allocation")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary: "Run wagon-level allocation for all eligible linked bookings",
|
||||
})
|
||||
@@ -697,7 +692,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/booking-window")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({ summary: "Open or close a schedule booking window" })
|
||||
async setBookingWindow(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -711,7 +706,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/window-rule")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens",
|
||||
@@ -725,7 +720,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/schedule-date")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window",
|
||||
@@ -739,7 +734,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/maintenance")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingReschedule()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
|
||||
@@ -753,7 +748,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/doc-review-complete")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)",
|
||||
@@ -764,7 +759,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("bookings/:bookingId/mark-paid")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary: "Staff: mark a reserved booking paid and allocate it now",
|
||||
})
|
||||
@@ -774,7 +769,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("bookings/:bookingId/expire")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary: "Staff: expire a reservation and free its capacity",
|
||||
})
|
||||
@@ -784,7 +779,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("bookings/:bookingId/move-schedule")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary: "Re-point a booking to another OPEN same-route schedule",
|
||||
})
|
||||
@@ -806,7 +801,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/checkpoints")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary: "Log the train passing a station (final station triggers arrival)",
|
||||
})
|
||||
@@ -818,7 +813,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/arrive")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Mark a dispatched train arrived (move assets to destination yard, free assets)",
|
||||
@@ -856,14 +851,14 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("container/schedules/:id/cancel")
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingCancel()
|
||||
@ApiOperation({ summary: "Cancel container train schedule" })
|
||||
cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||
}
|
||||
|
||||
@Post('bulk/schedules/:id/cancel')
|
||||
@TrainSchedulingManage()
|
||||
@TrainSchedulingCancel()
|
||||
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
||||
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||
|
||||
@@ -2303,14 +2303,19 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (direction !== 'EXPORT') return;
|
||||
|
||||
// Only bookings boarding at the schedule's ORIGIN station gate dispatch —
|
||||
// a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the
|
||||
// train reaches its yard, so its warehouse state says nothing at departure.
|
||||
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
|
||||
`WITH ${SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
|
||||
FROM sched_bookings sb
|
||||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = $1
|
||||
AND b.origin_yard_id = ts.origin_station_id
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
|
||||
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../common/rule-engine-guards';
|
||||
|
||||
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
||||
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||
@@ -51,21 +51,21 @@ export class WagonTypesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('wagon-types')
|
||||
@RuleEngineCreate('wagon-types')
|
||||
@ApiOperation({ summary: 'Create a wagon type' })
|
||||
create(@Body() dto: CreateWagonTypeDto) {
|
||||
return this.wagonTypesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('wagon-types')
|
||||
@RuleEngineUpdate('wagon-types')
|
||||
@ApiOperation({ summary: 'Update a wagon type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
|
||||
return this.wagonTypesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('wagon-types')
|
||||
@RuleEngineDelete('wagon-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a wagon type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -57,7 +57,6 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
|
||||
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
|
||||
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
||||
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
||||
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
||||
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
|
||||
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
||||
@@ -83,7 +82,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
|
||||
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
|
||||
perm('a3000001-0001-4000-8000-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'),
|
||||
perm('a3000001-0001-4000-8000-000000000009', 'edr_freight_app:contracts:sign_staff', 'Staff contract signature'),
|
||||
// Staff counter-signature is split per freight type too — fresh ids for the
|
||||
// same reason as the intake keys above.
|
||||
perm('a3000001-0001-4000-8000-000000000017', 'edr_freight_app:contracts:sign_staff:bulk', 'Staff contract signature: bulk'),
|
||||
perm('a3000001-0001-4000-8000-000000000018', 'edr_freight_app:contracts:sign_staff:container', 'Staff contract signature: container'),
|
||||
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
|
||||
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
||||
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
||||
@@ -93,24 +95,42 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
|
||||
];
|
||||
|
||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
||||
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
||||
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
|
||||
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
|
||||
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
|
||||
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
|
||||
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
|
||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
||||
'yard-distances': { view: 'b2000001-0001-4000-8000-000000000018', manage: 'b2000001-0001-4000-8000-000000000019' },
|
||||
// Existing per-slug view ids are kept as-is: position-type grants reference
|
||||
// them by id, so re-minting would orphan those rows.
|
||||
const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
|
||||
'cargo-types': 'b2000001-0001-4000-8000-000000000001',
|
||||
'container-types': 'b2000001-0001-4000-8000-000000000003',
|
||||
'wagon-types': 'b2000001-0001-4000-8000-000000000015',
|
||||
'service-types': 'b2000001-0001-4000-8000-000000000005',
|
||||
yards: 'b2000001-0001-4000-8000-000000000007',
|
||||
'shipping-lines': 'b2000001-0001-4000-8000-000000000009',
|
||||
'weight-limit-rules': 'b2000001-0001-4000-8000-00000000000b',
|
||||
'priority-configs': 'b2000001-0001-4000-8000-00000000000f',
|
||||
rates: 'b2000001-0001-4000-8000-000000000011',
|
||||
'approval-rules': 'b2000001-0001-4000-8000-000000000013',
|
||||
'yard-distances': 'b2000001-0001-4000-8000-000000000018',
|
||||
};
|
||||
|
||||
// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block
|
||||
// (b2000002-…) so a stale `:manage` grant can never silently confer a CRUD
|
||||
// action — the migration re-grants create/update/delete explicitly.
|
||||
const RULE_ENGINE_CRUD_ACTIONS = ['create', 'update', 'delete'] as const;
|
||||
type RuleEngineCrudAction = (typeof RULE_ENGINE_CRUD_ACTIONS)[number];
|
||||
const ruleEngineCrudId = (
|
||||
slug: RuleEngineResourceSlug,
|
||||
action: RuleEngineCrudAction,
|
||||
): string => {
|
||||
const n =
|
||||
RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
|
||||
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
|
||||
1; // 1..33
|
||||
return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slugs whose changes go through a separate approver. `manage` lets a staff
|
||||
* member propose a change; only `approve` lets someone put it into effect.
|
||||
* Only listed slugs get the permission — the rest are manage-only.
|
||||
* Slugs whose changes go through a separate approver. CRUD lets a staff member
|
||||
* propose a change; only `approve` lets someone put it into effect. Only listed
|
||||
* slugs get the permission.
|
||||
*/
|
||||
const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial<Record<RuleEngineResourceSlug, string>> = {
|
||||
rates: 'b2000001-0001-4000-8000-000000000017',
|
||||
@@ -121,11 +141,12 @@ export type RuleEngineApprovableSlug = 'rates';
|
||||
export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap(
|
||||
(slug) => {
|
||||
const resource = slugToResourceKey(slug);
|
||||
const ids = RULE_ENGINE_PERMISSION_IDS[slug];
|
||||
const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug];
|
||||
return [
|
||||
perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
||||
perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`),
|
||||
perm(RULE_ENGINE_VIEW_IDS[slug], `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
||||
perm(ruleEngineCrudId(slug, 'create'), `edr_freight_app:rule_engine:${resource}:create`, `Create ${slug}`),
|
||||
perm(ruleEngineCrudId(slug, 'update'), `edr_freight_app:rule_engine:${resource}:update`, `Update ${slug}`),
|
||||
perm(ruleEngineCrudId(slug, 'delete'), `edr_freight_app:rule_engine:${resource}:delete`, `Delete ${slug}`),
|
||||
...(approveId
|
||||
? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)]
|
||||
: []),
|
||||
@@ -395,7 +416,10 @@ export const FREIGHT_PERMS = {
|
||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
||||
generateContract: 'edr_freight_app:contracts:generate_contract',
|
||||
signStaff: 'edr_freight_app:contracts:sign_staff',
|
||||
signStaff: {
|
||||
bulk: 'edr_freight_app:contracts:sign_staff:bulk',
|
||||
container: 'edr_freight_app:contracts:sign_staff:container',
|
||||
},
|
||||
clearanceReview: 'edr_freight_app:contracts:clearance_review',
|
||||
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
||||
createBooking: 'edr_freight_app:contracts:create_booking',
|
||||
@@ -406,7 +430,6 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
trainScheduling: {
|
||||
view: 'edr_freight_app:train_scheduling:view',
|
||||
manage: 'edr_freight_app:train_scheduling:manage',
|
||||
create: 'edr_freight_app:train_scheduling:create',
|
||||
update: 'edr_freight_app:train_scheduling:update',
|
||||
cancel: 'edr_freight_app:train_scheduling:cancel',
|
||||
@@ -421,8 +444,12 @@ export const FREIGHT_PERMS = {
|
||||
ruleEngine: {
|
||||
view: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
||||
manage: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
||||
create: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:create`,
|
||||
update: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:update`,
|
||||
delete: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`,
|
||||
approve: (slug: RuleEngineApprovableSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`,
|
||||
},
|
||||
@@ -751,7 +778,11 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.trainScheduling.manage,
|
||||
FREIGHT_PERMS.trainScheduling.create,
|
||||
FREIGHT_PERMS.trainScheduling.update,
|
||||
FREIGHT_PERMS.trainScheduling.cancel,
|
||||
FREIGHT_PERMS.trainScheduling.reschedule,
|
||||
FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
FREIGHT_PERMS.fleet.manage,
|
||||
...FLEET_GRANULAR_KEYS,
|
||||
@@ -836,7 +867,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.generateContract,
|
||||
FREIGHT_PERMS.contracts.signStaff,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
||||
],
|
||||
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Flag,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
return message || (error as Error)?.message || fallback;
|
||||
};
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const DIRECTION_COLORS: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "teal",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||
|
||||
function DirectionChip({ direction }: { direction: string }) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||
{DIRECTION_LABELS[direction] ?? direction}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({
|
||||
icon,
|
||||
title,
|
||||
count,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={8} align="center">
|
||||
<ThemeIcon size={26} radius="md" variant="light" color="edr-green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||
{count}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Yard-work modal for the track page's "Log pass" step.
|
||||
*
|
||||
* A train runs A→B→C→D and bookings board/alight at any stop, so logging the
|
||||
* pass at a yard is the moment its yard work happens: bookings destined here
|
||||
* flip to ARRIVED (import/export) or COMPLETED (intercity) automatically the
|
||||
* instant the pass is logged, and bookings boarding here become loadable —
|
||||
* the server only accepts a load while the train's latest checkpoint is this
|
||||
* yard. The modal therefore drives the sequence: log the pass first, then
|
||||
* load anything that boards here (including cargo the operator forgot — it
|
||||
* stays loadable until the next pass is logged).
|
||||
*/
|
||||
export function LogPassYardWorkModal({
|
||||
opened,
|
||||
onClose,
|
||||
scheduleId,
|
||||
station,
|
||||
isFinal,
|
||||
alreadyLogged,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
scheduleId: string;
|
||||
station: TrackStation | null;
|
||||
isFinal: boolean;
|
||||
/** True when opened for the current station (pass already logged). */
|
||||
alreadyLogged: boolean;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [justLogged, setJustLogged] = useState(false);
|
||||
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
|
||||
const logged = alreadyLogged || justLogged;
|
||||
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId },
|
||||
enabled: opened && Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
|
||||
|
||||
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
||||
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
|
||||
const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
|
||||
const pendingBoarders = boarders.filter((r) => !r.loadedAt);
|
||||
|
||||
const doLogPass = () => {
|
||||
if (!station) return;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setJustLogged(true);
|
||||
toast({
|
||||
title: isFinal
|
||||
? "Train arrived — remaining bookings marked arrived, assets freed"
|
||||
: `Pass logged at ${station.label}`,
|
||||
description: isFinal
|
||||
? undefined
|
||||
: arrivals.some((r) => r.canUnload)
|
||||
? "Bookings arriving here have been marked arrived."
|
||||
: undefined,
|
||||
});
|
||||
void yardWorkQuery.refetch();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not log checkpoint",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const doLoad = (row: YardWorkBookingRow) => {
|
||||
load.mutate(
|
||||
{ scheduleId, bookingId: row.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: `${row.reference ?? "Booking"} loaded`,
|
||||
description: `Cargo boarded the train at ${station?.label ?? "this yard"}.`,
|
||||
});
|
||||
void yardWorkQuery.refetch();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not load booking",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const hasWork = boarders.length > 0 || arrivals.length > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
||||
<Text fw={700}>
|
||||
{isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""}
|
||||
</Text>
|
||||
{logged ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
{isFinal ? "Arrived" : "Pass logged"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{yardWorkQuery.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : !hasWork ? (
|
||||
<Alert color="gray" variant="light" radius="md" icon={<MapPin size={16} />}>
|
||||
No bookings board or alight at this station.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Arriving here ─────────────────────────────────────────── */}
|
||||
{arrivals.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<SectionLabel
|
||||
icon={<Flag size={14} />}
|
||||
title="Arriving at this yard"
|
||||
count={arrivals.length}
|
||||
/>
|
||||
{!logged ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Logging the pass marks the loaded bookings below as Arrived
|
||||
(import/export) or Completed (intercity) automatically.
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{arrivals.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.arrivedAt ? fmtDate(row.arrivedAt) : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{arrivals.length > 0 && boarders.length > 0 ? <Divider /> : null}
|
||||
|
||||
{/* ── Boarding here ─────────────────────────────────────────── */}
|
||||
{boarders.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<SectionLabel
|
||||
icon={<TrainFront size={14} />}
|
||||
title="Boarding at this yard"
|
||||
count={boarders.length}
|
||||
/>
|
||||
{!logged && pendingBoarders.length > 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Log the pass first — the train must be at {station?.label} before
|
||||
cargo can be loaded.
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Loaded</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{boarders.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
{row.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
GOV
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{row.loadedAt ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<CheckCircle2
|
||||
size={13}
|
||||
color="var(--mantine-color-edr-green-7)"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDate(row.loadedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
Not loaded
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!row.loadedAt ? (
|
||||
<Tooltip
|
||||
label={
|
||||
!logged
|
||||
? "Log the pass first — the train must be at this yard"
|
||||
: !row.canLoad
|
||||
? "Booking is not ready to load (payment pending)"
|
||||
: "Confirm cargo loaded onto the train"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!logged || !row.canLoad}
|
||||
loading={
|
||||
load.isPending && load.variables?.bookingId === row.id
|
||||
}
|
||||
onClick={() => doLoad(row)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{logged && pendingBoarders.length > 0
|
||||
? `${pendingBoarders.length} booking${pendingBoarders.length === 1 ? "" : "s"} still to load before the next station.`
|
||||
: ""}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{!logged ? (
|
||||
<Button
|
||||
color={isFinal ? "teal" : "edr-green"}
|
||||
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
|
||||
loading={recordCheckpoint.isPending}
|
||||
onClick={doLogPass}
|
||||
>
|
||||
{isFinal
|
||||
? `Mark arrived at ${station?.label ?? "destination"}`
|
||||
: `Log pass at ${station?.label ?? "station"}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -166,9 +166,6 @@ export function ScheduleWorkspacePanel({
|
||||
const setLoading = useMutation(
|
||||
api.trainScheduling.setLoadingStatus.mutationOptions(),
|
||||
);
|
||||
const confirmLoading = useMutation(
|
||||
api.trainScheduling.confirmLoading.mutationOptions(),
|
||||
);
|
||||
const moveSchedule = useMutation(
|
||||
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
||||
);
|
||||
@@ -301,25 +298,6 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
};
|
||||
|
||||
const doConfirmLoading = () => {
|
||||
confirmLoading
|
||||
.mutateAsync({ id: schedule.id })
|
||||
.then(() => {
|
||||
toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." });
|
||||
onChanged();
|
||||
})
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not confirm loading",
|
||||
description: apiErrorMessage(
|
||||
error,
|
||||
"Grant the Djibouti gatepass first, then confirm loading.",
|
||||
),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Point the pool booking at the chosen same-day train, then put it on wagons.
|
||||
// If the wagon step fails (that train is short too) the booking stays paid &
|
||||
// unassigned in the pool — nothing is lost, staff just pick another train.
|
||||
@@ -460,53 +438,8 @@ export function ScheduleWorkspacePanel({
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Loading confirmation — required before dispatch for import-Djibouti
|
||||
trains; shown for every direction so staff have one place to confirm. */}
|
||||
{canManage ? (
|
||||
<Group
|
||||
gap={10}
|
||||
p="sm"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: schedule.loadingConfirmed
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-yellow-0)",
|
||||
border: `1px solid ${
|
||||
schedule.loadingConfirmed
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-yellow-3)"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||
{schedule.loadingConfirmed ? (
|
||||
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
|
||||
) : (
|
||||
<PackageCheck size={18} color="#B7791F" />
|
||||
)}
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.loadingConfirmed
|
||||
? "Loading confirmed — cleared to dispatch"
|
||||
: "Confirm loading before dispatching this train"}
|
||||
</Text>
|
||||
</Group>
|
||||
{!schedule.loadingConfirmed ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
loading={confirmLoading.isPending}
|
||||
onClick={doConfirmLoading}
|
||||
>
|
||||
Confirm loading
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
{/* Loading confirmation gate removed: bookings can board mid-corridor,
|
||||
so per-yard loading happens from the track page's log-pass flow. */}
|
||||
|
||||
{/* Two-panel board */}
|
||||
<Group align="stretch" gap="lg" grow wrap="wrap">
|
||||
|
||||
@@ -199,7 +199,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
complete: FREIGHT_PERMS.bookings.operations,
|
||||
operationAccept: FREIGHT_PERMS.bookings.operations,
|
||||
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
||||
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ export const FREIGHT_PERMS = {
|
||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||
generateContract: "edr_freight_app:contracts:generate_contract",
|
||||
signStaff: "edr_freight_app:contracts:sign_staff",
|
||||
signStaff: {
|
||||
bulk: "edr_freight_app:contracts:sign_staff:bulk",
|
||||
container: "edr_freight_app:contracts:sign_staff:container",
|
||||
},
|
||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||
createBooking: "edr_freight_app:contracts:create_booking",
|
||||
@@ -57,7 +60,6 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
manage: "edr_freight_app:train_scheduling:manage",
|
||||
create: "edr_freight_app:train_scheduling:create",
|
||||
update: "edr_freight_app:train_scheduling:update",
|
||||
cancel: "edr_freight_app:train_scheduling:cancel",
|
||||
@@ -509,8 +511,18 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
|
||||
}
|
||||
|
||||
/** Any train-scheduling write action (create / update / cancel / reschedule). */
|
||||
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage);
|
||||
return (
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.create) ||
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.update) ||
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.cancel) ||
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.reschedule)
|
||||
);
|
||||
}
|
||||
|
||||
export function canCreateSchedule(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.create);
|
||||
}
|
||||
|
||||
export function canViewFleet(user: AuthUser | null | undefined): boolean {
|
||||
@@ -546,12 +558,17 @@ export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||
}
|
||||
|
||||
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
|
||||
export type RuleEngineAction = "view" | "create" | "update" | "delete";
|
||||
|
||||
export function ruleEngineActionKey(
|
||||
slug: RuleEngineResourceSlug,
|
||||
action: RuleEngineAction,
|
||||
): string {
|
||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:${action}`;
|
||||
}
|
||||
|
||||
export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
|
||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
|
||||
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
||||
return ruleEngineActionKey(slug, "view");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,10 +589,19 @@ export function canApproveRuleEngineChange(
|
||||
export function canAccessRuleEngineResource(
|
||||
user: AuthUser | null | undefined,
|
||||
slug: RuleEngineResourceSlug,
|
||||
mode: "view" | "manage",
|
||||
mode: RuleEngineAction,
|
||||
): boolean {
|
||||
const key = mode === "manage" ? ruleEngineManageKey(slug) : ruleEngineViewKey(slug);
|
||||
return hasPermission(user, key);
|
||||
return hasPermission(user, ruleEngineActionKey(slug, mode));
|
||||
}
|
||||
|
||||
/** Holds any write action on the resource — for surfaces gated on "can edit at all". */
|
||||
export function canWriteRuleEngineResource(
|
||||
user: AuthUser | null | undefined,
|
||||
slug: RuleEngineResourceSlug,
|
||||
): boolean {
|
||||
return (["create", "update", "delete"] as const).some((a) =>
|
||||
canAccessRuleEngineResource(user, slug, a),
|
||||
);
|
||||
}
|
||||
|
||||
export function canAccessAnyRuleEngineView(
|
||||
|
||||
@@ -20,6 +20,8 @@ import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuc
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
/**
|
||||
* Staff contract preview + sign. Staff must open and read the generated
|
||||
@@ -30,6 +32,7 @@ export default function ContractViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const { user } = useAuth();
|
||||
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [successOpen, setSuccessOpen] = useState(false);
|
||||
@@ -116,6 +119,15 @@ export default function ContractViewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Counter-signature permission is split per freight type — a bulk signer must
|
||||
// not sign a container contract (API enforces the same on POST /contract/sign).
|
||||
const maySign = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.contracts.signStaff[
|
||||
data.freightType === "BULK" ? "bulk" : "container"
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box p={{ base: "md", md: "xl" }}>
|
||||
<Box maw={920} mx="auto">
|
||||
@@ -145,7 +157,7 @@ export default function ContractViewPage() {
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
{data.canSignStaff && (
|
||||
{data.canSignStaff && maySign && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -43,6 +43,12 @@ const FleetResourcePage = () => {
|
||||
const canCreate = canFleetAction(user, slug, "create");
|
||||
const canUpdate = canFleetAction(user, slug, "update");
|
||||
const canDelete = canFleetAction(user, slug, "delete");
|
||||
// Wagon transfer workspace: shown only to holders of a transfer capability
|
||||
// (raise a request, fulfill one, or see the cross-yard history).
|
||||
const canTransfer =
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferRequest) ||
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) ||
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -411,6 +417,7 @@ const FleetResourcePage = () => {
|
||||
Yard Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
{canTransfer ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="grape"
|
||||
@@ -420,6 +427,7 @@ const FleetResourcePage = () => {
|
||||
>
|
||||
Transfer Requests
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{canCreate ? (
|
||||
|
||||
@@ -114,7 +114,9 @@ const CargoTypesPage = () => {
|
||||
const config = getRuleEngineResource(CARGO_SLUG);
|
||||
|
||||
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
||||
const canCreate = canAccessRuleEngineResource(user, CARGO_SLUG, "create");
|
||||
const canUpdate = canAccessRuleEngineResource(user, CARGO_SLUG, "update");
|
||||
const canDelete = canAccessRuleEngineResource(user, CARGO_SLUG, "delete");
|
||||
|
||||
// One fetch of the whole (small) set — page-walked because the API caps
|
||||
// pageSize at 100; the tree, ancestry and each level are derived client-side
|
||||
@@ -128,7 +130,7 @@ const CargoTypesPage = () => {
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
|
||||
const formFields = useMemo<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
@@ -291,7 +293,7 @@ const CargoTypesPage = () => {
|
||||
leftSection={<Search size={16} />}
|
||||
w={240}
|
||||
/>
|
||||
{canManage && (
|
||||
{canCreate && (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<Plus size={16} />}
|
||||
@@ -335,7 +337,7 @@ const CargoTypesPage = () => {
|
||||
? "No cargo categories yet"
|
||||
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
||||
</Text>
|
||||
{!term && canManage && (
|
||||
{!term && canCreate && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
@@ -354,7 +356,8 @@ const CargoTypesPage = () => {
|
||||
node={node}
|
||||
childCount={(childrenOf.get(node.id) ?? []).length}
|
||||
topBorder={i > 0}
|
||||
canManage={canManage}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
||||
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
||||
onDelete={() => setDeleteTarget(node)}
|
||||
@@ -444,7 +447,8 @@ interface CargoRowProps {
|
||||
node: CargoNode;
|
||||
childCount: number;
|
||||
topBorder: boolean;
|
||||
canManage: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -454,7 +458,8 @@ function CargoRow({
|
||||
node,
|
||||
childCount,
|
||||
topBorder,
|
||||
canManage,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -529,19 +534,19 @@ function CargoRow({
|
||||
</UnstyledButton>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{canManage && (
|
||||
<>
|
||||
{canUpdate && (
|
||||
<Tooltip label="Edit" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Tooltip label="Delete" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip label="Open" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
||||
|
||||
@@ -150,9 +150,20 @@ const RuleEngineResourcePage = () => {
|
||||
const canView = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "view"),
|
||||
);
|
||||
const canManage = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "manage"),
|
||||
// Per-action gates replace the retired coarse "manage": Add shows only with
|
||||
// create, row Edit with update, row Delete with delete.
|
||||
const canCreate = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "create"),
|
||||
);
|
||||
const canUpdate = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "update"),
|
||||
);
|
||||
const canDelete = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "delete"),
|
||||
);
|
||||
// Update-class controls (reorder, rate submit/approve, approval-rule decide)
|
||||
// all map to the update permission — the matching endpoints now require it.
|
||||
const canUpdateControls = canUpdate;
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
@@ -480,7 +491,7 @@ const RuleEngineResourcePage = () => {
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
{config.orderConfig && canManage ? (
|
||||
{config.orderConfig && canUpdateControls ? (
|
||||
<RuleEngineOrderControls
|
||||
record={row.original}
|
||||
orderConfig={config.orderConfig}
|
||||
@@ -493,7 +504,7 @@ const RuleEngineResourcePage = () => {
|
||||
record={row.original}
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canManage}
|
||||
readOnly={!canUpdateControls}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
@@ -504,8 +515,8 @@ const RuleEngineResourcePage = () => {
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
@@ -514,7 +525,7 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
return base;
|
||||
}, [
|
||||
canManage,
|
||||
canUpdateControls,
|
||||
config,
|
||||
isRates,
|
||||
pendingByRateId,
|
||||
@@ -642,7 +653,7 @@ const RuleEngineResourcePage = () => {
|
||||
title={config.label}
|
||||
subtitle={config.subtitle}
|
||||
action={
|
||||
canManage && config.slug !== "container-types" ? (
|
||||
canCreate && config.slug !== "container-types" ? (
|
||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
@@ -653,7 +664,7 @@ const RuleEngineResourcePage = () => {
|
||||
{isPriorityRules ? (
|
||||
<PriorityRuleApprovalsSection
|
||||
requests={priorityWorkflow.pending.data ?? []}
|
||||
canDecide={canManage}
|
||||
canDecide={canUpdateControls}
|
||||
approve={priorityWorkflow.approve}
|
||||
reject={priorityWorkflow.reject}
|
||||
/>
|
||||
@@ -742,7 +753,7 @@ const RuleEngineResourcePage = () => {
|
||||
showSearch={Boolean(config.supportsSearch)}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
onManageOrder={
|
||||
canManage && config.orderConfig
|
||||
canUpdateControls && config.orderConfig
|
||||
? () => setOrderDialogOpen(true)
|
||||
: undefined
|
||||
}
|
||||
@@ -806,16 +817,16 @@ const RuleEngineResourcePage = () => {
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
readOnly={!canManage}
|
||||
onEdit={canManage ? openEdit : undefined}
|
||||
onDelete={canManage ? setDeleteTarget : undefined}
|
||||
readOnly={!canUpdate && !canDelete}
|
||||
onEdit={canUpdate ? openEdit : undefined}
|
||||
onDelete={canDelete ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules"
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
@@ -7,9 +8,11 @@ import {
|
||||
Flag,
|
||||
MapPin,
|
||||
Navigation,
|
||||
PackageCheck,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -25,7 +28,9 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import type { TrackStation } from "@/types/trainScheduling";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
@@ -148,6 +153,18 @@ export default function TrainScheduleTrackPage() {
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
// Yard work drives the log-pass modal: which bookings board/alight per stop.
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
||||
}),
|
||||
);
|
||||
const [yardModal, setYardModal] = useState<{
|
||||
station: TrackStation;
|
||||
isFinal: boolean;
|
||||
alreadyLogged: boolean;
|
||||
} | null>(null);
|
||||
|
||||
if (trackQuery.isLoading) {
|
||||
return (
|
||||
@@ -181,8 +198,26 @@ export default function TrainScheduleTrackPage() {
|
||||
const inTransit = track.status === "DISPATCHED";
|
||||
const arrived = track.status === "ARRIVED";
|
||||
|
||||
// Yard work at a station: boarders not yet loaded, and loaded bookings that
|
||||
// alight there. When either exists, logging the pass goes through the modal
|
||||
// so the operator sees (and can act on) both lists; empty yards log directly.
|
||||
const yardWorkFor = (station: TrackStation | undefined) =>
|
||||
yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
||||
const stationHasWork = (station: TrackStation | undefined) => {
|
||||
const yard = yardWorkFor(station);
|
||||
return Boolean(
|
||||
yard &&
|
||||
(yard.toLoad.some((r) => !r.loadedAt) || yard.toUnload.some((r) => r.canUnload)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
if (station && stationHasWork(station)) {
|
||||
setYardModal({ station, isFinal, alreadyLogged: false });
|
||||
return;
|
||||
}
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo } },
|
||||
{
|
||||
@@ -203,6 +238,15 @@ export default function TrainScheduleTrackPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// "Forgot to load" catch: while the train sits at the current station, any
|
||||
// boarder there that is still unloaded can be loaded until the next pass.
|
||||
const currentStationObj = track.stations.find(
|
||||
(s) => s.sequenceNo === track.currentSequenceNo,
|
||||
);
|
||||
const currentYard = canLog ? yardWorkFor(currentStationObj) : undefined;
|
||||
const forgottenBoarders =
|
||||
currentYard?.toLoad.filter((r) => !r.loadedAt) ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Button
|
||||
@@ -416,6 +460,43 @@ export default function TrainScheduleTrackPage() {
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
/>
|
||||
|
||||
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||
loadable until the next pass is logged. */}
|
||||
{currentStationObj && forgottenBoarders.length > 0 ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title={`${forgottenBoarders.length} booking${
|
||||
forgottenBoarders.length === 1 ? "" : "s"
|
||||
} at ${currentStationObj.label} not loaded yet`}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<Text size="sm">
|
||||
The train is at {currentStationObj.label} — cargo boarding here can
|
||||
still be loaded before the next station is logged.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() =>
|
||||
setYardModal({
|
||||
station: currentStationObj,
|
||||
isFinal:
|
||||
currentStationObj.sequenceNo ===
|
||||
track.stations[totalStations - 1]?.sequenceNo,
|
||||
alreadyLogged: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open yard work
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -505,6 +586,15 @@ export default function TrainScheduleTrackPage() {
|
||||
</Timeline>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<LogPassYardWorkModal
|
||||
opened={yardModal !== null}
|
||||
onClose={() => setYardModal(null)}
|
||||
scheduleId={scheduleId}
|
||||
station={yardModal?.station ?? null}
|
||||
isFinal={yardModal?.isFinal ?? false}
|
||||
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -398,11 +398,8 @@ export default function TrainScheduleV2DetailPage() {
|
||||
!b.loadedAt &&
|
||||
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
|
||||
).length;
|
||||
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
|
||||
// confirmed in the workspace — surface it as a blocker, not just a warning.
|
||||
const loadingBlocksDispatch =
|
||||
schedule.requiresLoadingConfirmation === true &&
|
||||
schedule.loadingConfirmed !== true;
|
||||
// No loading hard-block: bookings may board mid-corridor, so loading happens
|
||||
// per yard from the track page's log-pass flow. Everything below is advisory.
|
||||
const hasDispatchWarnings =
|
||||
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
||||
|
||||
@@ -1271,22 +1268,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
undone.
|
||||
</Text>
|
||||
|
||||
{loadingBlocksDispatch ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Loading not confirmed"
|
||||
>
|
||||
This import train cannot depart until loading is confirmed. Use{" "}
|
||||
<Text span fw={700}>
|
||||
Confirm loading
|
||||
</Text>{" "}
|
||||
in the Workspace tab first.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
@@ -1309,8 +1290,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Text span fw={700}>
|
||||
{unloadedCount}
|
||||
</Text>{" "}
|
||||
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
|
||||
unloaded
|
||||
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} not loaded
|
||||
yet — mid-route boarders load from the track page when the train
|
||||
reaches their yard
|
||||
</List.Item>
|
||||
) : null}
|
||||
{intercityNotLoadedCount > 0 ? (
|
||||
@@ -1350,7 +1332,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={dispatch.isPending}
|
||||
disabled={loadingBlocksDispatch}
|
||||
onClick={() => void runDispatch()}
|
||||
>
|
||||
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
|
||||
|
||||
@@ -55,6 +55,8 @@ import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { formatRouteLabel } from "@/services/routes.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canCreateSchedule } from "@/lib/permissions";
|
||||
import type {
|
||||
FreightType,
|
||||
TrainScheduleListFilters,
|
||||
@@ -99,6 +101,8 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
export default function TrainScheduleV2ListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = canCreateSchedule(user);
|
||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -550,9 +554,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
title="Train Schedules"
|
||||
subtitle="Operational train scheduling with full allocation workflow."
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
|
||||
New schedule
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -657,7 +657,16 @@ export const api = {
|
||||
"finalize-schedule",
|
||||
(id) => trainSchedulingService.finalizeSchedule(id),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
// Finalize only flips statuses (schedule DRAFT→SCHEDULED + each booking's
|
||||
// schedulingStatus) — it never touches allocation or loading. Refresh only
|
||||
// the schedule detail/list + booking views instead of the broad
|
||||
// train-scheduling ROOT, which refired the eligible-bookings / pool /
|
||||
// yard-work queries and made the booking lists visibly reload.
|
||||
() => [
|
||||
["train-scheduling", "schedule"],
|
||||
["train-scheduling", "schedules"],
|
||||
QUERY_KEYS.BOOKINGS.ROOT,
|
||||
],
|
||||
),
|
||||
|
||||
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface ContractView {
|
||||
contractId: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
freightType: "BULK" | "CONTAINER";
|
||||
templateKey: string;
|
||||
title: string;
|
||||
html: string;
|
||||
|
||||
449
e2e/freight/cypress/e2e/flows/export_ledger_day.cy.ts
Normal file
449
e2e/freight/cypress/e2e/flows/export_ledger_day.cy.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* EXPORT "LEDGER DAY" (D+19) — one departure day, THREE trains of three
|
||||
* different consist styles, ~22 bookings of every flavour (ONE_TIME +
|
||||
* GENERAL, container + bulk, USD + ETB), every one of them driven to a known
|
||||
* final fate — and a generated day-ledger REPORT at the end.
|
||||
*
|
||||
* GRAIN built train TRN-LEDGER-PW2 — 37 × PW2 box wagons (the physical
|
||||
* consist IS the cap; the only built-train schedule in the suite)
|
||||
* → bulk only, 37/37 = 2 590 T
|
||||
* BOX loco pair, 54 × NW5 → containers only, 54/54
|
||||
* MIX loco pair, 54 slots → 24w containers (NW5) + 30w bulk (CW4)
|
||||
*
|
||||
* 145 slots, filled FCFS in wave order (export reserves at ACCEPT):
|
||||
* – bulk wave fills GRAIN exactly; containers then cascade PAST the full
|
||||
* PW2 train onto BOX; the mixed wave lands on MIX
|
||||
* – ONE booking per train never pays → all three EXPIRE in one sweep
|
||||
* – 2 late bookings are REFUSED at create (export never queues)
|
||||
* – 1 refused customer rebooks GRAIN's freed 5 wagons, pays, rides
|
||||
*
|
||||
* The final test classifies every booking of the day and writes
|
||||
* `cypress/reports/export-ledger-<day>.json`.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
acceptExport,
|
||||
bookBulk,
|
||||
bookContainers,
|
||||
clearGeneralBooking,
|
||||
createImportSchedule,
|
||||
db,
|
||||
dayLedger,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
expectWagonType,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollDb,
|
||||
resetCorridorDay,
|
||||
seedImportContract,
|
||||
withBooking,
|
||||
writeLedgerReport,
|
||||
type LedgerRow,
|
||||
type ScheduleRow,
|
||||
} from "./import-utils";
|
||||
|
||||
const GRAIN_AT = departureAt(19);
|
||||
const BOX_AT = new Date(GRAIN_AT.getTime() + 2 * 3_600_000);
|
||||
const MIX_AT = new Date(GRAIN_AT.getTime() + 4 * 3_600_000);
|
||||
const DAY = eatDayStr(GRAIN_AT);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
type Kind = "ONE_TIME" | "GENERAL";
|
||||
interface Line {
|
||||
suffix: string;
|
||||
kind: Kind;
|
||||
currency: "ETB" | "USD";
|
||||
freight: "CONTAINER" | "BULK";
|
||||
twenty?: number;
|
||||
tons?: number;
|
||||
wagons: number;
|
||||
wagonType: "PW2" | "NW5" | "CW4";
|
||||
pays: boolean;
|
||||
}
|
||||
|
||||
/** Wave 1 — fills the built 37 × PW2 GRAIN train exactly. */
|
||||
const GRAIN_LINES: Line[] = [
|
||||
{ suffix: "LG1", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 700, wagons: 10, wagonType: "PW2", pays: true },
|
||||
{ suffix: "LG2", kind: "GENERAL", currency: "ETB", freight: "BULK", tons: 560, wagons: 8, wagonType: "PW2", pays: true },
|
||||
{ suffix: "LG3", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 560, wagons: 8, wagonType: "PW2", pays: true },
|
||||
{ suffix: "LG4", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 420, wagons: 6, wagonType: "PW2", pays: true },
|
||||
{ suffix: "LG5", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 350, wagons: 5, wagonType: "PW2", pays: false },
|
||||
];
|
||||
|
||||
/** Wave 2 — 54 × NW5 on the pure container train. */
|
||||
const BOX_LINES: Line[] = [
|
||||
{ suffix: "LB1", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 40, wagons: 20, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LB2", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 24, wagons: 12, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LB3", kind: "GENERAL", currency: "USD", freight: "CONTAINER", twenty: 16, wagons: 8, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LB4", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 12, wagons: 6, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LB5", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 8, wagons: 4, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LB6", kind: "GENERAL", currency: "ETB", freight: "CONTAINER", twenty: 4, wagons: 2, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LB7", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 2, wagons: 1, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LB8", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 2, wagons: 1, wagonType: "NW5", pays: false },
|
||||
];
|
||||
|
||||
/** Wave 3 — 24w containers + 30w bulk share the MIX train. */
|
||||
const MIX_LINES: Line[] = [
|
||||
{ suffix: "LM1", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 24, wagons: 12, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LM2", kind: "GENERAL", currency: "ETB", freight: "CONTAINER", twenty: 16, wagons: 8, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LM3", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 8, wagons: 4, wagonType: "NW5", pays: true },
|
||||
{ suffix: "LM4", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: true },
|
||||
{ suffix: "LM5", kind: "GENERAL", currency: "ETB", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: true },
|
||||
{ suffix: "LM6", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: false },
|
||||
];
|
||||
|
||||
const ALL_LINES = [...GRAIN_LINES, ...BOX_LINES, ...MIX_LINES];
|
||||
const PAID = ALL_LINES.filter((l) => l.pays);
|
||||
const UNPAID = ALL_LINES.filter((l) => !l.pays);
|
||||
/** Refused at create once all 145 slots are held; LR2 later redeems itself. */
|
||||
const REJECTED = ["LR1", "LR2"];
|
||||
|
||||
let isoSeed = 20_000;
|
||||
|
||||
/** Book one line and take it to its FCFS reservation. */
|
||||
function bookAndAccept(line: Line) {
|
||||
if (line.freight === "CONTAINER") {
|
||||
bookContainers({
|
||||
suffix: line.suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
twenty: line.twenty,
|
||||
scheduledDate: DAY,
|
||||
});
|
||||
isoSeed += (line.twenty ?? 0) + 5;
|
||||
} else {
|
||||
// PW2 bulk rides the built GRAIN consist → GRAINS cargo; CW4 bulk rides the
|
||||
// loco-pair MIX train → WHEAT. One wagon length per cargo keeps the
|
||||
// loco-pair length budget deterministic (see seed 5b3).
|
||||
bookBulk({
|
||||
suffix: line.suffix,
|
||||
tons: line.tons!,
|
||||
scheduledDate: DAY,
|
||||
cargoCode: line.wagonType === "PW2" ? "E2E_IMP_GRAINS" : "E2E_IMP_WHEAT",
|
||||
});
|
||||
}
|
||||
if (line.kind === "GENERAL") clearGeneralBooking(line.suffix, DAY, "export");
|
||||
else acceptExport(line.suffix);
|
||||
}
|
||||
|
||||
function seedLine(line: Line) {
|
||||
seedImportContract({
|
||||
suffix: line.suffix,
|
||||
reference: stampedRef(line.suffix),
|
||||
kind: line.kind,
|
||||
currency: line.currency,
|
||||
freight: line.freight,
|
||||
direction: "EXPORT",
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
});
|
||||
}
|
||||
|
||||
interface DaySchedule {
|
||||
id: string;
|
||||
reference: string;
|
||||
max_wagons: number;
|
||||
booking_window_status: string;
|
||||
}
|
||||
|
||||
function daySchedule(at: Date) {
|
||||
return db<DaySchedule>(
|
||||
`SELECT ts.id, ts.reference, ts.max_wagons, ts.booking_window_status
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
[EXP_ORIGIN, EXP_DEST, at.toISOString()],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, `schedule departing ${at.toISOString()}`).to.have.length(1);
|
||||
return cy.wrap(rows[0], { log: false });
|
||||
});
|
||||
}
|
||||
|
||||
describe("export ledger day: three trains, every fate, one report", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
ALL_LINES.forEach(seedLine);
|
||||
seedLine({
|
||||
suffix: "LR1",
|
||||
kind: "ONE_TIME",
|
||||
currency: "USD",
|
||||
freight: "CONTAINER",
|
||||
twenty: 2,
|
||||
wagons: 1,
|
||||
wagonType: "NW5",
|
||||
pays: false,
|
||||
});
|
||||
seedLine({
|
||||
suffix: "LR2",
|
||||
kind: "ONE_TIME",
|
||||
currency: "ETB",
|
||||
freight: "BULK",
|
||||
tons: 350,
|
||||
wagons: 5,
|
||||
wagonType: "PW2",
|
||||
pays: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("operations schedules THREE trains for one day: 37×PW2 built GRAIN, 54×NW5 BOX, 54 MIX", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(GRAIN_AT, EXP_DEST, EXP_ORIGIN);
|
||||
|
||||
// The built train's coupled consist IS its capacity — 37, not the
|
||||
// loco-length-derived 54 every other schedule in the suite gets.
|
||||
createImportSchedule({
|
||||
departure: GRAIN_AT,
|
||||
trainCode: "TRN-LEDGER-PW2",
|
||||
maxWagons: 37,
|
||||
kind: "bulk",
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
});
|
||||
createImportSchedule({
|
||||
departure: BOX_AT,
|
||||
locoPair: ["LOCO-LED-3", "LOCO-LED-4"],
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
});
|
||||
createImportSchedule({
|
||||
departure: MIX_AT,
|
||||
locoPair: ["LOCO-LED-5", "LOCO-LED-6"],
|
||||
originCode: EXP_ORIGIN,
|
||||
destCode: EXP_DEST,
|
||||
});
|
||||
|
||||
[GRAIN_AT, BOX_AT, MIX_AT].forEach((at) =>
|
||||
daySchedule(at).then((s) => forceWindowOpen(s.id, 120)),
|
||||
);
|
||||
daySchedule(GRAIN_AT).then((s) =>
|
||||
expect(s.max_wagons, "built consist = 37 PW2").to.eq(37),
|
||||
);
|
||||
daySchedule(BOX_AT).then((s) => expect(s.max_wagons, "BOX = 54").to.eq(54));
|
||||
daySchedule(MIX_AT).then((s) => expect(s.max_wagons, "MIX = 54").to.eq(54));
|
||||
});
|
||||
|
||||
it("wave 1 — five bulk bookings fill the built PW2 train exactly (37/37 reserved)", () => {
|
||||
GRAIN_LINES.forEach(bookAndAccept);
|
||||
daySchedule(GRAIN_AT).then((s) => {
|
||||
GRAIN_LINES.forEach((l) =>
|
||||
withBooking(l.suffix, (b) =>
|
||||
expect(b.train_schedule_id, `${l.suffix} on GRAIN`).to.eq(s.id),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("ONE_TIME is single-slot; GENERAL draws down again but is stopped at the FCFS accept", () => {
|
||||
// A second booking on the live ONE_TIME contract LG1 is refused outright…
|
||||
bookBulk({
|
||||
suffix: "LG1",
|
||||
tons: 70,
|
||||
scheduledDate: DAY,
|
||||
cargoCode: "E2E_IMP_GRAINS",
|
||||
expectFailure: "already has an active booking",
|
||||
});
|
||||
|
||||
// …while the GENERAL neighbour LG2 draws again freely. Engine truth: a
|
||||
// GENERAL booking clears PER BOOKING, so it SKIPS the create-time
|
||||
// window/space gate that refused the ONE_TIME latecomers above — a full
|
||||
// day stops it only later, when staff try to accept it onto a train.
|
||||
bookBulk({ suffix: "LG2", tons: 70, scheduledDate: DAY, cargoCode: "E2E_IMP_GRAINS" });
|
||||
withBooking("LG2", (probe) => {
|
||||
expect(probe.status, "GENERAL drawdown accepted at create").to.eq(
|
||||
"AWAITING_DOCUMENTS",
|
||||
);
|
||||
// Dispose of the probe so the day's ledger keeps exactly one live
|
||||
// booking per contract (every lookup skips soft-deleted rows).
|
||||
db(`UPDATE freight.bookings SET deleted_at = now() WHERE id = $1`, [probe.id]);
|
||||
});
|
||||
withBooking("LG2", (b) =>
|
||||
expect(b.status, "LG2's real reservation is untouched").to.be.oneOf([
|
||||
"SELECTED_FOR_BATCH",
|
||||
"AWAITING_PAYMENT",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("wave 2 — containers cascade PAST the full PW2 train onto BOX (54/54 reserved)", () => {
|
||||
BOX_LINES.forEach(bookAndAccept);
|
||||
daySchedule(BOX_AT).then((s) => {
|
||||
BOX_LINES.forEach((l) =>
|
||||
withBooking(l.suffix, (b) =>
|
||||
expect(b.train_schedule_id, `${l.suffix} on BOX`).to.eq(s.id),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("wave 3 — containers and bulk share MIX (24w + 30w = 54/54 reserved)", () => {
|
||||
MIX_LINES.forEach(bookAndAccept);
|
||||
daySchedule(MIX_AT).then((s) => {
|
||||
MIX_LINES.forEach((l) =>
|
||||
withBooking(l.suffix, (b) =>
|
||||
expect(b.train_schedule_id, `${l.suffix} on MIX`).to.eq(s.id),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("every reservation's pay deadline is clamped to its OWN train's window close", () => {
|
||||
const check = (at: Date, lines: Line[]) =>
|
||||
daySchedule(at).then((s) => {
|
||||
db<{ window_closes_at: string }>(
|
||||
`SELECT window_closes_at FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
).then(({ rows }) => {
|
||||
lines.forEach((l) =>
|
||||
withBooking(l.suffix, (b) => {
|
||||
expect(
|
||||
new Date(b.payment_deadline!).getTime(),
|
||||
`${l.suffix} clamped`,
|
||||
).to.be.at.most(new Date(rows[0].window_closes_at).getTime());
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
check(GRAIN_AT, GRAIN_LINES);
|
||||
check(BOX_AT, BOX_LINES);
|
||||
check(MIX_AT, MIX_LINES);
|
||||
});
|
||||
|
||||
it("all 145 slots are held — two late bookings are REFUSED (export never queues)", () => {
|
||||
bookContainers({
|
||||
suffix: "LR1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 29_000,
|
||||
twenty: 2,
|
||||
scheduledDate: DAY,
|
||||
expectFailure: /space|window/i,
|
||||
});
|
||||
bookBulk({
|
||||
suffix: "LR2",
|
||||
tons: 350,
|
||||
scheduledDate: DAY,
|
||||
cargoCode: "E2E_IMP_GRAINS",
|
||||
expectFailure: /space|window/i,
|
||||
});
|
||||
});
|
||||
|
||||
it("16 of the 19 pay — typed allocation on all three trains", () => {
|
||||
PAID.forEach((l) => {
|
||||
markPaid(l.suffix);
|
||||
pollAllocations(l.suffix, l.wagons);
|
||||
expectWagonType(l.suffix, l.wagonType, l.wagons);
|
||||
});
|
||||
});
|
||||
|
||||
it("the three unpaid — one per train — expire in one sweep", () => {
|
||||
UNPAID.forEach((l) => forceReservationExpiry(l.suffix));
|
||||
UNPAID.forEach((l) => pollBookingStatus(l.suffix, "EXPIRED"));
|
||||
});
|
||||
|
||||
it("redemption: the refused bulk customer takes GRAIN's freed 5 PW2 wagons and pays", () => {
|
||||
bookBulk({ suffix: "LR2", tons: 350, scheduledDate: DAY, cargoCode: "E2E_IMP_GRAINS" });
|
||||
acceptExport("LR2");
|
||||
markPaid("LR2");
|
||||
pollAllocations("LR2", 5);
|
||||
expectWagonType("LR2", "PW2", 5);
|
||||
daySchedule(GRAIN_AT).then((s) =>
|
||||
withBooking("LR2", (b) =>
|
||||
expect(b.train_schedule_id, "LR2 rides GRAIN").to.eq(s.id),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("per-train totals: 37 PW2 + 54 NW5 + 54 mixed, links match bookings", () => {
|
||||
const totals: Array<[Date, string, number, number]> = [
|
||||
// [departure, label, wagons, bookings]
|
||||
[GRAIN_AT, "GRAIN", 37, 5], // 4 paid + the redeemed LR2
|
||||
[BOX_AT, "BOX", 53, 7], // one booking expired: 54 − 1
|
||||
[MIX_AT, "MIX", 44, 5], // one 10w booking expired: 54 − 10
|
||||
];
|
||||
totals.forEach(([at, label, wagons, bookings]) => {
|
||||
daySchedule(at).then((s) => {
|
||||
pollDb<{ n: string }>(
|
||||
`${label} carries ${bookings} bookings`,
|
||||
`SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||
WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
|
||||
[s.id],
|
||||
(row) => Number(row?.n) === bookings,
|
||||
15,
|
||||
);
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), `${label} wagons`).to.eq(wagons),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("THE LEDGER — classify every booking of the day and write the report", () => {
|
||||
dayLedger(stamp, { rejected: REJECTED, redeemed: ["LR2"] }).then((rows) => {
|
||||
const ledger = rows as LedgerRow[];
|
||||
const byFate = (fate: string) => ledger.filter((r) => r.fate === fate);
|
||||
|
||||
// Class counts: 16 riding + 1 redeemed + 3 expired + 2 refusals.
|
||||
expect(byFate("PAID_RIDING").length, "paid & riding").to.eq(PAID.length);
|
||||
expect(byFate("REBOOKED_PAID").length, "refused then rebooked").to.eq(1);
|
||||
expect(byFate("EXPIRED_UNPAID").length, "reserved but never paid").to.eq(
|
||||
UNPAID.length,
|
||||
);
|
||||
expect(byFate("REJECTED_NO_SPACE").length, "refused at create").to.eq(
|
||||
REJECTED.length,
|
||||
);
|
||||
expect(byFate("RESERVED_UNPAID").length, "nothing left hanging").to.eq(0);
|
||||
|
||||
// Every engineered line landed in its expected class.
|
||||
PAID.forEach((l) => {
|
||||
const row = ledger.find((r) => r.suffix === l.suffix && r.fate === "PAID_RIDING");
|
||||
expect(row, `${l.suffix} rides`).to.exist;
|
||||
expect(row!.kind, `${l.suffix} kind`).to.eq(l.kind);
|
||||
expect(row!.currency, `${l.suffix} currency`).to.eq(l.currency);
|
||||
expect(row!.wagon_type, `${l.suffix} wagon type`).to.eq(l.wagonType);
|
||||
expect(row!.wagons, `${l.suffix} wagons`).to.eq(l.wagons);
|
||||
});
|
||||
UNPAID.forEach((l) =>
|
||||
expect(
|
||||
ledger.find((r) => r.suffix === l.suffix && r.fate === "EXPIRED_UNPAID"),
|
||||
`${l.suffix} expired`,
|
||||
).to.exist,
|
||||
);
|
||||
// LR2 appears TWICE: the refusal and the redemption.
|
||||
expect(
|
||||
ledger.filter((r) => r.suffix === "LR2").length,
|
||||
"LR2 refused then rebooked",
|
||||
).to.eq(2);
|
||||
|
||||
// Currency split of the riding cargo (USD vs ETB invoices on one day).
|
||||
const riding = [...byFate("PAID_RIDING"), ...byFate("REBOOKED_PAID")];
|
||||
expect(riding.filter((r) => r.currency === "USD").length, "USD riders").to.be.greaterThan(0);
|
||||
expect(riding.filter((r) => r.currency === "ETB").length, "ETB riders").to.be.greaterThan(0);
|
||||
// Every riding booking sits on a train, every expired one does not ride.
|
||||
riding.forEach((r) => expect(r.train, `${r.suffix} has a train`).to.be.a("string"));
|
||||
|
||||
writeLedgerReport(`export-ledger-${DAY}`, ledger);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
@@ -271,7 +271,7 @@ export function dbBooking(suffix: string) {
|
||||
b.is_split, b.contract_id
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
);
|
||||
@@ -297,7 +297,7 @@ export function pollBookingStatus(suffix: string, status: string | string[], att
|
||||
`${suffix} → ${want.join("|")}`,
|
||||
`SELECT b.status FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
(row) => !!row && want.includes(row.status as string),
|
||||
@@ -389,15 +389,17 @@ export function bookBulk(opts: {
|
||||
suffix: string;
|
||||
tons: number;
|
||||
scheduledDate?: string; // omit for DOMESTIC (intercity)
|
||||
/** Cargo code — WHEAT rides CW4, GRAINS rides PW2. Defaults to wheat. */
|
||||
cargoCode?: "E2E_IMP_WHEAT" | "E2E_IMP_GRAINS";
|
||||
expectFailure?: string | RegExp;
|
||||
}) {
|
||||
db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>(
|
||||
`SELECT ct.id, ct.customs_clearing_enabled,
|
||||
(SELECT t.id FROM freight.cargo_types t WHERE t.code = 'E2E_IMP_WHEAT') AS cargo_type_id
|
||||
(SELECT t.id FROM freight.cargo_types t WHERE t.code = $2) AS cargo_type_id
|
||||
FROM freight.contracts ct
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||
ORDER BY ct.created_at DESC LIMIT 1`,
|
||||
[opts.suffix],
|
||||
[opts.suffix, opts.cargoCode ?? "E2E_IMP_WHEAT"],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
|
||||
const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
|
||||
@@ -433,7 +435,12 @@ export function bookBulk(opts: {
|
||||
* seed configures no required documents, so one ad-hoc doc satisfies the
|
||||
* 100%-approved gate.
|
||||
*/
|
||||
export function clearGeneralBooking(suffix: string, scheduledDate: string) {
|
||||
export function clearGeneralBooking(
|
||||
suffix: string,
|
||||
scheduledDate: string,
|
||||
/** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */
|
||||
mode: "import" | "export" = "import",
|
||||
) {
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
|
||||
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
|
||||
@@ -450,7 +457,8 @@ export function clearGeneralBooking(suffix: string, scheduledDate: string) {
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
acceptOperation(suffix);
|
||||
if (mode === "export") acceptExport(suffix);
|
||||
else acceptOperation(suffix);
|
||||
}
|
||||
|
||||
/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */
|
||||
@@ -473,6 +481,28 @@ export function setPriority(suffix: string, order: number) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customs bookings pay their clearance service fee ON the booking invoice —
|
||||
* there is no separate prepaid clearance invoice. Asserts the booking's
|
||||
* invoice carries a CUSTOMS_CLEARANCE line.
|
||||
*/
|
||||
export function expectClearanceOnBookingInvoice(suffix: string) {
|
||||
pollDb<{ n: string }>(
|
||||
`${suffix} clearance fee on booking invoice`,
|
||||
`SELECT COUNT(*)::text AS n
|
||||
FROM freight.invoice_lines l
|
||||
JOIN freight.invoices i ON i.id = l.invoice_id
|
||||
JOIN freight.bookings b ON b.id::text = i.source_id::text
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||
AND i.source = 'booking'
|
||||
AND l.charge_type LIKE 'CUSTOMS_CLEARANCE%'`,
|
||||
[suffix],
|
||||
(row) => Number(row?.n ?? 0) > 0,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/** Staff force-pay; polls PAID + SCHEDULED. */
|
||||
export function markPaid(suffix: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
@@ -484,7 +514,7 @@ export function markPaid(suffix: string) {
|
||||
`${suffix} PAID+SCHEDULED`,
|
||||
`SELECT b.status, b.scheduling_status FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
(row) => row?.status === "PAID" && row?.scheduling_status === "SCHEDULED",
|
||||
@@ -557,7 +587,7 @@ export function settleViaGateway(suffix: string) {
|
||||
`${suffix} PAID via gateway`,
|
||||
`SELECT b.status FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
(row) => row?.status === "PAID",
|
||||
@@ -808,7 +838,13 @@ export function withExportSchedule(departure: Date, fn: (s: ScheduleRow) => void
|
||||
*/
|
||||
export function createImportSchedule(opts: {
|
||||
departure: Date;
|
||||
locoPair: [string, string];
|
||||
/** Loco-pair mode — capacity comes from maxWagonsPerTrain. */
|
||||
locoPair?: [string, string];
|
||||
/**
|
||||
* Built-train mode — the Train-Builder consist IS the capacity (its coupled
|
||||
* wagon count), immune to the loco-length slot recompute.
|
||||
*/
|
||||
trainCode?: string;
|
||||
maxWagons?: number;
|
||||
kind?: "container" | "bulk";
|
||||
originCode?: string;
|
||||
@@ -816,16 +852,32 @@ export function createImportSchedule(opts: {
|
||||
}) {
|
||||
const originCode = opts.originCode ?? ORIGIN;
|
||||
const destCode = opts.destCode ?? DEST;
|
||||
const endpoint = `/api/train-scheduling/${opts.kind ?? "container"}/schedules`;
|
||||
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
|
||||
if (rows.length > 0) return;
|
||||
dbRouteId(originCode, destCode).then(({ rows: routes }) => {
|
||||
expect(routes, "corridor route").to.have.length(1);
|
||||
if (opts.trainCode) {
|
||||
db<{ id: string }>(`SELECT id FROM freight.trains WHERE code = $1`, [
|
||||
opts.trainCode,
|
||||
]).then(({ rows: trains }) => {
|
||||
expect(trains, `built train ${opts.trainCode}`).to.have.length(1);
|
||||
apiPost(opsStaff, endpoint, {
|
||||
routeId: routes[0].id,
|
||||
scheduleDate: opts.departure.toISOString(),
|
||||
trainId: trains[0].id,
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
db<{ id: string }>(
|
||||
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
|
||||
[opts.locoPair],
|
||||
[opts.locoPair ?? []],
|
||||
).then(({ rows: locos }) => {
|
||||
expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2);
|
||||
apiPost(opsStaff, `/api/train-scheduling/${opts.kind ?? "container"}/schedules`, {
|
||||
expect(locos, `locomotives ${(opts.locoPair ?? []).join(",")}`).to.have.length(2);
|
||||
apiPost(opsStaff, endpoint, {
|
||||
routeId: routes[0].id,
|
||||
scheduleDate: opts.departure.toISOString(),
|
||||
locomotiveIds: locos.map((l) => l.id),
|
||||
@@ -838,7 +890,111 @@ export function createImportSchedule(opts: {
|
||||
});
|
||||
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
|
||||
expect(rows, "created schedule").to.have.length(1);
|
||||
expect(rows[0].max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
|
||||
expect(rows[0].max_wagons, "consist size").to.eq(opts.maxWagons ?? 54);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// day ledger — who booked, who rides, who expired, who was refused
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LedgerRow {
|
||||
suffix: string;
|
||||
reference: string;
|
||||
kind: string;
|
||||
freight: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
wagons: number;
|
||||
wagon_type: string | null;
|
||||
train: string | null;
|
||||
fate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify every booking made under this run's stamped contracts into its
|
||||
* final fate. Bookings the engine REFUSED at create never exist as rows —
|
||||
* the caller passes those suffixes in (they are only observable as 4xx at
|
||||
* request time), plus any suffix that was refused and later rebooked.
|
||||
*/
|
||||
export function dayLedger(
|
||||
runStamp: string,
|
||||
opts: { rejected?: string[]; redeemed?: string[] } = {},
|
||||
) {
|
||||
return db<LedgerRow>(
|
||||
`SELECT split_part(ct.reference, '-', 4) AS suffix,
|
||||
b.reference,
|
||||
ct.contract_kind AS kind,
|
||||
b.freight_type AS freight,
|
||||
b.payment_currency AS currency,
|
||||
b.status,
|
||||
COALESCE(a.wagons, 0)::int AS wagons,
|
||||
a.wagon_type,
|
||||
ts.reference AS train,
|
||||
CASE
|
||||
WHEN b.status IN ('PAID','IN_TRANSIT','ARRIVED','COMPLETED') THEN 'PAID_RIDING'
|
||||
WHEN b.status = 'EXPIRED' THEN 'EXPIRED_UNPAID'
|
||||
WHEN b.status IN ('SELECTED_FOR_BATCH','AWAITING_PAYMENT') THEN 'RESERVED_UNPAID'
|
||||
ELSE 'PENDING_' || b.status
|
||||
END AS fate
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
LEFT JOIN freight.train_schedules ts ON ts.id = b.train_schedule_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*)::int AS wagons, max(wt.code) AS wagon_type
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||||
) a ON true
|
||||
WHERE ct.reference LIKE 'CTR-IMP-' || $1 || '-%' AND b.deleted_at IS NULL
|
||||
ORDER BY b.created_at`,
|
||||
[runStamp],
|
||||
).then(({ rows }) => {
|
||||
const rejected = (opts.rejected ?? []).map((suffix) => ({
|
||||
suffix,
|
||||
reference: "—",
|
||||
kind: "—",
|
||||
freight: "—",
|
||||
currency: "—",
|
||||
status: "NOT_CREATED",
|
||||
wagons: 0,
|
||||
wagon_type: null,
|
||||
train: null,
|
||||
fate: "REJECTED_NO_SPACE",
|
||||
})) as LedgerRow[];
|
||||
// A refused customer who later rebooked shows BOTH lines: the refusal
|
||||
// above and the live booking here, re-labelled.
|
||||
const ledger = [
|
||||
...rows.map((r) =>
|
||||
(opts.redeemed ?? []).includes(r.suffix) && r.fate === "PAID_RIDING"
|
||||
? { ...r, fate: "REBOOKED_PAID" }
|
||||
: r,
|
||||
),
|
||||
...rejected,
|
||||
];
|
||||
return cy.wrap(ledger, { log: false });
|
||||
});
|
||||
}
|
||||
|
||||
/** Print the ledger as a table into the Cypress log and write a JSON artifact. */
|
||||
export function writeLedgerReport(name: string, ledger: LedgerRow[]) {
|
||||
const counts = ledger.reduce<Record<string, number>>((acc, r) => {
|
||||
acc[r.fate] = (acc[r.fate] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
cy.log(`**LEDGER ${name}** — ${JSON.stringify(counts)}`);
|
||||
ledger.forEach((r) =>
|
||||
cy.log(
|
||||
`${r.suffix.padEnd(8)} ${r.kind.padEnd(9)} ${r.freight.padEnd(9)} ` +
|
||||
`${r.currency.padEnd(4)} ${String(r.wagons).padStart(2)}w ` +
|
||||
`${(r.wagon_type ?? "-").padEnd(4)} ${(r.train ?? "-").padEnd(14)} ${r.fate}`,
|
||||
),
|
||||
);
|
||||
cy.writeFile(`cypress/reports/${name}.json`, {
|
||||
generatedAt: new Date().toISOString(),
|
||||
counts,
|
||||
bookings: ledger,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
withBooking,
|
||||
withSchedule,
|
||||
type ScheduleRow,
|
||||
expectClearanceOnBookingInvoice,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE = departureAt(4);
|
||||
@@ -147,6 +148,9 @@ describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }
|
||||
|
||||
it("all six pay — allocated onto the train, 54/54 wagons, window FULL and schedule finalized", () => {
|
||||
BOOKINGS.forEach((b) => {
|
||||
// Customs bookings carry their clearance service fee ON the booking
|
||||
// invoice (no prepaid clearance invoice) — assert before settling.
|
||||
if (b.customs) expectClearanceOnBookingInvoice(b.suffix);
|
||||
markPaid(b.suffix);
|
||||
pollAllocations(b.suffix, b.wagons);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,24 @@
|
||||
-- missing production migration.
|
||||
DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;
|
||||
|
||||
-- 0b. Schema drift guard: CUSTOMS_CLEARANCE / WITH_RETURN rates became
|
||||
-- route-scoped in migrations 2820 + 2830, but an e2e image built before them
|
||||
-- still carries the old CK_rates_yard_scope (yards allowed only for ALWAYS
|
||||
-- BULK/CONTAINER/INTERCITY rows). Re-state the post-2830 shape — a no-op on an
|
||||
-- up-to-date image, and what lets the customs-clearance lane rates below load.
|
||||
ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL
|
||||
OR status = 'SUPERSEDED'
|
||||
OR CASE
|
||||
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||
OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
);
|
||||
|
||||
-- 1a. Extra Ethiopian mid-corridor yard.
|
||||
INSERT INTO freight.yards (id, code, label, country, is_active, display_order)
|
||||
SELECT gen_random_uuid(), 'E2E_AWASH', 'E2E Awash Yard', 'Ethiopia', true, 50
|
||||
@@ -124,7 +142,7 @@ FROM (
|
||||
JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
|
||||
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
|
||||
ORDER BY w2.wagon_number ASC
|
||||
LIMIT 120
|
||||
LIMIT 200
|
||||
) pick
|
||||
WHERE w.id = pick.id;
|
||||
|
||||
@@ -158,22 +176,35 @@ WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
||||
INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
|
||||
SELECT gen_random_uuid(), 'ECW' || lpad(g::text, 4, '0'), wt.id,
|
||||
(SELECT id FROM freight.yards
|
||||
WHERE code = CASE WHEN g <= 80 THEN 'KALITY' ELSE 'DIRE_DAWA' END)
|
||||
FROM generate_series(1, 100) AS g
|
||||
WHERE code = CASE WHEN g <= 120 THEN 'KALITY' ELSE 'DIRE_DAWA' END)
|
||||
FROM generate_series(1, 160) AS g
|
||||
JOIN freight.wagon_types wt ON wt.code = 'CW4'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0')
|
||||
);
|
||||
|
||||
-- 5b3. Ledger-day rolling stock: wheat may also ride PW2 box wagons, and the
|
||||
-- GRAIN train is a BUILT Train-Builder consist — 37 PW2 wagons coupled at
|
||||
-- KALITY behind two dedicated locos. A built train's physical wagon count IS
|
||||
-- its schedule capacity (37), immune to the loco-length slot recompute.
|
||||
-- 5b3. Ledger-day rolling stock: GRAINS ride PW2 box wagons on a BUILT
|
||||
-- Train-Builder consist — 37 PW2 wagons coupled at KALITY behind two dedicated
|
||||
-- locos. A built train's physical wagon count IS its schedule capacity (37),
|
||||
-- immune to the loco-length slot recompute.
|
||||
--
|
||||
-- GRAINS gets PW2 (17.066 m), WHEAT gets CW4 (13.976 m) below — kept on SEPARATE
|
||||
-- cargo types on purpose. `dimsFor`/`needFor` size a bulk booking off its cargo
|
||||
-- type's FIRST wagon type, so a cargo carrying two wagon-lengths sizes
|
||||
-- inconsistently on loco-pair export trains (one booking as CW4, another as PW2)
|
||||
-- and the length budget never matches the 54-wagon CW4 board the export specs
|
||||
-- expect. One cargo → one wagon length keeps that math deterministic.
|
||||
DELETE FROM freight.cargo_type_wagon_types x
|
||||
USING freight.cargo_types ct, freight.wagon_types wt
|
||||
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||
AND ((ct.code = 'E2E_IMP_WHEAT' AND wt.code = 'PW2')
|
||||
OR (ct.code = 'E2E_IMP_GRAINS' AND wt.code = 'CW4'));
|
||||
|
||||
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
||||
SELECT ct.id, wt.id
|
||||
FROM freight.cargo_types ct
|
||||
JOIN freight.wagon_types wt ON wt.code = 'PW2'
|
||||
WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
|
||||
WHERE ct.code = 'E2E_IMP_GRAINS'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.cargo_type_wagon_types x
|
||||
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||
@@ -182,7 +213,8 @@ WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
|
||||
INSERT INTO freight.locomotives
|
||||
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
|
||||
SELECT gen_random_uuid(), v.code, 9000, 760, y.id
|
||||
FROM (VALUES ('LOCO-LED-1'), ('LOCO-LED-2')) AS v(code)
|
||||
FROM (VALUES ('LOCO-LED-1'), ('LOCO-LED-2'), ('LOCO-LED-3'), ('LOCO-LED-4'),
|
||||
('LOCO-LED-5'), ('LOCO-LED-6')) AS v(code)
|
||||
JOIN freight.yards y ON y.code = 'KALITY'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
||||
|
||||
@@ -269,7 +301,7 @@ INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
||||
SELECT ct.id, wt.id
|
||||
FROM freight.cargo_types ct
|
||||
JOIN freight.wagon_types wt ON wt.code = 'CW4'
|
||||
WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
|
||||
WHERE ct.code = 'E2E_IMP_WHEAT'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.cargo_type_wagon_types x
|
||||
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||
@@ -289,7 +321,7 @@ WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
|
||||
-- Re-park the export CW4 pocket every seed (the mint above is insert-guarded).
|
||||
UPDATE freight.wagons w
|
||||
SET current_yard_id = (SELECT id FROM freight.yards
|
||||
WHERE code = CASE WHEN substring(w.wagon_number FROM 4)::int <= 80
|
||||
WHERE code = CASE WHEN substring(w.wagon_number FROM 4)::int <= 120
|
||||
THEN 'KALITY' ELSE 'DIRE_DAWA' END)
|
||||
FROM freight.wagon_types wt
|
||||
WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
|
||||
@@ -357,3 +389,62 @@ WHERE NOT EXISTS (
|
||||
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||
AND r.deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- 7b. Customs clearance service fees — billed on the booking invoice together
|
||||
-- with the freight. Sold per direction + route + cargo kind: container fees
|
||||
-- per container type (one row per active 20/40ft type so whichever type the
|
||||
-- server resolves for a size always matches), bulk fees per commodity.
|
||||
-- Without these, every customs booking hard-blocks at pricing.
|
||||
INSERT INTO freight.rates
|
||||
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||
trade_direction, container_type_id, origin_yard_id, destination_yard_id,
|
||||
proposed_by_staff_id)
|
||||
SELECT gen_random_uuid(), 'CUSTOMS_CLEARANCE', 'OTHER', 'CUSTOMS_CLEARANCE',
|
||||
'USD', 50, 'PER_CONTAINER', 'LIVE', v.direction, ct.id, a.id, b.id, u.id
|
||||
FROM (VALUES
|
||||
('IMPORT', 'DJIB_PORT', 'KALITY'),
|
||||
('IMPORT', 'DJIB_PORT', 'MOJO'),
|
||||
('IMPORT', 'NAGAD', 'KALITY'),
|
||||
('IMPORT', 'NAGAD', 'MOJO'),
|
||||
('EXPORT', 'KALITY', 'DJIB_PORT'),
|
||||
('EXPORT', 'MOJO', 'DJIB_PORT'),
|
||||
('EXPORT', 'DIRE_DAWA', 'DJIB_PORT')
|
||||
) AS v(direction, from_code, to_code)
|
||||
JOIN freight.yards a ON a.code = v.from_code
|
||||
JOIN freight.yards b ON b.code = v.to_code
|
||||
JOIN freight.container_types ct ON ct.size_ft IN (20, 40) AND ct.is_active
|
||||
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.rates r
|
||||
WHERE r.rate_type = 'CUSTOMS_CLEARANCE'
|
||||
AND r.container_type_id = ct.id
|
||||
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||
AND r.deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.rates
|
||||
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||
trade_direction, cargo_type_id, origin_yard_id, destination_yard_id,
|
||||
proposed_by_staff_id)
|
||||
SELECT gen_random_uuid(), 'CUSTOMS_CLEARANCE', 'OTHER', 'CUSTOMS_CLEARANCE',
|
||||
'USD', 2, 'PER_TON', 'LIVE', v.direction, cgt.id, a.id, b.id, u.id
|
||||
FROM (VALUES
|
||||
('IMPORT', 'DJIB_PORT', 'KALITY'),
|
||||
('IMPORT', 'DJIB_PORT', 'MOJO'),
|
||||
('IMPORT', 'NAGAD', 'KALITY'),
|
||||
('IMPORT', 'NAGAD', 'MOJO'),
|
||||
('EXPORT', 'KALITY', 'DJIB_PORT'),
|
||||
('EXPORT', 'MOJO', 'DJIB_PORT'),
|
||||
('EXPORT', 'DIRE_DAWA', 'DJIB_PORT')
|
||||
) AS v(direction, from_code, to_code)
|
||||
JOIN freight.yards a ON a.code = v.from_code
|
||||
JOIN freight.yards b ON b.code = v.to_code
|
||||
JOIN freight.cargo_types cgt ON cgt.code = 'E2E_IMP_WHEAT'
|
||||
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.rates r
|
||||
WHERE r.rate_type = 'CUSTOMS_CLEARANCE'
|
||||
AND r.cargo_type_id = cgt.id
|
||||
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||
AND r.deleted_at IS NULL
|
||||
);
|
||||
|
||||
17043
edr_freight_dump.sql
Normal file
17043
edr_freight_dump.sql
Normal file
File diff suppressed because one or more lines are too long
@@ -178,7 +178,7 @@ export enum InvoiceSource {
|
||||
Demurrage = "demurrage",
|
||||
FirstMile = "firstmile",
|
||||
LastMile = "lastmile",
|
||||
/** Customs clearance service fee, prepaid before clearance work begins. */
|
||||
/** Customs clearance service fee — billed on the booking invoice with the freight. */
|
||||
Clearance = "clearance"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user