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

add Excel import functionality for container bookings
This commit is contained in:
marshal
2026-07-09 21:12:19 +03:00
committed by GitHub
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 };
}