mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
refactor contract handling to support single route per contract and improve reference generation logic
This commit is contained in:
@@ -49,6 +49,9 @@ MINIO_PORT=9000
|
|||||||
MINIO_USE_SSL=false
|
MINIO_USE_SSL=false
|
||||||
MINIO_ACCESS_KEY=
|
MINIO_ACCESS_KEY=
|
||||||
MINIO_SECRET_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
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ RUN pnpm dlx turbo prune "@edr/freight-api" --docker
|
|||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
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 \
|
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
@@ -28,12 +32,26 @@ RUN pnpm turbo build --filter="@edr/freight-api..."
|
|||||||
|
|
||||||
FROM base AS deployer
|
FROM base AS deployer
|
||||||
COPY --from=builder /app/ .
|
COPY --from=builder /app/ .
|
||||||
|
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
||||||
|
|
||||||
FROM node:24.15.0-alpine AS runner
|
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
|
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
|
WORKDIR /app
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||||
|
|||||||
@@ -45,16 +45,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
return this.repository.findOne({ where: { reference } });
|
return this.repository.findOne({ where: { reference } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Count contracts created in a specific year. */
|
/**
|
||||||
async countByYear(year: number): Promise<number> {
|
* Highest NNNNN sequence already issued for `CTR-<year>-…` references.
|
||||||
const startDate = new Date(year, 0, 1);
|
* Includes soft-deleted contracts — their references still occupy the unique
|
||||||
const endDate = new Date(year + 1, 0, 1);
|
* 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.)
|
||||||
return this.repository
|
*/
|
||||||
|
async maxReferenceSequence(year: number): Promise<number> {
|
||||||
|
const row = await this.repository
|
||||||
.createQueryBuilder('contract')
|
.createQueryBuilder('contract')
|
||||||
.where('contract.created_at >= :startDate', { startDate })
|
.withDeleted()
|
||||||
.andWhere('contract.created_at < :endDate', { endDate })
|
.select(
|
||||||
.getCount();
|
"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. */
|
/** Find a contract by ID with all child collections, service type, company and files. */
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ export class ContractsService {
|
|||||||
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||||||
private async generateReference(): Promise<string> {
|
private async generateReference(): Promise<string> {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const count = await this.contractsRepository.countByYear(year);
|
const seq = await this.contractsRepository.maxReferenceSequence(year);
|
||||||
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
|
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a service type bundles customs clearance. */
|
/** Whether a service type bundles customs clearance. */
|
||||||
@@ -144,8 +144,6 @@ export class ContractsService {
|
|||||||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||||
|
|
||||||
const reference = dto.reference || (await this.generateReference());
|
|
||||||
|
|
||||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
// Stamp the operational profile (importer/exporter) for portal scoping.
|
||||||
let companyProfileId: string | null = null;
|
let companyProfileId: string | null = null;
|
||||||
if (!isGovernment && companyId) {
|
if (!isGovernment && companyId) {
|
||||||
@@ -177,34 +175,28 @@ export class ContractsService {
|
|||||||
// Customs clearing is owned by the service type, not the customer.
|
// Customs clearing is owned by the service type, not the customer.
|
||||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
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
|
||||||
reference,
|
// conflict and should surface. Auto-generated references retry past a
|
||||||
companyId: companyId ?? null,
|
// concurrent insert that grabbed the same sequence number.
|
||||||
companyProfileId,
|
const contract = dto.reference
|
||||||
isGovernment,
|
? await this.insertContract(dto.reference, {
|
||||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
companyId,
|
||||||
contractKind: dto.contractKind,
|
companyProfileId,
|
||||||
renewalOfId: dto.renewalOfId ?? null,
|
isGovernment,
|
||||||
tradeDirection: dto.tradeDirection,
|
includesCustoms,
|
||||||
freightType: dto.freightType,
|
dto,
|
||||||
serviceTypeId: dto.serviceTypeId,
|
})
|
||||||
paymentCurrency: dto.paymentCurrency,
|
: await insertWithGeneratedReference(
|
||||||
customsClearingEnabled: includesCustoms,
|
() => this.generateReference(),
|
||||||
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
(reference) =>
|
||||||
equipmentReturn: dto.equipmentReturn ?? null,
|
this.insertContract(reference, {
|
||||||
firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
|
companyId,
|
||||||
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
companyProfileId,
|
||||||
firstMilePickupLng: dto.firstMilePickupLng ?? null,
|
isGovernment,
|
||||||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
|
includesCustoms,
|
||||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
dto,
|
||||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
}),
|
||||||
isHazardous: dto.isHazardous ?? false,
|
);
|
||||||
isReefer: dto.isReefer ?? false,
|
|
||||||
contractType: dto.contractType ?? null,
|
|
||||||
status: 'DRAFT',
|
|
||||||
clearanceStatus: 'NOT_APPLICABLE',
|
|
||||||
clearanceCycleNumber: 0,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
await this.persistRoutes(contract.id, dto.routes);
|
await this.persistRoutes(contract.id, dto.routes);
|
||||||
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
|
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ export const minioConfig = registerAs("minio", () => ({
|
|||||||
accessKey: process.env.MINIO_ACCESS_KEY || "",
|
accessKey: process.env.MINIO_ACCESS_KEY || "",
|
||||||
secretKey: process.env.MINIO_SECRET_KEY || "",
|
secretKey: process.env.MINIO_SECRET_KEY || "",
|
||||||
bucket: process.env.MINIO_BUCKET || "fhc",
|
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",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export class MinioService {
|
|||||||
useSSL: config.useSSL,
|
useSSL: config.useSSL,
|
||||||
accessKey: config.accessKey,
|
accessKey: config.accessKey,
|
||||||
secretKey: config.secretKey,
|
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 {
|
try {
|
||||||
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
|
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
|
||||||
} catch (error) {
|
} 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);
|
this.logger.error(`Failed to generate signed URL for ${objectName}:`, error);
|
||||||
throw error;
|
return this.getPublicUrl(objectName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -526,23 +526,13 @@ export default function NewContractPage({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Routes — pure origin→destination lanes, no quantity. Route #1 is primary;
|
// Route — a single origin→destination lane, general contracts included.
|
||||||
// extras only apply to GENERAL contracts.
|
|
||||||
const routes: Freight.CreateContractRouteInputDto[] = [
|
const routes: Freight.CreateContractRouteInputDto[] = [
|
||||||
{
|
{
|
||||||
originYardId: data.originYard,
|
originYardId: data.originYard,
|
||||||
destinationYardId: data.destinationYard,
|
destinationYardId: data.destinationYard,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
},
|
},
|
||||||
...(isGeneral
|
|
||||||
? (data.extraRoutes ?? [])
|
|
||||||
.filter((r) => r.originYard && r.destinationYard)
|
|
||||||
.map((r, i) => ({
|
|
||||||
originYardId: r.originYard,
|
|
||||||
destinationYardId: r.destinationYard,
|
|
||||||
sortOrder: i + 1,
|
|
||||||
}))
|
|
||||||
: []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -52,13 +52,9 @@ export function contractToFormValues(
|
|||||||
const routes = [...(contract.routes ?? [])].sort(
|
const routes = [...(contract.routes ?? [])].sort(
|
||||||
(a, b) => a.sortOrder - b.sortOrder,
|
(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 primaryRoute = routes[0];
|
||||||
const extraRoutes = isGeneral
|
|
||||||
? routes.slice(1).map((r) => ({
|
|
||||||
originYard: r.originYardId,
|
|
||||||
destinationYard: r.destinationYardId,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const scope = contract.cargoScope ?? [];
|
const scope = contract.cargoScope ?? [];
|
||||||
|
|
||||||
@@ -131,7 +127,6 @@ export function contractToFormValues(
|
|||||||
|
|
||||||
originYard: primaryRoute?.originYardId ?? "",
|
originYard: primaryRoute?.originYardId ?? "",
|
||||||
destinationYard: primaryRoute?.destinationYardId ?? "",
|
destinationYard: primaryRoute?.destinationYardId ?? "",
|
||||||
extraRoutes,
|
|
||||||
|
|
||||||
documents: {},
|
documents: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{
|
|||||||
{
|
{
|
||||||
value: "general_contract",
|
value: "general_contract",
|
||||||
label: "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];
|
export type ContainerSize = (typeof CONTAINER_SIZES)[number];
|
||||||
|
|
||||||
// A GENERAL-contract quantity cap. The Mantine NumberInput backing these fields
|
// A GENERAL-contract quantity cap. The Mantine NumberInput backing these fields
|
||||||
// can briefly emit "" / undefined / NaN (cleared or never-touched field); those
|
// can briefly emit "" / undefined / NaN (cleared or never-touched field);
|
||||||
// all mean "uncapped", so coerce them to 0 before the >= 0 check rather than
|
// coerce those to 0 so the superRefine below can flag them with a clear
|
||||||
// letting them fail validation and silently block the Cargo & Route step.
|
// "greater than 0" message instead of a type error.
|
||||||
const nonNegativeQuantityCap = z.preprocess(
|
const nonNegativeQuantityCap = z.preprocess(
|
||||||
(v) =>
|
(v) =>
|
||||||
v === "" || v === null || v === undefined || Number.isNaN(v) ? 0 : v,
|
v === "" || v === null || v === undefined || Number.isNaN(v) ? 0 : v,
|
||||||
@@ -167,34 +167,23 @@ export const contractFormSchema = z
|
|||||||
// contract_cargo_scope row.
|
// contract_cargo_scope row.
|
||||||
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
|
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
|
||||||
// GENERAL only: per-size container quantity cap (total bookable over the
|
// GENERAL only: per-size container quantity cap (total bookable over the
|
||||||
// validity window). Keyed by size; 0/undefined = uncapped. The NumberInput
|
// validity window). Keyed by size; must be > 0 for every enabled size
|
||||||
// can momentarily hold "" / undefined (empty field) — coerce those to 0 so
|
// (enforced in the superRefine below).
|
||||||
// an untouched cap never blocks the step.
|
|
||||||
containerSizeCaps: z
|
containerSizeCaps: z
|
||||||
.record(z.string(), nonNegativeQuantityCap)
|
.record(z.string(), nonNegativeQuantityCap)
|
||||||
.default({}),
|
.default({}),
|
||||||
// Bulk scope: the cargo type path (group → commodity).
|
// Bulk scope: the cargo type path (group → commodity).
|
||||||
cargoTypePath: z.array(z.string()).default([]),
|
cargoTypePath: z.array(z.string()).default([]),
|
||||||
cargoFreeText: z.string().default(""),
|
cargoFreeText: z.string().default(""),
|
||||||
// GENERAL only: total bulk tons/items bookable. 0 = uncapped. Same empty-
|
// GENERAL only: total bulk tons/items bookable; must be > 0 (superRefine).
|
||||||
// field coercion as the container caps above.
|
|
||||||
bulkQuantityCap: nonNegativeQuantityCap.default(0),
|
bulkQuantityCap: nonNegativeQuantityCap.default(0),
|
||||||
// Contract-level billing flags.
|
// Contract-level billing flags.
|
||||||
isHazardous: z.boolean().default(false),
|
isHazardous: z.boolean().default(false),
|
||||||
isRefrigerated: 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."),
|
originYard: z.string().min(1, "Select an origin yard."),
|
||||||
destinationYard: z.string().min(1, "Select a destination 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({}),
|
documents: z.record(z.string(), z.any()).default({}),
|
||||||
notes: z.string().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>;
|
export type ContractFormValues = z.infer<typeof contractFormSchema>;
|
||||||
@@ -289,7 +301,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
|||||||
|
|
||||||
originYard: "",
|
originYard: "",
|
||||||
destinationYard: "",
|
destinationYard: "",
|
||||||
extraRoutes: [],
|
|
||||||
|
|
||||||
documents: {},
|
documents: {},
|
||||||
notes: "",
|
notes: "",
|
||||||
@@ -325,7 +336,6 @@ export const contractStepFields: Record<
|
|||||||
"isRefrigerated",
|
"isRefrigerated",
|
||||||
"originYard",
|
"originYard",
|
||||||
"destinationYard",
|
"destinationYard",
|
||||||
"extraRoutes",
|
|
||||||
],
|
],
|
||||||
// Step 2 — Review & Submit. (The separate Documents step was removed — the
|
// Step 2 — Review & Submit. (The separate Documents step was removed — the
|
||||||
// company profile documents are attached to the contract automatically.)
|
// company profile documents are attached to the contract automatically.)
|
||||||
|
|||||||
@@ -132,19 +132,12 @@ export function Step1ContractType({
|
|||||||
);
|
);
|
||||||
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
|
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
|
||||||
|
|
||||||
// ── Route (primary + extras) ──
|
// ── Route (single route per contract) ──
|
||||||
const routes = contract.routes ?? [];
|
const routes = contract.routes ?? [];
|
||||||
if (routes[0]) {
|
if (routes[0]) {
|
||||||
form.setValue("originYard", routes[0].originYardId);
|
form.setValue("originYard", routes[0].originYardId);
|
||||||
form.setValue("destinationYard", routes[0].destinationYardId);
|
form.setValue("destinationYard", routes[0].destinationYardId);
|
||||||
}
|
}
|
||||||
form.setValue(
|
|
||||||
"extraRoutes",
|
|
||||||
routes.slice(1).map((r) => ({
|
|
||||||
originYard: r.originYardId,
|
|
||||||
destinationYard: r.destinationYardId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Cargo scope ──
|
// ── Cargo scope ──
|
||||||
form.setValue(
|
form.setValue(
|
||||||
|
|||||||
@@ -227,14 +227,14 @@ export function Step3CargoScope({
|
|||||||
{/* GENERAL contract quantity cap (draw-down ceiling). */}
|
{/* GENERAL contract quantity cap (draw-down ceiling). */}
|
||||||
{isGeneral && (
|
{isGeneral && (
|
||||||
<Box>
|
<Box>
|
||||||
<StepLabel>Booking quantity cap (optional)</StepLabel>
|
<StepLabel>Booking quantity cap *</StepLabel>
|
||||||
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
||||||
Total quantity bookable across all shipments under this contract.
|
Total quantity bookable across all shipments under this contract.
|
||||||
Customers / GL can book repeatedly until it is reached. Leave 0 for
|
Customers / GL can book repeatedly until it is reached. Must be
|
||||||
unlimited.
|
greater than 0.
|
||||||
</Text>
|
</Text>
|
||||||
{cargoType === "container" ? (
|
{cargoType === "container" ? (
|
||||||
<Group gap={12} grow>
|
<Group gap={12} grow align="flex-start">
|
||||||
{enabledSizes.length === 0 ? (
|
{enabledSizes.length === 0 ? (
|
||||||
<Text fz={13} c="dimmed">
|
<Text fz={13} c="dimmed">
|
||||||
Select container sizes above to set their caps.
|
Select container sizes above to set their caps.
|
||||||
@@ -245,13 +245,14 @@ export function Step3CargoScope({
|
|||||||
key={size}
|
key={size}
|
||||||
name={`containerSizeCaps.${size}`}
|
name={`containerSizeCaps.${size}`}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field, fieldState }) => (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={`${size} cap (containers)`}
|
label={`${size} cap (containers) *`}
|
||||||
placeholder="0 = unlimited"
|
placeholder="e.g. 100"
|
||||||
min={0}
|
min={0}
|
||||||
value={Number(field.value ?? 0)}
|
value={Number(field.value ?? 0)}
|
||||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||||
|
error={fieldState.error?.message}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
/>
|
/>
|
||||||
@@ -264,13 +265,14 @@ export function Step3CargoScope({
|
|||||||
<Controller
|
<Controller
|
||||||
name="bulkQuantityCap"
|
name="bulkQuantityCap"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field, fieldState }) => (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Total cap (tons / items)"
|
label="Total cap (tons / items) *"
|
||||||
placeholder="0 = unlimited"
|
placeholder="e.g. 500"
|
||||||
min={0}
|
min={0}
|
||||||
value={Number(field.value ?? 0)}
|
value={Number(field.value ?? 0)}
|
||||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||||
|
error={fieldState.error?.message}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
import { Skeleton, Stack } from "@mantine/core";
|
||||||
import { MapPin, Plus, Trash2 } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import {
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
Controller,
|
|
||||||
useFieldArray,
|
|
||||||
type UseFormReturn,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||||
import { getRouteDirection } from "./helpers";
|
import { getRouteDirection } from "./helpers";
|
||||||
import { SelectField, StepLabel } from "./shared";
|
import { SelectField, StepLabel } from "./shared";
|
||||||
@@ -29,7 +25,6 @@ export function Step4Route({
|
|||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
const operationType = form.watch("operationType");
|
const operationType = form.watch("operationType");
|
||||||
const isGeneralContract = form.watch("contractKind") === "general_contract";
|
|
||||||
|
|
||||||
const { originCountry, destinationCountry } = useMemo(() => {
|
const { originCountry, destinationCountry } = useMemo(() => {
|
||||||
switch (operationType) {
|
switch (operationType) {
|
||||||
@@ -46,14 +41,6 @@ export function Step4Route({
|
|||||||
}
|
}
|
||||||
}, [operationType]);
|
}, [operationType]);
|
||||||
|
|
||||||
const {
|
|
||||||
fields: extraRoutes,
|
|
||||||
append: appendRoute,
|
|
||||||
remove: removeRoute,
|
|
||||||
} = useFieldArray({ control: form.control, name: "extraRoutes" });
|
|
||||||
|
|
||||||
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
|
|
||||||
|
|
||||||
const yardOptions = useMemo(() => {
|
const yardOptions = useMemo(() => {
|
||||||
if (!referenceData?.yard) return [];
|
if (!referenceData?.yard) return [];
|
||||||
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
|
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
|
||||||
@@ -96,22 +83,6 @@ export function Step4Route({
|
|||||||
}
|
}
|
||||||
}, [destinationCountry, dest, form]);
|
}, [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> = {
|
const directionStyle: Record<string, string> = {
|
||||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||||
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
|
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
|
||||||
@@ -173,94 +144,6 @@ export function Step4Route({
|
|||||||
|
|
||||||
</div>
|
</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 origin–destination 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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,10 +250,6 @@ export function Step8Review({
|
|||||||
? direction.charAt(0) + direction.slice(1).toLowerCase()
|
? direction.charAt(0) + direction.slice(1).toLowerCase()
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
const routesCount = 1 + (values.extraRoutes?.filter(
|
|
||||||
(r) => r.originYard && r.destinationYard,
|
|
||||||
).length ?? 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
@@ -335,17 +331,13 @@ export function Step8Review({
|
|||||||
/>
|
/>
|
||||||
<SummaryItem
|
<SummaryItem
|
||||||
icon={<Route size={18} />}
|
icon={<Route size={18} />}
|
||||||
label="Primary route"
|
label="Route"
|
||||||
value={`${originYardName} → ${destinationYardName}`}
|
value={`${originYardName} → ${destinationYardName}`}
|
||||||
/>
|
/>
|
||||||
<SummaryItem
|
<SummaryItem
|
||||||
icon={<MapPin size={18} />}
|
icon={<MapPin size={18} />}
|
||||||
label="Trade direction"
|
label="Trade direction"
|
||||||
value={
|
value={directionLabel}
|
||||||
isGeneralContract
|
|
||||||
? `${directionLabel} · ${routesCount} routes`
|
|
||||||
: directionLabel
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<SummaryItem
|
<SummaryItem
|
||||||
icon={<Package size={18} />}
|
icon={<Package size={18} />}
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ export * from "./repositories/base.repository";
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
export * from "./services/exchange";
|
export * from "./services/exchange";
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export * from "./utils/reference-sequence";
|
||||||
|
|||||||
50
packages/api-common/src/utils/reference-sequence.ts
Normal file
50
packages/api-common/src/utils/reference-sequence.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user