Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-07-30 11:31:02 +00:00
215 changed files with 15300 additions and 1450 deletions

View File

@@ -2,6 +2,7 @@ import {
MiddlewareConsumer,
Module,
OnApplicationBootstrap,
RequestMethod,
} from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
@@ -12,6 +13,7 @@ import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
@@ -101,6 +103,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
@Module({
imports: [
@@ -154,6 +157,18 @@ import { LoggerMiddleware } from "./logger.middleware";
applications: [EDR_FREIGHT_APPLICATION],
permissions: EDR_FREIGHT_PERMISSIONS,
}),
// Replaces the package's DataSeeder. Shared with edr-passenger-api, which
// seeds the same `iam` schema — see packages/iam-seed.
IamSeedModule.forRoot({
superAdmin: {
username: "superadmin",
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
roleKey: "super_admin",
organizationKey: "edr_freight",
unitKey: "edr_freight_app",
fallbackEmail: "superadmin@tria.com",
},
}),
BookingsModule,
ContractsModule,
SignaturesModule,
@@ -229,11 +244,12 @@ import { LoggerMiddleware } from "./logger.middleware";
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
// private readonly seeder: DataSeeder,
private readonly iamBaselineSeeder: IamBaselineSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
@@ -263,13 +279,22 @@ export class AppModule implements OnApplicationBootstrap {
// Permissions foundation — keep enabled:
// freightPermissionKeyMigration → renames legacy permission keys
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
// edrOrgSeeder → seeds org/unit + the Permission catalog
// iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions,
// position types, organization types +
// default units, org/unit settings and the
// super-admin account. Replaces the package's
// DataSeeder, and is shared with
// edr-passenger-api so one writer owns the
// `iam` schema. Runs after edrOrgSeeder
// because the super admin attaches to the
// edr_freight org/unit.
// Writes nothing unless SEED_IAM_BASELINE=true.
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
// await this.seeder.run();
await this.edrOrgSeeder.run();
await this.iamBaselineSeeder.run();
await this.freightPositionsSeeder.run();
// File upload settings — keep enabled.
@@ -308,5 +333,9 @@ export class AppModule implements OnApplicationBootstrap {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
consumer.apply(LoginAudienceMiddleware).forRoutes(
{ path: "auth/login", method: RequestMethod.POST },
{ path: "auth/mfa-verify", method: RequestMethod.POST },
);
}
}

View File

@@ -0,0 +1,40 @@
import { generateGrnNumber, grnOwnerSlug } from './grn.util';
/**
* The GRN is mapped to the goods owner for BOTH directions, so a note is
* identifiable by who owns the cargo. The reference slice stays the uniqueness
* anchor — one owner can have several bookings received the same day.
*/
const date = new Date('2026-07-27T09:15:00Z');
const bookingId = '1a2b3c4d-1111-2222-3333-444455556666';
describe('GRN number', () => {
it('maps an import GRN to the owner', () => {
expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe(
'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D',
);
});
it('maps an export GRN to the owner the same way', () => {
expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe(
'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D',
);
});
it('keeps the owner-less format when there is no owner (manual walk-in)', () => {
expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D');
expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D');
});
it('stays unique per booking for one owner on one day', () => {
const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme');
const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme');
expect(a).not.toBe(b);
});
it('strips punctuation and caps the owner segment', () => {
expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT');
expect(grnOwnerSlug('a/b c')).toBe('ABC');
expect(grnOwnerSlug(null)).toBeNull();
});
});

View File

@@ -1,13 +1,41 @@
/**
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<OWNER>-<REF8>`.
*
* The GRN is mapped to the goods OWNER (the booking's customer / consignee) for
* both import and export, so a note is identifiable by who owns the cargo
* without opening it. The trailing reference slice stays as the uniqueness
* anchor — one owner can have several bookings received on the same day.
* Owner-less receipts (manual walk-ins with no booking) fall back to the
* original `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>` form.
*
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
* raised in a warehouse — the two live in different tables
* (facility_handling_events vs warehouse_inventory), and a second generator would
* eventually let their formats drift apart.
*/
export function generateGrnNumber(direction: string, referenceId: string, date: Date): string {
export function generateGrnNumber(
direction: string,
referenceId: string,
date: Date,
ownerName?: string | null,
): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
const owner = grnOwnerSlug(ownerName);
const base = `GRN-${direction.toUpperCase()}-${stamp}`;
return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`;
}
/**
* Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a
* long company name can't run away with the number. Null when there is nothing
* usable, which drops the segment rather than emitting an empty `--`.
*/
export function grnOwnerSlug(ownerName?: string | null): string | null {
const slug = (ownerName ?? '')
.normalize('NFKD')
.replace(/[^a-zA-Z0-9]+/g, '')
.toUpperCase()
.slice(0, 12);
return slug || null;
}

View File

@@ -15,7 +15,7 @@ export interface FaydaJwk {
qi?: string;
}
export type FaydaPlatform = 'WEB' | 'MOBILE';
export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL';
export interface FaydaConfig {
enabled: boolean;
@@ -25,8 +25,10 @@ export interface FaydaConfig {
userInfoEndpoint: string;
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
redirectUri: string;
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
/** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */
webRedirectUri: string;
/** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */
portalRedirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
@@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => {
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri;
if (!enabled) {
return {
enabled: false,
@@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => {
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
webRedirectUri,
portalRedirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
@@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => {
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
webRedirectUri,
portalRedirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,

View File

@@ -46,6 +46,9 @@ async function bootstrap() {
"Accept",
"Authorization",
"X-Requested-With",
// Which freight frontend is calling — /auth/login uses this to reject
// cross-audience credentials (EDRFREIGHT-415).
"X-Client-App",
// IAM context headers required by @tria-plc/api-common's JwtGuard
"organization-unit-id",
"delegator-position-id",
@@ -58,6 +61,13 @@ async function bootstrap() {
"x-delegator-position-id",
"x-current-project-id",
"x-current-position-id",
// Headers sent by the freight-backoffice OKR/objective-service client
// (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts)
// under yet another naming convention — unprefixed "tenant-key"/"unit-id",
// and "x-delegated-position-id" (delegated, not delegator).
"tenant-key",
"unit-id",
"x-delegated-position-id",
],
exposedHeaders: ["Content-Disposition"],
maxAge: 86400, // cache preflight for 24h to cut chatter in dev

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Double handling becomes an explicit per-booking decision instead of an
* implicit "every import" charge. Warehouse staff record Yes/No after
* unloading (whether the goods actually had to be re-handled); the
* DOUBLE_HANDLING_FEE rule only bills when the answer is Yes.
*
* NULL = not decided yet → no charge, and the UI shows "not set" so the
* operator is prompted. Existing rows stay NULL deliberately: back-billing a
* fee nobody confirmed would be wrong.
*/
export class AddBookingDoubleHandling2850000000000 implements MigrationInterface {
name = 'AddBookingDoubleHandling2850000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling boolean;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_at timestamptz;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_by varchar(160);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_by;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling;`,
);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-truck detention clocks. Detention was timed once per last-mile leg
* (last_mile.arrived_at / delivered_at), so every truck on a multi-truck
* delivery shared one window and was billed identical days — wrong the moment
* two trucks arrive or return at different times.
*
* Deliberately NEW columns rather than reusing the existing per-truck
* arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out
* events stamped by release(), whereas detention runs from arrival at the
* DESTINATION until the truck is released/returned.
*
* Both nullable — a truck without its own window falls back to the leg-level
* timestamps, so legacy legs keep billing exactly as before.
*/
export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface {
name = 'AddPerTruckDetentionWindow2860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS returned_at timestamptz;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS returned_at,
DROP COLUMN IF EXISTS destination_arrived_at;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Livestock is billed and counted per head, not per ton — line it up with the
* other break-bulk cargo types (Machinery, Truck, Automobile) so bulk
* storage/demurrage fees charge per item instead of per ton for it.
*/
export class LivestockPerItem2900000000000 implements MigrationInterface {
name = "LivestockPerItem2900000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.cargo_types
SET unit_of_measure = 'PER_ITEM'
WHERE code = 'LIVESTOCK'
AND unit_of_measure IS DISTINCT FROM 'PER_ITEM'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.cargo_types
SET unit_of_measure = 'PER_TON'
WHERE code = 'LIVESTOCK'
AND unit_of_measure IS DISTINCT FROM 'PER_TON'
`);
}
}

View File

@@ -0,0 +1,131 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Indode's real 11-yard layout, plus the plumbing to auto-route a booking to
* the right yard by cargo type (and, for container yards, trade direction):
*
* - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only
* meaningful for CONTAINER_YARD, where import and export stacks are
* physically separate (Yard 5 vs Yard 6). Everything else takes cargo
* either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means
* "not a customer cargo yard" — Yards 10/11 (service/equipment) are
* CONTAINER_YARD structurally but must never be offered for ordinary
* import/export cargo, so the frontend match requires an EXACT IMPORT/
* EXPORT direction hit for container freight rather than treating BOTH as
* a wildcard.
* - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors
* the existing `cargo_type_wagon_types` join table). Empty = open to any
* cargo type of the yard's structural type (additive, never restrictive
* by default), so this cannot break a yard nobody has configured yet.
*
* Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here
* so Yards 1 and 9 have a real mapping ready for when they reopen.
*/
export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface {
name = "IndodeYardsAndCargoRouting2990000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_yards
ADD COLUMN IF NOT EXISTS direction varchar(10)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types (
yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE,
cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
PRIMARY KEY (yard_id, cargo_type_id)
)
`);
// New cargo types Indode's yard list names but the catalog didn't have yet.
await queryRunner.query(`
INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active)
VALUES
('FERTILIZER', 'Fertilizer', 'PER_TON', true),
('COFFEE', 'Coffee', 'PER_TON', true),
('TEA', 'Tea', 'PER_TON', true)
ON CONFLICT (code) DO NOTHING
`);
// The 11 real yards at Indode Open Warehouse (code 'IOW').
await queryRunner.query(`
INSERT INTO freight.warehouse_yards
(warehouse_id, name, code, type, direction, status, is_active)
SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE'
FROM freight.warehouses w
CROSS JOIN (VALUES
('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'),
('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'),
('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'),
('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'),
('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'),
('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'),
('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'),
('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'),
('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE')
) AS y(code, name, type, direction, status)
WHERE w.code = 'IOW'
ON CONFLICT (warehouse_id, code) DO NOTHING
`);
// One default zone per new yard, matching its yard's type — every existing
// yard (CY-1, CY-A) already follows this one-zone-per-yard shape.
await queryRunner.query(`
INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active)
SELECT y.id, y.name || ' Zone 1', 'Z1',
CASE y.type
WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE'
WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE'
WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE'
WHEN 'BULK_YARD' THEN 'BULK_ZONE'
ELSE 'GENERAL_CARGO_ZONE'
END,
y.status, y.status = 'ACTIVE'
FROM freight.warehouse_yards y
JOIN freight.warehouses w ON w.id = y.warehouse_id
WHERE w.code = 'IOW' AND y.code LIKE 'Y%'
ON CONFLICT (yard_id, code) DO NOTHING
`);
// Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally
// left with no rows — direction alone decides those, per the entity comment.
await queryRunner.query(`
INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id)
SELECT y.id, ct.id
FROM freight.warehouses w
JOIN freight.warehouse_yards y ON y.warehouse_id = w.id
JOIN (VALUES
('Y1', 'FERTILIZER'),
('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'),
('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'),
('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'),
('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'),
('Y7', 'PERISHABLE'),
('Y9', 'COFFEE'), ('Y9', 'TEA')
) AS m(yard_code, cargo_code) ON m.yard_code = y.code
JOIN freight.cargo_types ct ON ct.code = m.cargo_code
WHERE w.code = 'IOW'
ON CONFLICT (yard_id, cargo_type_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.warehouse_zones z
USING freight.warehouse_yards y, freight.warehouses w
WHERE z.yard_id = y.id AND y.warehouse_id = w.id
AND w.code = 'IOW' AND y.code LIKE 'Y%'
`);
await queryRunner.query(`
DELETE FROM freight.warehouse_yards y
USING freight.warehouses w
WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%'
`);
// Cargo types and the join table are left in place — other data may have
// started referencing them since; dropping columns/tables is not reversible
// once real rows exist, and leaving them is harmless.
}
}

View File

