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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-06 11:43:23 +03:00
committed by GitHub
24 changed files with 310 additions and 263 deletions

View File

@@ -49,6 +49,9 @@ MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
# Preset region so signed URLs are generated locally (no GetBucketLocation
# network call per sign). MinIO's default is us-east-1.
MINIO_REGION=us-east-1
# Redis
REDIS_HOST=localhost

View File

@@ -17,6 +17,10 @@ RUN pnpm dlx turbo prune "@edr/freight-api" --docker
FROM base AS installer
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Puppeteer's bundled Chromium can't run on Alpine (glibc build). Skip its
# download here — the runner installs Alpine's system Chromium instead and we
# point PUPPETEER_EXECUTABLE_PATH at it.
ENV PUPPETEER_SKIP_DOWNLOAD=true
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
--mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
@@ -28,12 +32,26 @@ RUN pnpm turbo build --filter="@edr/freight-api..."
FROM base AS deployer
COPY --from=builder /app/ .
ENV PUPPETEER_SKIP_DOWNLOAD=true
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat
# Chromium + fonts so puppeteer can render contract PDFs (HTML -> PDF). Without
# a working browser the PDF service falls back to an unstyled text-only PDF.
RUN apk add --no-cache \
libc6-compat \
chromium \
nss \
freetype \
harfbuzz \
ttf-freefont \
font-noto \
font-noto-cjk
ENV NODE_ENV=production
# Point puppeteer at the system Chromium and stop it trying to download its own.
ENV PUPPETEER_SKIP_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nestjs

View File

@@ -62,16 +62,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.repository.findOne({ where: { reference } });
}
/** Count bookings created in a specific year. */
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
/**
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
* Includes soft-deleted bookings so the next number clears references that
* still occupy the unique index. (A created-at count drifts below the issued
* sequence after any delete and then collides forever.)
*/
async maxReferenceSequence(year: number): Promise<number> {
const row = await this.repository
.createQueryBuilder('booking')
.where('booking.created_at >= :startDate', { startDate })
.andWhere('booking.created_at < :endDate', { endDate })
.getCount();
.withDeleted()
.select(
"COALESCE(MAX(CAST(SUBSTRING(booking.reference FROM '[0-9]+$') AS int)), 0)",
'max',
)
.where('booking.reference LIKE :prefix', { prefix: `BK-${year}-%` })
.getRawOne<{ max: string | number | null }>();
return Number(row?.max ?? 0);
}
/** Find a booking by reference with files and relations. */

View File

@@ -9,6 +9,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
@@ -207,8 +208,8 @@ export class BookingsService {
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
const seq = await this.bookingsRepository.maxReferenceSequence(year);
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
}
private buildCustomerTruckFreightOrderHtml(
@@ -593,7 +594,6 @@ export class BookingsService {
}
}
const reference = dto.reference || (await this.generateReference());
const containers = dto.containers ?? [];
assertFreightShape({
freightType: dto.freightType,
@@ -688,7 +688,10 @@ export class BookingsService {
// the customer clears it themselves and may name their broker.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const booking = await this.bookingsRepository.create({
// Explicit reference is caller-chosen (a collision is a real conflict);
// auto-generated references retry past a concurrent same-sequence insert.
const insertBooking = (reference: string) =>
this.bookingsRepository.create({
reference,
companyId,
companyProfileId,
@@ -743,7 +746,14 @@ export class BookingsService {
priorityScore: ruleResult.priorityScore,
totalAmount: 0,
paymentStatus: 'PENDING',
});
});
const booking = dto.reference
? await insertBooking(dto.reference)
: await insertWithGeneratedReference(
() => this.generateReference(),
insertBooking,
);
if (dto.freightType === 'CONTAINER') {
await this.bookingsRepository.createContainers(

View File

@@ -48,8 +48,22 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
});
}
/** Total rows — used to mint the next sequential reference. */
async count(): Promise<number> {
return this.repository.count();
/**
* Highest NNNNNN sequence already issued for `SR-…` references (all-time —
* these are not year-scoped). Includes soft-deleted rows so a cancel/delete
* can't make the next number reuse an earlier one. A plain row count drifts
* below the issued sequence after any delete and hands out duplicates.
*/
async maxReferenceSequence(): Promise<number> {
const row = await this.repository
.createQueryBuilder('request')
.withDeleted()
.select(
"COALESCE(MAX(CAST(SUBSTRING(request.reference FROM '[0-9]+$') AS int)), 0)",
'max',
)
.where('request.reference LIKE :prefix', { prefix: 'SR-%' })
.getRawOne<{ max: string | number | null }>();
return Number(row?.max ?? 0);
}
}

View File

@@ -192,8 +192,7 @@ export class BookingRequestService {
}
private async generateReference(): Promise<string> {
const count = await this.repo.count();
const seq = String(count + 1).padStart(6, '0');
return `SR-${seq}`;
const seq = await this.repo.maxReferenceSequence();
return `SR-${String(seq + 1).padStart(6, '0')}`;
}
}

