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

@@ -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 };
}