@@ -0,0 +1,92 @@
import { ForbiddenException } from '@nestjs/common';
import { LoginAudienceMiddleware } from './login-audience.middleware';
/**
* Touches only the DataSource, so build off the prototype rather than
* standing up a full Nest module — same pattern as
* warehouses/receive-export-paid.spec.ts.
*/
function makeMiddleware(userType: string | undefined) {
const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []);
const middleware = Object.create(
LoginAudienceMiddleware.prototype,
) as LoginAudienceMiddleware;
(middleware as unknown as { dataSource: unknown }).dataSource = { query };
return middleware;
}
function makeReq(clientApp: string | undefined, email = 'someone@example.com') {
return {
header: (name: string) =>
name.toLowerCase() === 'x-client-app' ? clientApp : undefined,
body: { email },
} as any;
}
describe('LoginAudienceMiddleware', () => {
it('rejects when the client app header is missing', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq(undefined), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects an unrecognized client app header', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('mobile'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects an employee account signing in through the portal client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('portal'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects a customer account signing in through the backoffice client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await expect(
middleware.use(makeReq('backoffice'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows an employee account through the backoffice client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await middleware.use(makeReq('backoffice'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('allows a customer account through the portal client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('lets an unknown identifier fall through to the login handler', async () => {
const middleware = makeMiddleware(undefined);
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,58 @@
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { NextFunction, Request, Response } from 'express';
export const CLIENT_APP_HEADER = 'x-client-app';
// EUserType values from @tria-plc/api-common, duplicated here to avoid
// pulling in the full enum just for this string comparison.
const ALLOWED_USER_TYPES_BY_CLIENT: Record<string, string[]> = {
backoffice: ['employee'],
portal: ['individual', 'external_organization'],
};
/**
* Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials
* against email/username/phone_number only (see vendor
* findUserForLogin), with no check that the account's userType belongs on
* the app that's asking. A backoffice (employee) client presenting a
* customer's credentials — or vice versa — must not get a session.
*/
@Injectable()
export class LoginAudienceMiddleware implements NestMiddleware {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async use(req: Request, _res: Response, next: NextFunction) {
const clientApp = req.header(CLIENT_APP_HEADER);
const allowedUserTypes = clientApp
? ALLOWED_USER_TYPES_BY_CLIENT[clientApp]
: undefined;
if (!allowedUserTypes) {
throw new ForbiddenException(
`Missing or unrecognized ${CLIENT_APP_HEADER} header`,
);
}
const identifier: unknown = req.body?.email;
if (typeof identifier !== 'string' || !identifier) {
// No identifier to look up — the vendor DTO validation rejects the
// request on its own.
return next();
}
const [user] = await this.dataSource.query(
`SELECT user_type AS "userType" FROM iam.users
WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`,
[identifier],
);
if (user && !allowedUserTypes.includes(user.userType)) {
throw new ForbiddenException(
`This account cannot sign in through the ${clientApp} application`,
);
}
next();
}
}

View File

@@ -302,6 +302,20 @@ export class Booking extends BaseEntity {
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null;
/**
* Did the goods need re-handling in the warehouse? Recorded by warehouse
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
* null = not yet decided (no charge).
*/
@Column({ name: 'double_handling', type: 'boolean', nullable: true })
doubleHandling?: boolean | null;
@Column({ name: 'double_handling_set_at', type: 'timestamptz', nullable: true })
doubleHandlingSetAt?: Date | null;
@Column({ name: 'double_handling_set_by', type: 'varchar', length: 160, nullable: true })
doubleHandlingSetBy?: string | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;

View File

@@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import {
CompanyIdentityStateDto,
CompleteIdentityVerificationDto,
} from "./dto/complete-identity-verification.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
@@ -188,9 +192,20 @@ export class CompaniesController {
@Post("fetch-etrade-info")
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo(
@CurrentUser() user: CurrentIamUser,
@Body() dto: FetchETradeDto,
): Promise<ETradeResponseDto> {
const data = await this.companiesService.fetchETradeData(dto.tin);
// Best-effort: a first-run onboarding draft may not exist yet, in which
// case there is no company to exclude and `tinTaken` checks every row —
// the correct behaviour for a brand-new lookup.
const companyId = await this.companiesService
.getCompanyInfoByUserId(user.id)
.then(({ company }) => company.id)
.catch(() => undefined);
const data = await this.companiesService.fetchETradeData(
dto.tin,
companyId,
);
return new ETradeResponseDto(data);
}
@@ -378,6 +393,32 @@ export class CompaniesController {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
@Post("identity/fayda/complete")
@ApiOperation({
summary:
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
})
async completeIdentityVerification(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto);
}
@Delete("identity/fayda/poa")
@ApiOperation({
summary:
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
})
async removePoaIdentity(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.removePoaIdentity(user.id);
}
@Patch("onboarding-step")
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -0,0 +1,412 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { CompanyNationality, CompanyStatus } from "./entities/company.entity";
import { ProfileType } from "./entities/company-profile.entity";
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
/**
* A person's identity is proved through Fayda: name, email, phone and address
* come from the verified payload, not typed. Fayda's userinfo carries no
* national ID number, so none is collected or derived here.
*
* - Ethiopian company: the owner (and its PoA, once named) is verified through
* Fayda, and their details can't be edited afterwards.
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
* supplies a typed passport number — required on its own, whether or not the
* owner also completes a (purely optional) Fayda verification.
*
* The owner is NOT the general manager — GM is a separate, plain typed role
* the portal offers a "same as owner" copy for, but it is never itself
* Fayda-verified or gated on.
*/
interface Ctx {
attributes: Record<string, unknown>;
files: { id: string; code: string; reviewStatus?: string | null }[];
profileTypes: ProfileType[];
status: CompanyStatus;
nationality: CompanyNationality;
verification: Record<string, unknown>;
}
const OWNER_VERIFIED = {
ownerFaydaSub: "owner-sub",
ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z",
ownerName: "Abebe Bikila",
};
const POA_VERIFIED = {
poaFaydaSub: "poa-sub",
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
poaName: "Tirunesh Dibaba",
poaEmail: "tirunesh@example.com",
poaPhone: "+251911000000",
};
const paper = () => ({
id: "file-1",
code: POA_DELEGATION_FILE_KEY,
reviewStatus: null,
});
function makeService(overrides: Partial<Ctx> = {}) {
const ctx: Ctx = {
attributes: {},
files: [],
profileTypes: [ProfileType.importer],
status: CompanyStatus.Pending,
nationality: CompanyNationality.Ethiopian,
verification: {
purpose: "VERIFY",
verified: true,
sub: "new-sub",
fullName: "Haile Gebrselassie",
email: "haile@example.com",
phoneNumber: "+251922000000",
address: "Addis Ababa",
birthdate: "1973-04-18",
gender: "Male",
},
...overrides,
};
const company = () => ({
id: "company-1",
status: ctx.status,
nationality: ctx.nationality,
attributes: ctx.attributes,
companyProfiles: ctx.profileTypes.map((type, i) => ({
id: `profile-${i}`,
type,
})),
type: "customer",
});
const deps = {
companiesRepo: {
findById: jest.fn(async () => company()),
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
if (patch.attributes)
ctx.attributes = patch.attributes as Record<string, unknown>;
return company();
}),
findByTin: jest.fn(async () => null),
},
companyProfilesRepo: {
findByCompanyId: jest.fn(async () =>
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
),
findByType: jest.fn(async (_id: string, type: ProfileType) =>
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () => null),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
...row,
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
id: "external-1",
companyId: "company-1",
company: company(),
onboardingCompleted: false,
})),
},
filesService: {
findByResource: jest.fn(async () => ctx.files),
findById: jest.fn(async () => null),
remove: jest.fn(async () => undefined),
},
companyNotifier: { changeRequestSubmitted: jest.fn() },
verifayda: {
completeVerification: jest.fn(async () => ctx.verification),
},
};
const service = new CompaniesService(
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
{} as never,
{} as never,
deps.companyNotifier as never,
{} as never,
deps.verifayda as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: company() }) as never,
);
return { service, ctx, deps, company };
}
describe("Fayda identity verification binds a person to the company", () => {
it("writes the verified identity", async () => {
const { service, ctx } = makeService();
const state = await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(ctx.attributes.ownerFaydaSub).toBe("new-sub");
expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie");
expect(state.owner.verified).toBe(true);
});
it("fills every PoA detail from the payload, address included", async () => {
const { service, ctx } = makeService();
await service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
});
expect(ctx.attributes.poaName).toBe("Haile Gebrselassie");
expect(ctx.attributes.poaEmail).toBe("haile@example.com");
expect(ctx.attributes.poaPhone).toBe("+251922000000");
expect(ctx.attributes.poaAddress).toBe("Addis Ababa");
});
it("verifies successfully even though Fayda returns no national ID number", async () => {
// Fayda's userinfo carries no FAN/FIN claim at all — this must be the
// normal, successful path, not an error.
const { service } = makeService({
verification: {
purpose: "VERIFY",
verified: true,
sub: "x",
fullName: "No Fan Here",
},
});
const state = await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(state.owner.verified).toBe(true);
});
it("refuses to make one identity both owner and PoA", async () => {
const { service } = makeService({
attributes: { ownerFaydaSub: "same-person" },
verification: {
purpose: "VERIFY",
verified: true,
sub: "same-person",
fullName: "Abebe Bikila",
},
});
await expect(
service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it("stages the change for review on an approved company", async () => {
// Swapping the person who can act for a live company is exactly what the
// backoffice review exists for, so it must not rewrite the row directly.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
await service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
});
it("refuses to rename a verified person by hand", async () => {
const { service } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
files: [paper()],
});
await expect(
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it("never locks or gates the general manager — it is not the verified subject", async () => {
// GM is a plain typed role; the portal offers a "same as owner" copy, but
// the backend must not treat it as identity-owned or require it verified.
const { service } = makeService({
attributes: { ...OWNER_VERIFIED },
});
await expect(
service.updateProfile("user-1", {
generalManagerName: "Someone Else",
generalManagerEmail: "someone@example.com",
generalManagerPhone: "+251911223344",
} as never),
).resolves.toBeDefined();
});
});
describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => {
// The company is applying for the forwarder role, so it must not already
// hold it — createCompanyProfileForUser short-circuits on an existing profile
// and would never reach the gate.
const applyingForFf = {
profileTypes: [ProfileType.importer],
attributes: { ...POA_VERIFIED },
files: [paper()],
};
it("blocks the forwarder role while the owner is unverified", async () => {
const { service } = makeService(applyingForFf);
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("blocks the forwarder role while the PoA is unverified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
attributes: {
...OWNER_VERIFIED,
poaName: "Tirunesh Dibaba",
poaEmail: "t@example.com",
poaPhone: "+251911000000",
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role once owner and PoA are both verified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("never asks a foreign company for Fayda, verified or not", async () => {
const { service } = makeService({
nationality: CompanyNationality.Foreign,
});
const state = await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
// Still lets the owner verify — a foreign owner verifying is allowed, just
// never required — but the passport is the thing that actually gates it.
expect(state.owner.verified).toBe(true);
expect(state.faydaRequired).toBe(false);
expect(state.passportRequired).toBe(true);
});
it("blocks the forwarder role for a foreign company with no owner passport", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
poaName: "Jean Dupont",
poaEmail: "jean@example.com",
poaPhone: "+33100000000",
},
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
ownerPassportNumber: "P1234567",
poaName: "Jean Dupont",
poaEmail: "jean@example.com",
poaPhone: "+33100000000",
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
// Verifying is optional for a foreign owner, but it does not waive the
// passport requirement — the two are independent credentials.
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
...OWNER_VERIFIED,
poaName: "Jean Dupont",
poaEmail: "jean@example.com",
poaPhone: "+33100000000",
},
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { VerifaydaModule } from "../verifayda/verifayda.module";
@Module({
imports: [
@@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service";
// imports this module back for portal recipient targeting, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
// Fayda identity verification for the company's owner and PoA.
VerifaydaModule,
],
controllers: [CompaniesController],
providers: [

View File

@@ -0,0 +1,242 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { CompanyStatus } from "./entities/company.entity";
import { ProfileType } from "./entities/company-profile.entity";
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
/**
* EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS
* delegation paper on file. The rule used to live only in the onboarding
* wizard's completion check, so every other write that could break the pairing
* — saving PoA details, deleting the paper, picking up the forwarder role —
* went unguarded. These cover those writes.
*/
interface Ctx {
attributes: Record<string, unknown>;
files: { id: string; code: string; reviewStatus?: string | null }[];
profileTypes: ProfileType[];
status: CompanyStatus;
pendingSnapshot: Record<string, unknown> | null;
}
const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" };
/**
* The forwarder role is gated on Fayda-verified identities as well as on the
* delegation paper. These tests are about the paper, so they run against a
* company whose identities are already verified — the identity rule itself is
* covered in companies.fayda-identity.spec.ts.
*/
const VERIFIED_IDENTITIES = {
ownerFaydaSub: "owner-sub",
poaFaydaSub: "poa-sub",
};
function makeService(overrides: Partial<Ctx> = {}) {
const ctx: Ctx = {
attributes: {},
files: [],
profileTypes: [ProfileType.importer],
status: CompanyStatus.Pending,
pendingSnapshot: null,
...overrides,
};
const company = () => ({
id: "company-1",
status: ctx.status,
attributes: ctx.attributes,
companyProfiles: ctx.profileTypes.map((type, i) => ({
id: `profile-${i}`,
type,
})),
type: "customer",
});
const deps = {
companiesRepo: {
findById: jest.fn(async () => company()),
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
ctx.attributes = (patch.attributes ??
ctx.attributes) as Record<string, unknown>;
return company();
}),
findByTin: jest.fn(async () => null),
},
companyProfilesRepo: {
findByCompanyId: jest.fn(async () =>
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
),
findByType: jest.fn(async (_id: string, type: ProfileType) =>
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
...row,
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
id: "external-1",
companyId: "company-1",
company: company(),
onboardingCompleted: false,
})),
},
filesService: {
findByResource: jest.fn(async () => ctx.files),
findById: jest.fn(async (id: string) =>
ctx.files.find((f) => f.id === id)
? {
...ctx.files.find((f) => f.id === id),
resource: "companies",
resourceId: "company-1",
name: "dars.pdf",
}
: null,
),
remove: jest.fn(async () => undefined),
},
companyNotifier: { changeRequestSubmitted: jest.fn() },
};
const service = new CompaniesService(
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
{} as never,
{} as never,
deps.companyNotifier as never,
{} as never,
{} as never,
);
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
// the PoA paths, so short-circuit it rather than mock the whole graph.
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: company() }) as never,
);
return { service, ctx, deps };
}
const paper = (reviewStatus: string | null = null) => ({
id: "file-1",
code: POA_DELEGATION_FILE_KEY,
reviewStatus,
});
describe("PoA delegation paper is enforced wherever PoA state changes", () => {
it("rejects PoA details saved with no paper on file", async () => {
const { service } = makeService();
await expect(
service.updateProfile("user-1", POA as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it("accepts PoA details once the paper is on file", async () => {
const { service } = makeService({ files: [paper()] });
await expect(
service.updateProfile("user-1", POA as never),
).resolves.toBeDefined();
});
it("rejects a paper the reviewer sent back for correction", async () => {
const { service } = makeService({ files: [paper("change_requested")] });
await expect(
service.updateProfile("user-1", POA as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it("leaves edits that don't touch the PoA alone", async () => {
// A company carrying legacy details must not be locked out of every other
// field until it produces a paper.
const { service } = makeService({ attributes: { ...POA }, files: [] });
await expect(
service.updateProfile("user-1", { companyEmail: "x@y.com" } as never),
).resolves.toBeDefined();
});
it("refuses to remove the paper while the PoA is still named", async () => {
const { service } = makeService({
attributes: { ...POA },
files: [paper()],
});
await expect(
service.removePoaDelegationLetter("user-1", "file-1"),
).rejects.toBeInstanceOf(BadRequestException);
});
it("allows removing the paper once the PoA has been cleared", async () => {
const { service } = makeService({ attributes: {}, files: [paper()] });
await expect(
service.removePoaDelegationLetter("user-1", "file-1"),
).resolves.toBeDefined();
});
it("judges the removal against a staged clear, not the live row", async () => {
// An Active company's edits are staged for review rather than written, so
// the live attributes still carry the PoA the customer just cleared.
const { service } = makeService({
status: CompanyStatus.Active,
attributes: { ...POA },
pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" },
files: [paper()],
});
await expect(
service.removePoaDelegationLetter("user-1", "file-1"),
).resolves.toBeDefined();
});
it("refuses the forwarder role to a company with no PoA", async () => {
const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } });
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role once PoA details and paper are both in place", async () => {
const { service } = makeService({
attributes: { ...POA, ...VERIFIED_IDENTITIES },
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
});

View File

@@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
.getMany();
}
async existsByTin(tin: string): Promise<boolean> {
const count = await this.repository.count({ where: { tin } as any });
async existsByTin(tin: string, excludeCompanyId?: string): Promise<boolean> {
const qb = this.repository
.createQueryBuilder('company')
.where('company.tin = :tin', { tin });
if (excludeCompanyId) {
qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId });
}
const count = await qb.getCount();
return count > 0;
}

View File

@@ -0,0 +1,113 @@
import { CompaniesService } from "./companies.service";
import { CompanyType } from "./entities/company.entity";
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
/**
* EDRFREIGHT-416: onboarding asked for a deselected role's documents.
*
* Re-running role selection used to only ADD operational profiles, so a role
* the user unticked on the way back left its company_profile row behind — and
* every role-driven requirement (business license, forwarder PoA) is derived
* from those rows. startOnboarding now reconciles both directions.
*/
interface ExistingProfile {
id: string;
type: ProfileType;
status: ProfileStatus;
}
function makeService(existing: ExistingProfile[]) {
const companyProfilesRepo = {
findByCompanyId: jest.fn(async () => existing),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
softDelete: jest.fn(async () => undefined),
};
const companiesRepo = { update: jest.fn(async () => null) };
const profilesRepo = {
findByUserId: jest.fn(async () => ({
id: "external-1",
companyId: "company-1",
company: { id: "company-1" },
})),
};
const service = new CompaniesService(
companiesRepo as never,
companyProfilesRepo as never,
{} as never,
profilesRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
);
return { service, companyProfilesRepo };
}
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
const start = (service: CompaniesService, roles: ProfileType[]) =>
service.startOnboarding(identity as never, CompanyType.Customer, roles);
describe("re-running role selection reconciles the operational profiles", () => {
it("drops the profile for a role the user deselected", async () => {
const { service, companyProfilesRepo } = makeService([
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
{
id: "p-ff",
type: ProfileType.freightForwarder,
status: ProfileStatus.Pending,
},
]);
await start(service, [ProfileType.importer]);
expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff");
expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1);
expect(companyProfilesRepo.create).not.toHaveBeenCalled();
});
it("keeps an already-approved profile even when it is unticked", async () => {
const { service, companyProfilesRepo } = makeService([
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
{
id: "p-exp",
type: ProfileType.exporter,
status: ProfileStatus.Active,
},
]);
await start(service, [ProfileType.importer]);
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
});
it("still adds a newly-picked role", async () => {
const { service, companyProfilesRepo } = makeService([
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
]);
await start(service, [ProfileType.importer, ProfileType.exporter]);
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1);
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ type: ProfileType.exporter }),
);
});
});

View File

@@ -17,10 +17,23 @@ import {
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import {
POA_DELEGATION_FILE_KEY,
POA_DELEGATION_LABEL,
POA_DELEGATION_PENDING_CODE,
} from "../file-upload-settings/poa-delegation.constants";
import { VerifaydaService } from "../verifayda/verifayda.service";
import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
CompleteIdentityVerificationDto,
IdentitySubject,
} from "./dto/complete-identity-verification.dto";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import type { CompanyRegistrationData } from "@edr/types";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
@@ -58,10 +71,6 @@ const LICENSE_CODE = "business_license";
/** Code for a license file staged in an open change request (not yet live). */
const LICENSE_PENDING_CODE = "business_license_pending";
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a PoA letter staged in an open change request (not yet live). */
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** FileRecord resource that company-level documents are stored under. */
const COMPANY_RESOURCE = "companies";
/** company.attributes keys that together mean "a PoA was entered". */
@@ -79,6 +88,62 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaPhone", label: "PoA phone" },
];
/**
* `attributes` key prefix per verifiable person. The owner is NOT the general
* manager — GM is a plain typed role (the portal offers a "same as owner" copy
* once the owner is verified), while the owner is who this verification
* actually proves. They're very often the same human; that's what the copy is
* for.
*/
const IDENTITY_PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
owner: "owner",
poa: "poa",
};
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
owner: "owner",
poa: "Power of Attorney",
};
/**
* Identity fields a Fayda verification owns outright, per person. Once verified
* these can no longer be typed — the government IdP is the source, so an edit
* that disagrees with it is either a mistake or an attempt to launder the
* guarantee away. The GM fields are deliberately absent: GM is never itself
* Fayda-verified, so it stays freely editable regardless of the owner's state.
*/
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
};
/**
* `UpdateProfileDto` fields eTrade is the sole source of truth for. A request
* touching any of these must be re-checked against a fresh eTrade lookup —
* see `assertEtradeFieldsAuthentic`.
*/
const ETRADE_SOURCED_FIELDS = [
"companyName",
"tin",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
"etradePhone",
] as const satisfies readonly (keyof UpdateProfileDto)[];
/** The attributes a verification writes, for one person. */
interface VerifiedIdentityAttributes {
[key: string]: unknown;
}
export interface UserIdentity {
userId: string;
firstName: string;
@@ -100,6 +165,7 @@ export class CompaniesService {
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
private readonly dataSource: DataSource,
private readonly verifaydaService: VerifaydaService,
) { }
/**
@@ -255,8 +321,9 @@ export class CompaniesService {
* chosen operational role(s) up front, so every subsequent wizard step can
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
*
* Idempotent: if the user already has a profile, returns it unchanged (only
* adding any newly-chosen roles). The draft company carries a placeholder TIN
* Idempotent: if the user already has a profile, returns it unchanged, with
* the operational profiles reconciled against the roles just chosen (added
* and — for still-pending ones — removed). The draft company carries a placeholder TIN
* (the real one is filled on the Company Information step) and stays
* status=pending / onboardingCompleted=false until the wizard finishes.
*/
@@ -271,7 +338,7 @@ export class CompaniesService {
const existing = await this.profilesRepo.findByUserId(identity.userId);
if (existing) {
const companyId = existing.company?.id ?? existing.companyId;
await this.ensureCompanyProfiles(companyId, companyType, roles);
await this.syncCompanyProfiles(companyId, companyType, roles);
if (nationality) {
await this.companiesRepo.update(companyId, { nationality });
}
@@ -302,25 +369,44 @@ export class CompaniesService {
onboardingCompleted: false,
});
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
return this.getCompanyInfoByUserId(identity.userId);
}
/** Create any of the requested operational profiles that don't exist yet. */
private async ensureCompanyProfiles(
/**
* Reconcile the company's operational profiles with the roles the user has
* selected: create the missing ones, drop the ones they deselected.
*
* Dropping matters because every role-driven onboarding requirement — the
* per-profile business license, the freight-forwarder PoA rule, the license
* cards in the wizard — is derived from these rows. A row left behind after
* the user went back and unticked a role keeps asking for that role's
* documents (EDRFREIGHT-416). Only still-pending profiles are removed: an
* approved one is live (it can carry bookings and contracts) and re-running
* role selection must never delete it.
*/
private async syncCompanyProfiles(
companyId: string,
companyType: CompanyType,
roles: ProfileType[],
): Promise<void> {
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
for (const type of roles) {
if (!allowedTypes.includes(type)) continue;
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (existing) continue;
const chosen = roles.filter((t) => allowedTypes.includes(t));
const existing = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const profile of existing) {
if (chosen.includes(profile.type)) continue;
if (profile.status !== ProfileStatus.Pending) continue;
// The license files uploaded against this profile go with it: they are
// only ever read per company_profile id, so a soft-deleted profile
// leaves nothing behind to prompt for. Re-picking the role creates a
// fresh profile the user uploads against again.
await this.companyProfilesRepo.softDelete(profile.id);
}
for (const type of chosen) {
if (existing.some((p) => p.type === type)) continue;
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId,
@@ -599,7 +685,9 @@ export class CompaniesService {
*/
private mapProfileDtoToCompanyUpdates(
company: Company,
dto: Partial<UpdateProfileDto>,
dto: Partial<UpdateProfileDto> & {
faydaIdentity?: VerifiedIdentityAttributes;
},
): Record<string, any> {
const companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
@@ -617,7 +705,6 @@ export class CompaniesService {
if (dto.tin !== undefined && dto.tin !== company.tin)
companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
@@ -661,6 +748,72 @@ export class CompaniesService {
if (dto.etradePhone !== undefined)
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
// A plain typed field — never Fayda-verified, so no lock ever applies to
// it. Independent of the owner's verification: still required for a
// foreign company even if the owner also verifies with Fayda.
if (dto.ownerPassportNumber !== undefined)
attrUpdates.ownerPassportNumber = dto.ownerPassportNumber;
// A verified identity overwrites the person's details. `faydaIdentity`
// never comes off the wire — the global validation pipe runs with
// forbidNonWhitelisted, so a client that sends it is rejected outright; it
// only reaches here from completeIdentityVerification, directly or through
// a staged snapshot.
if (dto.faydaIdentity) {
Object.assign(attrUpdates, dto.faydaIdentity);
}
// companyEmail/companyPhone are the Company-column mirrors of the owner's
// verified contact details (the portal derives and submits them, it never
// lets the customer type them once verified) — lock them the same way
// ownerEmail/ownerPhone themselves are locked below, once there is a
// verified owner to lock them to.
if (attrUpdates.ownerFaydaSub) {
if (
dto.companyEmail !== undefined &&
dto.companyEmail !== attrUpdates.ownerEmail
) {
throw new BadRequestException(
"companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
);
}
if (
dto.companyPhone !== undefined &&
normalizeE164(dto.companyPhone) !==
normalizeE164(String(attrUpdates.ownerPhone ?? ""))
) {
throw new BadRequestException(
"companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
);
}
}
// Renaming a Fayda-verified person by hand would launder the guarantee
// away, so the fields the verification owns are refused once it exists.
for (const subject of ["owner", "poa"] as IdentitySubject[]) {
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
const incoming = (dto as Record<string, unknown>)[field];
if (incoming === undefined) continue;
// The verification itself is allowed to write them; anything else is
// compared against what is already stored, not against the value this
// same call just copied into the patch. Phones are compared normalized:
// a form that re-renders +251911000000 as 0911000000 is echoing the
// stored value back, not trying to change it.
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
const stored = company.attributes?.[field];
const same = field.endsWith("Phone")
? normalizeE164(String(incoming)) ===
normalizeE164(String(stored ?? ""))
: incoming === stored;
if (!same) {
throw new BadRequestException(
`${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
);
}
}
}
companyUpdates.attributes = attrUpdates;
return companyUpdates;
}
@@ -702,6 +855,21 @@ export class CompaniesService {
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
await this.assertEtradeFieldsAuthentic(company, dto);
// Naming (or renaming) a Power of Attorney is one of the writes that can
// leave the company with a representative and nothing evidencing them, so
// it is gated here. Edits that don't touch the PoA are left alone — a
// company carrying legacy details must not be locked out of every other
// field until it produces a paper.
if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) {
const attributes = this.mapProfileDtoToCompanyUpdates(company, dto)
.attributes as Record<string, unknown>;
await this.assertPoaDelegationSatisfied(company.id, attributes, {
requirePoa: await this.isFreightForwarder(company.id),
});
}
if (company.status !== CompanyStatus.Active) {
await this.assertTinAvailable(company, dto.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
@@ -1127,11 +1295,24 @@ export class CompaniesService {
// blacklist skip all this — staff must always be able to act against a bad
// account.
return this.dataSource.transaction(async (manager) => {
await manager.findOne(Company, {
const company = await manager.findOne(Company, {
where: { id: existing.companyId },
lock: { mode: "pessimistic_write" },
});
// Putting a forwarder into service without a Power of Attorney backed by
// a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is
// the last place it has to be checked — the role may have been applied
// for before the paper was withdrawn.
if (company && existing.type === ProfileType.freightForwarder) {
this.assertIdentityVerified(company, { requirePoa: true });
await this.assertPoaDelegationSatisfied(
company.id,
company.attributes,
{ requirePoa: true },
);
}
const [companyDocs, profileDocs] = await Promise.all([
this.filesService.findWithOpenChangeRequest(
[existing.companyId],
@@ -1382,6 +1563,18 @@ export class CompaniesService {
);
if (existing) continue;
// A forwarder signs on other companies' behalf, so it cannot be taken on
// without a Power of Attorney and its DARS paper — checked here so the
// customer is told at the point of asking, not at review.
if (type === ProfileType.freightForwarder) {
this.assertIdentityVerified(company, { requirePoa: true });
await this.assertPoaDelegationSatisfied(
companyId,
await this.effectivePoaAttributes(company),
{ requirePoa: true },
);
}
// Self-service role adds start Pending and carry no reference — a reference
// is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
@@ -1419,6 +1612,14 @@ export class CompaniesService {
}
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created && type === ProfileType.freightForwarder) {
this.assertIdentityVerified(company, { requirePoa: true });
await this.assertPoaDelegationSatisfied(
companyId,
await this.effectivePoaAttributes(company),
{ requirePoa: true },
);
}
if (!created) {
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved.
@@ -1453,11 +1654,17 @@ export class CompaniesService {
userId: string,
): Promise<OnboardingRequirementsResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const identity = this.getCompanyIdentityState(company);
// 1. Required company-information fields.
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
(f) => !f.get(company),
).map((f) => ({ key: f.key, label: f.label }));
// 1. Required company-information fields. The FAN is never one of them —
// Fayda verification doesn't produce a FAN, so it's never collected as
// part of onboarding at all (see the identity block below).
const requiredInfo = this.REQUIRED_COMPANY_INFO.filter(
(f) => f.key !== "fanNumber",
);
const missingInfo = requiredInfo
.filter((f) => !f.get(company))
.map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
@@ -1504,26 +1711,31 @@ export class CompaniesService {
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
// has been entered must be evidenced by the delegation letter.
// has been entered must be evidenced by the DARS delegation paper — a legal
// requirement, so unlike the documents above it does not depend on the
// upload set carrying a field for it (see poa-delegation.constants.ts).
const poaRequired = (company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
);
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
const missingPoaFields = poaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
// Only gate on the letter once the document set actually carries the field.
const delegationField = (setting?.fields ?? []).find(
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
);
const missingDelegation =
Boolean(delegationField) &&
(poaRequired || poaProvided) &&
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
// An Ethiopian company does not type its PoA details at all — they arrive
// from the Fayda verification — so reporting them as missing fields would
// ask for something the form no longer offers. The identity block below
// reports "verify your PoA" instead.
const missingPoaFields =
poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided;
const missingDelegation = delegationDue && !delegation.onFile;
// A paper the reviewer sent back is not evidence — the customer has to
// replace it before the application counts as complete.
const flaggedDelegation = delegationDue && delegation.flagged;
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
@@ -1534,29 +1746,62 @@ export class CompaniesService {
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
? ["Upload the delegation letter for your Power of Attorney"]
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
: []),
...(flaggedDelegation
? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`]
: []),
...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"]
: []),
...(identity.faydaRequired &&
(poaRequired || poaProvided) &&
!identity.poa.verified
? ["Verify your Power of Attorney's identity with Fayda"]
: []),
...(identity.passportRequired && !identity.owner.passportNumber
? ["Add the company owner's passport number"]
: []),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents, one license per operational profile, and the
// PoA details/letter whenever those are mandatory.
// PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount =
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
(poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.length
: 0) + (delegationDue ? 1 : 0);
// One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
// is one and Fayda is what's mandatory here.
const identityItemCount = identity.faydaRequired
? delegationDue
? 2
: 1
: identity.passportRequired
? 1
: 0;
const missingIdentityCount = identity.faydaRequired
? (identity.owner.verified ? 0 : 1) +
(delegationDue && !identity.poa.verified ? 1 : 0)
: identity.passportRequired && !identity.owner.passportNumber
? 1
: 0;
const total =
this.REQUIRED_COMPANY_INFO.length +
requiredInfo.length +
requiredDocCount +
licenseProfiles.length +
poaItemCount;
poaItemCount +
identityItemCount;
const completed =
total -
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingPoaFields.length +
(missingDelegation ? 1 : 0));
(missingDelegation || flaggedDelegation ? 1 : 0) +
missingIdentityCount);
return new OnboardingRequirementsResponseDto({
documentSettingCode,
@@ -1567,10 +1812,15 @@ export class CompaniesService {
poa: {
required: poaRequired,
provided: poaProvided,
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
delegationLetterUploaded: delegation.onFile,
delegationLetterFlagged: delegation.flagged,
missingFields: missingPoaFields,
complete: missingPoaFields.length === 0 && !missingDelegation,
complete:
missingPoaFields.length === 0 &&
!missingDelegation &&
!flaggedDelegation,
},
identity,
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,
@@ -2044,15 +2294,348 @@ export class CompaniesService {
}
// ---------------------------------------------------------------------------
// Power of Attorney delegation letter
// Power of Attorney delegation paper (DARS)
//
// A company-level document that follows the same staged-review model as the
// business license: on an approved (Active) company an upload lands under the
// pending code and the live letter is flagged for removal, so the reviewer
// pending code and the live paper is flagged for removal, so the reviewer
// sees both and approval swaps them atomically. During onboarding it goes live.
// ---------------------------------------------------------------------------
/** The company's PoA letter(s), with each file's review status resolved. */
/**
* What the company has on file towards its DARS delegation paper. A paper
* staged for review counts as "on file" — it is the customer's whole
* obligation discharged; whether it is good enough is the reviewer's call,
* recorded as `flagged`.
*/
private async getPoaDelegationState(
companyId: string,
ignoreFileIds: string[] = [],
): Promise<{ onFile: boolean; flagged: boolean }> {
const records = (
await this.filesService.findByResource(companyId, COMPANY_RESOURCE)
).filter(
(r) =>
(r.code === POA_DELEGATION_FILE_KEY ||
r.code === POA_DELEGATION_PENDING_CODE) &&
!ignoreFileIds.includes(r.id),
);
return {
onFile: records.length > 0,
flagged: records.some((r) => r.reviewStatus === "change_requested"),
};
}
/**
* The rule behind EDRFREIGHT-358: a company that names a Power of Attorney
* must evidence it with a DARS delegation paper, and a freight forwarder —
* which signs on other companies' behalf — must have both, verified.
*
* This is enforced at every write that can break the pairing (PoA details
* saved, paper removed, forwarder role applied for or approved) rather than
* only at onboarding submission, which is what let a company that finished
* onboarding as an importer pick up the forwarder role with neither.
*
* `attributes` is the state being written, which is not always the state on
* the row yet — a staged change request carries it, and a removal has to be
* judged against the files that would survive it (`ignoreFileIds`).
*/
private async assertPoaDelegationSatisfied(
companyId: string,
attributes: Record<string, unknown> | null | undefined,
opts: { requirePoa: boolean; ignoreFileIds?: string[] },
): Promise<void> {
const read = (key: string) =>
(attributes?.[key] as string | undefined)?.trim();
const poaProvided = POA_ATTRIBUTES.some((k) => read(k));
if (!opts.requirePoa && !poaProvided) return;
if (opts.requirePoa) {
const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key));
if (missing.length > 0) {
throw new BadRequestException(
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
);
}
}
const { onFile, flagged } = await this.getPoaDelegationState(
companyId,
opts.ignoreFileIds,
);
if (!onFile) {
throw new BadRequestException(
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
);
}
if (flagged) {
throw new BadRequestException(
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
`Re-upload it before continuing.`,
);
}
}
/** Does this company operate as a freight forwarder? */
private async isFreightForwarder(companyId: string): Promise<boolean> {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
return profiles.some((p) => p.type === ProfileType.freightForwarder);
}
// ---------------------------------------------------------------------------
// Fayda identity verification (owner / PoA)
//
// A completed VeriFayda verification proves a person's name, phone, email
// and address — Fayda's userinfo carries no national ID number, so none of
// that is collected here. For an Ethiopian company both the owner and its
// PoA (once named) must be verified before the company can trade. Fayda is
// an Ethiopian national ID system, so a foreign company's owner proves
// identity with a typed passport number instead — required on its own
// terms, not waived by an owner who happens to verify with Fayda too.
// ---------------------------------------------------------------------------
/**
* Verification state for both people, plus whether it is mandatory here.
* `complete` answers the gate question directly so the portal, the onboarding
* requirements and the assertions below all read the same verdict — the
* derivation itself is shared with ProfileResponseDto.
*/
getCompanyIdentityState(company: Company): CompanyIdentityStateDto {
return buildCompanyIdentityState(company);
}
/**
* Complete a Fayda verification and bind the identity to one of the company's
* people. The portal starts the flow through the shared
* `POST /fayda/verification/start` and only tells us which person it was for
* here, at completion — so the verifayda module stays generic and its session
* table needs no company-specific column.
*/
async completeIdentityVerification(
userId: string,
dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
const prefix = IDENTITY_PREFIX[dto.subject];
const result = await this.verifaydaService.completeVerification({
code: dto.code,
state: dto.state,
});
if (!result.verified || !result.sub) {
throw new BadRequestException(
"Fayda could not verify this identity. Start the verification again.",
);
}
// The owner delegating power of attorney to themselves is not a
// delegation — it would let one identity satisfy both halves of the check.
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
if (otherSub && otherSub === result.sub) {
throw new BadRequestException(
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
);
}
const now = new Date().toISOString();
const identity: VerifiedIdentityAttributes = {
[`${prefix}FaydaSub`]: result.sub,
[`${prefix}FaydaVerifiedAt`]: now,
[`${prefix}Birthdate`]: result.birthdate ?? null,
[`${prefix}Gender`]: result.gender ?? null,
// The verified payload owns the person's details from here on.
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
};
// An approved company's profile edits are staged for backoffice review, and
// swapping the person who can act for the company is exactly the kind of
// edit that review exists for — so a verification lands the same way an
// ordinary edit does, rather than quietly rewriting a live record.
if (company.status === CompanyStatus.Active) {
await this.stageIdentityChange(company, userId, identity);
return this.getCompanyIdentityState(company);
}
const updated = await this.companiesRepo.update(company.id, {
attributes: { ...(company.attributes ?? {}), ...identity },
});
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
updated.companyProfiles = company.companyProfiles;
return this.getCompanyIdentityState(updated);
}
/**
* Drop the Power of Attorney entirely — the verified identity, the details it
* wrote and the delegation paper together.
*
* Only the PoA can go: a company always has an owner, and a freight forwarder
* always has a representative. Once a PoA is Fayda-verified its
* fields are locked, so blanking the form is no longer a way out — without
* this the customer would be stuck with a representative they cannot remove.
*/
async removePoaIdentity(userId: string): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
if (
(company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
)
) {
throw new BadRequestException(
"A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.",
);
}
const cleared: Record<string, unknown> = {};
for (const key of [
...POA_ATTRIBUTES,
"poaFaydaSub",
"poaFaydaVerifiedAt",
"poaBirthdate",
"poaGender",
]) {
cleared[key] = null;
}
const attributes = { ...(company.attributes ?? {}), ...cleared };
// The paper evidences a representative who no longer exists.
const records = await this.filesService.findByResource(
company.id,
COMPANY_RESOURCE,
);
for (const r of records) {
if (
r.code === POA_DELEGATION_FILE_KEY ||
r.code === POA_DELEGATION_PENDING_CODE
) {
await this.filesService.remove(r.id);
await this.withdrawDocumentIntent(company.id, r.id);
}
}
const updated = await this.companiesRepo.update(company.id, { attributes });
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
updated.companyProfiles = company.companyProfiles;
return this.getCompanyIdentityState(updated);
}
/** Stage a verified identity onto the company's pending change request. */
private async stageIdentityChange(
company: Company,
userId: string,
identity: VerifiedIdentityAttributes,
): Promise<void> {
const existing = await this.changeRequestRepo.findPendingByCompanyId(
company.id,
);
const now = new Date();
const snapshot = {
...(existing?.snapshot ?? {}),
faydaIdentity: {
...(((existing?.snapshot ?? {}) as Record<string, any>)
.faydaIdentity ?? {}),
...identity,
},
};
if (existing) {
await this.changeRequestRepo.update(existing.id, {
snapshot,
submittedBy: userId,
submittedAt: now,
note: null,
});
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
return;
}
const history = await this.changeRequestRepo.findByCompanyId(company.id);
const resubmitted = history.some(
(r) => r.status === ChangeRequestStatus.Rejected,
);
const request = await this.changeRequestRepo.create({
companyId: company.id,
snapshot,
status: ChangeRequestStatus.Pending,
submittedBy: userId,
submittedAt: now,
});
this.companyNotifier.changeRequestSubmitted(
company,
request.id,
resubmitted,
);
}
/**
* The gate: an Ethiopian company's owner must be Fayda-verified, and so must
* its Power of Attorney once it has one; a foreign company's owner must carry
* a passport number instead. Called from the same places as
* `assertPoaDelegationSatisfied` — the two rules describe the same moment
* (who may act for this company, and on what evidence) and drifting them
* apart is how one of them ends up unenforced.
*/
private assertIdentityVerified(
company: Company,
opts: { requirePoa: boolean },
): void {
const state = buildCompanyIdentityState(company);
if (state.passportRequired) {
if (!state.owner.passportNumber) {
throw new BadRequestException(
"Add the company owner's passport number before continuing.",
);
}
return;
}
if (!state.owner.verified) {
throw new BadRequestException(
"Verify the company owner's identity with Fayda before continuing.",
);
}
const poaNamed = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
if (!opts.requirePoa && !poaNamed) return;
if (!state.poa.verified) {
throw new BadRequestException(
opts.requirePoa
? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one."
: "Verify the Power of Attorney you named with Fayda, or remove the representative.",
);
}
}
/**
* The PoA details the company is heading for: its live attributes with any
* pending change-request snapshot laid over them. An Active company's edits
* are staged rather than written, so the live row on its own would judge the
* customer against details they have already asked to change.
*/
private async effectivePoaAttributes(
company: Company,
): Promise<Record<string, unknown>> {
const pending = await this.changeRequestRepo.findPendingByCompanyId(
company.id,
);
const snapshot = (pending?.snapshot ?? {}) as Record<string, unknown>;
const staged: Record<string, unknown> = {};
for (const key of POA_ATTRIBUTES) {
if (key in snapshot) staged[key] = snapshot[key];
}
return { ...(company.attributes ?? {}), ...staged };
}
/** The company's PoA paper(s), with each file's review status resolved. */
async listPoaDelegationFiles(
userId: string,
): Promise<CompanyDocumentFileView[]> {
@@ -2149,6 +2732,18 @@ export class CompaniesService {
throw new NotFoundException(`Delegation letter ${fileId} not found`);
}
// Taking the paper away is the other half of the pairing: allowed only once
// the representative it evidences is gone too (which, for an Active
// company, means the clearing edit is already staged).
await this.assertPoaDelegationSatisfied(
company.id,
await this.effectivePoaAttributes(company),
{
requirePoa: await this.isFreightForwarder(company.id),
ignoreFileIds: [fileId],
},
);
if (record.code === POA_DELEGATION_PENDING_CODE) {
await this.filesService.remove(fileId);
await this.withdrawDocumentIntent(company.id, fileId);
@@ -2328,7 +2923,10 @@ export class CompaniesService {
return match?.id ?? null;
}
async fetchETradeData(tin: string) {
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
private async resolveEtradeRegistration(
tin: string,
): Promise<CompanyRegistrationData> {
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
@@ -2336,11 +2934,71 @@ export class CompaniesService {
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
const registrationData = this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
}
async fetchETradeData(tin: string, excludeCompanyId?: string) {
const registrationData = await this.resolveEtradeRegistration(tin);
const tinTaken = await this.companiesRepo.existsByTin(
tin,
excludeCompanyId,
);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}
/**
* An eTrade-sourced field can only ever hold what a fresh eTrade lookup for
* this TIN actually returns — the portal never lets the customer type these
* once eTrade has supplied them, so a mismatch here means either stale
* client state or a hand-crafted request, and either way the write is
* refused rather than silently trusting it.
*/
private async assertEtradeFieldsAuthentic(
company: Company,
dto: UpdateProfileDto,
): Promise<void> {
const touched = ETRADE_SOURCED_FIELDS.some(
(key) => dto[key] !== undefined,
);
if (!touched) return;
const tin = dto.tin ?? company.tin;
const registration = await this.resolveEtradeRegistration(tin);
const expected: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
companyName: registration.companyName,
licenceNumber: registration.licenceNumber,
statusDescription: registration.statusDescription,
dateRegistered: registration.dateRegistered,
renewedFrom: registration.renewedFrom,
renewalDate: registration.renewalDate,
renewedTo: registration.renewedTo,
region: registration.region,
zone: registration.zone,
woreda: registration.woreda,
kebele: registration.kebele,
houseNo: registration.houseNo,
etradePhone:
registration.managerPhone ||
registration.regularPhone ||
registration.mobilePhone,
};
for (const key of ETRADE_SOURCED_FIELDS) {
const submitted = dto[key];
if (submitted === undefined) continue;
const source = expected[key];
// eTrade left this field blank — the onboarding/settings card falls back
// to letting the customer type it directly, so nothing to check against.
if (!source) continue;
const same =
key === "etradePhone"
? normalizeE164(String(submitted)) === normalizeE164(source)
: submitted === source;
if (!same) {
throw new BadRequestException(
`${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`,
);
}
}
}
}

View File

@@ -0,0 +1,152 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsString, IsNotEmpty } from "class-validator";
import { Company, CompanyNationality } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
/**
* The two people a company is verified through — its owner and its Power of
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
* a plain typed role (with a "same as owner" copy the portal offers), while
* the owner is the person this verification proves. They're very often the
* same human, which is exactly what the copy is for.
*/
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
export class CompleteIdentityVerificationDto {
@ApiProperty({
enum: IDENTITY_SUBJECTS,
description: "Which of the company's people this verification is for.",
})
@IsIn(IDENTITY_SUBJECTS)
subject!: IdentitySubject;
@ApiProperty({ description: "Authorization code from the Fayda redirect." })
@IsString()
@IsNotEmpty()
code!: string;
@ApiProperty({ description: "CSRF state from the Fayda redirect." })
@IsString()
@IsNotEmpty()
state!: string;
}
/** One person's verification state, as reported back to the portal. */
export class IdentityVerificationStateDto {
@ApiProperty() verified!: boolean;
@ApiProperty({ nullable: true }) name!: string | null;
@ApiProperty({ nullable: true }) phone!: string | null;
@ApiProperty({ nullable: true }) email!: string | null;
@ApiProperty({ nullable: true }) address!: string | null;
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
@ApiProperty({ nullable: true }) birthdate!: string | null;
@ApiProperty({ nullable: true }) gender!: string | null;
}
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
@ApiProperty({
nullable: true,
description:
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
})
passportNumber!: string | null;
}
export class CompanyIdentityStateDto {
@ApiProperty({
description:
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
})
faydaRequired!: boolean;
@ApiProperty({
description:
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
})
passportRequired!: boolean;
@ApiProperty({ type: OwnerIdentityStateDto })
owner!: OwnerIdentityStateDto;
@ApiProperty({ type: IdentityVerificationStateDto })
poa!: IdentityVerificationStateDto;
@ApiProperty({
description:
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
})
complete!: boolean;
}
/** `attributes` key prefix per person. */
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
owner: "owner",
poa: "poa",
};
/** company.attributes keys that together mean "a PoA was entered". */
const POA_KEYS = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const;
function stateFor(
attrs: Record<string, unknown>,
subject: IdentitySubject,
): IdentityVerificationStateDto {
const p = PREFIX[subject];
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
return {
verified: Boolean(read(`${p}FaydaSub`)),
name: read(`${p}Name`),
phone: read(`${p}Phone`),
email: read(`${p}Email`),
address: read(`${p}Address`),
verifiedAt: read(`${p}FaydaVerifiedAt`),
birthdate: read(`${p}Birthdate`),
gender: read(`${p}Gender`),
};
}
/**
* Derive both people's verification state from the company row.
*
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
* renders from it, so the settings page and the onboarding wizard can never
* disagree with the rule the API actually enforces.
*/
export function buildCompanyIdentityState(
company: Company,
): CompanyIdentityStateDto {
const attrs = company.attributes ?? {};
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
// one, so a typed passport number is the mandatory credential there instead.
// The two are mutually exclusive by nationality but independently tracked,
// since a foreign owner verifying with Fayda doesn't waive the passport.
const foreign = company.nationality === CompanyNationality.Foreign;
const faydaRequired = !foreign;
const passportRequired = foreign;
const owner: OwnerIdentityStateDto = {
...stateFor(attrs, "owner"),
passportNumber: read("ownerPassportNumber"),
};
const poa = stateFor(attrs, "poa");
const poaDue =
(company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const complete = faydaRequired
? owner.verified && (!poaDue || poa.verified)
: !passportRequired || Boolean(owner.passportNumber);
return { faydaRequired, passportRequired, owner, poa, complete };
}

View File

@@ -8,6 +8,8 @@
* truth the wizard uses to auto-finish.
*/
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
export interface OnboardingInfoField {
key: string;
label: string;
@@ -40,11 +42,13 @@ export interface OnboardingPoaState {
required: boolean;
/** True once any PoA detail has been entered. */
provided: boolean;
/** True when the delegation letter is stored for the company. */
/** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean;
/** True when a reviewer sent the paper back for correction. */
delegationLetterFlagged: boolean;
/** PoA details still missing (only populated when `required`). */
missingFields: OnboardingInfoField[];
/** False while the PoA step still owes details or a delegation letter. */
/** False while the PoA step still owes details or an uncorrected paper. */
complete: boolean;
}
@@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto {
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
poa: OnboardingPoaState;
/**
* Fayda verification state for the company's people. `required` is false for
* a foreign company, which is never gated on it — the portal renders the
* typed personnel forms in that case and the verify panels otherwise.
*/
identity: CompanyIdentityStateDto;
/** Overall setup progress across fields + documents + licenses. */
progress: { completed: number; total: number };
@@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto {
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;
this.poa = init.poa;
this.identity = init.identity;
this.progress = init.progress;
this.isComplete = init.isComplete;
this.onboardingCompleted = init.onboardingCompleted;

View File

@@ -1,3 +1,7 @@
import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from "./complete-identity-verification.dto";
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import {
@@ -52,6 +56,16 @@ export class ProfileResponseDto {
profileId: string;
/**
* Fayda verification state for the company's owner and PoA — not the general
* manager, which is a separate typed role. The settings tabs and the
* onboarding wizard render from `identity.faydaRequired` /
* `identity.passportRequired`: an Ethiopian company verifies the owner (and
* PoA) instead of typing their details; a foreign one requires a typed
* passport number instead.
*/
identity: CompanyIdentityStateDto;
/**
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
* settings page; `"rejected"` surfaces the note and prefills the (declined)
@@ -124,5 +138,6 @@ export class ProfileResponseDto {
: null;
this.reviewNote = openReview?.note ?? null;
this.pendingChanges = openReview?.snapshot ?? null;
this.identity = buildCompanyIdentityState(company);
}
}

View File

@@ -9,6 +9,10 @@ import {
ProfileLicenseFileView,
} from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from './complete-identity-verification.dto';
export class ResponseCompanyProfileDto {
id: string;
@@ -69,6 +73,28 @@ export class ResponseCompanyDto {
* external profiles weren't loaded.
*/
onboardingCompleted?: boolean;
// eTrade-sourced registration record — populated by the onboarding TIN
// lookup, locked/read-only on the portal from the moment it's fetched.
licenceNumber?: string | null;
statusDescription?: string | null;
dateRegistered?: string | null;
renewedFrom?: string | null;
renewalDate?: string | null;
renewedTo?: string | null;
region?: string | null;
zone?: string | null;
woreda?: string | null;
kebele?: string | null;
houseNo?: string | null;
/**
* Owner/PoA Fayda verification state, shared with the portal
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
* disagrees with — the rule the API actually enforces.
*/
identity: CompanyIdentityStateDto;
createdAt: Date;
updatedAt: Date;
@@ -95,6 +121,18 @@ export class ResponseCompanyDto {
? company.profiles.length === 0 ||
company.profiles.some((p) => p.onboardingCompleted)
: undefined;
this.licenceNumber = company.licenceNumber;
this.statusDescription = company.statusDescription;
this.dateRegistered = company.dateRegistered;
this.renewedFrom = company.renewedFrom;
this.renewalDate = company.renewalDate;
this.renewedTo = company.renewedTo;
this.region = company.region;
this.zone = company.zone;
this.woreda = company.woreda;
this.kebele = company.kebele;
this.houseNo = company.houseNo;
this.identity = buildCompanyIdentityState(company);
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -44,10 +44,11 @@ export class UpdateProfileDto {
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(16)
fanNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
// company's PoA (or its general manager), so it is derived from a completed
// Fayda verification rather than typed. The global validation pipe runs with
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
// so — see CompaniesService.completeIdentityVerification.
@IsOptional()
@IsString()
@@ -110,6 +111,16 @@ export class UpdateProfileDto {
@IsString()
poaAddress?: string;
/**
* The owner's passport number — the identity credential for a foreign
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
* written or locked by a Fayda verification: still required even if the
* owner also verifies.
*/
@IsOptional()
@IsString()
ownerPassportNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)

View File

@@ -15,6 +15,11 @@ import {
FILE_UPLOAD_SETTINGS_REPOSITORY,
IFileUploadSettingsRepository,
} from "./interfaces/file-upload-settings.repository.interface";
import {
COMPANY_ONBOARDING_CODE_PREFIX,
POA_DELEGATION_FILE_KEY,
poaDelegationField,
} from "./poa-delegation.constants";
@Injectable()
export class FileUploadSettingsService {
@@ -40,6 +45,22 @@ export class FileUploadSettingsService {
async getByCode(code: string): Promise<FileUploadSetting> {
const setting = await this.repository.findByCode(code);
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
return this.withPoaDelegationField(setting);
}
/**
* Company onboarding sets always carry the DARS delegation paper, whether or
* not anyone configured a row for it — see poa-delegation.constants.ts. Every
* consumer (the portal's PoA step, the onboarding gate) reads the set through
* here, so this is the single place the field can be guaranteed.
*/
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
const fields = setting.fields ?? [];
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0);
setting.fields = [...fields, poaDelegationField(lastOrder + 1)];
return setting;
}

View File

@@ -0,0 +1,52 @@
import { FileUploadField } from "./entities/file-upload-field.entity";
/**
* The DARS delegation paper — the document that evidences a company's Power of
* Attorney (EDRFREIGHT-358).
*
* Every other onboarding document is admin-managed: the rows in
* `file_upload_fields` are edited from the backoffice file-settings editor and
* the seeder deliberately inserts none. This one is different — a company that
* names a PoA must produce a delegation paper authenticated by the Documents
* Authentication and Registration Service, and that is a legal requirement
* rather than a configuration choice. So the field is defined here in code and
* injected into the company onboarding sets on read: no row to forget to seed,
* and deleting one in the editor cannot silently switch the requirement off.
*/
/** FileRecord `code` (and upload field key) of the live delegation paper. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a delegation paper staged in an open change request (not yet live). */
export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** Customer-facing name of the document, used by the API and both web apps. */
export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
/** Prefix of the setting codes the field is injected into. */
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
const POA_DELEGATION_HELP =
"Delegation paper issued by the Documents Authentication and Registration " +
"Service (DARS) delegating the representative named above. Upload the " +
"authenticated copy — a plain letter is not accepted.";
/**
* The field descriptor. `isRequired` stays false because the paper is only due
* once a PoA has actually been named (or the company operates as a freight
* forwarder) — a rule that spans form fields as well as files, so it is
* enforced in CompaniesService rather than by this flag.
*/
export function poaDelegationField(displayOrder: number): FileUploadField {
return {
fileKey: POA_DELEGATION_FILE_KEY,
fileLabel: POA_DELEGATION_LABEL,
helpText: POA_DELEGATION_HELP,
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
displayOrder,
} as FileUploadField;
}

View File

@@ -0,0 +1,29 @@
import { Type } from 'class-transformer';
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
/**
* One truck's detention window. Each truck reaches the destination and is
* released at its own time, so detention days differ between trucks on the
* same delivery. Null clears the value (falls back to the leg-level pair).
*/
export class TruckDetentionTimeInput {
@IsUUID()
vehicleId!: string;
/** Detention clock start — this truck reached the destination. */
@IsOptional()
@IsDateString()
destinationArrivedAt?: string | null;
/** Detention clock end — this truck was released/returned. Omit = still out. */
@IsOptional()
@IsDateString()
returnedAt?: string | null;
}
export class SetDetentionTimesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => TruckDetentionTimeInput)
trucks!: TruckDetentionTimeInput[];
}

View File

@@ -0,0 +1,22 @@
import { Type } from 'class-transformer';
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
export class TruckWarehouseGateTimeInput {
@IsUUID()
vehicleId!: string;
@IsOptional()
@IsDateString()
arrivedAt?: string | null;
@IsOptional()
@IsDateString()
departedAt?: string | null;
}
export class SetWarehouseGateTimesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => TruckWarehouseGateTimeInput)
trucks!: TruckWarehouseGateTimeInput[];
}

View File

@@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity {
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;
/**
* Detention clock START for THIS truck: reached the delivery destination.
* Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the
* leg-level `last_mile.arrived_at`.
*/
@Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true })
destinationArrivedAt?: Date | null;
/**
* Detention clock END for THIS truck: released / returned by the customer.
* Null (with no leg-level `delivered_at`) means still out — detention accrues.
*/
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
returnedAt?: Date | null;
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
grossWeightTons?: number | null;

View File

@@ -23,6 +23,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
import { SetWarehouseGateTimesDto } from './dto/set-warehouse-gate-times.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
@@ -131,6 +133,30 @@ export class LastMileController {
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Post(':id/detention-times')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
})
async setDetentionTimes(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetDetentionTimesDto,
) {
return this.lastMileService.setDetentionTimes(id, dto.trucks);
}
@Post(':id/warehouse-gate-times')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({
summary: 'Set each truck\'s warehouse gate arrival/departure times',
})
async setWarehouseGateTimes(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetWarehouseGateTimesDto,
) {
return this.lastMileService.setWarehouseGateTimes(id, dto.trucks);
}
@Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -327,6 +327,8 @@ export class LastMileService {
*/
async arrivalTrucksForBooking(bookingId: string): Promise<
Array<{
/** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
@@ -359,6 +361,7 @@ export class LastMileService {
: [];
const out: Array<{
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
@@ -386,6 +389,7 @@ export class LastMileService {
}
}
out.push({
lastMileId: lm.id,
vehicleId: vehicle.id,
truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
trailerPlateNumber: vehicle.trailerPlateNo || null,
@@ -869,6 +873,81 @@ export class LastMileService {
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
* client-side. Does NOT generate an invoice — that's a separate explicit step.
*/
/**
* Per-truck detention windows. Each truck reaches the destination and is
* released at its own time, so every truck gets its own clock (and therefore
* its own chargeable days). Locked once the detention invoice exists.
*/
async setDetentionTimes(
id: string,
trucks: Array<{
vehicleId: string;
destinationArrivedAt?: string | null;
returnedAt?: string | null;
}>,
): Promise<LastMile> {
await this.findById(id);
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Detention times cannot be changed after the invoice is generated',
);
}
for (const t of trucks) {
const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null;
const end = t.returnedAt ? new Date(t.returnedAt) : null;
if (start && end && end.getTime() < start.getTime()) {
throw new BadRequestException(
'A truck cannot be returned before it arrived — check the detention times',
);
}
await this.dataSource.manager.update(
LastMileVehicleAssignment,
{ lastMileId: id, vehicleId: t.vehicleId },
{ destinationArrivedAt: start, returnedAt: end },
);
}
return this.findById(id);
}
async setWarehouseGateTimes(
id: string,
trucks: Array<{
vehicleId: string;
arrivedAt?: string | null;
departedAt?: string | null;
}>,
): Promise<LastMile> {
await this.findById(id);
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Warehouse gate times cannot be changed after the invoice is generated',
);
}
for (const t of trucks) {
const arrived = t.arrivedAt ? new Date(t.arrivedAt) : null;
const departed = t.departedAt ? new Date(t.departedAt) : null;
if (arrived && departed && departed.getTime() < arrived.getTime()) {
throw new BadRequestException(
'A truck cannot depart before it arrived — check the warehouse gate times',
);
}
await this.dataSource.manager.update(
LastMileVehicleAssignment,
{ lastMileId: id, vehicleId: t.vehicleId },
{ arrivedAt: arrived, departedAt: departed },
);
}
return this.findById(id);
}
async setDistances(
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,

View File

@@ -39,7 +39,11 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
physicalWagon: true,
allocations: {
booking: { company: true, bookingContainers: { containerType: true } },
containerItems: true,
// Both size sources loaded: the item's own container_type_id FK
// (always set for a manually-entered item) and the booking-line
// fallback via bookingContainer.containerType — the marshalling
// document's 40ft/20ft tally reads whichever is present.
containerItems: { containerType: true, bookingContainer: { containerType: true } },
},
},
},

View File

@@ -48,10 +48,12 @@ export class FacilityHandlingService {
if (!facility?.hasFacility) return null;
const occurredAt = input.occurredAt ?? new Date();
// Mapped to the goods owner, same as every warehouse-raised GRN.
const grnNumber = generateGrnNumber(
booking.tradeDirection ?? 'DOMESTIC',
booking.id,
occurredAt,
booking.company?.name ?? null,
);
// Link the storage record when this facility keeps cargo — that link is
@@ -67,6 +69,21 @@ export class FacilityHandlingService {
inventoryId = inv?.id ?? null;
}
// The handed-over weight: the booking's declared VGM, else what its
// containers actually carry. A GRN without a weight is not a receipt.
let weightTons = Number(booking.cargoTotalWeightVgm) || null;
if (!weightTons) {
const [sum]: Array<{ tons: string | null }> = await manager.query(
`SELECT SUM(bcu.vgm_tons) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[booking.id],
);
weightTons = Number(sum?.tons) || null;
}
const repo = manager.getRepository(FacilityHandlingEvent);
await repo.save(
repo.create({
@@ -75,7 +92,7 @@ export class FacilityHandlingService {
trainScheduleId: input.trainScheduleId ?? null,
eventType,
grnNumber,
weightTons: Number(booking.cargoTotalWeightVgm) || null,
weightTons,
inventoryId,
performedBy: input.performedBy ?? null,
occurredAt,

View File

@@ -2784,11 +2784,13 @@ export class TrainSchedulingService {
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
booking: allocation.booking,
loadType: allocation.loadType ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean),
containerItems: allocation.containerItems ?? [],
})),
})),
operation: await this.getImportDjiboutiOperation(schedule.id),
@@ -2829,6 +2831,32 @@ export class TrainSchedulingService {
};
}
/**
* A container item's size in feet, for the marshalling document's 40ft/20ft
* tally. Two independent sources, since only one is populated depending on
* how the item was created:
* - `item.containerType` — the item's own container_type_id FK, set for
* manually-entered items (no booking-container line behind them).
* - `item.bookingContainer.containerType.sizeFt` / `.containerSize` — the
* booking-line fallback for items generated from an allocation.
* (`findByIdWithFullGraph` must load both relations or every item here
* silently resolves to null and the tally stays zero.)
*/
private resolveContainerItemSize(item: {
containerType?: { sizeFt?: number | null } | null;
bookingContainer?: {
containerSize?: string | null;
containerType?: { sizeFt?: number | null } | null;
} | null;
}): number | null {
const fromSizeFt = item.containerType?.sizeFt ?? item.bookingContainer?.containerType?.sizeFt;
if (fromSizeFt === 20 || fromSizeFt === 40) return fromSizeFt;
const label = item.bookingContainer?.containerSize;
if (label?.includes('40')) return 40;
if (label?.includes('20')) return 20;
return null;
}
private buildExportLoadListHtml(schedule: TrainSchedule): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -2869,6 +2897,7 @@ export class TrainSchedulingService {
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
const containerItems = allocation.containerItems ?? [];
const firstContainer = containerItems[0];
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
@@ -2877,6 +2906,7 @@ export class TrainSchedulingService {
return `<tr>
${wagonCells}
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(companyName)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td>
<td>${esc(sealNumbers)}</td>
@@ -2891,6 +2921,18 @@ export class TrainSchedulingService {
0,
);
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++;
else if (size === 20) count20ft++;
});
});
});
return `<!doctype html>
<html>
<head>
@@ -2940,6 +2982,9 @@ export class TrainSchedulingService {
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
@@ -2958,6 +3003,7 @@ export class TrainSchedulingService {
<th class="num">Tare Weight</th>
<th class="num">Load Capacity</th>
<th>Cargo Type</th>
<th>Company</th>
<th>Container No</th>
<th>Chassis No</th>
<th>Seal No</th>
@@ -3040,6 +3086,19 @@ export class TrainSchedulingService {
0,
);
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
loadList.wagons.forEach((wagon) => {
wagon.allocations.forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++;
else if (size === 20) count20ft++;
});
});
});
const allocationRows = loadList.wagons
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
@@ -3055,13 +3114,17 @@ export class TrainSchedulingService {
];
}
return wagon.allocations.map(
(allocation) => `<tr>
(allocation) => {
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
return `<tr>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(companyName)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`,
</tr>`;
},
);
})
.join('');
@@ -3126,6 +3189,9 @@ export class TrainSchedulingService {
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div>
@@ -3145,6 +3211,7 @@ export class TrainSchedulingService {
<th>Seq</th>
<th>Wagon</th>
<th>Booking</th>
<th>Company</th>
<th>Load</th>
<th>Container numbers</th>
<th class="num">Weight T</th>

View File

@@ -13,14 +13,14 @@ export class StartVerificationDto {
purpose?: 'LOGIN' | 'VERIFY';
@ApiPropertyOptional({
enum: ['WEB', 'MOBILE'],
enum: ['WEB', 'MOBILE', 'PORTAL'],
default: 'WEB',
description:
'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.',
'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB (backoffice) uses FAYDA_WEB_REDIRECT_URI, PORTAL uses FAYDA_PORTAL_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. All land on the same /complete handling.',
})
@IsOptional()
@IsIn(['WEB', 'MOBILE'])
platform?: 'WEB' | 'MOBILE';
@IsIn(['WEB', 'MOBILE', 'PORTAL'])
platform?: 'WEB' | 'MOBILE' | 'PORTAL';
@ApiPropertyOptional({
type: Boolean,
@@ -57,6 +57,12 @@ export class CompleteVerificationResultDto {
agentId?: string;
};
@ApiPropertyOptional({
description:
'Fayda OIDC subject — the stable key a verified identity is stored under (VERIFY flow). Pairwise pseudonymous.',
})
sub?: string;
@ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
fullName?: string;
@@ -74,6 +80,11 @@ export class CompleteVerificationResultDto {
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
gender?: string;
@ApiPropertyOptional({
description: 'Verified address from Fayda, English rendering (VERIFY flow).',
})
address?: string;
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
userDataSaved?: boolean;

View File

@@ -56,11 +56,15 @@ export interface CompleteVerificationResult {
promptPasswordSetup?: boolean;
iamUserId?: string;
user?: FaydaUserSummary;
/** Fayda OIDC subject — the stable key a verified identity is stored under. */
sub?: string;
fullName?: string;
email?: string;
phoneNumber?: string;
birthdate?: string;
gender?: string;
/** Verified address, English rendering (falls back to Amharic). */
address?: string;
userDataSaved?: boolean;
}
@@ -125,11 +129,15 @@ export class VerifaydaService {
});
}
/** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */
/**
* Each client lands on its own registered redirect_uri: MOBILE on the base
* one, the customer portal on its own origin, everything else (backoffice) on
* the web one. All three must be registered with eSignet.
*/
private redirectUriForPlatform(platform?: FaydaPlatform): string {
return platform === 'MOBILE'
? this.faydaConfig.redirectUri
: this.faydaConfig.webRedirectUri;
if (platform === 'MOBILE') return this.faydaConfig.redirectUri;
if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri;
return this.faydaConfig.webRedirectUri;
}
async completeVerification(
@@ -210,11 +218,13 @@ export class VerifaydaService {
result = {
purpose: 'VERIFY',
verified: true,
sub: normalized.sub,
fullName: normalized.fullName,
email: normalized.email,
phoneNumber: normalized.phoneNumber,
birthdate: normalized.birthdate,
gender: normalized.gender,
address: normalized.addressEn ?? normalized.addressAm,
userDataSaved,
iamUserId: iamUserId ?? undefined,
token: sessionToken?.token,

View File

@@ -0,0 +1,61 @@
import { WarehouseFeeService } from './warehouse-fee.service';
/**
* Double handling bills ONLY when warehouse staff answered Yes after
* unloading. Undecided (null) or No must produce a zero charge even when a
* matching DOUBLE_HANDLING_FEE rule exists.
*/
type Item = Parameters<WarehouseFeeService['previewForInventory']> extends unknown
? Record<string, unknown>
: never;
const svc = Object.create(WarehouseFeeService.prototype) as {
computeDoubleHandling: (
rule: Record<string, unknown> | null,
item: Item,
now: Date,
billingCurrency: string,
) => Promise<{ amount: number; billableUnits: number }>;
normalizeCurrency: (c?: string | null) => string;
convertAmount: (a: number, from: string, to: string) => Promise<number>;
resolveBulkQuantity: (item: Item) => { quantity: number; unitLabel: string };
};
// No exchange service on a bare prototype — bill in the rule's own currency.
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
svc.convertAmount = async (a) => a;
const rule = { basis: 'PER_CONTAINER', ratePerDay: 100, currency: 'USD', id: 'r1', name: 'DH' };
const item = (doubleHandling: boolean | null) => ({
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
inventoryQuantity: 2,
bookingContainerCount: 3,
inventoryWeight: 10,
cargoUnitOfMeasure: 'PER_TON',
doubleHandling,
}) as unknown as Item;
describe('double handling gate', () => {
it('bills rate x containers when the booking is flagged Yes', async () => {
const out = await svc.computeDoubleHandling(rule, item(true), new Date(), 'USD');
expect(out.billableUnits).toBe(3);
expect(out.amount).toBe(300);
});
it('charges nothing when the answer is No', async () => {
const out = await svc.computeDoubleHandling(rule, item(false), new Date(), 'USD');
expect(out.billableUnits).toBe(0);
expect(out.amount).toBe(0);
});
it('charges nothing while the answer is undecided', async () => {
const out = await svc.computeDoubleHandling(rule, item(null), new Date(), 'USD');
expect(out.amount).toBe(0);
});
it('charges nothing for export even when flagged Yes', async () => {
const exportItem = { ...(item(true) as Record<string, unknown>), tradeDirection: 'EXPORT' } as Item;
const out = await svc.computeDoubleHandling(rule, exportItem, new Date(), 'USD');
expect(out.amount).toBe(0);
});
});

View File

@@ -1,7 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity';
import {
WAREHOUSE_YARD_DIRECTIONS,
WAREHOUSE_YARD_TYPES,
WarehouseYardDirection,
WarehouseYardType,
} from '../entities/warehouse-yard.entity';
export class CreateWarehouseYardDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
@@ -46,4 +51,22 @@ export class CreateWarehouseYardDto {
@IsNumber()
@Min(0)
maxVolume?: number;
@ApiPropertyOptional({
enum: WAREHOUSE_YARD_DIRECTIONS,
description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.',
})
@IsOptional()
@IsEnum(WAREHOUSE_YARD_DIRECTIONS)
direction?: WarehouseYardDirection;
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.',
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
cargoTypeIds?: string[];
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator';
/**
* Warehouse staff's post-unloading answer: did these goods have to be
* re-handled? Only `true` makes the DOUBLE_HANDLING_FEE rule bill the booking.
*/
export class SetDoubleHandlingDto {
@ApiProperty({
description: 'Yes (true) applies the double-handling fee rule; No (false) does not.',
})
@IsBoolean()
doubleHandling!: boolean;
}

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { Warehouse } from './warehouse.entity';
import { WarehouseZone } from './warehouse-zone.entity';
@@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number];
/**
* Which trade direction this yard serves. Only meaningful for CONTAINER_YARD,
* where import and export stacks are physically separate areas (e.g. Indode's
* Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo
* either way, so BOTH/null is the right default there.
*/
export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number];
@Entity({ schema: 'freight', name: 'warehouse_yards' })
@Index(['warehouseId'])
@Index(['type'])
@@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity {
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
/** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
direction?: WarehouseYardDirection | null;
/**
* Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck,
* Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any
* cargo type of the yard's structural `type` (the pre-existing behavior),
* so this is additive and never blocks a yard that hasn't been configured.
*/
@ManyToMany(() => CargoType)
@JoinTable({
name: 'warehouse_yard_cargo_types',
schema: 'freight',
joinColumn: { name: 'yard_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' },
})
cargoTypes?: CargoType[];
@OneToMany(() => WarehouseZone, (zone) => zone.yard)
zones?: WarehouseZone[];
}

View File

@@ -0,0 +1,82 @@
import { WarehouseFeeService } from './warehouse-fee.service';
/**
* Detention is per truck: two trucks on the same delivery with different
* windows must produce different chargeable days and amounts (the old
* leg-level clock billed them identically).
*/
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const svc = Object.create(WarehouseFeeService.prototype) as {
computeTruckDetention: (
rule: Record<string, unknown> | null,
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number },
now: Date,
billingCurrency: string,
) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>;
normalizeCurrency: (c?: string | null) => string;
convertAmount: (a: number, from: string, to: string) => Promise<number>;
calculateTieredAmount: unknown;
};
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
svc.convertAmount = async (a) => a;
// 3h grace, 50/truck/day, no tiers.
const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] };
const now = new Date('2026-07-25T12:00:00Z');
describe('per-truck detention', () => {
it('bills each truck on its own window', async () => {
// Truck A: out ~1 day past grace. Truck B: out ~3 days past grace.
const a = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
const b = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
expect(a.chargeableDays).toBe(2);
expect(b.chargeableDays).toBe(4);
expect(a.amount).toBe(100);
expect(b.amount).toBe(200);
// The whole point: same delivery, different bills.
expect(a.amount).not.toBe(b.amount);
});
it('charges nothing inside the grace window', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 },
now,
'USD',
);
expect(out.chargeableDays).toBe(0);
expect(out.amount).toBe(0);
});
it('keeps accruing against now when a truck has not returned', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 },
now,
'USD',
);
expect(out.endIsOpen).toBe(true);
expect(out.chargeableDays).toBe(2);
});
});

View File

@@ -17,6 +17,8 @@ export interface ImportTrainRow {
route: string | null;
origin: string | null;
destination: string | null;
/** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */
destinationStationId: string | null;
arrivalTime: string | null;
totalBookings: number;
totalContainers: number;
@@ -38,6 +40,8 @@ export interface ImportTrainItemRow {
freightType: string | null;
containerNumber: string | null;
cargoType: string | null;
/** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */
cargoTypeCode: string | null;
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
@@ -205,6 +209,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
dy.id AS "destinationStationId",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
@@ -280,6 +285,7 @@ export class SchedulingReadFacade {
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
cgt.code AS "cargoTypeCode",
b.cargo_total_weight_vgm AS "weight",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
COALESCE(inv.status, b.status) AS "currentStatus",
@@ -376,6 +382,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
dy.id AS "destinationStationId",
dy.label AS "destinationName",
oy.country AS "originCountry",
dy.country AS "destinationCountry",

View File

@@ -0,0 +1,201 @@
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
// Bulk storage/demurrage used to bill a flat rate per day regardless of cargo
// quantity. It now scales by the cargo type's own unit of measure — tons for
// PER_TON cargo, item count for PER_ITEM cargo (Machinery, Truck, Automobile,
// Livestock…) — read from THIS inventory row, not the whole booking's total.
describe('WarehouseFeeService bulk quantity billing', () => {
const makeService = () =>
// compute() only touches its own arguments plus this.convertAmount, which
// short-circuits when rule.currency === billingCurrency — none of the
// constructor deps are exercised.
new WarehouseFeeService({} as any, {} as any, {} as any, {} as any);
const rule = (overrides: Partial<WarehouseFeeRule> = {}): WarehouseFeeRule =>
({
id: 'rule-1',
name: 'Bulk storage',
ruleType: 'STORAGE_FEE',
freeDays: 0,
ratePerDay: 10,
currency: 'USD',
tiers: [],
...overrides,
}) as WarehouseFeeRule;
const baseItem = (overrides: Record<string, unknown> = {}) => ({
arrivedAt: new Date('2026-01-01T00:00:00Z'),
gateClearedAt: null,
releaseDate: null,
freightType: 'BULK',
tradeDirection: 'IMPORT',
cargoTypeCode: 'WHEAT',
containerTypeCode: null,
vehicleType: null,
inventoryQuantity: 3,
inventoryWeight: 25,
bookingContainerCount: 0,
cargoUnitOfMeasure: null,
// Double handling now bills only when staff answered Yes after unloading;
// these quantity-basis cases assume that answer (the gate itself is covered
// in double-handling-gate.spec.ts).
doubleHandling: true,
facilityId: null,
warehouseId: null,
yardId: null,
zoneId: null,
...overrides,
});
// 5 elapsed days, 0 free days -> 5 chargeable days throughout.
const now = new Date('2026-01-06T00:00:00Z');
it('bills PER_TON bulk cargo by this row\'s weight, not a flat day rate', async () => {
const service = makeService();
const preview = await (service as any).compute(
'STORAGE_FEE',
rule(),
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 25 }),
now,
'USD',
);
expect(preview.unitLabel).toBe('ton');
expect(preview.containerCount).toBe(25);
expect(preview.billableUnits).toBe(5 * 25);
expect(preview.amount).toBe(5 * 25 * 10);
});
it('bills PER_ITEM bulk cargo (Machinery/Truck/Automobile/Livestock) by unit count', async () => {
const service = makeService();
const preview = await (service as any).compute(
'STORAGE_FEE',
rule(),
baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 3, cargoTypeCode: 'MACHINERY' }),
now,
'USD',
);
expect(preview.unitLabel).toBe('item');
expect(preview.containerCount).toBe(3);
expect(preview.billableUnits).toBe(5 * 3);
expect(preview.amount).toBe(5 * 3 * 10);
});
it('defaults to PER_TON when the cargo type has no unit of measure set', async () => {
const service = makeService();
const preview = await (service as any).compute(
'STORAGE_FEE',
rule(),
baseItem({ cargoUnitOfMeasure: null, inventoryWeight: 12 }),
now,
'USD',
);
expect(preview.unitLabel).toBe('ton');
expect(preview.containerCount).toBe(12);
});
it('charges nothing yet when the row has not been weighed/counted (0 is legitimate, not floored to 1)', async () => {
const service = makeService();
const preview = await (service as any).compute(
'STORAGE_FEE',
rule(),
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 0 }),
now,
'USD',
);
expect(preview.containerCount).toBe(0);
expect(preview.billableUnits).toBe(0);
expect(preview.amount).toBe(0);
});
it('leaves CONTAINER freight billing untouched by the new bulk fields', async () => {
const service = makeService();
const preview = await (service as any).compute(
'DEMURRAGE_FEE',
rule({ ruleType: 'DEMURRAGE_FEE' }),
baseItem({
freightType: 'CONTAINER',
bookingContainerCount: 4,
cargoUnitOfMeasure: 'PER_ITEM', // must be ignored for container freight
inventoryWeight: 999,
}),
now,
'USD',
);
expect(preview.unitLabel).toBe('container');
expect(preview.containerCount).toBe(4);
expect(preview.billableUnits).toBe(5 * 4);
});
// Double handling is a flat one-time charge, but previewForInventory() calls
// it once per warehouse_inventory ROW. Before this fix it read the whole
// booking's total on every row, so a booking split across N rows was billed
// N times against its full quantity. Reading each row's own weight/count
// fixes that: summing the rows now reproduces the booking total exactly once.
describe('double handling (row-level, not booking-wide)', () => {
const doubleHandlingRule = (basis: 'PER_CONTAINER' | 'PER_TON' | 'PER_ITEM') =>
rule({ ruleType: 'DOUBLE_HANDLING_FEE', basis, ratePerDay: 20 });
it('bills PER_TON by this row\'s own weight', async () => {
const service = makeService();
const preview = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
doubleHandlingRule('PER_TON'),
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }),
now,
'USD',
);
expect(preview.unitLabel).toBe('ton');
expect(preview.billableUnits).toBe(10);
expect(preview.amount).toBe(10 * 20);
});
it('bills PER_ITEM by this row\'s own unit count', async () => {
const service = makeService();
const preview = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
doubleHandlingRule('PER_ITEM'),
baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 2, cargoTypeCode: 'TRUCK' }),
now,
'USD',
);
expect(preview.unitLabel).toBe('item');
expect(preview.billableUnits).toBe(2);
expect(preview.amount).toBe(2 * 20);
});
it('two rows of one booking sum to the booking total exactly once (no N-times overcount)', async () => {
const service = makeService();
const ruleDef = doubleHandlingRule('PER_TON');
const rowA = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
ruleDef,
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 6 }),
now,
'USD',
);
const rowB = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
ruleDef,
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 4 }),
now,
'USD',
);
// Booking total is 10 tons across the two rows — billed once in total,
// not 10 tons charged against EACH row (which the old booking-wide read did).
expect(rowA.amount + rowB.amount).toBe(10 * 20);
});
it('no charge for export/domestic regardless of basis', async () => {
const service = makeService();
const preview = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
doubleHandlingRule('PER_TON'),
baseItem({ tradeDirection: 'EXPORT', cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }),
now,
'USD',
);
expect(preview.amount).toBe(0);
});
});
});

View File

@@ -20,9 +20,13 @@ interface ItemAttributes {
/** Vehicle type of the truck (truck detention scoping); null otherwise. */
vehicleType: string | null;
inventoryQuantity: number;
/** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */
inventoryWeight: number;
bookingContainerCount: number;
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */
cargoQuantity: number;
/** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */
cargoUnitOfMeasure: string | null;
/** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */
doubleHandling: boolean | null;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
@@ -60,7 +64,7 @@ export interface AccrualDashboardRow {
export interface FeePreview {
ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_ITEM); null otherwise. */
basis: FeeRuleBasis | null;
ruleId: string | null;
ruleName: string | null;
@@ -75,6 +79,8 @@ export interface FeePreview {
elapsedDays: number;
chargeableDays: number;
containerCount: number;
/** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */
unitLabel: string;
billableUnits: number;
amount: number;
tiers: Array<{
@@ -86,10 +92,20 @@ export interface FeePreview {
ratePerDay: number;
amount: number;
}>;
/** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */
/**
* Truck detention: one row PER TRUCK — each truck has its own detention
* window (it arrives and is released at its own time) and its own matching
* rule by truck type, so days and amount differ between trucks.
*/
groups?: Array<{
assignmentId: string | null;
vehicleId: string | null;
plateNumber: string | null;
vehicleType: string | null;
truckCount: number;
startDate: string | null;
endDate: string | null;
endIsOpen: boolean;
chargeableDays: number;
ratePerDay: number;
amount: number;
@@ -242,16 +258,18 @@ export class WarehouseFeeService {
inv.gate_cleared_at AS "gateClearedAt",
inv.release_date AS "releaseDate",
inv.quantity AS "inventoryQuantity",
inv.weight AS "inventoryWeight",
inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId",
inv.zone_id AS "zoneId",
w.facility_id AS "facilityId",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
b.double_handling AS "doubleHandling",
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
COALESCE(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -470,6 +488,22 @@ export class WarehouseFeeService {
};
}
/**
* Bulk's own billing quantity for THIS inventory row — weight (tons) for
* PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile,
* Livestock…). Shared by every cargo-scoped fee type (storage, demurrage,
* double handling) so a booking split across several rows is never billed
* more than once against its full total. 0 is a legitimate charge (nothing
* weighed/counted yet), so no forced floor.
*/
private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } {
const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase();
if (cargoUnit === 'PER_ITEM') {
return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' };
}
return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' };
}
private async compute(
ruleType: FeeRuleType,
rule: WarehouseFeeRule | null,
@@ -490,9 +524,11 @@ export class WarehouseFeeService {
const targetCurrency = this.normalizeCurrency(billingCurrency);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
const bulk = this.resolveBulkQuantity(item);
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
: bulk.quantity;
const unitLabel = isContainer ? 'container' : bulk.unitLabel;
const elapsedDays = start
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
@@ -533,6 +569,7 @@ export class WarehouseFeeService {
elapsedDays,
chargeableDays,
containerCount,
unitLabel,
billableUnits,
amount,
tiers: hasTiers ? convertedTiers : [],
@@ -560,12 +597,19 @@ export class WarehouseFeeService {
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
// which is stored in the cargo's own unit of measure.
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
// Double handling applies to IMPORT only — no charge for export/domestic.
// PER_TON (tonnes) and PER_ITEM (piece count) both read THIS row's own
// weight/count — never the whole booking's total. previewForInventory()
// computes double handling once per inventory row, so a booking-wide total
// would double- (or triple-) bill a booking split across several rows.
const bulk = this.resolveBulkQuantity(item);
// Double handling applies to IMPORT only — no charge for export/domestic —
// AND only when warehouse staff recorded that the goods were actually
// re-handled (booking flag = Yes after unloading). Undecided (null) or No
// means no charge, so the rule can exist without billing every import.
const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
const applies = isImport && item.doubleHandling === true;
const quantity = !applies ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity;
const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel;
const sourceAmount = Math.round(rate * quantity * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
@@ -586,6 +630,7 @@ export class WarehouseFeeService {
elapsedDays: 0,
chargeableDays: 0,
containerCount,
unitLabel,
billableUnits: quantity,
amount,
tiers: [],
@@ -771,6 +816,7 @@ export class WarehouseFeeService {
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
unitLabel: 'truck',
ruleId: null,
ruleName: null,
freeDays: 0,
@@ -791,25 +837,48 @@ export class WarehouseFeeService {
};
}
// Group the leg's vehicles by CANONICAL truck type so each type is billed
// by its own matching rule (rates differ by truck type). The FK to
// truck_types is the source of truth — renaming a type's label no longer
// silently unmatches its rule; the normalized legacy vehicle_type code is
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
// instead of dropping them). Falls back to one untyped group.
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
await this.dataSource.query(
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
count(*)::int AS "truckCount"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN freight.truck_types t
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
GROUP BY 1`,
[lastMileId],
);
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
// One row PER TRUCK: each truck has its own detention window (it reaches the
// destination and is released at its own time) and resolves its own rule by
// CANONICAL truck type — the truck_types FK is the source of truth, with the
// normalized legacy vehicle_type code as fallback so FK-less vehicles keep
// billing. Per-truck timestamps fall back to the leg-level pair for legacy
// legs recorded before per-truck tracking.
const truckRows: Array<{
assignmentId: string;
vehicleId: string;
plateNumber: string | null;
vehicleType: string | null;
startAt: Date | string | null;
endAt: Date | string | null;
}> = await this.dataSource.query(
`SELECT va.id AS "assignmentId",
va.vehicle_id AS "vehicleId",
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt",
COALESCE(va.returned_at, $3::timestamptz) AS "endAt"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN freight.truck_types t
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
ORDER BY va.created_at ASC`,
[lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null],
);
// No trucks assigned yet: keep the leg-level single-truck estimate so the
// preview still tells the operator what detention would cost.
const trucks = truckRows.length
? truckRows
: [
{
assignmentId: null as string | null,
vehicleId: null as string | null,
plateNumber: null as string | null,
vehicleType: null as string | null,
startAt: leg.arrivedAt ?? null,
endAt: leg.deliveredAt ?? null,
},
];
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
@@ -817,7 +886,7 @@ export class WarehouseFeeService {
const targetCurrency = this.normalizeCurrency(billingCurrency);
const computed = await Promise.all(
groups.map(async (g) => {
trucks.map(async (t) => {
const item: ItemAttributes = {
arrivedAt: null,
gateClearedAt: null,
@@ -826,46 +895,60 @@ export class WarehouseFeeService {
tradeDirection: leg.tradeDirection ?? null,
cargoTypeCode: null,
containerTypeCode: null,
vehicleType: g.vehicleType ?? null,
vehicleType: t.vehicleType ?? null,
inventoryQuantity: 1,
inventoryWeight: 0,
bookingContainerCount: 1,
cargoQuantity: 0,
cargoUnitOfMeasure: null,
// Irrelevant to detention (truck-time based, never double handling).
doubleHandling: null,
facilityId: null,
warehouseId: null,
yardId: null,
zoneId: null,
};
const rule = this.bestRule(detentionRules, item);
// truckCount 1 — this row IS one truck.
const c = await this.computeTruckDetention(
rule,
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
{ arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 },
now,
billingCurrency,
);
return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c };
return { ...t, c };
}),
);
const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100;
const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0);
const totalTrucks = computed.length;
const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
const chargeableDays = computed[0]?.c.chargeableDays ?? 0;
// Header days: the worst truck — a single number can't represent per-truck
// windows, and the longest detention is the one operations must act on.
const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0);
const single = computed.length === 1 ? computed[0].c : null;
const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null;
const earliestStart = computed
.map((x) => (x.startAt ? new Date(x.startAt).getTime() : null))
.filter((n): n is number => n != null)
.sort((a, b) => a - b)[0];
const anyOpen = computed.some((x) => x.c.endIsOpen);
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
unitLabel: 'truck',
ruleId: single?.ruleId ?? null,
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName,
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName,
freeDays: 0,
ratePerDay: single?.ratePerDay ?? 0,
currency: targetCurrency,
ruleCurrency: single?.ruleCurrency ?? null,
billingCurrency: targetCurrency,
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
endIsOpen: !leg.deliveredAt,
startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null,
endDate: (anyOpen ? now : new Date(Math.max(
...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())),
))).toISOString(),
endIsOpen: anyOpen,
elapsedDays: chargeableDays,
chargeableDays,
containerCount: totalTrucks,
@@ -873,8 +956,14 @@ export class WarehouseFeeService {
amount: totalAmount,
tiers: single ? single.tiers : [],
groups: computed.map((x) => ({
assignmentId: x.assignmentId,
vehicleId: x.vehicleId,
plateNumber: x.plateNumber,
vehicleType: x.vehicleType,
truckCount: x.truckCount,
truckCount: 1,
startDate: x.startAt ? new Date(x.startAt).toISOString() : null,
endDate: x.c.endDate,
endIsOpen: x.c.endIsOpen,
chargeableDays: x.c.chargeableDays,
ratePerDay: x.c.ratePerDay,
amount: x.c.amount,
@@ -927,6 +1016,7 @@ export class WarehouseFeeService {
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
unitLabel: 'truck',
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays: 0,

View File

@@ -17,6 +17,7 @@ import { MoveInventoryDto } from './dto/move-inventory.dto';
import { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -552,6 +553,23 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Patch('bookings/:bookingId/double-handling')
@BookingStaff([FREIGHT_PERMS.warehouseInventory.unload, FREIGHT_PERMS.warehouseInventory.inspect])
@ApiOperation({
summary: 'Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)',
})
setDoubleHandling(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: SetDoubleHandlingDto,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.setDoubleHandling(
bookingId,
dto.doubleHandling,
actorLabel(user),
);
}
@Get('bookings/:bookingId/container-items')
@StaffReference()
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })

View File

@@ -382,6 +382,8 @@ export interface ImportUnloadedRow {
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
hasAssignedTruck: boolean;
/** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
doubleHandling: boolean | null;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
@@ -1139,17 +1141,31 @@ export class WarehouseInventoryService {
async autoUnloadArrived(): Promise<AutoUnloadResult> {
const arrived: {
id: string;
/** Goods owner (company) — the GRN number is mapped to it. */
customer: string | null;
weight: string | null;
freightType: string | null;
tradeDirection: string | null;
cargoTypeCode: string | null;
}[] = await this.dataSource.query(
`SELECT b.id, b.cargo_total_weight_vgm AS weight,
`SELECT b.id,
-- Received weight must land on the inventory row: a booking with no
-- declared VGM still has per-container VGM to record.
COALESCE(
NULLIF(b.cargo_total_weight_vgm, 0),
(SELECT SUM(bcu.vgm_tons)
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
cgt.code AS "cargoTypeCode",
company.name AS customer
FROM freight.bookings b
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
[this.ARRIVED_BOOKING_STATUSES],
);
@@ -1186,7 +1202,7 @@ export class WarehouseInventoryService {
status: 'RECEIVED',
arrivedAt: new Date(),
...(booking.tradeDirection === 'EXPORT'
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) }
: {}),
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
});
@@ -1211,12 +1227,19 @@ export class WarehouseInventoryService {
// A GRN is the receipt for cargo entering the warehouse, so every booking
// gets one on unload — import as well as export. The direction only decides
// the GRN prefix, not whether one is issued.
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
// The GRN is mapped to the goods owner (the booking's company), so pull it
// alongside the direction rather than issuing an owner-less number.
const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> =
await this.dataSource.query(
`SELECT b.trade_direction AS "tradeDirection",
company.name AS "ownerName"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
const ownerName = bookingRow?.ownerName ?? null;
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
@@ -1240,7 +1263,7 @@ export class WarehouseInventoryService {
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
...(existing[0].grnNumber
? {}
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
@@ -1255,7 +1278,7 @@ export class WarehouseInventoryService {
weight: 0,
status: 'RECEIVED',
arrivedAt,
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName),
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);
@@ -1565,7 +1588,7 @@ export class WarehouseInventoryService {
}
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
const truckEntrance = dto.truckEntrance
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
: undefined;
@@ -1981,6 +2004,7 @@ export class WarehouseInventoryService {
WHERE lm.booking_id = b.id
AND lm.vehicle_id IS NOT NULL
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
b.double_handling AS "doubleHandling",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
@@ -2130,6 +2154,8 @@ export class WarehouseInventoryService {
const bookings: {
id: string;
status: string;
/** Goods owner (company) — the GRN number is mapped to it. */
customer: string | null;
weight: string | null;
freightType: string | null;
tradeDirection: string | null;
@@ -2140,12 +2166,24 @@ export class WarehouseInventoryService {
// at an intermediate yard was already unloaded there by the checkpoint
// auto-unload; without this filter it would be mis-located into the final
// yard's inventory too.
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
`SELECT b.id, b.status,
-- Same fallback as autoUnloadArrived: never land a 0 t receipt when
-- the booking's containers carry a VGM.
COALESCE(
NULLIF(b.cargo_total_weight_vgm, 0),
(SELECT SUM(bcu.vgm_tons)
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
cgt.code AS "cargoTypeCode",
company.name AS customer
FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND b.destination_yard_id = $2`,
[scheduleId, schedule.destinationStationId],
@@ -2203,11 +2241,16 @@ export class WarehouseInventoryService {
zoneId: unloadLocation.zoneId,
}
: {}),
// Record the received weight on a row that never carried one — the
// GRN prints this, and an existing non-zero weight is left alone.
...(Number(existing.weight) > 0 || !(Number(booking.weight) > 0)
? {}
: { weight: Number(booking.weight) }),
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
// Import GRN is issued automatically at train unload.
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }),
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }),
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
@@ -2216,6 +2259,22 @@ export class WarehouseInventoryService {
description: 'Unloaded from arrived import train',
performedBy,
});
// Capacity follows the recorded weight: deliver() decrements by the
// item's weight, so a weight written here must be counted here too.
const addedWeight = Number(booking.weight) - Number(existing.weight ?? 0);
if (addedWeight > 0) {
await this.applyCapacityDelta(
this.dataSource.manager,
{
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
yardId: unloadLocation?.yardId ?? existing.yardId,
zoneId: unloadLocation?.zoneId ?? existing.zoneId,
},
addedWeight,
0,
0,
);
}
result.unloadedCount += 1;
result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' });
continue;
@@ -2241,7 +2300,7 @@ export class WarehouseInventoryService {
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'UNLOADED',
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer),
arrivedAt: now,
unloadedAt: now,
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
@@ -2253,6 +2312,11 @@ export class WarehouseInventoryService {
description: 'Unloaded from arrived import train',
performedBy,
});
// New goods physically in the warehouse — count them, or deliver() would
// later free capacity that was never taken.
if (Number(saved.weight) > 0) {
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
}
result.unloadedCount += 1;
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' });
} catch (error) {
@@ -2670,7 +2734,12 @@ export class WarehouseInventoryService {
this.assertCapacity('Zone', zone, weight, volume, containerCount);
const now = new Date();
const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now);
const grnNumber = this.generateGrnNumber(
bookingDirection ?? 'WH',
dto.bookingId ?? 'MANUAL',
now,
truckEntrance?.ownerName ?? bookingSource?.customer,
);
const receiveNote = this.buildReceiveNote({
grnNumber,
notes: dto.notes?.trim() || 'Single booking received',
@@ -3610,6 +3679,7 @@ export class WarehouseInventoryService {
contractId: string | null;
hasLastMile: boolean;
handoverSigned: boolean;
inspectionStatus: string | null;
}>
> {
const rows: Array<{
@@ -3627,6 +3697,7 @@ export class WarehouseInventoryService {
contractId: string | null;
hasLastMile: boolean;
delivered: boolean;
inspectionStatus: string | null;
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
@@ -3641,7 +3712,8 @@ export class WarehouseInventoryService {
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
COALESCE(inv.status = 'DELIVERED', false) AS delivered
COALESCE(inv.status = 'DELIVERED', false) AS delivered,
inv.inspection_status AS "inspectionStatus"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
@@ -3693,6 +3765,7 @@ export class WarehouseInventoryService {
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
inspectionStatus: r.inspectionStatus,
handoverSigned,
}));
}
@@ -3944,7 +4017,10 @@ export class WarehouseInventoryService {
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
inv.quantity,
inv.weight,
-- An unweighed item still reports the cargo weight it holds: fall
-- back to the item's container VGM, then the booking's declared
-- weight, so a GRN never prints "0 t" for goods that are present.
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight,
inv.volume,
inv.status,
inv.notes,
@@ -3992,6 +4068,16 @@ export class WarehouseInventoryService {
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN LATERAL (
SELECT SUM(bcu.vgm_tons) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id
AND bcu.deleted_at IS NULL
AND (container.container_number IS NULL
OR bcu.container_number = container.container_number)
) item_vgm ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
WHERE inv.id = $1 AND inv.deleted_at IS NULL
@@ -4379,6 +4465,81 @@ export class WarehouseInventoryService {
});
}
/**
* Record whether a booking's goods needed double handling. Answered by
* warehouse staff once the goods are unloaded — only Yes bills the
* DOUBLE_HANDLING_FEE rule (see WarehouseFeeService.computeDoubleHandling).
* Locked once the fee has been invoiced, so a billed charge can't be
* retro-cancelled from the operations screen.
*/
async setDoubleHandling(
bookingId: string,
doubleHandling: boolean,
performedBy?: string,
): Promise<{ bookingId: string; doubleHandling: boolean; setAt: string }> {
const [booking]: Array<{ id: string; tradeDirection: string | null; reference: string | null }> =
await this.dataSource.query(
`SELECT id, trade_direction AS "tradeDirection", reference
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
throw new BadRequestException('Double handling applies to import bookings only');
}
// Warehouse fees are billed per inventory row (invoices.source = 'warehouse',
// source_id = the inventory id), with the fee type on the line's charge_type.
const [invoiced]: Array<{ one: number }> = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.invoices i
JOIN freight.invoice_lines il ON il.invoice_id = i.id AND il.deleted_at IS NULL
JOIN freight.warehouse_inventory inv
ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
WHERE inv.booking_id = $1
AND i.source = 'warehouse'
AND i.deleted_at IS NULL
AND i.status <> 'CANCELLED'
AND il.charge_type = 'DOUBLE_HANDLING'
LIMIT 1`,
[bookingId],
);
if (invoiced) {
throw new BadRequestException(
'Double handling has already been invoiced for this booking — cancel the invoice to change it',
);
}
const setAt = new Date();
await this.dataSource.query(
`UPDATE freight.bookings
SET double_handling = $2,
double_handling_set_at = $3,
double_handling_set_by = $4,
updated_at = NOW()
WHERE id = $1`,
[bookingId, doubleHandling, setAt, performedBy ?? null],
);
// Audit on the booking's inventory rows so it shows in warehouse history.
const items: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
`SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL`,
[bookingId],
);
for (const item of items) {
await this.activityLog.record({
activityType: 'INVENTORY_STORED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Double handling set to ${doubleHandling ? 'YES — fee rule applies' : 'NO'}`,
performedBy,
});
}
return { bookingId, doubleHandling, setAt: setAt.toISOString() };
}
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
const [inv]: Array<{ id: string }> = await this.dataSource.query(
@@ -5213,7 +5374,9 @@ export class WarehouseInventoryService {
});
const rows: Array<[string, unknown]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
// The GRN is mapped to the owner (import: consignee, export: shipper) —
// named explicitly so the note reads the same for both directions.
["Owner's Name", data.customerName],
['Customer TIN', data.customerTin],
['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
@@ -5844,8 +6007,13 @@ export class WarehouseInventoryService {
}
/** Shared with the facility handling flow — see common/grn.util.ts. */
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
return generateGrnNumber(direction, referenceId, date);
private generateGrnNumber(
direction: string,
referenceId: string,
date: Date,
ownerName?: string | null,
): string {
return generateGrnNumber(direction, referenceId, date, ownerName);
}
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {

View File

@@ -1,8 +1,9 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DeepPartial, Repository } from 'typeorm';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
@Injectable()
@@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository<WarehouseYard> {
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
super(repository);
}
/** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */
async update(id: string, data: DeepPartial<WarehouseYard>): Promise<WarehouseYard | null> {
const { cargoTypes, ...columns } = data;
if (Object.keys(columns).length) {
await this.repository.update(id, columns as never);
}
if (cargoTypes) {
const entity = await this.repository.findOne({ where: { id } as never });
if (entity) {
entity.cargoTypes = cargoTypes as CargoType[];
await this.repository.save(entity);
}
}
return this.findById(id);
}
}

View File

@@ -1,5 +1,6 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
import { WarehouseYard } from './entities/warehouse-yard.entity';
@@ -15,7 +16,7 @@ export class WarehouseYardsService {
findAll(): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({
relations: { warehouse: true, zones: true },
relations: { warehouse: true, zones: true, cargoTypes: true },
order: { code: 'ASC' },
});
}
@@ -23,14 +24,14 @@ export class WarehouseYardsService {
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({
where: { warehouseId },
relations: { zones: true },
relations: { zones: true, cargoTypes: true },
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<WarehouseYard> {
const yard = await this.yardsRepository.findById(id, {
relations: { warehouse: true, zones: true },
relations: { warehouse: true, zones: true, cargoTypes: true },
});
if (!yard) {
@@ -51,6 +52,7 @@ export class WarehouseYardsService {
name: dto.name.trim(),
code: dto.code.trim(),
type: dto.type,
direction: dto.direction ?? null,
capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null,
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
@@ -60,6 +62,8 @@ export class WarehouseYardsService {
currentVolume: 0,
status: 'ACTIVE',
isActive: true,
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType),
});
}
@@ -84,12 +88,16 @@ export class WarehouseYardsService {
name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type,
direction: dto.direction ?? existing.direction,
capacityWeight: newCapacityWeight,
capacityContainers: newCapacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume,
status,
isActive: status === 'ACTIVE',
...(dto.cargoTypeIds
? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) }
: {}),
});
if (!updated) {

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
interface OnboardingField {
fileKey: string;
@@ -17,27 +18,14 @@ interface OnboardingField {
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
/** fileKey of the delegation letter attached to the Power of Attorney step. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/**
* Seeded as optional: the delegation letter is only mandatory once a PoA has
* been entered, or when the company operates as a freight forwarder. That rule
* spans form fields as well as files, so it lives in the onboarding gate
* (companies.service.getOnboardingRequirements) rather than in `isRequired`.
* Listed in the sets below only so the reference defaults stay a complete
* picture of a company onboarding form. Unlike every other field here, the DARS
* delegation paper is not admin-managed: `FileUploadSettingsService.getByCode`
* injects it from poa-delegation.constants.ts whether or not a row exists.
*/
const poaDelegationField = (displayOrder: number): OnboardingField => ({
fileKey: POA_DELEGATION_FILE_KEY,
fileLabel: "PoA Delegation Letter",
helpText:
"Signed letter in which the General Manager delegates the representative named above.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder,
});
const poaDelegationDefault = (displayOrder: number): OnboardingField =>
poaDelegationField(displayOrder) as unknown as OnboardingField;
/** Documents required from an Ethiopian company at onboarding. */
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
@@ -75,7 +63,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
maxSizeMb: 10,
displayOrder: 3,
},
poaDelegationField(4),
poaDelegationDefault(4),
];
/** Documents required from a Foreign company at onboarding. */
@@ -124,7 +112,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
maxSizeMb: 10,
displayOrder: 4,
},
poaDelegationField(5),
poaDelegationDefault(5),
];
/** Legacy combined set, kept for the older per-company-type codes. */