Merge pull request #1208 from Tria-plc/dev

to stagin
This commit is contained in:
Nathnael Wondisha
2026-08-10 09:59:53 +03:00
committed by GitHub
31 changed files with 13638 additions and 553 deletions

View File

@@ -82,8 +82,8 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder"; import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder";
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; // import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; // import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; // import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules //New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module"; import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
@@ -281,8 +281,8 @@ if (!process.env.APPLICATION_NAME) {
// WarehouseDemoSeeder, // WarehouseDemoSeeder,
// ExportDjiboutiInterchangeDemoSeeder, // ExportDjiboutiInterchangeDemoSeeder,
// MarshallingDemoTrainsSeeder, // MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder, // ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder, // PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware, LoginAudienceMiddleware,
// Feeds position-TYPE grants to the synchronous permission checks — without // Feeds position-TYPE grants to the synchronous permission checks — without
// it, staff whose permissions live on their position type resolve to none. // it, staff whose permissions live on their position type resolve to none.

View File

@@ -10,7 +10,7 @@ import {
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/** /**
@@ -55,10 +55,13 @@ export class BookingLifecycleNotifierService {
logLabel: string, logLabel: string,
): Promise<void> { ): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`); this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.companyId // Both channels come from the same resolver: the company row's own columns
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) // are only half the story (see companyNotifyEmailExpr), and reading them off
: null; // the loaded entity silently dropped every mail to a company whose address
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; // lives in `attributes`.
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) { if (phone) {
try { try {

View File

@@ -92,6 +92,7 @@ interface CarriageAcceptanceWagonRow {
interface CarriageAcceptanceReceivedRow { interface CarriageAcceptanceReceivedRow {
allocatedWeightTons: string | null; allocatedWeightTons: string | null;
containerNumbers: string | null; containerNumbers: string | null;
sealNumbers?: string | null;
} }
const URGENT_PRIORITY_THRESHOLD = 1000; const URGENT_PRIORITY_THRESHOLD = 1000;
@@ -291,15 +292,19 @@ export class BookingsService {
if (pendingWagons) { if (pendingWagons) {
// Direct truck-to-train cargo never enters the warehouse, so there is no // Direct truck-to-train cargo never enters the warehouse, so there is no
// GRN'd inventory to build the sheet from. Choosing direct handover is // GRN'd inventory to build the sheet from. Choosing direct handover is
// itself the acceptance, so the sheet issues off the booking's own // itself the acceptance, so the sheet issues off the containers the
// containers (or its VGM weight when the cargo is bulk). // customer declared on the booking — freight.containers only gains rows at
// allocation, by which point the wagon query above already serves.
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
? await this.dataSource.query( ? await this.dataSource.query(
`SELECT NULL::numeric AS "allocatedWeightTons", `SELECT NULL::numeric AS "allocatedWeightTons",
c.container_number AS "containerNumbers" unit.container_number AS "containerNumbers",
FROM freight.containers c unit.seal_number AS "sealNumbers"
WHERE c.booking_id = $1 AND c.deleted_at IS NULL FROM freight.booking_container_units unit
ORDER BY c.container_number`, JOIN freight.booking_container line
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
WHERE line.booking_id = $1 AND unit.deleted_at IS NULL
ORDER BY unit.container_number`,
[bookingId], [bookingId],
) )
: booking.tradeDirection === 'EXPORT' : booking.tradeDirection === 'EXPORT'
@@ -319,11 +324,13 @@ export class BookingsService {
) )
: []; : [];
// Bulk direct cargo has no containers — one line carrying the booking's // Bulk direct cargo has no containers — one line carrying the booking's
// declared weight still makes a valid sheet. // declared weight still makes a valid sheet. bulkTotalWeightTons only
// holds the real tonnage for PER_ITEM break-bulk; everywhere else (PER_TON
// bulk and every container booking) the VGM column is the weight.
if (isDirectExport && receivedLines.length === 0) { if (isDirectExport && receivedLines.length === 0) {
const totalWeight = booking.bulkTotalWeightTons ?? booking.cargoTotalWeightVgm;
receivedLines.push({ receivedLines.push({
allocatedWeightTons: allocatedWeightTons: totalWeight == null ? null : String(totalWeight),
booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons),
containerNumbers: null, containerNumbers: null,
}); });
} }
@@ -347,7 +354,7 @@ export class BookingsService {
marshalledAt: null, marshalledAt: null,
arrivalAt: null, arrivalAt: null,
containerNumbers: row.containerNumbers, containerNumbers: row.containerNumbers,
sealNumbers: null, sealNumbers: row.sealNumbers ?? null,
})); }));
} }

View File

@@ -147,6 +147,10 @@ function makeService(overrides: Partial<Ctx> = {}) {
verifayda: { verifayda: {
completeVerification: jest.fn(async () => ctx.verification), completeVerification: jest.fn(async () => ctx.verification),
}, },
// Onboarding requirements resolve the nationality's document set through
// this; no setting means no company documents, which keeps those cases
// focused on the identity/PoA half of the list.
fileUploadSettings: { getByCode: jest.fn(async () => null) },
}; };
const service = new CompaniesService( const service = new CompaniesService(
@@ -157,7 +161,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
deps.profilesRepo as never, deps.profilesRepo as never,
{} as never, {} as never,
deps.filesService as never, deps.filesService as never,
{} as never, deps.fileUploadSettings as never,
{} as never, {} as never,
deps.companyNotifier as never, deps.companyNotifier as never,
{} as never, {} as never,
@@ -763,6 +767,73 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
expect(state.gm.email).toBe("legacy@example.com"); expect(state.gm.email).toBe("legacy@example.com");
}); });
// The mirror image, and the reason the fallback above is gated on the GM
// being unverified: a manager Fayda proved but supplied no email for types
// one instead, and the portal decides whether to render that input by asking
// whether the identity holds one. Reading the typed column back as part of
// the verified identity would answer "yes" the moment it was saved — the
// input would vanish and a typo could never be corrected.
it("keeps a verified GM's typed email out of the verified identity", async () => {
const { service, company } = makeService({
attributes: {
...OWNER_VERIFIED,
gmFaydaSub: "gm-sub",
gmFaydaVerifiedAt: "2026-07-03T00:00:00.000Z",
gmName: "Derartu Tulu",
generalManagerName: "Derartu Tulu",
generalManagerEmail: "typed@example.com",
},
});
const state = service.getCompanyIdentityState(company() as never);
expect(state.gm.verified).toBe(true);
expect(state.gm.name).toBe("Derartu Tulu");
expect(state.gm.email).toBeNull();
});
it("never stands the account in for a GM Fayda gave no email", async () => {
// Deliberate: the account is the person onboarding, not necessarily the
// manager. The portal asks for the email instead.
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED },
verification: {
purpose: "VERIFY",
verified: true,
sub: "gm-sub",
fullName: "Derartu Tulu",
phoneNumber: "+251911222333",
},
});
const state = await service.completeIdentityVerification(
"user-1",
{ subject: "gm", code: "c", state: "s" },
{ email: "account@example.com", phoneNumber: "+251911777777" },
);
expect(ctx.attributes.gmEmail).toBeUndefined();
expect(state.gm.verified).toBe(true);
expect(state.gm.email).toBeNull();
});
it("accepts the email typed for a GM whose verification carried none", async () => {
const { service, ctx } = makeService({
attributes: {
...OWNER_VERIFIED,
gmFaydaSub: "gm-sub",
gmName: "Derartu Tulu",
generalManagerName: "Derartu Tulu",
},
});
await service.updateProfile("user-1", {
generalManagerEmail: "gm@example.com",
} as never);
expect(ctx.attributes.generalManagerEmail).toBe("gm@example.com");
});
it("does not let an unproven GM block the company from trading", async () => { it("does not let an unproven GM block the company from trading", async () => {
// The GM names who to talk to, not what the company may do. Capturing it // The GM names who to talk to, not what the company may do. Capturing it
// through Fayda changed how it is collected, not whether it gates. // through Fayda changed how it is collected, not whether it gates.
@@ -775,3 +846,94 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
}); });
/**
* Fayda's email and phone claims are optional, so a *verified* representative
* can still be missing the details `REQUIRED_POA_FIELDS` demands. The PoA step
* renders an input for whatever the verification did not supply — so onboarding
* has to report them outstanding, rather than letting a freight forwarder
* submit an incomplete representative and be refused its next PoA edit for it.
*/
describe("onboarding requirements name the PoA details Fayda did not supply", () => {
const POA_VERIFIED_NO_CONTACTS = {
poaFaydaSub: "poa-sub",
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
poaName: "Tirunesh Dibaba",
};
it("reports the missing email and phone for a freight forwarder", async () => {
const { service } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED_NO_CONTACTS },
profileTypes: [ProfileType.freightForwarder],
files: [paper()],
});
const req = await service.getOnboardingRequirements("user-1");
expect(req.poa.missingFields.map((f) => f.key)).toEqual([
"poaEmail",
"poaPhone",
]);
expect(req.poa.complete).toBe(false);
expect(req.outstanding).toEqual(
expect.arrayContaining(["Add your poa email", "Add your poa phone"]),
);
});
it("clears once they are typed", async () => {
const { service } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
profileTypes: [ProfileType.freightForwarder],
files: [paper()],
});
const req = await service.getOnboardingRequirements("user-1");
expect(req.poa.missingFields).toEqual([]);
expect(req.poa.complete).toBe(true);
expect(req.outstanding).not.toContain("Add your poa email");
});
// Fayda's email claim is optional and the GM's verification has no account to
// fall back on, so demanding one blocked a manager the government had already
// proved. `companyNotifyEmailExpr` resolves the address from the contact
// person or the registering account instead, so nothing needs this filled.
it("does not hold a company back for a general manager with no email", async () => {
const { service } = makeService({
attributes: {
...OWNER_VERIFIED,
gmFaydaSub: "gm-sub",
generalManagerName: "Derartu Tulu",
generalManagerPhone: "+251911222333",
},
});
const req = await service.getOnboardingRequirements("user-1");
expect(req.companyInfo.missingFields.map((f) => f.key)).not.toContain(
"generalManagerEmail",
);
expect(req.outstanding).not.toContain("Add your general manager email");
});
it("still holds it back for the manager's name and phone", async () => {
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
const req = await service.getOnboardingRequirements("user-1");
expect(req.companyInfo.missingFields.map((f) => f.key)).toEqual(
expect.arrayContaining(["generalManagerName", "generalManagerPhone"]),
);
});
// An importer that never named a representative owes nothing here — the step
// is one it may walk straight past.
it("asks nothing of a company with no PoA at all", async () => {
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
const req = await service.getOnboardingRequirements("user-1");
expect(req.poa.missingFields).toEqual([]);
expect(req.poa.complete).toBe(true);
});
});

View File

@@ -253,11 +253,14 @@ export class CompaniesService {
label: "General manager name", label: "General manager name",
get: (c) => c.attributes?.generalManagerName, get: (c) => c.attributes?.generalManagerName,
}, },
{ // The manager's EMAIL is deliberately absent. It was demanded because the
key: "generalManagerEmail", // notifiers were believed to mail it, and Fayda's email claim is optional
label: "General manager email", // — so a manager the government proved without one blocked the whole
get: (c) => c.attributes?.generalManagerEmail, // submission over an address nothing could produce. `companyNotifyEmailExpr`
}, // now resolves the address itself and falls through to the contact
// person's, then to the registering account's (which signup guarantees),
// so nothing depends on this being filled. It is still collected and still
// preferred when present; it just no longer holds the company hostage.
{ {
key: "generalManagerPhone", key: "generalManagerPhone",
label: "General manager phone", label: "General manager phone",
@@ -2042,13 +2045,21 @@ export class CompaniesService {
const poaProvided = POA_ATTRIBUTES.some((k) => const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(), (company.attributes?.[k] as string | undefined)?.trim(),
); );
// No company types its PoA details — they arrive from the Fayda
// verification whatever the nationality — so reporting them as missing
// fields would ask for something no form offers. The identity block below
// reports "verify your PoA" instead.
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
const delegation = await this.getPoaDelegationState(company.id); const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided; const delegationDue = poaRequired || poaProvided;
// The representative's details normally arrive from their Fayda
// verification — but Fayda's email and phone claims are optional and
// routinely come back empty, and the PoA step renders an input for whatever
// the verification did not supply. So these are askable after all, and are
// reported outstanding once a PoA is required or provided; reporting
// nothing here let a freight forwarder finish onboarding with a
// representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then
// 400'd their next PoA edit for it.
const missingPoaFields = delegationDue
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
const missingDelegation = delegationDue && !delegation.onFile; const missingDelegation = delegationDue && !delegation.onFile;
// A paper the reviewer sent back is not evidence — the customer has to // A paper the reviewer sent back is not evidence — the customer has to
// replace it before the application counts as complete. // replace it before the application counts as complete.
@@ -2101,7 +2112,10 @@ export class CompaniesService {
// fields, required documents, one license per operational profile, and the // fields, required documents, one license per operational profile, and the
// PoA details/paper whenever those are mandatory. // PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length; const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount = delegationDue ? 1 : 0; // The delegation paper plus the representative's own required details —
// `completed` below subtracts every one of those it is still missing, so
// leaving them out of the total would make the bar understate progress.
const poaItemCount = delegationDue ? 1 + REQUIRED_POA_FIELDS.length : 0;
// One item per identity credential the company has to prove: the owner // One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once // always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — Fayda for an Ethiopian company, a named representative // there is one — Fayda for an Ethiopian company, a named representative

View File

@@ -10,7 +10,7 @@ import {
import { Company, CompanyStatus } from "./entities/company.entity"; import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service"; import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; import { resolveCompanyNotifyContact } from "../notifications/resolve-company-phone.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
/** Account statuses that lock the customer out and therefore must be told to them. */ /** Account statuses that lock the customer out and therefore must be told to them. */
@@ -38,8 +38,13 @@ export class CompanyNotifierService {
/** Send SMS + email to the company contact; log-only on failure. */ /** Send SMS + email to the company contact; log-only on failure. */
private async notifyContact(company: Company, message: string): Promise<void> { private async notifyContact(company: Company, message: string): Promise<void> {
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id); // One resolver for both channels — the company row's own email column is
const email = company.email ?? company.generalManagerEmail ?? null; // only set for a Fayda-verified owner (see companyNotifyEmailExpr), which
// left an approval/suspension notice unsent to everyone else.
const { phone, email } = await resolveCompanyNotifyContact(
this.dataSource,
company.id,
);
if (phone) { if (phone) {
try { try {

View File

@@ -143,12 +143,18 @@ function stateFor(
birthdate: read(`${p}Birthdate`), birthdate: read(`${p}Birthdate`),
gender: read(`${p}Gender`), gender: read(`${p}Gender`),
}; };
if (subject !== "gm") return state; if (subject !== "gm" || state.verified) return state;
// Companies onboarded before the GM was verifiable have typed details and no // Companies onboarded before the GM was verifiable have typed details and no
// `gm*` attributes at all. Report those rather than a blank card — they are // `gm*` attributes at all. Report those rather than a blank card — they are
// still what the notifiers mail — leaving `verified` false so the portal // still what the notifiers mail — leaving `verified` false so the portal
// offers the upgrade instead of pretending the identity is proven. // offers the upgrade instead of pretending the identity is proven.
//
// Only for such an unverified GM, which is the whole population this exists
// for. Merging the typed columns into a *verified* manager's state would read
// back the email the portal asked them to type when Fayda supplied none, and
// the input offering it — keyed on that value being absent — would vanish the
// moment it was saved, leaving a typo uncorrectable.
return { return {
...state, ...state,
name: state.name ?? read(GM_TYPED_KEYS.name), name: state.name ?? read(GM_TYPED_KEYS.name),

View File

@@ -10,7 +10,7 @@ import {
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/** /**
@@ -52,10 +52,11 @@ export class ContractNotifierService {
logLabel: string, logLabel: string,
): Promise<void> { ): Promise<void> {
this.logger.log(`${logLabel}${this.ref(c)}`); this.logger.log(`${logLabel}${this.ref(c)}`);
const phone = c.companyId // One resolver for both channels — the company row's own email column is
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId) // only set for a Fayda-verified owner (see companyNotifyEmailExpr).
: null; const { phone, email } = c.companyId
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; ? await resolveCompanyNotifyContact(this.dataSource, c.companyId)
: { phone: null, email: null };
if (phone) { if (phone) {
try { try {

View File

@@ -51,8 +51,11 @@ export class EmailClientService implements OnApplicationBootstrap {
this.logger.log( this.logger.log(
`EMAIL publish to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' confirmed=${queued}`, `EMAIL publish to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' confirmed=${queued}`,
); );
// Recipient + content are PII — debug only. // Full content logged on purpose (audit of what actually left the API).
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`); // Note: recipient + body are PII and land in plain application logs.
this.logger.log(
`EMAIL payload to=${dto.to} subject="${dto.subject}" text="${dto.text ?? ""}" html="${dto.html ?? ""}"`,
);
return { queued }; return { queued };
} }

View File

@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { import {
companyNotifyEmailExpr,
companyNotifyPhoneExpr, companyNotifyPhoneExpr,
primaryContactUserJoin, primaryContactUserJoin,
} from './resolve-company-phone.util'; } from './resolve-company-phone.util';
@@ -19,7 +20,8 @@ export async function sendCompanyChannels(
): Promise<void> { ): Promise<void> {
const [contact]: Array<{ phone: string | null; email: string | null }> = const [contact]: Array<{ phone: string | null; email: string | null }> =
await dataSource.query( await dataSource.query(
`SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email `SELECT ${companyNotifyPhoneExpr('co')} AS phone,
${companyNotifyEmailExpr('co')} AS email
FROM freight.companies co FROM freight.companies co
${primaryContactUserJoin('co')} ${primaryContactUserJoin('co')}
WHERE co.id = $1 AND co.deleted_at IS NULL`, WHERE co.id = $1 AND co.deleted_at IS NULL`,

View File

@@ -1,7 +1,7 @@
import { DataSource, EntityManager } from "typeorm"; import { DataSource, EntityManager } from "typeorm";
/** /**
* Where a customer-facing SMS actually goes. * Where a customer-facing SMS or email actually goes.
* *
* The person who signs up, logs in, and receives OTPs is an IAM user, and * The person who signs up, logs in, and receives OTPs is an IAM user, and
* `iam.users.phone_number` is the number they control and can change themselves * `iam.users.phone_number` is the number they control and can change themselves
@@ -12,11 +12,13 @@ import { DataSource, EntityManager } from "typeorm";
* `companies.contact_person_phone` is deliberately NOT consulted: the live write * `companies.contact_person_phone` is deliberately NOT consulted: the live write
* path stores that value in the `attributes` jsonb and has never populated the * path stores that value in the `attributes` jsonb and has never populated the
* column, so every reader of it was silently falling through to `phone` anyway. * column, so every reader of it was silently falling through to `phone` anyway.
* `companies.general_manager_email` is the same trap on the email side — see
* {@link companyNotifyEmailExpr}.
*/ */
/** /**
* LEFT JOIN a company alias to its primary contact's IAM user, exposing * LEFT JOIN a company alias to its primary contact's IAM user, exposing
* `pc.phone_number`. * `pc.phone_number` and `pc.email`.
* *
* LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a * LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a
* company having two `is_primary_contact` rows, and a plain join would then * company having two `is_primary_contact` rows, and a plain join would then
@@ -28,7 +30,7 @@ import { DataSource, EntityManager } from "typeorm";
export function primaryContactUserJoin(alias: string): string { export function primaryContactUserJoin(alias: string): string {
return ` return `
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT u.phone_number SELECT u.phone_number, u.email
FROM freight.external_profiles ep FROM freight.external_profiles ep
JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true
WHERE ep.company_id = ${alias}.id WHERE ep.company_id = ${alias}.id
@@ -44,17 +46,45 @@ export function companyNotifyPhoneExpr(alias: string): string {
return `COALESCE(pc.phone_number, ${alias}.phone)`; return `COALESCE(pc.phone_number, ${alias}.phone)`;
} }
/** The SMS number for one company, or null when neither source has one. */ /**
export async function resolveCompanyNotifyPhone( * SQL expression for the company's notification address, given the joined `pc`
* alias.
*
* `companies.email` alone is not enough: it is written from ONE place — a
* Fayda-verified owner's email claim — so a foreign company, whose owner proves
* identity by passport instead, never gets one. Readers papered over that with
* `COALESCE(email, general_manager_email)`, but that column has the same problem
* `contact_person_phone` has above: onboarding writes the value into the
* `attributes` jsonb and nothing has ever populated the column, so the fallback
* could not fire and the mail was dropped in silence.
*
* So: the company address, then the two the customer actually filled in during
* onboarding, then the account that registered them — which always has one,
* signup requires it. `NULLIF` because a blank jsonb key is not an address and
* `COALESCE` would happily stop on it.
*/
export function companyNotifyEmailExpr(alias: string): string {
return `COALESCE(
NULLIF(${alias}.email, ''),
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
NULLIF(${alias}.attributes->>'contactPersonEmail', ''),
NULLIF(pc.email, '')
)`;
}
/** Both channels for one company; either side is null when nothing has one. */
export async function resolveCompanyNotifyContact(
db: DataSource | EntityManager, db: DataSource | EntityManager,
companyId: string, companyId: string,
): Promise<string | null> { ): Promise<{ phone: string | null; email: string | null }> {
const rows: Array<{ phone: string | null }> = await db.query( const rows: Array<{ phone: string | null; email: string | null }> =
`SELECT ${companyNotifyPhoneExpr("co")} AS phone await db.query(
`SELECT ${companyNotifyPhoneExpr("co")} AS phone,
${companyNotifyEmailExpr("co")} AS email
FROM freight.companies co FROM freight.companies co
${primaryContactUserJoin("co")} ${primaryContactUserJoin("co")}
WHERE co.id = $1 AND co.deleted_at IS NULL`, WHERE co.id = $1 AND co.deleted_at IS NULL`,
[companyId], [companyId],
); );
return rows[0]?.phone ?? null; return { phone: rows[0]?.phone ?? null, email: rows[0]?.email ?? null };
} }

View File

@@ -52,8 +52,9 @@ export class SmsClientService implements OnApplicationBootstrap {
this.logger.log( this.logger.log(
`SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' confirmed=${queued}`, `SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' confirmed=${queued}`,
); );
// Recipient + content are PII — debug only. // Full content logged on purpose (audit of what actually left the API).
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`); // Note: recipient + body are PII and land in plain application logs.
this.logger.log(`SMS payload to=${dto.to} text="${dto.message}"`);
return { queued }; return { queued };
} }
@@ -81,7 +82,7 @@ export class SmsClientService implements OnApplicationBootstrap {
this.logger.log( this.logger.log(
`BULK SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} confirmed=${queued}`, `BULK SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} confirmed=${queued}`,
); );
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`); this.logger.log(`BULK SMS payload messages=${JSON.stringify(messages)}`);
return { queued }; return { queued };
} }

View File

@@ -95,6 +95,20 @@ export class BookingJourneyService {
await manager await manager
.getRepository(TrainScheduleBooking) .getRepository(TrainScheduleBooking)
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' }); .update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' });
// Warehouse cargo may be loaded either from the warehouse Load-to-Train
// queue or from the schedule itself. Loading here must move its inventory
// too, otherwise the goods read as still sitting in the shed while the
// train leaves with them. No-ops for direct truck-to-train (no inventory).
// ponytail: no WarehouseLoading record on this path — those are only read
// back as per-inventory loading history, never billed. Create them here if
// that history ever has to be complete.
await manager.query(
`UPDATE freight.warehouse_inventory
SET status = 'LOADED', loaded_at = COALESCE(loaded_at, $2), updated_at = NOW()
WHERE booking_id = $1 AND deleted_at IS NULL
AND status NOT IN ('LOADED', 'DISPATCHED')`,
[bookingId, now],
);
// The facility handed the cargo over — raise its GRN. No-ops for yards // The facility handed the cargo over — raise its GRN. No-ops for yards
// without a facility (import/export terminals), which keep their own flow. // without a facility (import/export terminals), which keep their own flow.
await this.facilityHandling.recordHandling(manager, { await this.facilityHandling.recordHandling(manager, {

View File

@@ -11,7 +11,7 @@ import {
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { BATCH_TIMEZONE } from './booking-batch.constants'; import { BATCH_TIMEZONE } from './booking-batch.constants';
@@ -65,10 +65,11 @@ export class BookingNotifierService {
logLabel: string, logLabel: string,
): Promise<void> { ): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`); this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.companyId // One resolver for both channels — the company row's own email column is
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) // only set for a Fayda-verified owner (see companyNotifyEmailExpr).
: null; const { phone, email } = b.companyId
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; ? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) { if (phone) {
try { try {

View File

@@ -14,6 +14,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { import {
companyNotifyEmailExpr,
companyNotifyPhoneExpr, companyNotifyPhoneExpr,
primaryContactUserJoin, primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util'; } from '../notifications/resolve-company-phone.util';
@@ -649,7 +650,7 @@ export class BookingWindowService implements OnModuleInit {
`SELECT DISTINCT `SELECT DISTINCT
c.company_id, c.company_id,
${companyNotifyPhoneExpr('co')} AS phone, ${companyNotifyPhoneExpr('co')} AS phone,
COALESCE(co.email, co.general_manager_email) AS email ${companyNotifyEmailExpr('co')} AS email
FROM freight.contract_routes cr FROM freight.contract_routes cr
JOIN freight.contracts c JOIN freight.contracts c
ON c.id = cr.contract_id ON c.id = cr.contract_id
@@ -761,7 +762,7 @@ export class BookingWindowService implements OnModuleInit {
b.id AS "bookingId", b.id AS "bookingId",
b.company_id AS "companyId", b.company_id AS "companyId",
${companyNotifyPhoneExpr('co')} AS phone, ${companyNotifyPhoneExpr('co')} AS phone,
COALESCE(co.email, co.general_manager_email) AS email, ${companyNotifyEmailExpr('co')} AS email,
COALESCE(oy.label, oy.code) || ' to ' || COALESCE(oy.label, oy.code) || ' to ' ||
COALESCE(dy.label, dy.code) AS corridor COALESCE(dy.label, dy.code) AS corridor
FROM freight.bookings b FROM freight.bookings b

View File

@@ -46,12 +46,7 @@ import PaymentsPage from "./pages/payments/PaymentsPage";
import AuditLogsPage from "./pages/audit/AuditLogsPage"; import AuditLogsPage from "./pages/audit/AuditLogsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission"; import { RequirePermission } from "./components/auth/RequirePermission";
import { import { FREIGHT_PERMS } from "./lib/permissions";
FREIGHT_PERMS,
isDjiboutiGl,
isEthiopianGl,
isSuperAdmin,
} from "./lib/permissions";
import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage"; import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
@@ -92,7 +87,6 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"; import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage";
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage"; import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
@@ -119,11 +113,8 @@ import SupportInboxPage from "./pages/support/SupportInboxPage";
import { import {
APP_TITLE, APP_TITLE,
buildSidebarSections, buildSidebarSections,
DJ_CLEARANCE_HREF,
ET_CLEARANCE_HREF,
filterSidebarByPermission, filterSidebarByPermission,
findActiveSidebarLabel, findActiveSidebarLabel,
GL_WORKFLOW_PATH_PATTERNS,
} from "@/components/layout/sidebar-sections"; } from "@/components/layout/sidebar-sections";
const DashboardShell = () => { const DashboardShell = () => {
@@ -139,18 +130,6 @@ const DashboardShell = () => {
); );
const displayName = user?.name?.en || user?.username || user?.email || "User"; const displayName = user?.name?.en || user?.username || user?.email || "User";
// GL positions are locked to their single clearance page: if they navigate
// (or deep-link) anywhere else, send them back to their clearance hub.
// Super admin is exempt. Allow the clearance path + its detail sub-routes.
const superAdmin = isSuperAdmin(user);
const glClearanceHome = !superAdmin
? isEthiopianGl(user)
? ET_CLEARANCE_HREF
: isDjiboutiGl(user)
? DJ_CLEARANCE_HREF
: null
: null;
useEffect(() => { useEffect(() => {
const activeLabel = findActiveSidebarLabel( const activeLabel = findActiveSidebarLabel(
location.pathname, location.pathname,
@@ -159,20 +138,9 @@ const DashboardShell = () => {
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE; document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
}, [location.pathname, sidebarSections]); }, [location.pathname, sidebarSections]);
if (
glClearanceHome &&
!location.pathname.startsWith(glClearanceHome) &&
!GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname))
) {
return <Navigate to={glClearanceHome} replace />;
}
return ( return (
<FreightDashboardLayout <FreightDashboardLayout
sidebarSections={sidebarSections} sidebarSections={sidebarSections}
// GL Ethiopia / GL Djibouti are locked to a single clearance page — no
// sidebar (or mobile burger) at all; the page renders full width.
hideSidebar={Boolean(glClearanceHome)}
activeHref={location.pathname} activeHref={location.pathname}
onNavigate={navigate} onNavigate={navigate}
enableThemeToggle enableThemeToggle
@@ -506,7 +474,6 @@ const App = () => {
<Route path="intercity" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><IntercityPage /></RequirePermission>} /> <Route path="intercity" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><IntercityPage /></RequirePermission>} />
<Route path="trucks-on-site" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><TrucksOnSitePage /></RequirePermission>} /> <Route path="trucks-on-site" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><TrucksOnSitePage /></RequirePermission>} />
<Route path="import-trucks" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportTrucksPage /></RequirePermission>} /> <Route path="import-trucks" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportTrucksPage /></RequirePermission>} />
<Route path="edr-last-mile-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><EDRLastMileReturnsPage /></RequirePermission>} />
<Route path="container-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ContainerReturnsPage /></RequirePermission>} /> <Route path="container-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ContainerReturnsPage /></RequirePermission>} />
<Route path="loaded-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadedInventoryPage /></RequirePermission>} /> <Route path="loaded-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadedInventoryPage /></RequirePermission>} />
<Route path="dispatch-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><DispatchQueuePage /></RequirePermission>} /> <Route path="dispatch-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><DispatchQueuePage /></RequirePermission>} />

View File

@@ -344,12 +344,6 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
icon: <Truck />, icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view, permission: FREIGHT_PERMS.warehouseInventory.view,
}, },
{
label: "EDR Last Mile Returns",
href: "/dashboard/edr-last-mile-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{ {
label: "Container Returns", label: "Container Returns",
href: "/dashboard/container-returns", href: "/dashboard/container-returns",

View File

@@ -1,406 +0,0 @@
import { Fragment, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Group,
Loader,
Modal,
Stack,
Table,
Text,
TextInput,
Textarea,
Select,
Checkbox,
} from "@mantine/core";
import { ChevronDown, ChevronRight } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { importOperationsService } from "@/services/importOperations.service";
interface ReturnContainer {
containerNumber: string;
size: string | null;
type: string | null;
selected: boolean;
}
interface TruckReturn {
key: string;
plate: string;
companyName: string | null;
bookingRef: string;
bookingId: string;
customerId: string | null;
containers: ReturnContainer[];
}
export default function EDRLastMileReturnsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [expanded, setExpanded] = useState<string | null>(null);
const [returnModalOpen, setReturnModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
queryKey: ["import-unloaded-queue"],
queryFn: async () => {
const response = await api.warehouses.importUnloadedQueue.call();
return response ?? [];
},
});
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
const truckReturnsQuery = useQuery({
queryKey: ["edr-last-mile-returns", bookingIds],
queryFn: async () => {
const grouped = new Map<string, TruckReturn>();
for (const item of unloadedQueue) {
if (!item.bookingId) continue;
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
for (const truck of edrTrucks) {
const inventory = await api.warehouses.listInventory.call({ filter: { bookingId: item.bookingId } }).catch(() => []);
const returnContainers = inventory
.filter((inv: any) => inv.isReturn)
.map((inv: any) => ({
containerNumber: inv.containerNumber || "—",
size: inv.containerSize || null,
type: inv.containerType || null,
selected: false,
}));
if (returnContainers.length > 0) {
const key = `${item.bookingId}-${truck.vehicleId}`;
grouped.set(key, {
key,
plate: [truck.truckPlateNumber, truck.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
companyName: item.customerName ?? null,
bookingRef: item.bookingReference ?? item.bookingId,
bookingId: item.bookingId,
customerId: item.customerId || null,
containers: returnContainers,
});
}
}
}
return Array.from(grouped.values());
},
enabled: bookingIds.length > 0 && !queueLoading,
});
const trucksWithReturns = useMemo(() => truckReturnsQuery.data ?? [], [truckReturnsQuery.data]);
const controls = useListControls(trucksWithReturns, {
searchKeys: ["plate", "companyName", "bookingRef"],
});
const createReturnsMutation = useMutation({
mutationFn: async (payload: { trucks: Array<{ bookingId: string; customerId: string | null; containers: Array<{ containerNumber: string; returnDate: string; facility: string; yard?: string; zone?: string; condition?: string; handoverNote?: string }> }> }) => {
const results = [];
for (const truck of payload.trucks) {
for (const container of truck.containers) {
const result = await importOperationsService.createEmptyReturn({
containerNumber: container.containerNumber,
returnDate: new Date(container.returnDate).toISOString(),
bookingId: truck.bookingId,
customerId: truck.customerId ?? undefined,
facility: container.facility,
yard: container.yard,
zone: container.zone,
condition: container.condition,
handoverNote: container.handoverNote,
});
results.push(result);
}
}
return results;
},
onSuccess: () => {
toast({ title: "Empty container returns recorded" });
qc.invalidateQueries({ queryKey: ["edr-last-mile-returns", bookingIds] });
setReturnModalOpen(false);
setActiveKey(null);
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Failed to record returns",
description: error?.response?.data?.message || error?.message,
});
},
});
const activeTruck = activeKey ? trucksWithReturns.find(t => t.key === activeKey) ?? null : null;
if (queueLoading || truckReturnsQuery.isLoading) {
return (
<PageContainer>
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="EDR Last Mile Returns"
subtitle="Empty containers returned by EDR-haulage trucks — single or bulk processing"
/>
{trucksWithReturns.length === 0 ? (
<Alert color="gray">No EDR trucks with return containers found.</Alert>
) : (
<>
<Table.ScrollContainer minWidth={1000}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Plate</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Return Containers</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{controls.pagedRows.map((truck) => {
const isOpen = expanded === truck.key;
return (
<Fragment key={truck.key}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setExpanded(isOpen ? null : truck.key)}
>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text fw={600}>{truck.plate}</Text>
</Table.Td>
<Table.Td>{truck.companyName ?? "—"}</Table.Td>
<Table.Td>{truck.bookingRef}</Table.Td>
<Table.Td>
<Badge>{truck.containers.length} container{truck.containers.length !== 1 ? "s" : ""}</Badge>
</Table.Td>
<Table.Td ta="right">
<Button
size="xs"
variant="light"
onClick={() => {
setActiveKey(truck.key);
setReturnModalOpen(true);
}}
>
Process Returns
</Button>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr>
<Table.Td colSpan={6}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox disabled />
</Table.Th>
<Table.Th>Container</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Type</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{truck.containers.map((container, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<Checkbox checked={container.selected} />
</Table.Td>
<Table.Td>{container.containerNumber}</Table.Td>
<Table.Td>{container.size ?? "—"}</Table.Td>
<Table.Td>{container.type ?? "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="trucks"
onPaginationChange={controls.setPagination}
/>
</>
)}
<EmptyContainerReturnModal
opened={returnModalOpen}
onClose={() => setReturnModalOpen(false)}
truck={activeTruck}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
</PageContainer>
);
}
interface EmptyContainerReturnModalProps {
opened: boolean;
onClose: () => void;
truck: TruckReturn | null;
onSubmit: (payload: any) => void;
loading: boolean;
}
function EmptyContainerReturnModal({ opened, onClose, truck, onSubmit, loading }: EmptyContainerReturnModalProps) {
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null);
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: async () => {
return await warehouseService.list({});
},
});
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const warehouseOptions = Array.isArray(warehouses) ? warehouses.map((wh: any) => ({
value: wh.id,
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
})) : [];
const selectedWarehouse = warehouse && Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
const handleSubmit = () => {
if (!truck || !selectedContainers.length || !warehouse) return;
const containers = truck.containers
.filter((c) => selectedContainers.includes(c.containerNumber))
.map((c) => ({
containerNumber: c.containerNumber,
returnDate,
facility: selectedWarehouse?.name || warehouse,
yard: selectedWarehouse?.code || undefined,
zone: undefined,
condition: condition || undefined,
handoverNote: handoverNote || undefined,
}));
onSubmit({
trucks: [{
bookingId: truck.bookingId,
customerId: truck.customerId,
containers,
}],
});
};
return (
<Modal opened={opened} onClose={onClose} title="Process Empty Container Returns" size="lg">
{truck && (
<Stack gap="md">
<Group>
<Text fw={600}>{truck.plate}</Text>
<Text size="sm" c="dimmed">{truck.bookingRef}</Text>
</Group>
<div>
<Text size="sm" fw={600} mb="xs">Select containers to return:</Text>
<Stack gap="xs">
{truck.containers.map((container) => (
<Checkbox
key={container.containerNumber}
label={`${container.containerNumber} (${container.size || "bulk"})`}
checked={selectedContainers.includes(container.containerNumber)}
onChange={(e) => {
if (e.currentTarget.checked) {
setSelectedContainers([...selectedContainers, container.containerNumber]);
} else {
setSelectedContainers(selectedContainers.filter(c => c !== container.containerNumber));
}
}}
/>
))}
</Stack>
</div>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"
value={warehouse}
onChange={setWarehouse}
data={warehouseOptions}
required
searchable
/>
<TextInput
label="Return Date"
type="date"
value={returnDate}
onChange={(e) => setReturnDate(e.currentTarget.value)}
required
/>
<Textarea
label="Condition"
placeholder="Damage, residue, or cleanliness notes"
value={condition}
onChange={(e) => setCondition(e.currentTarget.value)}
rows={3}
/>
<Textarea
label="Handover Note"
placeholder="Consignee, trucker, or authorization notes"
value={handoverNote}
onChange={(e) => setHandoverNote(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedContainers.length || !warehouse}
loading={loading}
>
{selectedContainers.length > 1 ? "Bulk" : "Single"} Return ({selectedContainers.length})
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -184,10 +184,20 @@ export default function CompanyProfileForm({
// forms (plus a mandatory owner passport number). // forms (plus a mandatory owner passport number).
const verifiedIdentity = identity?.faydaRequired === true; const verifiedIdentity = identity?.faydaRequired === true;
// Which fields the current step renders an input for and therefore requires.
// Filled in further down (it depends on values this form owns), and read at
// validation time rather than at render time — the resolver below runs on
// events, always after the render that assigned it.
const requiredKeysRef = useRef<(keyof FormData)[]>([]);
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver( resolver: (values, context, options) =>
buildOnboardingSchema(identity?.passportRequired === true), zodResolver(
buildOnboardingSchema(
identity?.passportRequired === true,
requiredKeysRef.current,
), ),
)(values, context, options),
// `values` below re-seeds the form whenever the profile is refetched — and // `values` below re-seeds the form whenever the profile is refetched — and
// an in-page identity action (ticking "same as owner") refetches it. Without // an in-page identity action (ticking "same as owner") refetches it. Without
// this, that reset silently throws away whatever the customer was part-way // this, that reset silently throws away whatever the customer was part-way
@@ -432,15 +442,19 @@ export default function CompanyProfileForm({
// Where the GM's details come from depends on how they were established: a // Where the GM's details come from depends on how they were established: a
// Fayda verification (or a "same as owner" declaration) owns them outright, // Fayda verification (or a "same as owner" declaration) owns them outright,
// and only a company that may still type them falls back to form state. // and only a company that may still type them falls back to form state.
// A verified GM's identity wins, but Fayda's email and phone claims are
// optional: what the verification did not supply is typed on this step, and
// the API deliberately does not read those back onto the identity (they are
// not proven), so the form value is the only place they exist.
const gmVerified = identity?.gm.verified ?? false; const gmVerified = identity?.gm.verified ?? false;
const gmName = gmVerified const gmName = gmVerified
? (identity?.gm.name ?? "") ? firstPresent(identity?.gm.name, watch("generalManagerName"))
: watch("generalManagerName"); : watch("generalManagerName");
const gmEmail = gmVerified const gmEmail = gmVerified
? (identity?.gm.email ?? "") ? firstPresent(identity?.gm.email, watch("generalManagerEmail"))
: watch("generalManagerEmail"); : watch("generalManagerEmail");
const gmPhone = gmVerified const gmPhone = gmVerified
? (identity?.gm.phone ?? "") ? firstPresent(identity?.gm.phone, watch("generalManagerPhone"))
: watch("generalManagerPhone"); : watch("generalManagerPhone");
/** /**
@@ -448,9 +462,11 @@ export default function CompanyProfileForm({
* "same as owner" declaration, or (only where Fayda is optional) by typing. * "same as owner" declaration, or (only where Fayda is optional) by typing.
* Fayda is an Ethiopian national ID, so a foreign company's GM may hold none. * Fayda is an Ethiopian national ID, so a foreign company's GM may hold none.
*/ */
// Matches what the API actually demands (`REQUIRED_COMPANY_INFO`): a name and
// a phone. The email is collected but optional — a manager proved through
// Fayda may have no email claim, and the notify resolver no longer needs one.
const gmTyped = Boolean( const gmTyped = Boolean(
watch("generalManagerName")?.trim() && watch("generalManagerName")?.trim() &&
watch("generalManagerEmail")?.trim() &&
watch("generalManagerPhone")?.trim(), watch("generalManagerPhone")?.trim(),
); );
const gmEstablished = const gmEstablished =
@@ -623,12 +639,23 @@ export default function CompanyProfileForm({
// delegated, so it's required the moment a PoA exists. The API enforces the // delegated, so it's required the moment a PoA exists. The API enforces the
// same rule on save, so skipping it here only costs the customer a // same rule on save, so skipping it here only costs the customer a
// round-trip. // round-trip.
// A PoA exists exactly when one has been verified — the details are the //
// verification's output, so there is nothing else that could stand for one. // "Exists" is the API's own test (`POA_ATTRIBUTES.some(...)`): ANY detail,
// Until then the upload is hidden: there is no representative for the paper // verified or typed. Requiring a complete typed representative here instead
// to authorise, and a freight forwarder is held on the verification gate // hid the upload from a customer who had entered only a name — for whom the
// below rather than on a file field it cannot yet fill. // API still demands the paper, and whose resume would then be clamped back to
const poaProvided = (identity?.poa.verified ?? false) || poaTyped; // this step with nothing on it to fill.
const poaAnyDetail = [
identity?.poa.name,
identity?.poa.email,
identity?.poa.phone,
identity?.poa.address,
watch("poaName"),
watch("poaEmail"),
watch("poaPhone"),
watch("poaLocation"),
].some((v) => v?.trim());
const poaProvided = (identity?.poa.verified ?? false) || poaAnyDetail;
// A freight forwarder owes the paper whether or not its representative could // A freight forwarder owes the paper whether or not its representative could
// verify with Fayda — the API demands it at completion either way. Keying // verify with Fayda — the API demands it at completion either way. Keying
// this on the verification alone hid the upload from a foreign forwarder and // this on the verification alone hid the upload from a foreign forwarder and
@@ -641,6 +668,60 @@ export default function CompanyProfileForm({
return Array.isArray(v) ? v.length > 0 : v != null; return Array.isArray(v) ? v.length > 0 : v != null;
})(); })();
/**
* What a Fayda verification did NOT supply, per person.
*
* Fayda's email, phone and address claims are optional and routinely come
* back empty, so a *verified* person can still be missing details the API
* demands (`REQUIRED_POA_FIELDS`, `REQUIRED_COMPANY_INFO`). Those gaps are
* typed instead — the API keeps exactly the keys a claim left empty typeable,
* since a claim that returned nothing owns no value to protect.
*
* Computed here, once, and handed to both the step (which renders an input
* per gap) and the schema (which requires exactly those) — a field is
* required if and only if there is an input on screen to fix it in.
*/
// Where Fayda is mandatory an unverified representative must verify rather
// than be typed, so nothing is offered until the verification lands.
const poaTypedAllowed =
!identity || identity.poa.verified || !identity.faydaRequired;
// A representative the verification never proved holds only typed details —
// the API leaves those unlocked, so the inputs stay on screen and stay
// editable. Only a *verified* PoA hides the fields their claim did fill,
// which is also the only case the API refuses to let anyone overwrite.
const poaGap = (v?: string | null) =>
poaTypedAllowed && (!identity?.poa.verified || !v?.trim());
const poaGaps = {
name: poaGap(identity?.poa.name),
email: poaGap(identity?.poa.email),
phone: poaGap(identity?.poa.phone),
address: poaGap(identity?.poa.address),
};
// The GM's own verification never falls back to the signed-in account — that
// account is the person onboarding, not necessarily the manager — so a GM
// verified with no email claim has nowhere else for one to come from.
// "Same as owner" is exempt: the API copies the owner's (account-backed)
// contact details across, so there is no gap and no input.
const gmGaps = {
email: !gmSameAsOwner && gmVerified && !identity?.gm.email?.trim(),
phone: !gmSameAsOwner && gmVerified && !identity?.gm.phone?.trim(),
};
const requiredKeys: (keyof FormData)[] = [];
if (step === "personnel") {
// The manager's email is offered but not demanded: the API dropped it from
// `REQUIRED_COMPANY_INFO` once the notify resolver stopped depending on it.
// The phone is still required there, so it is still required here.
if (gmGaps.phone) requiredKeys.push("generalManagerPhone");
} else if (step === "poa" && delegationRequired) {
// Only once a PoA is required or provided: an untouched optional PoA is
// still a step the customer may walk straight past.
if (poaGaps.name) requiredKeys.push("poaName");
if (poaGaps.email) requiredKeys.push("poaEmail");
if (poaGaps.phone) requiredKeys.push("poaPhone");
}
requiredKeysRef.current = requiredKeys;
/** /**
* Collect the messages for a set of fields into one sentence. * Collect the messages for a set of fields into one sentence.
* *
@@ -656,6 +737,7 @@ export default function CompanyProfileForm({
// yet, so the closure would still be holding the previous attempt's state. // yet, so the closure would still be holding the previous attempt's state.
const parsed = buildOnboardingSchema( const parsed = buildOnboardingSchema(
identity?.passportRequired === true, identity?.passportRequired === true,
requiredKeys,
).safeParse(getValues()); ).safeParse(getValues());
const wanted = new Set<string>(fields as string[]); const wanted = new Set<string>(fields as string[]);
const messages = parsed.success const messages = parsed.success
@@ -875,6 +957,7 @@ export default function CompanyProfileForm({
onToggleGmSameAsOwner={toggleGmSameAsOwner} onToggleGmSameAsOwner={toggleGmSameAsOwner}
gmLinkPending={gmLinkPending} gmLinkPending={gmLinkPending}
gmVerified={gmVerified} gmVerified={gmVerified}
gaps={gmGaps}
/> />
)} )}
@@ -892,6 +975,7 @@ export default function CompanyProfileForm({
form={form} form={form}
identity={identity} identity={identity}
requirePoa={requirePoa} requirePoa={requirePoa}
gaps={poaGaps}
delegationRequired={delegationRequired} delegationRequired={delegationRequired}
poaDocumentSetting={poaDocumentSetting} poaDocumentSetting={poaDocumentSetting}
documentFiles={documentFiles} documentFiles={documentFiles}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { onboardingSchema, stepFields } from "./schema"; import { buildOnboardingSchema, onboardingSchema, stepFields } from "./schema";
import { import {
firstPresent, firstPresent,
firstValidEmail, firstValidEmail,
@@ -101,6 +101,56 @@ describe("stepFields", () => {
}); });
}); });
describe("buildOnboardingSchema (conditionally required fields)", () => {
const issuesFor = (
data: FormData,
required: (keyof FormData)[],
): (keyof FormData)[] => {
const parsed = buildOnboardingSchema(false, required).safeParse(data);
return parsed.success
? []
: (parsed.error.issues.map((i) => i.path[0]) as (keyof FormData)[]);
};
// Fayda's email/phone claims are optional: the step renders an input for what
// the verification did not supply, and requires exactly those. Nothing else —
// a field with no input on screen must never fail Continue.
it("requires only the keys it is handed", () => {
const issues = issuesFor(values(), [
"generalManagerEmail",
"generalManagerPhone",
]);
expect(issues).toEqual(["generalManagerEmail", "generalManagerPhone"]);
});
it("passes once those keys are filled", () => {
expect(
issuesFor(
values({
generalManagerEmail: "gm@example.com",
generalManagerPhone: "+251911223344",
}),
["generalManagerEmail", "generalManagerPhone"],
),
).toEqual([]);
});
it("leaves the same blank fields alone when nothing is required", () => {
expect(issuesFor(values(), [])).toEqual([]);
});
it("names the field in the message, so it reads under its own input", () => {
const parsed = buildOnboardingSchema(false, ["poaEmail"]).safeParse(
values(),
);
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues[0]?.message).toBe(
"Representative's email is required",
);
});
});
describe("stepPayload (company)", () => { describe("stepPayload (company)", () => {
it("omits the eTrade bundle when nothing was re-verified", () => { it("omits the eTrade bundle when nothing was re-verified", () => {
const payload = stepPayload("company", values(), {}); const payload = stepPayload("company", values(), {});

View File

@@ -105,27 +105,54 @@ export type FormData = z.infer<typeof onboardingSchema>;
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** /**
* The PoA's identifying fields are never typed — they come from the Fayda * Labels for the fields a Fayda verification may or may not have supplied. Which
* verification, whatever the company's nationality — so nothing here requires * of them are mandatory is decided per render (see `requiredKeys` below), so the
* them. A freight forwarder's mandatory PoA is gated on the verification * message has to be built here rather than attached to the base schema.
* itself, and its delegation letter alongside it, both in CompanyProfileForm */
* (files live outside form state). const CONDITIONAL_LABELS: Partial<Record<keyof FormData, string>> = {
poaName: "Representative's name",
poaEmail: "Representative's email",
poaPhone: "Representative's phone",
generalManagerName: "General manager's name",
generalManagerEmail: "General manager's email",
generalManagerPhone: "General manager's phone",
};
/**
* The PoA's and GM's identifying fields normally come from their Fayda
* verification, so nothing in the base schema requires them. But Fayda's email
* and phone claims are optional and routinely come back empty, and the steps
* render an input for whatever the verification did not supply — so those
* fields become mandatory exactly then.
* *
* That leaves the owner's passport number as the only conditional field. * `requiredKeys` is that decision, made by CompanyProfileForm from the same
* state that drives the rendering: a field is required iff an input exists for
* it. Passing it in (rather than deriving it here) is what keeps the two from
* drifting into a Continue button that fails on a field nobody can see.
*/ */
export function buildOnboardingSchema( export function buildOnboardingSchema(
/** True for a foreign company: the owner's passport number is mandatory. */ /** True for a foreign company: the owner's passport number is mandatory. */
passportRequired = false, passportRequired = false,
/** Fields the current step renders an input for and must not leave blank. */
requiredKeys: readonly (keyof FormData)[] = [],
) { ) {
if (!passportRequired) return onboardingSchema; if (!passportRequired && requiredKeys.length === 0) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => { return onboardingSchema.superRefine((d, ctx) => {
if (!d.ownerPassportNumber?.trim()) { if (passportRequired && !d.ownerPassportNumber?.trim()) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["ownerPassportNumber"], path: ["ownerPassportNumber"],
message: "The owner's passport number is required", message: "The owner's passport number is required",
}); });
} }
for (const key of requiredKeys) {
if (d[key]?.trim()) continue;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: `${CONDITIONAL_LABELS[key] ?? key} is required`,
});
}
}); });
} }

View File

@@ -18,6 +18,12 @@ export interface PersonnelStepProps {
/** A server-side "same as owner" declaration is in flight. */ /** A server-side "same as owner" declaration is in flight. */
gmLinkPending: boolean; gmLinkPending: boolean;
gmVerified: boolean; gmVerified: boolean;
/**
* Which of the manager's contact details their Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
* requires exactly these in the schema.
*/
gaps: { email: boolean; phone: boolean };
} }
export default function PersonnelStep({ export default function PersonnelStep({
@@ -28,6 +34,7 @@ export default function PersonnelStep({
onToggleGmSameAsOwner, onToggleGmSameAsOwner,
gmLinkPending, gmLinkPending,
gmVerified, gmVerified,
gaps,
}: PersonnelStepProps) { }: PersonnelStepProps) {
const { const {
register, register,
@@ -74,6 +81,34 @@ export default function PersonnelStep({
/> />
)} )}
{/* Fayda's email and phone claims are optional, and the manager's own
verification has no account to fall back on the way the owner's does
— the person onboarding is not necessarily the manager. Whatever the
verification left empty is typed here, and required: without it the
submit fails on "Add your general manager email" with no field
anywhere to satisfy it. */}
{(gaps.email || gaps.phone) && (
<SimpleGrid cols={gaps.email && gaps.phone ? 2 : 1} spacing="md">
{gaps.email && (
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
)}
{gaps.phone && (
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
)}
</SimpleGrid>
)}
{/* Typed details survive only where Fayda cannot be required — {/* Typed details survive only where Fayda cannot be required —
a foreign company's manager may hold no Fayda ID. Once a foreign company's manager may hold no Fayda ID. Once
verified the API owns these fields, so they go away. */} verified the API owns these fields, so they go away. */}

View File

@@ -14,6 +14,13 @@ export interface PoaStepProps {
identity?: CompanyIdentityState; identity?: CompanyIdentityState;
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */ /** This company holds a freight-forwarder profile, so the PoA is mandatory. */
requirePoa: boolean; requirePoa: boolean;
/**
* Which of the representative's details the Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
* requires exactly these in the schema — so every input rendered below is one
* the customer is actually asked to fill.
*/
gaps: { name: boolean; email: boolean; phone: boolean; address: boolean };
/** The DARS delegation paper is owed (a PoA exists, or the company forwards). */ /** The DARS delegation paper is owed (a PoA exists, or the company forwards). */
delegationRequired: boolean; delegationRequired: boolean;
/** Single-field upload setting carrying just the delegation letter. */ /** Single-field upload setting carrying just the delegation letter. */
@@ -28,6 +35,7 @@ export default function PoaStep({
form, form,
identity, identity,
requirePoa, requirePoa,
gaps,
delegationRequired, delegationRequired,
poaDocumentSetting, poaDocumentSetting,
documentFiles, documentFiles,
@@ -45,24 +53,21 @@ export default function PoaStep({
// empty, so a *verified* representative can still be missing the email and // empty, so a *verified* representative can still be missing the email and
// phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) — // phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) —
// and the panel above renders no input for them, which dead-ends the step on // and the panel above renders no input for them, which dead-ends the step on
// "Add the poa email first". Offer an input for whatever the verification // "Add the poa email first". `gaps` is exactly what the verification did not
// did not supply: the API keeps exactly those keys typeable, since a claim // supply: the API keeps those keys typeable, since a claim that returned
// that returned nothing owns no value to protect (`faydaOwnedKeys`). // nothing owns no value to protect (`faydaOwnedKeys`).
const poa = identity?.poa; const needsEmail = gaps.email;
// Where Fayda is mandatory an unverified representative must verify rather const needsPhone = gaps.phone;
// than be typed, so nothing is offered until the verification lands.
const typedAllowed = !identity || poa!.verified || !identity.faydaRequired;
const missing = (value: string | null | undefined) =>
typedAllowed && !value?.trim();
const needsEmail = missing(poa?.email);
const needsPhone = missing(poa?.phone);
return ( return (
<> <>
<Text size="sm" c="edr-muted"> <Text size="sm" c="edr-muted">
{requirePoa {requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required." ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."} : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}{" "}
{/* The API refuses an owner who delegates to themselves — say so here,
or the customer only finds out after being sent to Fayda and back. */}
The representative must be someone other than the company's owner.
</Text> </Text>
{/* A representative acts for the company inside Ethiopia {/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of whoever owns it, so the PoA is proven with Fayda regardless of
@@ -78,7 +83,7 @@ export default function PoaStep({
)} )}
{/* Whatever the Fayda claim did carry is shown on the panel above and {/* Whatever the Fayda claim did carry is shown on the panel above and
is never typed here — the verification owns it. */} is never typed here — the verification owns it. */}
{missing(poa?.name) && ( {gaps.name && (
<TextInput <TextInput
label="Representative's Name" label="Representative's Name"
placeholder="Abebe Bikila" placeholder="Abebe Bikila"
@@ -106,7 +111,7 @@ export default function PoaStep({
)} )}
</SimpleGrid> </SimpleGrid>
)} )}
{missing(poa?.address) && ( {gaps.address && (
<TextInput <TextInput
label="PoA Location" label="PoA Location"
placeholder="City, Country" placeholder="City, Country"

View File

@@ -127,9 +127,13 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: FormData) => mutationFn: (data: FormData) =>
api.companies.updateProfile.call({ api.companies.updateProfile.call({
generalManagerName: data.generalManagerName, // `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
generalManagerEmail: data.generalManagerEmail, // and undefined, so an empty string is validated and 400s with
generalManagerPhone: data.generalManagerPhone, // "generalManagerEmail must be an email". A verified manager legitimately
// leaves the fields Fayda did supply blank here.
generalManagerName: data.generalManagerName || undefined,
generalManagerEmail: data.generalManagerEmail || undefined,
generalManagerPhone: data.generalManagerPhone || undefined,
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
@@ -144,6 +148,16 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
// posting empty strings over a proven identity. // posting empty strings over a proven identity.
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired; const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
// Except for what the verification never supplied. Fayda's email and phone
// claims are optional, and a manager verified without them has no account to
// fall back on the way the owner does — so those stay typed, here as well as
// in onboarding, or a wrong address could never be corrected.
const gmGaps = {
email: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.email?.trim(),
phone: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.phone?.trim(),
};
const savable = typedFieldsInUse || gmGaps.email || gmGaps.phone;
return ( return (
<Card padding="lg"> <Card padding="lg">
<Group gap="sm" mb="xs"> <Group gap="sm" mb="xs">
@@ -187,6 +201,34 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
/> />
)} )}
{/* Whatever the verification did not supply is typed instead — the
API keeps exactly those keys writable. */}
{(gmGaps.email || gmGaps.phone) && (
<Grid>
{gmGaps.email && (
<Grid.Col span={gmGaps.phone ? 6 : 12}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
</Grid.Col>
)}
{gmGaps.phone && (
<Grid.Col span={gmGaps.email ? 6 : 12}>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
)}
</Grid>
)}
{/* Typed details survive only where Fayda cannot be required — a {/* Typed details survive only where Fayda cannot be required — a
foreign company's manager may hold no Fayda ID. Once verified the foreign company's manager may hold no Fayda ID. Once verified the
API owns these fields and refuses edits, so they go away. */} API owns these fields and refuses edits, so they go away. */}
@@ -258,7 +300,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
empty strings at an identity the API owns and refuses to empty strings at an identity the API owns and refuses to
overwrite. Onboarding still needs a way forward, so the button overwrite. Onboarding still needs a way forward, so the button
becomes a plain Continue rather than disappearing. */} becomes a plain Continue rather than disappearing. */}
{typedFieldsInUse ? ( {savable ? (
<Button <Button
type="submit" type="submit"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,301 @@
-- restore deleted pre-baseline migration rows
INSERT INTO freight.migrations (timestamp, name) VALUES
(1748427600000, 'AddServiceTypesAndCargoTypes1748427600000'),
(1748514000000, 'AddRuleEngineTablesAndCodes1748514000000'),
(1748550000000, 'CreateFreightLegacyBaseline1748550000000'),
(1748600000000, 'ItmlsFullSchemaRewrite1748600000000'),
(1748700000000, 'AddBookingsConfigForeignKeys1748700000000'),
(1748800000000, 'AddBookingsRemainingForeignKeys1748800000000'),
(1748900000000, 'MoveCustomersToFreightSchema1748900000000'),
(1749000000000, 'NormalizeWeightLimitTradeDirectionBoth1749000000000'),
(1749100000000, 'CreateFreightFilesTable1749100000000'),
(1749200000000, 'CreateCompaniesModule1749200000000'),
(1749200000000, 'BookingFlowRefactor1749200000000'),
(1749300000000, 'AddFanNumberToCompanies1749300000000'),
(1749300000000, 'AddBookingFreightType1749300000000'),
(1749400000000, 'AddTrainScheduling1749400000000'),
(1749400000000, 'AddContractSignatures1749400000000'),
(1749500000000, 'AddCompanyIdToBookings1749500000000'),
(1749600000000, 'AddBlocksRoleToApprovalStep1749600000000'),
(1749700000000, 'SeedDefaultApprovalRules1749700000000'),
(1749800000000, 'AddShippingLinesCodeUniqueIndex1749800000000'),
(1749900000000, 'CreateFileUploadSettingsTables1749900000000'),
(1750000000000, 'CreateFacilitiesTable1750000000000'),
(1750000000000, 'AddTrainExtendedColumns1750000000000'),
(1750000000000, 'AddCompanyContactColumns1750000000000'),
(1750000000001, 'AddFacilityIdToWarehouses1750000000001'),
(1750000000002, 'AddProofOfDeliveryToCargoes1750000000002'),
(1750000000003, 'AddWarehouseInspection1750000000003'),
(1750100000000, 'CreateFleetCrudTables1750100000000'),
(1750100000000, 'AddRoutesAndExtendLocomotives1750100000000'),
(1750200000000, 'SeedDefaultWagonTypes1750200000000'),
(1750200000000, 'AddPhysicalWagonToTrainSetWagons1750200000000'),
(1750300000000, 'AddRouteToTrainSchedules1750300000000'),
(1750300000000, 'AddCurrentLocationToWagons1750300000000'),
(1750400000000, 'SeedEdRWagonFleet1750400000000'),
(1750400000000, 'AddSchedulingAllocationEnhancements1750400000000'),
(1750500000000, 'AddWagonReadiness1750500000000'),
(1750600000000, 'AddGovernmentBookingFields1750600000000'),
(1750700000000, 'CreateSchedulingEvents1750700000000'),
(1750800000000, 'FixContainerWagonsPerUnit1750800000000'),
(1750900000000, 'AddContainerNumberToBookingContainer1750900000000'),
(1751000000000, 'CreateTrainSchedulingGlobalRules1751000000000'),
(1751000000001, 'AddDeletedAtToTrainSchedulingGlobalRules1751000000001'),
(1752000000000, 'CreateCompanyProfiles1752000000000'),
(1752000000001, 'MoveBusinessLicenseToProfile1752000000001'),
(1770000000000, 'CreateVehiclesTable1770000000000'),
(1775000000000, 'CreateDriversTable1775000000000'),
(1780639311366, 'CreatePaymentTable1780639311366'),
(1780639978834, 'AlterClientActionToJsonb1780639978834'),
(1780644945086, 'UpdatePaymentTimestamp1780644945086'),
(1781000000000, 'AddLocomotiveReadiness1781000000000'),
(1781000000001, 'CreateTrainCheckpointEvents1781000000001'),
(1781000000002, 'AddBatchBookingFields1781000000002'),
(1781000000003, 'AddSelectedForBatchStatus1781000000003'),
(1781000000004, 'AddDomesticWeightLimitTradeDirection1781000000004'),
(1781000000005, 'CreateTrainCompositionRemovalLog1781000000005'),
(1782000000000, 'WagonLocomotiveYardLink1782000000000'),
(1782000000001, 'AddPaymentWebhookEventAndRefund1782000000001'),
(1782000000002, 'ExtendPaymentMethodEnum1782000000002'),
(1783000000000, 'ReplacePriorityRulesWithPriorityConfigs1783000000000'),
(1784000000000, 'CreateSavedSignatures1784000000000'),
(1784000000001, 'SeedWagonsWithYardAssignment1784000000001'),
(1784100000000, 'AddBookingRouteDayIndex1784100000000'),
(1790000000000, 'CreateWarehouseModule1790000000000'),
(1790000000001, 'WarehouseBatch21790000000001'),
(1790000000002, 'WarehouseBatch31790000000002'),
(1791000000000, 'AddWarehouseAllocationAndFeeRules1791000000000'),
(1791000000000, 'AddActiveModeAndOnboardingToExternalProfiles1791000000000'),
(1791000000001, 'AddWarehouseFeeInvoices1791000000001'),
(1791000000001, 'AddCompanyProfileIdToBookings1791000000001'),
(1791000000002, 'AddNationalityToCompanies1791000000002'),
(1791000000002, 'AddImportPickupDeliveryColumns1791000000002'),
(1791000000003, 'AddInventoryUnloadedAt1791000000003'),
(1791000000003, 'AddETradeFieldsToCompanies1791000000003'),
(1791000000003, 'AddBusinessLicenseFilesToCompanyProfiles1791000000003'),
(1791000000004, 'AddFacilityIdToWarehousesFix1791000000004'),
(1791000000005, 'AddWarehouseInventoryInspectionStatusFix1791000000005'),
(1791999999999, 'CreateDropdownSettings1791999999999'),
(1792000000000, 'AddUnitOfMeasureToCargoTypes1792000000000'),
(1792000000001, 'AddBookingTypeAndContractFields1792000000001'),
(1792000000002, 'CreateBookingOrders1792000000002'),
(1792000000003, 'SeedGeneralContractPeriod1792000000003'),
(1792000000004, 'SeedContractValidityPeriods1792000000004'),
(1800000000001, 'AddVehicleDriverAssignment1800000000001'),
(1810000000000, 'CreateFirstMile1810000000000'),
(1810000000001, 'CreateLastMile1810000000001'),
(1810000000002, 'MakeCompanyProfileReferenceNullable1810000000002'),
(1810000000002, 'CreateLastMileContainerAllocations1810000000002'),
(1810000000002, 'AddVehicleCodeAndPlates1810000000002'),
(1810000000003, 'CreateOtpVerifications1810000000003'),
(1810000000004, 'AddPostPaymentCompletedColumn1810000000004'),
(1820000000000, 'DropAllowConsolidation1820000000000'),
(1820000000001, 'CreateContractRouteLines1820000000001'),
(1820000000002, 'CreateBookingDocumentReview1820000000002'),
(1820000000003, 'AddPriceAdjustment1820000000003'),
(1820000000004, 'FoldSurchargeTypesIntoRates1820000000004'),
(1820000000005, 'AddContractValidityWindow1820000000005'),
(1820000000006, 'AddCustomsAgentAndMileCoordinates1820000000006'),
(1820000000010, 'AddGeneralContractOrderFields1820000000010'),
(1820000000011, 'DropEmailPhoneFromExternalProfiles1820000000011'),
(1820000000011, 'AddTrainSetLocomotives1820000000011'),
(1820000000012, 'AddEstimatedShipmentDate1820000000012'),
(1821000000000, 'CreateInterchangeDocuments1821000000000'),
(1821000000001, 'EnsureWarehouseInventoryInspectionStatus1821000000001'),
(1821000000002, 'CreateInvoices1821000000002'),
(1821000000002, 'AddDistanceColumnsToVehicles1821000000002'),
(1821000000003, 'AddCompanyKindAndGovBookingLinks1821000000003'),
(1821000000004, 'MakePaymentsTypeGeneric1821000000004'),
(1822000000000, 'CreateImportDjiboutiOperations1822000000000'),
(1822000000000, 'CreateContracts1822000000000'),
(1823000000000, 'CreateImportOperationsTables1823000000000'),
(1823000000000, 'BackfillContractsFromBookings1823000000000'),
(1824000000000, 'DropLegacyContractTables1824000000000'),
(1825000000000, 'CreateBookingContainerAllocations1825000000000'),
(1825000000000, 'AddGlOperations1825000000000'),
(1826000000000, 'AddCargoScopeQuantityCap1826000000000'),
(1827000000000, 'CreateBookingRequests1827000000000'),
(1828000000000, 'ExtendInvoicesForPartialPayment1828000000000'),
(1828000000000, 'AddGrnNumberToWarehouseInventory1828000000000'),
(1828000000000, 'AddBulkHazmatReeferQuantity1828000000000'),
(1829000000000, 'PhasedClearanceCycleMeta1829000000000'),
(1829000000000, 'CentralizeWarehouseInvoices1829000000000'),
(1829000000001, 'SeedRoVesselMinDays1829000000001'),
(1829000000002, 'BookingClearanceMeta1829000000002'),
(1830000000000, 'DropCargoTypeShowFreeTextBox1830000000000'),
(1830000000000, 'CreateFirstMileContainerAllocations1830000000000'),
(1830000000000, 'AddExpiredInvoiceStatus1830000000000'),
(1830000000001, 'RouteStatusAndSegmentKm1830000000001'),
(1830000000002, 'PreClearanceFinalizedAt1830000000002'),
(1831000000000, 'AddWarehouseFeeRuleTiers1831000000000'),
(1832000000000, 'AddCustomerTruckAssignmentToBookings1832000000000'),
(1840000000000, 'CreateFuelTables1840000000000'),
(1850000000000, 'CreateMaintenanceTables1850000000000'),
(1860000000000, 'AddPaidToFirstAndLastMile1860000000000'),
(1861000000000, 'AddBookingWindowGlobalRules1861000000000'),
(1861000000001, 'ReleaseStuckAssignedLocomotives1861000000001'),
(1862000000000, 'AddScheduleWindowPhases1862000000000'),
(1863000000000, 'CreateBookingBatchOffers1863000000000'),
(1870000000000, 'RepairSynchronizeDrift1870000000000'),
(1870000000000, 'AddLocationToVehicles1870000000000'),
(1880000000000, 'AddVehicleStatuses1880000000000'),
(1890000000000, 'SeparateVehicleAvailability1890000000000'),
(1890000000001, 'AddVehicleCodeAndPlates1890000000001'),
(1890000000002, 'AddFaydaVerificationSessions1890000000002'),
(1890000000003, 'AddDriverFaydaVerification1890000000003'),
(1890000000004, 'AddLastMileVehicleAssignments1890000000004'),
(1890000000005, 'AddDriverGender1890000000005'),
(1890000000006, 'AddDriverFaydaSubUnique1890000000006'),
(1890000000007, 'DriverUniquePartialSoftDelete1890000000007'),
(1890000000008, 'AddFleetEvents1890000000008'),
(1890000000009, 'AddLastMileAssignmentContainerNumber1890000000009'),
(1890000000010, 'AddLastMileAssignmentDistance1890000000010'),
(1900000000000, 'SimplifyRatesAndWeightLimitRules1900000000000'),
(1900000000000, 'AddLoadingStatusToTrainScheduleBookings1900000000000'),
(1900000000000, 'AddEmailToOtpVerifications1900000000000'),
(1910000000000, 'WidenWindowDurationHoursPrecision1910000000000'),
(1920000000000, 'AddScheduleWindowRuleSnapshot1920000000000'),
(1930000000000, 'AddMaxCapacityToWeightLimitRules1930000000000'),
(1940000000000, 'AddWagonTypeFkToCargoAndContainerTypes1940000000000'),
(1940000000000, 'AddFirstMileVehicleAssignments1940000000000'),
(1950000000000, 'CreateNotifications1950000000000'),
(1950000000000, 'AddWindowCloseHour1950000000000'),
(1950000000000, 'AddVehicleCompliance1950000000000'),
(1950000000000, 'AddCustomerTruckAssignments1950000000000'),
(1960000000000, 'AddIncidents1960000000000'),
(1960000000000, 'AddContainerReceiptToBookingContainerUnits1960000000000'),
(1970000000000, 'AddMaintenanceDepth1970000000000'),
(1970000000000, 'AddCustomerTruckDeparture1970000000000'),
(1980000000000, 'YardCountryEnumAndRouteDirection1980000000000'),
(1980000000000, 'AddProcurement1980000000000'),
(1980000000000, 'AddBookingHandovers1980000000000'),
(1990000000000, 'SegmentCorridorBookings1990000000000'),
(1990000000000, 'AddVehiclePricePerKm1990000000000'),
(1990000000000, 'AddDoubleHandlingBasisAndMachinery1990000000000'),
(2000000000000, 'CreateCompanyChangeRequest2000000000000'),
(2000000000000, 'AddTruckDetentionTiming2000000000000'),
(2000000000000, 'AddGpsTracking2000000000000'),
(2000000000000, 'AddCustomsPriorityConfig2000000000000'),
(2000000000001, 'AddCompanyProfileReview2000000000001'),
(2010000000000, 'AddFeeRuleVehicleType2010000000000'),
(2010000000000, 'AddConsolidationResumeStatus2010000000000'),
(2020000000000, 'WarehouseCapacityKgToTons2020000000000'),
(2020000000000, 'RepairOtpEmailSchema2020000000000'),
(2030000000000, 'AddTrainScheduleReference2030000000000'),
(2040000000000, 'MigrateLicenseFilesToFileRecords2040000000000'),
(2040000000000, 'AddLocomotiveOverageTolerance2040000000000'),
(2050000000000, 'DropWagonTypeMaxWagonsPerTrain2050000000000'),
(2050000000000, 'AddCustomerTruckContainerLoadedAt2050000000000'),
(2060000000000, 'SeedRailWagonTypes2060000000000'),
(2060000000000, 'CreateYardDistances2060000000000'),
(2070000000000, 'MakeWagonTypeTareWeightRequired2070000000000'),
(2080000000000, 'DropWagonSpecColumns2080000000000'),
(2090000000000, 'RepairGrnNumberColumn2090000000000'),
(2090000000000, 'CreateContractTemplates2090000000000'),
(2100000000000, 'WarehouseLoadingTrainAssociation2100000000000'),
(2100000000000, 'CompanyProfileDefaultPending2100000000000'),
(2110000000000, 'RepairVehicleAvailabilityColumn2110000000000'),
(2110000000000, 'AddBookingIsSplit2110000000000'),
(2120000000000, 'AddScheduleWagonAllocationSnapshot2120000000000'),
(2120000000000, 'AddLastMileProofOfDelivery2120000000000'),
(2130000000000, 'AddHandoverSignerName2130000000000'),
(2140000000000, 'CreateAccrualAcks2140000000000'),
(2150000000000, 'TrainBuilder2150000000000'),
(2160000000000, 'MultiWagonTypePerCargoAndContainer2160000000000'),
(2170000000000, 'ScheduleWagonAdjustmentLogs2170000000000'),
(2170000000000, 'CreateWagonTransferRequests2170000000000'),
(2180000000000, 'LinkWagonMovementToTransferRequest2180000000000'),
(2190000000000, 'DropReopenDelayMinutes2190000000000'),
(2200000000000, 'TrainNumberPair2200000000000'),
(2210000000000, 'ScheduleScopedWagonPins2210000000000'),
(2220000000000, 'AddContractDocumentSnapshot2220000000000'),
(2230000000000, 'RenameWagonStatusRetiredToDetained2230000000000'),
(2240000000000, 'AddTransferRequestReason2240000000000'),
(2250000000000, 'CreatePriorityRuleChangeRequests2250000000000'),
(2260000000000, 'SeedEdrWagonFleetErNumbering2260000000000'),
(2260000000000, 'AddLastMileTruckArrivalDeparture2260000000000'),
(2260000000000, 'AddClearanceFeePayment2260000000000'),
(2270000000000, 'AddWagonTrainNumbers2270000000000'),
(2270000000000, 'AddContainerReturnQuantity2270000000000'),
(2280000000000, 'WagonNumberPartialUnique2280000000000'),
(2280000000000, 'SeedWagonRunNumbers2280000000000'),
(2290000000000, 'YardFacilities2290000000000'),
(2290000000000, 'SeedWagonYardDoraleh2290000000000'),
(2290000000000, 'DropContainerWagonsPerUnit2290000000000'),
(2300000000000, 'RepairGpsTrackingTables2300000000000'),
(2300000000000, 'CreateRateChangeRequests2300000000000'),
(2310000000000, 'CreateSupportChat2310000000000'),
(2320000000000, 'YardFacilityFreightTypes2320000000000'),
(2320000000000, 'SupportChatAttachments2320000000000'),
(2320000000000, 'AddRateYardScope2320000000000'),
(2330000000000, 'AddBookingCloseOffset2330000000000'),
(2340000000000, 'AddReverseWagonOrder2340000000000'),
(2340000000000, 'AddCargoTypeHasLashing2340000000000'),
(2350000000000, 'RefreshContractPricingArticles2350000000000'),
(2360000000000, 'RefreshContractPricingArticles2360000000000'),
(2370000000000, 'AddContainerUnitReturnFlag2370000000000'),
(2380000000000, 'AddTrainDeactivatedStatus2380000000000'),
(2390000000000, 'WidenYardCodeForSoftDeleteSuffix2390000000000'),
(2390000000000, 'SeedImportTrainNumbers2390000000000'),
(2400000000000, 'NormalizeCompanyRegions2400000000000'),
(2400000000000, 'AddCustomerTruckExitWeights2400000000000'),
(2410000000000, 'DropBookingApprovalWidenRoles2410000000000'),
(2420000000000, 'CreateContractDocumentRevisions2420000000000'),
(2430000000000, 'UniqueLocomotiveName2430000000000'),
(2430000000000, 'AddFileReviewStatus2430000000000'),
(2440000000000, 'AddHandoverEdrAssignment2440000000000'),
(2450000000000, 'DropActiveProfileTypeFromExternalProfiles2450000000000'),
(2460000000000, 'AddCacBankPaymentMethod2460000000000'),
(2470000000000, 'AddAcquisitionItemName2470000000000'),
(2480000000000, 'AddMaintenanceDueNotifiedAt2480000000000'),
(2800000000000, 'AddMaintenanceIntervals2800000000000'),
(2800000000001, 'AddSignatureToHandover2800000000001'),
(2810000000000, 'AddMaintenanceServiceItem2810000000000'),
(2820000000000, 'CustomsClearanceRouteScope2820000000000'),
(2820000000000, 'AddMileTonsQuantity2820000000000'),
(2830000000000, 'ReturnSurchargeRouteScope2830000000000'),
(2840000000000, 'AddTruckTypes2840000000000'),
(2850000000000, 'AddBookingDoubleHandling2850000000000'),
(2860000000000, 'DropClearanceFeePrepay2860000000000'),
(2860000000000, 'AddPerTruckDetentionWindow2860000000000'),
(2870000000000, 'CustomsClearancePerKind2870000000000'),
(2880000000000, 'LashingPerKind2880000000000'),
(2890000000000, 'LashingBulkOnlyPerDirection2890000000000'),
(2900000000000, 'LivestockPerItem2900000000000'),
(2910000000000, 'AddContractSignatureStamp2910000000000'),
(2920000000000, 'AddRevisionActorName2920000000000'),
(2930000000000, 'AddWagonTransferPartialFulfilment2930000000000'),
(2940000000000, 'AddFileVersionHistory2940000000000'),
(2950000000000, 'AddTransitAssigneeHandshake2950000000000'),
(2960000000000, 'AddContractHazardDeclaration2960000000000'),
(2970000000000, 'AddDoCollectionDates2970000000000'),
(2980000000000, 'AddBookingRequestCurrency2980000000000'),
(2990000000000, 'IndodeYardsAndCargoRouting2990000000000'),
(3000000000000, 'AddContractSuspension3000000000000'),
(3010000000000, 'AddBookingTransitAssignee3010000000000'),
(3020000000000, 'AddContractSubmittedAt3020000000000'),
(3030000000000, 'AddGlExchangeDocumentFields3030000000000'),
(3040000000000, 'AddTransitAgents3040000000000'),
(3050000000000, 'AddCbeBillPaymentMethod3050000000000'),
(3050000000000, 'MergeDuplicateSebetaYards3050000000000'),
(3060000000000, 'AddExportPaymentWindow3060000000000'),
(3070000000000, 'AddBulkTotalWeightTons3070000000000'),
(3080000000000, 'BackfillDireDawaMilestone3080000000000'),
(3090000000000, 'AddSavedSignatureStamp3090000000000'),
(3100000000000, 'PromoteDraftSchedulesToScheduled3100000000000'),
(3110000000000, 'AddYardToScheduleWagonAdjustmentLogs3110000000000'),
(3120000000000, 'AddApprovedAtToCompanies3120000000000'),
(3120000000000, 'AddCargoTypeItemsPerWagonMap3120000000000'),
(3130000000000, 'CreateCompanyRevisions3130000000000'),
(3140000000000, 'SyncBulkRateUnitsToCargoUom3140000000000'),
(3150000000000, 'CreateUserTradeAccess3150000000000'),
(3150000000000, 'FixFaydaAddressShape3150000000000'),
(3160000000000, 'FixPaymentPaidAtType3160000000000'),
(3170000000000, 'YardFacilityOriginDestination3170000000000'),
(3180000000000, 'PortYardFacilityRecords3180000000000'),
(3190000000000, 'AddCargoTypeTonsPerWagonMap3190000000000'),
(3200000000000, 'AddScheduleWindowRuleCustom3200000000000'),
(3210000000000, 'EmptyContainerReturnedBy3210000000000'),
(3220000000000, 'EmptyContainerReturnStatusHistory3220000000000'),
(3230000000000, 'SplitContractTemplatesByCustoms3230000000000'),
(3240000000000, 'CreateExchangeSettings3240000000000');

Binary file not shown.