add Excel import functionality for container bookings

This commit is contained in:
Marshal
2026-07-09 18:10:58 +00:00
parent a7d05fba34
commit 1d77cc9219
14 changed files with 1074 additions and 50 deletions

View File

@@ -51,6 +51,11 @@ export class BookingRequestService {
const contract = await this.contractsService.findById(contractId);
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
this.assertGeneralCustoms(contract);
if (contract.status === 'CONTRACT_CLOSED') {
throw new ConflictException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new ConflictException(
'The contract must be active before requesting a shipment.',

View File

@@ -0,0 +1,149 @@
import { BadRequestException } from '@nestjs/common';
import { ContractBookingService } from './contract-booking.service';
import { Contract } from './entities/contract.entity';
/**
* Contract auto-completion by quantity cap. Once a GENERAL contract's capped
* scope is fully consumed (e.g. a split remainder rebooked), the contract moves
* to CONTRACT_CLOSED even inside its validity window, and further bookings are
* blocked — including while a booking window is open. Released capacity
* (cancelled/expired booking) reopens the contract on the next attempt.
*/
describe('ContractBookingService — quantity-cap completion', () => {
function makeService() {
const contractsRepository = {
findByIdWithRelations: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new ContractBookingService(
contractsRepository as never,
{} as never, // bookingsRepository
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // workflowService
{} as never, // invoiceService
{} as never, // dataSource
{} as never, // trainSchedulingService
);
return { service, contractsRepository };
}
type WithPrivate = {
maybeCompleteContract: (c: Contract) => Promise<void>;
};
const generalContract = (status: string): Contract =>
({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
status,
}) as Contract;
it('closes a GENERAL contract when every capped line is exhausted', async () => {
const { service, contractsRepository } = makeService();
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
{ containerSize: '40FT', cap: 4, booked: 4, remaining: 0 },
]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('CONTRACT_ACTIVE'),
);
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('absorbs bulk-ton float dust when judging exhaustion', async () => {
const { service, contractsRepository } = makeService();
jest
.spyOn(service, 'computeCapacity')
.mockResolvedValue([{ cap: 100, booked: 99.9995, remaining: 0.0005 }]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('FULLY_EXECUTED'),
);
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('keeps the contract open while any capped line has capacity left', async () => {
const { service, contractsRepository } = makeService();
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
{ containerSize: '40FT', cap: 4, booked: 3, remaining: 1 },
]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('CONTRACT_ACTIVE'),
);
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('never closes an uncapped contract', async () => {
const { service, contractsRepository } = makeService();
jest.spyOn(service, 'computeCapacity').mockResolvedValue([]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('CONTRACT_ACTIVE'),
);
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('never closes a ONE_TIME contract (single-slot rule governs it)', async () => {
const { service, contractsRepository } = makeService();
const spy = jest.spyOn(service, 'computeCapacity');
await (service as never as WithPrivate).maybeCompleteContract({
id: 'c-1',
contractKind: 'ONE_TIME',
status: 'FULLY_EXECUTED',
} as Contract);
expect(spy).not.toHaveBeenCalled();
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('rejects a new booking on a completed contract even inside an open window', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(
generalContract('CONTRACT_CLOSED'),
);
jest
.spyOn(service, 'computeCapacity')
.mockResolvedValue([{ cap: 10, booked: 10, remaining: 0 }]);
await expect(
service.createUnderContract('c-1', {} as never, null, null),
).rejects.toThrow(BadRequestException);
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('reopens a completed contract when capacity was released', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(
generalContract('CONTRACT_CLOSED'),
);
jest
.spyOn(service, 'computeCapacity')
.mockResolvedValue([{ cap: 10, booked: 8, remaining: 2 }]);
// The create path continues past the gate and dies later on the bare mocks —
// only the reopen transition is under test here.
await service.createUnderContract('c-1', {} as never, null, null).catch(() => undefined);
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_ACTIVE',
});
});
});

View File

@@ -83,6 +83,24 @@ export class ContractBookingService {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
// A contract whose quantity cap was fully booked is completed — no further
// bookings, even while contract validity and a booking window are still
// open. Capacity released after closure (a cancelled/expired booking)
// reopens the contract on the next booking attempt.
if (contract.status === 'CONTRACT_CLOSED') {
const capacity = await this.computeCapacity(contract);
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
if (!hasRoom) {
throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_ACTIVE',
} as never);
contract.status = 'CONTRACT_ACTIVE';
}
// GL Ethiopia is identified by the dedicated contract create-booking permission
// (granted to the edr_gl_ethiopia preset).
const isGlActor =
@@ -291,6 +309,9 @@ export class ContractBookingService {
if (!parked.paired) {
// Waiting for a partner — stop here. The booking sits in
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
// A parked booking still holds contract capacity, so the cap may
// already be exhausted by it.
await this.maybeCompleteContract(contract);
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
@@ -304,6 +325,8 @@ export class ContractBookingService {
generalCustoms,
);
await this.maybeCompleteContract(contract);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: result ?? booking, warnings };
}
@@ -606,6 +629,44 @@ export class ContractBookingService {
});
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract (including a split remainder
* being rebooked): when no capped scope line has capacity left, the contract
* moves to CONTRACT_CLOSED even though its validity window is still open —
* blocking further bookings and shipment requests, including inside an open
* booking window. Never throws: a status hiccup must not undo the booking
* that was just created.
*/
private async maybeCompleteContract(contract: Contract): Promise<void> {
try {
// ONE_TIME contracts are governed by the single-active-booking slot (and
// are promoted to GENERAL on split), so only GENERAL completes by cap.
if (contract.contractKind !== 'GENERAL') return;
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to
// 3 decimals); container caps are integers and unaffected.
const exhausted = capacity.every(
(c) => c.remaining != null && c.remaining <= 0.001,
);
if (!exhausted) return;
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`,
);
} catch (err) {
this.logger.error(
`Could not evaluate completion for contract ${contract.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Quantities already booked under a contract that still hold capacity. Excludes
* bookings that never shipped (CANCELLED / REJECTED / EXPIRED).

View File

@@ -265,10 +265,11 @@ export class ContractsService {
}
}
// 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);
// Attach the company's onboarding documents (TIN, licenses, IDs) and the
// profile's 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, companyId ?? null, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
@@ -316,51 +317,95 @@ export class ContractsService {
}
/**
* Copy a company profile's stored business-license / onboarding documents onto
* a contract by reference (no byte re-upload). Codes are slugged from each
* document name so they group under "Profile documents" on the contract detail
* page. No-op when the contract has no profile or the profile has no documents.
* Copy the company's onboarding documents (TIN certificate, commercial /
* investment license, national ID, passport — resource "companies", coded by
* the upload-setting fileKey) and the company profile's business-license
* documents (resource "company_profiles") onto a contract by reference (no
* byte re-upload). Idempotent: codes already present on the contract — user
* uploads or an earlier carry — are never duplicated or overwritten, so it is
* safe to run on every create and update. No-op when there is nothing to copy.
*/
private async attachProfileDocuments(
contractId: string,
companyId: string | null,
companyProfileId: string | null,
): Promise<void> {
if (!companyProfileId) return;
// Business-license files are FileRecords (resource "company_profiles"); carry
// the live ones by reference. Staged/pending uploads are excluded by code.
const records = await this.filesService.findByResource(
companyProfileId,
'company_profiles',
if (!companyId && !companyProfileId) return;
const existingCodes = new Set(
(await this.filesService.findByResource(contractId, 'contracts')).map(
(r) => r.code,
),
);
const docs = records
.filter((r) => r.code === 'business_license')
.map((r) => ({
name: r.name,
url: r.url,
size: r.size,
mimeType: r.mimeType,
}));
const docs: Array<{
code: string;
name: string;
url: string;
size: number;
mimeType?: string;
}> = [];
if (companyId) {
// Company onboarding documents keep their fileKey codes (tin_certificate,
// commercial_license, …) so the portal can match them against the
// onboarding upload-setting fields. Re-uploads append rows, so keep only
// the newest record per code.
const companyRecords = await this.filesService.findByResource(
companyId,
'companies',
);
const latestByCode = new Map<string, (typeof companyRecords)[number]>();
for (const r of companyRecords) {
const prev = latestByCode.get(r.code);
if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r);
}
for (const r of latestByCode.values()) {
if (existingCodes.has(r.code)) continue;
docs.push({
code: r.code,
name: r.name,
url: r.url,
size: r.size,
mimeType: r.mimeType,
});
}
}
if (companyProfileId) {
// Business-license files are FileRecords (resource "company_profiles");
// carry the live ones by reference. Staged/pending uploads are excluded by
// code. Codes are slugged from each document name so they group under
// "Profile documents" on the contract detail page.
const records = await this.filesService.findByResource(
companyProfileId,
'company_profiles',
);
const slug = (name: string) =>
name
.toLowerCase()
.replace(/\.[a-z0-9]+$/, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'profile_document';
records
.filter((r) => r.code === 'business_license')
.forEach((r, i) => {
const code = `${slug(r.name)}_${i + 1}`;
if (existingCodes.has(code)) return;
docs.push({
code,
name: r.name,
url: r.url,
size: r.size,
mimeType: r.mimeType,
});
});
}
if (docs.length === 0) return;
const slug = (name: string) =>
name
.toLowerCase()
.replace(/\.[a-z0-9]+$/, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'profile_document';
try {
await this.filesService.attachExistingFiles(
contractId,
'contracts',
docs.map((d, i) => ({
code: `${slug(d.name)}_${i + 1}`,
name: d.name,
url: d.url,
size: d.size,
mimeType: d.mimeType,
})),
);
await this.filesService.attachExistingFiles(contractId, 'contracts', docs);
} catch {
// Non-fatal — the contract is still valid without the carried documents.
}
@@ -481,6 +526,15 @@ export class ContractsService {
await this.filesService.uploadMany(id, 'contracts', files);
}
// Re-carry any company/profile document that is still missing from the
// contract (runs after the upload so fresh replacements keep their slot).
// Backfills contracts created before profile documents were carried over.
await this.attachProfileDocuments(
id,
existing.companyId ?? null,
existing.companyProfileId ?? null,
);
return { contract: await this.findById(id), warnings };
}

View File

@@ -36,6 +36,9 @@ export interface SizedOffer {
* rows, so reducing the lines releases it automatically) and can be rebooked in
* any later window within contract validity. A ONE_TIME contract is promoted to
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
* Once the remainder is rebooked and the cap hits zero, ContractBookingService
* completes the contract (CONTRACT_CLOSED): no further bookings or shipment
* requests, even while validity and a booking window are still open.
*/
@Injectable()
export class BookingSplitService {

View File

@@ -39,6 +39,7 @@
"stream-browserify": "^3.0.0",
"tailwind-merge": "^3.6.0",
"tinymce": "^8.6.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.0"
},
"devDependencies": {

View File

@@ -12,6 +12,7 @@ import {
Button,
Center,
Divider,
FileButton,
Group,
Loader,
Modal,
@@ -31,7 +32,9 @@ import {
CalendarDays,
CheckCircle2,
ChevronLeft,
FileDown,
FileText,
FileUp,
MapPin,
Package,
Receipt,
@@ -54,6 +57,10 @@ import {
type GlShipmentQuantities,
} from "./gl-booking-form/total";
import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice";
import {
downloadContainerImportTemplate,
parseContainerExcel,
} from "./gl-booking-form/container-excel";
import {
fieldStyles,
StepCard,
@@ -392,6 +399,57 @@ export default function GlCreateBookingForm() {
// bulk needs a positive quantity with hazardous/reefer portions bounded by it.
const [showErrors, setShowErrors] = useState(false);
// Excel import: one row per container. All-or-nothing — a file with any bad
// row is rejected with row-numbered errors so nothing is silently dropped.
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importSummary, setImportSummary] = useState<string | null>(null);
const importResetRef = useRef<(() => void) | null>(null);
const excelOpts = {
allowedSizes: containerSizes,
includeHazardous: contract?.isHazardous ?? false,
includeReefer: contract?.isReefer ?? false,
};
const handleImportFile = async (file: File | null) => {
// Reset the hidden input so re-picking the same (fixed) file re-fires.
importResetRef.current?.();
if (!file) return;
const { rows, errors } = await parseContainerExcel(file, excelOpts);
if (errors.length > 0) {
setImportSummary(null);
setImportErrors(errors);
return;
}
// Replace only the lines for sizes present in the file; a contracted size
// the file omits keeps whatever was already entered for it.
setContainerLines((prev) =>
containerSizes.map((size) => {
const imported = rows.filter((r) => r.containerSize === size);
if (imported.length === 0) {
return (
prev.find((l) => l.containerSize === size) ?? {
containerSize: size,
units: [emptyUnit()],
}
);
}
return {
containerSize: size,
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: r.vgmTons,
hazardous: r.hazardous,
reefer: r.reefer,
})),
};
}),
);
setImportErrors([]);
setShowErrors(false);
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
};
const unitErrors = useMemo<UnitErrors[][]>(() => {
if (!isContainer) return [];
const numberCounts = new Map<string, number>();
@@ -740,6 +798,83 @@ export default function GlCreateBookingForm() {
description="Enter the quantity and per-container details for each size in the contract scope."
/>
<Stack gap={18}>
{containerSizes.length > 0 && (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Group justify="space-between" wrap="wrap" gap="sm">
<Box>
<Text fz={13} fw={600}>
Import containers from Excel
</Text>
<Text fz={12} c="dimmed">
One row per container. Importing fills the lines below
for the sizes in the file.
</Text>
</Box>
<Group gap="sm">
<Button
variant="default"
size="xs"
radius="md"
leftSection={<FileDown size={14} />}
onClick={() => downloadContainerImportTemplate(excelOpts)}
>
Download template
</Button>
<FileButton
resetRef={importResetRef}
accept=".xlsx,.xls"
onChange={handleImportFile}
>
{(props) => (
<Button
{...props}
size="xs"
radius="md"
color="edr-green"
leftSection={<FileUp size={14} />}
>
Import Excel
</Button>
)}
</FileButton>
</Group>
</Group>
{importErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Import failed — fix the file and try again"
mt="sm"
>
<Stack gap={4}>
{importErrors.slice(0, 8).map((msg, i) => (
<Text key={i} fz="xs">
{msg}
</Text>
))}
{importErrors.length > 8 && (
<Text fz="xs" c="dimmed">
and {importErrors.length - 8} more.
</Text>
)}
</Stack>
</Alert>
)}
{importSummary && (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<CheckCircle2 size={16} />}
mt="sm"
>
<Text fz="xs">{importSummary}</Text>
</Alert>
)}
</Paper>
)}
<ContractCapacityNotice contractId={contract.id} isContainer />
{containerLines.length === 0 ? (
<Text fz="sm" c="dimmed">

View File

@@ -0,0 +1,200 @@
import * as XLSX from "xlsx";
// Excel import for container shipments: one spreadsheet row per physical
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
// hazardous/reefer flags when the contract allows them. The parser is
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
// partial import can never silently drop containers.
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
export interface ContainerExcelOptions {
/** Container sizes the contract scope allows (e.g. ["20ft", "40ft"]). */
allowedSizes: string[];
includeHazardous: boolean;
includeReefer: boolean;
}
export interface ImportedContainerRow {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: string;
hazardous: boolean;
reefer: boolean;
}
export interface ContainerExcelResult {
rows: ImportedContainerRow[];
errors: string[];
}
type ColumnKey =
| "containerSize"
| "containerNumber"
| "sealNumber"
| "vgmTons"
| "hazardous"
| "reefer";
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
function headerKey(raw: string): ColumnKey | null {
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
if (!h) return null;
if (h.includes("size")) return "containerSize";
if (h.includes("seal")) return "sealNumber";
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
if (h.includes("hazard")) return "hazardous";
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
// After the more specific matches: "Container Number", "Container No", …
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
}
/** "20", "20ft", "20 FT" … → the matching contracted size, or null. */
function normalizeSize(raw: string, allowed: string[]): string | null {
const digits = raw.replace(/[^0-9]/g, "");
if (!digits) return null;
return allowed.find((s) => s.replace(/[^0-9]/g, "") === digits) ?? null;
}
function parseFlag(raw: string): boolean {
const v = raw.trim().toLowerCase();
return v === "yes" || v === "y" || v === "true" || v === "1" || v === "x";
}
/**
* Parse an uploaded workbook into one row per container. Returns either the
* full row set or the list of row-numbered problems (never both).
*/
export async function parseContainerExcel(
file: File,
opts: ContainerExcelOptions,
): Promise<ContainerExcelResult> {
let sheet: XLSX.WorkSheet | undefined;
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
sheet = workbook.Sheets[workbook.SheetNames[0]];
} catch {
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
}
if (!sheet) {
return { rows: [], errors: ["The file has no sheets."] };
}
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
raw: false,
defval: "",
});
// First row with a recognizable column is the header; everything above
// (titles, blank rows) is ignored.
let headerRowIdx = -1;
let columns: Array<ColumnKey | null> = [];
for (let i = 0; i < grid.length; i++) {
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
if (mapped.includes("containerNumber") && mapped.includes("containerSize")) {
headerRowIdx = i;
columns = mapped;
break;
}
}
if (headerRowIdx < 0) {
return {
rows: [],
errors: [
'Could not find the expected columns. The sheet needs at least "Container Size" and "Container Number" headers — download the template to see the format.',
],
};
}
if (!columns.includes("vgmTons")) {
return {
rows: [],
errors: ['Missing a "VGM (Tons)" column — download the template to see the format.'],
};
}
const rows: ImportedContainerRow[] = [];
const errors: string[] = [];
const numberCounts = new Map<string, number>();
for (let i = headerRowIdx + 1; i < grid.length; i++) {
const cells = grid[i] ?? [];
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: ColumnKey) => {
const idx = columns.indexOf(key);
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
};
const size = normalizeSize(cell("containerSize"), opts.allowedSizes);
if (!size) {
errors.push(
`Row ${rowNo}: container size "${cell("containerSize") || "—"}" is not in this contract's scope (allowed: ${opts.allowedSizes.join(", ")}).`,
);
}
const containerNumber = cell("containerNumber").toUpperCase();
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
errors.push(
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. MSCU1234567).`,
);
} else {
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const vgmRaw = cell("vgmTons");
const vgm = Number(vgmRaw);
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
errors.push(`Row ${rowNo}: VGM "${vgmRaw || "—"}" must be a number greater than 0.`);
}
rows.push({
containerSize: size ?? "",
containerNumber,
sealNumber: cell("sealNumber"),
vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
reefer: opts.includeReefer && parseFlag(cell("reefer")),
});
}
numberCounts.forEach((count, num) => {
if (count > 1) errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
});
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no container rows below the header.");
}
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
}
/** Generate and download the simple import template with one sample row per size. */
export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
const sampleRows = sizes.map((size, i) => {
const row: Array<string | number> = [
size,
`MSCU${String(1234567 + i).padStart(7, "0")}`,
`SL${String(482910 + i)}`,
size.startsWith("40") ? 28 : 24.5,
];
if (opts.includeHazardous) row.push("NO");
if (opts.includeReefer) row.push("NO");
return row;
});
const sheet = XLSX.utils.aoa_to_sheet([headers, ...sampleRows]);
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 16) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, "Containers");
XLSX.writeFile(workbook, "container-import-template.xlsx");
}

View File

@@ -36,6 +36,7 @@
"recharts": "^3.8.1",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.6.0",
"xlsx": "^0.18.5",
"zod": "^4.4.3",
"zustand": "^5.0.0"
},

View File

@@ -59,6 +59,7 @@ import {
ContractDocsEditor,
documentSettingCode,
missingRequiredDocKeys,
useCompanyDocuments,
} from "./new-contract-form/ContractDocsEditor";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import type { ProfileTypeValue } from "@/services/companies.service";
@@ -116,6 +117,9 @@ export default function NewContractPage({
}),
enabled: isEdit,
});
// Profile documents (TIN, licenses, IDs) satisfy requirements too — the API
// carries them onto the contract on save.
const companyDocs = useCompanyDocuments();
// Contract creation is gated on profile approval, same as bookings.
if (!auth.isPending && auth.company && !auth.canBook) {
@@ -526,6 +530,7 @@ export default function NewContractPage({
editDocSettingQuery.data,
editContract,
editDocuments,
companyDocs,
);
if (missing.length > 0) {
setShowDocErrors(true);
@@ -643,6 +648,7 @@ export default function NewContractPage({
editDocSettingQuery.data,
editContract,
editDocuments,
companyDocs,
);
if (missing.length > 0) {
setShowDocErrors(true);
@@ -846,6 +852,7 @@ export default function NewContractPage({
editDocSettingQuery.data,
editContract,
editDocuments,
companyDocs,
).map((k) => [k, "Required"]),
)
: {}

View File

@@ -9,6 +9,7 @@ import {
Button,
Center,
Divider,
FileButton,
Group,
Loader,
Modal,
@@ -26,6 +27,8 @@ import {
CalendarDays,
CheckCircle2,
ChevronLeft,
FileDown,
FileUp,
MapPin,
Package,
Receipt,
@@ -51,6 +54,10 @@ import {
initialShipmentFormValues,
} from "./new-shipment-form/schema";
import { computeShipmentTotal } from "./new-shipment-form/total";
import {
downloadContainerImportTemplate,
parseContainerExcel,
} from "./new-shipment-form/container-excel";
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -931,6 +938,60 @@ function CargoStep({
const lines = form.watch("containers") ?? [];
// Excel import: one row per container. All-or-nothing — a file with any bad
// row is rejected with row-numbered errors so nothing is silently dropped.
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importSummary, setImportSummary] = useState<string | null>(null);
const importResetRef = useRef<(() => void) | null>(null);
const excelOpts = {
allowedSizes: sizes,
includeHazardous: contract.isHazardous ?? false,
includeReefer: contract.isReefer ?? false,
};
const handleImportFile = async (file: File | null) => {
// Reset the hidden input so re-picking the same (fixed) file re-fires.
importResetRef.current?.();
if (!file) return;
const { rows, errors } = await parseContainerExcel(file, excelOpts);
if (errors.length > 0) {
setImportSummary(null);
setImportErrors(errors);
return;
}
// Replace only the lines for sizes present in the file; a contracted size
// the file omits keeps whatever was already entered for it.
const current = form.getValues("containers") ?? [];
const next = sizes.map((size) => {
const imported = rows.filter((r) => r.containerSize === size);
if (imported.length === 0) {
return (
current.find((l) => l.containerSize === size) ?? {
containerSize: size,
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
}
);
}
return {
containerSize: size,
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: r.vgmTons,
})),
};
});
form.setValue("containers", next, { shouldValidate: true, shouldDirty: true });
setImportErrors([]);
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
};
if (isContainer) {
return (
<StepCard>
@@ -940,6 +1001,83 @@ function CargoStep({
description="Enter the quantity and per-container details for each size in your contract scope."
/>
<Stack gap={18}>
{sizes.length > 0 && (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Group justify="space-between" wrap="wrap" gap="sm">
<Box>
<Text fz={13} fw={600} c="#10202F">
Import containers from Excel
</Text>
<Text fz={12} c="dimmed">
One row per container. Importing fills the lines below for
the sizes in your file.
</Text>
</Box>
<Group gap="sm">
<Button
variant="default"
size="xs"
radius="md"
leftSection={<FileDown size={14} />}
onClick={() => downloadContainerImportTemplate(excelOpts)}
>
Download template
</Button>
<FileButton
resetRef={importResetRef}
accept=".xlsx,.xls"
onChange={handleImportFile}
>
{(props) => (
<Button
{...props}
size="xs"
radius="md"
color="edr-green"
leftSection={<FileUp size={14} />}
>
Import Excel
</Button>
)}
</FileButton>
</Group>
</Group>
{importErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Import failed — fix the file and try again"
mt="sm"
>
<Stack gap={4}>
{importErrors.slice(0, 8).map((msg, i) => (
<Text key={i} fz="xs">
{msg}
</Text>
))}
{importErrors.length > 8 && (
<Text fz="xs" c="dimmed">
and {importErrors.length - 8} more.
</Text>
)}
</Stack>
</Alert>
)}
{importSummary && (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<CheckCircle2 size={16} />}
mt="sm"
>
<Text fz="xs">{importSummary}</Text>
</Alert>
)}
</Paper>
)}
<ContractCapacityNotice contractId={contract.id} isContainer />
{lines.map((line, index) => (
<ContainerLineEditor

View File

@@ -7,6 +7,7 @@ import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type { CompanyDocument } from "@/services/companies.service";
import { fileViewUrl } from "@/constants/apiConfig";
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
import { BORDER, GREEN, INK } from "../contract-ui";
@@ -38,6 +39,38 @@ function dedupeLatestByCode(files: ContractFile[]): ContractFile[] {
return order.map((c) => latest.get(c)!);
}
/**
* The company's onboarding documents (TIN certificate, commercial license,
* national ID, …) from the profile. Contracts carry these automatically on
* save; the edit page also shows them directly so they are always visible even
* on contracts created before the carry-over existed.
*/
export function useCompanyDocuments(): CompanyDocument[] {
const auth = useAuth();
const companyId = auth.company?.company?.id as string | undefined;
const query = useQuery({
...api.companies.documents.queryOptions({
input: { companyId: companyId ?? "" },
}),
enabled: Boolean(companyId),
});
return query.data ?? [];
}
/** Latest company document per code, excluding codes already on the contract. */
function profileFallbackDocs(
companyDocs: CompanyDocument[],
contractFiles: ContractFile[],
): CompanyDocument[] {
const onContract = new Set(contractFiles.map((f) => f.code));
const latest = new Map<string, CompanyDocument>();
for (const d of companyDocs) {
const prev = latest.get(d.code);
if (!prev || d.uploadedAt > prev.uploadedAt) latest.set(d.code, d);
}
return [...latest.values()].filter((d) => !onContract.has(d.code));
}
/**
* Document replace/upload block for an existing contract. Lists the documents
* already on file (latest upload per code) and renders the onboarding-driven
@@ -72,8 +105,25 @@ export function ContractDocsEditor({
}),
);
const companyDocs = useCompanyDocuments();
const files = contract.files ?? [];
const onFile = useMemo(() => dedupeLatestByCode(files), [files]);
const onFile = useMemo(() => {
const contractRows = dedupeLatestByCode(files).map((f) => ({
id: f.id,
code: f.code,
name: f.name,
fromProfile: false,
}));
// Profile documents not yet carried onto the contract still show — they are
// attached automatically on the next save.
const profileRows = profileFallbackDocs(companyDocs, files).map((d) => ({
id: d.id,
code: d.code,
name: d.name,
fromProfile: true,
}));
return [...contractRows, ...profileRows];
}, [files, companyDocs]);
return (
<Stack gap="md">
@@ -115,12 +165,21 @@ export function ContractDocsEditor({
</Text>
</Box>
<Group gap={8} wrap="nowrap">
<Group gap={5} c={GREEN}>
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c={GREEN}>
On file
</Text>
</Group>
{file.fromProfile ? (
<Group gap={5} style={{ color: "#2E5B96" }}>
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} style={{ color: "#2E5B96" }}>
From profile
</Text>
</Group>
) : (
<Group gap={5} c={GREEN}>
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c={GREEN}>
On file
</Text>
</Group>
)}
<Button
component="a"
href={fileViewUrl(file.id, true)}
@@ -170,16 +229,21 @@ export function ContractDocsEditor({
/**
* Keys of the documents the onboarding setting marks required that are neither
* already on the contract nor freshly attached in `documents`. Empty means the
* customer may resubmit.
* already on the contract, nor on the company profile (the API carries those
* onto the contract on save), nor freshly attached in `documents`. Empty means
* the customer may resubmit.
*/
export function missingRequiredDocKeys(
setting: Freight.IFileUploadSetting | undefined,
contract: Freight.IContract,
documents: DocumentsValue,
companyDocs: CompanyDocument[] = [],
): string[] {
const fields = setting?.fields ?? [];
const existingCodes = new Set((contract.files ?? []).map((f) => f.code));
const existingCodes = new Set([
...(contract.files ?? []).map((f) => f.code),
...companyDocs.map((d) => d.code),
]);
return fields
.filter((f) => f.isRequired)
.filter(

View File

@@ -0,0 +1,200 @@
import * as XLSX from "xlsx";
// Excel import for container shipments: one spreadsheet row per physical
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
// hazardous/reefer flags when the contract allows them. The parser is
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
// partial import can never silently drop containers.
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
export interface ContainerExcelOptions {
/** Container sizes the contract scope allows (e.g. ["20ft", "40ft"]). */
allowedSizes: string[];
includeHazardous: boolean;
includeReefer: boolean;
}
export interface ImportedContainerRow {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: string;
hazardous: boolean;
reefer: boolean;
}
export interface ContainerExcelResult {
rows: ImportedContainerRow[];
errors: string[];
}
type ColumnKey =
| "containerSize"
| "containerNumber"
| "sealNumber"
| "vgmTons"
| "hazardous"
| "reefer";
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
function headerKey(raw: string): ColumnKey | null {
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
if (!h) return null;
if (h.includes("size")) return "containerSize";
if (h.includes("seal")) return "sealNumber";
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
if (h.includes("hazard")) return "hazardous";
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
// After the more specific matches: "Container Number", "Container No", …
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
}
/** "20", "20ft", "20 FT" … → the matching contracted size, or null. */
function normalizeSize(raw: string, allowed: string[]): string | null {
const digits = raw.replace(/[^0-9]/g, "");
if (!digits) return null;
return allowed.find((s) => s.replace(/[^0-9]/g, "") === digits) ?? null;
}
function parseFlag(raw: string): boolean {
const v = raw.trim().toLowerCase();
return v === "yes" || v === "y" || v === "true" || v === "1" || v === "x";
}
/**
* Parse an uploaded workbook into one row per container. Returns either the
* full row set or the list of row-numbered problems (never both).
*/
export async function parseContainerExcel(
file: File,
opts: ContainerExcelOptions,
): Promise<ContainerExcelResult> {
let sheet: XLSX.WorkSheet | undefined;
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
sheet = workbook.Sheets[workbook.SheetNames[0]];
} catch {
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
}
if (!sheet) {
return { rows: [], errors: ["The file has no sheets."] };
}
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
raw: false,
defval: "",
});
// First row with a recognizable column is the header; everything above
// (titles, blank rows) is ignored.
let headerRowIdx = -1;
let columns: Array<ColumnKey | null> = [];
for (let i = 0; i < grid.length; i++) {
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
if (mapped.includes("containerNumber") && mapped.includes("containerSize")) {
headerRowIdx = i;
columns = mapped;
break;
}
}
if (headerRowIdx < 0) {
return {
rows: [],
errors: [
'Could not find the expected columns. The sheet needs at least "Container Size" and "Container Number" headers — download the template to see the format.',
],
};
}
if (!columns.includes("vgmTons")) {
return {
rows: [],
errors: ['Missing a "VGM (Tons)" column — download the template to see the format.'],
};
}
const rows: ImportedContainerRow[] = [];
const errors: string[] = [];
const numberCounts = new Map<string, number>();
for (let i = headerRowIdx + 1; i < grid.length; i++) {
const cells = grid[i] ?? [];
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: ColumnKey) => {
const idx = columns.indexOf(key);
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
};
const size = normalizeSize(cell("containerSize"), opts.allowedSizes);
if (!size) {
errors.push(
`Row ${rowNo}: container size "${cell("containerSize") || "—"}" is not in this contract's scope (allowed: ${opts.allowedSizes.join(", ")}).`,
);
}
const containerNumber = cell("containerNumber").toUpperCase();
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
errors.push(
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. MSCU1234567).`,
);
} else {
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const vgmRaw = cell("vgmTons");
const vgm = Number(vgmRaw);
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
errors.push(`Row ${rowNo}: VGM "${vgmRaw || "—"}" must be a number greater than 0.`);
}
rows.push({
containerSize: size ?? "",
containerNumber,
sealNumber: cell("sealNumber"),
vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
reefer: opts.includeReefer && parseFlag(cell("reefer")),
});
}
numberCounts.forEach((count, num) => {
if (count > 1) errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
});
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no container rows below the header.");
}
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
}
/** Generate and download the simple import template with one sample row per size. */
export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
const sampleRows = sizes.map((size, i) => {
const row: Array<string | number> = [
size,
`MSCU${String(1234567 + i).padStart(7, "0")}`,
`SL${String(482910 + i)}`,
size.startsWith("40") ? 28 : 24.5,
];
if (opts.includeHazardous) row.push("NO");
if (opts.includeReefer) row.push("NO");
return row;
});
const sheet = XLSX.utils.aoa_to_sheet([headers, ...sampleRows]);
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 16) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, "Containers");
XLSX.writeFile(workbook, "container-import-template.xlsx");
}

6
pnpm-lock.yaml generated
View File

@@ -298,6 +298,9 @@ importers:
tinymce:
specifier: ^8.6.0
version: 8.6.0
xlsx:
specifier: ^0.18.5
version: 0.18.5
zustand:
specifier: ^5.0.0
version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))
@@ -419,6 +422,9 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
xlsx:
specifier: ^0.18.5
version: 0.18.5
zod:
specifier: ^4.4.3
version: 4.4.3