Merge pull request #1145 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-07 01:35:39 +03:00
committed by GitHub
44 changed files with 1824 additions and 202 deletions

View File

@@ -110,6 +110,7 @@ import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
if (!process.env.APPLICATION_NAME) {
process.env.APPLICATION_NAME = "freight";
@@ -273,6 +274,9 @@ if (!process.env.APPLICATION_NAME) {
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
// Feeds position-TYPE grants to the synchronous permission checks — without
// it, staff whose permissions live on their position type resolve to none.
PositionTypePermissionsCache,
],
})
export class AppModule implements OnApplicationBootstrap {

View File

@@ -1,6 +1,9 @@
import {
assertCanApproveContractStep,
canEditContractStep,
collectPermissionKeys,
hasFreightPermission,
setPositionTypePermissionResolver,
} from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
@@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => {
);
});
});
/**
* The GL lockout regression: positions created through the admin UI keep their
* grants on the position TYPE, and the JWT only ever snapshots DIRECT position
* permissions. Without the type resolver those staff resolved to zero
* permissions, so every gated route rejected them — which is what kept GL
* officers out of their own clearance detail pages.
*/
describe('collectPermissionKeys — position-type grants', () => {
const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview;
afterEach(() => {
setPositionTypePermissionResolver(() => []);
});
const glOfficer = {
roles: [],
permissions: [],
employee: {
position: {
permissions: [], // admin-created position carries NO direct grants
positionType: { key: 'commercial-global-logistics-(et)-officer' },
},
},
};
it('resolves permissions carried by the position type', () => {
setPositionTypePermissionResolver((key) =>
key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [],
);
expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true);
});
it('handles the array-shaped employee payload too', () => {
setPositionTypePermissionResolver(() => [CLEARANCE]);
const arrayShaped = {
roles: [],
permissions: [],
employee: [
{
positions: [
{ permissions: [], positionType: { key: 'djibouti-gl-officer' } },
],
},
],
};
expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true);
});
it('still rejects when neither the position nor its type grants it', () => {
setPositionTypePermissionResolver(() => []);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false);
});
it('keeps direct position permissions working with no resolver installed', () => {
const direct = {
roles: [],
permissions: [],
employee: { position: { permissions: [{ key: CLEARANCE }] } },
};
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
});
});

View File

@@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo
return isSuperAdmin(user) || isOrganizationAdmin(user);
}
/** Flat permission keys from JWT / session user (roles + position permissions). */
/**
* Permissions carried by a position TYPE rather than the position itself.
*
* The JWT snapshots only DIRECT position permissions, so type-level grants —
* which is where admin-created positions keep theirs — are absent from the
* token entirely. This resolver is installed at startup
* (see `PositionTypePermissionsCache`) so the synchronous permission checks
* below can still see them. Left as a no-op resolver until then, which
* degrades to the old position-only behaviour rather than throwing.
*/
let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () =>
[];
export function setPositionTypePermissionResolver(
resolver: (positionTypeKey: string) => string[],
): void {
positionTypePermissionResolver = resolver;
}
/**
* Flat permission keys from JWT / session user: roles, position permissions,
* and the grants held by each position's TYPE.
*/
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set<string>();
const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => {
if (!positionType?.key) return;
for (const key of positionTypePermissionResolver(positionType.key)) {
keys.add(key);
}
};
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
@@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(pos.positionType);
}
}
return [...keys];
@@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(employee.position?.positionType);
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);

View File

