mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
Merge pull request #1145 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user