This commit is contained in:
Marshal
2026-08-19 08:41:51 +00:00
parent f4a654f273
commit 13f66dfb66
15 changed files with 249 additions and 16 deletions

View File

@@ -12,7 +12,7 @@
* humanized handler name where a route has none.
*
* Excludes the AI Assist and Account entities.
* Generated from the controllers under src/ — 512 endpoints.
* Generated from the controllers under src/ — 517 endpoints.
*/
/** [title, method, entity] for one auditable route. */
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
@@ -102,6 +102,9 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"],
"POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"],
// Chat
"POST /api/chat/sync": ["Re-run the chat room/membership reconcile immediately (normally nightly)", "POST", "Chat"],
// Company
"POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"],
"POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"],
@@ -129,6 +132,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
"PATCH /api/companies/identity/poa-declared": ["Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified.", "PATCH", "Company"],
"POST /api/companies/onboarding/revert-to-etrade": ["Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade", "POST", "Company"],
// Compliance
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
@@ -256,6 +260,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/invoices/:id/eims/cancel": ["Cancel the invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
// Exchange Setting
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],

View File

@@ -940,8 +940,8 @@ export class BookingsController {
@Get('clearance/et-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
getBookingEtClearanceQueue() {
return this.bookingClearanceService.etQueue();
getBookingEtClearanceQueue(@CurrentUser() user: unknown) {
return this.bookingClearanceService.etQueue(user);
}
@Get('clearance/dj-queue')

View File

@@ -32,11 +32,14 @@ function makeService(overrides?: {
workflowThrows?: boolean;
/** Resolve the input doc set with no required fields → every doc counts approved. */
docsApproved?: boolean;
/** Yard ids the caller is scoped to; `null` (default) = unrestricted. */
yardScope?: string[] | null;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(booking),
findByStatuses: jest.fn().mockResolvedValue([]),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
@@ -111,6 +114,7 @@ function makeService(overrides?: {
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
);
return {
@@ -124,6 +128,30 @@ function makeService(overrides?: {
}
describe('BookingClearanceService', () => {
describe('etQueue yard scope', () => {
const queueBookings = [
{ ...generalImportBooking, id: 'b-mojo-out', originYardId: 'mojo', destinationYardId: 'dire' },
{ ...generalImportBooking, id: 'b-mojo-in', originYardId: 'addis', destinationYardId: 'mojo' },
{ ...generalImportBooking, id: 'b-elsewhere', originYardId: 'addis', destinationYardId: 'dire' },
] as unknown as Booking[];
it('keeps only bookings whose origin or destination is in scope', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: ['mojo'] });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows.map((b) => b.id)).toEqual(['b-mojo-out', 'b-mojo-in']);
});
it('shows everything when the position has no yard mapping', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: null });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows).toHaveLength(3);
});
});
describe('adviseDuty', () => {
it('skips duty milestones when duty is not required', async () => {
const { service, workflowService, bookingsRepository } = makeService();

View File

@@ -30,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
@@ -158,6 +159,7 @@ export class BookingClearanceService {
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
private readonly yardScope: YardScopeService,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -981,7 +983,7 @@ export class BookingClearanceService {
return this.bookingsService.findById(bookingId);
}
async etQueue(): Promise<Booking[]> {
async etQueue(user?: unknown): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
]);
@@ -991,7 +993,26 @@ export class BookingClearanceService {
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
return this.attachContractSummary(filtered);
const rows = await this.attachContractSummary(filtered);
return this.narrowToYardScope(rows, user);
}
/**
* Keep only bookings whose ORIGIN or DESTINATION yard is one of the caller's
* assigned yards (`freight.yard_positions` via the active position). Yards in
* the middle of a route do not count. An unmapped position, super admin or
* `yards:view_all` holder sees everything (scope resolves to `null`).
* Runs after {@link attachContractSummary} so route-fallback yards count too.
*/
private async narrowToYardScope(bookings: Booking[], user: unknown): Promise<Booking[]> {
const scope = await this.yardScope.getScopedYardIds(user as never);
if (scope === null) return bookings;
const inScope = (id: string | null | undefined) => !!id && scope.includes(id);
return bookings.filter(
(b) =>
inScope(b.originYardId ?? b.originYard?.id) ||
inScope(b.destinationYardId ?? b.destinationYard?.id),
);
}
/**

View File

@@ -30,7 +30,8 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
async findQueue(): Promise<BookingRequest[]> {
return this.repository.find({
order: { createdAt: 'DESC' },
relations: { contract: { company: true } },
// `routes` rides along so the queue can be narrowed to the caller's yards.
relations: { contract: { company: true, routes: true } },
});
}

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service';
@@ -28,6 +29,7 @@ export class BookingRequestService {
private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService,
private readonly notifier: ContractNotifierService,
private readonly yardScope: YardScopeService,
) {}
/**
@@ -168,8 +170,25 @@ export class BookingRequestService {
return request;
}
queue(): Promise<BookingRequest[]> {
return this.repo.findQueue();
/**
* GL queue narrowed to the caller's yards: a request stays when its route's
* ORIGIN or DESTINATION yard is one the caller's active position is mapped to
* (unmapped position / super admin → everything). A request with no
* resolvable route (no `contractRouteId` on a multi-route contract) has no
* yards to judge by and is kept visible.
*/
async queue(user?: unknown): Promise<BookingRequest[]> {
const rows = await this.repo.findQueue();
const scope = await this.yardScope.getScopedYardIds(user as never);
if (scope === null) return rows;
return rows.filter((r) => {
const routes = r.contract?.routes ?? [];
const route =
routes.find((x) => x.id === r.contractRouteId) ??
(routes.length === 1 ? routes[0] : undefined);
if (!route) return true;
return scope.includes(route.originYardId) || scope.includes(route.destinationYardId);
});
}
private async findPending(requestId: string): Promise<BookingRequest> {

View File

@@ -123,8 +123,8 @@ export class ContractsController {
@Get('booking-requests/queue')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
bookingRequestQueue() {
return this.bookingRequestService.queue();
bookingRequestQueue(@CurrentUser() user: AuthUserPayload) {
return this.bookingRequestService.queue(user);
}
@Get('booking-requests/:reqId')

View File

@@ -42,6 +42,7 @@ import { TrainBuilderService } from './train-builder.service';
FREIGHT_PERMS.trains.delete,
FREIGHT_PERMS.trains.changeLocomotives,
FREIGHT_PERMS.trains.changeYard,
FREIGHT_PERMS.trains.changeWagonYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
])
@@ -107,6 +108,26 @@ export class TrainBuilderController {
return this.trainBuilderService.setYard(id, dto.currentYardId);
}
@Patch(':id/wagons/:wagonId/yard')
@FleetManage(FREIGHT_PERMS.trains.changeWagonYard)
@ApiOperation({
summary:
'Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated',
})
setWagonYard(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@Body() dto: UpdateTrainYardDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.setWagonYard(
id,
wagonId,
dto.currentYardId,
resolveAuthUserId(user),
);
}
@Post(':id/wagons')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })

View File

@@ -514,6 +514,42 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Move ONE coupled wagon to another yard (the train and the rest of the
* consist stay put). Refused while any live (DRAFT/SCHEDULED/DISPATCHED)
* schedule has the wagon allocated to a slot — its standing yard is part of
* that schedule's route validation. Ledger row mirrors `setYard`.
*/
async setWagonYard(id: string, wagonId: string, currentYardId: string, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
if (wagon.currentYardId === currentYardId) return;
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is allocated to a scheduled or dispatched run; its yard cannot be changed`,
);
}
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: wagon.currentYardId ?? null,
toYardId: yard.id,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
occurredAt: new Date(),
}),
);
});
return this.getComposition(id);
}
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {