mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
Merge branch 'staging' into freight/fix/pay
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
||||
containersPerWagonForSize,
|
||||
wagonsPerUnitForSize,
|
||||
} from '../rule-engine/container-type.util';
|
||||
import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util';
|
||||
import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { wagonRemainder } from './consolidation.service';
|
||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
@@ -1188,8 +1188,9 @@ export class BookingPricingService {
|
||||
);
|
||||
if (!(capacity > 0)) return null;
|
||||
// Break-bulk (PER_ITEM): `tons` above is the item count; size by
|
||||
// indivisible items instead of pretending the count is tonnage.
|
||||
const byItems = bulkItemWagonsRequired(booking, capacity);
|
||||
// indivisible items instead of pretending the count is tonnage. Best
|
||||
// count across allowed wagon types, each capped by its items-fit.
|
||||
const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity);
|
||||
if (byItems > 0) return byItems;
|
||||
return Math.max(1, Math.ceil(tons / capacity));
|
||||
} catch {
|
||||
|
||||
@@ -62,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat
|
||||
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||
import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
|
||||
@@ -685,6 +686,17 @@ export class CompaniesController {
|
||||
return requests.map((r) => new ChangeRequestResponseDto(r));
|
||||
}
|
||||
|
||||
@Get(":companyId/revisions")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Onboarding-phase edit history (version history)" })
|
||||
async listCompanyRevisions(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<CompanyRevisionResponseDto[]> {
|
||||
const revisions =
|
||||
await this.companiesService.listCompanyRevisions(companyId);
|
||||
return revisions.map((r) => new CompanyRevisionResponseDto(r));
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
@@ -719,6 +731,25 @@ export class CompaniesController {
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/request-changes")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
|
||||
})
|
||||
async requestChangeRequestChanges(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectChangeRequestDto,
|
||||
): Promise<ChangeRequestResponseDto> {
|
||||
const request = await this.companiesService.requestChangeRequestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post(":companyId/profiles")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
|
||||
@@ -120,6 +120,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "rev-1",
|
||||
...row,
|
||||
})),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
@@ -144,6 +151,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.revisionRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
|
||||
@@ -15,9 +15,11 @@ import { Company } from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
@@ -29,6 +31,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
ExternalProfile,
|
||||
CompanyProfile,
|
||||
CompanyChangeRequest,
|
||||
CompanyRevision,
|
||||
Booking,
|
||||
]),
|
||||
HttpModule,
|
||||
@@ -49,6 +52,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
ExternalProfileRepository,
|
||||
CompanyProfileRepository,
|
||||
CompanyChangeRequestRepository,
|
||||
CompanyRevisionRepository,
|
||||
CompanyDashboardRepository,
|
||||
ETradeService,
|
||||
CompanyNotifierService,
|
||||
|
||||
@@ -91,6 +91,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "rev-1",
|
||||
...row,
|
||||
})),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
@@ -121,6 +128,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.revisionRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
|
||||
@@ -39,6 +39,7 @@ function makeService(existing: ExistingProfile[]) {
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
@@ -9,6 +10,11 @@ import { DataSource, EntityManager } from "typeorm";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
import {
|
||||
diffCompanyUpdate,
|
||||
summarizeCompanyChanges,
|
||||
} from "./company-revision-diff.util";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import {
|
||||
CompanyDashboardRepository,
|
||||
@@ -64,6 +70,10 @@ import {
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "./entities/company-change-request.entity";
|
||||
import {
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
} from "./entities/company-revision.entity";
|
||||
|
||||
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||
const LICENSE_RESOURCE = "company_profiles";
|
||||
@@ -176,10 +186,13 @@ export interface UserIdentity {
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
private readonly logger = new Logger(CompaniesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
||||
private readonly revisionRepo: CompanyRevisionRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly filesService: FilesService,
|
||||
@@ -682,7 +695,16 @@ export class CompaniesService {
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
const before = await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto };
|
||||
// Staff can also promote Pending -> Active directly through this generic
|
||||
// endpoint (not just via the first-profile-approval path), so stamp it here too.
|
||||
if (
|
||||
dto.status === CompanyStatus.Active &&
|
||||
before.status !== CompanyStatus.Active
|
||||
) {
|
||||
patch.approvedAt = new Date();
|
||||
}
|
||||
const updated = await this.companiesRepo.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||
|
||||
// Suspending or blacklisting locks the customer out, so they must be told.
|
||||
@@ -840,6 +862,34 @@ export class CompaniesService {
|
||||
return companyUpdates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a version-history entry for an onboarding-phase edit (the company
|
||||
* is not yet Active, so the change went straight to the live row with no
|
||||
* approval gate to carry a record of it). Best-effort: a no-op patch or a
|
||||
* failure to write history must never break the edit that triggered it.
|
||||
*/
|
||||
private async recordCompanyRevision(
|
||||
before: Company,
|
||||
patch: Record<string, any>,
|
||||
actorId?: string | null,
|
||||
extraChanges: CompanyRevisionChange[] = [],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const changes = [...diffCompanyUpdate(before, patch), ...extraChanges];
|
||||
if (changes.length === 0) return;
|
||||
await this.revisionRepo.create({
|
||||
companyId: before.id,
|
||||
actorId: actorId ?? null,
|
||||
summary: summarizeCompanyChanges(changes),
|
||||
changes,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record company revision for ${before.id}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a TIN already registered to a *different* company. */
|
||||
private async assertTinAvailable(
|
||||
company: Company,
|
||||
@@ -902,6 +952,7 @@ export class CompaniesService {
|
||||
);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
await this.recordCompanyRevision(company, companyUpdates, userId);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
@@ -945,10 +996,14 @@ export class CompaniesService {
|
||||
if (existing) {
|
||||
request =
|
||||
(await this.changeRequestRepo.update(existing.id, {
|
||||
// Note is left untouched: if this request was ChangesRequested, the
|
||||
// reviewer's ask stays visible on the resubmitted (Pending) row —
|
||||
// clearing it here would hide what was asked for right when the
|
||||
// reviewer comes back to check whether it was actually addressed.
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
})) ?? existing;
|
||||
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
||||
} else {
|
||||
@@ -986,6 +1041,53 @@ export class CompaniesService {
|
||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
|
||||
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
|
||||
await this.findCompanyById(companyId);
|
||||
return this.revisionRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair adjacent remove-then-add intents into one before/after revision
|
||||
* change — that's exactly how a "replace" is staged (see
|
||||
* `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]`
|
||||
* pushed together, and later merges only ever append after that pair, so
|
||||
* adjacency is preserved). A remove or add with no adjacent partner (a pure
|
||||
* add, or a pure removal) stands alone.
|
||||
*/
|
||||
private pairReplaceIntents<
|
||||
T extends { op: "add" | "remove"; fileId: string; fileName?: string },
|
||||
>(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] {
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
let i = 0;
|
||||
while (i < intents.length) {
|
||||
const current = intents[i];
|
||||
const next = intents[i + 1];
|
||||
if (current.op === "remove" && next?.op === "add") {
|
||||
changes.push({
|
||||
field: `document:${current.fileId}`,
|
||||
label: labelFor(next),
|
||||
from: current.fileName ?? null,
|
||||
to: next.fileName ?? null,
|
||||
fromFileId: current.fileId,
|
||||
toFileId: next.fileId,
|
||||
});
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
changes.push({
|
||||
field: `document:${current.fileId}`,
|
||||
label: labelFor(current),
|
||||
from: current.op === "remove" ? (current.fileName ?? null) : null,
|
||||
to: current.op === "add" ? (current.fileName ?? null) : null,
|
||||
fromFileId: current.op === "remove" ? current.fileId : null,
|
||||
toFileId: current.op === "add" ? current.fileId : null,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending change request: apply its snapshot to the live Company and
|
||||
* mark the request approved. Any staged documents are already attached to the
|
||||
@@ -1015,6 +1117,30 @@ export class CompaniesService {
|
||||
await this.applyLicenseChanges(request);
|
||||
await this.applyDocumentChanges(request);
|
||||
|
||||
// This is the ONLY place post-approval FIELD/license/PoA-document changes
|
||||
// land on the live row — without this call, everything the #419
|
||||
// change-request flow does to those is invisible in Version History.
|
||||
// `documentFileIds` (the general bulk company-documents upload) is
|
||||
// deliberately NOT re-recorded here — those documents go live immediately
|
||||
// at upload time and are already recorded there (see
|
||||
// `uploadCompanyDocuments`); redoing it here would double the entry.
|
||||
const documentChanges: CompanyRevisionChange[] = [
|
||||
...this.pairReplaceIntents(
|
||||
request.documents?.licenseChanges ?? [],
|
||||
() => "Business license",
|
||||
),
|
||||
...this.pairReplaceIntents(
|
||||
request.documents?.documentChanges ?? [],
|
||||
(intent) => intent.code,
|
||||
),
|
||||
];
|
||||
await this.recordCompanyRevision(
|
||||
company,
|
||||
companyUpdates,
|
||||
reviewerId,
|
||||
documentChanges,
|
||||
);
|
||||
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Approved,
|
||||
@@ -1025,12 +1151,62 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh upload under a single-file document slot (`isMultiple: false`)
|
||||
* replaces whatever was there, not adds to it — soft-delete the prior live
|
||||
* file(s) for that code, and describe each replacement (plus each genuinely
|
||||
* new upload) as a revision change carrying both file ids, so the reviewer
|
||||
* can open the previous and current file. Multi-file slots are left alone
|
||||
* (genuinely additive, no single "the" document to diff against). Unrecognised
|
||||
* codes (no matching field in the nationality's document setting) are also
|
||||
* left alone — safer to under-clean than to guess wrong. Independent of the
|
||||
* change-request review outcome: nothing else in this flow ever retires a
|
||||
* superseded document, on approve OR reject — these documents go live the
|
||||
* moment they're uploaded.
|
||||
*/
|
||||
private async replaceSingleFileCompanyDocuments(
|
||||
company: Company,
|
||||
before: FileRecord[],
|
||||
uploaded: FileRecord[],
|
||||
): Promise<CompanyRevisionChange[]> {
|
||||
const setting = await this.fileUploadSettingsService
|
||||
.getByCode(this.documentSettingCodeFor(company.nationality))
|
||||
.catch(() => null);
|
||||
const fields = setting?.fields ?? [];
|
||||
const singleFileCodes = new Set(
|
||||
fields.filter((f) => !f.isMultiple).map((f) => f.fileKey),
|
||||
);
|
||||
const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel]));
|
||||
const uploadedIds = new Set(uploaded.map((f) => f.id));
|
||||
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
const toRemove: FileRecord[] = [];
|
||||
for (const file of uploaded) {
|
||||
if (!singleFileCodes.has(file.code)) continue;
|
||||
const prior = before.find(
|
||||
(f) => f.code === file.code && !uploadedIds.has(f.id),
|
||||
);
|
||||
changes.push({
|
||||
field: `document:${file.code}`,
|
||||
label: labelByCode.get(file.code) ?? file.code,
|
||||
from: prior?.name ?? null,
|
||||
to: file.name,
|
||||
fromFileId: prior?.id ?? null,
|
||||
toFileId: file.id,
|
||||
});
|
||||
if (prior) toRemove.push(prior);
|
||||
}
|
||||
await Promise.all(toRemove.map((f) => this.filesService.remove(f.id)));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload company documents. For an approved company this also opens/updates a
|
||||
* pending change request (recording the uploaded file ids) so the upload is
|
||||
* reviewed and the customer is locked until it clears — consistent with the
|
||||
* field-edit review. During onboarding (company not yet active) it's a plain
|
||||
* upload with no review.
|
||||
* upload with no review. Either way the documents go live immediately, so
|
||||
* the revision history is recorded right away too, not gated on a decision.
|
||||
*/
|
||||
async uploadCompanyDocuments(
|
||||
companyId: string,
|
||||
@@ -1038,11 +1214,20 @@ export class CompaniesService {
|
||||
submittedBy?: string,
|
||||
): Promise<FileRecord[]> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const before = await this.filesService.findByResource(
|
||||
companyId,
|
||||
"companies",
|
||||
);
|
||||
const uploaded = await this.filesService.uploadMany(
|
||||
companyId,
|
||||
"companies",
|
||||
files,
|
||||
);
|
||||
const documentChanges = await this.replaceSingleFileCompanyDocuments(
|
||||
company,
|
||||
before,
|
||||
uploaded,
|
||||
);
|
||||
await this.resolveDocumentChangeRequests(
|
||||
companyId,
|
||||
"companies",
|
||||
@@ -1056,6 +1241,9 @@ export class CompaniesService {
|
||||
submittedBy,
|
||||
);
|
||||
}
|
||||
if (documentChanges.length > 0) {
|
||||
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
@@ -1170,7 +1358,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
@@ -1231,6 +1420,37 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for specific fixes without rejecting outright: unlike
|
||||
* {@link rejectChangeRequest}, staged license/document intents are kept (the
|
||||
* row stays open), so the customer's next edit is appended to this SAME
|
||||
* request — via the merge branches in `updateProfile`/`stageDocumentChange`/
|
||||
* `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead
|
||||
* of starting a fresh cycle.
|
||||
*/
|
||||
async requestChangeRequestChanges(
|
||||
id: string,
|
||||
note: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
);
|
||||
}
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.ChangesRequested,
|
||||
note,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
})) ?? request
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCompany(id: string): Promise<void> {
|
||||
await this.findCompanyById(id);
|
||||
await this.companiesRepo.softDelete(id);
|
||||
@@ -1472,6 +1692,7 @@ export class CompaniesService {
|
||||
) {
|
||||
await companyRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
this.companyNotifier.companyApproved(company);
|
||||
}
|
||||
@@ -2264,7 +2485,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
@@ -2590,7 +2812,8 @@ export class CompaniesService {
|
||||
snapshot,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
return;
|
||||
@@ -2870,7 +3093,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Repository } from "typeorm";
|
||||
import { FindOperator, Repository } from "typeorm";
|
||||
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import {
|
||||
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
|
||||
|
||||
const COMPANY_ID = "company-1";
|
||||
|
||||
/** Matches a row's status against either a plain value or an `In([...])` operator. */
|
||||
function statusMatches(
|
||||
rowStatus: ChangeRequestStatus,
|
||||
where: ChangeRequestStatus | FindOperator<ChangeRequestStatus> | undefined,
|
||||
): boolean {
|
||||
if (where === undefined) return true;
|
||||
if (where instanceof FindOperator) return where.value.includes(rowStatus);
|
||||
return rowStatus === where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
|
||||
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
||||
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
|
||||
function mockRepositoryOver(rows: Row[]) {
|
||||
return {
|
||||
findOne: jest.fn(
|
||||
({ where }: { where: Partial<Row> & { companyId: string } }) =>
|
||||
({
|
||||
where,
|
||||
}: {
|
||||
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
rows
|
||||
.filter(
|
||||
(row) =>
|
||||
where.companyId === COMPANY_ID &&
|
||||
(where.status === undefined || row.status === where.status),
|
||||
statusMatches(row.status, where.status),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
||||
null,
|
||||
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
|
||||
expect(result?.id).toBe("pending");
|
||||
});
|
||||
|
||||
it("treats a changes-requested request as open, same as pending", async () => {
|
||||
const changesRequested: Row = {
|
||||
id: "changes-requested",
|
||||
status: ChangeRequestStatus.ChangesRequested,
|
||||
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const result = await subject([
|
||||
rejected,
|
||||
changesRequested,
|
||||
]).findLatestOpenByCompanyId(COMPANY_ID);
|
||||
|
||||
expect(result?.id).toBe("changes-requested");
|
||||
});
|
||||
|
||||
it("returns the latest rejected request when nothing is pending", async () => {
|
||||
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
||||
COMPANY_ID,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */
|
||||
const OPEN_FOR_EDIT_STATUSES = [
|
||||
ChangeRequestStatus.Pending,
|
||||
ChangeRequestStatus.ChangesRequested,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||
constructor(
|
||||
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The company's current pending request, if any. */
|
||||
/** The company's current open request (Pending or ChangesRequested), if any — the row the next edit appends to. */
|
||||
async findPendingByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
||||
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Company } from "./entities/company.entity";
|
||||
import type { CompanyRevisionChange } from "./entities/company-revision.entity";
|
||||
|
||||
/** Human label per audited company field — anything not listed here is skipped (internal/lock fields like `*FaydaSub`). */
|
||||
export const COMPANY_FIELD_LABELS: Record<string, string> = {
|
||||
name: "Company name",
|
||||
phone: "Phone",
|
||||
email: "Email",
|
||||
address: "Address",
|
||||
country: "Country",
|
||||
tin: "TIN",
|
||||
vatNumber: "VAT number",
|
||||
fanNumber: "FAN number",
|
||||
nationality: "Nationality",
|
||||
website: "Website",
|
||||
licenceNumber: "Licence number",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House No",
|
||||
contactPersonName: "Contact person name",
|
||||
contactPersonPhone: "Contact person phone",
|
||||
contactPersonEmail: "Contact person email",
|
||||
contactPersonPosition: "Contact person position",
|
||||
generalManagerName: "General manager name",
|
||||
generalManagerPhone: "General manager phone",
|
||||
generalManagerEmail: "General manager email",
|
||||
poaName: "PoA name",
|
||||
poaPhone: "PoA phone",
|
||||
poaEmail: "PoA email",
|
||||
poaLocation: "PoA location",
|
||||
poaAddress: "PoA address",
|
||||
documents: "Document",
|
||||
};
|
||||
|
||||
function displayValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the company row before a write against the patch about to be
|
||||
* applied (the same shape `mapProfileDtoToCompanyUpdates` returns: scalar
|
||||
* columns plus a merged `attributes` blob). Only fields with a known label
|
||||
* are reported, so identity-lock bookkeeping (`ownerFaydaSub`, etc.) never
|
||||
* shows up as noise.
|
||||
*/
|
||||
export function diffCompanyUpdate(
|
||||
before: Company,
|
||||
patch: Record<string, any>,
|
||||
): CompanyRevisionChange[] {
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
const { attributes: attrPatch, ...columnPatch } = patch;
|
||||
|
||||
for (const [field, nextRaw] of Object.entries(columnPatch)) {
|
||||
const label = COMPANY_FIELD_LABELS[field];
|
||||
if (!label) continue;
|
||||
const next = displayValue(nextRaw);
|
||||
const previous = displayValue((before as unknown as Record<string, unknown>)[field]);
|
||||
if (next === previous) continue;
|
||||
changes.push({ field, label, from: previous, to: next });
|
||||
}
|
||||
|
||||
if (attrPatch) {
|
||||
const beforeAttrs = before.attributes ?? {};
|
||||
for (const [field, nextRaw] of Object.entries(attrPatch)) {
|
||||
const label = COMPANY_FIELD_LABELS[field];
|
||||
if (!label) continue;
|
||||
const next = displayValue(nextRaw);
|
||||
const previous = displayValue(beforeAttrs[field]);
|
||||
if (next === previous) continue;
|
||||
changes.push({ field, label, from: previous, to: next });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Short human summary of a change set, e.g. "phone, address changed". */
|
||||
export function summarizeCompanyChanges(
|
||||
changes: CompanyRevisionChange[],
|
||||
): string {
|
||||
if (changes.length === 0) return "No changes";
|
||||
const labels = changes.map((c) => c.label.toLowerCase());
|
||||
return labels.length <= 3
|
||||
? `${labels.join(", ")} changed`
|
||||
: `${labels.length} fields changed`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CompanyRevisionRepository extends BaseRepository<CompanyRevision> {
|
||||
constructor(
|
||||
@InjectRepository(CompanyRevision)
|
||||
repo: Repository<CompanyRevision>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** Revision history for a company, newest first. */
|
||||
async findByCompanyId(companyId: string): Promise<CompanyRevision[]> {
|
||||
return this.repository.find({
|
||||
where: { companyId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,12 @@ export class CompanyInfoResponseDto {
|
||||
/**
|
||||
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
||||
* `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit
|
||||
* call to action, but the customer's edit appends to this SAME request
|
||||
* instead of starting a fresh one.
|
||||
*/
|
||||
review: {
|
||||
status: 'pending' | 'rejected';
|
||||
status: 'pending' | 'rejected' | 'changes_requested';
|
||||
note: string | null;
|
||||
} | null;
|
||||
|
||||
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
|
||||
const open =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.review = open
|
||||
? {
|
||||
status: open.status as 'pending' | 'rejected',
|
||||
status: open.status as 'pending' | 'rejected' | 'changes_requested',
|
||||
note: open.note ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
} from "../entities/company-revision.entity";
|
||||
|
||||
/** One version-history entry, shown on the backoffice customer detail page. */
|
||||
export class CompanyRevisionResponseDto {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorId: string | null;
|
||||
summary: string;
|
||||
changes: CompanyRevisionChange[];
|
||||
createdAt: Date;
|
||||
|
||||
constructor(revision: CompanyRevision) {
|
||||
this.id = revision.id;
|
||||
this.companyId = revision.companyId;
|
||||
this.actorId = revision.actorId ?? null;
|
||||
this.summary = revision.summary;
|
||||
this.changes = revision.changes ?? [];
|
||||
this.createdAt = revision.createdAt;
|
||||
}
|
||||
}
|
||||
@@ -68,10 +68,12 @@ export class ProfileResponseDto {
|
||||
|
||||
/**
|
||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
||||
* settings page; `"rejected"`/`"changes_requested"` both surface the note and
|
||||
* prefill the proposed values from `pendingChanges` so the customer can amend
|
||||
* & resubmit — `"changes_requested"` just appends the edit to this same
|
||||
* request instead of starting a fresh one.
|
||||
*/
|
||||
reviewStatus: "pending" | "rejected" | null;
|
||||
reviewStatus: "pending" | "rejected" | "changes_requested" | null;
|
||||
reviewNote: string | null;
|
||||
pendingChanges: Record<string, any> | null;
|
||||
|
||||
@@ -127,7 +129,8 @@ export class ProfileResponseDto {
|
||||
const openReview =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.reviewStatus =
|
||||
@@ -135,7 +138,9 @@ export class ProfileResponseDto {
|
||||
? "pending"
|
||||
: openReview?.status === ChangeRequestStatus.Rejected
|
||||
? "rejected"
|
||||
: null;
|
||||
: openReview?.status === ChangeRequestStatus.ChangesRequested
|
||||
? "changes_requested"
|
||||
: null;
|
||||
this.reviewNote = openReview?.note ?? null;
|
||||
this.pendingChanges = openReview?.snapshot ?? null;
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
|
||||
@@ -97,6 +97,7 @@ export class ResponseCompanyDto {
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
approvedAt: Date | null;
|
||||
|
||||
constructor(company: Company) {
|
||||
this.id = company.id;
|
||||
@@ -135,5 +136,6 @@ export class ResponseCompanyDto {
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
this.approvedAt = company.approvedAt ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
|
||||
/**
|
||||
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
||||
* settings page by an already-approved company are staged here (not written to
|
||||
* the live Company row) until a backoffice reviewer approves — at which point
|
||||
* the snapshot is applied — or rejects with a note, after which the customer can
|
||||
* amend and resubmit.
|
||||
* the live Company row) until a backoffice reviewer resolves it:
|
||||
* - Approved — the snapshot is applied to the live Company row.
|
||||
* - Rejected — terminal for this row; the customer's next edit starts a fresh one.
|
||||
* - ChangesRequested — soft: the row stays open with the reviewer's note attached,
|
||||
* so the customer's next edit is appended (merged) into this SAME row instead
|
||||
* of starting a new cycle.
|
||||
*/
|
||||
export enum ChangeRequestStatus {
|
||||
Pending = "pending",
|
||||
Approved = "approved",
|
||||
Rejected = "rejected",
|
||||
ChangesRequested = "changes_requested",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
/**
|
||||
* One recorded field/document change, as shown on the customer's version
|
||||
* history. A document change carries `fromFileId`/`toFileId` alongside the
|
||||
* display names, so the reviewer can open the previous and current file —
|
||||
* not just read that "a document changed."
|
||||
*/
|
||||
export interface CompanyRevisionChange {
|
||||
field: string;
|
||||
label: string;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
fromFileId?: string | null;
|
||||
toFileId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only audit of edits made to a company record BEFORE it reaches
|
||||
* `Active` (the onboarding phase), where {@link CompaniesService.updateProfile}
|
||||
* and {@link CompaniesService.uploadCompanyDocuments} write straight to the
|
||||
* live row with no approval gate — and, until this entity, no trace at all.
|
||||
* Post-approval edits already get history via `CompanyChangeRequest`; this
|
||||
* covers the gap before that gate exists.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "company_revisions" })
|
||||
@Index(["companyId"])
|
||||
export class CompanyRevision extends BaseEntity {
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
@Column({ name: "actor_id", type: "uuid", nullable: true })
|
||||
actorId?: string | null;
|
||||
|
||||
@Column({ name: "summary", type: "varchar", length: 255 })
|
||||
summary!: string;
|
||||
|
||||
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
|
||||
changes!: CompanyRevisionChange[];
|
||||
}
|
||||
@@ -61,6 +61,10 @@ export class Company extends BaseEntity {
|
||||
})
|
||||
status!: CompanyStatus;
|
||||
|
||||
/** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */
|
||||
@Column({ name: "approved_at", type: "timestamptz", nullable: true })
|
||||
approvedAt?: Date | null;
|
||||
|
||||
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
|
||||
tin!: string;
|
||||
|
||||
|
||||
@@ -51,7 +51,11 @@ export class FilesController {
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const record = await this.filesService.findById(fileId);
|
||||
// Includes soft-deleted records: a superseded document (replaced via a
|
||||
// single-file document slot, or resolved as part of a license/PoA swap)
|
||||
// is only reachable by UUID through the change-request/version-history
|
||||
// diff, where reviewers need to open the "previous" file to compare it.
|
||||
const record = await this.filesService.findByIdIncludingDeleted(fileId);
|
||||
|
||||
// Chat attachments are cross-tenant sensitive and this route has no
|
||||
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
||||
@@ -63,7 +67,9 @@ export class FilesController {
|
||||
);
|
||||
}
|
||||
|
||||
const { stream } = await this.filesService.streamById(fileId);
|
||||
const { stream } = await this.filesService.streamById(fileId, {
|
||||
includeDeleted: true,
|
||||
});
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
const disposition = forceDownload ? "attachment" : "inline";
|
||||
|
||||
|
||||
@@ -245,6 +245,23 @@ export class FilesService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link findById}, but also matches a soft-deleted record — a
|
||||
* superseded document (replaced via a single-file slot, or a resolved
|
||||
* license/PoA swap) is exactly this: gone from every live listing, but its
|
||||
* id is still handed to reviewers in the change-request/version-history
|
||||
* diff so they can open the "previous" file for comparison. Only the
|
||||
* preview/download route should use this; every other caller wants the
|
||||
* default (soft-deleted = not found).
|
||||
*/
|
||||
async findByIdIncludingDeleted(id: string): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findById(id, {
|
||||
withDeleted: true,
|
||||
});
|
||||
if (!record) throw new NotFoundException(`File ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a reviewer verdict on one document. `change_requested` keeps the note
|
||||
* (the customer sees it verbatim); any other verdict clears it, so a stale
|
||||
@@ -360,8 +377,11 @@ export class FilesService {
|
||||
|
||||
async streamById(
|
||||
id: string,
|
||||
opts: { includeDeleted?: boolean } = {},
|
||||
): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
const record = await this.findById(id);
|
||||
const record = opts.includeDeleted
|
||||
? await this.findByIdIncludingDeleted(id)
|
||||
: await this.findById(id);
|
||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
return { stream, record };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateCargoTypeDto {
|
||||
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
|
||||
@@ -32,6 +32,18 @@ export class CreateCargoTypeDto {
|
||||
@IsUUID('4', { each: true })
|
||||
wagonTypeIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'PER_ITEM cargo only: items that physically fit one wagon, keyed by wagon-type id ' +
|
||||
'(e.g. { "<nw5-id>": 4, "<nw7-id>": 6 }). Required for every wagonTypeId when ' +
|
||||
'unitOfMeasure is PER_ITEM.',
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'integer', minimum: 1 },
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -50,6 +50,16 @@ export class CargoType extends BaseEntity {
|
||||
})
|
||||
wagonTypes?: WagonType[];
|
||||
|
||||
/**
|
||||
* PER_ITEM (break-bulk) only: how many whole items physically fit each
|
||||
* allowed wagon type, keyed by wagon-type id (e.g. cars → { NW5: 4, NW7: 6 }).
|
||||
* Allocation loads min(this fit, floor(capacityTons / perItemTons)) per
|
||||
* wagon — floor space and rated tonnage bind independently. Keys are kept a
|
||||
* subset of the wagonTypes join rows by the cargo-types service.
|
||||
*/
|
||||
@Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true })
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
|
||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface IRatesRepository {
|
||||
create(data: Partial<Rate>): Promise<Rate>;
|
||||
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
/**
|
||||
* Flip a commodity's PER_TON↔PER_ITEM rates to match its unit of measure.
|
||||
* Both units bill the same stored quantity — only the name differs — so a
|
||||
* uom change must rename the units or bookings keep quoting "per ton" for
|
||||
* counted cargo. Returns the number of rates flipped.
|
||||
*/
|
||||
syncBulkQuantityUnit(cargoTypeId: string, unitOfMeasure: 'PER_TON' | 'PER_ITEM'): Promise<number>;
|
||||
}
|
||||
|
||||
export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY');
|
||||
|
||||
@@ -177,4 +177,16 @@ export class RatesRepository implements IRatesRepository {
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
|
||||
async syncBulkQuantityUnit(
|
||||
cargoTypeId: string,
|
||||
unitOfMeasure: 'PER_TON' | 'PER_ITEM',
|
||||
): Promise<number> {
|
||||
const from = unitOfMeasure === 'PER_ITEM' ? 'PER_TON' : 'PER_ITEM';
|
||||
const result = await this.repo.update(
|
||||
{ cargoTypeId, rateUnit: from },
|
||||
{ rateUnit: unitOfMeasure },
|
||||
);
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CargoUnitOfMeasure, PaginatedResponse } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -11,6 +17,7 @@ import {
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
ICargoTypesRepository,
|
||||
} from '../interfaces/cargo-types.repository.interface';
|
||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||
import { DisplayOrderService } from './display-order.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -18,6 +25,8 @@ export class CargoTypesService {
|
||||
constructor(
|
||||
@Inject(CARGO_TYPES_REPOSITORY)
|
||||
private readonly repository: ICargoTypesRepository,
|
||||
@Inject(RATES_REPOSITORY)
|
||||
private readonly ratesRepository: IRatesRepository,
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
@@ -38,6 +47,34 @@ export class CargoTypesService {
|
||||
return this.repository.findByCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* PER_ITEM (break-bulk) cargo must carry a whole-items-fit for EVERY allowed
|
||||
* wagon type — allocation caps each wagon at min(fit, tonnage) and a missing
|
||||
* fit would silently fall back to tonnage-only loading. Returns the map
|
||||
* trimmed to the allowed ids (stale keys from a removed wagon type drop out);
|
||||
* null when the cargo is not PER_ITEM or has no wagon types.
|
||||
*/
|
||||
private resolveItemsPerWagonMap(input: {
|
||||
unitOfMeasure?: CargoUnitOfMeasure | null;
|
||||
wagonTypeIds: string[];
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
}): Record<string, number> | null {
|
||||
if (input.unitOfMeasure !== CargoUnitOfMeasure.PerItem || !input.wagonTypeIds.length) {
|
||||
return null;
|
||||
}
|
||||
const map: Record<string, number> = {};
|
||||
for (const wagonTypeId of input.wagonTypeIds) {
|
||||
const fit = Number(input.itemsPerWagonMap?.[wagonTypeId]);
|
||||
if (!Number.isInteger(fit) || fit < 1) {
|
||||
throw new BadRequestException(
|
||||
`itemsPerWagonMap must define how many items fit wagon type ${wagonTypeId} (integer >= 1) for PER_ITEM cargo`,
|
||||
);
|
||||
}
|
||||
map[wagonTypeId] = fit;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Create a new cargo type. */
|
||||
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
||||
const code = generateCode(dto.cargoTypeName);
|
||||
@@ -62,26 +99,58 @@ export class CargoTypesService {
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
|
||||
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
|
||||
itemsPerWagonMap: this.resolveItemsPerWagonMap({
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
wagonTypeIds: dto.wagonTypeIds ?? [],
|
||||
itemsPerWagonMap: dto.itemsPerWagonMap,
|
||||
}),
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an existing cargo type. */
|
||||
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
|
||||
await this.findById(id);
|
||||
const existing = await this.findById(id);
|
||||
if (dto.parentGroupId) {
|
||||
if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent');
|
||||
const parent = await this.repository.findById(dto.parentGroupId);
|
||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||
}
|
||||
const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto;
|
||||
const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto;
|
||||
// Re-validate the fit map whenever anything it depends on moves — a partial
|
||||
// update merges with the stored values so e.g. adding a wagon type without
|
||||
// its fit still 400s. Untouched fields leave the stored map alone.
|
||||
const touchesItemsFit =
|
||||
wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined;
|
||||
const updated = await this.repository.update(id, {
|
||||
...columns,
|
||||
...(wagonTypeIds
|
||||
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) }
|
||||
: {}),
|
||||
...(touchesItemsFit
|
||||
? {
|
||||
itemsPerWagonMap: this.resolveItemsPerWagonMap({
|
||||
unitOfMeasure:
|
||||
dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure,
|
||||
wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id),
|
||||
itemsPerWagonMap:
|
||||
itemsPerWagonMap !== undefined ? itemsPerWagonMap : existing.itemsPerWagonMap,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
|
||||
// A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the
|
||||
// same stored quantity) — sync them or bookings keep quoting "per ton" for
|
||||
// counted cargo.
|
||||
if (
|
||||
dto.unitOfMeasure !== undefined &&
|
||||
dto.unitOfMeasure !== existing.unitOfMeasure &&
|
||||
(dto.unitOfMeasure === CargoUnitOfMeasure.PerTon ||
|
||||
dto.unitOfMeasure === CargoUnitOfMeasure.PerItem)
|
||||
) {
|
||||
await this.ratesRepository.syncBulkQuantityUnit(id, dto.unitOfMeasure);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
@@ -4060,7 +4061,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
|
||||
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
|
||||
const byItems = bulkItemWagonsRequired(booking, capacityTons);
|
||||
// `dimsFor` resolved dims from the first allowed wagon type, so charge that
|
||||
// same type's configured items-fit alongside its capacity.
|
||||
const byItems = bulkItemWagonsRequired(
|
||||
booking,
|
||||
capacityTons,
|
||||
bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id),
|
||||
);
|
||||
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bookingCargoTons, bulkItemWagonsRequired } from './train-capacity.util';
|
||||
import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
@@ -53,8 +53,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
|
||||
if (booking.freightType === 'BULK') {
|
||||
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
|
||||
// Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm`
|
||||
// holds the item count there, not tons.
|
||||
const byItems = bulkItemWagonsRequired(booking, capacity);
|
||||
// holds the item count there, not tons. No wagon type is fixed yet, so use
|
||||
// the best count across the cargo's allowed types (per-type items-fit
|
||||
// respected); falls back to `capacity` when the relation isn't loaded.
|
||||
const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity);
|
||||
if (byItems > 0) return byItems;
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
return Math.max(1, Math.ceil(weight / capacity));
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bookingGrossWeightTons,
|
||||
bookingTrainLengthMeters,
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
bulkItemWagonsRequired,
|
||||
consistUsage,
|
||||
consistViolations,
|
||||
@@ -76,6 +77,62 @@ describe('train-capacity.util', () => {
|
||||
expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0);
|
||||
expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0);
|
||||
});
|
||||
|
||||
describe('configured items-fit (floor space vs tonnage)', () => {
|
||||
it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => {
|
||||
// floor(70/20) = 3 by tonnage < 4 by floor space.
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17);
|
||||
});
|
||||
|
||||
it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => {
|
||||
// floor(70/10) = 7 by tonnage, but only 4 fit physically.
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13);
|
||||
});
|
||||
|
||||
it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => {
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17);
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17);
|
||||
// floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons.
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkItemWagonsForAllowedTypes', () => {
|
||||
const breakBulk = (quantity: number, weightTons: number) => ({
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: quantity,
|
||||
bulkTotalWeightTons: weightTons,
|
||||
});
|
||||
|
||||
it('picks the fewest-wagon allowed type, each capped by its own fit', () => {
|
||||
const cargoType = {
|
||||
wagonTypes: [
|
||||
{ id: 'nw5', capacityTons: 70 },
|
||||
{ id: 'nw7', capacityTons: 80 },
|
||||
],
|
||||
itemsPerWagonMap: { nw5: 4, nw7: 6 },
|
||||
};
|
||||
// 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons;
|
||||
// NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13.
|
||||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13);
|
||||
});
|
||||
|
||||
it('equals the old max-capacity estimate when no fits are configured', () => {
|
||||
const cargoType = {
|
||||
wagonTypes: [
|
||||
{ id: 'a', capacityTons: 50 },
|
||||
{ id: 'b', capacityTons: 70 },
|
||||
],
|
||||
};
|
||||
// Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17.
|
||||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17);
|
||||
});
|
||||
|
||||
it('falls back to the given capacity when the relation is missing', () => {
|
||||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17);
|
||||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingCargoTons (break-bulk weight preference)', () => {
|
||||
|
||||
@@ -120,6 +120,12 @@ export function bookingCargoTons(booking: {
|
||||
* 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons.
|
||||
* Returns 0 when the booking is not item-counted (PER_TON bulk, containers) —
|
||||
* callers then fall back to the pooled-tonnage math.
|
||||
*
|
||||
* `itemsFit` is the wagon type's PHYSICAL item capacity (floor space — from
|
||||
* cargoType.itemsPerWagonMap). It binds independently of tonnage: a 70T wagon
|
||||
* that fits 4 cars takes 3 cars of 20T (weight binds) but only 4 cars of 10T
|
||||
* (floor binds, 30T of rated capacity ride empty). Absent/invalid fit falls
|
||||
* back to tonnage-only (legacy cargo types without a configured fit).
|
||||
*/
|
||||
export function bulkItemWagonsRequired(
|
||||
booking: {
|
||||
@@ -128,6 +134,7 @@ export function bulkItemWagonsRequired(
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
},
|
||||
capacityTons: number,
|
||||
itemsFit?: number | null,
|
||||
): number {
|
||||
if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0;
|
||||
const quantity = num(booking.cargoTotalWeightVgm);
|
||||
@@ -136,10 +143,56 @@ export function bulkItemWagonsRequired(
|
||||
const perItemTons = totalWeightTons / quantity;
|
||||
// ponytail: an item heavier than a whole wagon still charges 1 wagon per
|
||||
// item; reject such bookings at creation time if the case turns real.
|
||||
const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons));
|
||||
const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons));
|
||||
const byFloor = num(itemsFit) >= 1 ? Math.floor(num(itemsFit)) : Infinity;
|
||||
const itemsPerWagon = Math.min(byTonnage, byFloor);
|
||||
return Math.max(1, Math.ceil(quantity / itemsPerWagon));
|
||||
}
|
||||
|
||||
type ItemFitCargoType = {
|
||||
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
} | null;
|
||||
|
||||
/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */
|
||||
export function bulkItemsFitFor(
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
): number | null {
|
||||
const fit = wagonTypeId ? Number(cargoType?.itemsPerWagonMap?.[wagonTypeId]) : NaN;
|
||||
return Number.isFinite(fit) && fit >= 1 ? fit : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-bulk wagon count when no single wagon type is fixed yet: the best
|
||||
* (fewest-wagon) count across the cargo type's allowed wagon types, each
|
||||
* respecting its own items-fit. With no fits configured this equals the old
|
||||
* max-capacity estimate; with no allowed types it degrades to
|
||||
* `fallbackCapacityTons` tonnage-only.
|
||||
*/
|
||||
export function bulkItemWagonsForAllowedTypes(
|
||||
booking: {
|
||||
freightType?: string | null;
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
},
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
fallbackCapacityTons: number,
|
||||
): number {
|
||||
const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0);
|
||||
if (!allowed.length) return bulkItemWagonsRequired(booking, fallbackCapacityTons);
|
||||
let best = 0;
|
||||
for (const wagonType of allowed) {
|
||||
const wagons = bulkItemWagonsRequired(
|
||||
booking,
|
||||
num(wagonType.capacityTons),
|
||||
bulkItemsFitFor(cargoType, wagonType.id),
|
||||
);
|
||||
if (wagons > 0 && (best === 0 || wagons < best)) best = wagons;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
|
||||
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
||||
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
||||
|
||||
@@ -3,7 +3,12 @@ import { AllocationLoadType } from '@edr/types';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
consistViolations,
|
||||
} from './train-capacity.util';
|
||||
|
||||
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||
@@ -175,7 +180,11 @@ export function buildBulkWagonPlan(
|
||||
// Break-bulk (PER_ITEM) bookings size by indivisible items per booking —
|
||||
// their tonnage must NOT pool with PER_TON cargo (an item can't split
|
||||
// across wagons the way loose tonnage can).
|
||||
const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity));
|
||||
const itemSlotsByBooking = bookings.map((b) =>
|
||||
// The plan fixed THIS wagon type, so its configured items-fit binds — not
|
||||
// the best fit across the cargo's allowed types.
|
||||
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
|
||||
);
|
||||
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||||
const totalWeight = roundTons(
|
||||
bookings.reduce(
|
||||
|
||||
Reference in New Issue
Block a user