Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-07 08:09:34 +00:00
79 changed files with 7273 additions and 802 deletions

View File

@@ -72,6 +72,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
FilterWagonCancellationsDto,
RebookCancelledWagonsDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -151,6 +157,7 @@ export class BookingsController {
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
) {}
@Post()
@@ -507,6 +514,150 @@ 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);
}
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
// Customer endpoints are ownership-scoped (no portal permission keys); the
// staff history/void/rebook variants are permission-gated below.
@Post(':id/wagon-cancellations/preview')
@ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' })
async previewWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@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.wagonCancellationService.previewCancellation(id, dto);
}
@Post(':id/wagon-cancellations')
@ApiOperation({
summary:
'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles',
})
async requestWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@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.wagonCancellationService.requestCancellation(id, dto, user?.id);
}
@Get(':id/wagon-cancellations')
@ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' })
async listBookingWagonCancellations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
}
@Get('wagon-cancellations/my')
@ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' })
async listMyWagonCancellations(
@Query() filter: FilterWagonCancellationsDto,
@CurrentUser() user: TCurrentUser,
) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? '');
if (!companyId) throw new ForbiddenException('No customer company for this user.');
return this.wagonCancellationService.list({
companyId,
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Get('wagon-cancellations/history')
@WagonCancellationView()
@ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' })
async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) {
return this.wagonCancellationService.list({
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Post('wagon-cancellations/:cancellationId/withdraw')
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
async withdrawWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
return this.wagonCancellationService.withdraw(cancellationId);
}
@Post('wagon-cancellations/:cancellationId/rebook')
@ApiOperation({
summary:
'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)',
})
async rebookWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@Body() dto: RebookCancelledWagonsDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
}
/** Owner-or-staff gate shared by the per-cancellation actions. */
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
const row = await this.wagonCancellationService.findById(cancellationId);
const booking = await this.bookingsService.findById(row.bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
@Get(':id/customer-trucks')
@MixedAudience([
FREIGHT_PERMS.bookings.view,
@@ -1382,6 +1533,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")
@PortalCustomer()
@ApiOperation({