View File

@@ -8,6 +8,7 @@ import {
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -110,7 +111,6 @@ export class ContractBookingService {
const route = await this.resolveRoute(contract, dto.contractRouteId);
const warnings: string[] = [];
const reference = await this.generateReference();
const freightType = contract.freightType;
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
@@ -146,7 +146,11 @@ export class ContractBookingService {
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({
// Retry past a concurrent insert that grabbed the same BK sequence number.
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
@@ -180,7 +184,8 @@ export class ContractBookingService {
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never);
} as never),
);
// Persist container lines + per-unit container numbers (container freight only).
if (freightType === 'CONTAINER') {
@@ -825,8 +830,7 @@ export class ContractBookingService {
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
const seq = String(count + 1).padStart(6, '0');
return `BK-${year}-${seq}`;
const seq = await this.bookingsRepository.maxReferenceSequence(year);
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
}
}

View File

@@ -5,6 +5,7 @@ import {
Logger,
} from '@nestjs/common';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
@@ -611,8 +612,11 @@ export class ContractTransitionService {
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);
const reference = await this.generateRenewalReference();
const renewal = await this.contractsRepository.create({
// Retry past a concurrent insert that grabbed the same CTR sequence number.
const renewal = await insertWithGeneratedReference(
() => this.generateRenewalReference(),
(reference) =>
this.contractsRepository.create({
reference,
companyId: source.companyId,
companyProfileId: source.companyProfileId,
@@ -640,7 +644,8 @@ export class ContractTransitionService {
status: 'RENEWAL_DRAFT',
clearanceStatus: 'NOT_APPLICABLE',
clearanceCycleNumber: 0,
} as never);
} as never),
);
void userId;
return this.contractsService.findById(renewal.id);
@@ -648,7 +653,7 @@ export class ContractTransitionService {
private async generateRenewalReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.contractsRepository.countByYear(year);
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
const seq = await this.contractsRepository.maxReferenceSequence(year);
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
}

View File

@@ -45,16 +45,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
return this.repository.findOne({ where: { reference } });
}
/** Count contracts created in a specific year. */
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
/**
* Highest NNNNN sequence already issued for `CTR-<year>-…` references.
* Includes soft-deleted contracts — their references still occupy the unique
* index, so the next number must move past them. (A created-at count drifts
* below the issued sequence after any delete and then collides forever.)
*/
async maxReferenceSequence(year: number): Promise<number> {
const row = await this.repository
.createQueryBuilder('contract')
.where('contract.created_at >= :startDate', { startDate })
.andWhere('contract.created_at < :endDate', { endDate })
.getCount();
.withDeleted()
.select(
"COALESCE(MAX(CAST(SUBSTRING(contract.reference FROM '[0-9]+$') AS int)), 0)",
'max',
)
.where('contract.reference LIKE :prefix', { prefix: `CTR-${year}-%` })
.getRawOne<{ max: string | number | null }>();
return Number(row?.max ?? 0);
}
/** Find a contract by ID with all child collections, service type, company and files. */

View File

@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
@@ -57,8 +58,8 @@ export class ContractsService {
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.contractsRepository.countByYear(year);
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
const seq = await this.contractsRepository.maxReferenceSequence(year);
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
/** Whether a service type bundles customs clearance. */
@@ -144,8 +145,6 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
const reference = dto.reference || (await this.generateReference());
// Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
@@ -177,7 +176,61 @@ export class ContractsService {
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const contract = await this.contractsRepository.create({
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
// concurrent insert that grabbed the same sequence number.
const contract = dto.reference
? await this.insertContract(dto.reference, {
companyId,
companyProfileId,
isGovernment,
includesCustoms,
dto,
})
: await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.insertContract(reference, {
companyId,
companyProfileId,
isGovernment,
includesCustoms,
dto,
}),
);
await this.persistRoutes(contract.id, dto.routes);
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
if (files.length > 0) {
try {
await this.filesService.uploadMany(contract.id, 'contracts', files);
} catch {
warnings.push('File upload failed — contract was created without attached files.');
}
}
// Attach the company profile's onboarding / business-license documents to the
// contract by reference. The separate "Documents" intake step was removed —
// the profile documents are simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
/** Insert one DRAFT contract row with the given reference (no children). */
private insertContract(
reference: string,
ctx: {
companyId: string | null | undefined;
companyProfileId: string | null;
isGovernment: boolean;
includesCustoms: boolean;
dto: CreateContractDto;
},
): Promise<Contract> {
const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx;
return this.contractsRepository.create({
reference,
companyId: companyId ?? null,
companyProfileId,
@@ -205,24 +258,6 @@ export class ContractsService {
clearanceStatus: 'NOT_APPLICABLE',
clearanceCycleNumber: 0,
} as never);
await this.persistRoutes(contract.id, dto.routes);
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
if (files.length > 0) {
try {
await this.filesService.uploadMany(contract.id, 'contracts', files);
} catch {
warnings.push('File upload failed — contract was created without attached files.');
}
}
// Attach the company profile's onboarding / business-license documents to the
// contract by reference. The separate "Documents" intake step was removed —
// the profile documents are simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
/**

View File

@@ -7,4 +7,9 @@ export const minioConfig = registerAs("minio", () => ({
accessKey: process.env.MINIO_ACCESS_KEY || "",
secretKey: process.env.MINIO_SECRET_KEY || "",
bucket: process.env.MINIO_BUCKET || "fhc",
// Preset the region so presignedGetObject signs URLs locally. Without it the
// minio client fires a live GetBucketLocation request to the endpoint on every
// sign — which blocks (no timeout) when MinIO is slow/unreachable and hangs
// API responses that reload a booking's files (e.g. staff accept).
region: process.env.MINIO_REGION || "us-east-1",
}));

View File

@@ -29,6 +29,9 @@ export class MinioService {
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey,
// Presetting the region keeps presignedGetObject fully local — no live
// GetBucketLocation round-trip to the endpoint on each signed URL.
region: config.region,
});
}
@@ -108,8 +111,11 @@ export class MinioService {
try {
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
} catch (error) {
// Signing a file URL must never break a booking/transition response — the
// caller only needs SOMETHING to link to. Degrade to the public object URL
// and log, rather than throwing (which would 500 an otherwise-good load).
this.logger.error(`Failed to generate signed URL for ${objectName}:`, error);
throw error;
return this.getPublicUrl(objectName);
}
}
}

View File

@@ -474,7 +474,8 @@ const isClearanceItem = (item: SidebarItem): boolean =>
/**
* Keep only items the user is permitted to see; drop now-empty sections.
*
* Position-scoped visibility (super_admin bypasses all of this):
* Position-scoped visibility (super_admin sees everything):
* - Super Admin → sees all items (all permissions pass, all tabs visible)
* - Ethiopian GL → sees ONLY the ET document-clearance page.
* - Djibouti GL → sees ONLY the DJ clearance page.
* - Everyone else → sees everything they have permission for, EXCEPT the two
@@ -484,9 +485,11 @@ const filterSidebarByPermission = (
sections: SidebarSection[],
user: ReturnType<typeof useAuth>["user"],
): SidebarSection[] => {
const superAdmin = isSuperAdmin(user);
const etGl = !superAdmin && isEthiopianGl(user);
const djGl = !superAdmin && isDjiboutiGl(user);
// Superadmin sees every section and item — no permission filtering.
if (isSuperAdmin(user)) return sections;
const etGl = isEthiopianGl(user);
const djGl = isDjiboutiGl(user);
const permissionAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
@@ -497,8 +500,6 @@ const filterSidebarByPermission = (
};
const itemAllowed = (item: SidebarItem): boolean => {
if (superAdmin) return true;
// GL positions are locked to their single clearance page.
if (etGl) return isEtClearanceItem(item);
if (djGl) return isDjClearanceItem(item);

View File

@@ -122,7 +122,14 @@ function computeImportActiveStep(
if (!clearance.gatepassGranted) return 8;
if (!t1Uploaded && !clearance.t1?.closed) return 9;
if (!clearance.t1?.closed) return 10;
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 11;
// Risk is "assigned" when the booking milestone says so OR the clearance view
// already carries a riskLevel. The ET page derives its bookingMilestones from a
// separately-fetched booking id that can lag or mismatch the booking carrying
// the milestone — `clearance.riskLevel` is server truth and matches the badge.
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
if (!riskAssigned) return 11;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
@@ -229,6 +236,13 @@ export function PhasedClearanceActionPanel({
const actionBookingId =
clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null;
const t1Uploaded = t1FilesFromWorkflow(workflowFiles).length > 0;
// Risk is assigned when the clearance view carries a riskLevel (server truth,
// drives the badge) OR the fetched booking milestone confirms it. Kept in sync
// with computeImportActiveStep so the stepper never freezes on a page whose
// bookingMilestones lag/mismatch the booking that holds the milestone.
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
const activeStep = useMemo(
() =>
isImport
@@ -595,7 +609,7 @@ export function PhasedClearanceActionPanel({
label="Customs risk"
description="GL Ethiopia assigns Green / Yellow / Red"
icon={
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED") ? (
riskAssigned ? (
<CheckCircle2 size={14} />
) : (
<ShieldAlert size={14} />
@@ -606,7 +620,7 @@ export function PhasedClearanceActionPanel({
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
done={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
done={riskAssigned}
onChanged={onChanged}
/>
</Stepper.Step>
@@ -620,7 +634,7 @@ export function PhasedClearanceActionPanel({
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
riskAssigned={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
riskAssigned={riskAssigned}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}

View File

@@ -44,7 +44,7 @@ export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { view, viewer } = useFileViewer();
const { data: contract } = useContractDetail(id);
const { data: contract, refetch: refetchContract } = useContractDetail(id);
const {
data: clearance,
isLoading,
@@ -250,6 +250,7 @@ export default function ContractClearanceDetailPage() {
phasedCustoms={phasedCustoms}
onChanged={() => {
void refetch();
void refetchContract();
void refetchBookingMilestones();
}}
/>

View File

@@ -526,23 +526,13 @@ export default function NewContractPage({
},
];
// Routespure origin→destination lanes, no quantity. Route #1 is primary;
// extras only apply to GENERAL contracts.
// Route — a single origin→destination lane, general contracts included.
const routes: Freight.CreateContractRouteInputDto[] = [
{
originYardId: data.originYard,
destinationYardId: data.destinationYard,
sortOrder: 0,
},
...(isGeneral
? (data.extraRoutes ?? [])
.filter((r) => r.originYard && r.destinationYard)
.map((r, i) => ({
originYardId: r.originYard,
destinationYardId: r.destinationYard,
sortOrder: i + 1,
}))
: []),
];
return {

View File

@@ -52,13 +52,9 @@ export function contractToFormValues(
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
// Contracts carry a single route now; older multi-route GENERAL contracts
// load only their primary route.
const primaryRoute = routes[0];
const extraRoutes = isGeneral
? routes.slice(1).map((r) => ({
originYard: r.originYardId,
destinationYard: r.destinationYardId,
}))
: [];
const scope = contract.cargoScope ?? [];
@@ -131,7 +127,6 @@ export function contractToFormValues(
originYard: primaryRoute?.originYardId ?? "",
destinationYard: primaryRoute?.destinationYardId ?? "",
extraRoutes,
documents: {},
};

View File

@@ -73,7 +73,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{
{
value: "general_contract",
label: "General Contract",
description: "Ship multiple times over the validity window across routes.",
description: "Ship multiple times over the validity window on one route.",
},
];
@@ -107,9 +107,9 @@ export const CONTAINER_SIZES = ["20ft", "40ft"] as const;
export type ContainerSize = (typeof CONTAINER_SIZES)[number];
// A GENERAL-contract quantity cap. The Mantine NumberInput backing these fields
// can briefly emit "" / undefined / NaN (cleared or never-touched field); those
// all mean "uncapped", so coerce them to 0 before the >= 0 check rather than
// letting them fail validation and silently block the Cargo & Route step.
// can briefly emit "" / undefined / NaN (cleared or never-touched field);
// coerce those to 0 so the superRefine below can flag them with a clear
// "greater than 0" message instead of a type error.
const nonNegativeQuantityCap = z.preprocess(
(v) =>
v === "" || v === null || v === undefined || Number.isNaN(v) ? 0 : v,
@@ -167,34 +167,23 @@ export const contractFormSchema = z
// contract_cargo_scope row.
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
// GENERAL only: per-size container quantity cap (total bookable over the
// validity window). Keyed by size; 0/undefined = uncapped. The NumberInput
// can momentarily hold "" / undefined (empty field) — coerce those to 0 so
// an untouched cap never blocks the step.
// validity window). Keyed by size; must be > 0 for every enabled size
// (enforced in the superRefine below).
containerSizeCaps: z
.record(z.string(), nonNegativeQuantityCap)
.default({}),
// Bulk scope: the cargo type path (group → commodity).
cargoTypePath: z.array(z.string()).default([]),
cargoFreeText: z.string().default(""),
// GENERAL only: total bulk tons/items bookable. 0 = uncapped. Same empty-
// field coercion as the container caps above.
// GENERAL only: total bulk tons/items bookable; must be > 0 (superRefine).
bulkQuantityCap: nonNegativeQuantityCap.default(0),
// Contract-level billing flags.
isHazardous: z.boolean().default(false),
isRefrigerated: z.boolean().default(false),
// ── Route ──
// ── Route ── (one route per contract — general contracts included)
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
// Additional routes for a GENERAL contract (route #1 is the primary above).
extraRoutes: z
.array(
z.object({
originYard: z.string().default(""),
destinationYard: z.string().default(""),
}),
)
.default([]),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string().default(""),
@@ -260,6 +249,29 @@ export const contractFormSchema = z
});
}
}
// GENERAL contracts must carry a real (> 0) quantity cap — an untouched
// NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the
// Cargo & Route step until the customer enters a quantity.
if (data.contractKind === "general_contract") {
if (data.cargoType === "container") {
for (const size of data.enabledContainerSizes) {
if (!(data.containerSizeCaps[size] > 0)) {
ctx.addIssue({
code: "custom",
path: ["containerSizeCaps", size],
message: `Enter a ${size} quantity greater than 0.`,
});
}
}
}
if (data.cargoType === "bulk" && !(data.bulkQuantityCap > 0)) {
ctx.addIssue({
code: "custom",
path: ["bulkQuantityCap"],
message: "Enter a total quantity greater than 0.",
});
}
}
});
export type ContractFormValues = z.infer<typeof contractFormSchema>;
@@ -289,7 +301,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
originYard: "",
destinationYard: "",
extraRoutes: [],
documents: {},
notes: "",
@@ -325,7 +336,6 @@ export const contractStepFields: Record<
"isRefrigerated",
"originYard",
"destinationYard",
"extraRoutes",
],
// Step 2 — Review & Submit. (The separate Documents step was removed — the
// company profile documents are attached to the contract automatically.)

View File

@@ -132,19 +132,12 @@ export function Step1ContractType({
);
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
// ── Route (primary + extras) ──
// ── Route (single route per contract) ──
const routes = contract.routes ?? [];
if (routes[0]) {
form.setValue("originYard", routes[0].originYardId);
form.setValue("destinationYard", routes[0].destinationYardId);
}
form.setValue(
"extraRoutes",
routes.slice(1).map((r) => ({
originYard: r.originYardId,
destinationYard: r.destinationYardId,
})),
);
// ── Cargo scope ──
form.setValue(

View File

@@ -227,14 +227,14 @@ export function Step3CargoScope({
{/* GENERAL contract quantity cap (draw-down ceiling). */}
{isGeneral && (
<Box>
<StepLabel>Booking quantity cap (optional)</StepLabel>
<StepLabel>Booking quantity cap *</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
Total quantity bookable across all shipments under this contract.
Customers / GL can book repeatedly until it is reached. Leave 0 for
unlimited.
Customers / GL can book repeatedly until it is reached. Must be
greater than 0.
</Text>
{cargoType === "container" ? (
<Group gap={12} grow>
<Group gap={12} grow align="flex-start">
{enabledSizes.length === 0 ? (
<Text fz={13} c="dimmed">
Select container sizes above to set their caps.
@@ -245,13 +245,14 @@ export function Step3CargoScope({
key={size}
name={`containerSizeCaps.${size}`}
control={form.control}
render={({ field }) => (
render={({ field, fieldState }) => (
<NumberInput
label={`${size} cap (containers)`}
placeholder="0 = unlimited"
label={`${size} cap (containers) *`}
placeholder="e.g. 100"
min={0}
value={Number(field.value ?? 0)}
onChange={(v) => field.onChange(Number(v) || 0)}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
@@ -264,13 +265,14 @@ export function Step3CargoScope({
<Controller
name="bulkQuantityCap"
control={form.control}
render={({ field }) => (
render={({ field, fieldState }) => (
<NumberInput
label="Total cap (tons / items)"
placeholder="0 = unlimited"
label="Total cap (tons / items) *"
placeholder="e.g. 500"
min={0}
value={Number(field.value ?? 0)}
onChange={(v) => field.onChange(Number(v) || 0)}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>

View File

@@ -1,12 +1,8 @@
import type { Freight } from "@edr/types";
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { MapPin, Plus, Trash2 } from "lucide-react";
import { Skeleton, Stack } from "@mantine/core";
import { MapPin } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import {
Controller,
useFieldArray,
type UseFormReturn,
} from "react-hook-form";
import { Controller, type UseFormReturn } from "react-hook-form";
import { ContractFormInputValues, type ContractFormValues } from "./schema";
import { getRouteDirection } from "./helpers";
import { SelectField, StepLabel } from "./shared";
@@ -29,7 +25,6 @@ export function Step4Route({
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType");
const isGeneralContract = form.watch("contractKind") === "general_contract";
const { originCountry, destinationCountry } = useMemo(() => {
switch (operationType) {
@@ -46,14 +41,6 @@ export function Step4Route({
}
}, [operationType]);
const {
fields: extraRoutes,
append: appendRoute,
remove: removeRoute,
} = useFieldArray({ control: form.control, name: "extraRoutes" });
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
@@ -96,22 +83,6 @@ export function Step4Route({
}
}, [destinationCountry, dest, form]);
useEffect(() => {
watchedExtraRoutes.forEach((route, i) => {
const ro = referenceData?.yard.find((y) => y.id === route?.originYard);
if (originCountry && ro && ro.country !== originCountry) {
form.setValue(`extraRoutes.${i}.originYard`, "");
}
const rd = referenceData?.yard.find(
(y) => y.id === route?.destinationYard,
);
if (destinationCountry && rd && rd.country !== destinationCountry) {
form.setValue(`extraRoutes.${i}.destinationYard`, "");
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originCountry, destinationCountry, referenceData, form]);
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
@@ -173,94 +144,6 @@ export function Step4Route({
</div>
)}
{isGeneralContract && !isLoading && (
<Box mt={18}>
<Group justify="space-between" align="center" mb={8}>
<StepLabel>Additional contract routes</StepLabel>
<Button
variant="light"
color="edr-green"
size="xs"
radius="md"
leftSection={<Plus size={14} />}
disabled={stationSelectDisabled}
onClick={() =>
appendRoute({ originYard: "", destinationYard: "" })
}
>
Add route
</Button>
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
A general contract can cover several routes. The route above is your
primary route; add more origindestination routes the contract
should cover.
</Text>
<Stack gap={12}>
{extraRoutes.map((rf, i) => {
const rowOrigin = watchedExtraRoutes[i]?.originYard ?? "";
const rowDestination =
watchedExtraRoutes[i]?.destinationYard ?? "";
const rowOriginData = yardsForSide(originCountry, rowDestination);
const rowDestData = yardsForSide(destinationCountry, rowOrigin);
return (
<Group
key={rf.id}
gap={10}
align="flex-start"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.originYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin"
placeholder="Origin..."
disabled={stationSelectDisabled}
data={rowOriginData}
/>
)}
/>
</Box>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.destinationYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination"
placeholder="Destination..."
disabled={stationSelectDisabled}
data={rowDestData}
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"
size="xs"
mt={24}
px={6}
onClick={() => removeRoute(i)}
>
<Trash2 size={16} />
</Button>
</Group>
);
})}
</Stack>
</Box>
)}
</Stack>
);
}

View File

@@ -250,10 +250,6 @@ export function Step8Review({
? direction.charAt(0) + direction.slice(1).toLowerCase()
: "—";
const routesCount = 1 + (values.extraRoutes?.filter(
(r) => r.originYard && r.destinationYard,
).length ?? 0);
return (
<Stack gap="lg">
<StepHeader
@@ -335,17 +331,13 @@ export function Step8Review({
/>
<SummaryItem
icon={<Route size={18} />}
label="Primary route"
label="Route"
value={`${originYardName}${destinationYardName}`}
/>
<SummaryItem
icon={<MapPin size={18} />}
label="Trade direction"
value={
isGeneralContract
? `${directionLabel} · ${routesCount} routes`
: directionLabel
}
value={directionLabel}
/>
<SummaryItem
icon={<Package size={18} />}

View File

@@ -20,3 +20,6 @@ export * from "./repositories/base.repository";
// Services
export * from "./services/exchange";
// Utils
export * from "./utils/reference-sequence";

View File

@@ -0,0 +1,50 @@
import { QueryFailedError } from "typeorm";
/** Postgres unique-violation SQLSTATE. */
const PG_UNIQUE_VIOLATION = "23505";
/**
* True when `error` is a Postgres unique-constraint violation. Used to detect a
* reference-number collision from a concurrent insert so the caller can retry
* with a freshly-computed number instead of surfacing a 500.
*/
export function isUniqueViolation(error: unknown): boolean {
if (!(error instanceof QueryFailedError)) return false;
const driver = (
error as QueryFailedError & { driverError?: { code?: string } }
).driverError;
return driver?.code === PG_UNIQUE_VIOLATION;
}
/**
* Run `insert(reference)` under a "generate → try → retry on collision" loop.
*
* `MAX(sequence) + 1` alone is not concurrency-safe: two requests can read the
* same max and derive the same reference, and one insert then hits the unique
* index. On that collision we recompute the reference and try again, so the
* sequence advances under load instead of throwing. Non-collision errors (and
* exhausting the attempt budget) propagate unchanged.
*
* @param generate Async producer of the next reference (e.g. `CTR-2026-00033`).
* Re-invoked on each attempt so it re-reads the current max.
* @param insert Performs the insert with the given reference; its result is
* returned on success.
* @param attempts Maximum tries before giving up (default 5).
*/
export async function insertWithGeneratedReference<T>(
generate: () => Promise<string>,
insert: (reference: string) => Promise<T>,
attempts = 5,
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt++) {
const reference = await generate();
try {
return await insert(reference);
} catch (error) {
if (!isUniqueViolation(error)) throw error;
lastError = error;
}
}
throw lastError;
}