@@ -0,0 +1,83 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { setPositionTypePermissionResolver } from './freight-permission.util';
/**
* Permissions granted to a position TYPE (`iam.position_type_permissions`).
*
* A position type is the platform's notion of a role, and positions created
* through the admin UI carry their grants there rather than on the position
* itself. The JWT only ever snapshots DIRECT position permissions, so those
* grants are invisible to `collectPermissionKeys` — staff on such a position
* resolve to zero permissions and every permission-gated route rejects them.
*
* The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are
* synchronous and sit on the request path, so the mapping is held in memory and
* refreshed periodically rather than queried per request. The dataset is tiny
* (tens of types, a few hundred rows), so a full reload is cheaper than any
* incremental scheme.
*/
@Injectable()
export class PositionTypePermissionsCache implements OnModuleInit {
private readonly logger = new Logger(PositionTypePermissionsCache.name);
/** position_type key → permission keys. Empty until the first load lands. */
private byPositionTypeKey = new Map<string, string[]>();
// ponytail: fixed 5-min refresh, no invalidation hook. A permission granted
// in the admin UI takes up to one interval to reach the guards. Wire the
// grant mutation to call `refresh()` if that lag ever matters.
private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000;
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async onModuleInit(): Promise<void> {
await this.refresh();
// Hand the lookup to the permission utils, whose checks are synchronous and
// therefore cannot query IAM themselves.
setPositionTypePermissionResolver((positionTypeKey) =>
this.get(positionTypeKey),
);
const timer = setInterval(() => {
void this.refresh();
}, PositionTypePermissionsCache.REFRESH_INTERVAL_MS);
// Never hold the process open for a cache refresh.
timer.unref?.();
}
/** Permission keys for a position-type key ([] when unknown/not loaded). */
get(positionTypeKey: string | undefined | null): string[] {
if (!positionTypeKey) return [];
return this.byPositionTypeKey.get(positionTypeKey) ?? [];
}
/** Reload the whole mapping. Failures keep the previous snapshot in place. */
async refresh(): Promise<void> {
try {
const rows: { position_type_key: string; permission_key: string }[] =
await this.dataSource.query(
`SELECT pt.key AS position_type_key, perm.key AS permission_key
FROM iam.position_type_permissions ptp
JOIN iam.position_types pt ON pt.id = ptp.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id`,
);
const next = new Map<string, string[]>();
for (const row of rows) {
if (!row.position_type_key || !row.permission_key) continue;
const keys = next.get(row.position_type_key);
if (keys) keys.push(row.permission_key);
else next.set(row.position_type_key, [row.permission_key]);
}
this.byPositionTypeKey = next;
} catch (err) {
// iam schema unreachable — keep serving the previous snapshot rather than
// dropping every type-derived permission and locking staff out.
this.logger.warn(
`Position-type permission refresh failed: ${(err as Error).message}`,
);
}
}
}

View File

@@ -35,10 +35,57 @@ export class FreightMeService {
}
}
/**
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
* A position type is the platform's notion of a role, and admin-created
* positions carry their grants there rather than on the position itself — but
* the JWT only ever snapshots direct position permissions. Without this, staff
* on such a position resolve to zero permissions and every permission-gated
* route rejects them (this is what locked GL officers out of their clearance
* detail pages). Resolved live from IAM, same as the position type above.
*/
private async lookupPositionTypePermissions(
positionId: string | undefined,
): Promise<string[]> {
if (!positionId) return [];
try {
const rows: { key: string }[] = await this.dataSource.query(
`SELECT DISTINCT perm.key
FROM iam.positions p
JOIN iam.position_type_permissions ptp
ON ptp.position_type_id = p.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id
WHERE p.id = $1`,
[positionId],
);
return rows.map((r) => r.key).filter(Boolean);
} catch {
return []; // iam schema unreachable — degrade to position-only permissions
}
}
async getEnrichedProfile(user: TCurrentUser) {
const positionType = await this.lookupPositionType(
user.employee?.position?.id,
const positionId = user.employee?.position?.id;
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(positionId),
this.lookupPositionTypePermissions(positionId),
]);
// Merge the type-level grants into the position's own permission list so
// BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this same nested array.
const positionPermissions = [
...(user.employee?.position?.permissions ?? []),
];
const seenPermissionKeys = new Set(
positionPermissions.map((p) => p?.key).filter(Boolean),
);
for (const key of positionTypePermissionKeys) {
if (!seenPermissionKeys.has(key)) {
seenPermissionKeys.add(key);
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
}
}
const employee = user.employee
? [
@@ -56,7 +103,7 @@ export class FreightMeService {
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [],
permissions: positionPermissions,
positionType,
},
]
@@ -65,7 +112,15 @@ export class FreightMeService {
]
: [];
const permissionKeys = collectPermissionKeys(user);
// `collectPermissionKeys` reads the raw token (position-level only), so
// union the type-level grants in — the backoffice prefers this flat list
// over the nested array and would otherwise still see none of them.
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...positionTypePermissionKeys,
]),
];
return {
id: user.id,

View File

@@ -90,6 +90,21 @@ export class BookingInvoiceService {
return this.billing.generateInvoice(input);
}
/**
* Cancel the booking's open PREPAID invoice, if any — used when a
* changes-requested resubmit restates the cargo, so the re-priced booking can
* be re-invoiced. Throws when the invoice already has payments recorded
* (cargo must not change out from under recorded money).
*/
async cancelUnpaidInvoiceForBooking(bookingId: string): Promise<void> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
bookingId,
"PREPAID",
);
if (existing) await this.billing.cancelInvoice(existing.id);
}
/**
* React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): each invoice type advances

View File

@@ -429,6 +429,20 @@ export class BookingTransitionService {
return fresh;
}
/**
* Customer self-service cancel, allowed only before payment — no fee.
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
* take the plain cancel path (open invoices expired, nothing reserved yet).
* Anything past payment falls through to cancel()'s status assertion.
*/
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (booking.status === "SELECTED_FOR_BATCH") {
return this.cancelHold(bookingId, reason);
}
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -978,6 +992,15 @@ export class BookingTransitionService {
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
// Export rail rides the exact train the customer picked — never an
// auto-assigned one. Both portal flows (clearance + contract completion)
// surface a picker, so a missing id is an invalid submission, not a
// legitimate "let the system choose".
if (isExportTrain && !requestedId) {
throw new BadRequestException(
"Select a train for the chosen shipment day.",
);
}
const scheduledBooking = {
...booking,
scheduledDate: date,

View File

@@ -496,6 +496,22 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/wagons')
@ApiOperation({
summary:
'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train',
})
async wagonAllocations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.bookingsService.wagonAllocations(id);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(
@@ -1335,6 +1351,19 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/customer-cancel")
@ApiOperation({
summary:
"Customer cancels their own booking before payment — no cancellation fee",
})
async customerCancel(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.customerCancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/cancel-hold")
@ApiOperation({
summary:

View File

@@ -339,6 +339,63 @@ export class BookingsService {
};
}
/**
* Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same
* join chain as the carriage acceptance sheet, but structured (containers as
* an array per wagon, bulk load description when the wagon carries bulk).
* Empty array until the booking has been allocated onto a train.
*/
async wagonAllocations(bookingId: string): Promise<unknown[]> {
return this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
wt.code AS "wagonTypeCode",
wt.tare_weight_tons AS "tareWeightTons",
tsw.capacity_tons AS "capacityTons",
tsw.length_meters AS "lengthMeters",
a.allocated_weight_tons AS "allocatedWeightTons",
a.load_type AS "loadType",
a.status AS "status",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "originStation",
sd.label AS "destinationStation",
bl.cargo_description AS "bulkCargoDescription",
bl.quantity AS "bulkQuantity",
COALESCE(
json_agg(
json_build_object(
'containerNumber', ci.container_number,
'sealNumber', ci.seal_number,
'positionOnWagon', ci.position_on_wagon,
'grossWeightTons', ci.gross_weight_tons
) ORDER BY ci.position_on_wagon, ci.container_number
) FILTER (WHERE ci.id IS NOT NULL),
'[]'
) AS "containers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
bl.cargo_description, bl.quantity
ORDER BY tsw.sequence_no`,
[bookingId],
);
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding
@@ -1425,7 +1482,46 @@ export class BookingsService {
tradeDirection,
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
// Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED
// booking) must obey the same gate as creation: the route needs an OPEN
// departure on that EAT day that can carry the cargo. Skipped when the day
// didn't change, for general contracts (period-based, no pinned day) and
// for intercity (staff assign a passing train later).
if (dto.scheduledDate) {
const day = eatDay(new Date(dto.scheduledDate));
const dayChanged =
!existing.scheduledDate || eatDay(existing.scheduledDate) !== day;
if (
dayChanged &&
existing.bookingType !== 'GENERAL_CONTRACT' &&
tradeDirection !== 'DOMESTIC'
) {
const { hasDeparture, hasCompatible } =
await this.trainSchedulingService.checkDayCargoCompatibility(
originYardId,
destinationYardId,
day,
{
freightType: freightType as 'CONTAINER' | 'BULK',
cargoTypeId,
containerTypeIds: containers
.map((c) => c.containerTypeId)
.filter((cid): cid is string => Boolean(cid)),
},
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
if (!hasCompatible) {
throw new BadRequestException(
'No wagon on the selected day can carry this cargo type — please choose another day',
);
}
}
updates.scheduledDate = new Date(dto.scheduledDate);
}
if (dto.estimatedShipmentDate)
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);

View File

@@ -0,0 +1,115 @@
import { ContractBookingService } from './contract-booking.service';
/**
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
* for the cargo itself to change, so a completion payload that restates
* containers must cancel the unpaid invoice, wipe the persisted cargo and
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
* payload without cargo keeps the day-only resubmit behavior.
*/
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
const CONTRACT = {
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
customsClearingEnabled: false,
contractValidUntil: null,
cargoScope: [],
};
const bookingWithCargo = () => ({
id: 'b-1',
contractId: 'c-1',
reference: 'BKG-1',
status: 'OPERATION_CHANGES_REQUESTED',
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
cargoTotalWeightVgm: 80,
originYardId: 'y-o',
destinationYardId: 'y-d',
});
function makeService() {
const bookingsRepository = {
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
deleteContainers: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
};
const invoiceService = {
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
};
const trainSchedulingService = {
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
};
const contractsRepository = {
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
invoiceService as never,
{} as never, // bookingNotifier
{} as never, // dataSource
trainSchedulingService as never,
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return { service, bookingsRepository, invoiceService };
}
// Both paths dead-end into a downstream private assert we replace with a
// sentinel — which path threw tells us which branch the resubmit took.
const SENTINEL = new Error('reached-branch');
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First gate inside the fresh-completion (!hasCargo) path.
jest
.spyOn(
service as never as { assertWithinQuantityCap: () => Promise<void> },
'assertWithinQuantityCap',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
containers: [{ containerSize: '20FT', quantity: 2 }],
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
cargoTotalWeightVgm: 0,
});
});
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First call inside the day-only (hasCargo) resubmit path.
jest
.spyOn(
service as never as {
assertPersistedContainersAvailable: () => Promise<void>;
},
'assertPersistedContainersAvailable',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
});
});

View File

@@ -692,11 +692,30 @@ export class ContractBookingService {
});
const freightType = contract.freightType;
const hasCargo =
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// Operations may return a booking asking for the CARGO to change (fewer or
// more containers), not just the day. A resubmit whose payload restates the
// cargo therefore starts the completion over: cancel the unpaid invoice
// first (it throws if money is already recorded — cargo must not change
// under a paid invoice), then wipe the persisted cargo so the fresh-
// completion path below re-persists, re-prices and re-invoices from the
// payload. A resubmit without cargo keeps today's day-only behavior.
const restatesCargo = Boolean(
dto.containers?.length || dto.bulkLines?.length,
);
if (hasCargo && restatesCargo) {
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
hasCargo = false;
}
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
@@ -1863,6 +1882,9 @@ export class ContractBookingService {
async validateShipment(
contractId: string,
dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: the booking being completed must not clash
// with its own persisted containers.
excludeBookingId?: string,
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
@@ -2024,6 +2046,7 @@ export class ContractBookingService {
originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId,
},
excludeBookingId,
);
containerClashErrors = clashes.map(
(c) =>

View File

@@ -1124,8 +1124,11 @@ export class ContractsController {
validateShipment(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: exclude this booking's own persisted
// containers from the same-train clash check.
@Query('bookingId') bookingId?: string,
) {
return this.contractBookingService.validateShipment(id, dto);
return this.contractBookingService.validateShipment(id, dto, bookingId);
}
@Get(':id/capacity')

View File

@@ -959,7 +959,20 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.customers.verify,
FREIGHT_PERMS.customers.deactivate,
]),
director: dedupe([...ROLE_PERMISSION_PRESETS.director]),
// Director additionally manages train scheduling + rail fleet (same block the
// operation officer/chief hold), on top of the approval-chain role preset.
director: dedupe([
...ROLE_PERMISSION_PRESETS.director,
FREIGHT_PERMS.trainScheduling.view,
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,
]),
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),
djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]),