mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into origin/freight_feature/transit
This commit is contained in:
@@ -4,24 +4,17 @@ import {
|
||||
HttpException,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import type { Request, Response } from 'express';
|
||||
} from "@nestjs/common";
|
||||
import { Observable, tap } from "rxjs";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import {
|
||||
auditEndpointMatcher,
|
||||
type MatchedAuditEndpoint,
|
||||
} from './audit-endpoint-matcher';
|
||||
import {
|
||||
isAuditableActor,
|
||||
resolveAuditActor,
|
||||
type AuditActorSource,
|
||||
} from './audit-actor';
|
||||
import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer';
|
||||
import { AuditService } from "./audit.service";
|
||||
import { auditEndpointMatcher, type MatchedAuditEndpoint } from "./audit-endpoint-matcher";
|
||||
import { isAuditableActor, resolveAuditActor, type AuditActorSource } from "./audit-actor";
|
||||
import { redactUrlQuery, sanitizeRequestPayload } from "./audit.sanitizer";
|
||||
|
||||
/** Methods that can change state. Everything else is never audited. */
|
||||
const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
const AUDITED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
/** `error_message` ceiling — stack traces do not belong in this column. */
|
||||
const MAX_ERROR_LENGTH = 2_000;
|
||||
@@ -52,7 +45,7 @@ export class AuditInterceptor implements NestInterceptor {
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
// Non-HTTP contexts (the RabbitMQ microservice transport) have no request.
|
||||
if (context.getType() !== 'http') return next.handle();
|
||||
if (context.getType() !== "http") return next.handle();
|
||||
|
||||
const httpContext = context.switchToHttp();
|
||||
const request = httpContext.getRequest<RequestWithUser>();
|
||||
@@ -72,10 +65,7 @@ export class AuditInterceptor implements NestInterceptor {
|
||||
const startedAt = Date.now();
|
||||
// The body is captured up front: handlers are free to mutate the DTO they
|
||||
// are given, so reading it after the fact can record post-mutation values.
|
||||
const requestPayload = sanitizeRequestPayload(
|
||||
request.body,
|
||||
request.files ?? request.file,
|
||||
);
|
||||
const requestPayload = sanitizeRequestPayload(request.body, request.files ?? request.file);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
@@ -137,7 +127,6 @@ export class AuditInterceptor implements NestInterceptor {
|
||||
resourceId: matched.resourceId,
|
||||
request: requestPayload,
|
||||
ipAddress: resolveIp(request),
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
requestId: resolveRequestId(request),
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
@@ -154,10 +143,10 @@ function resolveErrorMessage(error: unknown): string | null {
|
||||
if (error instanceof HttpException) {
|
||||
const response = error.getResponse();
|
||||
const message =
|
||||
typeof response === 'string'
|
||||
typeof response === "string"
|
||||
? response
|
||||
: ((response as { message?: unknown })?.message ?? error.message);
|
||||
const text = Array.isArray(message) ? message.join('; ') : String(message);
|
||||
const text = Array.isArray(message) ? message.join("; ") : String(message);
|
||||
return text.slice(0, MAX_ERROR_LENGTH);
|
||||
}
|
||||
|
||||
@@ -171,19 +160,19 @@ function resolveErrorMessage(error: unknown): string | null {
|
||||
* entry (the original client) taken.
|
||||
*/
|
||||
function resolveIp(request: Request): string | null {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
const forwarded = request.headers["x-forwarded-for"];
|
||||
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
||||
const candidate = raw?.split(',')[0]?.trim() || request.ip;
|
||||
const candidate = raw?.split(",")[0]?.trim() || request.ip;
|
||||
if (!candidate) return null;
|
||||
|
||||
// Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column
|
||||
// accepts but which reads badly and breaks grouping by address.
|
||||
return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate;
|
||||
return candidate.startsWith("::ffff:") ? candidate.slice(7) : candidate;
|
||||
}
|
||||
|
||||
/** Correlation id from the proxy/tracing layer, when present. */
|
||||
function resolveRequestId(request: RequestWithUser): string | null {
|
||||
const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id'];
|
||||
const header = request.headers["x-request-id"] ?? request.headers["x-correlation-id"];
|
||||
const value = Array.isArray(header) ? header[0] : header;
|
||||
return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
|
||||
/**
|
||||
* One portal login belonging to a customer company: the company-side profile
|
||||
* joined to the IAM account that actually signs in.
|
||||
*
|
||||
* The two halves drift apart routinely — `company.email` is business contact
|
||||
* detail, while `email` here is the credential a reset link goes to — which is
|
||||
* exactly why staff need to see the IAM side rather than the company row.
|
||||
*/
|
||||
export interface CustomerAccount {
|
||||
/** external_profiles.id */
|
||||
profileId: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jobTitle: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
onboardingStep: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
/** Null when the profile points at a user row that no longer exists. */
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
phoneVerified: boolean | null;
|
||||
/** IAM account status (`EUserStatus`), surfaced as-is. */
|
||||
status: string | null;
|
||||
isActive: boolean | null;
|
||||
/** False means the account was created but never activated by its owner. */
|
||||
hasSetPassword: boolean | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerAccountsService {
|
||||
constructor(
|
||||
@InjectRepository(ExternalProfile)
|
||||
private readonly profiles: Repository<ExternalProfile>,
|
||||
@InjectRepository(User)
|
||||
private readonly users: Repository<User>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Every portal account for a company, primary contact first.
|
||||
*
|
||||
* Deliberately NOT filtered to active accounts: a suspended or never-activated
|
||||
* login is the case staff are usually looking into, and hiding it would leave
|
||||
* "the customer says they can't log in" unanswerable from this screen.
|
||||
*/
|
||||
async listForCompany(companyId: string): Promise<CustomerAccount[]> {
|
||||
const profiles = await this.profiles.find({ where: { companyId } });
|
||||
if (profiles.length === 0) return [];
|
||||
|
||||
const userIds = profiles.map((p) => p.userId).filter(Boolean);
|
||||
// Explicit select: the User entity's relations include credentials and
|
||||
// sessions, and this response goes to a browser.
|
||||
const users = userIds.length
|
||||
? await this.users
|
||||
.createQueryBuilder("user")
|
||||
.select([
|
||||
"user.id",
|
||||
"user.username",
|
||||
"user.email",
|
||||
"user.phoneNumber",
|
||||
"user.isPhoneNumberVerified",
|
||||
"user.status",
|
||||
"user.isActive",
|
||||
"user.hasSetPassword",
|
||||
])
|
||||
.where({ id: In(userIds) })
|
||||
.getMany()
|
||||
: [];
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
return profiles
|
||||
.map((p) => {
|
||||
const user = byId.get(p.userId);
|
||||
return {
|
||||
profileId: p.id,
|
||||
userId: p.userId,
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
jobTitle: p.jobTitle ?? null,
|
||||
isPrimaryContact: p.isPrimaryContact,
|
||||
onboardingStep: p.onboardingStep ?? null,
|
||||
onboardingCompleted: p.onboardingCompleted ?? false,
|
||||
username: user?.username ?? null,
|
||||
email: user?.email ?? null,
|
||||
phoneNumber: user?.phoneNumber ?? null,
|
||||
phoneVerified: user?.isPhoneNumberVerified ?? null,
|
||||
status: user?.status ?? null,
|
||||
isActive: user?.isActive ?? null,
|
||||
hasSetPassword: user?.hasSetPassword ?? null,
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Primary contact first — it is the account every staff action
|
||||
// (password reset, notifications) actually targets.
|
||||
if (a.isPrimaryContact !== b.isPrimaryContact) {
|
||||
return a.isPrimaryContact ? -1 : 1;
|
||||
}
|
||||
return a.createdAt.getTime() - b.createdAt.getTime();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
||||
import {
|
||||
CustomerAccount,
|
||||
CustomerAccountsService,
|
||||
} from "./customer-accounts.service";
|
||||
import {
|
||||
CustomerResetService,
|
||||
CustomerResetTarget,
|
||||
@@ -25,7 +29,22 @@ import {
|
||||
@Controller("backoffice/customers")
|
||||
@ApiBearerAuth()
|
||||
export class CustomerResetController {
|
||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
||||
constructor(
|
||||
private readonly customerResetService: CustomerResetService,
|
||||
private readonly customerAccountsService: CustomerAccountsService,
|
||||
) {}
|
||||
|
||||
@Get(":companyId/accounts")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The portal login accounts belonging to a customer, primary contact first",
|
||||
})
|
||||
async accounts(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<CustomerAccount[]> {
|
||||
return this.customerAccountsService.listForCompany(companyId);
|
||||
}
|
||||
|
||||
@Get(":companyId/reset-target")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { CustomerAccountsService } from './customer-accounts.service';
|
||||
import { CustomerResetController } from './customer-reset.controller';
|
||||
import { CustomerResetService } from './customer-reset.service';
|
||||
import { ForgotPasswordController } from './forgot-password.controller';
|
||||
@@ -50,6 +51,7 @@ import { ListUsersService } from './list-users.service';
|
||||
CheckAvailabilityService,
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
CustomerAccountsService,
|
||||
],
|
||||
// Shipping-line registration mints activation links through the same
|
||||
// staff-triggered reset path customers use.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import type { SnapshotEmployee } from '../../common/freight-jwt.guard';
|
||||
import {
|
||||
collectPermissionKeys,
|
||||
isSuperAdmin,
|
||||
@@ -82,8 +83,26 @@ export class FreightMeService {
|
||||
? [employeeRecord.position]
|
||||
: [];
|
||||
|
||||
const enrichedPositions = await Promise.all(
|
||||
rawPositions.map(async (position) => {
|
||||
// IAM keeps one employee row per organization, so a user holding a freight
|
||||
// post and a Smart Office post owns two rows. The backoffice reads
|
||||
// `employee` as an array and the position picker lists what it finds there
|
||||
// — returning only the active row hides the other desk and makes it
|
||||
// unselectable. `FreightJwtGuard` leaves the full set here.
|
||||
const employeeRows = (user as { employeeRows?: SnapshotEmployee[] })
|
||||
.employeeRows;
|
||||
|
||||
// Active row first: the backoffice reads `employee[0]` for
|
||||
// unitId/organizationId, so the desk the caller is acting as must lead.
|
||||
const rows: SnapshotEmployee[] = employeeRows?.length
|
||||
? [
|
||||
...employeeRows.filter((row) => row.id === employeeRecord?.id),
|
||||
...employeeRows.filter((row) => row.id !== employeeRecord?.id),
|
||||
]
|
||||
: employeeRecord
|
||||
? [{ ...employeeRecord, positions: rawPositions } as SnapshotEmployee]
|
||||
: [];
|
||||
|
||||
const enrichPosition = async (position: TokenPosition) => {
|
||||
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
||||
this.lookupPositionType(position.id),
|
||||
this.lookupPositionTypePermissions(position.id),
|
||||
@@ -114,20 +133,24 @@ export class FreightMeService {
|
||||
positionType,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const enrichedRows = await Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
row,
|
||||
positions: await Promise.all(
|
||||
((row.positions ?? []) as TokenPosition[]).map(enrichPosition),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
const employee = employeeRecord
|
||||
? [
|
||||
{
|
||||
id: employeeRecord.id,
|
||||
organizationId: employeeRecord.organizationId,
|
||||
unitId: employeeRecord.unitId,
|
||||
name: employeeRecord.name,
|
||||
positions: enrichedPositions.map((p) => p.position),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const employee = enrichedRows.map(({ row, positions }) => ({
|
||||
id: row.id as string,
|
||||
organizationId: row.organizationId as string,
|
||||
unitId: row.unitId as string,
|
||||
name: row.name,
|
||||
positions: positions.map((p) => p.position),
|
||||
}));
|
||||
|
||||
// `collectPermissionKeys` reads the raw token (position-level only), so
|
||||
// union the type-level grants in — the backoffice prefers this flat list
|
||||
@@ -135,7 +158,9 @@ export class FreightMeService {
|
||||
const permissionKeys = [
|
||||
...new Set([
|
||||
...collectPermissionKeys(user),
|
||||
...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys),
|
||||
...enrichedRows.flatMap(({ positions }) =>
|
||||
positions.flatMap((p) => p.positionTypePermissionKeys),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentModule } from "../payment/payment.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,6 +26,10 @@ import { FilesModule } from "../files/files.module";
|
||||
DocumentsModule,
|
||||
UserTradeAccessModule,
|
||||
FilesModule,
|
||||
// Customer notice when Finance confirms a manual payment. The inbox module
|
||||
// reaches this one back through CompaniesModule, hence forwardRef.
|
||||
NotificationsModule,
|
||||
forwardRef(() => NotificationInboxModule),
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
|
||||
@@ -83,6 +83,8 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
});
|
||||
|
||||
@@ -166,6 +168,8 @@ describe("BillingService.issueMemo", () => {
|
||||
{} as never,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, manager, savedLines };
|
||||
}
|
||||
@@ -301,6 +305,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -357,6 +363,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -403,6 +411,8 @@ describe("BillingService.settleByPaymentId", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -517,6 +527,8 @@ describe("BillingService.recordPayment", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -635,6 +647,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
@@ -709,6 +723,8 @@ describe("BillingService.issuePayable", () => {
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
@@ -801,6 +817,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -885,6 +903,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -939,8 +959,12 @@ describe("BillingService.document", () => {
|
||||
const build = (invoice: Record<string, unknown>) => {
|
||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
||||
// `toDocumentModel` reads the booking (route/wagons, PNR) straight off the
|
||||
// data source for a booking-sourced invoice — a stub that answers "no such
|
||||
// booking" keeps these summary assertions about the invoice itself.
|
||||
const dataSource = { getRepository: () => ({ findOne: jest.fn().mockResolvedValue(null) }) };
|
||||
const service = new BillingService(
|
||||
{} as never,
|
||||
dataSource as never,
|
||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never,
|
||||
{} as never,
|
||||
@@ -955,6 +979,8 @@ describe("BillingService.document", () => {
|
||||
: undefined,
|
||||
} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, render, renderThermal };
|
||||
};
|
||||
@@ -1014,6 +1040,34 @@ describe("BillingService.document", () => {
|
||||
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||
});
|
||||
|
||||
it("prints the provider transaction reference of a settled invoice", async () => {
|
||||
const { service, render } = build(
|
||||
invoiceRow({
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paidAmount: 100,
|
||||
balanceAmount: 0,
|
||||
payments: [{ amount: 100, method: "GATEWAY", reference: "FT26082700123", paidAt: "2026-08-27T09:00:00.000Z" }],
|
||||
payment: { transactionId: "FT26082700123" },
|
||||
}),
|
||||
);
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary).toContainEqual({ label: "Transaction ref", value: "FT26082700123" });
|
||||
});
|
||||
|
||||
it("adds no transaction reference row to an unpaid invoice", async () => {
|
||||
const { service, render } = build(invoiceRow());
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(
|
||||
model.summary.find((r: { label: string }) => r.label === "Transaction ref"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls render (not renderThermal) for the default format", async () => {
|
||||
const { service, render, renderThermal } = build(invoiceRow());
|
||||
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||
@@ -1047,6 +1101,8 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
function makeService(invoiceType: string) {
|
||||
const invoice = {
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-001",
|
||||
companyId: "company-1",
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: invoiceType,
|
||||
@@ -1057,9 +1113,16 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
const recordPayment = jest.fn().mockResolvedValue(invoice);
|
||||
const dataSource = {
|
||||
getRepository: () => ({
|
||||
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
|
||||
findOne: async () => ({
|
||||
id: "booking-1",
|
||||
reference: "BK-001",
|
||||
paymentDeadline: PAST,
|
||||
}),
|
||||
}),
|
||||
query: async () => [{ phone: "+251900000000", email: "c@x.com" }],
|
||||
};
|
||||
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||
const notify = jest.fn().mockResolvedValue(undefined);
|
||||
const service = new BillingService(
|
||||
dataSource as never,
|
||||
{ findById: async () => invoice } as never,
|
||||
@@ -1071,10 +1134,12 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true } as never,
|
||||
{ directSend } as never,
|
||||
{ notify } as never,
|
||||
);
|
||||
(service as unknown as { recordPayment: unknown }).recordPayment =
|
||||
recordPayment;
|
||||
return { service, recordPayment };
|
||||
return { service, recordPayment, directSend, notify };
|
||||
}
|
||||
|
||||
const slip = { originalname: "slip.pdf" } as never;
|
||||
@@ -1097,6 +1162,42 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("notifies the customer (inbox + SMS + email) once the payment is confirmed", async () => {
|
||||
const { service, notify, directSend } = makeService(
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
);
|
||||
await service.confirmOfflinePayment("inv-1", slip, {});
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipients: { companyId: "company-1" },
|
||||
type: "PAYMENT_RECEIVED",
|
||||
link: "/billing/inv-1",
|
||||
body: expect.stringMatching(/500 ETB .*INV-001 \(booking BK-001\)/),
|
||||
}),
|
||||
);
|
||||
expect(directSend).toHaveBeenCalledWith(
|
||||
"sms",
|
||||
"+251900000000",
|
||||
expect.stringContaining("INV-001"),
|
||||
);
|
||||
expect(directSend).toHaveBeenCalledWith(
|
||||
"email",
|
||||
"c@x.com",
|
||||
expect.stringContaining("INV-001"),
|
||||
);
|
||||
});
|
||||
|
||||
it("still settles when the customer notice fails", async () => {
|
||||
const { service, notify, recordPayment } = makeService(
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
);
|
||||
notify.mockRejectedValueOnce(new Error("inbox down"));
|
||||
await expect(
|
||||
service.confirmOfflinePayment("inv-1", slip, {}),
|
||||
).resolves.toBeDefined();
|
||||
expect(recordPayment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still requires the bank slip for a cancellation fee", async () => {
|
||||
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
|
||||
await expect(
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import {
|
||||
Freight,
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
PaymentReferenceType,
|
||||
} from "@edr/types";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
BadRequestException,
|
||||
@@ -20,6 +25,10 @@ import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wago
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
|
||||
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { resolveShippingLineNotifyTarget } from "../notifications/resolve-shipping-line-contact.util";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
@@ -29,9 +38,12 @@ import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
sameCompanyName,
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { amountInWords } from "./documents/mor-document.util";
|
||||
import { buildEimsSeller, resolveLineTax } from "../eims/eims-invoice-context";
|
||||
import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
@@ -41,6 +53,7 @@ import {
|
||||
applySettlement,
|
||||
invoicePaymentMethodExpr,
|
||||
round2,
|
||||
settlementReferences,
|
||||
} from "./invoice-settlement.util";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
|
||||
@@ -114,6 +127,8 @@ export interface InvoiceListFilters {
|
||||
status?: Freight.InvoiceStatus;
|
||||
statuses?: Freight.InvoiceStatus[];
|
||||
sources?: string[];
|
||||
/** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */
|
||||
types?: string[];
|
||||
eimsStatuses?: string[];
|
||||
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
|
||||
paymentMethods?: string[];
|
||||
@@ -267,6 +282,8 @@ export class BillingService {
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -300,7 +317,12 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.sources?.length) {
|
||||
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
|
||||
qb.andWhere("invoice.source IN (:...sources)", {
|
||||
sources: filter.sources,
|
||||
});
|
||||
}
|
||||
if (filter.types?.length) {
|
||||
qb.andWhere("invoice.type IN (:...types)", { types: filter.types });
|
||||
}
|
||||
if (filter.eimsStatuses?.length) {
|
||||
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
||||
@@ -326,7 +348,9 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.issuedTo) {
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", {
|
||||
issuedTo: filter.issuedTo,
|
||||
});
|
||||
}
|
||||
if (filter.dueFrom) {
|
||||
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
|
||||
@@ -589,21 +613,28 @@ export class BillingService {
|
||||
/**
|
||||
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
|
||||
* never through the gateway) and ETB invoices Finance settles by hand (bank
|
||||
* transfer / counter) instead of the customer paying online. Open ones by
|
||||
* default or a single status when filtered; both currencies unless
|
||||
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
|
||||
* trade direction and pay-window deadline so the UI can show the countdown
|
||||
* and link to the booking.
|
||||
* transfer / counter) instead of the customer paying online. Both currencies
|
||||
* unless `currency` narrows it, and only ones whose manual-payment channel is
|
||||
* switched on. Open ones by default — pin `status` or `statuses` to widen
|
||||
* that. Every other dimension is the invoice list's own (`applyInvoiceFilters`
|
||||
* + `INVOICE_SORT_COLUMNS`), so the two screens filter and sort alike.
|
||||
* Booking-sourced rows carry the booking's reference, trade direction and
|
||||
* pay-window deadline so the UI can show the countdown and link to the
|
||||
* booking.
|
||||
*/
|
||||
async findOfflineUsdPaginated(
|
||||
filter: {
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
filter: InvoiceListFilters & {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
} = {},
|
||||
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
|
||||
): Promise<{
|
||||
items: OfflineUsdInvoiceRow[];
|
||||
total: number;
|
||||
/** Sum of `balanceAmount` over the WHOLE filtered set, by currency. */
|
||||
outstanding: Record<string, number>;
|
||||
}> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
@@ -612,33 +643,75 @@ export class BillingService {
|
||||
// a row Finance cannot act on is noise, and the confirm endpoint would
|
||||
// refuse it anyway. All off → nothing to work.
|
||||
const enabled = await this.manualPaymentSettings.enabledCurrencies();
|
||||
if (!enabled.length) return { items: [], total: 0 };
|
||||
const currencies = filter.currency
|
||||
? enabled.filter((c) => c === filter.currency)
|
||||
: enabled;
|
||||
if (!currencies.length) return { items: [], total: 0 };
|
||||
const empty = { items: [], total: 0, outstanding: {} };
|
||||
if (!enabled.length) return empty;
|
||||
const wanted = filter.currency?.toUpperCase();
|
||||
const currencies = wanted ? enabled.filter((c) => c === wanted) : enabled;
|
||||
if (!currencies.length) return empty;
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
/**
|
||||
* The worklist narrows by the same vocabulary as the main invoice list, so
|
||||
* both share `applyInvoiceFilters` — which references the `company` and
|
||||
* `payment` aliases, hence the unconditional joins. `select` is false for
|
||||
* the aggregate pass, where joined columns would break the GROUP BY.
|
||||
*/
|
||||
const buildQb = (select: boolean) => {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice");
|
||||
if (select) {
|
||||
qb.leftJoinAndSelect("invoice.company", "company").leftJoinAndSelect(
|
||||
"invoice.payment",
|
||||
"payment",
|
||||
);
|
||||
} else {
|
||||
qb.leftJoin("invoice.company", "company").leftJoin(
|
||||
"invoice.payment",
|
||||
"payment",
|
||||
);
|
||||
}
|
||||
qb.where("UPPER(invoice.currency) IN (:...currencies)", { currencies });
|
||||
// "What still needs settling" is the default cut, but only until the
|
||||
// caller pins a status — either the single-status param or the filter
|
||||
// bar's multi-select.
|
||||
if (!filter.status && !filter.statuses?.length) {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
// `currency` is already enforced by the enabled-currency IN above, and
|
||||
// re-applying it would only repeat the same predicate.
|
||||
this.applyInvoiceFilters(qb, { ...filter, currency: undefined });
|
||||
return qb;
|
||||
};
|
||||
|
||||
const qb = buildQb(true)
|
||||
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
|
||||
// raw; the id tiebreaker keeps paging stable when the column ties.
|
||||
.orderBy(
|
||||
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
|
||||
filter.sortOrder ?? "DESC",
|
||||
)
|
||||
.addOrderBy("invoice.id", "ASC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [rawItems, total] = await qb.getManyAndCount();
|
||||
|
||||
// Outstanding across the whole filtered set, not the visible page — the
|
||||
// KPI must not change as Finance pages through the worklist.
|
||||
const outstandingRows: { currency: string; outstanding: string }[] =
|
||||
await buildQb(false)
|
||||
.select("invoice.currency", "currency")
|
||||
.addSelect("SUM(invoice.balanceAmount)", "outstanding")
|
||||
.groupBy("invoice.currency")
|
||||
.getRawMany();
|
||||
// Folded case-insensitively on the way out: stored casing has drifted
|
||||
// ("usd" rows exist), so two groups can address the same currency.
|
||||
const outstanding: Record<string, number> = {};
|
||||
for (const row of outstandingRows) {
|
||||
const key = (row.currency ?? "").toUpperCase();
|
||||
outstanding[key] =
|
||||
(outstanding[key] ?? 0) + (Number(row.outstanding) || 0);
|
||||
}
|
||||
const items = await this.attachShippingLineCompanies(rawItems);
|
||||
|
||||
const bookingIds = items
|
||||
@@ -702,6 +775,7 @@ export class BillingService {
|
||||
} as OfflineUsdInvoiceRow;
|
||||
}),
|
||||
total,
|
||||
outstanding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -769,8 +843,9 @@ export class BillingService {
|
||||
uploadedByName: input.userName ?? null,
|
||||
});
|
||||
|
||||
return this.recordPayment(invoiceId, {
|
||||
amount: Number(invoice.balanceAmount),
|
||||
const amount = Number(invoice.balanceAmount);
|
||||
const paid = await this.recordPayment(invoiceId, {
|
||||
amount,
|
||||
method: "BANK_TRANSFER",
|
||||
reference: input.reference || slip.name,
|
||||
metadata: {
|
||||
@@ -780,6 +855,104 @@ export class BillingService {
|
||||
confirmedByName: input.userName ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// The customer did not pay through the portal, so nothing else tells them
|
||||
// Finance has settled their invoice — this is their only confirmation.
|
||||
await this.notifyCustomerManualPaymentConfirmed(paid, amount);
|
||||
return paid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer Finance confirmed their manual (bank transfer / counter)
|
||||
* payment: portal inbox entry plus SMS and email to the company's contact
|
||||
* (or the shipping line's own contact for a credit invoice). Best-effort —
|
||||
* a notification failure never undoes the settlement, it is only logged.
|
||||
*/
|
||||
private async notifyCustomerManualPaymentConfirmed(
|
||||
invoice: Invoice,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const bookingRef =
|
||||
invoice.source === Freight.InvoiceSource.Booking
|
||||
? await this.bookingReferenceFor(invoice.sourceId)
|
||||
: null;
|
||||
const body =
|
||||
`Your payment of ${round2(amount)} ${invoice.currency} for invoice ${invoice.invoiceNumber}` +
|
||||
(bookingRef ? ` (booking ${bookingRef})` : "") +
|
||||
` has been received and confirmed. Thank you.`;
|
||||
const title = "Payment confirmed";
|
||||
const data = {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
bookingId: bookingRef ? invoice.sourceId : null,
|
||||
};
|
||||
|
||||
if (invoice.companyId || invoice.companyProfileId) {
|
||||
await this.inbox.notify({
|
||||
recipients: invoice.companyId
|
||||
? { companyId: invoice.companyId }
|
||||
: { companyProfileId: invoice.companyProfileId! },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title,
|
||||
body,
|
||||
link: `/billing/${invoice.id}`,
|
||||
data,
|
||||
});
|
||||
if (invoice.companyId) {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
this.notifications,
|
||||
invoice.companyId,
|
||||
body,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (invoice.shippingLineCompanyId) {
|
||||
const target = await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
invoice.shippingLineCompanyId,
|
||||
);
|
||||
if (target.userId) {
|
||||
await this.inbox.notify({
|
||||
recipients: { userIds: [target.userId] },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title,
|
||||
body,
|
||||
link: `/shipping-line/invoices/${invoice.id}`,
|
||||
data,
|
||||
});
|
||||
}
|
||||
for (const [method, to] of [
|
||||
["sms", target.phone],
|
||||
["email", target.email],
|
||||
] as const) {
|
||||
if (!to) continue;
|
||||
try {
|
||||
await this.notifications.directSend(method, to, body);
|
||||
} catch {
|
||||
/* best-effort: provider unavailable */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Manual payment confirmed notify failed for invoice ${invoice.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Booking reference for a booking id, or null when the booking is gone. */
|
||||
private async bookingReferenceFor(bookingId: string): Promise<string | null> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
select: ["id", "reference"],
|
||||
});
|
||||
return booking?.reference ?? null;
|
||||
}
|
||||
|
||||
/** Invoice header plus its line items. */
|
||||
@@ -848,7 +1021,9 @@ export class BillingService {
|
||||
{
|
||||
label: "Wagons",
|
||||
value:
|
||||
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
|
||||
booking.wagonsRequired != null
|
||||
? String(booking.wagonsRequired)
|
||||
: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -875,10 +1050,21 @@ export class BillingService {
|
||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||
|
||||
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
|
||||
|
||||
const summary: InvoiceDocumentModel["summary"] = [
|
||||
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
|
||||
// filed against, not just the seller. VatNumber shown only when the company has one.
|
||||
{ label: "Buyer", value: invoice.company?.name ?? null },
|
||||
// The trade name of the eTrade licence THIS profile operates as. A TIN
|
||||
// holds many licences and the invoiced role (importer/exporter/forwarder)
|
||||
// is usually a different business from the one the company registered
|
||||
// under, so the buyer's name alone doesn't say which one was billed.
|
||||
// Suppressed when it just repeats the buyer name — most companies trade
|
||||
// under their registered name and a duplicate row helps nobody.
|
||||
...(tradeName && !sameCompanyName(tradeName, invoice.company?.name)
|
||||
? [{ label: "Buyer trade name", value: tradeName }]
|
||||
: []),
|
||||
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
||||
...(invoice.company?.vatNumber
|
||||
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
||||
@@ -907,11 +1093,24 @@ export class BillingService {
|
||||
const eimsCfg = this.config.get<EimsConfig>("eims");
|
||||
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
|
||||
if (eimsCfg?.invoice?.sellerVatNumber) {
|
||||
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
|
||||
summary.push({
|
||||
label: "Seller VAT No.",
|
||||
value: eimsCfg.invoice.sellerVatNumber,
|
||||
});
|
||||
}
|
||||
|
||||
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
||||
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
if (invoice.eimsIrn)
|
||||
summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
|
||||
// The provider's transaction number for the money actually received — CBE's `FT…`,
|
||||
// telebirr's receipt number, or the bank-slip reference a teller recorded manually.
|
||||
// It is what a payer holding a receipt can match this invoice against, and what
|
||||
// finance reconciles a bank statement with; without it a PAID invoice proves only
|
||||
// that EDR says it was paid. `findById` already loads the `payment` relation, so both
|
||||
// sources are in hand here — see settlementReferences for why both are read.
|
||||
const txnRefs = settlementReferences(invoice);
|
||||
if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs });
|
||||
|
||||
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
|
||||
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
|
||||
@@ -921,7 +1120,8 @@ export class BillingService {
|
||||
where: { id: invoice.sourceId },
|
||||
select: ["id", "pnrCode"],
|
||||
});
|
||||
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
if (booking?.pnrCode)
|
||||
summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -933,16 +1133,133 @@ export class BillingService {
|
||||
currency: invoice.currency,
|
||||
summary,
|
||||
categoryHeader: "Charge type",
|
||||
lines: invoice.lines.map((l) => ({
|
||||
description: l.description ?? l.chargeType,
|
||||
category: l.chargeType,
|
||||
quantity: l.quantity,
|
||||
unitRate: l.unitRate,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
})),
|
||||
lines: invoice.lines.map((l) => {
|
||||
// Same resolver the filing used, so the printed Tax Code / Excise / Discount columns
|
||||
// state what MoR actually holds for this line.
|
||||
const tax = eimsCfg?.invoice ? resolveLineTax(eimsCfg, l.chargeType) : null;
|
||||
return {
|
||||
description: l.description ?? l.chargeType,
|
||||
category: l.chargeType,
|
||||
quantity: l.quantity,
|
||||
unitRate: l.unitRate,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
nature: eimsCfg?.invoice?.natureOfSupplies ?? null,
|
||||
uom: eimsCfg?.invoice?.unitDefault ?? null,
|
||||
taxCode: tax?.code ?? null,
|
||||
excise: tax?.exciseTaxValue ?? null,
|
||||
discount: tax?.discount ?? null,
|
||||
};
|
||||
}),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr
|
||||
? pngDataUrl(invoice.eimsSignedQr)
|
||||
: null,
|
||||
mor: eimsCfg?.invoice ? this.buildMorDetails(invoice, eimsCfg) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The MoR tax-document view of an invoice (ADD-P001) — the bilingual layout a customer also sees
|
||||
* when they scan the QR on the Ministry's portal.
|
||||
*
|
||||
* Built from the invoice plus EIMS configuration alone, never from a live EIMS call: a document
|
||||
* has to print whether or not it is registered yet, and printing must not depend on the gateway
|
||||
* being up. Per-line tax comes from `resolveLineTax`, the same resolver that decided what was
|
||||
* actually filed, so the paper and the filing cannot disagree.
|
||||
*/
|
||||
private buildMorDetails(
|
||||
invoice: Invoice & { lines: InvoiceLine[] },
|
||||
cfg: EimsConfig,
|
||||
): InvoiceDocumentModel["mor"] {
|
||||
const seller = buildEimsSeller(cfg);
|
||||
const company = invoice.company;
|
||||
const documentType = (invoice.eimsDocumentType as "INV" | "DEB" | "CRE" | undefined) ?? "INV";
|
||||
// CREDIT until the money is in: the title states the sale's payment nature, not its status.
|
||||
const isCash = Number(invoice.paidAmount) >= Number(invoice.totalAmount);
|
||||
|
||||
const TITLES: Record<string, { am: string; en: string }> = {
|
||||
INV: isCash
|
||||
? { am: "የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Cash sales invoice / VAT / Excise Tax" }
|
||||
: { am: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Credit sales invoice / VAT / Excise Tax" },
|
||||
CRE: { am: "የታክስ ክሬዲት ሰነድ", en: "Tax Credit Note" },
|
||||
DEB: { am: "የታክስ ዴቢት ሰነድ", en: "Tax Debit Note" },
|
||||
};
|
||||
|
||||
let total = 0;
|
||||
let excise = 0;
|
||||
let discount = 0;
|
||||
let vatAmount = 0;
|
||||
let vatTaxable = 0;
|
||||
for (const line of invoice.lines) {
|
||||
const tax = resolveLineTax(cfg, line.chargeType);
|
||||
const lineTotal = Number(line.amount);
|
||||
total += lineTotal;
|
||||
excise += tax.exciseTaxValue;
|
||||
discount += tax.discount;
|
||||
if (tax.ratePercent > 0) {
|
||||
vatTaxable += lineTotal;
|
||||
vatAmount += (lineTotal * tax.ratePercent) / 100;
|
||||
}
|
||||
}
|
||||
const totalIncludingTax = Number(invoice.totalAmount);
|
||||
const rate = cfg.invoice.taxRatePercent ?? 0;
|
||||
|
||||
const title = TITLES[documentType] ?? TITLES.INV;
|
||||
|
||||
return {
|
||||
titleAm: title.am,
|
||||
titleEn: title.en,
|
||||
saleType: cfg.invoice.transactionType,
|
||||
irn: invoice.eimsIrn,
|
||||
systemNumber: cfg.systemNumber || null,
|
||||
referenceNumber: invoice.eimsDocumentNumber ?? null,
|
||||
relatedDocumentIrn: invoice.relatedInvoice?.eimsIrn ?? null,
|
||||
seller: {
|
||||
name: cfg.invoice.sellerLegalName || seller.LegalName,
|
||||
city: seller.City,
|
||||
subCity: seller.SubCity,
|
||||
woreda: seller.Wereda,
|
||||
kebele: seller.Locality,
|
||||
houseNo: seller.HouseNumber,
|
||||
tin: seller.Tin,
|
||||
vatNumber: seller.VatNumber,
|
||||
},
|
||||
buyer: {
|
||||
name: company?.name ?? "N/A",
|
||||
city: company?.zone ?? null,
|
||||
subCity: company?.zone ?? null,
|
||||
woreda: company?.woreda ?? null,
|
||||
kebele: company?.kebele ?? null,
|
||||
houseNo: company?.houseNo ?? null,
|
||||
tin: company?.tin ?? null,
|
||||
vatNumber: company?.vatNumber ?? null,
|
||||
},
|
||||
tax: {
|
||||
total: round2(total),
|
||||
discount: round2(discount),
|
||||
taxableTotal: round2(vatTaxable),
|
||||
excise: round2(excise),
|
||||
vatTaxableAmount: round2(vatTaxable),
|
||||
// An exempt seller still prints the row, labelled the way the Ministry's portal labels it.
|
||||
vatLabel: rate > 0 ? `ተ.እ.ታ / VAT ${rate}%` : `${cfg.invoice.taxCode} ታክስ / ${cfg.invoice.taxCode} Tax rate (N/A%)`,
|
||||
vatAmount: round2(vatAmount),
|
||||
incomeWithholding: cfg.invoice.incomeWithholdValue ?? 0,
|
||||
vatWithholding: cfg.invoice.transactionWithholdValue ?? 0,
|
||||
totalIncludingTax: round2(totalIncludingTax),
|
||||
amountInWords: amountInWords(totalIncludingTax),
|
||||
},
|
||||
payment: {
|
||||
mode: isCash ? "CASH" : "CREDIT",
|
||||
typeMethod: cfg.invoice.paymentTerm,
|
||||
receiverName: company?.name ?? null,
|
||||
},
|
||||
// A memo is an amendment to a filed document; MoR's layout carries the sign-off that
|
||||
// authorised it. Names come from the recorded reason until an approval chain exists.
|
||||
approval:
|
||||
documentType === "INV"
|
||||
? null
|
||||
: { requestedBy: invoice.eimsReason ?? null, checkedBy: null, approvedBy: null },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1201,7 +1518,9 @@ export class BillingService {
|
||||
metadata: l.metadata ?? null,
|
||||
}));
|
||||
|
||||
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
|
||||
const total = round2(
|
||||
lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0),
|
||||
);
|
||||
if (!(total > 0)) {
|
||||
throw new BadRequestException("A memo must have a positive total.");
|
||||
}
|
||||
@@ -1231,7 +1550,9 @@ export class BillingService {
|
||||
subtotalAmount: total,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
|
||||
...(settled
|
||||
? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() }
|
||||
: {}),
|
||||
},
|
||||
mg,
|
||||
code,
|
||||
@@ -1242,7 +1563,11 @@ export class BillingService {
|
||||
eimsReason: reason,
|
||||
relatedInvoiceId: original.id,
|
||||
...(settled
|
||||
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
|
||||
? {
|
||||
paidAmount: memo.totalAmount,
|
||||
balanceAmount: 0,
|
||||
paidAt: new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, memo.id, patch);
|
||||
@@ -1302,7 +1627,7 @@ export class BillingService {
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
||||
@@ -1823,9 +2148,9 @@ export class BillingService {
|
||||
dueAt,
|
||||
...(issuing
|
||||
? {
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||
@@ -1889,10 +2214,7 @@ export class BillingService {
|
||||
const repo = this.dataSource.getRepository(Invoice);
|
||||
const invoices = await repo.findBy({
|
||||
paymentId,
|
||||
status: In([
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
]),
|
||||
status: In([Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending]),
|
||||
});
|
||||
for (const invoice of invoices) {
|
||||
await repo.update(
|
||||
@@ -2069,7 +2391,10 @@ export class BillingService {
|
||||
// Same reference, for an ad-hoc additional charge — its own column, since
|
||||
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
|
||||
// can carry many of these at once.
|
||||
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
|
||||
if (
|
||||
billReference &&
|
||||
invoice.source === Freight.InvoiceSource.AdditionalCharge
|
||||
) {
|
||||
await this.dataSource
|
||||
.getRepository(AdditionalCharge)
|
||||
.update({ id: invoice.sourceId }, { paymentReference: billReference });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
|
||||
import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service";
|
||||
|
||||
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
|
||||
kind: "INVOICE",
|
||||
@@ -87,3 +87,203 @@ describe("InvoiceDocumentService.buildThermalHtml", () => {
|
||||
expect(html).not.toContain("right: 160px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameCompanyName", () => {
|
||||
it("treats eTrade's legal-suffix spellings as the same name", () => {
|
||||
expect(sameCompanyName("ABIJOEL PLC", "ABIJOEL P L C")).toBe(true);
|
||||
expect(
|
||||
sameCompanyName(
|
||||
"WISH TRADING PLC",
|
||||
"WISH TRADING PRIVATE LIMITED COMPANY",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sameCompanyName("TUTA TRADING PLC", "TUTA TRADING ONE MEMBER PLC"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a genuinely different trade name distinct", () => {
|
||||
// Real pairs from eTrade: the licence trades under a different name than
|
||||
// the company registered under, which is exactly the row worth printing.
|
||||
expect(
|
||||
sameCompanyName("Cozy Coffee Grower and Exporter", "ABIJOEL P L C"),
|
||||
).toBe(false);
|
||||
expect(sameCompanyName("MENNA PRODUCTION", "ICOFFEE TRADING PLC")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
sameCompanyName("YUNABEK TRADING PLC", "YUNABEK INVESTMENT PLC"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when either side is missing, so no row is printed", () => {
|
||||
expect(sameCompanyName("", "ABIJOEL P L C")).toBe(false);
|
||||
expect(sameCompanyName(null, null)).toBe(false);
|
||||
expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvoiceDocumentService.buildHtml — MoR tax-document layout (ADD-P001)", () => {
|
||||
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
|
||||
|
||||
const mor = (over: Partial<NonNullable<InvoiceDocumentModel["mor"]>> = {}) =>
|
||||
({
|
||||
titleAm: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ",
|
||||
titleEn: "Credit sales invoice / VAT / Excise Tax",
|
||||
saleType: "B2B",
|
||||
irn: "IRN-123",
|
||||
systemNumber: "2B6E48BB75",
|
||||
seller: { name: "Ethio-Djibouti Railway SC", tin: "0053481357" },
|
||||
buyer: { name: "Afri Software Solutions", tin: "0089238373" },
|
||||
tax: {
|
||||
total: 904008.15,
|
||||
discount: 0,
|
||||
taxableTotal: 0,
|
||||
excise: 0,
|
||||
vatTaxableAmount: 0,
|
||||
vatLabel: "VATEX ታክስ / VATEX Tax rate (N/A%)",
|
||||
vatAmount: 0,
|
||||
incomeWithholding: 0,
|
||||
vatWithholding: 0,
|
||||
totalIncludingTax: 904008.15,
|
||||
amountInWords: "Nine hundred and four thousand and eight Birr and fifteen Cents",
|
||||
},
|
||||
payment: { mode: "CREDIT", typeMethod: "IMMIDIATE", receiverName: "Afri Software Solutions" },
|
||||
...over,
|
||||
}) as NonNullable<InvoiceDocumentModel["mor"]>;
|
||||
|
||||
it("switches layout only when the mor block is present", () => {
|
||||
expect(service.buildHtml(model())).not.toContain("Total including Tax");
|
||||
expect(service.buildHtml(model({ mor: mor() }))).toContain("Total including Tax");
|
||||
});
|
||||
|
||||
it("prints the bilingual title, sale type, IRN and system number", () => {
|
||||
const html = service.buildHtml(model({ mor: mor() }));
|
||||
expect(html).toContain("Credit sales invoice / VAT / Excise Tax");
|
||||
expect(html).toContain("የዱቤ ሽያጭ ደረሰኝ");
|
||||
expect(html).toContain("(B2B)");
|
||||
expect(html).toContain("IRN-123");
|
||||
expect(html).toContain("2B6E48BB75");
|
||||
});
|
||||
|
||||
it("prints every totals row even when the figure is zero", () => {
|
||||
const html = service.buildHtml(model({ mor: mor() }));
|
||||
for (const label of [
|
||||
"Discount Amount",
|
||||
"Taxable Total",
|
||||
"Excise Tax",
|
||||
"Total VAT Taxable Amount",
|
||||
"Total Withheld Amount",
|
||||
"Total VAT Withheld Amount",
|
||||
"Total including Tax (in words)",
|
||||
]) {
|
||||
expect(html).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders amounts bare, with the currency named once in the total label", () => {
|
||||
const html = service.buildHtml(model({ mor: mor() }));
|
||||
expect(html).toContain("904,008.15");
|
||||
expect(html).toContain("Total (ETB)");
|
||||
// The generic "1 Birr (ETB)" per-cell format must not leak into the tax layout.
|
||||
expect(html).not.toContain("904,008.15 Birr (ETB)");
|
||||
});
|
||||
|
||||
it("carries the MoR item columns", () => {
|
||||
const html = service.buildHtml(
|
||||
model({
|
||||
mor: mor(),
|
||||
lines: [
|
||||
{
|
||||
description: "Container Import",
|
||||
quantity: 3,
|
||||
unitRate: 5223,
|
||||
amount: 15670,
|
||||
nature: "service",
|
||||
uom: "PCS",
|
||||
taxCode: "VATEX",
|
||||
excise: 0,
|
||||
discount: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Tax Code");
|
||||
expect(html).toContain("VATEX");
|
||||
expect(html).toContain("service");
|
||||
expect(html).toContain("PCS");
|
||||
});
|
||||
|
||||
it("shows the related document and approval block on a credit/debit note", () => {
|
||||
const html = service.buildHtml(
|
||||
model({
|
||||
mor: mor({
|
||||
titleEn: "Tax Credit Note",
|
||||
relatedDocumentIrn: "ORIGINAL-IRN",
|
||||
approval: { requestedBy: "biruk", checkedBy: "ermias", approvedBy: "kassahun" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Related Document");
|
||||
expect(html).toContain("ORIGINAL-IRN");
|
||||
expect(html).toContain("INVOICE AMENDMENT AUTHORIZATION");
|
||||
expect(html).toContain("kassahun");
|
||||
});
|
||||
|
||||
it("renders the sales receipt's linked-invoice table", () => {
|
||||
const html = service.buildHtml(
|
||||
model({
|
||||
mor: mor({
|
||||
titleEn: "Cash Receipt Voucher",
|
||||
receipt: {
|
||||
rrn: "RRN-9",
|
||||
reason: "Payment for goods purchased",
|
||||
collectedAmount: 950,
|
||||
invoices: [
|
||||
{
|
||||
irn: "INV-IRN-1",
|
||||
paymentCoverage: "PARTIAL",
|
||||
totalAmount: 1200,
|
||||
remainingAmount: 250,
|
||||
paidAmount: 950,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("RRN-9");
|
||||
expect(html).toContain("Payment Coverage");
|
||||
expect(html).toContain("PARTIAL");
|
||||
expect(html).toContain("Remaining Amount");
|
||||
});
|
||||
|
||||
it("renders the withholding receipt without an item table", () => {
|
||||
const html = service.buildHtml(
|
||||
model({
|
||||
mor: mor({
|
||||
titleEn: "Withholding tax on payment",
|
||||
tax: null,
|
||||
withholding: {
|
||||
receiptNumber: "WH-26-574705075",
|
||||
counter: "574705075",
|
||||
reason: "Tax Withholding",
|
||||
type: "TWTH",
|
||||
invoiceCurrency: "ETB",
|
||||
preTaxAmount: 8640000,
|
||||
withheldAmount: 259200,
|
||||
systemType: "MAN",
|
||||
systemNumber: "2B6E48BB75",
|
||||
},
|
||||
}),
|
||||
lines: [{ description: "ignored", amount: 1 }],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("WH-26-574705075");
|
||||
expect(html).toContain("TWTH");
|
||||
expect(html).toContain("Pre Tax Amount");
|
||||
expect(html).toContain("259,200.00");
|
||||
// A withholding receipt has no billed items — the item table must be suppressed entirely.
|
||||
expect(html).not.toContain("Unit Price");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,11 @@ import { LogoSettingsService } from "../../logo-settings/logo-settings.service";
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
|
||||
import { logoImageCss, logoMarkup } from "./logo-markup.util";
|
||||
import {
|
||||
formatDocumentTime,
|
||||
formatEthiopianDate,
|
||||
formatGregorianDate,
|
||||
} from "./mor-document.util";
|
||||
import {
|
||||
PdfColor,
|
||||
assembleSinglePagePdf,
|
||||
@@ -44,10 +49,53 @@ function money(amount: unknown, currency: string): string {
|
||||
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare fixed-2 amount for the MoR tax layout — `1,304,228.00`, no currency suffix.
|
||||
*
|
||||
* The Ministry's own documents name the currency once, in the `ድምር (ETB) / Total (ETB)` label, and
|
||||
* keep every figure a plain right-aligned number. Repeating "Birr (ETB)" in each cell (what the
|
||||
* generic `money` helper does) both breaks that column alignment and reads as a different
|
||||
* document from the one the customer sees when they scan the QR.
|
||||
*/
|
||||
function amount2(value: unknown): string {
|
||||
return Number(value ?? 0).toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(value: unknown): string {
|
||||
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this trade name just the company name again?
|
||||
*
|
||||
* Compared loosely on purpose: eTrade spells the same legal suffix as "PLC",
|
||||
* "P L C" and "PRIVATE LIMITED COMPANY", and pads names with double spaces, so
|
||||
* an exact comparison would call two spellings of one name different and print
|
||||
* a redundant row. Used only to decide whether a trade-name row is worth
|
||||
* showing — never to decide that two businesses ARE the same.
|
||||
*/
|
||||
export function sameCompanyName(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
const norm = (v: string | null | undefined) =>
|
||||
(v ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[.,]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\bPRIVATE LIMITED COMPANY\b/g, "PLC")
|
||||
.replace(/\bP L C\b/g, "PLC")
|
||||
.replace(/\bONE (MEMBER|PERSON) PLC\b/g, "PLC")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const left = norm(a);
|
||||
return left !== "" && left === norm(b);
|
||||
}
|
||||
|
||||
/** One billed line on the document (charge type / fee type agnostic). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
@@ -57,6 +105,115 @@ export interface InvoiceDocumentLine {
|
||||
unitRate?: number | null;
|
||||
amount?: number | null;
|
||||
currency?: string | null;
|
||||
/**
|
||||
* MoR tax-document columns (ADD-P001). Populated only for documents that carry a
|
||||
* {@link MorDocumentDetails}; the generic EDR layout ignores them.
|
||||
*/
|
||||
nature?: string | null;
|
||||
uom?: string | null;
|
||||
taxCode?: string | null;
|
||||
excise?: number | null;
|
||||
discount?: number | null;
|
||||
}
|
||||
|
||||
/** One party block (`ከ / From`, `ለ / To`) of a MoR tax document. */
|
||||
export interface MorPartyDetails {
|
||||
name: string;
|
||||
city?: string | null;
|
||||
/** `ዞን / ክ/ከተማ` — Zone/Sub city. */
|
||||
subCity?: string | null;
|
||||
woreda?: string | null;
|
||||
kebele?: string | null;
|
||||
houseNo?: string | null;
|
||||
tin?: string | null;
|
||||
subTin?: string | null;
|
||||
vatNumber?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ministry's totals block, in its printed order. Every row prints even at zero — a tax
|
||||
* document states each figure explicitly rather than omitting the ones that happen to be nil.
|
||||
*/
|
||||
export interface MorTaxSummary {
|
||||
total: number;
|
||||
discount: number;
|
||||
taxableTotal: number;
|
||||
excise: number;
|
||||
vatTaxableAmount: number;
|
||||
/** e.g. `ተ.እ.ታ / VAT 15%`, or `VATEX ታክስ / VATEX Tax rate (N/A%)` for an exempt seller. */
|
||||
vatLabel: string;
|
||||
vatAmount: number;
|
||||
incomeWithholding: number;
|
||||
vatWithholding: number;
|
||||
totalIncludingTax: number;
|
||||
amountInWords: string;
|
||||
}
|
||||
|
||||
export interface MorPaymentDetails {
|
||||
/** `CASH` / `CREDIT` — also selects the document title. */
|
||||
mode: string;
|
||||
/** `IMMEDIATE` and friends. */
|
||||
typeMethod: string;
|
||||
receiverName?: string | null;
|
||||
}
|
||||
|
||||
/** Credit/debit memo authorisation block. */
|
||||
export interface MorApprovalDetails {
|
||||
requestedBy?: string | null;
|
||||
checkedBy?: string | null;
|
||||
approvedBy?: string | null;
|
||||
}
|
||||
|
||||
/** Sales receipt (CRV) specifics. */
|
||||
export interface MorReceiptDetails {
|
||||
rrn: string;
|
||||
reason: string;
|
||||
collectedAmount: number;
|
||||
invoices: Array<{
|
||||
irn: string;
|
||||
paymentCoverage: string;
|
||||
totalAmount: number;
|
||||
remainingAmount: number;
|
||||
paidAmount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Withholding receipt specifics — a different document shape, with no item table. */
|
||||
export interface MorWithholdingDetails {
|
||||
receiptNumber: string;
|
||||
counter: string;
|
||||
reason: string;
|
||||
/** MoR withholding type, e.g. `TWTH`. */
|
||||
type: string;
|
||||
invoiceCurrency: string;
|
||||
preTaxAmount: number;
|
||||
withheldAmount: number;
|
||||
systemType: string;
|
||||
systemNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the MoR (ADD-P001) print layout needs beyond the generic model. Present ⇒ the
|
||||
* document renders in the Ministry's bilingual tax-document format instead of the plain EDR one.
|
||||
*/
|
||||
export interface MorDocumentDetails {
|
||||
/** Bilingual heading, e.g. `የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ` + `Cash sales invoice / VAT / Excise Tax`. */
|
||||
titleAm: string;
|
||||
titleEn: string;
|
||||
/** `B2B` / `B2C` / `B2G`. */
|
||||
saleType?: string | null;
|
||||
irn?: string | null;
|
||||
systemNumber?: string | null;
|
||||
referenceNumber?: string | null;
|
||||
/** Original document's IRN — credit and debit notes only. */
|
||||
relatedDocumentIrn?: string | null;
|
||||
seller: MorPartyDetails;
|
||||
buyer: MorPartyDetails;
|
||||
tax?: MorTaxSummary | null;
|
||||
payment?: MorPaymentDetails | null;
|
||||
approval?: MorApprovalDetails | null;
|
||||
receipt?: MorReceiptDetails | null;
|
||||
withholding?: MorWithholdingDetails | null;
|
||||
}
|
||||
|
||||
/** A labelled total row in the totals box; mark `grand` for the headline total. */
|
||||
@@ -101,6 +258,12 @@ export interface InvoiceDocumentModel {
|
||||
* itself goes through the ordinary `summary` rows, not a dedicated field.
|
||||
*/
|
||||
qrImageUrl?: string | null;
|
||||
/**
|
||||
* Present ⇒ render the Ministry's bilingual tax-document layout (ADD-P001) rather than the
|
||||
* generic EDR one. Set for every document EIMS knows about: invoice, credit/debit note, sales
|
||||
* receipt and withholding receipt.
|
||||
*/
|
||||
mor?: MorDocumentDetails | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,12 +367,38 @@ export class InvoiceDocumentService {
|
||||
})
|
||||
.join("");
|
||||
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
// A thermal receipt is a compact derivative of the A4 tax document, not a different document:
|
||||
// the tax breakdown, the amount in words and the payment mode are the legally load-bearing
|
||||
// parts and must survive the narrower page. Only the item-table columns are dropped.
|
||||
const tax = model.mor?.tax;
|
||||
const totalRows = tax
|
||||
? [
|
||||
["Total", money(tax.total, model.currency)],
|
||||
["Discount", money(tax.discount, model.currency)],
|
||||
["Taxable Total", money(tax.taxableTotal, model.currency)],
|
||||
["Excise Tax", money(tax.excise, model.currency)],
|
||||
[tax.vatLabel, money(tax.vatAmount, model.currency)],
|
||||
["Withheld", money(tax.incomeWithholding, model.currency)],
|
||||
["VAT Withheld", money(tax.vatWithholding, model.currency)],
|
||||
]
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<div class="total-row"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`,
|
||||
)
|
||||
.join("") +
|
||||
`<div class="total-row grand"><span>Total incl. Tax</span><strong>${esc(money(tax.totalIncludingTax, model.currency))}</strong></div>` +
|
||||
`<div class="words">${esc(tax.amountInWords)}</div>`
|
||||
: model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const payMarkup = model.mor?.payment
|
||||
? `<div class="rule"></div><div class="row"><span class="label">Mode of Payment</span><span class="value">${esc(model.mor.payment.mode)}</span></div>
|
||||
<div class="row"><span class="label">Type/Method</span><span class="value">${esc(model.mor.payment.typeMethod)}</span></div>`
|
||||
: "";
|
||||
|
||||
const qrMarkup = model.qrImageUrl
|
||||
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
|
||||
@@ -236,6 +425,7 @@ export class InvoiceDocumentService {
|
||||
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
|
||||
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
|
||||
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
|
||||
.words { font-size: 8px; text-align: center; margin-top: 4px; font-style: italic; }
|
||||
.qr { text-align: center; margin: 8px 0; }
|
||||
.qr img { width: 150px; height: 150px; }
|
||||
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
|
||||
@@ -254,6 +444,7 @@ export class InvoiceDocumentService {
|
||||
${itemBlocks}
|
||||
<div class="rule"></div>
|
||||
${totalRows}
|
||||
${payMarkup}
|
||||
${qrMarkup}
|
||||
<div class="footer">Thank you</div>
|
||||
</div>
|
||||
@@ -309,7 +500,11 @@ export class InvoiceDocumentService {
|
||||
let y = 700;
|
||||
const colX = [36, 300];
|
||||
const colW = 250;
|
||||
model.summary.slice(0, 16).forEach((row, i) => {
|
||||
// 20, not 16: a booking invoice already fills 16 rows with every optional one present
|
||||
// (buyer trade name, buyer VAT, seller TIN/VAT, IRN, PNR) and the transaction ref is the
|
||||
// 17th — the old cap silently dropped whichever row landed last. Still fits: 20 rows end
|
||||
// at y=423, leaving the line-item table its full run down to the y<190 cut-off.
|
||||
model.summary.slice(0, 20).forEach((row, i) => {
|
||||
const x = colX[i % 2];
|
||||
if (i % 2 === 0 && i > 0) y -= 27;
|
||||
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
|
||||
@@ -382,6 +577,10 @@ export class InvoiceDocumentService {
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
// A MoR-registered document prints in the Ministry's own bilingual format (ADD-P001). Anything
|
||||
// else — internal fee notes, statements — keeps the plain EDR layout below.
|
||||
if (model.mor) return this.buildMorHtml(model, model.mor);
|
||||
|
||||
const date = formatDate;
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
@@ -499,7 +698,317 @@ export class InvoiceDocumentService {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR EIMS tax-document layout (ADD-P001) — invoice, credit/debit note, sales receipt and
|
||||
* withholding receipt share this one template, differing only in which optional blocks appear.
|
||||
*
|
||||
* Field labels and their order come from the Ministry's own portal rendering of a registered EDR
|
||||
* invoice, so a printout and the page a customer reaches by scanning the QR read the same way.
|
||||
* Every totals row prints even at zero: a tax document states each figure rather than hiding the
|
||||
* nil ones.
|
||||
*/
|
||||
buildMorHtml(model: InvoiceDocumentModel, mor: MorDocumentDetails): string {
|
||||
const currency = model.currency;
|
||||
const party = (p: MorPartyDetails, sideAm: string, sideEn: string, tinAm: string, tinEn: string): string => `
|
||||
<table class="party">
|
||||
<tr><th class="side"><span class="am">${esc(sideAm)}</span><span class="en">${esc(sideEn)}</span></th>
|
||||
<td class="pname">${esc(p.name)}</td></tr>
|
||||
${morRow("ከተማ", "City/Town", p.city)}
|
||||
${morRow("ዞን / ክ/ከተማ", "Zone/Sub city", p.subCity)}
|
||||
${morRow("ወረዳ", "Woreda", p.woreda)}
|
||||
${morRow("ቀበሌ", "Kebele", p.kebele)}
|
||||
${morRow("የቤ/ቁ", "H/No", p.houseNo)}
|
||||
${morRow("የግብር ከፋይ መለያ ቁጥር", `${tinEn}'s TIN`, p.tin, tinAm)}
|
||||
${morRow("ንዑስ/ቁ", "Sub-TIN", p.subTin)}
|
||||
${morRow("ተ.እ.ታ ቁጥር", `${tinEn}'s VAT`, p.vatNumber)}
|
||||
</table>`;
|
||||
|
||||
const itemRows = model.lines
|
||||
.map(
|
||||
(item, i) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td>${esc(item.description)}</td>
|
||||
<td>${esc(item.nature ?? "-")}</td>
|
||||
<td>${esc(item.uom ?? "-")}</td>
|
||||
<td class="num">${esc(item.quantity ?? 0)}</td>
|
||||
<td class="num">${esc(amount2(item.unitRate))}</td>
|
||||
<td>${esc(item.taxCode ?? "-")}</td>
|
||||
<td class="num">${esc(amount2(item.excise ?? 0))}</td>
|
||||
<td class="num">${esc(amount2(item.discount ?? 0))}</td>
|
||||
<td class="num strong">${esc(amount2(item.amount))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const tax = mor.tax;
|
||||
const taxRows = tax
|
||||
? [
|
||||
totalRow("ድምር", `Total (${currency})`, amount2(tax.total)),
|
||||
totalRow("የቅናሽ መጠን", "Discount Amount", amount2(tax.discount)),
|
||||
totalRow("ታክስ የሚከፈልበት ድምር", "Taxable Total", amount2(tax.taxableTotal)),
|
||||
totalRow("ኤክሳይዝ ታክስ", "Excise Tax", amount2(tax.excise)),
|
||||
totalRow("ተ.እ.ታ የሚከፈልበት ድምር", "Total VAT Taxable Amount", amount2(tax.vatTaxableAmount)),
|
||||
totalRow("", tax.vatLabel, amount2(tax.vatAmount)),
|
||||
totalRow("ጠቅላላ የተያዘ መጠን", "Total Withheld Amount", amount2(tax.incomeWithholding)),
|
||||
totalRow("ጠቅላላ የተያዘ መጠን ተ.እ", "Total VAT Withheld Amount", amount2(tax.vatWithholding)),
|
||||
totalRow("ጠቅላላ ዋጋ ከታክስ ጋር", "Total including Tax", amount2(tax.totalIncludingTax), true),
|
||||
].join("")
|
||||
: "";
|
||||
|
||||
const wordsRow = tax
|
||||
? `<tr class="words"><td class="wl"><span class="am">ጠቅላላ ዋጋ ከታክስ ጋር (በፊደል)</span><span class="en">Total including Tax (in words)</span></td>
|
||||
<td class="wv">${esc(tax.amountInWords)}</td></tr>`
|
||||
: "";
|
||||
|
||||
const receipt = mor.receipt;
|
||||
const receiptBlock = receipt
|
||||
? `<table class="kv">
|
||||
${morRow("የክፍያ ምክንያት", "Payment Reason", receipt.reason)}
|
||||
${morRow("የተሰበሰበ መጠን", "Collected Amount", amount2(receipt.collectedAmount))}
|
||||
</table>
|
||||
<div class="sec">የደረሰኞች ዝርዝር / Invoices</div>
|
||||
<table class="items">
|
||||
<thead><tr>
|
||||
<th>IRN</th>
|
||||
<th>${esc("የክፍያ ሽፋን / Payment Coverage")}</th>
|
||||
<th class="num">${esc("ጠቅላላ ዋጋ / Total Amount")}</th>
|
||||
<th class="num">${esc("ቀሪ / Remaining Amount")}</th>
|
||||
<th class="num">${esc("የተከፈለ / Paid Amount")}</th>
|
||||
</tr></thead>
|
||||
<tbody>${receipt.invoices
|
||||
.map(
|
||||
(inv) => `<tr>
|
||||
<td class="irn">${esc(inv.irn)}</td>
|
||||
<td>${esc(inv.paymentCoverage)}</td>
|
||||
<td class="num">${esc(amount2(inv.totalAmount))}</td>
|
||||
<td class="num">${esc(amount2(inv.remainingAmount))}</td>
|
||||
<td class="num strong">${esc(amount2(inv.paidAmount))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}</tbody>
|
||||
</table>
|
||||
<div class="paid-total">ጠቅላላ የተከፈለ መጠን / Total Paid: <strong>${esc(amount2(receipt.collectedAmount))}</strong></div>`
|
||||
: "";
|
||||
|
||||
const wh = mor.withholding;
|
||||
const withholdingBlock = wh
|
||||
? `<table class="kv">
|
||||
${morRow("የደረሰኝ ቁጥር", "Receipt #", wh.receiptNumber)}
|
||||
${morRow("ቆጣሪ", "Counter", wh.counter)}
|
||||
${morRow("ምክንያት", "Reason", wh.reason)}
|
||||
${morRow("አይነት", "Type", wh.type)}
|
||||
</table>
|
||||
<table class="items">
|
||||
<thead><tr>
|
||||
<th>${esc("የደረሰኝ ቁጥር / Invoice Doc. Number")}</th>
|
||||
<th>${esc("የገንዘብ ዓይነት / Invoice Currency")}</th>
|
||||
<th class="num">${esc("ከታክስ በፊት ያለው ዋጋ / Pre Tax Amount")}</th>
|
||||
<th class="num">${esc("ተይዞ የቀረ መጠን / Withheld Amount")}</th>
|
||||
</tr></thead>
|
||||
<tbody><tr>
|
||||
<td>${esc(wh.receiptNumber)}</td>
|
||||
<td>${esc(wh.invoiceCurrency)}</td>
|
||||
<td class="num">${esc(amount2(wh.preTaxAmount))}</td>
|
||||
<td class="num strong">${esc(amount2(wh.withheldAmount))}</td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
<div class="paid-total">በገዥ ተይዞ የቀረ መጠን / Withheld Amount: <strong>${esc(amount2(wh.withheldAmount))}</strong></div>
|
||||
<table class="kv sys">
|
||||
${morRow("የስርዓት አይነት", "System Type", wh.systemType)}
|
||||
${morRow("የስርዓት ቁጥር", "System Number", wh.systemNumber)}
|
||||
</table>`
|
||||
: "";
|
||||
|
||||
const payment = mor.payment;
|
||||
const paymentBlock = payment
|
||||
? `<table class="pay">
|
||||
<tr>
|
||||
<td><span class="am">የክፍያ ሁኔታ</span><span class="en">Mode of Payment</span><strong>${esc(payment.mode)}</strong></td>
|
||||
<td><span class="am">አይነት</span><span class="en">Type/Method</span><strong>${esc(payment.typeMethod)}</strong></td>
|
||||
<td><span class="am">የተቀባይ ስምና ፊርማ</span><span class="en">Receiver Name & Signature</span><strong>${esc(payment.receiverName ?? "")}</strong></td>
|
||||
</tr>
|
||||
</table>`
|
||||
: "";
|
||||
|
||||
const approval = mor.approval;
|
||||
const approvalBlock = approval
|
||||
? `<div class="amend">INVOICE AMENDMENT AUTHORIZATION</div>
|
||||
<div class="amend-note">This amendment has been reviewed and approved in accordance with the company's approval matrix.</div>
|
||||
<table class="pay">
|
||||
<tr>
|
||||
<td><span class="am">የጠየቀው</span><span class="en">Requested By</span><strong>${esc(approval.requestedBy ?? "")}</strong></td>
|
||||
<td><span class="am">ያረጋገጠው</span><span class="en">Checked By</span><strong>${esc(approval.checkedBy ?? "")}</strong></td>
|
||||
<td><span class="am">ያፀደቀው</span><span class="en">Approved By</span><strong>${esc(approval.approvedBy ?? "")}</strong></td>
|
||||
</tr>
|
||||
</table>`
|
||||
: "";
|
||||
|
||||
const qrBlock = model.qrImageUrl
|
||||
? `<img class="qr" src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" />`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(mor.titleEn)} ${esc(model.documentNumber)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 10mm 9mm 12mm; }
|
||||
body { font-family: "Noto Sans Ethiopic", "Abyssinica SIL", Arial, sans-serif; color: #111827; margin: 0; font-size: 9.5px; }
|
||||
.doc { position: relative; }
|
||||
.am { display: block; font-size: 8px; color: #374151; }
|
||||
.en { display: block; font-size: 8.5px; color: #6b7280; }
|
||||
/* Header ------------------------------------------------------------- */
|
||||
.hdr { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #0f766e; padding-bottom: 6px; }
|
||||
.hdr-logo { max-height: 42px; max-width: 150px; object-fit: contain; display: block; margin-bottom: 4px; }
|
||||
.org { font-size: 12px; font-weight: 700; color: #0f172a; }
|
||||
.org-sub { font-size: 8.5px; color: #4b5563; line-height: 1.45; }
|
||||
.hdr-meta { text-align: right; font-size: 8.5px; }
|
||||
.hdr-meta div { margin-bottom: 2px; }
|
||||
.hdr-meta b { display: inline-block; min-width: 92px; text-align: right; color: #111827; }
|
||||
/* Title -------------------------------------------------------------- */
|
||||
.title { text-align: center; margin: 8px 0 4px; }
|
||||
.title .t-am { font-size: 12px; font-weight: 700; }
|
||||
.title .t-en { font-size: 11.5px; font-weight: 700; text-decoration: underline; }
|
||||
.title .t-type { font-size: 9.5px; color: #4b5563; margin-top: 2px; }
|
||||
/* Identity strip ----------------------------------------------------- */
|
||||
.ident { display: flex; justify-content: space-between; gap: 10px; margin: 6px 0 8px; }
|
||||
.ident table { border-collapse: collapse; }
|
||||
.ident td { padding: 1.5px 0; vertical-align: top; font-size: 8.5px; }
|
||||
.ident td.k { color: #6b7280; padding-right: 8px; white-space: nowrap; }
|
||||
.ident td.v { font-weight: 600; word-break: break-all; max-width: 330px; }
|
||||
.qr { width: 96px; height: 96px; flex: none; }
|
||||
/* Parties ------------------------------------------------------------ */
|
||||
.parties { display: flex; gap: 8px; }
|
||||
.parties > div { flex: 1; min-width: 0; }
|
||||
table.party { width: 100%; border-collapse: collapse; border: 1px solid #9ca3af; }
|
||||
table.party th, table.party td { border: 1px solid #d1d5db; padding: 2.5px 5px; text-align: left; vertical-align: top; font-weight: normal; }
|
||||
table.party th.side { width: 42%; background: #f9fafb; }
|
||||
table.party td.pname { font-weight: 700; font-size: 10px; }
|
||||
table.party td.pv { font-weight: 600; word-break: break-all; }
|
||||
/* Items -------------------------------------------------------------- */
|
||||
.sec { margin: 8px 0 3px; font-size: 9px; font-weight: 700; color: #374151; }
|
||||
table.items { width: 100%; border-collapse: collapse; margin-top: 6px; table-layout: fixed; }
|
||||
table.items th { background: #f3f4f6; font-size: 7.5px; color: #374151; }
|
||||
table.items th, table.items td { border: 1px solid #9ca3af; padding: 3px 4px; text-align: left; word-wrap: break-word; }
|
||||
table.items td { font-size: 8.5px; }
|
||||
table.items .num { text-align: right; }
|
||||
table.items .strong { font-weight: 700; }
|
||||
table.items td.irn { font-size: 7px; word-break: break-all; }
|
||||
/* Totals ------------------------------------------------------------- */
|
||||
table.totals { width: 100%; border-collapse: collapse; margin-top: -1px; }
|
||||
table.totals td { border: 1px solid #9ca3af; padding: 3px 6px; font-size: 8.5px; }
|
||||
table.totals td.tl { text-align: right; }
|
||||
table.totals td.tv { text-align: right; width: 130px; font-weight: 600; }
|
||||
table.totals tr.grand td { font-weight: 800; font-size: 10px; background: #f9fafb; }
|
||||
table.totals tr.words td { padding: 4px 6px; }
|
||||
table.totals td.wl { width: 240px; }
|
||||
table.totals td.wv { font-weight: 700; text-align: center; }
|
||||
/* Key/value + payment ------------------------------------------------ */
|
||||
table.kv { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
||||
table.kv td { border: 1px solid #9ca3af; padding: 3px 6px; font-size: 8.5px; }
|
||||
table.kv td.k { width: 220px; background: #f9fafb; }
|
||||
table.kv td.v { font-weight: 600; }
|
||||
table.kv.sys { margin-top: 10px; }
|
||||
.paid-total { text-align: right; font-size: 9px; margin-top: 4px; }
|
||||
table.pay { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
table.pay td { border: 1px solid #9ca3af; padding: 4px 6px; width: 33.33%; }
|
||||
table.pay strong { display: block; font-size: 10px; margin-top: 2px; }
|
||||
.amend { margin-top: 12px; text-align: center; font-weight: 800; font-size: 10px; color: #b91c1c; letter-spacing: .04em; }
|
||||
.amend-note { text-align: center; font-size: 8px; color: #6b7280; }
|
||||
/* Footer ------------------------------------------------------------- */
|
||||
.foot { margin-top: 14px; border-top: 1px solid #d1d5db; padding-top: 4px; display: flex; justify-content: space-between; font-size: 7.5px; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="hdr">
|
||||
<div>
|
||||
${model.logoImageUrl ? `<img class="hdr-logo" src="${esc(model.logoImageUrl)}" alt="EDR" />` : ""}
|
||||
<div class="org">${esc(mor.seller.name)}</div>
|
||||
<div class="org-sub">Ethio-Djibouti Railway S.C.</div>
|
||||
</div>
|
||||
<div class="hdr-meta">
|
||||
<div><span class="am">የደረሰኝ ቁጥር</span><span class="en">Document No</span><b>${esc(model.documentNumber)}</b></div>
|
||||
<div><span class="am">ቀን</span><span class="en">Date</span><b>${esc(formatEthiopianDate(model.issuedAt))}</b></div>
|
||||
<div><b>${esc(formatGregorianDate(model.issuedAt))}</b></div>
|
||||
<div><span class="am">ሰአት</span><span class="en">Time</span><b>${esc(formatDocumentTime(model.issuedAt))}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="title">
|
||||
<div class="t-am">${esc(mor.titleAm)}</div>
|
||||
<div class="t-en">${esc(mor.titleEn)}</div>
|
||||
${mor.saleType ? `<div class="t-type">የሽያጭ አይነት (${esc(mor.saleType)})</div>` : ""}
|
||||
</div>
|
||||
|
||||
<div class="ident">
|
||||
<table>
|
||||
${mor.irn ? `<tr><td class="k">IRN</td><td class="v">${esc(mor.irn)}</td></tr>` : ""}
|
||||
${mor.receipt ? `<tr><td class="k">RRN</td><td class="v">${esc(mor.receipt.rrn)}</td></tr>` : ""}
|
||||
${mor.systemNumber ? `<tr><td class="k">System Number</td><td class="v">${esc(mor.systemNumber)}</td></tr>` : ""}
|
||||
${mor.referenceNumber ? `<tr><td class="k">Reference Number</td><td class="v">${esc(mor.referenceNumber)}</td></tr>` : ""}
|
||||
${mor.relatedDocumentIrn ? `<tr><td class="k">Related Document</td><td class="v">${esc(mor.relatedDocumentIrn)}</td></tr>` : ""}
|
||||
</table>
|
||||
${qrBlock}
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div>${party(mor.seller, "ከ", "From", "የሻጭ", "Seller")}</div>
|
||||
<div>${party(mor.buyer, "ለ", "To", "የገዢ", "Customer")}</div>
|
||||
</div>
|
||||
|
||||
${withholdingBlock}
|
||||
${receiptBlock}
|
||||
|
||||
${
|
||||
model.lines.length > 0 && !mor.withholding
|
||||
? `<table class="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:4%">${esc("ተ/ቁ")}<br/>No.</th>
|
||||
<th style="width:24%">${esc("የዕቃው / አገልግሎት አይነት")}<br/>Description</th>
|
||||
<th style="width:9%">${esc("ምድብ")}<br/>Nature</th>
|
||||
<th style="width:7%">${esc("መለኪያ")}<br/>UoM</th>
|
||||
<th style="width:7%" class="num">${esc("ብዛት")}<br/>Qty</th>
|
||||
<th style="width:12%" class="num">${esc("የአንዱ ዋጋ")}<br/>Unit Price</th>
|
||||
<th style="width:9%">${esc("ታክስ ኮድ")}<br/>Tax Code</th>
|
||||
<th style="width:9%" class="num">${esc("ኤክሳይዝ")}<br/>Excise</th>
|
||||
<th style="width:9%" class="num">${esc("ቅናሽ")}<br/>Discount</th>
|
||||
<th style="width:14%" class="num">${esc("ጠቅላላ ዋጋ")}<br/>Total Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${itemRows}</tbody>
|
||||
</table>`
|
||||
: ""
|
||||
}
|
||||
|
||||
${tax ? `<table class="totals">${taxRows}${wordsRow}</table>` : ""}
|
||||
${paymentBlock}
|
||||
${approvalBlock}
|
||||
|
||||
<div class="foot">
|
||||
<div>Ethio-Djibouti Railway S.C. — ${esc(mor.titleEn)}</div>
|
||||
<div>Page 1 of 1 · Printed ${esc(formatGregorianDate(new Date()))} ${esc(formatDocumentTime(new Date()))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
safeFilename(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
||||
}
|
||||
}
|
||||
|
||||
/** One bilingual label/value row inside a party or key-value table. */
|
||||
function morRow(am: string, en: string, value: unknown, amOverride?: string): string {
|
||||
return `<tr><td class="k"><span class="am">${esc(amOverride ? `${amOverride} ${am}` : am)}</span><span class="en">${esc(en)}</span></td><td class="v pv">${esc(
|
||||
value === null || value === undefined || value === "" ? "N/A" : value,
|
||||
)}</td></tr>`;
|
||||
}
|
||||
|
||||
/** One row of the Ministry's totals block. */
|
||||
function totalRow(am: string, en: string, value: string, grand = false): string {
|
||||
return `<tr class="${grand ? "grand" : ""}"><td class="tl">${esc(am ? `${am} / ${en}` : en)}</td><td class="tv">${esc(value)}</td></tr>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
amountInWords,
|
||||
formatEthiopianDate,
|
||||
formatGregorianDate,
|
||||
gregorianToEthiopian,
|
||||
numberToWords,
|
||||
} from "./mor-document.util";
|
||||
|
||||
describe("gregorianToEthiopian", () => {
|
||||
it("matches the MoR portal's own rendering of a registered EDR invoice", () => {
|
||||
// portal.mor.gov.et printed `25-12-2018 ዓ/ም` beside `31-08-2026 G.C` for INV document no. 3.
|
||||
expect(gregorianToEthiopian(new Date(2026, 7, 31))).toEqual({ year: 2018, month: 12, day: 25 });
|
||||
expect(formatEthiopianDate(new Date(2026, 7, 31))).toBe("25-12-2018 ዓ/ም");
|
||||
expect(formatGregorianDate(new Date(2026, 7, 31))).toBe("31-08-2026 G.C");
|
||||
});
|
||||
|
||||
it("rolls the year on Ethiopian new year, not on the Gregorian one", () => {
|
||||
// 11 Sep 2026 is 1 መስከረም 2019; the day before is still 2018.
|
||||
expect(gregorianToEthiopian(new Date(2026, 8, 10))).toMatchObject({ year: 2018, month: 13 });
|
||||
expect(gregorianToEthiopian(new Date(2026, 8, 11))).toEqual({ year: 2019, month: 1, day: 1 });
|
||||
});
|
||||
|
||||
it("returns a placeholder rather than throwing on a missing date", () => {
|
||||
expect(formatEthiopianDate(null)).toBe("-");
|
||||
expect(formatGregorianDate(undefined)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("amountInWords", () => {
|
||||
it("spells an amount with cents the way the reference tax invoice does", () => {
|
||||
// WISCOM's certified printout: 3,759.93 -> "three thousand seven hundred and fifty-nine Birr
|
||||
// and ninety-three Cents".
|
||||
expect(amountInWords(3759.93)).toBe(
|
||||
"Three thousand seven hundred and fifty-nine Birr and ninety-three Cents",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the 'and' inside a scale group, as the reference printouts do", () => {
|
||||
// 407,422.98 on the reference credit-sales invoice reads "Four Hundred And Seven Thousand Four
|
||||
// Hundred And Twenty-Two Birr and Ninety-Eight Cents". Note the MoR portal itself uses the
|
||||
// other convention ("nine hundred four thousand"); the printed document follows the reference.
|
||||
expect(amountInWords(407422.98)).toBe(
|
||||
"Four hundred and seven thousand four hundred and twenty-two Birr and ninety-eight Cents",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the cents clause on a whole amount", () => {
|
||||
expect(amountInWords(880)).toBe("Eight hundred and eighty Birr");
|
||||
});
|
||||
|
||||
it("carries rounded cents into the Birr instead of printing 100 Cents", () => {
|
||||
expect(amountInWords(9.999)).toBe("Ten Birr");
|
||||
});
|
||||
|
||||
it("handles zero and sub-Birr amounts", () => {
|
||||
expect(amountInWords(0)).toBe("Zero Birr");
|
||||
expect(amountInWords(0.5)).toBe("Zero Birr and fifty Cents");
|
||||
});
|
||||
|
||||
it("spells the scale words", () => {
|
||||
expect(numberToWords(1_000_000)).toBe("one million");
|
||||
expect(numberToWords(21)).toBe("twenty-one");
|
||||
expect(numberToWords(115)).toBe("one hundred and fifteen");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Presentation helpers for MoR EIMS tax documents (ADD-P001 print layout).
|
||||
*
|
||||
* The layout these serve is modelled on the Ministry's own portal rendering of a registered EDR
|
||||
* invoice (portal.mor.gov.et), which is the authoritative source for the bilingual field labels —
|
||||
* not on any one vendor's template.
|
||||
*/
|
||||
|
||||
/** Ethiopian month names, index 0 = መስከረም. */
|
||||
const ETHIOPIAN_MONTHS = [
|
||||
"መስከረም",
|
||||
"ጥቅምት",
|
||||
"ኅዳር",
|
||||
"ታኅሣሥ",
|
||||
"ጥር",
|
||||
"የካቲት",
|
||||
"መጋቢት",
|
||||
"ሚያዝያ",
|
||||
"ግንቦት",
|
||||
"ሰኔ",
|
||||
"ሐምሌ",
|
||||
"ነሐሴ",
|
||||
"ጳጉሜ",
|
||||
] as const;
|
||||
|
||||
export interface EthiopianDate {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gregorian → Ethiopian, via Julian Day Number.
|
||||
*
|
||||
* JDN rather than day-of-year arithmetic because the Ethiopian new year drifts against September
|
||||
* 11/12 on the Gregorian leap cycle; JDN is the same conversion the passenger portal already uses.
|
||||
*/
|
||||
export function gregorianToEthiopian(date: Date): EthiopianDate {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
const a = Math.floor((14 - month) / 12);
|
||||
const y = year + 4800 - a;
|
||||
const m = month + 12 * a - 3;
|
||||
const jdn =
|
||||
day +
|
||||
Math.floor((153 * m + 2) / 5) +
|
||||
365 * y +
|
||||
Math.floor(y / 4) -
|
||||
Math.floor(y / 100) +
|
||||
Math.floor(y / 400) -
|
||||
32045;
|
||||
|
||||
// 1723856 is the JDN of 1 መስከረም 1 E.C.
|
||||
const r = (jdn - 1723856) % 1461;
|
||||
const n = (r % 365) + 365 * Math.floor(r / 1460);
|
||||
const ethYear = 4 * Math.floor((jdn - 1723856) / 1461) + Math.floor(r / 365) - Math.floor(r / 1460);
|
||||
const ethMonth = Math.floor(n / 30) + 1;
|
||||
const ethDay = (n % 30) + 1;
|
||||
|
||||
return { year: ethYear, month: ethMonth, day: ethDay };
|
||||
}
|
||||
|
||||
/** `25-12-2018 ዓ/ም` — the numeric form the MoR portal prints beside the Gregorian date. */
|
||||
export function formatEthiopianDate(value: Date | string | null | undefined): string {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return "-";
|
||||
const { year, month, day } = gregorianToEthiopian(date);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(day)}-${pad(month)}-${year} ዓ/ም`;
|
||||
}
|
||||
|
||||
/** `ሐምሌ 25, 2018` — the long form, when a document has room for it. */
|
||||
export function formatEthiopianDateLong(value: Date | string | null | undefined): string {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return "-";
|
||||
const { year, month, day } = gregorianToEthiopian(date);
|
||||
return `${ETHIOPIAN_MONTHS[month - 1] ?? ""} ${day}, ${year}`;
|
||||
}
|
||||
|
||||
/** `31-08-2026 G.C` — Gregorian, labelled the way the MoR portal labels it. */
|
||||
export function formatGregorianDate(value: Date | string | null | undefined): string {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return "-";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} G.C`;
|
||||
}
|
||||
|
||||
/** `10:58:30`, 24-hour, to match the portal's `ሰአት/Time` row. */
|
||||
export function formatDocumentTime(value: Date | string | null | undefined): string {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return "-";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
const ONES = [
|
||||
"",
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
"four",
|
||||
"five",
|
||||
"six",
|
||||
"seven",
|
||||
"eight",
|
||||
"nine",
|
||||
"ten",
|
||||
"eleven",
|
||||
"twelve",
|
||||
"thirteen",
|
||||
"fourteen",
|
||||
"fifteen",
|
||||
"sixteen",
|
||||
"seventeen",
|
||||
"eighteen",
|
||||
"nineteen",
|
||||
];
|
||||
const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
|
||||
const SCALES: [number, string][] = [
|
||||
[1_000_000_000, "billion"],
|
||||
[1_000_000, "million"],
|
||||
[1_000, "thousand"],
|
||||
];
|
||||
|
||||
/** 0-999 in words. */
|
||||
function underThousand(value: number): string {
|
||||
if (value < 20) return ONES[value];
|
||||
if (value < 100) {
|
||||
const rest = value % 10;
|
||||
return TENS[Math.floor(value / 10)] + (rest ? `-${ONES[rest]}` : "");
|
||||
}
|
||||
const rest = value % 100;
|
||||
return `${ONES[Math.floor(value / 100)]} hundred${rest ? ` and ${underThousand(rest)}` : ""}`;
|
||||
}
|
||||
|
||||
/** Whole number in words. Returns "zero" for 0. */
|
||||
export function numberToWords(value: number): string {
|
||||
const n = Math.floor(Math.abs(value));
|
||||
if (n === 0) return "zero";
|
||||
|
||||
const parts: string[] = [];
|
||||
let remaining = n;
|
||||
for (const [scale, name] of SCALES) {
|
||||
const count = Math.floor(remaining / scale);
|
||||
if (count > 0) {
|
||||
parts.push(`${numberToWords(count)} ${name}`);
|
||||
remaining %= scale;
|
||||
}
|
||||
}
|
||||
if (remaining > 0) {
|
||||
// "and" only before a trailing sub-hundred group, matching how the amount reads aloud
|
||||
// ("three thousand seven hundred and fifty-nine", not "three thousand and seven hundred").
|
||||
parts.push(parts.length > 0 && remaining < 100 ? `and ${underThousand(remaining)}` : underThousand(remaining));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* `Total including Tax (in words)` — the legally required spelling-out of the payable amount.
|
||||
*
|
||||
* Computed here rather than read back from MoR: the Ministry renders its own copy on the portal,
|
||||
* but returns nothing carrying it on `/v1/register`, and the line has to print on a document that
|
||||
* may not be registered yet.
|
||||
*/
|
||||
export function amountInWords(value: number, currencyLabel = "Birr", fractionLabel = "Cents"): string {
|
||||
const amount = Number.isFinite(value) ? Math.abs(value) : 0;
|
||||
const birr = Math.floor(amount);
|
||||
// Round the remainder rather than truncate: 0.155 must read as sixteen cents, not fifteen.
|
||||
const cents = Math.round((amount - birr) * 100);
|
||||
// Rounding cents can carry into the next Birr (x.999 -> 100 cents).
|
||||
const [wholeBirr, wholeCents] = cents === 100 ? [birr + 1, 0] : [birr, cents];
|
||||
|
||||
const head = `${numberToWords(wholeBirr)} ${currencyLabel}`;
|
||||
const text = wholeCents > 0 ? `${head} and ${numberToWords(wholeCents)} ${fractionLabel}` : head;
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => {
|
||||
search: "INV-2026",
|
||||
statuses: "PENDING,OVERDUE",
|
||||
sources: "booking,warehouse",
|
||||
types: "PREPAID,WAGON_CANCEL_FEE",
|
||||
eimsStatuses: "NOT_SUBMITTED",
|
||||
currency: "etb",
|
||||
issuedFrom: "2026-08-01T00:00:00.000Z",
|
||||
@@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => {
|
||||
expect(errors).toEqual([]);
|
||||
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
|
||||
expect(dto.sources).toEqual(["booking", "warehouse"]);
|
||||
expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]);
|
||||
expect(dto.currency).toBe("ETB");
|
||||
expect(dto.minAmount).toBe(100);
|
||||
expect(dto.hasBalance).toBe(true);
|
||||
|
||||
@@ -89,6 +89,18 @@ export class FilterInvoiceDto {
|
||||
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
|
||||
sources?: Freight.InvoiceSource[];
|
||||
|
||||
/**
|
||||
* What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form
|
||||
* like `paymentMethods`: every billing source mints its own `type` string, so
|
||||
* an `IsIn` here would silently drop a real value.
|
||||
*/
|
||||
@ApiPropertyOptional({ isArray: true, example: ["PREPAID"] })
|
||||
@IsOptional()
|
||||
@Transform(csv)
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
types?: string[];
|
||||
|
||||
/** MoR filing state — Finance's "what still needs registering" cut. */
|
||||
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { settlementReferences } from "./invoice-settlement.util";
|
||||
|
||||
describe("settlementReferences", () => {
|
||||
it("returns the provider reference recorded on the invoice ledger", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "FT26082700123" }],
|
||||
}),
|
||||
).toBe("FT26082700123");
|
||||
});
|
||||
|
||||
it("reads the linked gateway payment row when the ledger has no reference", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: null }],
|
||||
payment: { transactionId: "TB998877" },
|
||||
}),
|
||||
).toBe("TB998877");
|
||||
});
|
||||
|
||||
it("does not repeat a reference that both sources carry", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "FT26082700123" }],
|
||||
payment: { transactionId: "FT26082700123" },
|
||||
}),
|
||||
).toBe("FT26082700123");
|
||||
});
|
||||
|
||||
it("lists every leg of a partially-then-fully paid invoice, oldest first", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "SLIP-001" }, { reference: "FT26082700123" }],
|
||||
}),
|
||||
).toBe("SLIP-001, FT26082700123");
|
||||
});
|
||||
|
||||
it("drops the internal intent id the gateway path falls back to", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "3f8a1c2e-9b4d-4a71-8c6e-2d5f7a9b1c30" }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is null for an unpaid invoice", () => {
|
||||
expect(settlementReferences({ payments: [] })).toBeNull();
|
||||
expect(settlementReferences({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -74,3 +74,44 @@ export const INVOICE_PAYMENT_METHODS = [
|
||||
/** Settled at a gateway whose provider row is no longer linked. */
|
||||
"GATEWAY",
|
||||
] as const;
|
||||
|
||||
/** Anything shaped enough to read settlement references off. */
|
||||
interface SettlementReferenceSource {
|
||||
payments?: Array<{ reference?: string | null }> | null;
|
||||
payment?: { transactionId?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settlement reference is the PROVIDER's own transaction number, never ours.
|
||||
* The gateway path falls back to the intent id when a provider returns no txn
|
||||
* ref (`markInvoiceAsPaid`: `providerTxnId ?? paymentId`), and that id is a
|
||||
* uuid — an internal correlation key that means nothing to a payer holding a
|
||||
* bank slip, so it is dropped rather than printed. No provider's reference is
|
||||
* uuid-shaped: CBE sends `FT…`, telebirr/ebirr/waafi send digit strings.
|
||||
*/
|
||||
const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Every provider transaction reference recorded against an invoice, oldest
|
||||
* first, joined for display — CBE's `FT…`, telebirr's receipt number, or the
|
||||
* bank-slip number a teller typed into a manual settlement. Null when nothing
|
||||
* identifiable was recorded.
|
||||
*
|
||||
* Reads BOTH sources because neither alone is complete: the invoice's own
|
||||
* ledger is the only record of manual settlements and of each leg of a
|
||||
* partially-paid invoice, while the linked `freight.payments` row is the only
|
||||
* place a provider txn id lands when it arrives after settlement (a webhook
|
||||
* that stamps `transactionId` on an already-settled intent). Deduped, since
|
||||
* the ordinary gateway path writes the same value to both.
|
||||
*/
|
||||
export function settlementReferences(
|
||||
invoice: SettlementReferenceSource,
|
||||
): string | null {
|
||||
const refs = [
|
||||
...(invoice.payments ?? []).map((p) => p.reference),
|
||||
invoice.payment?.transactionId,
|
||||
].filter(
|
||||
(ref): ref is string => Boolean(ref) && !INTERNAL_ID.test(ref as string),
|
||||
);
|
||||
return [...new Set(refs)].join(", ") || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
CARGO_TYPE_SUBTREE_SQL,
|
||||
bookingContainerCountSql,
|
||||
bookingContainerVgmSql,
|
||||
bookingContentMatchSql,
|
||||
bookingContentSql,
|
||||
bookingHasContainerTypeSql,
|
||||
bookingRequestedCargoSql,
|
||||
bookingRequestedContainerCountSql,
|
||||
} from './booking-content.sql';
|
||||
|
||||
describe('bookingContentSql', () => {
|
||||
const sql = bookingContentSql('b');
|
||||
|
||||
it('prefers the container lines, since container bookings carry no description', () => {
|
||||
expect(sql.indexOf('freight.booking_container')).toBeLessThan(
|
||||
sql.indexOf('freight.cargo_types'),
|
||||
);
|
||||
expect(sql).toContain('freight.container_types');
|
||||
expect(sql).toContain('bc.deleted_at IS NULL');
|
||||
});
|
||||
|
||||
it('falls back to commodity, then to the free-text description', () => {
|
||||
expect(sql.indexOf('cgt.cargo_type_name')).toBeLessThan(
|
||||
sql.indexOf('b.cargo_free_text'),
|
||||
);
|
||||
});
|
||||
|
||||
// An empty string is not a missing value to COALESCE — without NULLIF a blank
|
||||
// description would win over the commodity behind it.
|
||||
it('treats an empty string as absent at every level', () => {
|
||||
expect(sql.match(/NULLIF/g)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('rewrites every reference when embedded under another alias', () => {
|
||||
expect(bookingContentSql('bk')).not.toMatch(/\bb\.(cargo|id)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CARGO_TYPE_SUBTREE_SQL', () => {
|
||||
// The filter offers groups, not just leaves, so picking "Bulk" has to reach
|
||||
// commodities at any depth beneath it — two levels today, more tomorrow.
|
||||
it('walks the tree recursively rather than one level of children', () => {
|
||||
expect(CARGO_TYPE_SUBTREE_SQL).toContain('WITH RECURSIVE');
|
||||
expect(CARGO_TYPE_SUBTREE_SQL).toContain('c.parent_group_id = sub.id');
|
||||
});
|
||||
|
||||
it('includes the picked node itself, so a leaf still matches exactly', () => {
|
||||
expect(CARGO_TYPE_SUBTREE_SQL).toContain('WHERE id = :cargoTypeId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingContentMatchSql', () => {
|
||||
const sql = bookingContentMatchSql('b');
|
||||
|
||||
it('searches all three places content can live', () => {
|
||||
expect(sql).toContain('b.cargo_free_text ILIKE :cargoText');
|
||||
expect(sql).toContain('cgt.cargo_type_name ILIKE :cargoText');
|
||||
expect(sql).toContain('cnt.code ILIKE :cargoText');
|
||||
});
|
||||
|
||||
// Anything but OR would make the text box match nothing for whole freight
|
||||
// types — a container booking has no commodity, a bulk one has no container.
|
||||
it('ORs them, and stays one parenthesised term for andWhere', () => {
|
||||
expect(sql).not.toContain(' AND :cargoText');
|
||||
expect(sql.startsWith('(')).toBe(true);
|
||||
expect(sql.trimEnd().endsWith(')')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingContainerCountSql', () => {
|
||||
// booking_container is one row per LINE carrying a quantity, so counting rows
|
||||
// would report a 54-container booking as 1.
|
||||
it('sums the line quantities rather than counting lines', () => {
|
||||
expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)');
|
||||
expect(bookingContainerCountSql('b')).not.toContain('COUNT(');
|
||||
});
|
||||
|
||||
it('counts every type by default and one type when scoped', () => {
|
||||
expect(bookingContainerCountSql('b')).not.toContain('container_type_id');
|
||||
expect(bookingContainerCountSql('b', true)).toContain(
|
||||
'bc.container_type_id = :containerTypeId',
|
||||
);
|
||||
});
|
||||
|
||||
it('is 0, never NULL, so a bound comparison still decides', () => {
|
||||
expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)');
|
||||
});
|
||||
|
||||
it('ignores soft-deleted lines', () => {
|
||||
expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL');
|
||||
expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL');
|
||||
});
|
||||
|
||||
it('rewrites the booking reference under another alias', () => {
|
||||
expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id');
|
||||
expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingContainerVgmSql', () => {
|
||||
// The whole point: b.cargo_total_weight_vgm is 0 for portal container
|
||||
// bookings, so the weight has to come off the lines.
|
||||
it('reads the lines, never the booking-level column', () => {
|
||||
const sql = bookingContainerVgmSql('b');
|
||||
expect(sql).toContain('SUM(bc.total_vgm_tons)');
|
||||
expect(sql).not.toContain('cargo_total_weight_vgm');
|
||||
expect(sql).toContain('bc.deleted_at IS NULL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requested (shipment-request) cargo', () => {
|
||||
const cargo = bookingRequestedCargoSql('b');
|
||||
const count = bookingRequestedContainerCountSql('b');
|
||||
|
||||
it('reads the request, never the booking or its container lines', () => {
|
||||
for (const sql of [cargo, count]) {
|
||||
expect(sql).toContain('freight.booking_requests br');
|
||||
expect(sql).toContain('br.created_booking_id = b.id');
|
||||
expect(sql).not.toContain('freight.booking_container');
|
||||
}
|
||||
});
|
||||
|
||||
// requested_lines is a free-form jsonb column; jsonb_array_elements throws on
|
||||
// a non-array, which would 500 the whole list for one malformed row.
|
||||
it('survives a requested_lines with no container array', () => {
|
||||
for (const sql of [cargo, count]) {
|
||||
expect(sql).toContain("jsonb_typeof(br.requested_lines->'containers') = 'array'");
|
||||
expect(sql).toContain("ELSE '[]'::jsonb");
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the bulk shape too, not only containers', () => {
|
||||
expect(cargo).toContain("'bulk'->>'cargoWeightTons'");
|
||||
expect(cargo).toContain("'bulk'->>'itemCount'");
|
||||
});
|
||||
|
||||
it('counts 0 rather than NULL when no request exists', () => {
|
||||
expect(count).toContain("COALESCE(SUM((l->>'quantity')::int), 0)");
|
||||
});
|
||||
|
||||
it('ignores soft-deleted requests', () => {
|
||||
expect(cargo).toContain('br.deleted_at IS NULL');
|
||||
expect(count).toContain('br.deleted_at IS NULL');
|
||||
});
|
||||
});
|
||||
148
apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts
Normal file
148
apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* What the customer said is IN the booking, per freight type — the list
|
||||
* filter, the summary and the export all read this one expression so the
|
||||
* column, the pill and the sheet can never disagree.
|
||||
*
|
||||
* BULK the commodity picked from the cargo tree (`cargo_types`), falling
|
||||
* back to the free-text description for a bare group or a legacy row
|
||||
* that has no commodity.
|
||||
* CONTAINER the wizard asks for no description at all — VGM and contents are
|
||||
* captured later in operations — so the closest thing to the
|
||||
* customer's own words is the container lines they entered:
|
||||
* "2 × 40FT, 1 × 20FT".
|
||||
*
|
||||
* Containers are checked FIRST: a container booking has no `cargo_type_id`
|
||||
* (the API rejects one), so the order only matters for a mixed legacy row,
|
||||
* where the physical lines are the better answer.
|
||||
*/
|
||||
export function bookingContentSql(alias = 'b'): string {
|
||||
return `COALESCE(
|
||||
NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', '
|
||||
ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code)
|
||||
FROM freight.booking_container bc
|
||||
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
|
||||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''),
|
||||
NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt
|
||||
WHERE cgt.id = ${alias}.cargo_type_id), ''),
|
||||
NULLIF(${alias}.cargo_free_text, ''))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter
|
||||
* matches every commodity beneath it — the same group→commodity drill-down the
|
||||
* booking wizard offers, read back. Recursive because `cargo_types` is an
|
||||
* arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels.
|
||||
*/
|
||||
export const CARGO_TYPE_SUBTREE_SQL = `(
|
||||
WITH RECURSIVE sub AS (
|
||||
SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId
|
||||
UNION ALL
|
||||
SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id
|
||||
)
|
||||
SELECT id FROM sub)`;
|
||||
|
||||
/**
|
||||
* Contains-match over every part of the content a customer can type or pick:
|
||||
* their own description, the commodity's name, and the container types on the
|
||||
* booking. Bind `:cargoText` already wrapped in `%`.
|
||||
*/
|
||||
export function bookingContentMatchSql(alias = 'b'): string {
|
||||
return `(${alias}.cargo_free_text ILIKE :cargoText
|
||||
OR EXISTS (SELECT 1 FROM freight.cargo_types cgt
|
||||
WHERE cgt.id = ${alias}.cargo_type_id
|
||||
AND cgt.cargo_type_name ILIKE :cargoText)
|
||||
OR EXISTS (SELECT 1 FROM freight.booking_container bc
|
||||
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
|
||||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL
|
||||
AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Containers on a booking, as a count of physical boxes — `booking_container`
|
||||
* is one row PER LINE with a `quantity`, not one row per box, so this sums the
|
||||
* quantity rather than counting rows.
|
||||
*
|
||||
* `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one
|
||||
* number filter answer both "10 containers in total" and "10 forty-footers":
|
||||
* the count filter reads the container-type filter when one is set, and counts
|
||||
* every type when it is not.
|
||||
*/
|
||||
export function bookingContainerCountSql(alias = 'b', scopedToType = false): string {
|
||||
return `(SELECT COALESCE(SUM(bc.quantity), 0)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id
|
||||
AND bc.deleted_at IS NULL${
|
||||
scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : ''
|
||||
})`;
|
||||
}
|
||||
|
||||
/** Bookings carrying at least one line of `:containerTypeId`. */
|
||||
export function bookingHasContainerTypeSql(alias = 'b'): string {
|
||||
return `EXISTS (SELECT 1 FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id = :containerTypeId)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container VGM on a booking, in tons — the sum of the per-line totals.
|
||||
*
|
||||
* NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for
|
||||
* container freight (VGM is captured per container, later, in operations), so
|
||||
* reading the booking-level column showed every portal container booking as
|
||||
* weighing nothing. Same reason `bookingTonsSql` falls through to these lines.
|
||||
*/
|
||||
export function bookingContainerVgmSql(alias = 'b'): string {
|
||||
return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id
|
||||
AND bc.deleted_at IS NULL)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo the customer declared on the SHIPMENT REQUEST behind a booking, which
|
||||
* is not the same fact as cargo on the booking itself.
|
||||
*
|
||||
* On a GENERAL + customs contract the customer cannot book directly: they
|
||||
* submit a request (day + quantities), and `initiateForShipmentRequest` opens a
|
||||
* BARE instance from it — "the request itself carries the quantities; the
|
||||
* instance carries none". So between initiation and `completeUnderContract` the
|
||||
* booking legitimately holds no cargo while the customer's declared quantities
|
||||
* sit on `booking_requests.requested_lines`.
|
||||
*
|
||||
* Kept in its own column rather than folded into the real container count: a
|
||||
* declared 2 × 20FT is a request, not two boxes on a booking, and merging the
|
||||
* two would overstate operational totals.
|
||||
*/
|
||||
const REQUESTED_CONTAINER_LINES = `jsonb_array_elements(
|
||||
CASE WHEN jsonb_typeof(br.requested_lines->'containers') = 'array'
|
||||
THEN br.requested_lines->'containers'
|
||||
ELSE '[]'::jsonb END)`;
|
||||
|
||||
/** Human-readable declared cargo: "2 × 20FT", "12 t", "40 items". */
|
||||
export function bookingRequestedCargoSql(alias = 'b'): string {
|
||||
return `(SELECT COALESCE(
|
||||
(SELECT string_agg((l->>'quantity') || ' × ' || upper(l->>'containerSize'), ', '
|
||||
ORDER BY l->>'containerSize')
|
||||
FROM ${REQUESTED_CONTAINER_LINES} AS l),
|
||||
NULLIF(br.requested_lines->'bulk'->>'cargoWeightTons', '') || ' t',
|
||||
NULLIF(br.requested_lines->'bulk'->>'itemCount', '') || ' items')
|
||||
FROM freight.booking_requests br
|
||||
WHERE br.created_booking_id = ${alias}.id
|
||||
AND br.deleted_at IS NULL
|
||||
ORDER BY br.created_at DESC
|
||||
LIMIT 1)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boxes declared on the shipment request. Pairs with the real container count:
|
||||
* `Containers = 0` AND `Requested containers >= 1` is exactly the set awaiting
|
||||
* completion.
|
||||
*/
|
||||
export function bookingRequestedContainerCountSql(alias = 'b'): string {
|
||||
return `(SELECT COALESCE(SUM((l->>'quantity')::int), 0)
|
||||
FROM freight.booking_requests br
|
||||
CROSS JOIN LATERAL ${REQUESTED_CONTAINER_LINES} AS l
|
||||
WHERE br.created_booking_id = ${alias}.id
|
||||
AND br.deleted_at IS NULL)`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { bookingTonsSql } from './booking-tons.sql';
|
||||
|
||||
describe('bookingTonsSql', () => {
|
||||
const sql = bookingTonsSql('b');
|
||||
|
||||
// The regression this exists for: a plain COALESCE stops at the portal's
|
||||
// literal 0 for container bookings and reports them as weighing nothing.
|
||||
it('treats a stored 0 as "no figure" on both booking-level columns', () => {
|
||||
expect(sql).toContain('NULLIF(b.bulk_total_weight_tons, 0)');
|
||||
expect(sql).toContain('NULLIF(b.cargo_total_weight_vgm, 0)');
|
||||
});
|
||||
|
||||
it('falls back to the per-line container VGM, excluding soft-deleted lines', () => {
|
||||
expect(sql).toContain('SUM(bc.total_vgm_tons)');
|
||||
expect(sql).toContain('freight.booking_container bc');
|
||||
expect(sql).toContain('bc.booking_id = b.id');
|
||||
expect(sql).toContain('bc.deleted_at IS NULL');
|
||||
});
|
||||
|
||||
it('never returns NULL, so callers may SUM it directly', () => {
|
||||
expect(sql.trimEnd().endsWith('0)')).toBe(true);
|
||||
});
|
||||
|
||||
it('rewrites every reference when embedded under another alias', () => {
|
||||
const aliased = bookingTonsSql('bk');
|
||||
expect(aliased).not.toMatch(/\bb\./);
|
||||
expect(aliased).toContain('bk.cargo_total_weight_vgm');
|
||||
expect(aliased).toContain('bc.booking_id = bk.id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* SQL mirror of `bookingCargoTons()` (train-scheduling/train-capacity.util.ts).
|
||||
*
|
||||
* Three storage conventions share `bookings.cargo_total_weight_vgm`:
|
||||
* - BULK PER_TON — the column holds tons.
|
||||
* - BULK PER_ITEM — the column holds an ITEM COUNT; the tons are in
|
||||
* `bulk_total_weight_tons`.
|
||||
* - CONTAINER — the portal wizard captures VGM per line, not per booking,
|
||||
* and sends 0 (portal NewBookingPage: "containers carry NO weight at the
|
||||
* wizard"). The tons live in `booking_container.total_vgm_tons`. The
|
||||
* backoffice wizard does store a booking-level total, so both shapes exist
|
||||
* in the same table.
|
||||
*
|
||||
* Hence NULLIF on both columns: a plain
|
||||
* `COALESCE(bulk_total_weight_tons, cargo_total_weight_vgm)` stops at the
|
||||
* portal's 0 — COALESCE falls through on NULL, never on 0 — and every
|
||||
* portal-created container booking reads as 0 tons in exports and reports.
|
||||
*/
|
||||
export function bookingTonsSql(alias = 'b'): string {
|
||||
return `COALESCE(
|
||||
NULLIF(${alias}.bulk_total_weight_tons, 0),
|
||||
NULLIF(${alias}.cargo_total_weight_vgm, 0),
|
||||
(SELECT SUM(bc.total_vgm_tons) FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL),
|
||||
0)`;
|
||||
}
|
||||
@@ -370,7 +370,21 @@ export class BookingTransitionService {
|
||||
|
||||
async startTransit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["PAID"]);
|
||||
// Paid is read from the PAYMENT status only; the booking status merely
|
||||
// guards against re-entering transit from a later stage.
|
||||
if (booking.paymentStatus !== "PAID") {
|
||||
throw new ConflictException(
|
||||
`Booking must be paid before it can start transit (payment status "${booking.paymentStatus ?? "PENDING"}")`,
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, [
|
||||
"PAID",
|
||||
"FULLY_EXECUTED",
|
||||
"PNR_GENERATED",
|
||||
"WAGON_ASSIGNED",
|
||||
"READY_FOR_ASSIGNMENT",
|
||||
"APPROVED",
|
||||
]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "IN_TRANSIT",
|
||||
@@ -1739,6 +1753,7 @@ export class BookingTransitionService {
|
||||
// (portal and backoffice). Degrades to null like every fragile field here.
|
||||
let trainSchedule: {
|
||||
trainNumber: string | null;
|
||||
voyageNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: Date | null;
|
||||
} | null = null;
|
||||
@@ -1750,6 +1765,8 @@ export class BookingTransitionService {
|
||||
if (s) {
|
||||
trainSchedule = {
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
// The schedule's own voyage (sailing) number shown to the customer.
|
||||
voyageNumber: s.voyageNumber ?? null,
|
||||
reference: s.reference ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
|
||||
};
|
||||
|
||||
@@ -232,3 +232,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', ()
|
||||
expect(dto.requestedWagons).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The cancellation fee is paid BEFORE the credit is redeemed.
|
||||
*
|
||||
* An at-loading cut applies immediately and opens the credit while its fee
|
||||
* invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was
|
||||
* settled. Without the gate the customer rebooks the same wagons and the
|
||||
* cancellation fee is simply never collected. EDR-fault cuts carry no fee and
|
||||
* must stay freely rebookable — partial or whole, container or bulk.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => {
|
||||
const source = {
|
||||
id: 'b1',
|
||||
contractId: 'c1',
|
||||
paymentCurrency: 'ETB',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
};
|
||||
|
||||
const makeSvc = (row: Record<string, unknown>) => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & { rebook(id: string, dto: unknown): Promise<unknown> };
|
||||
svc.repo = { findById: async () => row };
|
||||
svc.bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async () => null,
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
/** Bulk credit — no bySize, so nothing depends on container snapshots. */
|
||||
const bulkRow = (over: Record<string, unknown>) => ({
|
||||
id: 'wc1',
|
||||
bookingId: 'b1',
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
creditAmount: 5000,
|
||||
wagonsCancelled: 2,
|
||||
cancelledQuantities: { bulkTons: 100 },
|
||||
feeCurrency: 'ETB',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('blocks a rebook while a customer-fault fee is unpaid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: null }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/pay the ETB 1500\.00 cancellation fee for 2 wagon\(s\)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks a WHOLE-booking customer-fault cancel just the same', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 4000, feePaidAt: null, wagonsCancelled: 4 }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/4 wagon\(s\) before rebooking/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the rebook through once the fee is paid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: new Date() }),
|
||||
);
|
||||
// Past the gate it fails later (no contract/create wiring in this harness) —
|
||||
// what matters is that it is no longer the fee that stops it.
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('never charges an EDR-fault cut', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: 'EDR', feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('leaves legacy rows without a fee untouched', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: null, feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
CancelledUnitSnapshot,
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
} from './entities/booking-wagon-cancellation.entity';
|
||||
import { WagonEventType } from '@edr/types';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
|
||||
|
||||
@@ -134,6 +136,7 @@ export class BookingWagonCancellationService {
|
||||
private readonly firstMile: FirstMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
// ── T1: request ────────────────────────────────────────────────────────────
|
||||
@@ -1003,6 +1006,18 @@ export class BookingWagonCancellationService {
|
||||
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
|
||||
);
|
||||
}
|
||||
// Customer-fault fee settles BEFORE the credit is redeemed. An at-loading
|
||||
// cut applies immediately and opens the credit while its invoice stays
|
||||
// open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without
|
||||
// this the customer rebooks the wagons and never pays the cancellation
|
||||
// fee the notice already promised. EDR fault carries no fee and is
|
||||
// unaffected; onFeePaid stamps feePaidAt and the gate opens by itself.
|
||||
if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) {
|
||||
throw new BadRequestException(
|
||||
`Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` +
|
||||
`${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`,
|
||||
);
|
||||
}
|
||||
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
|
||||
if (!source.contractId) {
|
||||
@@ -1835,6 +1850,7 @@ export class BookingWagonCancellationService {
|
||||
.getRepository(WagonAllocationContainerItem)
|
||||
.delete(cut.map((i) => i.id));
|
||||
if (cut.length === items.length) {
|
||||
await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(alloc.id);
|
||||
} else {
|
||||
const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0);
|
||||
@@ -1881,9 +1897,66 @@ export class BookingWagonCancellationService {
|
||||
await manager
|
||||
.getRepository(WagonAllocationBulkLoad)
|
||||
.delete({ wagonBookingAllocationId: In(ids) });
|
||||
await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* BOOKING_CANCELLED history row for every physical wagon behind the released
|
||||
* allocations — resolved through the slot BEFORE the allocation rows go, one
|
||||
* query for the whole batch. Slots with no wagon pinned yet leave no row.
|
||||
*/
|
||||
private async recordAllocationRelease(
|
||||
manager: EntityManager,
|
||||
allocationIds: string[],
|
||||
bookingId: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (!allocationIds.length) return;
|
||||
const rows: Array<{
|
||||
allocationId: string;
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
yardId: string | null;
|
||||
trainId: string | null;
|
||||
scheduleId: string | null;
|
||||
weightTons: string | null;
|
||||
loadType: string | null;
|
||||
}> = await manager.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
w.id AS "wagonId",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
w.current_yard_id AS "yardId",
|
||||
w.train_id AS "trainId",
|
||||
w.current_train_schedule_id AS "scheduleId",
|
||||
a.allocated_weight_tons AS "weightTons",
|
||||
a.load_type AS "loadType"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
WHERE a.id = ANY($1::uuid[])`,
|
||||
[allocationIds],
|
||||
);
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
rows.map((r) => ({
|
||||
wagonId: r.wagonId,
|
||||
wagonNumber: r.wagonNumber,
|
||||
type: WagonEventType.BookingCancelled,
|
||||
fromYardId: r.yardId,
|
||||
trainId: r.trainId,
|
||||
trainScheduleId: r.scheduleId,
|
||||
bookingId,
|
||||
reason,
|
||||
metadata: {
|
||||
allocationId: r.allocationId,
|
||||
loadType: r.loadType,
|
||||
weightTons: r.weightTons == null ? null : Number(r.weightTons),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-reduction quantities snapshot (only when the booking was never split before). */
|
||||
private async currentQuantities(
|
||||
manager: EntityManager,
|
||||
@@ -1951,9 +2024,12 @@ export class BookingWagonCancellationService {
|
||||
// the same cargo); number/seal/VGM come from the override when given.
|
||||
units: sized.map((u, i) => ({
|
||||
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
|
||||
// A credit snapshot taken before seals were mandatory can carry
|
||||
// none; the booking service normalizes the blank back to null
|
||||
// rather than blocking the rebook of already-paid cargo.
|
||||
sealNumber: replacement
|
||||
? (replacement[i]?.sealNumber ?? undefined)
|
||||
: (u.sealNumber ?? undefined),
|
||||
? (replacement[i]?.sealNumber ?? '')
|
||||
: (u.sealNumber ?? ''),
|
||||
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||
import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto";
|
||||
import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto";
|
||||
import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto";
|
||||
import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto";
|
||||
import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto";
|
||||
import { CustomerTruckService } from "./customer-truck.service";
|
||||
@@ -665,9 +666,11 @@ export class BookingsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// GL (createBooking) rebooks credits and must see the ledger for that.
|
||||
const staff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
if (!staff) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -848,6 +851,14 @@ export class BookingsController {
|
||||
staffPermission: string,
|
||||
): Promise<void> {
|
||||
if (hasFreightPermission(user, staffPermission)) return;
|
||||
// Rebooking a credit creates a booking under the contract — GL's booking
|
||||
// creation key covers it even where the dedicated rebook key was never granted.
|
||||
if (
|
||||
staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook &&
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const row = await this.wagonCancellationService.findById(cancellationId);
|
||||
const booking = await this.bookingsService.findById(row.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
@@ -907,7 +918,7 @@ export class BookingsController {
|
||||
})
|
||||
async bulkAddCustomerTrucks(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() payload: { trucks: AddCustomerTruckDto[] },
|
||||
@Body() payload: BulkCustomerTrucksDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
|
||||
@@ -21,6 +21,13 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import {
|
||||
CARGO_TYPE_SUBTREE_SQL,
|
||||
bookingContainerCountSql,
|
||||
bookingContentMatchSql,
|
||||
bookingHasContainerTypeSql,
|
||||
bookingRequestedContainerCountSql,
|
||||
} from './booking-content.sql';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import {
|
||||
BookingDocumentReview,
|
||||
@@ -65,7 +72,17 @@ export interface BookingListFilterOptions {
|
||||
contractId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
/** Cargo type OR cargo group — a group matches every commodity beneath it. */
|
||||
cargoTypeId?: string;
|
||||
/** Contains-search over content: description, commodity name, container types. */
|
||||
cargoText?: string;
|
||||
/** Bookings carrying this container type; also scopes the container count. */
|
||||
containerTypeId?: string;
|
||||
containersMin?: number;
|
||||
containersMax?: number;
|
||||
/** Bounds on containers declared on the shipment request behind the booking. */
|
||||
requestedContainersMin?: number;
|
||||
requestedContainersMax?: number;
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
@@ -1176,11 +1193,55 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
});
|
||||
}
|
||||
// A group is selectable in the filter, not just a leaf commodity, so this
|
||||
// matches the whole subtree — picking "Bulk" must return every commodity
|
||||
// under it, the same drill-down the booking wizard offers, read back.
|
||||
if (options.cargoTypeId) {
|
||||
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
|
||||
qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, {
|
||||
cargoTypeId: options.cargoTypeId,
|
||||
});
|
||||
}
|
||||
if (options.cargoText) {
|
||||
qb.andWhere(bookingContentMatchSql('booking'), {
|
||||
cargoText: `%${options.cargoText}%`,
|
||||
});
|
||||
}
|
||||
if (options.containerTypeId) {
|
||||
qb.andWhere(bookingHasContainerTypeSql('booking'), {
|
||||
containerTypeId: options.containerTypeId,
|
||||
});
|
||||
}
|
||||
// One count filter, two questions: with a container type picked it counts
|
||||
// that type, without one it counts every box on the booking.
|
||||
if (options.containersMin != null || options.containersMax != null) {
|
||||
const count = bookingContainerCountSql(
|
||||
'booking',
|
||||
Boolean(options.containerTypeId),
|
||||
);
|
||||
if (options.containersMin != null) {
|
||||
qb.andWhere(`${count} >= :containersMin`, {
|
||||
containersMin: options.containersMin,
|
||||
});
|
||||
}
|
||||
if (options.containersMax != null) {
|
||||
qb.andWhere(`${count} <= :containersMax`, {
|
||||
containersMax: options.containersMax,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Declared on the shipment request, not on the booking. Pairs with the
|
||||
// count above: containers 0..0 AND requested >= 1 is the set awaiting
|
||||
// completion after clearance.
|
||||
if (options.requestedContainersMin != null) {
|
||||
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, {
|
||||
requestedContainersMin: options.requestedContainersMin,
|
||||
});
|
||||
}
|
||||
if (options.requestedContainersMax != null) {
|
||||
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, {
|
||||
requestedContainersMax: options.requestedContainersMax,
|
||||
});
|
||||
}
|
||||
if (omit !== 'freightType' && options.freightType) {
|
||||
qb.andWhere('booking.freight_type = :freightType', {
|
||||
freightType: options.freightType,
|
||||
|
||||
@@ -29,6 +29,11 @@ import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import {
|
||||
EDR_HAULAGE_CONFLICT_MESSAGE,
|
||||
LAST_MILE_COMMITTED_SQL,
|
||||
edrHaulsThisBooking,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -112,8 +117,13 @@ interface CarriageAcceptanceWagonRow {
|
||||
departureAt: Date | null;
|
||||
marshalledAt: string | null;
|
||||
arrivalAt: string | null;
|
||||
/** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */
|
||||
departureStation: string | null;
|
||||
arrivalStation: string | null;
|
||||
containerNumbers: string | null;
|
||||
sealNumbers: string | null;
|
||||
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
||||
@@ -169,18 +179,23 @@ export class BookingsService {
|
||||
dto: CustomerTruckAssignmentDto,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
|
||||
const usesMileService =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? hasLastMile
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? hasFirstMile
|
||||
: hasFirstMile || hasLastMile;
|
||||
if (usesMileService) {
|
||||
throw new BadRequestException(
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||||
);
|
||||
// Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg
|
||||
// closes self-haul only once it has been approved.
|
||||
const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query(
|
||||
`SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (
|
||||
edrHaulsThisBooking({
|
||||
tradeDirection: booking.tradeDirection ?? null,
|
||||
firstMile: booking.firstMilePickupAddress ?? null,
|
||||
lastMile: booking.lastMileDeliveryAddress ?? null,
|
||||
lastMileCommitted: Boolean(commitment?.lastMileCommitted),
|
||||
})
|
||||
) {
|
||||
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||
}
|
||||
if (booking.customerTruckAssignedAt) {
|
||||
throw new ConflictException('Customer truck assignment is already submitted and locked');
|
||||
@@ -263,9 +278,13 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
||||
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
|
||||
* the wagons are allocated before marshalling (import), so it is only available
|
||||
* once the booking has wagon allocations.
|
||||
* occupies. A booking is routinely loaded in parts (some containers go, the
|
||||
* rest wait for the next train), so each row carries a Status of Loaded or
|
||||
* Not loaded and the totals count only the loaded ones: the customer sees the
|
||||
* whole plan on one page without the sheet overstating what EDR has taken.
|
||||
*
|
||||
* Handed to the customer when EDR accepts the cargo (export) and when the
|
||||
* wagons are allocated before marshalling (import).
|
||||
*/
|
||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
@@ -285,6 +304,9 @@ export class BookingsService {
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "marshalledAt",
|
||||
sd.label AS "arrivalAt",
|
||||
COALESCE(by_.label, so.label) AS "departureStation",
|
||||
COALESCE(ay.label, sd.label) AS "arrivalStation",
|
||||
a.status AS "status",
|
||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
@@ -296,13 +318,31 @@ export class BookingsService {
|
||||
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
|
||||
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
AND (
|
||||
$2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.booking_container_units received_unit
|
||||
JOIN freight.booking_container received_line
|
||||
ON received_line.id = received_unit.booking_container_id
|
||||
AND received_line.deleted_at IS NULL
|
||||
WHERE received_line.booking_id = a.booking_id
|
||||
AND received_unit.container_number = ci.container_number
|
||||
AND received_unit.received_to_port = true
|
||||
AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL
|
||||
AND received_unit.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label,
|
||||
by_.label, ay.label
|
||||
HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
[bookingId, booking.tradeDirection, booking.freightType],
|
||||
);
|
||||
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
|
||||
// takes custody of the cargo when it receives it, and the customer is handed
|
||||
@@ -337,17 +377,17 @@ export class BookingsService {
|
||||
)
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? await this.dataSource.query(
|
||||
`SELECT inv.weight AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.containers c
|
||||
ON c.id = inv.container_id AND c.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
|
||||
AND COALESCE(
|
||||
NULLIF(TRIM(inv.grn_number), ''),
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) IS NOT NULL
|
||||
ORDER BY inv.created_at`,
|
||||
`SELECT unit.vgm_tons AS "allocatedWeightTons",
|
||||
unit.container_number AS "containerNumbers",
|
||||
unit.seal_number AS "sealNumbers"
|
||||
FROM freight.booking_container_units unit
|
||||
JOIN freight.booking_container line
|
||||
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
|
||||
WHERE line.booking_id = $1
|
||||
AND unit.deleted_at IS NULL
|
||||
AND unit.received_to_port = true
|
||||
AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL
|
||||
ORDER BY unit.received_at, unit.container_number`,
|
||||
[bookingId],
|
||||
)
|
||||
: [];
|
||||
@@ -381,8 +421,12 @@ export class BookingsService {
|
||||
departureAt: null,
|
||||
marshalledAt: null,
|
||||
arrivalAt: null,
|
||||
departureStation: null,
|
||||
arrivalStation: null,
|
||||
containerNumbers: row.containerNumbers,
|
||||
sealNumbers: row.sealNumbers ?? null,
|
||||
// A received line has no allocation; it is cargo EDR already holds.
|
||||
status: null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -497,7 +541,17 @@ export class BookingsService {
|
||||
const header = wagons[0];
|
||||
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
||||
|
||||
const totals = wagons.reduce(
|
||||
// Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
|
||||
// totals count only those: the sheet shows the whole plan, but must never
|
||||
// total up cargo still sitting in the yard. A received-line sheet
|
||||
// (pendingWagons) has no allocation status, and every line on it is cargo
|
||||
// already accepted, so it counts in full.
|
||||
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
|
||||
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
|
||||
const loadedWagons = wagons.filter(isLoaded);
|
||||
const notLoadedCount = wagons.length - loadedWagons.length;
|
||||
|
||||
const totals = loadedWagons.reduce(
|
||||
(acc, w) => ({
|
||||
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
||||
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
||||
@@ -507,7 +561,7 @@ export class BookingsService {
|
||||
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
||||
);
|
||||
// A wagon carrying no weight and no container is running empty under this booking.
|
||||
const fullWagons = wagons.filter(
|
||||
const fullWagons = loadedWagons.filter(
|
||||
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
||||
).length;
|
||||
|
||||
@@ -520,11 +574,14 @@ export class BookingsService {
|
||||
<td class="num">${num(w.tareWeightTons, 2)}</td>
|
||||
<td class="num">${num(w.equatedLength)}</td>
|
||||
<td class="num">${num(w.loadCapacityTons)}</td>
|
||||
<td>${esc(arrivalStation)}</td>
|
||||
<td>${esc(w.arrivalStation ?? arrivalStation)}</td>
|
||||
<td>${esc(cargoName)}</td>
|
||||
<td>${esc(departureStation)}</td>
|
||||
<td>${esc(w.departureStation ?? departureStation)}</td>
|
||||
<td>${esc(w.containerNumbers)}</td>
|
||||
<td>${esc(w.sealNumbers)}</td>
|
||||
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
|
||||
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
|
||||
}</td>
|
||||
<td class="num">${money(prices[i])}</td>
|
||||
</tr>`,
|
||||
)
|
||||
@@ -535,23 +592,37 @@ export class BookingsService {
|
||||
// figure from the printed sheet.
|
||||
const totalsRow = `<tr class="totals">
|
||||
<td>TOT</td>
|
||||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
||||
<td>${
|
||||
pendingWagons
|
||||
? 'pending marshalling'
|
||||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
||||
}</td>
|
||||
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
|
||||
<td></td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td></td>
|
||||
<td>Gross ${num(totals.tare + totals.load)} T</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>`;
|
||||
|
||||
// The signed footer of the paper sheet. Rendered as .tile so the
|
||||
// Chromium-less fallback (buildTabularFallbackPdf parses .tile, not
|
||||
// arbitrary divs) still prints every figure.
|
||||
const footer = `
|
||||
<div class="summary footer-summary">
|
||||
<div class="tile"><span>In Total Wagon No.</span><strong>${loadedWagons.length}</strong></div>
|
||||
<div class="tile"><span>Tare Weight (T)</span><strong>${num(totals.tare, 2)}</strong></div>
|
||||
<div class="tile"><span>Load Capacity (T)</span><strong>${num(totals.capacity)}</strong></div>
|
||||
<div class="tile"><span>Gross Weight (T)</span><strong>${num(totals.tare + totals.load)}</strong></div>
|
||||
<div class="tile"><span>Equated Length</span><strong>${num(totals.length)}</strong></div>
|
||||
<div class="tile"><span>Full Wagon</span><strong>${pendingWagons ? '-' : fullWagons}</strong></div>
|
||||
<div class="tile"><span>Empty Wagon</span><strong>${
|
||||
pendingWagons ? '-' : loadedWagons.length - fullWagons
|
||||
}</strong></div>
|
||||
<div class="tile"><span>Total Amount (${esc(currency)})</span><strong>${money(totalAmount)}</strong></div>
|
||||
</div>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -568,6 +639,8 @@ export class BookingsService {
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; }
|
||||
.footer-summary .tile { background: #f8fafc; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
@@ -575,6 +648,8 @@ export class BookingsService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
.loaded { color: #0f766e; font-weight: 700; }
|
||||
.pending { color: #b45309; font-weight: 700; }
|
||||
tr.totals td { background: #f8fafc; font-weight: 700; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
@@ -618,6 +693,7 @@ export class BookingsService {
|
||||
<th>Departure Station</th>
|
||||
<th>Container No.</th>
|
||||
<th>Seal No.</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Price (${esc(currency)})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -626,6 +702,7 @@ export class BookingsService {
|
||||
${totalsRow}
|
||||
</tbody>
|
||||
</table>
|
||||
${footer}
|
||||
|
||||
<div class="notice">
|
||||
${
|
||||
@@ -1845,6 +1922,12 @@ export class BookingsService {
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
cargoText: filter.cargoText,
|
||||
containerTypeId: filter.containerTypeId,
|
||||
containersMin: filter.containersMin,
|
||||
containersMax: filter.containersMax,
|
||||
requestedContainersMin: filter.requestedContainersMin,
|
||||
requestedContainersMax: filter.requestedContainersMax,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
@@ -2131,6 +2214,12 @@ export class BookingsService {
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
cargoText: filter.cargoText,
|
||||
containerTypeId: filter.containerTypeId,
|
||||
containersMin: filter.containersMin,
|
||||
containersMax: filter.containersMax,
|
||||
requestedContainersMin: filter.requestedContainersMin,
|
||||
requestedContainersMax: filter.requestedContainersMax,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
|
||||
@@ -24,3 +24,84 @@ describe('carriage acceptance sheet — price split', () => {
|
||||
expect(shares).toEqual([33.33, 33.33, 33.34]);
|
||||
});
|
||||
});
|
||||
|
||||
// The HTML builder only reaches `this` for two prototype helpers (escapeHtml,
|
||||
// splitAmountAcrossWagons), so the prototype itself serves as `this`.
|
||||
const buildSheet = (wagons: unknown[], booking: Record<string, unknown> = {}): string =>
|
||||
(
|
||||
BookingsService.prototype as unknown as {
|
||||
buildCarriageAcceptanceSheetHtml(
|
||||
b: unknown,
|
||||
w: unknown[],
|
||||
o: { pendingWagons: boolean },
|
||||
): string;
|
||||
}
|
||||
).buildCarriageAcceptanceSheetHtml.call(
|
||||
BookingsService.prototype,
|
||||
{
|
||||
reference: 'BK-1',
|
||||
tradeDirection: 'EXPORT',
|
||||
totalAmount: 100,
|
||||
paymentCurrency: 'ETB',
|
||||
originYard: { label: 'Booking Origin' },
|
||||
destinationYard: { label: 'Booking Destination' },
|
||||
...booking,
|
||||
},
|
||||
wagons,
|
||||
{ pendingWagons: false },
|
||||
);
|
||||
|
||||
const wagon = (over: Record<string, unknown> = {}) => ({
|
||||
sequenceNo: 1,
|
||||
wagonType: 'FLAT',
|
||||
wagonNumber: 'W-001',
|
||||
tareWeightTons: '20',
|
||||
equatedLength: '14',
|
||||
loadCapacityTons: '60',
|
||||
allocatedWeightTons: '40',
|
||||
trainNumber: '8302',
|
||||
departureAt: null,
|
||||
marshalledAt: 'DCT/SGTD',
|
||||
arrivalAt: 'GMP',
|
||||
departureStation: null,
|
||||
arrivalStation: null,
|
||||
containerNumbers: 'CN-1',
|
||||
sealNumbers: 'SL-1',
|
||||
status: 'LOADED',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('carriage acceptance sheet — rows and footer', () => {
|
||||
it('prints each row its own Departure/Arrival Station, falling back to the booking yards', () => {
|
||||
const html = buildSheet([
|
||||
wagon({ departureStation: 'Dire Dawa Port', arrivalStation: 'Adama' }),
|
||||
wagon({ sequenceNo: 2, wagonNumber: 'W-002' }),
|
||||
]);
|
||||
expect(html).toContain('<td>Dire Dawa Port</td>');
|
||||
expect(html).toContain('<td>Adama</td>');
|
||||
expect(html).toContain('<td>Booking Origin</td>');
|
||||
expect(html).toContain('<td>Booking Destination</td>');
|
||||
});
|
||||
|
||||
it('totals the footer over loaded wagons only', () => {
|
||||
const html = buildSheet([
|
||||
wagon(),
|
||||
wagon({ sequenceNo: 2, wagonNumber: 'W-002', status: 'ALLOCATED' }),
|
||||
wagon({
|
||||
sequenceNo: 3,
|
||||
wagonNumber: 'W-003',
|
||||
allocatedWeightTons: '0',
|
||||
containerNumbers: null,
|
||||
}),
|
||||
]);
|
||||
// 2 loaded of 3: tare 40, capacity 120, equated length 28, gross 40 + 40 load.
|
||||
expect(html).toContain('<span>In Total Wagon No.</span><strong>2</strong>');
|
||||
expect(html).toContain('<span>Tare Weight (T)</span><strong>40.00</strong>');
|
||||
expect(html).toContain('<span>Load Capacity (T)</span><strong>120.000</strong>');
|
||||
expect(html).toContain('<span>Gross Weight (T)</span><strong>80.000</strong>');
|
||||
expect(html).toContain('<span>Equated Length</span><strong>28.000</strong>');
|
||||
expect(html).toContain('<span>Full Wagon</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Empty Wagon</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Total Amount (ETB)</span><strong>100.00</strong>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,17 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import type {
|
||||
BulkTruckUploadError,
|
||||
BulkTruckUploadResult,
|
||||
} from './dto/bulk-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import {
|
||||
EDR_HAULAGE_CONFLICT_MESSAGE,
|
||||
usesEdrMileService,
|
||||
LAST_MILE_COMMITTED_SQL,
|
||||
edrHaulsThisBooking,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
@@ -35,6 +40,9 @@ interface BookingGuardRow {
|
||||
lastMile: string | null;
|
||||
paymentStatus: string | null;
|
||||
status: string | null;
|
||||
trainScheduleStatus: string | null;
|
||||
/** See `MileCommitmentRow` — an approved EDR last-mile leg closes self-haul. */
|
||||
lastMileCommitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,19 +302,16 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (booking.freightType === 'CONTAINER' && !requested.length) {
|
||||
throw new BadRequestException('Select the containers loaded on this truck');
|
||||
}
|
||||
if (requested.length) {
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||
for (const n of requested) {
|
||||
if (elsewhere.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||
});
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -542,9 +547,17 @@ export class CustomerTruckService {
|
||||
first_mile_pickup_address AS "firstMile",
|
||||
last_mile_delivery_address AS "lastMile",
|
||||
payment_status AS "paymentStatus",
|
||||
status
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
b.status,
|
||||
(SELECT ts.status
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts
|
||||
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
|
||||
WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
ORDER BY ts.updated_at DESC
|
||||
LIMIT 1) AS "trainScheduleStatus",
|
||||
${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
@@ -552,10 +565,12 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
||||
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
|
||||
// halves of this rule cannot drift apart — they did, and a booking ended up
|
||||
// with a customer truck and an EDR leg at once.
|
||||
if (usesEdrMileService(booking)) {
|
||||
// Mirrors the EDR side (LastMileService.assertEdrHaulsThisBooking) so the
|
||||
// two halves of this rule cannot drift apart — they did, and a booking ended
|
||||
// up with a customer truck and an EDR leg at once. A last-mile leg only
|
||||
// blocks self-haul once it is approved; until then the customer may still
|
||||
// bring their own truck, and doing so makes the pending request unapprovable.
|
||||
if (edrHaulsThisBooking(booking)) {
|
||||
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||
}
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
@@ -575,7 +590,7 @@ export class CustomerTruckService {
|
||||
private assertAssignmentWindow(booking: BookingGuardRow): void {
|
||||
const status = booking.status ?? '';
|
||||
if (booking.tradeDirection === 'IMPORT') {
|
||||
if (status !== 'ARRIVED') {
|
||||
if (status !== 'ARRIVED' && booking.trainScheduleStatus !== 'ARRIVED') {
|
||||
throw new BadRequestException(
|
||||
'Import pickup trucks can only be assigned after the train has arrived',
|
||||
);
|
||||
@@ -626,26 +641,29 @@ export class CustomerTruckService {
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
|
||||
/**
|
||||
* Add trucks one at a time, keeping the good ones. Partial success is the
|
||||
* right shape here: one mistyped plate in a twenty-row spreadsheet should not
|
||||
* discard the other nineteen trucks. Every row still goes through `addTruck`,
|
||||
* so no guard is skipped.
|
||||
*/
|
||||
async addBulkTrucks(
|
||||
bookingId: string,
|
||||
dtos: AddCustomerTruckDto[],
|
||||
): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ row: number; truck: string; reason: string }>;
|
||||
}> {
|
||||
const errors: Array<{ row: number; truck: string; reason: string }> = [];
|
||||
): Promise<BulkTruckUploadResult> {
|
||||
const errors: BulkTruckUploadError[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < dtos.length; i++) {
|
||||
try {
|
||||
await this.addTruck(bookingId, dtos[i]);
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
row: i + 2, // Row 1 is header
|
||||
index: i,
|
||||
row: i + 2, // Row 1 is the header
|
||||
truck: dtos[i].truckPlateNumber,
|
||||
reason: err.message || 'Unknown error',
|
||||
reason: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
|
||||
|
||||
/**
|
||||
* Add one external customer truck to a booking.
|
||||
@@ -41,7 +41,7 @@ export class AddCustomerTruckDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
@Matches(ISO_CONTAINER_NUMBER, {
|
||||
each: true,
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class BulkCustomerTruckRow {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
driverName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
message: 'each container must be ISO format (e.g. ABCD1234567)',
|
||||
})
|
||||
containerNumbers?: (string | null)[];
|
||||
}
|
||||
import { AddCustomerTruckDto } from './add-customer-truck.dto';
|
||||
|
||||
/**
|
||||
* Bulk self-haul truck assignment, parsed from the customer's Excel upload in
|
||||
* the browser and posted as JSON (the house pattern — the API never receives an
|
||||
* .xlsx for import).
|
||||
*
|
||||
* Rows reuse `AddCustomerTruckDto` verbatim rather than redeclaring the fields:
|
||||
* the earlier copy drifted, missing `plannedTons` / `plannedQuantity`, so bulk
|
||||
* cargo could not be uploaded at all.
|
||||
*/
|
||||
export class BulkCustomerTrucksDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(100)
|
||||
trucks!: BulkCustomerTruckRow[];
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AddCustomerTruckDto)
|
||||
trucks!: AddCustomerTruckDto[];
|
||||
}
|
||||
|
||||
export interface BulkTruckUploadError {
|
||||
/**
|
||||
* Position in the submitted array. The client knows which spreadsheet line it
|
||||
* read each entry from, so it maps this back to the row number the customer
|
||||
* actually sees.
|
||||
*/
|
||||
index: number;
|
||||
/** 1-based row assuming a single header line — a fallback for non-Excel callers. */
|
||||
row: number;
|
||||
truck: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface BulkTruckUploadResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{
|
||||
row: number;
|
||||
truck: string;
|
||||
reason: string;
|
||||
}>;
|
||||
created: Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
containers: number;
|
||||
}>;
|
||||
errors: BulkTruckUploadError[];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
|
||||
|
||||
export const CUSTOMER_TRUCK_TYPES = [
|
||||
'Flatbed',
|
||||
'Container Chassis',
|
||||
'Lowboy',
|
||||
'Box Truck',
|
||||
'Tipper',
|
||||
] as const;
|
||||
/**
|
||||
* Re-exported for the DTOs that already import it from here. The list itself
|
||||
* lives in `@edr/types` so the portal's dropdown and its Excel template read the
|
||||
* same values this validator enforces.
|
||||
*/
|
||||
export { CUSTOMER_TRUCK_TYPES };
|
||||
|
||||
export class CustomerTruckAssignmentDto {
|
||||
@IsString()
|
||||
@@ -27,7 +27,7 @@ export class CustomerTruckAssignmentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(16)
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
@Matches(ISO_CONTAINER_NUMBER, {
|
||||
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumberToLoad!: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ISO_CONTAINER_NUMBER } from '@edr/types';
|
||||
|
||||
/**
|
||||
* Register an import self-haul truck leaving the port: the containers it actually
|
||||
@@ -20,7 +21,7 @@ export class DepartCustomerTruckDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
@Matches(ISO_CONTAINER_NUMBER, {
|
||||
each: true,
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
|
||||
@@ -62,11 +62,60 @@ export class FilterBookingDto {
|
||||
@IsUUID()
|
||||
serviceTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Cargo type OR cargo group — a group matches every commodity beneath it',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Contains-search over booking content: cargo description, commodity name, container types',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
cargoText?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Bookings carrying this container type. Also scopes containersMin/Max to it.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Minimum container count — of containerTypeId when set, else of all types',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
containersMin?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum container count — see containersMin' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
containersMax?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Minimum containers declared on the shipment request behind the booking',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
requestedContainersMin?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum requested containers — see requestedContainersMin' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
requestedContainersMax?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
|
||||
import { ISO_CONTAINER_NUMBER } from '@edr/types';
|
||||
|
||||
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
|
||||
export class LoadCustomerTruckDto {
|
||||
@@ -7,7 +8,7 @@ export class LoadCustomerTruckDto {
|
||||
// A truck carries at most 2 containers (two 20ft, or one 40ft).
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
@Matches(ISO_CONTAINER_NUMBER, {
|
||||
each: true,
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { NotificationType, type NotifyInput } from '@edr/types';
|
||||
|
||||
import type { ChatConfig } from '../../config/chat.config';
|
||||
import { ChatBridgeService } from './chat-bridge.service';
|
||||
import type { MatrixClient } from './matrix.client';
|
||||
|
||||
const config: ChatConfig = {
|
||||
enabled: true,
|
||||
baseUrl: 'https://matrix.test',
|
||||
publicBaseUrl: 'https://matrix.test',
|
||||
webUrl: 'https://chat.test',
|
||||
serverName: 'matrix.test',
|
||||
jwtSecret: 'secret',
|
||||
adminToken: 'syt_whatever',
|
||||
};
|
||||
|
||||
function harness(overrides: Partial<ChatConfig> = {}) {
|
||||
const matrix = {
|
||||
ensureRoom: jest.fn(async (alias: string) => `!${alias}:matrix.test`),
|
||||
sendMessage: jest.fn(
|
||||
async (_roomId: string, _body: string, _html?: string) => undefined,
|
||||
),
|
||||
};
|
||||
const service = new ChatBridgeService(
|
||||
{ ...config, ...overrides },
|
||||
matrix as unknown as MatrixClient,
|
||||
);
|
||||
return { service, matrix };
|
||||
}
|
||||
|
||||
const notification = (type: NotificationType): NotifyInput =>
|
||||
({ type, title: 'Booking BK-1', body: 'needs review' }) as unknown as NotifyInput;
|
||||
|
||||
describe('ChatBridgeService', () => {
|
||||
it('posts every notification type into #freight-alerts', async () => {
|
||||
// This used to route REQUEST_SUBMITTED and CLEARANCE_REVIEW to a hardcoded
|
||||
// `dept-operation` alias, but the reconcile derives dept aliases from the
|
||||
// IAM position key (`edr_freight_app/opn` shaped), so nothing it created
|
||||
// ever matched. The bridge made its own empty room and posted there, where
|
||||
// no employee was a member.
|
||||
const { service, matrix } = harness();
|
||||
|
||||
for (const type of [
|
||||
NotificationType.REQUEST_SUBMITTED,
|
||||
NotificationType.CLEARANCE_REVIEW,
|
||||
NotificationType.GENERIC,
|
||||
]) {
|
||||
await service.bridge(notification(type));
|
||||
}
|
||||
|
||||
expect(new Set(matrix.ensureRoom.mock.calls.map(([alias]) => alias))).toEqual(
|
||||
new Set(['freight-alerts']),
|
||||
);
|
||||
expect(matrix.sendMessage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does nothing at all when chat is switched off', async () => {
|
||||
const { service, matrix } = harness({ enabled: false });
|
||||
|
||||
await service.bridge(notification(NotificationType.GENERIC));
|
||||
|
||||
expect(matrix.ensureRoom).not.toHaveBeenCalled();
|
||||
expect(matrix.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never lets a chat failure escape into the notification that triggered it', async () => {
|
||||
// Same contract as NotificationInboxService.notify(): bridging is
|
||||
// best-effort and must not roll back the caller's transaction.
|
||||
const { service, matrix } = harness();
|
||||
matrix.ensureRoom.mockRejectedValueOnce(new Error('Matrix POST ... -> 429'));
|
||||
|
||||
await expect(
|
||||
service.bridge(notification(NotificationType.GENERIC)),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,11 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
import { NotificationType, type NotifyInput } from '@edr/types';
|
||||
import type { NotifyInput } from '@edr/types';
|
||||
|
||||
import chatConfig from '../../config/chat.config';
|
||||
import { ALERTS_ROOM } from './chat-provisioning.service';
|
||||
import { MatrixClient } from './matrix.client';
|
||||
|
||||
const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
|
||||
|
||||
/**
|
||||
* Best-effort per-type routing to an existing dept room. Anything not listed
|
||||
* (including GENERIC) falls through to #freight-alerts — safer than a wrong
|
||||
* guess at which department a type belongs to. Extend as real usage shows
|
||||
* which types actually want a dept room instead of the shared feed.
|
||||
*
|
||||
* `name` matters only if this bridge is the very first thing to touch that
|
||||
* alias (normally the nightly/on-demand reconcile creates dept rooms first,
|
||||
* with the position's real name) — ensureRoom never renames an existing
|
||||
* room, so this must match what ChatProvisioningService would have used.
|
||||
*/
|
||||
const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: string }>> = {
|
||||
[NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' },
|
||||
[NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors BACKOFFICE-audience notifications into chat so staff see them
|
||||
* without having the inbox open. Hooked once into
|
||||
@@ -31,6 +14,15 @@ const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: str
|
||||
*
|
||||
* Gated on BACKOFFICE only: notify() also serves PORTAL (customer)
|
||||
* notifications, which must never land in an internal staff room.
|
||||
*
|
||||
* Everything goes to one room. This used to route REQUEST_SUBMITTED and
|
||||
* CLEARANCE_REVIEW to a hardcoded `dept-operation` alias — but the reconcile
|
||||
* derives dept aliases from the IAM position key, which is `edr_freight_app/opn`
|
||||
* shaped, so `#dept-operation` matched nothing it creates. The bridge quietly
|
||||
* created its own empty room and posted every notification into it, where no
|
||||
* employee was a member. A single room the reconcile actually populates beats
|
||||
* per-type routing that silently misses; add routing back when real usage asks
|
||||
* for it, keyed off the same derivation the reconcile uses.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatBridgeService {
|
||||
@@ -46,8 +38,9 @@ export class ChatBridgeService {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
try {
|
||||
const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM;
|
||||
const roomId = await this.matrix.ensureRoom(room.alias, room.name);
|
||||
// get-or-create as a safety net only: the reconcile creates this room
|
||||
// inside the space and joins every position holder to it.
|
||||
const roomId = await this.matrix.ensureRoom(ALERTS_ROOM.alias, ALERTS_ROOM.name);
|
||||
const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`;
|
||||
const html = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
|
||||
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { ChatProvisioningService } from './chat-provisioning.service';
|
||||
import type { MatrixClient } from './matrix.client';
|
||||
|
||||
const NAA = '03f5eb9e-23a0-4413-8d98-8de4b98b1be2';
|
||||
const SUPER_ADMIN = 'f1534714-fa4a-4780-a081-05d4c1f6c25f';
|
||||
const BOT = '@edrbot:m.test';
|
||||
|
||||
interface Holder {
|
||||
positionKey: string;
|
||||
positionName: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
const holder = (
|
||||
userId: string,
|
||||
userName: string,
|
||||
positionKey: string,
|
||||
positionName = positionKey,
|
||||
): Holder => ({ positionKey, positionName, userId, userName });
|
||||
|
||||
/**
|
||||
* `members` maps a room id to who Matrix currently reports as joined, so a
|
||||
* test can put a leaver in a room and watch what the reconcile does about it.
|
||||
*/
|
||||
function harness(holders: Holder[], members: Record<string, string[]> = {}) {
|
||||
const matrix = {
|
||||
mxidFor: jest.fn(
|
||||
(userId: string, name: string) => `@${name}.${userId.slice(0, 6)}:m.test`,
|
||||
),
|
||||
whoami: jest.fn(async () => BOT),
|
||||
ensureUser: jest.fn(async (_mxid: string, _name?: string) => undefined),
|
||||
ensureRoom: jest.fn(
|
||||
async (alias: string, _name?: string, _opts?: unknown) => `!${alias}:m.test`,
|
||||
),
|
||||
ensureJoined: jest.fn(async (_roomId: string, _mxid: string) => undefined),
|
||||
joinedMembers: jest.fn(async (roomId: string) => members[roomId] ?? [BOT]),
|
||||
kick: jest.fn(async (_roomId: string, _mxid: string, _reason: string) => undefined),
|
||||
lockUser: jest.fn(async (_mxid: string) => undefined),
|
||||
};
|
||||
const dataSource = { query: jest.fn(async () => holders) };
|
||||
const service = new ChatProvisioningService(
|
||||
dataSource as unknown as DataSource,
|
||||
matrix as unknown as MatrixClient,
|
||||
);
|
||||
return { service, matrix, dataSource };
|
||||
}
|
||||
|
||||
/**
|
||||
* `joinUserRooms` is the only thing standing between a first sign-in and an
|
||||
* empty Element — the reconcile that would otherwise fill the room list runs
|
||||
* nightly.
|
||||
*/
|
||||
describe('ChatProvisioningService.joinUserRooms', () => {
|
||||
it('creates nothing for a user holding no current position', async () => {
|
||||
// Super Admin on dev: three iam.employees rows, zero employee_positions.
|
||||
// Synapse still auto-registers the account on JWT login, so the only
|
||||
// symptom is a working sign-in into a client with no rooms in it.
|
||||
const { service, matrix } = harness([]);
|
||||
|
||||
await expect(service.joinUserRooms(SUPER_ADMIN, 'Super Admin')).resolves.toBe(0);
|
||||
|
||||
expect(matrix.ensureUser).not.toHaveBeenCalled();
|
||||
expect(matrix.ensureRoom).not.toHaveBeenCalled();
|
||||
expect(matrix.ensureJoined).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('joins a holder to the space, #general, #freight-alerts and their dept room', async () => {
|
||||
const { service, matrix } = harness([
|
||||
holder(NAA, 'naa', 'edr_freight_app/marketer', 'Marketer'),
|
||||
]);
|
||||
|
||||
await expect(service.joinUserRooms(NAA, 'naa')).resolves.toBe(4);
|
||||
|
||||
// The account has to exist before the admin join API will touch it — JWT
|
||||
// auto-registration happens after this runs.
|
||||
expect(matrix.ensureUser).toHaveBeenCalledWith('@naa.03f5eb:m.test', 'naa');
|
||||
|
||||
expect(matrix.ensureRoom.mock.calls.map(([alias]) => alias)).toEqual([
|
||||
'edr-freight',
|
||||
'general',
|
||||
'freight-alerts',
|
||||
'dept-edr_freight_app/marketer',
|
||||
]);
|
||||
|
||||
// The space itself is joined, not only the rooms under it: Element shows a
|
||||
// space in the left rail only to its members, so dropping this scatters
|
||||
// every dept room loose into Home. #freight-alerts is joined here too, or
|
||||
// a new hire sees no bridged notification until the nightly reconcile.
|
||||
expect(matrix.ensureJoined.mock.calls.map(([roomId]) => roomId)).toEqual([
|
||||
'!edr-freight:m.test',
|
||||
'!general:m.test',
|
||||
'!freight-alerts:m.test',
|
||||
'!dept-edr_freight_app/marketer:m.test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('scopes the position lookup to the one user', async () => {
|
||||
const { service, dataSource } = harness([]);
|
||||
await service.joinUserRooms(NAA, 'naa');
|
||||
// Without the third parameter this would reconcile the whole unit on every
|
||||
// click of "Open EDR Chat".
|
||||
const [sql, params] = dataSource.query.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown[],
|
||||
];
|
||||
expect(sql).toContain('AND e.user_id = $3');
|
||||
expect(params).toEqual(['edr_freight', 'edr_freight_app', NAA]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatProvisioningService.reconcile', () => {
|
||||
it('aborts instead of emptying every room when the holder query returns nothing', async () => {
|
||||
// Zero holders never means "every employee left at once" — it means the
|
||||
// query failed, the org/unit keys drifted, or a migration is mid-flight.
|
||||
// Acting on it would kick every member of every room and lock every
|
||||
// account, which is exactly the outage this guard exists to prevent.
|
||||
const { service, matrix } = harness([]);
|
||||
|
||||
await expect(service.reconcile()).rejects.toThrow(/no current position holders/i);
|
||||
|
||||
expect(matrix.kick).not.toHaveBeenCalled();
|
||||
expect(matrix.lockUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks a departed member rather than deactivating them', async () => {
|
||||
const leaver = '@gone.999999:m.test';
|
||||
const { service, matrix } = harness(
|
||||
[holder(NAA, 'naa', 'marketer', 'Marketer')],
|
||||
{
|
||||
'!edr-freight:m.test': [BOT, '@naa.03f5eb:m.test', leaver],
|
||||
'!general:m.test': [BOT, '@naa.03f5eb:m.test', leaver],
|
||||
'!freight-alerts:m.test': [BOT, '@naa.03f5eb:m.test'],
|
||||
'!dept-marketer:m.test': [BOT, '@naa.03f5eb:m.test'],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.reconcile();
|
||||
|
||||
expect(matrix.kick.mock.calls.map(([, mxid]) => mxid)).toEqual([leaver, leaver]);
|
||||
// Locking is reversible; deactivation is not, and on a homeserver with no
|
||||
// password login it cannot be undone at all.
|
||||
expect(matrix.lockUser).toHaveBeenCalledTimes(1);
|
||||
expect(matrix.lockUser).toHaveBeenCalledWith(leaver);
|
||||
expect(result.locked).toBe(1);
|
||||
});
|
||||
|
||||
it('does not lock someone who only moved between positions', async () => {
|
||||
const naaMxid = '@naa.03f5eb:m.test';
|
||||
// naa holds `marketer` now; the room for their old position still lists them.
|
||||
const { service, matrix } = harness(
|
||||
[
|
||||
holder(NAA, 'naa', 'marketer', 'Marketer'),
|
||||
holder('aaa04914-b7ee-47b3-9c63-4324046a26bd', 'nati', 'opn', 'Operation'),
|
||||
],
|
||||
{ '!dept-opn:m.test': [BOT, naaMxid, '@nati.aaa049:m.test'] },
|
||||
);
|
||||
|
||||
const result = await service.reconcile();
|
||||
|
||||
expect(matrix.kick).toHaveBeenCalledWith(
|
||||
'!dept-opn:m.test',
|
||||
naaMxid,
|
||||
expect.any(String),
|
||||
);
|
||||
// Kicked from one room, still current elsewhere — their account stays open.
|
||||
expect(matrix.lockUser).not.toHaveBeenCalled();
|
||||
expect(result.locked).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses to empty a populated room when its desired set is empty', async () => {
|
||||
// Per-room backstop for the paths the unit-level guard above cannot see.
|
||||
const { service, matrix } = harness([holder(NAA, 'naa', 'marketer')], {
|
||||
'!room:m.test': [BOT, '@naa.03f5eb:m.test', '@nati.aaa049:m.test'],
|
||||
});
|
||||
|
||||
const diff = await (
|
||||
service as unknown as {
|
||||
syncMembership: (
|
||||
roomId: string,
|
||||
desired: Set<string>,
|
||||
bot: string,
|
||||
) => Promise<{ joined: number; kicked: string[] }>;
|
||||
}
|
||||
).syncMembership('!room:m.test', new Set<string>(), BOT);
|
||||
|
||||
expect(diff).toEqual({ joined: 0, kicked: [] });
|
||||
expect(matrix.kick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,11 @@ const UNIT_KEY = 'edr_freight_app';
|
||||
const SPACE_ALIAS = 'edr-freight';
|
||||
const GENERAL_ALIAS = 'general';
|
||||
|
||||
/** Where ChatBridgeService mirrors backoffice notifications. Provisioned here,
|
||||
* with every position holder in it, so bridged messages land somewhere staff
|
||||
* actually are — the bridge only ever get-or-creates it as a safety net. */
|
||||
export const ALERTS_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
|
||||
|
||||
interface PositionHolder {
|
||||
positionKey: string;
|
||||
positionName: string;
|
||||
@@ -24,7 +29,8 @@ export interface ReconcileResult {
|
||||
rooms: number;
|
||||
joined: number;
|
||||
kicked: number;
|
||||
deactivated: number;
|
||||
/** Departed accounts locked — reversible. See {@link MatrixClient.lockUser}. */
|
||||
locked: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +61,7 @@ export class ChatProvisioningService {
|
||||
const result = await this.reconcile();
|
||||
this.logger.log(
|
||||
`Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` +
|
||||
`${result.kicked} kicked, ${result.deactivated} deactivated`,
|
||||
`${result.kicked} kicked, ${result.locked} locked`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Never throws into the scheduler — chat provisioning must not be able
|
||||
@@ -113,10 +119,23 @@ export class ChatProvisioningService {
|
||||
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
|
||||
isSpace: true,
|
||||
});
|
||||
// The space itself, not only the rooms under it: Element lists a space in
|
||||
// the left rail only for members of that space, so skipping this scatters
|
||||
// every dept room loose into Home and the "EDR Freight" grouping never
|
||||
// appears at all.
|
||||
await this.matrix.ensureJoined(spaceId, mxid);
|
||||
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
|
||||
parentSpaceId: spaceId,
|
||||
});
|
||||
await this.matrix.ensureJoined(generalRoomId, mxid);
|
||||
// Without this a new hire sees no bridged notification until the nightly
|
||||
// reconcile puts them in the alerts room.
|
||||
const alertsRoomId = await this.matrix.ensureRoom(
|
||||
ALERTS_ROOM.alias,
|
||||
ALERTS_ROOM.name,
|
||||
{ parentSpaceId: spaceId },
|
||||
);
|
||||
await this.matrix.ensureJoined(alertsRoomId, mxid);
|
||||
|
||||
for (const position of positions) {
|
||||
const roomId = await this.matrix.ensureRoom(
|
||||
@@ -127,10 +146,10 @@ export class ChatProvisioningService {
|
||||
await this.matrix.ensureJoined(roomId, mxid);
|
||||
}
|
||||
|
||||
return positions.length + 1;
|
||||
return positions.length + 3; // space + general + alerts
|
||||
}
|
||||
|
||||
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
|
||||
/** Force-joins additions, kicks users no longer entitled to this room. */
|
||||
private async syncMembership(
|
||||
roomId: string,
|
||||
desiredUserIds: Set<string>,
|
||||
@@ -139,6 +158,19 @@ export class ChatProvisioningService {
|
||||
const current = await this.matrix.joinedMembers(roomId);
|
||||
const currentSet = new Set(current.filter((id) => id !== botMxid));
|
||||
|
||||
// An empty desired set against a populated room is not "everyone left" —
|
||||
// it is a query that failed, a key that drifted, or a migration caught
|
||||
// mid-flight. Acting on it would clear the room and then lock every
|
||||
// account that was in it. {@link reconcile} guards the same shape at the
|
||||
// unit level; this is the per-room backstop for the paths it cannot see.
|
||||
if (desiredUserIds.size === 0 && currentSet.size > 0) {
|
||||
this.logger.warn(
|
||||
`Refusing to empty room ${roomId}: desired membership is empty while ` +
|
||||
`${currentSet.size} member(s) are joined. Left untouched.`,
|
||||
);
|
||||
return { joined: 0, kicked: [] };
|
||||
}
|
||||
|
||||
let joined = 0;
|
||||
for (const userId of desiredUserIds) {
|
||||
if (!currentSet.has(userId)) {
|
||||
@@ -160,6 +192,17 @@ export class ChatProvisioningService {
|
||||
|
||||
async reconcile(): Promise<ReconcileResult> {
|
||||
const holders = await this.currentHolders();
|
||||
// The desired state for the whole unit. Empty means the IAM query failed,
|
||||
// the org/unit keys drifted, or a migration is mid-flight — it never means
|
||||
// every employee left at once. Continuing would kick every member of every
|
||||
// room and lock every account, so refuse the run and keep yesterday's
|
||||
// state, which is wrong at worst by a day.
|
||||
if (holders.length === 0) {
|
||||
throw new Error(
|
||||
`Chat reconcile aborted: no current position holders for ${ORG_KEY}/${UNIT_KEY}. ` +
|
||||
'Refusing to read that as "remove everyone".',
|
||||
);
|
||||
}
|
||||
const botMxid = await this.matrix.whoami();
|
||||
|
||||
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
|
||||
@@ -168,6 +211,11 @@ export class ChatProvisioningService {
|
||||
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
|
||||
parentSpaceId: spaceId,
|
||||
});
|
||||
const alertsRoomId = await this.matrix.ensureRoom(
|
||||
ALERTS_ROOM.alias,
|
||||
ALERTS_ROOM.name,
|
||||
{ parentSpaceId: spaceId },
|
||||
);
|
||||
|
||||
const allUserIds = new Set(
|
||||
holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)),
|
||||
@@ -184,19 +232,32 @@ export class ChatProvisioningService {
|
||||
await this.matrix.ensureUser(mxid, h.userName);
|
||||
}
|
||||
|
||||
let rooms = 2; // space + general
|
||||
let rooms = 3; // space + general + alerts
|
||||
let joined = 0;
|
||||
let kicked = 0;
|
||||
// A user kicked from anything while holding zero current positions
|
||||
// anywhere in the unit (allUserIds spans every position) is a full
|
||||
// leaver, not just moved between positions — deactivate their account.
|
||||
// leaver, not just moved between positions — lock their account.
|
||||
const kickedUserIds = new Set<string>();
|
||||
|
||||
// Space membership follows the org tree exactly like room membership —
|
||||
// see the ensureJoined in joinUserRooms for why the space needs joining
|
||||
// at all.
|
||||
const spaceDiff = await this.syncMembership(spaceId, allUserIds, botMxid);
|
||||
joined += spaceDiff.joined;
|
||||
kicked += spaceDiff.kicked.length;
|
||||
spaceDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
|
||||
|
||||
const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid);
|
||||
joined += generalDiff.joined;
|
||||
kicked += generalDiff.kicked.length;
|
||||
generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
|
||||
|
||||
const alertsDiff = await this.syncMembership(alertsRoomId, allUserIds, botMxid);
|
||||
joined += alertsDiff.joined;
|
||||
kicked += alertsDiff.kicked.length;
|
||||
alertsDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
|
||||
|
||||
const byPosition = new Map<string, { name: string; userIds: Set<string> }>();
|
||||
for (const h of holders) {
|
||||
const entry = byPosition.get(h.positionKey) ?? {
|
||||
@@ -219,19 +280,19 @@ export class ChatProvisioningService {
|
||||
diff.kicked.forEach((uid) => kickedUserIds.add(uid));
|
||||
}
|
||||
|
||||
let deactivated = 0;
|
||||
let locked = 0;
|
||||
for (const userId of kickedUserIds) {
|
||||
if (allUserIds.has(userId)) continue; // moved position, still current elsewhere
|
||||
try {
|
||||
await this.matrix.deactivateUser(userId);
|
||||
deactivated += 1;
|
||||
await this.matrix.lockUser(userId);
|
||||
locked += 1;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to deactivate departed user ${userId}: ${(err as Error).message}`,
|
||||
`Failed to lock departed user ${userId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { rooms, joined, kicked, deactivated };
|
||||
return { rooms, joined, kicked, locked };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { MatrixClient } from './matrix.client';
|
||||
providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService],
|
||||
// ChatBridgeService: consumed by NotificationInboxModule to mirror
|
||||
// BACKOFFICE notifications into chat — see notification-inbox.module.ts.
|
||||
exports: [ChatBridgeService],
|
||||
// MatrixClient: HealthModule's readiness probe reports whether
|
||||
// MATRIX_ADMIN_TOKEN really carries server-admin rights.
|
||||
exports: [ChatBridgeService, MatrixClient],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { chatLocalpart } from './matrix.client';
|
||||
import type { ChatConfig } from '../../config/chat.config';
|
||||
import { MatrixClient, chatLocalpart } from './matrix.client';
|
||||
|
||||
describe('chatLocalpart', () => {
|
||||
it('reads from the name, not the id', () => {
|
||||
@@ -33,3 +34,180 @@ describe('chatLocalpart', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const config: ChatConfig = {
|
||||
enabled: true,
|
||||
baseUrl: 'https://matrix.test',
|
||||
publicBaseUrl: 'https://matrix.test',
|
||||
webUrl: 'https://chat.test',
|
||||
serverName: 'matrix.test',
|
||||
jwtSecret: 'secret',
|
||||
adminToken: 'syt_whatever',
|
||||
};
|
||||
|
||||
type FetchFn = typeof globalThis.fetch;
|
||||
|
||||
/** Just enough of a Response for {@link MatrixClient}'s fetch wrappers. */
|
||||
function response(status: number, body: unknown) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
const realFetch: FetchFn = globalThis.fetch;
|
||||
const fetchMock = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
globalThis.fetch = fetchMock as unknown as FetchFn;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe('MatrixClient.verifyServerAdmin', () => {
|
||||
it('accepts a token that can actually call the Synapse admin API', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
|
||||
.mockResolvedValueOnce(response(200, { users: [], total: 1 }));
|
||||
|
||||
const check = await new MatrixClient(config).verifyServerAdmin();
|
||||
|
||||
expect(check).toEqual({ ok: true, actingAs: '@edrbot:matrix.test' });
|
||||
// The admin ping is the check. If this ever regresses to whoami alone,
|
||||
// the assertion below is what catches it.
|
||||
expect(String(fetchMock.mock.calls[1][0])).toContain('/_synapse/admin/');
|
||||
});
|
||||
|
||||
it('rejects a valid token that is not a server admin', async () => {
|
||||
// The dev outage, exactly: MATRIX_ADMIN_TOKEN held @super-admin's own
|
||||
// token. whoami answered 200, every /_synapse/admin call answered 403,
|
||||
// ensureUser threw, ChatSsoService swallowed it, and every employee got a
|
||||
// working sign-in into an Element with no rooms in it.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response(200, { user_id: '@super-admin.f15347:matrix.test' }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
response(403, {
|
||||
errcode: 'M_FORBIDDEN',
|
||||
error: 'You are not a server admin',
|
||||
}),
|
||||
);
|
||||
|
||||
const check = await new MatrixClient(config).verifyServerAdmin();
|
||||
|
||||
expect(check.ok).toBe(false);
|
||||
// Naming the account the token belongs to is the whole point — it is what
|
||||
// turns "chat is broken" into "wrong token in the env".
|
||||
expect(check.actingAs).toBe('@super-admin.f15347:matrix.test');
|
||||
expect(check.error).toContain('403');
|
||||
});
|
||||
|
||||
it('rejects a token that is not valid at all', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
response(401, { errcode: 'M_UNKNOWN_TOKEN', error: 'Invalid access token' }),
|
||||
);
|
||||
|
||||
const check = await new MatrixClient(config).verifyServerAdmin();
|
||||
|
||||
expect(check.ok).toBe(false);
|
||||
expect(check.actingAs).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // no point pinging admin after this
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixClient.ensureUser', () => {
|
||||
it('lifts the lock on a returning employee', async () => {
|
||||
// A previous reconcile locked them as a leaver. Force-joining them back
|
||||
// into rooms while they still cannot log in is a silent half-restore.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response(200, { name: '@naa.03f5eb:matrix.test', locked: true }),
|
||||
)
|
||||
.mockResolvedValueOnce(response(200, {}));
|
||||
|
||||
await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const [url, init] = fetchMock.mock.calls[1] as [string, { body: string }];
|
||||
expect(String(url)).toContain('/_synapse/admin/v2/users/');
|
||||
expect(JSON.parse(init.body)).toEqual({ locked: false });
|
||||
});
|
||||
|
||||
it('leaves an account that is not locked alone', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
response(200, { name: '@naa.03f5eb:matrix.test', locked: false }),
|
||||
);
|
||||
|
||||
await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixClient rate limiting', () => {
|
||||
it('retries a 429 after the delay Synapse asks for', async () => {
|
||||
// The dev outage: a reconcile is a burst of writes, Synapse throttled an
|
||||
// m.space.child PUT, and one un-retried 429 threw the whole run away.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
|
||||
.mockResolvedValueOnce(
|
||||
response(429, {
|
||||
errcode: 'M_LIMIT_EXCEEDED',
|
||||
error: 'Too Many Requests',
|
||||
retry_after_ms: 1,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(response(200, { users: [] }));
|
||||
|
||||
const check = await new MatrixClient(config).verifyServerAdmin();
|
||||
|
||||
expect(check.ok).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('gives up rather than hanging on a homeserver that only ever 429s', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
response(429, { errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1 }),
|
||||
);
|
||||
|
||||
const check = await new MatrixClient(config).verifyServerAdmin();
|
||||
|
||||
expect(check.ok).toBe(false);
|
||||
expect(check.error).toContain('429');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixClient.adminCheck', () => {
|
||||
it('does not re-hit Synapse on every readiness probe', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
|
||||
.mockResolvedValueOnce(response(200, { users: [] }));
|
||||
|
||||
const client = new MatrixClient(config);
|
||||
const first = await client.adminCheck();
|
||||
const second = await client.adminCheck();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2); // whoami + admin ping, once
|
||||
});
|
||||
|
||||
it('re-checks when forced, so boot never reads a stale verdict', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
|
||||
.mockResolvedValueOnce(response(200, { users: [] }))
|
||||
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
|
||||
.mockResolvedValueOnce(response(200, { users: [] }));
|
||||
|
||||
const client = new MatrixClient(config);
|
||||
await client.adminCheck();
|
||||
await client.adminCheck(true);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { OnApplicationBootstrap } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
|
||||
import chatConfig from '../../config/chat.config';
|
||||
@@ -40,8 +41,27 @@ export function chatLocalpart(userId: string, displayName: string): string {
|
||||
return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`;
|
||||
}
|
||||
|
||||
/** Result of {@link MatrixClient.verifyServerAdmin}. */
|
||||
export interface AdminCheck {
|
||||
ok: boolean;
|
||||
/** Who MATRIX_ADMIN_TOKEN belongs to — present whenever the token is valid
|
||||
* at all, including when it is valid but carries no admin rights. */
|
||||
actingAs?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MatrixClient {
|
||||
export class MatrixClient implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(MatrixClient.name);
|
||||
|
||||
/** The token is a deploy-time fact and the readiness probe runs every few
|
||||
* seconds, so {@link adminCheck} memoises for this long. */
|
||||
private static readonly ADMIN_CHECK_TTL_MS = 5 * 60_000;
|
||||
/** Enough to ride out Synapse's limiter; short enough that a genuinely
|
||||
* wedged homeserver still fails the run rather than hanging it. */
|
||||
private static readonly MAX_RATE_LIMIT_RETRIES = 5;
|
||||
private adminCheckCache?: { at: number; result: AdminCheck };
|
||||
|
||||
constructor(
|
||||
@Inject(chatConfig.KEY)
|
||||
private readonly config: ConfigType<typeof chatConfig>,
|
||||
@@ -73,13 +93,48 @@ export class MatrixClient {
|
||||
return this.config.serverName;
|
||||
}
|
||||
|
||||
/** MATRIX_ENABLED — read by the readiness probe to tell "off" from "broken". */
|
||||
get enabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synapse answers a burst of writes with 429 + `retry_after_ms`, and a
|
||||
* reconcile is nothing but a burst of writes — one run creates the space,
|
||||
* #general and a room per position, then force-joins every holder into each.
|
||||
* The first run against dev tripped the limiter on an `m.space.child` PUT,
|
||||
* and because nothing retried, that single 429 threw the whole reconcile
|
||||
* away mid-flight. On the sign-in path ChatSsoService swallows the throw, so
|
||||
* the only visible symptom was an empty Element.
|
||||
*
|
||||
* Honour the delay Synapse asks for rather than guessing at one.
|
||||
*/
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
init: Parameters<typeof fetch>[1],
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const res = await fetch(url, init);
|
||||
if (res.status !== 429 || attempt >= MatrixClient.MAX_RATE_LIMIT_RETRIES) {
|
||||
return res;
|
||||
}
|
||||
// Body is discarded either way — this response is being retried.
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
retry_after_ms?: number;
|
||||
};
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, (Number(body.retry_after_ms) || 1000) + 100),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
token: string = this.config.adminToken,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -103,7 +158,7 @@ export class MatrixClient {
|
||||
path: string,
|
||||
body: unknown,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -123,7 +178,7 @@ export class MatrixClient {
|
||||
path: string,
|
||||
token?: string,
|
||||
): Promise<T | null> {
|
||||
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` },
|
||||
});
|
||||
@@ -157,6 +212,68 @@ export class MatrixClient {
|
||||
return res.user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is MATRIX_ADMIN_TOKEN actually a *server admin* token?
|
||||
*
|
||||
* `whoami` cannot answer this: it returns 200 for any valid user token at
|
||||
* all. Dev shipped with MATRIX_ADMIN_TOKEN holding an ordinary staff
|
||||
* account's token — whoami said 200, every `/_synapse/admin/*` call said
|
||||
* 403 "You are not a server admin", `ensureUser` threw, ChatSsoService
|
||||
* swallowed it (by design — a failed room join must not deny anyone a
|
||||
* sign-in link), and every employee got a working sign-in into a client
|
||||
* with no rooms in it. Nothing else in the system noticed.
|
||||
*
|
||||
* So this pings an endpoint only a server admin may call, and reports who
|
||||
* the token belongs to — the one fact that makes the mix-up obvious.
|
||||
*/
|
||||
async verifyServerAdmin(): Promise<AdminCheck> {
|
||||
let actingAs: string | undefined;
|
||||
try {
|
||||
actingAs = await this.whoami();
|
||||
await this.request('GET', '/_synapse/admin/v2/users?limit=1');
|
||||
return { ok: true, actingAs };
|
||||
} catch (err) {
|
||||
return { ok: false, actingAs, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link verifyServerAdmin}, memoised for {@link ADMIN_CHECK_TTL_MS}. */
|
||||
async adminCheck(force = false): Promise<AdminCheck> {
|
||||
const cached = this.adminCheckCache;
|
||||
if (
|
||||
!force &&
|
||||
cached &&
|
||||
Date.now() - cached.at < MatrixClient.ADMIN_CHECK_TTL_MS
|
||||
) {
|
||||
return cached.result;
|
||||
}
|
||||
const result = await this.verifyServerAdmin();
|
||||
this.adminCheckCache = { at: Date.now(), result };
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail loud at boot instead of silently on every sign-in. Logged, never
|
||||
* thrown: chat provisioning must not be able to stop the API from starting,
|
||||
* the same contract the reconcile cron and the notification bridge hold to.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.config.enabled) return;
|
||||
const check = await this.adminCheck(true);
|
||||
if (check.ok) {
|
||||
this.logger.log(
|
||||
`MATRIX_ADMIN_TOKEN verified — server admin as ${check.actingAs}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.error(
|
||||
'MATRIX_ADMIN_TOKEN is not a server-admin token' +
|
||||
(check.actingAs ? ` (it belongs to ${check.actingAs})` : '') +
|
||||
`: ${check.error}. Chat provisioning will create no rooms, and every ` +
|
||||
'employee who opens chat will land in an empty Element.',
|
||||
);
|
||||
}
|
||||
|
||||
/** Currently-joined user ids for a room (not full member-event state). */
|
||||
async joinedMembers(roomId: string): Promise<string[]> {
|
||||
const res = await this.request<{ joined: Record<string, unknown> }>(
|
||||
@@ -249,11 +366,18 @@ export class MatrixClient {
|
||||
* ("User not found") on an account that doesn't exist yet.
|
||||
*/
|
||||
async ensureUser(userId: string, displayName?: string): Promise<void> {
|
||||
const existing = await this.requestOrNull<{ name: string }>(
|
||||
const existing = await this.requestOrNull<{ name: string; locked?: boolean }>(
|
||||
'GET',
|
||||
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
|
||||
);
|
||||
if (existing) return;
|
||||
if (existing) {
|
||||
// A returning employee is still locked from the reconcile that saw them
|
||||
// leave. Force-joining them into rooms while they cannot log in is a
|
||||
// silent half-restore, and this is the one call that already knows the
|
||||
// flag — so undo it here rather than making the caller ask again.
|
||||
if (existing.locked) await this.setLocked(userId, false);
|
||||
return;
|
||||
}
|
||||
await this.request(
|
||||
'PUT',
|
||||
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
|
||||
@@ -293,12 +417,30 @@ export class MatrixClient {
|
||||
);
|
||||
}
|
||||
|
||||
/** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */
|
||||
deactivateUser(userId: string): Promise<void> {
|
||||
/**
|
||||
* Lock a departed employee out of chat — reversible, unlike deactivation.
|
||||
*
|
||||
* This used to call `/_synapse/admin/v1/deactivate`. That revokes sessions
|
||||
* the same way but cannot be undone in any useful sense on this deployment:
|
||||
* reactivation wants a password, and `password_config.enabled: false` means
|
||||
* there is none to set. Room memberships do not come back either. One bad
|
||||
* reconcile — a half-applied IAM migration, a renamed org key — would have
|
||||
* destroyed every staff account that way, permanently.
|
||||
*
|
||||
* Locking blocks exactly the same access (Synapse rejects the account's
|
||||
* tokens with M_USER_LOCKED and refuses new logins) and is undone with a
|
||||
* single PUT — see {@link ensureUser}, which lifts it automatically when
|
||||
* someone comes back.
|
||||
*/
|
||||
lockUser(userId: string): Promise<void> {
|
||||
return this.setLocked(userId, true);
|
||||
}
|
||||
|
||||
private setLocked(userId: string, locked: boolean): Promise<void> {
|
||||
return this.request(
|
||||
'POST',
|
||||
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
|
||||
{ erase: false },
|
||||
'PUT',
|
||||
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
|
||||
{ locked },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||
import { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto";
|
||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||
import {
|
||||
CompanyIdentityStateDto,
|
||||
@@ -268,11 +270,42 @@ export class CompaniesController {
|
||||
): Promise<ResponseCompanyProfileDto[]> {
|
||||
const profiles = await this.companiesService.addCompanyProfilesForUser(
|
||||
user.id,
|
||||
dto.types,
|
||||
dto.profiles,
|
||||
);
|
||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||
}
|
||||
|
||||
@Get("etrade-businesses")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The eTrade business licences under this company's TIN, for attaching to its operational profiles",
|
||||
})
|
||||
async listEtradeBusinesses(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<ETradeBusinessOption[]> {
|
||||
return this.companiesService.listEtradeBusinessesForUser(user.id);
|
||||
}
|
||||
|
||||
@Patch("company-profiles/:profileId/etrade-business")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Attach one of the TIN's eTrade businesses to an operational profile (re-attaching refreshes the stored snapshot)",
|
||||
})
|
||||
async attachEtradeBusiness(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId") profileId: string,
|
||||
@Body() dto: AttachEtradeBusinessDto,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
const profile = await this.companiesService.attachEtradeBusinessToProfile(
|
||||
user.id,
|
||||
profileId,
|
||||
dto.licenceNumber,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@Post("onboarding/start")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
@@ -329,6 +362,7 @@ export class CompaniesController {
|
||||
user.id,
|
||||
dto.type,
|
||||
dto.businessLicense,
|
||||
dto.licenceNumber,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
deps.fileUploadSettings as never,
|
||||
{} as never,
|
||||
// Only the business-licence lookup is exercised here: adding a role now
|
||||
// resolves which eTrade business it operates as.
|
||||
{
|
||||
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
|
||||
licenceNumber,
|
||||
tradeName: "Test Trade Name",
|
||||
activity: "Freight Forwarders",
|
||||
renewedTo: "7/7/2026",
|
||||
}),
|
||||
} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
deps.verifayda as never,
|
||||
@@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => {
|
||||
|
||||
describe("the freight-forwarder gate", () => {
|
||||
const addForwarder = (service: CompaniesService) =>
|
||||
service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]);
|
||||
service.addCompanyProfilesForUser("user-1", [
|
||||
{ type: ProfileType.freightForwarder, licenceNumber: "LIC-1" },
|
||||
]);
|
||||
|
||||
it("blocks the role while the representative is unverified", async () => {
|
||||
const { service } = makeService({ attributes: { poaDeclared: "yes" } });
|
||||
|
||||
@@ -133,7 +133,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
// Only the business-licence lookup is exercised here: adding a role now
|
||||
// resolves which eTrade business it operates as.
|
||||
{
|
||||
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
|
||||
licenceNumber,
|
||||
tradeName: "Test Trade Name",
|
||||
activity: "Freight Forwarders",
|
||||
renewedTo: "7/7/2026",
|
||||
}),
|
||||
} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
undefined,
|
||||
"LIC-1",
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
undefined,
|
||||
"LIC-1",
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
undefined,
|
||||
"LIC-1",
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { ProfileType } from "./entities/company-profile.entity";
|
||||
import { COOPERATIVE_KEY } from "./entities/company.entity";
|
||||
|
||||
/**
|
||||
* A TIN holds many business licences; each operational profile names the one it
|
||||
* trades as. What matters here is that the stored business is always eTrade's
|
||||
* own record, looked up under the company's own TIN — never the client's word
|
||||
* for it — and that the requirement lifts for a company eTrade knows nothing
|
||||
* about.
|
||||
*/
|
||||
const BUSINESSES = [
|
||||
{
|
||||
licenceNumber: "MT/AA/14/670/128936/2007",
|
||||
tradeName: "Pave Freight Forwarding",
|
||||
activity: "Freight Forwarders",
|
||||
renewedTo: "7/7/2026",
|
||||
},
|
||||
{
|
||||
licenceNumber: "MT/AA/14/670/11551235/2017",
|
||||
tradeName: "Pave Minerals Export",
|
||||
activity: "Export trade in minerals",
|
||||
renewedTo: "7/7/2026",
|
||||
},
|
||||
];
|
||||
|
||||
function makeService(attributes: Record<string, unknown> = {}) {
|
||||
const company = {
|
||||
id: "company-1",
|
||||
tin: "0045014036",
|
||||
type: "customer",
|
||||
attributes,
|
||||
companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }],
|
||||
};
|
||||
|
||||
const created: Record<string, unknown>[] = [];
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async () => created),
|
||||
findByType: jest.fn(async () => null),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => {
|
||||
created.push({ id: `profile-${created.length + 2}`, ...row });
|
||||
return created[created.length - 1];
|
||||
}),
|
||||
update: jest.fn(async (id: string, data: Record<string, unknown>) => ({
|
||||
id,
|
||||
...data,
|
||||
})),
|
||||
};
|
||||
|
||||
const etradeService = {
|
||||
listBusinessOptions: jest.fn(async () => BUSINESSES),
|
||||
findBusinessOption: jest.fn(async (_tin: string, licenceNumber: string) => {
|
||||
const match = BUSINESSES.find((b) => b.licenceNumber === licenceNumber);
|
||||
if (!match) throw new BadRequestException("no such licence");
|
||||
return match;
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
{} as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ findByUserId: jest.fn(async () => ({ id: "ext-1", companyId: "company-1" })) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
etradeService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(async () => ({ profile: {}, company }) as never);
|
||||
// Private, but every add path goes through it; stubbing it keeps this spec on
|
||||
// the business-attachment logic instead of the whole company lookup graph.
|
||||
(service as unknown as Record<string, unknown>).findCompanyById = async () =>
|
||||
company;
|
||||
|
||||
return { service, companyProfilesRepo, etradeService };
|
||||
}
|
||||
|
||||
describe("attaching an eTrade business to a company profile", () => {
|
||||
it("stores eTrade's own record for the chosen licence, not the client's", async () => {
|
||||
const { service, companyProfilesRepo } = makeService();
|
||||
const updated = await service.attachEtradeBusinessToProfile(
|
||||
"user-1",
|
||||
"profile-1",
|
||||
"MT/AA/14/670/128936/2007",
|
||||
);
|
||||
expect(companyProfilesRepo.update).toHaveBeenCalledWith("profile-1", {
|
||||
etradeBusiness: BUSINESSES[0],
|
||||
});
|
||||
expect(updated.etradeBusiness).toEqual(BUSINESSES[0]);
|
||||
});
|
||||
|
||||
it("refuses a licence eTrade does not list under this TIN", async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.attachEtradeBusinessToProfile("user-1", "profile-1", "SOMEONE/ELSES/LICENCE"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses a profile belonging to another company", async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.attachEtradeBusinessToProfile("user-1", "not-mine", BUSINESSES[0].licenceNumber),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("the same business may back more than one profile", async () => {
|
||||
const { service, etradeService } = makeService();
|
||||
await service.addCompanyProfilesForUser("user-1", [
|
||||
{ type: ProfileType.exporter, licenceNumber: BUSINESSES[0].licenceNumber },
|
||||
{ type: ProfileType.importer, licenceNumber: BUSINESSES[0].licenceNumber },
|
||||
]);
|
||||
expect(etradeService.findBusinessOption).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("choosing a business is required when the company has one to choose", () => {
|
||||
it("rejects a role added without a licence", async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.addCompanyProfilesForUser("user-1", [{ type: ProfileType.exporter }]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("lifts the requirement for a co-operative, which has no eTrade record", async () => {
|
||||
const { service, companyProfilesRepo, etradeService } = makeService({
|
||||
[COOPERATIVE_KEY]: true,
|
||||
});
|
||||
await service.addCompanyProfilesForUser("user-1", [
|
||||
{ type: ProfileType.exporter },
|
||||
]);
|
||||
expect(etradeService.findBusinessOption).not.toHaveBeenCalled();
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ etradeBusiness: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a co-operative no businesses to pick from", async () => {
|
||||
const { service } = makeService({ [COOPERATIVE_KEY]: true });
|
||||
await expect(service.listEtradeBusinessesForUser("user-1")).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
createdTo,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
profileType,
|
||||
sortBy = 'review',
|
||||
sortOrder = 'DESC',
|
||||
} = query;
|
||||
@@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
|
||||
if (search) {
|
||||
const term = `%${search.trim()}%`;
|
||||
// Staff search by whatever is in front of them: the company name, the
|
||||
// TIN/email, a profile reference off a document — and, since a TIN holds
|
||||
// many licences, the trade name or licence number of the specific
|
||||
// business a role operates as. All the per-profile terms share one EXISTS
|
||||
// so a match on any of them qualifies the company once.
|
||||
qb.andWhere(
|
||||
`(company.name ILIKE :term
|
||||
OR company.tin ILIKE :term
|
||||
OR company.email ILIKE :term
|
||||
OR company.licence_number ILIKE :term
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = company.id
|
||||
AND cp.reference ILIKE :term
|
||||
AND cp.deleted_at IS NULL
|
||||
AND (
|
||||
cp.reference ILIKE :term
|
||||
OR cp.etrade_business->>'tradeName' ILIKE :term
|
||||
OR cp.etrade_business->>'licenceNumber' ILIKE :term
|
||||
)
|
||||
))`,
|
||||
{ term },
|
||||
);
|
||||
}
|
||||
|
||||
// Companies holding a given operational role. EXISTS rather than a filter
|
||||
// on the joined `companyProfiles` alias: constraining the join would drop
|
||||
// the company's OTHER profiles from the loaded entity, so the list would
|
||||
// render an exporter-and-importer as importer-only.
|
||||
if (profileType) {
|
||||
qb.andWhere(
|
||||
`EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp_type
|
||||
WHERE cp_type.company_id = company.id
|
||||
AND cp_type.deleted_at IS NULL
|
||||
AND cp_type.type = :profileType
|
||||
)`,
|
||||
{ profileType },
|
||||
);
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||
if (sortBy === 'review') {
|
||||
// Queue ordering: actionable tiers first, newest first within each. The
|
||||
|
||||
@@ -47,7 +47,7 @@ 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 type { CompanyRegistrationData, ETradeBusinessOption } 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";
|
||||
@@ -343,6 +343,11 @@ export class CompaniesService {
|
||||
companyId: company.id,
|
||||
type: input.type,
|
||||
businessLicense: input.businessLicense ?? null,
|
||||
etradeBusiness: await this.resolveProfileBusiness(
|
||||
company,
|
||||
input.licenceNumber,
|
||||
input.type,
|
||||
),
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
@@ -2149,8 +2154,9 @@ export class CompaniesService {
|
||||
*/
|
||||
async addCompanyProfilesForUser(
|
||||
userId: string,
|
||||
types: ProfileType[],
|
||||
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
|
||||
): Promise<CompanyProfile[]> {
|
||||
const types = inputs.map((i) => i.type);
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
@@ -2185,11 +2191,21 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Which eTrade business this role operates as. Resolved (and rejected if
|
||||
// absent) BEFORE the row is created, so a role never lands unattached on
|
||||
// a company that has licences to pick from.
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
inputs.find((i) => i.type === type)?.licenceNumber,
|
||||
type,
|
||||
);
|
||||
|
||||
// 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({
|
||||
companyId,
|
||||
type,
|
||||
etradeBusiness,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
@@ -2207,6 +2223,7 @@ export class CompaniesService {
|
||||
userId: string,
|
||||
type: ProfileType,
|
||||
businessLicense?: string,
|
||||
licenceNumber?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
@@ -2232,12 +2249,18 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
if (!created) {
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
licenceNumber,
|
||||
type,
|
||||
);
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved.
|
||||
created = await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
businessLicense: businessLicense ?? null,
|
||||
etradeBusiness,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
@@ -2320,6 +2343,7 @@ export class CompaniesService {
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
||||
etradeBusiness: p.etradeBusiness ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -2331,6 +2355,19 @@ export class CompaniesService {
|
||||
? []
|
||||
: licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
// Which eTrade business each role operates as. Enforced here rather than at
|
||||
// role creation because the wizard picks roles on its FIRST step, before a
|
||||
// TIN has been entered — there is nothing to pick from yet. The customer
|
||||
// attaches one on the documents step, alongside that role's licence file,
|
||||
// and onboarding cannot be submitted until every role has one.
|
||||
//
|
||||
// Lifted for a company with no eTrade record at all: a co-operative or a
|
||||
// foreign investor has no licence list, so the requirement would be
|
||||
// unsatisfiable (see `usesManualRegistration`).
|
||||
const missingBusinesses = usesManualRegistration(company)
|
||||
? []
|
||||
: licenseProfiles.filter((p) => !p.etradeBusiness);
|
||||
|
||||
// 4. Power of Attorney. Whether there is one at all is the company's own
|
||||
// declaration — the question the wizard asks outright — and that answer is
|
||||
// what decides whose identity gets verified, so an unanswered one is itself
|
||||
@@ -2378,6 +2415,10 @@ export class CompaniesService {
|
||||
(p) =>
|
||||
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
||||
),
|
||||
...missingBusinesses.map(
|
||||
(p) =>
|
||||
`Choose which eTrade business your ${p.type.replace(/_/g, " ")} profile operates as`,
|
||||
),
|
||||
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...(missingDelegation
|
||||
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
||||
@@ -2416,6 +2457,8 @@ export class CompaniesService {
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
(cooperative ? 0 : licenseProfiles.length) +
|
||||
// One "which business?" item per role, on the same terms as the licences.
|
||||
(usesManualRegistration(company) ? 0 : licenseProfiles.length) +
|
||||
poaItemCount +
|
||||
// The declaration and the verification it selects.
|
||||
2;
|
||||
@@ -2424,6 +2467,7 @@ export class CompaniesService {
|
||||
(missingInfo.length +
|
||||
missingDocs.length +
|
||||
missingLicenses.length +
|
||||
missingBusinesses.length +
|
||||
missingPoaFields.length +
|
||||
(missingDelegation || flaggedDelegation ? 1 : 0) +
|
||||
missingIdentityCount);
|
||||
@@ -3747,6 +3791,86 @@ export class CompaniesService {
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the eTrade business a new/updated profile is being attached to.
|
||||
*
|
||||
* The client sends a licence number; what gets stored is eTrade's own record
|
||||
* of it, looked up under THIS company's TIN. That is the whole check — a
|
||||
* licence belonging to someone else's TIN simply is not in the list, so a
|
||||
* client cannot attach a profile to a business the company does not hold.
|
||||
*
|
||||
* Returns null (rather than throwing) for a company that registered without
|
||||
* eTrade: a co-operative union or farm holds no business licence, and a
|
||||
* foreign investor's licence is the Investment Commission's, not the trade
|
||||
* registry's. There is no list for them to pick from, so the role is theirs
|
||||
* to hold unattached — the reviewer checks their uploaded documents instead.
|
||||
*/
|
||||
private async resolveProfileBusiness(
|
||||
company: Company,
|
||||
licenceNumber: string | undefined,
|
||||
type: ProfileType,
|
||||
): Promise<ETradeBusinessOption | null> {
|
||||
if (usesManualRegistration(company)) return null;
|
||||
if (!licenceNumber) {
|
||||
throw new BadRequestException(
|
||||
`Choose which of your eTrade business licences the ${type.replace(/_/g, " ")} profile operates as.`,
|
||||
);
|
||||
}
|
||||
return this.etradeService.findBusinessOption(company.tin, licenceNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* The eTrade business licences the current user's company can attach to its
|
||||
* operational profiles. Empty for a company that registered without eTrade.
|
||||
*/
|
||||
async listEtradeBusinessesForUser(
|
||||
userId: string,
|
||||
): Promise<ETradeBusinessOption[]> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
if (usesManualRegistration(company)) return [];
|
||||
return this.etradeService.listBusinessOptions(company.tin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach (or re-attach) one of the TIN's eTrade businesses to a profile.
|
||||
*
|
||||
* Separate from role creation because the onboarding wizard picks roles
|
||||
* before the TIN is known — the business is chosen later, on the step that
|
||||
* already collects each role's licence document. Re-attaching also refreshes
|
||||
* the stored snapshot, which is how a renewed licence's new expiry lands.
|
||||
*/
|
||||
async attachEtradeBusinessToProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
licenceNumber: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const profile = (company.companyProfiles ?? []).find(
|
||||
(p) => p.id === profileId,
|
||||
);
|
||||
if (!profile) {
|
||||
throw new NotFoundException(
|
||||
`Company profile ${profileId} not found for this company`,
|
||||
);
|
||||
}
|
||||
if (usesManualRegistration(company)) {
|
||||
throw new BadRequestException(
|
||||
"This company is not registered with eTrade, so it has no business licences to attach.",
|
||||
);
|
||||
}
|
||||
const business = await this.etradeService.findBusinessOption(
|
||||
company.tin,
|
||||
licenceNumber,
|
||||
);
|
||||
const updated = await this.companyProfilesRepo.update(profile.id, {
|
||||
etradeBusiness: business,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
|
||||
private async resolveEtradeRegistration(
|
||||
tin: string,
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class AddCompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as.
|
||||
*
|
||||
* Optional at the DTO layer, required by the service for any company that
|
||||
* HAS an eTrade record — a co-operative or investor-licence company has none
|
||||
* to pick from, and rejecting them here would be wrong. See
|
||||
* `CompaniesService.resolveProfileBusiness`.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
}
|
||||
|
||||
export class AddCompanyProfilesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsEnum(ProfileType, { each: true })
|
||||
types!: ProfileType[];
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AddCompanyProfileInputDto)
|
||||
profiles!: AddCompanyProfileInputDto[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class AttachEtradeBusinessDto {
|
||||
/**
|
||||
* The eTrade licence number of the business this profile operates as. Checked
|
||||
* against the licences eTrade lists under the company's own TIN, so an
|
||||
* unknown or someone else's licence is rejected rather than stored.
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
licenceNumber!: string;
|
||||
}
|
||||
@@ -9,4 +9,14 @@ export class CreateCompanyProfileDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as. Required
|
||||
* by the service for any company that has an eTrade record; see
|
||||
* `AddCompanyProfileInputDto.licenceNumber`.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ export class CompanyProfileInputDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as. Required
|
||||
* by the service for any company that has an eTrade record; see
|
||||
* `CompaniesService.resolveProfileBusiness`.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
}
|
||||
|
||||
export class CreateCompanyWithProfileDto {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class ListCompaniesQueryDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@@ -56,6 +57,16 @@ export class ListCompaniesQueryDto {
|
||||
@IsIn(Object.values(CompanyNationality))
|
||||
nationality?: CompanyNationality;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ProfileType,
|
||||
description:
|
||||
"Only companies holding this operational role. A company may hold " +
|
||||
"several; its other roles are still returned on the row.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(ProfileType))
|
||||
profileType?: ProfileType;
|
||||
|
||||
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* truth the wizard uses to auto-finish.
|
||||
*/
|
||||
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
import {
|
||||
CompanyIdentityStateDto,
|
||||
PoaDeclaration,
|
||||
@@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile {
|
||||
reference: string;
|
||||
/** True when at least one business-license file is stored on the profile. */
|
||||
uploaded: boolean;
|
||||
/**
|
||||
* The eTrade business this role operates as, once the customer has attached
|
||||
* one. Null while outstanding — the wizard renders the picker off this.
|
||||
*/
|
||||
etradeBusiness: ETradeBusinessOption | null;
|
||||
}
|
||||
|
||||
export interface OnboardingPoaState {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
hasInvestorLicence,
|
||||
isCooperative,
|
||||
} from '../entities/company.entity';
|
||||
import type { ETradeBusinessOption } from '@edr/types';
|
||||
import {
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
@@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto {
|
||||
*/
|
||||
licenseFiles: ProfileLicenseFileView[];
|
||||
attributes?: Record<string, any> | null;
|
||||
/**
|
||||
* The eTrade business licence this role operates as, or null when nothing is
|
||||
* attached yet (or the company registered without eTrade). Snapshot — see
|
||||
* `CompanyProfile.etradeBusiness`.
|
||||
*/
|
||||
etradeBusiness?: ETradeBusinessOption | null;
|
||||
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
||||
reviewNote?: string | null;
|
||||
createdAt: Date;
|
||||
@@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto {
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.licenseFiles = [];
|
||||
this.attributes = profile.attributes;
|
||||
this.etradeBusiness = profile.etradeBusiness ?? null;
|
||||
this.reviewNote = profile.reviewNote ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
@@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity {
|
||||
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
|
||||
businessLicenseFiles?: BusinessLicenseFile[] | null;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this profile operates as.
|
||||
*
|
||||
* A TIN holds many licences split by activity, so "exporter" and "freight
|
||||
* forwarder" are usually two different businesses under one company. Stored
|
||||
* as a snapshot rather than a bare licence number so the trade name and
|
||||
* activity render without an eTrade call — that API is slow and regularly
|
||||
* down, and this is display data, not a source of truth. Re-attaching
|
||||
* refreshes it.
|
||||
*
|
||||
* NULL when nothing is attached yet, or when the company registered without
|
||||
* eTrade at all (co-operative / investor licence — see
|
||||
* {@link usesManualRegistration}). One business may back several profiles.
|
||||
*/
|
||||
@Column({ name: "etrade_business", type: "jsonb", nullable: true })
|
||||
etradeBusiness?: ETradeBusinessOption | null;
|
||||
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
|
||||
@@ -78,6 +78,27 @@ describe('ETradeService business selection', () => {
|
||||
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
|
||||
});
|
||||
|
||||
it("takes the selected licence's trade name as the company name", () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
{
|
||||
LicenceNumber: 'MT/AA/14/670/128936/2007',
|
||||
TradeName: 'Pave Freight Forwarding',
|
||||
} as ETradeBusinessInfo,
|
||||
companyInfo(),
|
||||
);
|
||||
expect(data.companyName).toBe('Pave Freight Forwarding');
|
||||
});
|
||||
|
||||
it('falls back to the registered name when the licence has no trade name', () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
{ LicenceNumber: 'x', TradeName: ' ' } as ETradeBusinessInfo,
|
||||
companyInfo(),
|
||||
);
|
||||
expect(data.companyName).toBe('PAVE LOGISTICS AND TRADING P L C');
|
||||
});
|
||||
|
||||
it('lists every licence for the picker, code prefixes stripped', () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
ETradeCompanyInfo,
|
||||
ETradeBusinessInfo,
|
||||
ETradeBusinessOption,
|
||||
CompanyRegistrationData,
|
||||
normalizeRegion,
|
||||
} from "@edr/types";
|
||||
@@ -102,10 +103,16 @@ export class ETradeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* `companyInfo` carries the registered organization name (`BusinessName`);
|
||||
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
|
||||
* company name resolves to the legal entity rather than the trade name — and
|
||||
* never to `ManagerNameEng`, which is the manager's personal name.
|
||||
* `businessInfo` carries the selected licence's `TradeName`; `companyInfo`
|
||||
* carries the registered organization name (`BusinessName`). The company name
|
||||
* resolves to the trade name of the licence the customer picked — a TIN
|
||||
* routinely trades under a name that is not its registered one, and the
|
||||
* business they selected is the one they operate as here. `BusinessName` is
|
||||
* the fallback, because eTrade leaves `TradeName` blank on plenty of licences.
|
||||
* Never `ManagerNameEng`, which is the manager's personal name.
|
||||
*
|
||||
* Callers that need the legal entity (tax filings, EIMS seller details) must
|
||||
* read `companyInfo.BusinessName` themselves — it is not this field.
|
||||
*/
|
||||
extractRegistrationData(
|
||||
businessInfo: ETradeBusinessInfo,
|
||||
@@ -115,7 +122,7 @@ export class ETradeService {
|
||||
|
||||
return {
|
||||
companyName:
|
||||
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
|
||||
businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "",
|
||||
licenceNumber: businessInfo.LicenceNumber,
|
||||
statusDescription: businessInfo.StatusDescription,
|
||||
dateRegistered: businessInfo.DateRegistered,
|
||||
@@ -137,17 +144,57 @@ export class ETradeService {
|
||||
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
|
||||
managerName: primaryManager?.ManagerNameEng || "",
|
||||
managerPhone: primaryManager?.RegularPhone || "",
|
||||
businesses: (companyInfo?.Businesses ?? []).map((b) => ({
|
||||
licenceNumber: b.LicenceNumber,
|
||||
tradeName: b.TradesName?.trim() || "",
|
||||
activity: (b.SubGroups ?? [])
|
||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||
// eTrade also puts null entries in this array, so every hop is optional.
|
||||
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
renewedTo: b.RenewedTo || "",
|
||||
})),
|
||||
businesses: (companyInfo?.Businesses ?? []).map(toBusinessOption),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every business licence held under a TIN, as the customer picks them.
|
||||
*
|
||||
* Split out from {@link extractRegistrationData} because attaching a business
|
||||
* to a company profile needs the list alone — no licence detail fetch, so one
|
||||
* eTrade call instead of two.
|
||||
*/
|
||||
async listBusinessOptions(tin: string): Promise<ETradeBusinessOption[]> {
|
||||
const companyInfo = await this.getCompanyInfoByTin(tin);
|
||||
return (companyInfo.Businesses ?? []).map(toBusinessOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one of the TIN's licences, or throw if eTrade does not list it.
|
||||
*
|
||||
* This is the trust boundary for a client-supplied licence number: a profile
|
||||
* may only ever be attached to a business eTrade actually holds under that
|
||||
* TIN, so the snapshot that gets stored is eTrade's own data, never the
|
||||
* client's.
|
||||
*/
|
||||
async findBusinessOption(
|
||||
tin: string,
|
||||
licenceNumber: string,
|
||||
): Promise<ETradeBusinessOption> {
|
||||
const options = await this.listBusinessOptions(tin);
|
||||
const match = options.find((b) => b.licenceNumber === licenceNumber);
|
||||
if (!match) {
|
||||
throw new BadRequestException(
|
||||
`eTrade lists no business licence "${licenceNumber}" under TIN ${tin}.`,
|
||||
);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
function toBusinessOption(
|
||||
b: ETradeCompanyInfo["Businesses"][number],
|
||||
): ETradeBusinessOption {
|
||||
return {
|
||||
licenceNumber: b.LicenceNumber,
|
||||
tradeName: b.TradesName?.trim() || "",
|
||||
activity: (b.SubGroups ?? [])
|
||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||
// eTrade also puts null entries in this array, so every hop is optional.
|
||||
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
renewedTo: b.RenewedTo || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
import { Container } from './entities/container.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { WagonEventType } from '@edr/types';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
@Injectable()
|
||||
export class ContainersService {
|
||||
@@ -19,6 +21,7 @@ export class ContainersService {
|
||||
@InjectRepository(ContainerType)
|
||||
private readonly containerTypeRepo: Repository<ContainerType>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateContainerDto): Promise<Container> {
|
||||
@@ -150,7 +153,16 @@ export class ContainersService {
|
||||
// Placing a container on a wagon does not make it AVAILABLE. The status enum
|
||||
// (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON
|
||||
// state, so leave the existing status unchanged rather than forcing AVAILABLE.
|
||||
return containerRepo.save(container);
|
||||
const saved = await containerRepo.save(container);
|
||||
await this.wagonHistory.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.ContainerPlaced,
|
||||
toYardId: wagon.currentYardId ?? null,
|
||||
toValue: container.containerNumber,
|
||||
metadata: { containerId: container.id, position },
|
||||
});
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,9 +171,23 @@ export class ContainersService {
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot unassign a loaded container');
|
||||
}
|
||||
const previousWagonId = container.wagonId;
|
||||
const previousPosition = container.position ?? null;
|
||||
container.wagonId = null;
|
||||
container.position = null;
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
const saved = await this.containerRepo.save(container);
|
||||
if (previousWagonId) {
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id: previousWagonId } });
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: previousWagonId,
|
||||
wagonNumber: wagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.ContainerRemoved,
|
||||
fromYardId: wagon?.currentYardId ?? null,
|
||||
fromValue: container.containerNumber,
|
||||
metadata: { containerId: container.id, position: previousPosition },
|
||||
});
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2250,7 +2250,9 @@ export class ContractBookingService {
|
||||
unitRepo.create({
|
||||
bookingContainerId: containerRow.id,
|
||||
containerNumber: unit.containerNumber,
|
||||
sealNumber: unit.sealNumber ?? null,
|
||||
// Legacy units recovered by the remainder placement can still
|
||||
// arrive sealless — keep those null rather than empty-string.
|
||||
sealNumber: unit.sealNumber?.trim() || null,
|
||||
vgmTons: unit.vgmTons,
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -32,10 +33,12 @@ export class CreateContainerUnitDto {
|
||||
})
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@ApiProperty({ description: 'Seal number — required on every container, import and export alike.' })
|
||||
@IsString()
|
||||
sealNumber?: string;
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsNotEmpty({ message: 'sealNumber is required' })
|
||||
@MaxLength(64)
|
||||
sealNumber!: string;
|
||||
|
||||
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
|
||||
@IsNumber()
|
||||
|
||||
@@ -134,6 +134,7 @@ export class EimsBulkRegistrationService {
|
||||
region: invoice.company?.region,
|
||||
zone: invoice.company?.zone,
|
||||
woreda: invoice.company?.woreda,
|
||||
kebele: invoice.company?.kebele,
|
||||
});
|
||||
return { invoice, documentType, relatedDocument, buyerGeo };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { of, throwError } from "rxjs";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
import { eimsConfig } from "./eims-test-fixtures";
|
||||
|
||||
const API_KEY = "super-secret-apikey";
|
||||
const CLIENT_SECRET = "super-secret-value";
|
||||
const TOKEN = "access-token-value";
|
||||
|
||||
/** Stub signer: the real signing path has its own spec and needs no key material here. */
|
||||
const signer = {
|
||||
signRequest: <T>(request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }),
|
||||
} as unknown as EimsSignerService;
|
||||
|
||||
const build = (post: jest.Mock, config: EimsConfig = eimsConfig(), token: string = TOKEN) =>
|
||||
new EimsClientService(
|
||||
{ post } as unknown as HttpService,
|
||||
{ get: () => config } as unknown as ConfigService,
|
||||
{
|
||||
getValidAccessToken: jest.fn().mockResolvedValue(token),
|
||||
invalidate: jest.fn(),
|
||||
} as unknown as EimsAuthService,
|
||||
signer,
|
||||
);
|
||||
|
||||
const ok = (data: unknown = { statusCode: 200, body: { Irn: "irn-echoed" } }) =>
|
||||
jest.fn().mockReturnValue(of({ data }));
|
||||
|
||||
const axiosErr = (status: number, data: unknown) =>
|
||||
new AxiosError("Request failed", undefined, undefined, undefined, {
|
||||
status,
|
||||
statusText: "",
|
||||
data,
|
||||
headers: new AxiosHeaders(),
|
||||
config: { headers: new AxiosHeaders() },
|
||||
});
|
||||
|
||||
/** `post(url, body, config)` — the config argument every assertion below reads. */
|
||||
const sentConfig = (post: jest.Mock, call = 0) => post.mock.calls[call][2];
|
||||
const sentBody = (post: jest.Mock, call = 0) => post.mock.calls[call][1];
|
||||
|
||||
describe("EimsClientService transport", () => {
|
||||
const protectedHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
apikey: API_KEY,
|
||||
};
|
||||
|
||||
it.each([
|
||||
["verify", "/v1/verify", { irn: "irn-1" }],
|
||||
["sales receipt", "/v1/receipt/sales", { receipt: "sales" }],
|
||||
["withholding receipt", "/v1/receipt/withholding", { receipt: "withholding" }],
|
||||
["cancel", "/v1/cancel", { Irn: "irn-1" }],
|
||||
["bulk cancel", "/v1/bulkCancel", [{ Irn: "irn-1" }]],
|
||||
])("authenticates the raw %s endpoint without changing its body", async (_name, path, body) => {
|
||||
const post = ok();
|
||||
await build(post).postBearer(path, body);
|
||||
|
||||
expect(sentConfig(post).headers).toEqual(protectedHeaders);
|
||||
expect(sentBody(post)).toBe(body);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["invoice", { DocumentDetails: { Type: "INV" } }],
|
||||
["credit memo", { DocumentDetails: { Type: "CRE" } }],
|
||||
["debit memo", { DocumentDetails: { Type: "DEB" } }],
|
||||
])("authenticates and signs a %s registration", async (_name, request) => {
|
||||
const post = ok({ statusCode: 200, body: { irn: "irn-1" } });
|
||||
await build(post).postSigned("/v1/register", request);
|
||||
|
||||
expect(sentConfig(post).headers).toEqual(protectedHeaders);
|
||||
expect(JSON.parse(sentBody(post) as string)).toEqual({
|
||||
request,
|
||||
signature: "SIGNATURE",
|
||||
certificate: "CERTIFICATE",
|
||||
});
|
||||
});
|
||||
|
||||
it("authenticates bulk registration through the same signed path", async () => {
|
||||
const post = ok({ conversationId: "conversation-1", status: 202 });
|
||||
const request = [{ DocumentDetails: { Type: "INV" } }];
|
||||
await build(post).postSigned("/v1/bulkRegister", request);
|
||||
|
||||
expect(sentConfig(post).headers).toEqual(protectedHeaders);
|
||||
});
|
||||
|
||||
it("wraps a signed call in the {request,signature,certificate} envelope", async () => {
|
||||
const post = ok({ statusCode: 200, body: { irn: "irn-1" } });
|
||||
await build(post).postSigned("/v1/register", { Invoice: 1 });
|
||||
|
||||
expect(JSON.parse(sentBody(post) as string)).toEqual({
|
||||
request: { Invoice: 1 },
|
||||
signature: "SIGNATURE",
|
||||
certificate: "CERTIFICATE",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an unsigned body verbatim", async () => {
|
||||
const post = ok();
|
||||
await build(post).postBearer("/v1/cancel", { Irn: "irn-1" });
|
||||
|
||||
// Raw object, not the JSON string `toSignedBody` produces.
|
||||
expect(sentBody(post)).toEqual({ Irn: "irn-1" });
|
||||
});
|
||||
|
||||
it("re-authenticates a raw verify call through the one 401 retry without changing its body", async () => {
|
||||
const post = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(throwError(() => axiosErr(401, { message: "expired" })))
|
||||
.mockReturnValueOnce(of({ data: { statusCode: 200, body: { Irn: "irn-1" } } }));
|
||||
|
||||
await build(post).postBearer("/v1/verify", { irn: "irn-1" });
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
expect(sentConfig(post, 1).headers.Authorization).toBe(`Bearer ${TOKEN}`);
|
||||
expect(sentBody(post, 1)).toEqual({ irn: "irn-1" });
|
||||
});
|
||||
|
||||
it("never leaks the api key, bearer token or client secret into a thrown failure", async () => {
|
||||
const logError = jest.spyOn(Logger.prototype, "error").mockImplementation(() => undefined);
|
||||
const post = jest.fn().mockReturnValue(
|
||||
throwError(() =>
|
||||
// A gateway rejection may echo request data; redaction must remove it before logging.
|
||||
axiosErr(400, {
|
||||
message: "GATEWAY ERROR",
|
||||
code: "4001",
|
||||
details: [
|
||||
{ field: "certificate", errorMessage: "must not be null" },
|
||||
{ field: "signature", errorMessage: "must not be null" },
|
||||
{ field: "request", errorMessage: "must not be null" },
|
||||
],
|
||||
// An echoed request is exactly what redaction has to drop.
|
||||
request: { apikey: API_KEY, clientSecret: CLIENT_SECRET },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const error: Error = await build(post)
|
||||
.postBearer("/v1/verify", { irn: "irn-1" })
|
||||
.then(() => {
|
||||
throw new Error("expected the call to reject");
|
||||
})
|
||||
.catch((err: Error) => err);
|
||||
|
||||
const serialized = JSON.stringify({
|
||||
message: error.message,
|
||||
response: (error as { getResponse?: () => unknown }).getResponse?.(),
|
||||
details: (error as { details?: unknown }).details,
|
||||
});
|
||||
expect(serialized).not.toContain(API_KEY);
|
||||
expect(serialized).not.toContain(CLIENT_SECRET);
|
||||
expect(serialized).not.toContain(TOKEN);
|
||||
const serializedLogs = JSON.stringify(logError.mock.calls);
|
||||
expect(serializedLogs).not.toContain(API_KEY);
|
||||
expect(serializedLogs).not.toContain(CLIENT_SECRET);
|
||||
expect(serializedLogs).not.toContain(TOKEN);
|
||||
// The gateway's own reporting still survives redaction.
|
||||
expect(error.message).toContain("4001");
|
||||
logError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,10 @@ import { EimsSignerService, toSignedBody } from "./eims-signer.service";
|
||||
import { toEimsApiException } from "./eims.errors";
|
||||
|
||||
/**
|
||||
* Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …).
|
||||
* Foundation for EIMS's authenticated endpoints (`/v1/register`, `/v1/verify`, …).
|
||||
*
|
||||
* Login is not routed through here: `/auth/login` carries no bearer token and lives in
|
||||
* `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase.
|
||||
* `EimsAuthService`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsClientService {
|
||||
@@ -29,7 +29,8 @@ export class EimsClientService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response.
|
||||
* Sign `request`, POST it to `path` with the shared protected-endpoint headers, and return the
|
||||
* parsed response.
|
||||
* A 401 invalidates the cached token and retries exactly once.
|
||||
*/
|
||||
async postSigned<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
@@ -37,12 +38,8 @@ export class EimsClientService {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope.
|
||||
*
|
||||
* `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a
|
||||
* raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point
|
||||
* so that if the live gateway turns out to require signing after all, exactly one call site
|
||||
* changes — `postSigned` is already the alternative.
|
||||
* POST `request` verbatim with the shared protected-endpoint headers, but **not** wrapped in a
|
||||
* signed envelope. This is the wire contract for verify, cancel and receipt calls.
|
||||
*/
|
||||
async postBearer<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
return this.send<TRequest, TResponse>(path, request, false, false);
|
||||
@@ -61,7 +58,11 @@ export class EimsClientService {
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<TResponse>(`${cfg.baseUrl}${path}`, body, {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
apikey: cfg.apiKey,
|
||||
},
|
||||
timeout: cfg.httpTimeoutMs,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EimsConfig } from "../../config/eims.config";
|
||||
import { MorGeoCodes } from "../../config/mor-location.resolver";
|
||||
import { EimsSessionContext } from "./eims-auth.service";
|
||||
import {
|
||||
EimsLineTax,
|
||||
EimsMapperContext,
|
||||
EimsMapperLine,
|
||||
EimsSellerDetails,
|
||||
@@ -172,12 +173,35 @@ export interface EimsContextInput {
|
||||
relatedDocument?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax treatment of one charge type: its per-`chargeType` override when one is configured
|
||||
* (validated symmetric in `assertChargeTypeOverrides`), else the single invoice-wide default.
|
||||
*
|
||||
* Exported because the printed tax document has to state the same Tax Code, Excise and Discount
|
||||
* per line that was filed with MoR, and it must be able to do so without a live EIMS session —
|
||||
* `buildEimsContext` needs a system number from an access token, printing does not.
|
||||
*/
|
||||
export function resolveLineTax(config: EimsConfig, chargeType: string): EimsLineTax {
|
||||
const { invoice } = config;
|
||||
return {
|
||||
code: invoice.taxCodeByChargeType[chargeType] ?? invoice.taxCode,
|
||||
ratePercent:
|
||||
chargeType in invoice.taxRateByChargeType
|
||||
? Number(invoice.taxRateByChargeType[chargeType])
|
||||
: invoice.taxRatePercent!,
|
||||
exciseTaxValue:
|
||||
chargeType in invoice.exciseByChargeType
|
||||
? Number(invoice.exciseByChargeType[chargeType])
|
||||
: (invoice.exciseTaxValue ?? 0),
|
||||
discount:
|
||||
chargeType in invoice.discountByChargeType
|
||||
? Number(invoice.discountByChargeType[chargeType])
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
|
||||
const { invoice } = config;
|
||||
// Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call.
|
||||
const taxCode = invoice.taxCode;
|
||||
const ratePercent = invoice.taxRatePercent!;
|
||||
const exciseTaxValue = invoice.exciseTaxValue ?? 0;
|
||||
|
||||
return {
|
||||
systemNumber: input.session.systemNumber,
|
||||
@@ -191,23 +215,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
||||
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
||||
// Per-`chargeType` override when one is configured (validated symmetric in
|
||||
// assertChargeTypeOverrides), else the single invoice-wide default.
|
||||
taxForLine: (line: EimsMapperLine) => {
|
||||
const { chargeType } = line;
|
||||
const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode;
|
||||
const rate =
|
||||
chargeType in invoice.taxRateByChargeType
|
||||
? Number(invoice.taxRateByChargeType[chargeType])
|
||||
: ratePercent;
|
||||
const excise =
|
||||
chargeType in invoice.exciseByChargeType
|
||||
? Number(invoice.exciseByChargeType[chargeType])
|
||||
: exciseTaxValue;
|
||||
const discount =
|
||||
chargeType in invoice.discountByChargeType
|
||||
? Number(invoice.discountByChargeType[chargeType])
|
||||
: 0;
|
||||
return { code, ratePercent: rate, exciseTaxValue: excise, discount };
|
||||
},
|
||||
taxForLine: (line: EimsMapperLine) => resolveLineTax(config, line.chargeType),
|
||||
natureOfSupplies: invoice.natureOfSupplies,
|
||||
unitDefault: invoice.unitDefault,
|
||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||
|
||||
@@ -759,16 +759,15 @@ describe("EimsInvoiceRegistrationService staff alerting", () => {
|
||||
});
|
||||
|
||||
describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
|
||||
it("verifies the stored IRN over the unsigned bearer transport", async () => {
|
||||
it("verifies the stored IRN as an unchanged raw body", async () => {
|
||||
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
|
||||
const postSigned = jest.fn();
|
||||
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||
const postSigned = jest.fn();
|
||||
|
||||
const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims(
|
||||
INVOICE_ID,
|
||||
);
|
||||
|
||||
// Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched.
|
||||
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
expect(result.body).toMatchObject({ Irn: IRN });
|
||||
@@ -792,6 +791,27 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
|
||||
).rejects.toThrow(/no EIMS IRN to verify/);
|
||||
expect(postBearer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a filed invoice and the IRN chain untouched when the gateway rejects the verify", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }),
|
||||
]);
|
||||
const before = { ...db.invoices.get(INVOICE_ID)! };
|
||||
const stateBefore = { ...db.state! };
|
||||
// The live failure this guards: `GATEWAY ERROR code=4001`, a transport fault on a document
|
||||
// that is already registered. Verification is a read — a failed read must never downgrade the
|
||||
// registration or move the counter.
|
||||
const postBearer = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "GATEWAY ERROR code=4001", 400));
|
||||
|
||||
await expect(
|
||||
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
|
||||
).rejects.toThrow(/4001/);
|
||||
|
||||
expect(db.invoices.get(INVOICE_ID)).toEqual(before);
|
||||
expect(db.state).toEqual(stateBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
||||
@@ -886,15 +906,15 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
||||
|
||||
it("discards the attempt, leaving the chain where it was", async () => {
|
||||
const db = blocked();
|
||||
const postBearer = jest.fn();
|
||||
const postSigned = jest.fn();
|
||||
|
||||
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
|
||||
const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(
|
||||
INVOICE_ID,
|
||||
{ discard: true },
|
||||
);
|
||||
|
||||
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null });
|
||||
expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm
|
||||
expect(postSigned).not.toHaveBeenCalled(); // nothing to confirm
|
||||
expect(db.state).toMatchObject({
|
||||
previousIrn: null,
|
||||
inFlightInvoiceId: null,
|
||||
@@ -908,10 +928,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
||||
OTHER_INVOICE_ID,
|
||||
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
|
||||
);
|
||||
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||
const postSigned = jest.fn().mockResolvedValue(verifyResponse());
|
||||
|
||||
await expect(
|
||||
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, {
|
||||
build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(OTHER_INVOICE_ID, {
|
||||
irn: IRN,
|
||||
}),
|
||||
).rejects.toThrow(/in-flight EIMS submission is invoice/);
|
||||
|
||||
@@ -126,6 +126,7 @@ export class EimsInvoiceRegistrationService {
|
||||
region: invoice.company?.region,
|
||||
zone: invoice.company?.zone,
|
||||
woreda: invoice.company?.woreda,
|
||||
kebele: invoice.company?.kebele,
|
||||
});
|
||||
|
||||
// Authenticate before reserving: the source system comes from the token, and the state row is
|
||||
@@ -213,7 +214,8 @@ export class EimsInvoiceRegistrationService {
|
||||
* compared — the supplied collection's own fixture uses different example values on each side,
|
||||
* so equality there would assert a property of the mock rather than of the gateway.
|
||||
*
|
||||
* Bearer-authenticated but unsigned, via `postBearer` — see that method for why.
|
||||
* Raw, via `postBearer`: verification accepts exactly `{"irn":"…"}` and relies on the shared
|
||||
* transport for the bearer token and API-key header. It must not be signed or wrapped.
|
||||
*/
|
||||
private async queryVerify(irn: string): Promise<EimsVerifyResponse> {
|
||||
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
MorPartyDetails,
|
||||
pngDataUrl,
|
||||
} from "../billing/documents/invoice-document.service";
|
||||
import { buildEimsSeller } from "./eims-invoice-context";
|
||||
import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||
import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types";
|
||||
|
||||
@@ -20,7 +23,11 @@ import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-rece
|
||||
* would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw
|
||||
* surface as a 400 — there is nothing sensible to render instead.
|
||||
*/
|
||||
export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel {
|
||||
export function toReceiptDocumentModel(
|
||||
receipt: EimsReceipt,
|
||||
invoice: Invoice,
|
||||
config?: EimsConfig,
|
||||
): InvoiceDocumentModel {
|
||||
if (receipt.status !== EimsReceiptStatus.Registered) {
|
||||
throw new Error(
|
||||
`Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`,
|
||||
@@ -45,6 +52,34 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice):
|
||||
// if that default changes for an unrelated reason.
|
||||
sealText: "EDR PAID",
|
||||
extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }],
|
||||
mor: config?.invoice
|
||||
? {
|
||||
titleAm: "የገንዘብ መቀበያ ደረሰኝ",
|
||||
titleEn: "Cash Receipt Voucher",
|
||||
saleType: config.invoice.transactionType,
|
||||
systemNumber: req.SourceSystemNumber || config.systemNumber || null,
|
||||
...parties(config, invoice),
|
||||
payment: {
|
||||
mode: req.TransactionDetails.ModeOfPayment,
|
||||
typeMethod: config.invoice.paymentTerm,
|
||||
receiverName: invoice.company?.name ?? null,
|
||||
},
|
||||
receipt: {
|
||||
rrn: receipt.rrn ?? "",
|
||||
reason: req.Reason,
|
||||
collectedAmount: req.CollectedAmount,
|
||||
// One row per invoice the payment covers — MoR's receipt is invoice-linked, so the
|
||||
// printed voucher has to show which document(s) the money was applied to.
|
||||
invoices: req.Invoices.map((line) => ({
|
||||
irn: line.InvoiceIRN,
|
||||
paymentCoverage: line.PaymentCoverage,
|
||||
totalAmount: line.TotalAmount,
|
||||
remainingAmount: line.RemainingAmount ?? 0,
|
||||
paidAmount: line.InvoicePaidAmount,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,9 +94,63 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice):
|
||||
// wrong here, so this is the one case that MUST override it.
|
||||
sealText: "EDR",
|
||||
extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }],
|
||||
mor: config?.invoice
|
||||
? {
|
||||
titleAm: "ከተከፋይ ሒሳብ ላይ ለተቀነሰ ግብር የተሰጠ ደረሰኝ",
|
||||
titleEn: "Withholding tax on payment",
|
||||
...parties(config, invoice),
|
||||
systemNumber: req.SourceSystemNumber || config.systemNumber || null,
|
||||
withholding: {
|
||||
receiptNumber: receipt.receiptNumber,
|
||||
counter: req.ReceiptCounter,
|
||||
reason: req.Reason,
|
||||
type: req.WithholdDetail.Type,
|
||||
invoiceCurrency: req.InvoiceDetail.Currency,
|
||||
preTaxAmount: req.WithholdDetail.PreTaxAmount,
|
||||
withheldAmount: req.WithholdDetail.WithholdingAmount,
|
||||
systemType: req.SourceSystemType,
|
||||
systemNumber: req.SourceSystemNumber || config.systemNumber || "",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `ከ / From` and `ለ / To` for a receipt. On a withholding receipt the seller is the withholding
|
||||
* agent and the buyer the taxpayer, which is the same pair of blocks in the same order — the
|
||||
* layout relabels them, so the mapping does not change.
|
||||
*/
|
||||
function parties(
|
||||
config: EimsConfig,
|
||||
invoice: Invoice,
|
||||
): { seller: MorPartyDetails; buyer: MorPartyDetails } {
|
||||
const seller = buildEimsSeller(config);
|
||||
const company = invoice.company;
|
||||
return {
|
||||
seller: {
|
||||
name: config.invoice.sellerLegalName || seller.LegalName,
|
||||
city: seller.City,
|
||||
subCity: seller.SubCity,
|
||||
woreda: seller.Wereda,
|
||||
kebele: seller.Locality,
|
||||
houseNo: seller.HouseNumber,
|
||||
tin: seller.Tin,
|
||||
vatNumber: seller.VatNumber,
|
||||
},
|
||||
buyer: {
|
||||
name: company?.name ?? "N/A",
|
||||
city: company?.zone ?? null,
|
||||
subCity: company?.zone ?? null,
|
||||
woreda: company?.woreda ?? null,
|
||||
kebele: company?.kebele ?? null,
|
||||
houseNo: company?.houseNo ?? null,
|
||||
tin: company?.tin ?? null,
|
||||
vatNumber: company?.vatNumber ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function build(
|
||||
receipt: EimsReceipt,
|
||||
invoice: Invoice,
|
||||
@@ -73,6 +162,7 @@ function build(
|
||||
amount: number;
|
||||
sealText: string;
|
||||
extraSummary: Array<{ label: string; value: string | null }>;
|
||||
mor?: InvoiceDocumentModel["mor"];
|
||||
},
|
||||
): InvoiceDocumentModel {
|
||||
return {
|
||||
|
||||
@@ -191,6 +191,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
|
||||
|
||||
it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const invoiceBefore = { ...db.invoices.get(INVOICE_ID)! };
|
||||
const postBearer = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406));
|
||||
@@ -200,6 +201,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
|
||||
).rejects.toBeInstanceOf(EimsApiException);
|
||||
const [receipt] = [...db.receipts.values()];
|
||||
expect(receipt.status).toBe(EimsReceiptStatus.Failed);
|
||||
expect(db.invoices.get(INVOICE_ID)).toEqual(invoiceBefore);
|
||||
});
|
||||
|
||||
it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => {
|
||||
|
||||
@@ -196,7 +196,7 @@ export class EimsReceiptService {
|
||||
|
||||
let model: ReturnType<typeof toReceiptDocumentModel>;
|
||||
try {
|
||||
model = toReceiptDocumentModel(receipt, invoice);
|
||||
model = toReceiptDocumentModel(receipt, invoice, this.cfg);
|
||||
} catch (err) {
|
||||
// Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 —
|
||||
// a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService
|
||||
|
||||
@@ -116,9 +116,14 @@ export class EimsSellerCacheService implements OnModuleInit {
|
||||
region: data.region,
|
||||
zone: data.zone,
|
||||
woreda: data.woreda,
|
||||
kebele: data.kebele,
|
||||
});
|
||||
this.cached = {
|
||||
LegalName: data.companyName || undefined,
|
||||
// The *legal* entity name, not the licence's trade name that
|
||||
// `data.companyName` now carries — an EIMS seller is filed under its
|
||||
// registered name.
|
||||
LegalName:
|
||||
companyInfo?.BusinessName?.trim() || data.companyName || undefined,
|
||||
Phone: data.mobilePhone || data.regularPhone || undefined,
|
||||
Region: geo?.Region,
|
||||
Wereda: geo?.Wereda,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateEmptyReturnRequestDto {
|
||||
@ApiProperty({ description: 'Booking the empties came in on.' })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description:
|
||||
'One container number per empty being returned — the customer types as many as they said they are sending back.',
|
||||
example: ['TEMU1234567', 'MSCU7654321'],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
@MinLength(4, { each: true })
|
||||
@MaxLength(64, { each: true })
|
||||
containerNumbers!: string[];
|
||||
}
|
||||
|
||||
export class ApproveEmptyReturnRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Per-container price to bill. Defaults to the route WITH_RETURN rate the quote was built from.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
unitAmount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Currency of `unitAmount`. Defaults to the quote currency (ETB).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class RejectEmptyReturnRequestDto {
|
||||
@ApiProperty({ description: 'Why the request was turned down — shown to the customer.' })
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class ScheduleEmptyReturnRequestDto {
|
||||
@ApiProperty({
|
||||
description: 'The day the customer will hand the empties over.',
|
||||
example: '2026-09-20',
|
||||
})
|
||||
@IsDateString()
|
||||
returnDate!: string;
|
||||
|
||||
@ApiProperty({ description: 'Plate of the truck bringing the empties back.' })
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(32)
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@ApiProperty({ description: 'Driver bringing the empties back.' })
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
truckDriverName!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Truck type (flatbed, container chassis…).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
truckType?: string;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
ApproveEmptyReturnRequestDto,
|
||||
CreateEmptyReturnRequestDto,
|
||||
RejectEmptyReturnRequestDto,
|
||||
ScheduleEmptyReturnRequestDto,
|
||||
} from './dto/empty-return-request.dto';
|
||||
import { EmptyReturnRequestsService } from './empty-return-requests.service';
|
||||
import type { EmptyReturnRequestStatus } from './entities/empty-return-request.entity';
|
||||
|
||||
/**
|
||||
* Reading the queue is OR'd with the warehouse-inventory key the rest of the
|
||||
* Imports menu uses, so the staff who already run container returns can open
|
||||
* it while the dedicated key is still being handed out. Approving and
|
||||
* rejecting stay on the review key alone — that one is a commercial decision.
|
||||
*/
|
||||
const CAN_VIEW = [
|
||||
FREIGHT_PERMS.emptyReturnRequests.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
];
|
||||
|
||||
@ApiTags('empty-return-requests')
|
||||
@ApiBearerAuth()
|
||||
@Controller('empty-return-requests')
|
||||
export class EmptyReturnRequestsController {
|
||||
constructor(private readonly service: EmptyReturnRequestsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(CAN_VIEW)
|
||||
@ApiOperation({ summary: 'Empty container return requests queue' })
|
||||
findAll(@Query('status') status?: string, @Query('bookingId') bookingId?: string) {
|
||||
return this.service.findAll({
|
||||
status: status as EmptyReturnRequestStatus | undefined,
|
||||
bookingId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('planned')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({
|
||||
summary: 'Scheduled empty returns the warehouse is expecting, with date and truck',
|
||||
})
|
||||
planned() {
|
||||
return this.service.plannedReturns();
|
||||
}
|
||||
|
||||
@Get('eligibility/:bookingId')
|
||||
@MixedAudience(CAN_VIEW)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Whether a booking may request an empty return, its free containers, and the price per container',
|
||||
})
|
||||
eligibility(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.eligibility(bookingId, this.portalUserId(user));
|
||||
}
|
||||
|
||||
@Get('by-booking/:bookingId')
|
||||
@MixedAudience(CAN_VIEW)
|
||||
@ApiOperation({ summary: "A booking's empty return requests, newest first" })
|
||||
findForBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.service.findForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@MixedAudience(CAN_VIEW)
|
||||
@ApiOperation({ summary: 'Get an empty return request by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.findById(id, this.portalUserId(user));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: 'Customer requests to return empty containers on a booking sold without return',
|
||||
})
|
||||
create(@Body() dto: CreateEmptyReturnRequestDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.create(dto, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Post(':id/schedule')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: 'Customer sets the return date and the truck bringing the empties back',
|
||||
})
|
||||
schedule(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ScheduleEmptyReturnRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.schedule(id, user?.id ?? null, dto);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Approve and bill the request — the price defaults to the route WITH_RETURN rate per container',
|
||||
})
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ApproveEmptyReturnRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.approve(id, user?.id ?? null, dto);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review)
|
||||
@ApiOperation({ summary: 'Reject the request with a reason shown to the customer' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectEmptyReturnRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.reject(id, user?.id ?? null, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff read any booking's request; a customer is held to their own. Passing
|
||||
* the user id is what turns the ownership check on, so staff pass null.
|
||||
*/
|
||||
private portalUserId(user: TCurrentUser): string | null {
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.emptyReturnRequests.review)) return null;
|
||||
return user?.id ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { registerExchangeModule } from '../exchange-settings/exchange-module-options';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { EmptyReturnRequest } from './entities/empty-return-request.entity';
|
||||
import { EmptyReturnRequestsController } from './empty-return-requests.controller';
|
||||
import { EmptyReturnRequestsRepository } from './empty-return-requests.repository';
|
||||
import { EmptyReturnRequestsService } from './empty-return-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([EmptyReturnRequest]),
|
||||
BillingModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
NotificationInboxModule,
|
||||
RuleEngineModule,
|
||||
registerExchangeModule(),
|
||||
],
|
||||
controllers: [EmptyReturnRequestsController],
|
||||
providers: [EmptyReturnRequestsRepository, EmptyReturnRequestsService],
|
||||
exports: [EmptyReturnRequestsService],
|
||||
})
|
||||
export class EmptyReturnRequestsModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
|
||||
import { EmptyReturnRequest } from './entities/empty-return-request.entity';
|
||||
|
||||
@Injectable()
|
||||
export class EmptyReturnRequestsRepository extends BaseRepository<EmptyReturnRequest> {
|
||||
constructor(
|
||||
@InjectRepository(EmptyReturnRequest)
|
||||
repository: Repository<EmptyReturnRequest>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { EmptyReturnRequestsService } from './empty-return-requests.service';
|
||||
import type { EmptyReturnRequest } from './entities/empty-return-request.entity';
|
||||
|
||||
/**
|
||||
* The service is mostly gates and pricing over raw SQL, so the SQL is stubbed
|
||||
* by matching a distinctive fragment of each statement. Every stub returns the
|
||||
* shape the real query returns.
|
||||
*/
|
||||
type QueryStub = Array<[string, unknown]>;
|
||||
|
||||
const booking = {
|
||||
id: 'b1',
|
||||
reference: 'BK-2026-000300',
|
||||
companyId: 'co1',
|
||||
companyProfileId: 'cp1',
|
||||
status: 'ARRIVED',
|
||||
freightType: 'CONTAINER',
|
||||
equipmentReturn: 'WITHOUT_RETURN',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'y-dj',
|
||||
destinationYardId: 'y-mojo',
|
||||
paymentCurrency: 'ETB',
|
||||
};
|
||||
|
||||
function build(
|
||||
overrides: {
|
||||
booking?: Partial<typeof booking>;
|
||||
request?: Partial<EmptyReturnRequest>;
|
||||
rates?: unknown[];
|
||||
queries?: QueryStub;
|
||||
} = {},
|
||||
) {
|
||||
const merged = { ...booking, ...overrides.booking };
|
||||
|
||||
const requestRow: EmptyReturnRequest = {
|
||||
id: 'r1',
|
||||
bookingId: merged.id,
|
||||
companyId: merged.companyId,
|
||||
status: 'SUBMITTED',
|
||||
containerNumbers: ['TEMU1111111', 'TEMU2222222', 'TEMU3333333'],
|
||||
containerCount: 3,
|
||||
submittedAt: new Date(),
|
||||
...overrides.request,
|
||||
} as EmptyReturnRequest;
|
||||
|
||||
const stubs: QueryStub = [
|
||||
['FROM freight.booking_container\n', [{ containerTypeId: 'ct-40' }]],
|
||||
[
|
||||
'upper(bcu.container_number)',
|
||||
[{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }],
|
||||
],
|
||||
['COALESCE(SUM(quantity), 0)', [{ quantity: '5' }]],
|
||||
['unnest(r.container_numbers)', []],
|
||||
['COUNT(*) AS outstanding', [{ outstanding: '0' }]],
|
||||
...(overrides.queries ?? []),
|
||||
];
|
||||
|
||||
const query = jest.fn(async (sql: string) => {
|
||||
// Later stubs win, so a test can override one of the defaults.
|
||||
for (let i = stubs.length - 1; i >= 0; i -= 1) {
|
||||
if (sql.includes(stubs[i][0])) return stubs[i][1];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const requests = {
|
||||
findById: jest.fn(async () => requestRow),
|
||||
findAll: jest.fn(async () => [requestRow]),
|
||||
create: jest.fn(async (data: Partial<EmptyReturnRequest>) => ({ ...requestRow, ...data })),
|
||||
update: jest.fn(async () => requestRow),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn(async () => merged),
|
||||
assertCustomerCanAccessBooking: jest.fn(async () => undefined),
|
||||
};
|
||||
const billing = { generateInvoice: jest.fn(async () => ({ id: 'inv1' })) };
|
||||
const notifications = { notify: jest.fn(async () => undefined) };
|
||||
const ratesService = {
|
||||
findLiveRatesDetailed: jest.fn(
|
||||
async () =>
|
||||
overrides.rates ?? [
|
||||
{
|
||||
trigger: 'WITH_RETURN',
|
||||
currency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'y-dj',
|
||||
destinationYardId: 'y-mojo',
|
||||
containerTypeId: 'ct-40',
|
||||
rateValue: '100',
|
||||
},
|
||||
],
|
||||
),
|
||||
};
|
||||
const exchange = { getRate: jest.fn(async () => 120) };
|
||||
|
||||
const service = new EmptyReturnRequestsService(
|
||||
requests as never,
|
||||
{ findById: jest.fn(async () => merged) } as never,
|
||||
bookingsService as never,
|
||||
billing as never,
|
||||
notifications as never,
|
||||
ratesService as never,
|
||||
exchange as never,
|
||||
{ query } as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
requests,
|
||||
bookingsService,
|
||||
billing,
|
||||
notifications,
|
||||
query,
|
||||
requestRow,
|
||||
booking: merged,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EmptyReturnRequestsService — eligibility', () => {
|
||||
it('lets an arrived container booking sold without return ask for one', async () => {
|
||||
const { service } = build();
|
||||
const result = await service.eligibility('b1', 'user1');
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reason).toBeNull();
|
||||
expect(result.availableContainerNumbers).toEqual(['TEMU1111111', 'TEMU2222222']);
|
||||
});
|
||||
|
||||
it('refuses bulk freight — there is no equipment to give back', async () => {
|
||||
const { service } = build({ booking: { freightType: 'BULK' } });
|
||||
const result = await service.eligibility('b1', 'user1');
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reason).toMatch(/container freight only/i);
|
||||
});
|
||||
|
||||
it('refuses a booking that already bought the return service', async () => {
|
||||
const withReturn = build({ booking: { equipmentReturn: 'WITH_RETURN' } });
|
||||
const legacy = build({ booking: { equipmentReturn: 'RETURN' } });
|
||||
|
||||
expect((await withReturn.service.eligibility('b1', null)).reason).toMatch(
|
||||
/already ships with/i,
|
||||
);
|
||||
expect((await legacy.service.eligibility('b1', null)).reason).toMatch(/already ships with/i);
|
||||
});
|
||||
|
||||
it('refuses a booking that has not shipped yet', async () => {
|
||||
const { service } = build({ booking: { status: 'PAID' } });
|
||||
const result = await service.eligibility('b1', null);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reason).toMatch(/once the booking is in transit/i);
|
||||
});
|
||||
|
||||
it('allows it after delivery, when the empty actually comes back', async () => {
|
||||
const { service } = build({ booking: { status: 'COMPLETED' } });
|
||||
expect((await service.eligibility('b1', null)).eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses when every container is already on a request', async () => {
|
||||
const { service } = build({
|
||||
queries: [
|
||||
[
|
||||
'unnest(r.container_numbers)',
|
||||
[{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }],
|
||||
],
|
||||
],
|
||||
});
|
||||
const result = await service.eligibility('b1', null);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reason).toMatch(/already on an empty return request/i);
|
||||
});
|
||||
|
||||
it('refuses a booking with no container numbers to pick from', async () => {
|
||||
const { service } = build({ queries: [['upper(bcu.container_number)', []]] });
|
||||
const result = await service.eligibility('b1', null);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reason).toMatch(/no container numbers are recorded/i);
|
||||
expect(result.availableContainerNumbers).toEqual([]);
|
||||
});
|
||||
|
||||
it('checks booking ownership for a portal caller, and skips it for staff', async () => {
|
||||
const portal = build();
|
||||
await portal.service.eligibility('b1', 'user1');
|
||||
expect(portal.bookingsService.assertCustomerCanAccessBooking).toHaveBeenCalled();
|
||||
|
||||
const staff = build();
|
||||
await staff.service.eligibility('b1', null);
|
||||
expect(staff.bookingsService.assertCustomerCanAccessBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyReturnRequestsService — creating a request', () => {
|
||||
it('accepts containers that came in on the booking', async () => {
|
||||
const { service, requests } = build();
|
||||
await service.create(
|
||||
{ bookingId: 'b1', containerNumbers: ['temu1111111', 'TEMU2222222'] },
|
||||
'user1',
|
||||
);
|
||||
|
||||
expect(requests.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bookingId: 'b1',
|
||||
containerNumbers: ['TEMU1111111', 'TEMU2222222'],
|
||||
containerCount: 2,
|
||||
status: 'SUBMITTED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a container that is not on the booking', async () => {
|
||||
const { service, requests } = build();
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ bookingId: 'b1', containerNumbers: ['TEMU1111111', 'MSCU9999999'] },
|
||||
'user1',
|
||||
),
|
||||
).rejects.toThrow(/Not on booking BK-2026-000300: MSCU9999999/);
|
||||
expect(requests.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses the same container twice', async () => {
|
||||
const { service } = build();
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ bookingId: 'b1', containerNumbers: ['TEMU1111111', 'TEMU1111111'] },
|
||||
'user1',
|
||||
),
|
||||
).rejects.toThrow(/selected twice/i);
|
||||
});
|
||||
|
||||
it('refuses a container already sitting on a live request', async () => {
|
||||
const { service } = build({
|
||||
queries: [['unnest(r.container_numbers)', [{ containerNumber: 'TEMU1111111' }]]],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'),
|
||||
).rejects.toThrow(/Already on an empty return request/);
|
||||
});
|
||||
|
||||
it('refuses a booking that already ships with return', async () => {
|
||||
const { service } = build({ booking: { equipmentReturn: 'WITH_RETURN' } });
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyReturnRequestsService — pricing', () => {
|
||||
it('prices a container at the route WITH_RETURN rate, converted to birr', async () => {
|
||||
const { service, booking: b } = build();
|
||||
const quote = await service.quote(b as never);
|
||||
|
||||
// 100 USD × 120 ETB/USD
|
||||
expect(quote).toMatchObject({ unitAmount: 12000, currency: 'ETB', sourceRateUsd: 100 });
|
||||
expect(quote.unavailableReason).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the route rate that names no container type', async () => {
|
||||
const { service, booking: b } = build({
|
||||
rates: [
|
||||
{
|
||||
trigger: 'WITH_RETURN',
|
||||
currency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'y-dj',
|
||||
destinationYardId: 'y-mojo',
|
||||
containerTypeId: null,
|
||||
rateValue: '80',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect((await service.quote(b as never)).unitAmount).toBe(9600);
|
||||
});
|
||||
|
||||
it('reports no price when no rate covers the route', async () => {
|
||||
const { service, booking: b } = build({
|
||||
rates: [
|
||||
{
|
||||
trigger: 'WITH_RETURN',
|
||||
currency: 'USD',
|
||||
tradeDirection: 'EXPORT',
|
||||
originYardId: 'other',
|
||||
destinationYardId: 'other',
|
||||
containerTypeId: null,
|
||||
rateValue: '80',
|
||||
},
|
||||
],
|
||||
});
|
||||
const quote = await service.quote(b as never);
|
||||
|
||||
expect(quote.unitAmount).toBeNull();
|
||||
expect(quote.unavailableReason).toMatch(/no empty-return rate/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyReturnRequestsService — approval', () => {
|
||||
it('bills container count × the route rate and stores the invoice', async () => {
|
||||
const { service, billing, requests } = build();
|
||||
await service.approve('r1', 'staff1', {});
|
||||
|
||||
expect(billing.generateInvoice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'empty_return_request',
|
||||
sourceId: 'r1',
|
||||
currency: 'ETB',
|
||||
totalAmount: 36000, // 3 × 12,000
|
||||
}),
|
||||
);
|
||||
expect(requests.update).toHaveBeenCalledWith(
|
||||
'r1',
|
||||
expect.objectContaining({
|
||||
status: 'APPROVED',
|
||||
quotedUnitAmount: 12000,
|
||||
quotedTotalAmount: 36000,
|
||||
invoiceId: 'inv1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("bills the reviewer's override instead of the route rate", async () => {
|
||||
const { service, billing } = build();
|
||||
await service.approve('r1', 'staff1', { unitAmount: 5000 });
|
||||
|
||||
expect(billing.generateInvoice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ totalAmount: 15000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to approve without a price when no rate covers the route', async () => {
|
||||
const { service } = build({ rates: [] });
|
||||
await expect(service.approve('r1', 'staff1', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('only approves a submitted request', async () => {
|
||||
const { service } = build({ request: { status: 'APPROVED' } });
|
||||
await expect(service.approve('r1', 'staff1', {})).rejects.toThrow(/Only a submitted request/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyReturnRequestsService — scheduling', () => {
|
||||
const details = {
|
||||
returnDate: '2026-09-20',
|
||||
truckPlateNumber: '3-a12345',
|
||||
truckDriverName: 'Abebe K.',
|
||||
};
|
||||
|
||||
it('takes the date and truck once the invoice is paid', async () => {
|
||||
const { service, requests } = build({ request: { status: 'PAID' } });
|
||||
await service.schedule('r1', 'user1', details);
|
||||
|
||||
expect(requests.update).toHaveBeenCalledWith(
|
||||
'r1',
|
||||
expect.objectContaining({
|
||||
status: 'SCHEDULED',
|
||||
requestedReturnDate: '2026-09-20',
|
||||
truckPlateNumber: '3-A12345',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('tells an unpaid customer to pay first', async () => {
|
||||
const { service } = build({ request: { status: 'APPROVED' } });
|
||||
await expect(service.schedule('r1', 'user1', details)).rejects.toThrow(
|
||||
/Pay the empty return invoice/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyReturnRequestsService — payment and completion', () => {
|
||||
it('moves an approved request to PAID when its invoice settles', async () => {
|
||||
const { service, requests } = build({ request: { status: 'APPROVED' } });
|
||||
await service.onInvoicePaid({ sourceId: 'r1' });
|
||||
|
||||
expect(requests.update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'PAID' }));
|
||||
});
|
||||
|
||||
it('ignores a settlement for a request that is not awaiting payment', async () => {
|
||||
const { service, requests } = build({ request: { status: 'SCHEDULED' } });
|
||||
await service.onInvoicePaid({ sourceId: 'r1' });
|
||||
|
||||
expect(requests.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes a scheduled request once every container is recorded back', async () => {
|
||||
const { service, requests } = build({ request: { status: 'SCHEDULED' } });
|
||||
await service.settleScheduledForBooking('b1');
|
||||
|
||||
expect(requests.update).toHaveBeenCalledWith(
|
||||
'r1',
|
||||
expect.objectContaining({ status: 'COMPLETED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves it scheduled while any container is still outstanding', async () => {
|
||||
const { service, requests } = build({
|
||||
request: { status: 'SCHEDULED' },
|
||||
queries: [['COUNT(*) AS outstanding', [{ outstanding: '2' }]]],
|
||||
});
|
||||
await service.settleScheduledForBooking('b1');
|
||||
|
||||
expect(requests.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,617 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { Freight, NotificationAudience, NotificationPriority, NotificationType } from '@edr/types';
|
||||
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import {
|
||||
ApproveEmptyReturnRequestDto,
|
||||
CreateEmptyReturnRequestDto,
|
||||
RejectEmptyReturnRequestDto,
|
||||
ScheduleEmptyReturnRequestDto,
|
||||
} from './dto/empty-return-request.dto';
|
||||
import {
|
||||
EmptyReturnRequest,
|
||||
type EmptyReturnRequestStatus,
|
||||
} from './entities/empty-return-request.entity';
|
||||
import { EmptyReturnRequestsRepository } from './empty-return-requests.repository';
|
||||
|
||||
/** The invoice `source` this module owns — also the `${source}.invoice.paid` event prefix. */
|
||||
const INVOICE_SOURCE = 'empty_return_request';
|
||||
|
||||
/**
|
||||
* Booking statuses that may still ask for an empty return. The empty only goes
|
||||
* back after the cargo is delivered, so everything from departure onward
|
||||
* qualifies — cutting it off at ARRIVED would take the option away exactly
|
||||
* when the customer needs it.
|
||||
*/
|
||||
const REQUESTABLE_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED'];
|
||||
|
||||
/** Requests that still hold their container numbers — a rejected one releases them. */
|
||||
const OPEN_STATUSES: EmptyReturnRequestStatus[] = [
|
||||
'SUBMITTED',
|
||||
'APPROVED',
|
||||
'PAID',
|
||||
'SCHEDULED',
|
||||
'COMPLETED',
|
||||
];
|
||||
|
||||
export interface EmptyReturnQuote {
|
||||
/** Per-container price in `currency`; null when no rate covers this route. */
|
||||
unitAmount: number | null;
|
||||
currency: string;
|
||||
/** The USD route rate the quote came from, before conversion. */
|
||||
sourceRateUsd: number | null;
|
||||
/** Why there is no price, for the UI to show instead of a number. */
|
||||
unavailableReason: string | null;
|
||||
}
|
||||
|
||||
export interface EmptyReturnEligibility {
|
||||
eligible: boolean;
|
||||
/** Why the customer cannot request one, when `eligible` is false. */
|
||||
reason: string | null;
|
||||
/** Containers on the booking that are not already spoken for. */
|
||||
availableContainerNumbers: string[];
|
||||
maxContainers: number;
|
||||
quote: EmptyReturnQuote;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmptyReturnRequestsService {
|
||||
constructor(
|
||||
private readonly requests: EmptyReturnRequestsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly notifications: NotificationInboxService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly exchange: ExchangeService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
// ── reads ────────────────────────────────────────────────────────────────
|
||||
|
||||
async findAll(filter: {
|
||||
status?: EmptyReturnRequestStatus;
|
||||
bookingId?: string;
|
||||
}): Promise<
|
||||
Array<EmptyReturnRequest & { bookingReference: string | null; companyName: string | null }>
|
||||
> {
|
||||
return this.dataSource.query(
|
||||
`SELECT r.*,
|
||||
b.reference AS "bookingReference",
|
||||
c.name AS "companyName"
|
||||
FROM freight.empty_return_requests r
|
||||
LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies c ON c.id = r.company_id
|
||||
WHERE r.deleted_at IS NULL
|
||||
AND ($1::text IS NULL OR r.status = $1)
|
||||
AND ($2::uuid IS NULL OR r.booking_id = $2)
|
||||
ORDER BY r.submitted_at DESC`,
|
||||
[filter.status ?? null, filter.bookingId ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
/** One request. A portal caller must own the booking; staff pass `null`. */
|
||||
async findById(id: string, userId: string | null = null): Promise<EmptyReturnRequest> {
|
||||
const request = await this.requests.findById(id);
|
||||
if (!request) throw new NotFoundException(`Empty return request ${id} not found`);
|
||||
if (userId) {
|
||||
const booking = await this.bookingsService.findById(request.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/** A booking's own requests — the portal card's history. */
|
||||
findForBooking(bookingId: string): Promise<EmptyReturnRequest[]> {
|
||||
return this.requests.findAll({
|
||||
where: { bookingId },
|
||||
order: { submittedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this booking ask for an empty return, how many containers are left to
|
||||
* ask for, and what one would cost. Drives the portal card: the customer
|
||||
* sees the price before committing, and staff see the same number prefilled
|
||||
* at approval.
|
||||
*/
|
||||
async eligibility(bookingId: string, userId: string | null): Promise<EmptyReturnEligibility> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||
|
||||
const quote = await this.quote(booking);
|
||||
const spoken = await this.spokenForContainers(bookingId);
|
||||
const all = await this.bookingContainerNumbers(bookingId);
|
||||
const available = all.filter((number) => !spoken.has(number));
|
||||
|
||||
const reason = this.ineligibilityReason(booking, all.length, available.length);
|
||||
return {
|
||||
eligible: reason === null,
|
||||
reason,
|
||||
availableContainerNumbers: available,
|
||||
maxContainers: available.length,
|
||||
quote,
|
||||
};
|
||||
}
|
||||
|
||||
private ineligibilityReason(
|
||||
booking: Booking,
|
||||
bookingContainerCount: number,
|
||||
availableCount: number,
|
||||
): string | null {
|
||||
if (booking.freightType !== 'CONTAINER') {
|
||||
return 'Empty container return applies to container freight only.';
|
||||
}
|
||||
if (booking.equipmentReturn === 'WITH_RETURN' || booking.equipmentReturn === 'RETURN') {
|
||||
return 'This booking already ships with empty container return included.';
|
||||
}
|
||||
if (!REQUESTABLE_BOOKING_STATUSES.includes(booking.status)) {
|
||||
return `An empty return can be requested once the booking is in transit (current status: ${booking.status}).`;
|
||||
}
|
||||
// The customer picks from this booking's own containers, so a booking that
|
||||
// never captured its container numbers has nothing to pick.
|
||||
if (bookingContainerCount === 0) {
|
||||
return 'No container numbers are recorded on this booking — contact EDR to arrange the return.';
|
||||
}
|
||||
if (availableCount === 0) {
|
||||
return 'Every container on this booking is already on an empty return request.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── pricing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-container price for returning an empty on this booking, taken from the
|
||||
* same live WITH_RETURN rate the rule engine bills when the service is
|
||||
* bought up front (route + trade direction + container type, priced in USD).
|
||||
* Billed in ETB, converted at the current rate, because this is collected
|
||||
* locally rather than on the freight invoice.
|
||||
*
|
||||
* ponytail: prices off the booking's FIRST container line. A booking mixing
|
||||
* 20ft and 40ft therefore quotes one size's rate for every box — split the
|
||||
* quote per container if mixed-size bookings start returning empties.
|
||||
*/
|
||||
async quote(booking: Booking): Promise<EmptyReturnQuote> {
|
||||
const currency = 'ETB';
|
||||
if (booking.freightType !== 'CONTAINER') {
|
||||
return {
|
||||
unitAmount: null,
|
||||
currency,
|
||||
sourceRateUsd: null,
|
||||
unavailableReason: 'Not container freight.',
|
||||
};
|
||||
}
|
||||
|
||||
const [line]: Array<{ containerTypeId: string | null }> = await this.dataSource.query(
|
||||
`SELECT container_type_id AS "containerTypeId"
|
||||
FROM freight.booking_container
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`,
|
||||
[booking.id],
|
||||
);
|
||||
|
||||
const rates = await this.ratesService.findLiveRatesDetailed();
|
||||
const onLeg = rates.filter(
|
||||
(rate) =>
|
||||
rate.trigger === 'WITH_RETURN' &&
|
||||
rate.currency === 'USD' &&
|
||||
rate.tradeDirection === booking.tradeDirection &&
|
||||
rate.originYardId === booking.originYardId &&
|
||||
rate.destinationYardId === booking.destinationYardId,
|
||||
);
|
||||
const rate =
|
||||
onLeg.find((r) => r.containerTypeId === (line?.containerTypeId ?? null)) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
|
||||
if (!rate) {
|
||||
return {
|
||||
unitAmount: null,
|
||||
currency,
|
||||
sourceRateUsd: null,
|
||||
unavailableReason:
|
||||
'No empty-return rate covers this route and container type — enter the amount manually.',
|
||||
};
|
||||
}
|
||||
|
||||
const usdToEtb = await this.exchange.getRate('USD', 'ETB');
|
||||
const rateUsd = Number(rate.rateValue);
|
||||
return {
|
||||
unitAmount: Math.round(rateUsd * usdToEtb * 100) / 100,
|
||||
currency,
|
||||
sourceRateUsd: rateUsd,
|
||||
unavailableReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── customer actions ─────────────────────────────────────────────────────
|
||||
|
||||
async create(
|
||||
dto: CreateEmptyReturnRequestDto,
|
||||
userId: string | null,
|
||||
): Promise<EmptyReturnRequest> {
|
||||
const booking = await this.bookingsService.findById(dto.bookingId);
|
||||
if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||
|
||||
const numbers = dto.containerNumbers.map((n) => n.trim().toUpperCase()).filter(Boolean);
|
||||
if (numbers.length === 0) {
|
||||
throw new BadRequestException('Select at least one container.');
|
||||
}
|
||||
if (new Set(numbers).size !== numbers.length) {
|
||||
throw new BadRequestException('The same container is selected twice.');
|
||||
}
|
||||
|
||||
// Only this booking's own containers can be returned against it. The
|
||||
// portal offers a pick list, so anything else is a stale page or a
|
||||
// hand-made request.
|
||||
const onBooking = new Set(await this.bookingContainerNumbers(booking.id));
|
||||
const foreign = numbers.filter((number) => !onBooking.has(number));
|
||||
if (foreign.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Not on booking ${booking.reference ?? booking.id}: ${foreign.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const reason = this.ineligibilityReason(booking, onBooking.size, numbers.length);
|
||||
if (reason) throw new BadRequestException(reason);
|
||||
|
||||
await this.assertContainersFree(numbers);
|
||||
|
||||
const saved = await this.requests.create({
|
||||
bookingId: booking.id,
|
||||
companyId: booking.companyId ?? null,
|
||||
status: 'SUBMITTED',
|
||||
containerNumbers: numbers,
|
||||
containerCount: numbers.length,
|
||||
submittedByUserId: userId,
|
||||
submittedAt: new Date(),
|
||||
} as Partial<EmptyReturnRequest>);
|
||||
|
||||
void this.notifications.notify({
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Empty container return requested',
|
||||
body: `${booking.reference ?? booking.id}: a customer asked to return ${numbers.length} empty container${
|
||||
numbers.length === 1 ? '' : 's'
|
||||
}.`,
|
||||
link: '/dashboard/empty-return-requests',
|
||||
data: { bookingId: booking.id, requestId: saved.id },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Date + truck, once the invoice is settled. This is what the warehouse then expects. */
|
||||
async schedule(
|
||||
id: string,
|
||||
userId: string | null,
|
||||
dto: ScheduleEmptyReturnRequestDto,
|
||||
): Promise<EmptyReturnRequest> {
|
||||
const request = await this.findById(id, userId);
|
||||
if (request.status !== 'PAID' && request.status !== 'SCHEDULED') {
|
||||
throw new BadRequestException(
|
||||
request.status === 'APPROVED'
|
||||
? 'Pay the empty return invoice before booking a date.'
|
||||
: `This request cannot be scheduled (current status: ${request.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.requests.update(id, {
|
||||
status: 'SCHEDULED',
|
||||
requestedReturnDate: dto.returnDate,
|
||||
truckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
truckDriverName: dto.truckDriverName.trim(),
|
||||
truckType: dto.truckType?.trim() ?? null,
|
||||
scheduledAt: new Date(),
|
||||
} as Partial<EmptyReturnRequest>);
|
||||
|
||||
void this.notifications.notify({
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Empty return scheduled',
|
||||
body: `${request.containerCount} empty container${request.containerCount === 1 ? '' : 's'} arriving ${
|
||||
dto.returnDate
|
||||
} on truck ${dto.truckPlateNumber}.`,
|
||||
link: '/dashboard/container-returns',
|
||||
data: { bookingId: request.bookingId, requestId: id },
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── staff actions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Approve and bill. The reviewer's `unitAmount` wins; otherwise the route
|
||||
* rate stands. The invoice is issued here, so the customer can pay straight
|
||||
* away — payment lands back on `onInvoicePaid`.
|
||||
*/
|
||||
async approve(
|
||||
id: string,
|
||||
staffId: string | null,
|
||||
dto: ApproveEmptyReturnRequestDto,
|
||||
): Promise<EmptyReturnRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== 'SUBMITTED') {
|
||||
throw new BadRequestException(
|
||||
`Only a submitted request can be approved (current status: ${request.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await this.bookingsService.findById(request.bookingId);
|
||||
// `chk_invoices_single_payer` requires exactly one payer, and this invoice
|
||||
// is always billed to the customer — so a booking with no company cannot
|
||||
// be invoiced at all. Say so here rather than at the constraint.
|
||||
if (!booking.companyId) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${booking.reference ?? booking.id} has no company to bill — the empty return cannot be invoiced.`,
|
||||
);
|
||||
}
|
||||
|
||||
const quote = await this.quote(booking);
|
||||
const unitAmount = dto.unitAmount ?? quote.unitAmount;
|
||||
if (!unitAmount || unitAmount <= 0) {
|
||||
throw new BadRequestException(
|
||||
quote.unavailableReason ?? 'No price for this return — enter the per-container amount.',
|
||||
);
|
||||
}
|
||||
|
||||
const currency = dto.currency ?? quote.currency;
|
||||
const totalAmount = Math.round(unitAmount * request.containerCount * 100) / 100;
|
||||
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: INVOICE_SOURCE as Freight.InvoiceSource,
|
||||
sourceId: request.id,
|
||||
type: 'EMPTY_RETURN',
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId || '',
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CONTAINER_WITH_RETURN',
|
||||
description: `Empty container return — ${request.containerCount} container${
|
||||
request.containerCount === 1 ? '' : 's'
|
||||
} on booking ${booking.reference ?? booking.id}`,
|
||||
amount: totalAmount,
|
||||
},
|
||||
],
|
||||
totalAmount,
|
||||
});
|
||||
|
||||
await this.requests.update(id, {
|
||||
status: 'APPROVED',
|
||||
quotedUnitAmount: unitAmount,
|
||||
quotedTotalAmount: totalAmount,
|
||||
currency,
|
||||
invoiceId: invoice.id,
|
||||
reviewedByStaffId: staffId,
|
||||
reviewedAt: new Date(),
|
||||
} as Partial<EmptyReturnRequest>);
|
||||
|
||||
if (booking.companyId) {
|
||||
void this.notifications.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
title: 'Empty container return approved — payment due',
|
||||
body: `Your empty return request for booking ${booking.reference ?? booking.id} was approved: ${totalAmount.toLocaleString()} ${currency} for ${request.containerCount} container${
|
||||
request.containerCount === 1 ? '' : 's'
|
||||
}. Pay the invoice, then choose your return date and truck.`,
|
||||
link: `/bookings/${booking.id}`,
|
||||
data: { bookingId: booking.id, requestId: id, invoiceId: invoice.id },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async reject(
|
||||
id: string,
|
||||
staffId: string | null,
|
||||
dto: RejectEmptyReturnRequestDto,
|
||||
): Promise<EmptyReturnRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== 'SUBMITTED') {
|
||||
throw new BadRequestException(
|
||||
`Only a submitted request can be rejected (current status: ${request.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.requests.update(id, {
|
||||
status: 'REJECTED',
|
||||
reviewedByStaffId: staffId,
|
||||
reviewedAt: new Date(),
|
||||
rejectionReason: dto.reason,
|
||||
} as Partial<EmptyReturnRequest>);
|
||||
|
||||
const booking = await this.bookingsRepository.findById(request.bookingId);
|
||||
if (booking?.companyId) {
|
||||
void this.notifications.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Empty container return rejected',
|
||||
body: `Your empty return request for booking ${booking.reference ?? request.bookingId} was rejected: ${dto.reason}`,
|
||||
link: `/bookings/${request.bookingId}`,
|
||||
data: { bookingId: request.bookingId, requestId: id },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── warehouse handoff ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scheduled requests the warehouse is waiting on — the planned side of the
|
||||
* Container Returns screen. Containers already recorded as returned are
|
||||
* carried per request so staff confirm only what is still outstanding.
|
||||
*/
|
||||
async plannedReturns(): Promise<
|
||||
Array<{
|
||||
requestId: string;
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
companyName: string | null;
|
||||
companyId: string | null;
|
||||
requestedReturnDate: string | null;
|
||||
truckPlateNumber: string | null;
|
||||
truckDriverName: string | null;
|
||||
truckType: string | null;
|
||||
containers: Array<{ containerNumber: string; returnId: string | null }>;
|
||||
}>
|
||||
> {
|
||||
return this.dataSource.query(
|
||||
`SELECT r.id AS "requestId",
|
||||
r.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
c.name AS "companyName",
|
||||
r.company_id AS "companyId",
|
||||
r.requested_return_date AS "requestedReturnDate",
|
||||
r.truck_plate_number AS "truckPlateNumber",
|
||||
r.truck_driver_name AS "truckDriverName",
|
||||
r.truck_type AS "truckType",
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'containerNumber', n,
|
||||
'returnId', (
|
||||
SELECT er.id FROM freight.empty_container_returns er
|
||||
WHERE er.deleted_at IS NULL
|
||||
AND er.booking_id = r.booking_id
|
||||
AND upper(er.container_number) = upper(n)
|
||||
ORDER BY er.created_at DESC LIMIT 1
|
||||
)
|
||||
) ORDER BY ord)
|
||||
FROM unnest(r.container_numbers) WITH ORDINALITY AS t(n, ord)
|
||||
) AS containers
|
||||
FROM freight.empty_return_requests r
|
||||
LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies c ON c.id = r.company_id
|
||||
WHERE r.deleted_at IS NULL
|
||||
AND r.status = 'SCHEDULED'
|
||||
ORDER BY r.requested_return_date ASC NULLS LAST, r.scheduled_at ASC`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a scheduled request once every container it covers has been recorded
|
||||
* as returned. Called after the warehouse records the returns; a request
|
||||
* with anything still outstanding stays SCHEDULED.
|
||||
*/
|
||||
async settleScheduledForBooking(bookingId: string): Promise<void> {
|
||||
const open = await this.requests.findAll({
|
||||
where: { bookingId, status: 'SCHEDULED' },
|
||||
});
|
||||
|
||||
for (const request of open) {
|
||||
const [{ outstanding }]: Array<{ outstanding: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) AS outstanding
|
||||
FROM unnest($2::text[]) AS n
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.empty_container_returns er
|
||||
WHERE er.deleted_at IS NULL
|
||||
AND er.booking_id = $1
|
||||
AND upper(er.container_number) = upper(n)
|
||||
)`,
|
||||
[bookingId, request.containerNumbers],
|
||||
);
|
||||
if (Number(outstanding) > 0) continue;
|
||||
|
||||
await this.requests.update(request.id, {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
} as Partial<EmptyReturnRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
// ── payment ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
|
||||
@OnEvent(`${INVOICE_SOURCE}.invoice.paid`)
|
||||
async onInvoicePaid(payload: { sourceId: string }): Promise<void> {
|
||||
const request = await this.requests.findById(payload.sourceId);
|
||||
if (!request || request.status !== 'APPROVED') return;
|
||||
|
||||
await this.requests.update(request.id, {
|
||||
status: 'PAID',
|
||||
paidAt: new Date(),
|
||||
} as Partial<EmptyReturnRequest>);
|
||||
|
||||
const booking = await this.bookingsRepository.findById(request.bookingId);
|
||||
if (!booking?.companyId) return;
|
||||
void this.notifications.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title: 'Empty return paid — choose your return date',
|
||||
body: `Payment received for the empty return on booking ${booking.reference ?? request.bookingId}. Tell us the date and the truck bringing the containers back.`,
|
||||
link: `/bookings/${request.bookingId}`,
|
||||
data: { bookingId: request.bookingId, requestId: request.id },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Container numbers captured on the booking, upper-cased. */
|
||||
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||
`SELECT DISTINCT upper(bcu.container_number) AS "containerNumber"
|
||||
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
|
||||
AND bcu.container_number IS NOT NULL
|
||||
ORDER BY 1`,
|
||||
[bookingId],
|
||||
);
|
||||
return rows.map((row) => row.containerNumber);
|
||||
}
|
||||
|
||||
/** Numbers already claimed by a live request on this booking. */
|
||||
private async spokenForContainers(bookingId: string): Promise<Set<string>> {
|
||||
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||
`SELECT DISTINCT upper(n) AS "containerNumber"
|
||||
FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n
|
||||
WHERE r.deleted_at IS NULL
|
||||
AND r.booking_id = $1
|
||||
AND r.status = ANY($2)`,
|
||||
[bookingId, OPEN_STATUSES],
|
||||
);
|
||||
return new Set(rows.map((row) => row.containerNumber));
|
||||
}
|
||||
|
||||
/** A container may only sit on one live request at a time, on any booking. */
|
||||
private async assertContainersFree(numbers: string[]): Promise<void> {
|
||||
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||
`SELECT DISTINCT upper(n) AS "containerNumber"
|
||||
FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n
|
||||
WHERE r.deleted_at IS NULL
|
||||
AND r.status = ANY($1)
|
||||
AND upper(n) = ANY($2)`,
|
||||
[OPEN_STATUSES, numbers],
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Already on an empty return request: ${rows.map((r) => r.containerNumber).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
|
||||
export const EMPTY_RETURN_REQUEST_STATUSES = [
|
||||
/** Customer named the containers; waiting on operations. */
|
||||
'SUBMITTED',
|
||||
/** Operations approved and priced it; the invoice is out, waiting on payment. */
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
/** Invoice settled; waiting on the customer to book a date and a truck. */
|
||||
'PAID',
|
||||
/** Date and truck given — the warehouse now expects these empties. */
|
||||
'SCHEDULED',
|
||||
/** The empties arrived and were recorded as returns. */
|
||||
'COMPLETED',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
|
||||
export type EmptyReturnRequestStatus = (typeof EMPTY_RETURN_REQUEST_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* A customer's request to return empties on a booking that did NOT buy the
|
||||
* return service up front (`equipment_return` is not WITH_RETURN). Container
|
||||
* freight only — a bulk booking has no equipment to give back.
|
||||
*
|
||||
* The request carries the commercial half of the flow: which containers, what
|
||||
* operations priced it at, the invoice, and the date/truck the customer
|
||||
* booked. The physical return is still recorded in `empty_container_returns`
|
||||
* when the truck arrives, which is what closes this row out as COMPLETED.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'empty_return_requests' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
export class EmptyReturnRequest extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
/** Denormalised at submit so the queue and the invoice agree on the payer. */
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 30, default: 'SUBMITTED' })
|
||||
status!: EmptyReturnRequestStatus;
|
||||
|
||||
/** The container numbers the customer is sending back, as typed. */
|
||||
@Column({ name: 'container_numbers', type: 'text', array: true, default: () => "'{}'" })
|
||||
containerNumbers!: string[];
|
||||
|
||||
@Column({ name: 'container_count', type: 'smallint', default: 0 })
|
||||
containerCount!: number;
|
||||
|
||||
/** Per-container price at approval — the route's WITH_RETURN rate, or the reviewer's override. */
|
||||
@Column({
|
||||
name: 'quoted_unit_amount',
|
||||
type: 'numeric',
|
||||
precision: 14,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
transformer: {
|
||||
to: (v?: number | null) => v,
|
||||
from: (v?: string | null) => (v == null ? null : Number(v)),
|
||||
},
|
||||
})
|
||||
quotedUnitAmount?: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'quoted_total_amount',
|
||||
type: 'numeric',
|
||||
precision: 14,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
transformer: {
|
||||
to: (v?: number | null) => v,
|
||||
from: (v?: string | null) => (v == null ? null : Number(v)),
|
||||
},
|
||||
})
|
||||
quotedTotalAmount?: number | null;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
|
||||
currency?: string | null;
|
||||
|
||||
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
/** Customer's chosen day for handing the empties over. */
|
||||
@Column({ name: 'requested_return_date', type: 'date', nullable: true })
|
||||
requestedReturnDate?: string | null;
|
||||
|
||||
@Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true })
|
||||
truckPlateNumber?: string | null;
|
||||
|
||||
@Column({ name: 'truck_driver_name', type: 'varchar', length: 120, nullable: true })
|
||||
truckDriverName?: string | null;
|
||||
|
||||
@Column({ name: 'truck_type', type: 'varchar', length: 60, nullable: true })
|
||||
truckType?: string | null;
|
||||
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date | null;
|
||||
|
||||
@Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true })
|
||||
submittedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'timestamptz', default: () => 'now()' })
|
||||
submittedAt!: Date;
|
||||
|
||||
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
|
||||
reviewedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
|
||||
rejectionReason?: string | null;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt?: Date | null;
|
||||
}
|
||||
@@ -1,4 +1,17 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import {
|
||||
CARGO_TYPE_SUBTREE_SQL,
|
||||
bookingContainerCountSql,
|
||||
bookingContentMatchSql,
|
||||
bookingContainerVgmSql,
|
||||
bookingContentSql,
|
||||
bookingHasContainerTypeSql,
|
||||
bookingRequestedCargoSql,
|
||||
bookingRequestedContainerCountSql,
|
||||
} from '../../bookings/booking-content.sql';
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
@@ -10,23 +23,104 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
import { ExportFilterOption } from '../export-filter.util';
|
||||
import { ExportDataset, ExportField } from '../export.types';
|
||||
|
||||
/**
|
||||
* Domain semantics that the retired `bookings-list` report used to share.
|
||||
* Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
|
||||
* holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
|
||||
* overrides `total_amount`. Getting either wrong misreports money or weight.
|
||||
* Tonnage is `bookingTonsSql` — the one resolver for the three ways a booking
|
||||
* stores its weight. `adjusted_total_amount` silently overrides `total_amount`.
|
||||
* Getting either wrong misreports money or weight.
|
||||
*/
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const TONS = bookingTonsSql('b');
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
|
||||
/** What the customer described as the booking's contents — see the helper. */
|
||||
const CONTENT = bookingContentSql('b');
|
||||
const CONTAINER_COUNT = bookingContainerCountSql('b');
|
||||
const REQUESTED_COUNT = bookingRequestedContainerCountSql('b');
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
|
||||
'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT',
|
||||
'ARRIVED', 'DELIVERED', 'COMPLETED',
|
||||
].map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
|
||||
|
||||
/**
|
||||
* Cargo tree flattened for a single select: groups and every commodity beneath
|
||||
* them, each labelled by its full path ("Bulk → Wheat") the way the booking
|
||||
* wizard shows a deep leaf. Picking a group row filters its whole subtree.
|
||||
*
|
||||
* Recursive because `cargo_types` is arbitrary-depth, not two levels.
|
||||
*/
|
||||
async function cargoTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
|
||||
return ds.query(`
|
||||
WITH RECURSIVE t AS (
|
||||
SELECT id, display_order, 0 AS depth,
|
||||
ARRAY[display_order]::int[] AS ord,
|
||||
ARRAY[cargo_type_name]::text[] AS path
|
||||
FROM freight.cargo_types
|
||||
WHERE parent_group_id IS NULL AND deleted_at IS NULL AND is_active
|
||||
UNION ALL
|
||||
SELECT c.id, c.display_order, t.depth + 1,
|
||||
t.ord || c.display_order,
|
||||
t.path || c.cargo_type_name
|
||||
FROM freight.cargo_types c
|
||||
JOIN t ON c.parent_group_id = t.id
|
||||
WHERE c.deleted_at IS NULL AND c.is_active
|
||||
)
|
||||
SELECT id AS value, array_to_string(path, ' → ') AS label
|
||||
FROM t ORDER BY ord, path
|
||||
`) as Promise<ExportFilterOption[]>;
|
||||
}
|
||||
|
||||
/** Container types are 2 rows that change about never. */
|
||||
async function containerTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
|
||||
return ds.query(`
|
||||
SELECT id AS value, COALESCE(label, code) AS label
|
||||
FROM freight.container_types
|
||||
WHERE deleted_at IS NULL AND is_active
|
||||
ORDER BY display_order, code
|
||||
`) as Promise<ExportFilterOption[]>;
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* One column per container type ("20FT", "40FT", …), each the box count of
|
||||
* that type on the booking. Resolved from `container_types` rather than
|
||||
* hardcoded, so adding a 45ft adds its column without a deploy of this file.
|
||||
*
|
||||
* The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL
|
||||
* string with no parameter bag — so ids that are not uuids are dropped rather
|
||||
* than spliced. They come from our own table; the guard is for the day someone
|
||||
* changes that column's type.
|
||||
*/
|
||||
async function containerTypeFields(ds: DataSource): Promise<ExportField[]> {
|
||||
const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(`
|
||||
SELECT id, code, label
|
||||
FROM freight.container_types
|
||||
WHERE deleted_at IS NULL AND is_active
|
||||
ORDER BY display_order, code
|
||||
`);
|
||||
return rows
|
||||
.filter((r) => UUID_RE.test(r.id))
|
||||
.map((r) => {
|
||||
const name = r.label || r.code;
|
||||
return {
|
||||
key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`,
|
||||
label: `${name} containers`,
|
||||
type: 'number' as const,
|
||||
group: 'cargo',
|
||||
select: `(SELECT COALESCE(SUM(bc.quantity), 0)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = b.id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id = '${r.id}')::int`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const bookingsDataset: ExportDataset = {
|
||||
key: 'bookings',
|
||||
title: 'Bookings',
|
||||
@@ -112,10 +206,21 @@ export const bookingsDataset: ExportDataset = {
|
||||
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' },
|
||||
|
||||
// ---- Cargo -----------------------------------------------------------
|
||||
{ key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
|
||||
// What the customer said is in the booking. `cargo` below is the narrower
|
||||
// commodity-only view, kept for saved presets that already tick it.
|
||||
{ key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT },
|
||||
{ key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
|
||||
{ key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' },
|
||||
// Boxes, not lines: booking_container is one row per LINE with a quantity.
|
||||
{ key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT },
|
||||
// Declared on the shipment request, not yet on the booking — see the helper.
|
||||
{ key: 'requestedCargo', label: 'Requested cargo', type: 'string', group: 'cargo', select: bookingRequestedCargoSql('b') },
|
||||
{ key: 'requestedContainers', label: 'Requested containers', type: 'number', group: 'cargo', select: `${REQUESTED_COUNT}::int`, sortExpr: REQUESTED_COUNT },
|
||||
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
|
||||
{ key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' },
|
||||
// The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that
|
||||
// column at 0 for container freight, so it read 0 for every such booking.
|
||||
{ key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') },
|
||||
{ key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' },
|
||||
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' },
|
||||
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' },
|
||||
@@ -172,6 +277,8 @@ export const bookingsDataset: ExportDataset = {
|
||||
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
|
||||
],
|
||||
|
||||
dynamicFields: containerTypeFields,
|
||||
|
||||
filters: [
|
||||
{ key: 'created', label: 'Created', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
@@ -190,6 +297,13 @@ export const bookingsDataset: ExportDataset = {
|
||||
{ value: 'PAID', label: 'Paid' },
|
||||
{ value: 'FAILED', label: 'Failed' },
|
||||
] },
|
||||
{ key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions },
|
||||
{ key: 'cargoText', label: 'Content contains', type: 'text' },
|
||||
{ key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions },
|
||||
{ key: 'containersMin', label: 'Containers (min)', type: 'text' },
|
||||
{ key: 'containersMax', label: 'Containers (max)', type: 'text' },
|
||||
{ key: 'requestedContainersMin', label: 'Requested containers (min)', type: 'text' },
|
||||
{ key: 'requestedContainersMax', label: 'Requested containers (max)', type: 'text' },
|
||||
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
||||
],
|
||||
@@ -210,6 +324,23 @@ export const bookingsDataset: ExportDataset = {
|
||||
|
||||
if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
|
||||
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||
// Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL).
|
||||
if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId });
|
||||
if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` });
|
||||
if (params.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId });
|
||||
// With a container type picked the count is of THAT type, else of every box.
|
||||
const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId));
|
||||
// coerceFilterParams yields null (not undefined) for an unset filter, and
|
||||
// Number(null) is 0 — which would silently apply ">= 0" to every export.
|
||||
const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v));
|
||||
const min = num(params.containersMin);
|
||||
const max = num(params.containersMax);
|
||||
if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min });
|
||||
if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max });
|
||||
const reqMin = num(params.requestedContainersMin);
|
||||
const reqMax = num(params.requestedContainersMax);
|
||||
if (Number.isFinite(reqMin)) qb.andWhere(`${REQUESTED_COUNT} >= :requestedContainersMin`, { requestedContainersMin: reqMin });
|
||||
if (Number.isFinite(reqMax)) qb.andWhere(`${REQUESTED_COUNT} <= :requestedContainersMax`, { requestedContainersMax: reqMax });
|
||||
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
|
||||
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
|
||||
if (params.search) {
|
||||
|
||||
@@ -122,6 +122,16 @@ export const customersDataset: ExportDataset = {
|
||||
{ value: 'ethiopian', label: 'Ethiopian' },
|
||||
{ value: 'foreign', label: 'Foreign' },
|
||||
] },
|
||||
// The operational role, NOT `type` above — the list's Role pill. One
|
||||
// `customer` company routinely holds several profiles, so this asks "who
|
||||
// does X?" rather than "what kind of company is this?".
|
||||
{ key: 'profileType', label: 'Role', type: 'select', options: [
|
||||
{ value: 'importer', label: 'Importer' },
|
||||
{ value: 'exporter', label: 'Exporter' },
|
||||
{ value: 'freight_forwarder', label: 'Freight forwarder' },
|
||||
{ value: 'dj_freight_forwarder', label: 'DJ freight forwarder' },
|
||||
{ value: 'transporter', label: 'Transporter' },
|
||||
] },
|
||||
// The list's Status filter folds the review queues in, and sends these two
|
||||
// alongside `status`. They are predicates, not columns — see
|
||||
// `company-scope.sql.ts`, shared with the list so both agree exactly.
|
||||
@@ -147,6 +157,18 @@ export const customersDataset: ExportDataset = {
|
||||
if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind });
|
||||
if (params.status) qb.andWhere('c.status = :status', { status: params.status });
|
||||
if (params.nationality) qb.andWhere('c.nationality = :nationality', { nationality: params.nationality });
|
||||
if (params.profileType) {
|
||||
// EXISTS, matching the list repository exactly — a join here would
|
||||
// multiply a company holding two profiles into two rows and put the file
|
||||
// out of step with the count endpoint.
|
||||
qb.andWhere(
|
||||
`EXISTS (SELECT 1 FROM freight.company_profiles cp_type
|
||||
WHERE cp_type.company_id = c.id
|
||||
AND cp_type.deleted_at IS NULL
|
||||
AND cp_type.type = :profileType)`,
|
||||
{ profileType: params.profileType },
|
||||
);
|
||||
}
|
||||
if (params.onboardingCompleted) {
|
||||
const draft = companyDraftSql('c');
|
||||
qb.andWhere(params.onboardingCompleted === 'true' ? `NOT ${draft}` : draft);
|
||||
|
||||
@@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = {
|
||||
// on-screen filter actually carries into the export.
|
||||
{ key: 'status', label: 'Status (single)', type: 'text' },
|
||||
{ key: 'sources', label: 'Source', type: 'multiselect' },
|
||||
{ key: 'types', label: 'Type', type: 'multiselect' },
|
||||
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
|
||||
{ key: 'paymentMethods', label: 'Payment method', type: 'multiselect' },
|
||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||
@@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = {
|
||||
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
|
||||
const sources = params.sources as string[] | null;
|
||||
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
|
||||
const types = params.types as string[] | null;
|
||||
if (types?.length) qb.andWhere('i.type IN (:...types)', { types });
|
||||
const eimsStatuses = params.eimsStatuses as string[] | null;
|
||||
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
|
||||
const paymentMethods = params.paymentMethods as string[] | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Route } from '../../routes/entities/route.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
@@ -86,7 +87,7 @@ export const trainSchedulesDataset: ExportDataset = {
|
||||
},
|
||||
{
|
||||
key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true,
|
||||
select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8
|
||||
select: `(SELECT ROUND(COALESCE(SUM(${bookingTonsSql('b')}), 0))::float8
|
||||
FROM freight.bookings b
|
||||
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import type { ExportField } from './export.types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
||||
@@ -64,6 +66,26 @@ export function coerceFilterParams(
|
||||
*/
|
||||
const optionsCache = new Map<string, ExportFilterOption[]>();
|
||||
|
||||
/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */
|
||||
const fieldsCache = new Map<string, ExportField[]>();
|
||||
|
||||
/**
|
||||
* A dataset's full field list: its static fields plus whatever `dynamicFields`
|
||||
* resolves from the DB. Every read of `dataset.fields` goes through this, so
|
||||
* the catalog and the download agree on which keys exist.
|
||||
*/
|
||||
export async function resolveDatasetFields(
|
||||
dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise<ExportField[]> },
|
||||
ds: DataSource,
|
||||
): Promise<ExportField[]> {
|
||||
if (!dataset.dynamicFields) return dataset.fields;
|
||||
const cached = fieldsCache.get(dataset.key);
|
||||
if (cached) return cached;
|
||||
const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))];
|
||||
fieldsCache.set(dataset.key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function resolveFilterOptions(
|
||||
filters: ExportFilterDef[],
|
||||
ds: DataSource,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
EXPORT_MIME,
|
||||
formatRowCap,
|
||||
pickByKey,
|
||||
pickDatasetFields,
|
||||
resolveExportFormat,
|
||||
resolveRowLimit,
|
||||
} from './export-request.util';
|
||||
@@ -85,3 +86,48 @@ describe('pickByKey', () => {
|
||||
expect(pickByKey(columns, 'ghost,also-ghost')).toEqual(columns);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickDatasetFields', () => {
|
||||
const fields = [
|
||||
{ key: 'name', label: 'Company', default: true },
|
||||
{ key: 'tin', label: 'TIN', default: true },
|
||||
{ key: 'website', label: 'Website' },
|
||||
{ key: 'kebele', label: 'Kebele' },
|
||||
];
|
||||
const defaults = [fields[0], fields[1]];
|
||||
|
||||
it.each([undefined, '', ' ', ','])('%p means the default fields', (raw) => {
|
||||
expect(pickDatasetFields(fields, raw)).toEqual(defaults);
|
||||
});
|
||||
|
||||
it('a subset is honoured, in the dataset\'s own order', () => {
|
||||
expect(pickDatasetFields(fields, 'kebele,name')).toEqual([fields[0], fields[3]]);
|
||||
});
|
||||
|
||||
it('asking for EVERY field exports every field', () => {
|
||||
// The dialog's "All columns" chip sends exactly this. Falling back to the
|
||||
// defaults here was the bug: 37 ticked customer columns exported as 9.
|
||||
expect(pickDatasetFields(fields, 'name,tin,website,kebele')).toEqual(fields);
|
||||
});
|
||||
|
||||
it('a non-default field alone is not widened back to the defaults', () => {
|
||||
expect(pickDatasetFields(fields, 'website')).toEqual([fields[2]]);
|
||||
});
|
||||
|
||||
it('unknown keys are dropped, the recognised ones still stand', () => {
|
||||
expect(pickDatasetFields(fields, 'ghost,website')).toEqual([fields[2]]);
|
||||
});
|
||||
|
||||
it('all-unknown keys fall back to the defaults, not to everything', () => {
|
||||
expect(pickDatasetFields(fields, 'ghost,also-ghost')).toEqual(defaults);
|
||||
});
|
||||
|
||||
it('surrounding whitespace in a hand-built fields list is tolerated', () => {
|
||||
expect(pickDatasetFields(fields, ' name , website ')).toEqual([fields[0], fields[2]]);
|
||||
});
|
||||
|
||||
it('a dataset with no default flags falls back to every field', () => {
|
||||
const flat = [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }];
|
||||
expect(pickDatasetFields(flat, undefined)).toEqual(flat);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,3 +56,31 @@ export function pickByKey<T extends { key: string }>(all: T[], raw: string | und
|
||||
const filtered = requested?.length ? all.filter((c) => requested.includes(c.key)) : all;
|
||||
return filtered.length ? filtered : all;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dataset's requested field subset, whitelisted against what the caller may
|
||||
* have. Unlike `pickByKey`, "nothing recognised" falls back to the DEFAULT
|
||||
* fields rather than to every field — a bookings export declares ~70 columns
|
||||
* and dumping all of them on an unparameterised call is nobody's intent.
|
||||
*
|
||||
* Selecting every field is a legitimate request — the dialog's "All columns"
|
||||
* chip sends exactly that — so the fallback keys off whether any requested key
|
||||
* MATCHED, never off how many fields came back. Comparing the picked count to
|
||||
* `all.length` (as this did originally) made "All columns" silently export the
|
||||
* default columns instead.
|
||||
*/
|
||||
export function pickDatasetFields<T extends { key: string; default?: boolean }>(
|
||||
all: T[],
|
||||
raw: string | undefined,
|
||||
): T[] {
|
||||
const requested = new Set(
|
||||
raw
|
||||
?.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean) ?? [],
|
||||
);
|
||||
const picked = requested.size ? all.filter((f) => requested.has(f.key)) : [];
|
||||
if (picked.length) return picked;
|
||||
const defaults = all.filter((f) => f.default);
|
||||
return defaults.length ? defaults : all;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,15 @@ export interface ExportDataset {
|
||||
alwaysJoin?: string[];
|
||||
groups: ExportGroup[];
|
||||
fields: ExportField[];
|
||||
/**
|
||||
* Extra fields resolved from reference data and appended to `fields` — one
|
||||
* column per row of some small, rarely-changing table (a column per container
|
||||
* type, say). Cached for the process, like `ExportFilterDef.optionsQuery`.
|
||||
*
|
||||
* The SQL these build is interpolated, not bound, so a resolver MUST validate
|
||||
* anything it splices in; see `bookingsDataset` for the uuid guard.
|
||||
*/
|
||||
dynamicFields?: (ds: DataSource) => Promise<ExportField[]>;
|
||||
filters: ExportFilterDef[];
|
||||
/** Must name a field whose `sortExpr` references only the base alias. */
|
||||
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
|
||||
|
||||
@@ -9,11 +9,11 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
|
||||
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { resolveFilterOptions } from './export-filter.util';
|
||||
import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util';
|
||||
import {
|
||||
EXPORT_MIME,
|
||||
formatRowCap,
|
||||
pickByKey,
|
||||
pickDatasetFields,
|
||||
resolveExportFormat,
|
||||
resolveRowLimit,
|
||||
} from './export-request.util';
|
||||
@@ -31,13 +31,16 @@ const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
|
||||
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
|
||||
* the schema — they never leave the server.
|
||||
*/
|
||||
const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
|
||||
const toCatalogEntry = (
|
||||
dataset: ExportDataset,
|
||||
fields: ExportField[],
|
||||
): ExportCatalogEntry => ({
|
||||
key: dataset.key,
|
||||
title: dataset.title,
|
||||
description: dataset.description,
|
||||
group: dataset.group,
|
||||
groups: dataset.groups,
|
||||
fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({
|
||||
fields: fields.map(({ key, label, type, group, default: isDefault }) => ({
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
@@ -72,7 +75,7 @@ export class ExportsController {
|
||||
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
|
||||
return Promise.all(
|
||||
allowed.map(async (d) => ({
|
||||
...toCatalogEntry(d),
|
||||
...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)),
|
||||
filters: await resolveFilterOptions(d.filters, this.dataSource),
|
||||
})),
|
||||
);
|
||||
@@ -102,7 +105,10 @@ export class ExportsController {
|
||||
const dataset = this.resolve(key, user);
|
||||
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
const format = resolveExportFormat(query.format);
|
||||
const fields = this.resolveFields(dataset, query.fields);
|
||||
const fields = pickDatasetFields(
|
||||
await resolveDatasetFields(dataset, this.dataSource),
|
||||
query.fields,
|
||||
);
|
||||
|
||||
const rows = await this.runner.run(dataset, fields, query, directions, {
|
||||
cap: formatRowCap(format),
|
||||
@@ -129,22 +135,6 @@ export class ExportsController {
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requested fields, whitelisted against the dataset. No `fields=` means the
|
||||
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
|
||||
* all of them on an unparameterised call is nobody's intent.
|
||||
*/
|
||||
private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] {
|
||||
if (raw?.trim()) {
|
||||
const picked = pickByKey(dataset.fields, raw);
|
||||
// pickByKey falls back to everything when nothing matched; for a dataset
|
||||
// the safer read of "all keys unknown" is still the default set.
|
||||
if (picked.length !== dataset.fields.length) return picked;
|
||||
}
|
||||
const defaults = dataset.fields.filter((f) => f.default);
|
||||
return defaults.length ? defaults : dataset.fields;
|
||||
}
|
||||
|
||||
private resolve(key: string, user: TCurrentUser): ExportDataset {
|
||||
const dataset = getDataset(key);
|
||||
if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import type { Response } from 'express';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { MatrixClient } from '../chat/matrix.client';
|
||||
import type { EmailClientService } from '../notifications/email-client.service';
|
||||
import type { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
type ReadinessBody = {
|
||||
status: string;
|
||||
checks: {
|
||||
chat: { status: string; enabled: boolean; actingAs?: string; error?: string };
|
||||
};
|
||||
};
|
||||
|
||||
/** Captures what the controller wrote, in place of an express Response. */
|
||||
function recorder() {
|
||||
const sent: { code?: number; body?: ReadinessBody } = {};
|
||||
const res = {
|
||||
status(code: number) {
|
||||
sent.code = code;
|
||||
return this;
|
||||
},
|
||||
json(body: ReadinessBody) {
|
||||
sent.body = body;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return { sent, res: res as unknown as Response };
|
||||
}
|
||||
|
||||
function controllerWith(matrix: Partial<MatrixClient>) {
|
||||
const dataSource = { query: jest.fn(async () => [{ '?column?': 1 }]) };
|
||||
return new HealthController(
|
||||
dataSource as unknown as DataSource,
|
||||
{ brokerConnected: true } as unknown as SmsClientService,
|
||||
{ brokerConnected: true } as unknown as EmailClientService,
|
||||
matrix as MatrixClient,
|
||||
);
|
||||
}
|
||||
|
||||
describe('HealthController readiness — chat check', () => {
|
||||
it('reports degraded, not 503, when MATRIX_ADMIN_TOKEN is not a server admin', async () => {
|
||||
// The dev outage. Chat is broken, but chat is not worth pulling the pod
|
||||
// out of the load balancer for — bookings and billing still work.
|
||||
const controller = controllerWith({
|
||||
enabled: true,
|
||||
adminCheck: jest.fn(async () => ({
|
||||
ok: false,
|
||||
actingAs: '@super-admin.f15347:matrixdev.edrsc.com',
|
||||
error: 'Matrix GET /_synapse/admin/v2/users?limit=1 -> 403: not a server admin',
|
||||
})),
|
||||
});
|
||||
|
||||
const { sent, res } = recorder();
|
||||
await controller.readiness(res);
|
||||
|
||||
expect(sent.code).toBe(200);
|
||||
expect(sent.body?.status).toBe('degraded');
|
||||
expect(sent.body?.checks.chat.status).toBe('error');
|
||||
// The account name is the actionable half — it says *which* token is wired up.
|
||||
expect(sent.body?.checks.chat.actingAs).toBe(
|
||||
'@super-admin.f15347:matrixdev.edrsc.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports ok when the token really is a server admin', async () => {
|
||||
const controller = controllerWith({
|
||||
enabled: true,
|
||||
adminCheck: jest.fn(async () => ({
|
||||
ok: true,
|
||||
actingAs: '@edrbot:matrixdev.edrsc.com',
|
||||
})),
|
||||
});
|
||||
|
||||
const { sent, res } = recorder();
|
||||
await controller.readiness(res);
|
||||
|
||||
expect(sent.body?.status).toBe('ok');
|
||||
expect(sent.body?.checks.chat).toMatchObject({
|
||||
status: 'ok',
|
||||
enabled: true,
|
||||
actingAs: '@edrbot:matrixdev.edrsc.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call Synapse, or degrade, when chat is switched off', async () => {
|
||||
const adminCheck = jest.fn();
|
||||
const controller = controllerWith({ enabled: false, adminCheck });
|
||||
|
||||
const { sent, res } = recorder();
|
||||
await controller.readiness(res);
|
||||
|
||||
expect(adminCheck).not.toHaveBeenCalled();
|
||||
expect(sent.body?.status).toBe('ok');
|
||||
expect(sent.body?.checks.chat).toEqual({ status: 'unknown', enabled: false });
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { Public } from "@edr/api-common";
|
||||
import { Response } from "express";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { MatrixClient } from "../chat/matrix.client";
|
||||
import { EmailClientService } from "../notifications/email-client.service";
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
|
||||
@@ -32,6 +33,7 @@ export class HealthController {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly smsClient: SmsClientService,
|
||||
private readonly emailClient: EmailClientService,
|
||||
private readonly matrix: MatrixClient,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -45,7 +47,7 @@ export class HealthController {
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Readiness probe — database plus SMS/email broker connectivity. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true.",
|
||||
"Readiness probe — database, SMS/email broker connectivity, and the Matrix admin token. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true; chat failures always report as degraded.",
|
||||
})
|
||||
async readiness(@Res() res: Response) {
|
||||
const startedAt = Date.now();
|
||||
@@ -76,23 +78,55 @@ export class HealthController {
|
||||
enabled: process.env.RABBITMQ_ENABLED !== "false",
|
||||
};
|
||||
|
||||
const chat = await this.chatCheck();
|
||||
|
||||
const brokerDown =
|
||||
broker.sms.status === "error" || broker.email.status === "error";
|
||||
const failed =
|
||||
database.status === "error" ||
|
||||
(READINESS_REQUIRES_BROKER && brokerDown);
|
||||
|
||||
const status = failed ? "error" : brokerDown ? "degraded" : "ok";
|
||||
const status = failed
|
||||
? "error"
|
||||
: brokerDown || chat.status === "error"
|
||||
? "degraded"
|
||||
: "ok";
|
||||
|
||||
return res
|
||||
.status(failed ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK)
|
||||
.json({
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: { database, broker },
|
||||
checks: { database, broker, chat },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat provisioning runs entirely on MATRIX_ADMIN_TOKEN, and a token that is
|
||||
* valid but not *server admin* fails only the `/_synapse/admin` half: rooms
|
||||
* are never created, joins never happen, and the sole symptom is an empty
|
||||
* Element for every employee. Nothing else in the probe would catch that.
|
||||
*
|
||||
* Degraded, never a 503 — chat is not worth pulling the pod out of the load
|
||||
* balancer for, by the same reasoning as the broker check above. `unknown`
|
||||
* when MATRIX_ENABLED is off: a feature that is switched off is not a fault.
|
||||
*/
|
||||
private async chatCheck(): Promise<{
|
||||
status: CheckStatus;
|
||||
enabled: boolean;
|
||||
actingAs?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
if (!this.matrix.enabled) return { status: "unknown", enabled: false };
|
||||
const check = await this.matrix.adminCheck();
|
||||
return {
|
||||
status: check.ok ? "ok" : "error",
|
||||
enabled: true,
|
||||
actingAs: check.actingAs,
|
||||
error: check.error,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("info")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "App info — version, environment, uptime" })
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { ChatModule } from "../chat/chat.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
|
||||
@Module({
|
||||
// NotificationsModule exports the SMS/email clients; the readiness probe reads
|
||||
// their broker connection state rather than opening a second connection.
|
||||
imports: [NotificationsModule],
|
||||
// ChatModule exports MatrixClient for the MATRIX_ADMIN_TOKEN check.
|
||||
imports: [NotificationsModule, ChatModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -150,6 +153,14 @@ export class CreateEmptyContainerReturnDto {
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Owning company name — free text when the company is not a registered customer.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
companyName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@@ -196,6 +207,16 @@ export class CreateEmptyContainerReturnDto {
|
||||
returnedBy?: 'EDR' | 'CUSTOMER';
|
||||
}
|
||||
|
||||
export class BulkCreateEmptyContainerReturnsDto {
|
||||
@ApiProperty({ type: [CreateEmptyContainerReturnDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(1000)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateEmptyContainerReturnDto)
|
||||
returns!: CreateEmptyContainerReturnDto[];
|
||||
}
|
||||
|
||||
export class LoadEmptyContainerItemDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
assembleEmptyReturnBookings,
|
||||
type EmptyReturnBookingUnitRow,
|
||||
} from './empty-return-bookings.util';
|
||||
|
||||
const booking = {
|
||||
bookingId: 'b1',
|
||||
bookingReference: 'BK-2026-000263',
|
||||
bookingStatus: 'IN_TRANSIT',
|
||||
equipmentReturn: 'WITH_RETURN',
|
||||
customerId: 'c1',
|
||||
companyName: 'Afri Software Solutions',
|
||||
};
|
||||
|
||||
const unit = (
|
||||
overrides: Partial<EmptyReturnBookingUnitRow> & { unitId: string; containerNumber: string },
|
||||
): EmptyReturnBookingUnitRow => ({
|
||||
...booking,
|
||||
containerSize: '40ft',
|
||||
containerType: '40FT',
|
||||
returnId: null,
|
||||
returnStatus: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('assembleEmptyReturnBookings', () => {
|
||||
it('groups a booking’s flagged containers onto one row, all pending', () => {
|
||||
const rows = assembleEmptyReturnBookings([
|
||||
unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }),
|
||||
unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }),
|
||||
]);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].bookingReference).toBe('BK-2026-000263');
|
||||
expect(rows[0].companyName).toBe('Afri Software Solutions');
|
||||
expect(rows[0].containers.map((c) => c.containerNumber)).toEqual([
|
||||
'MSFH8596324',
|
||||
'SDJU8596324',
|
||||
]);
|
||||
expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 0, pendingCount: 2 });
|
||||
});
|
||||
|
||||
it('keeps an already-recorded container visible but out of the pending count', () => {
|
||||
const rows = assembleEmptyReturnBookings([
|
||||
unit({
|
||||
unitId: 'u1',
|
||||
containerNumber: 'MSFH8596324',
|
||||
returnId: 'r1',
|
||||
returnStatus: 'ASSIGNED_STORAGE',
|
||||
}),
|
||||
unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }),
|
||||
]);
|
||||
|
||||
expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 1, pendingCount: 1 });
|
||||
expect(rows[0].containers[0].returnStatus).toBe('ASSIGNED_STORAGE');
|
||||
});
|
||||
|
||||
it('drops a booking once every container is recorded', () => {
|
||||
const rows = assembleEmptyReturnBookings([
|
||||
unit({
|
||||
unitId: 'u1',
|
||||
containerNumber: 'MSFH8596324',
|
||||
returnId: 'r1',
|
||||
returnStatus: 'RETURNED',
|
||||
}),
|
||||
unit({
|
||||
unitId: 'u2',
|
||||
containerNumber: 'SDJU8596324',
|
||||
returnId: 'r2',
|
||||
returnStatus: 'COMPLETED',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps each booking on its own row, in query order', () => {
|
||||
const other = {
|
||||
...booking,
|
||||
bookingId: 'b2',
|
||||
bookingReference: 'BK-2026-000286',
|
||||
companyName: 'DE BE KE',
|
||||
};
|
||||
const rows = assembleEmptyReturnBookings([
|
||||
unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }),
|
||||
{ ...unit({ unitId: 'u2', containerNumber: 'ASDS1234567' }), ...other },
|
||||
unit({ unitId: 'u3', containerNumber: 'SDJU8596324' }),
|
||||
]);
|
||||
|
||||
expect(rows.map((r) => r.bookingReference)).toEqual(['BK-2026-000263', 'BK-2026-000286']);
|
||||
expect(rows[0].containers).toHaveLength(2);
|
||||
expect(rows[1].containers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns nothing when no booking owes an empty', () => {
|
||||
expect(assembleEmptyReturnBookings([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { EmptyContainerReturnStatus } from './entities/empty-container-return.entity';
|
||||
|
||||
/**
|
||||
* `WITH_RETURN` is the current value; `RETURN` is what older bookings were
|
||||
* written with. Both mean the same thing — the booking owes empties back.
|
||||
*/
|
||||
export const WITH_RETURN_EQUIPMENT_VALUES = ['WITH_RETURN', 'RETURN'];
|
||||
|
||||
/** Bookings in these statuses never ship, so they never owe an empty back. */
|
||||
export const EMPTY_RETURN_CLOSED_BOOKING_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
/**
|
||||
* One flagged return container of a booking, as the query hands it over: the
|
||||
* booking columns repeat on every row, and `returnId` is set when this exact
|
||||
* container already has an empty return recorded against the booking.
|
||||
*/
|
||||
export interface EmptyReturnBookingUnitRow {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
bookingStatus: string;
|
||||
equipmentReturn: string;
|
||||
customerId: string | null;
|
||||
companyName: string | null;
|
||||
unitId: string;
|
||||
containerNumber: string;
|
||||
containerSize: string | null;
|
||||
containerType: string | null;
|
||||
returnId: string | null;
|
||||
returnStatus: EmptyContainerReturnStatus | null;
|
||||
}
|
||||
|
||||
/** One container a booking owes back empty. */
|
||||
export interface EmptyReturnBookingContainer {
|
||||
/** Stable row key — the booking container unit id. */
|
||||
key: string;
|
||||
unitId: string;
|
||||
containerNumber: string;
|
||||
containerSize: string | null;
|
||||
containerType: string | null;
|
||||
/** Set once the empty return for this container has been recorded. */
|
||||
returnId: string | null;
|
||||
returnStatus: EmptyContainerReturnStatus | null;
|
||||
}
|
||||
|
||||
/** A booking that ships with empty-container return and still owes empties. */
|
||||
export interface EmptyReturnBookingRow {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
bookingStatus: string;
|
||||
equipmentReturn: string;
|
||||
customerId: string | null;
|
||||
companyName: string | null;
|
||||
containers: EmptyReturnBookingContainer[];
|
||||
expectedCount: number;
|
||||
recordedCount: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a booking's flagged return containers onto one row per booking.
|
||||
*
|
||||
* A container whose empty return is already recorded keeps its row — the
|
||||
* screen shows what has been done — but stops counting as pending, and a
|
||||
* booking with nothing left pending drops off the list entirely.
|
||||
*
|
||||
* Row order follows the query (newest booking first, containers in booking
|
||||
* order), so the caller decides the ordering, not this function.
|
||||
*/
|
||||
export function assembleEmptyReturnBookings(
|
||||
units: EmptyReturnBookingUnitRow[],
|
||||
): EmptyReturnBookingRow[] {
|
||||
const rows = new Map<string, EmptyReturnBookingRow>();
|
||||
|
||||
for (const unit of units) {
|
||||
const row = rows.get(unit.bookingId) ?? {
|
||||
bookingId: unit.bookingId,
|
||||
bookingReference: unit.bookingReference,
|
||||
bookingStatus: unit.bookingStatus,
|
||||
equipmentReturn: unit.equipmentReturn,
|
||||
customerId: unit.customerId,
|
||||
companyName: unit.companyName,
|
||||
containers: [],
|
||||
expectedCount: 0,
|
||||
recordedCount: 0,
|
||||
pendingCount: 0,
|
||||
};
|
||||
row.containers.push({
|
||||
key: unit.unitId,
|
||||
unitId: unit.unitId,
|
||||
containerNumber: unit.containerNumber,
|
||||
containerSize: unit.containerSize,
|
||||
containerType: unit.containerType,
|
||||
returnId: unit.returnId,
|
||||
returnStatus: unit.returnStatus,
|
||||
});
|
||||
rows.set(unit.bookingId, row);
|
||||
}
|
||||
|
||||
return [...rows.values()]
|
||||
.map((row) => ({
|
||||
...row,
|
||||
expectedCount: row.containers.length,
|
||||
recordedCount: row.containers.filter((container) => container.returnId).length,
|
||||
pendingCount: row.containers.filter((container) => !container.returnId).length,
|
||||
}))
|
||||
.filter((row) => row.pendingCount > 0);
|
||||
}
|
||||
@@ -27,6 +27,15 @@ export class EmptyContainerReturn extends BaseEntity {
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId?: string | null;
|
||||
|
||||
/**
|
||||
* Owning company as text. Set when the box was backfilled for a company that
|
||||
* is not (yet) a registered customer, so `customer_id` cannot carry it. When
|
||||
* a registered company IS picked, both are set — the name is the label the
|
||||
* list renders without a join.
|
||||
*/
|
||||
@Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
|
||||
companyName?: string | null;
|
||||
|
||||
@Column({ name: 'return_date', type: 'timestamptz' })
|
||||
returnDate!: Date;
|
||||
|
||||
@@ -75,3 +84,14 @@ export class EmptyContainerReturn extends BaseEntity {
|
||||
performedBy: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the returns list: the entity's own columns plus the booking
|
||||
* reference and owning company joined in. Standalone returns leave
|
||||
* `bookingId`/`bookingReference` null.
|
||||
*/
|
||||
export interface EmptyContainerReturnListItem
|
||||
extends Omit<EmptyContainerReturn, 'createdAt' | 'updatedAt' | 'deletedAt'> {
|
||||
bookingReference: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { BookingsService } from '../bookings/bookings.service';
|
||||
import {
|
||||
AssignCustomsRiskDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
BulkCreateEmptyContainerReturnsDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
ImportOperationActionDto,
|
||||
LoadEmptyContainersOnTrainDto,
|
||||
@@ -118,6 +119,15 @@ export class ImportOperationsController {
|
||||
return this.service.listEmptyReturns();
|
||||
}
|
||||
|
||||
@Get('empty-return-bookings')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary: 'Bookings shipping with empty-container return that still owe empties, with their containers',
|
||||
})
|
||||
listEmptyReturnBookings() {
|
||||
return this.service.listEmptyReturnBookings();
|
||||
}
|
||||
|
||||
@Post('empty-container-returns')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
|
||||
@@ -125,6 +135,15 @@ export class ImportOperationsController {
|
||||
return this.service.createEmptyReturn(dto);
|
||||
}
|
||||
|
||||
@Post('empty-container-returns/bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary: 'Bulk-record empties already in the yard but never entered in the system',
|
||||
})
|
||||
bulkCreateEmptyReturns(@Body() dto: BulkCreateEmptyContainerReturnsDto) {
|
||||
return this.service.bulkCreateEmptyReturns(dto);
|
||||
}
|
||||
|
||||
@Post('empty-container-returns/load-on-train')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { EmptyReturnRequestsModule } from '../empty-return-requests/empty-return-requests.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { WarehousesModule } from '../warehouses/warehouses.module';
|
||||
@@ -26,6 +27,9 @@ import { ImportOperationsService } from './import-operations.service';
|
||||
BookingsModule,
|
||||
NotificationInboxModule,
|
||||
NotificationsModule,
|
||||
// Recording a return is what closes out the customer's scheduled empty
|
||||
// return request, once every container on it is back.
|
||||
EmptyReturnRequestsModule,
|
||||
],
|
||||
controllers: [ImportOperationsController],
|
||||
providers: [ImportOperationsService],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||
@@ -8,8 +8,10 @@ import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { EmptyReturnRequestsService } from '../empty-return-requests/empty-return-requests.service';
|
||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||
import {
|
||||
BulkCreateEmptyContainerReturnsDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
ImportOperationActionDto,
|
||||
@@ -24,7 +26,18 @@ import {
|
||||
type DjiboutiIncidentType,
|
||||
} from './entities/djibouti-incident.entity';
|
||||
import { assertWagonLoad } from './empty-container-wagon.util';
|
||||
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
||||
import {
|
||||
assembleEmptyReturnBookings,
|
||||
EMPTY_RETURN_CLOSED_BOOKING_STATUSES,
|
||||
WITH_RETURN_EQUIPMENT_VALUES,
|
||||
type EmptyReturnBookingRow,
|
||||
type EmptyReturnBookingUnitRow,
|
||||
} from './empty-return-bookings.util';
|
||||
import {
|
||||
EmptyContainerReturn,
|
||||
type EmptyContainerReturnListItem,
|
||||
type EmptyContainerReturnStatus,
|
||||
} from './entities/empty-container-return.entity';
|
||||
import {
|
||||
ImportCustomsFinalization,
|
||||
type ImportCustomsDocumentType,
|
||||
@@ -52,6 +65,7 @@ export class ImportOperationsService {
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly emptyReturnRequests: EmptyReturnRequestsService,
|
||||
) {}
|
||||
|
||||
listIncidents(bookingId?: string) {
|
||||
@@ -159,14 +173,94 @@ export class ImportOperationsService {
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
listEmptyReturns() {
|
||||
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
|
||||
/**
|
||||
* Every empty return, booking-linked and standalone alike, in one list. The
|
||||
* booking reference and the owning company are joined in so the table can
|
||||
* show which booking a box came back on without a second round trip — a
|
||||
* standalone row simply has neither, and falls back to the typed
|
||||
* `company_name`.
|
||||
*/
|
||||
listEmptyReturns(): Promise<EmptyContainerReturnListItem[]> {
|
||||
return this.emptyReturns.manager.query(`
|
||||
SELECT
|
||||
r.id,
|
||||
r.container_number AS "containerNumber",
|
||||
r.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
r.customer_id AS "customerId",
|
||||
COALESCE(r.company_name, c.name) AS "companyName",
|
||||
r.return_date AS "returnDate",
|
||||
r.facility,
|
||||
r.yard,
|
||||
r.zone,
|
||||
r.condition,
|
||||
r.handover_note AS "handoverNote",
|
||||
r.status,
|
||||
r.wagon_allocation_reference AS "wagonAllocationReference",
|
||||
r.container_size AS "containerSize",
|
||||
r.train_schedule_id AS "trainScheduleId",
|
||||
r.wagon_sequence_no AS "wagonSequenceNo",
|
||||
r.performed_by AS "performedBy",
|
||||
r.returned_by AS "returnedBy",
|
||||
r.status_history AS "statusHistory",
|
||||
r.created_at AS "createdAt"
|
||||
FROM freight.empty_container_returns r
|
||||
LEFT JOIN freight.bookings b ON b.id = r.booking_id
|
||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||
WHERE r.deleted_at IS NULL
|
||||
ORDER BY r.created_at DESC
|
||||
`);
|
||||
}
|
||||
|
||||
listEmptyReturnsForBooking(bookingId: string) {
|
||||
return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings that ship WITH empty-container return and still owe empties, each
|
||||
* with the containers that are to be returned — the ones the booking flagged
|
||||
* `is_return`, carrying the empty return already recorded against each, if
|
||||
* any.
|
||||
*/
|
||||
async listEmptyReturnBookings(): Promise<EmptyReturnBookingRow[]> {
|
||||
const units: EmptyReturnBookingUnitRow[] = await this.emptyReturns.manager.query(
|
||||
`SELECT b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.status AS "bookingStatus",
|
||||
b.equipment_return AS "equipmentReturn",
|
||||
b.company_id AS "customerId",
|
||||
c.name AS "companyName",
|
||||
u.id AS "unitId",
|
||||
u.container_number AS "containerNumber",
|
||||
COALESCE(bc.container_size, ct.code) AS "containerSize",
|
||||
ct.label AS "containerType",
|
||||
r.id AS "returnId",
|
||||
r.status AS "returnStatus"
|
||||
FROM freight.booking_container_units u
|
||||
JOIN freight.booking_container bc ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL
|
||||
JOIN freight.bookings b ON b.id = bc.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT er.id, er.status
|
||||
FROM freight.empty_container_returns er
|
||||
WHERE er.deleted_at IS NULL
|
||||
AND er.booking_id = b.id
|
||||
AND upper(er.container_number) = upper(u.container_number)
|
||||
ORDER BY er.created_at DESC
|
||||
LIMIT 1
|
||||
) r ON TRUE
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND u.is_return = true
|
||||
AND b.equipment_return = ANY($1)
|
||||
AND b.status <> ALL($2)
|
||||
ORDER BY b.created_at DESC, u.sort_order ASC`,
|
||||
[WITH_RETURN_EQUIPMENT_VALUES, EMPTY_RETURN_CLOSED_BOOKING_STATUSES],
|
||||
);
|
||||
|
||||
return assembleEmptyReturnBookings(units);
|
||||
}
|
||||
|
||||
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
|
||||
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
|
||||
const saved = await this.emptyReturns.save(
|
||||
@@ -174,6 +268,7 @@ export class ImportOperationsService {
|
||||
containerNumber: dto.containerNumber,
|
||||
bookingId: dto.bookingId ?? null,
|
||||
customerId: dto.customerId ?? null,
|
||||
companyName: dto.companyName ?? null,
|
||||
returnDate,
|
||||
containerSize: dto.containerSize ?? null,
|
||||
facility: dto.facility ?? null,
|
||||
@@ -195,10 +290,72 @@ export class ImportOperationsService {
|
||||
// Standalone returns (no booking) have no company to notify.
|
||||
if (saved.bookingId) {
|
||||
await this.notifyEquipmentInterchangeReady(saved);
|
||||
// Closes the customer's scheduled request once its last container is in.
|
||||
await this.emptyReturnRequests.settleScheduledForBooking(saved.bookingId);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk backfill of empties already sitting in a yard but never recorded.
|
||||
* All-or-nothing: if any container number already has an open (not COMPLETED)
|
||||
* return, nothing is written — re-uploading the same sheet must not duplicate
|
||||
* boxes. No interchange notification is sent; these are historical rows, not
|
||||
* a live handover.
|
||||
*/
|
||||
async bulkCreateEmptyReturns(dto: BulkCreateEmptyContainerReturnsDto) {
|
||||
const numbers = dto.returns.map((r) => r.containerNumber.trim().toUpperCase());
|
||||
|
||||
const seen = new Set<string>();
|
||||
const dupInFile = numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false)));
|
||||
if (dupInFile.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Container number(s) repeated in the upload: ${[...new Set(dupInFile)].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.emptyReturns.find({
|
||||
where: {
|
||||
containerNumber: In(numbers),
|
||||
status: Not('COMPLETED' as EmptyContainerReturnStatus),
|
||||
},
|
||||
select: { containerNumber: true },
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Already recorded as returned: ${existing.map((r) => r.containerNumber).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rows = dto.returns.map((r, i) => {
|
||||
const returnDate = r.returnDate ? new Date(r.returnDate) : new Date();
|
||||
return this.emptyReturns.create({
|
||||
containerNumber: numbers[i],
|
||||
bookingId: r.bookingId ?? null,
|
||||
customerId: r.customerId ?? null,
|
||||
companyName: r.companyName ?? null,
|
||||
returnDate,
|
||||
containerSize: r.containerSize ?? null,
|
||||
facility: r.facility ?? null,
|
||||
yard: r.yard ?? null,
|
||||
zone: r.zone ?? null,
|
||||
condition: r.condition ?? null,
|
||||
handoverNote: r.handoverNote ?? null,
|
||||
performedBy: r.performedBy ?? null,
|
||||
returnedBy: r.returnedBy ?? null,
|
||||
statusHistory: [
|
||||
{
|
||||
status: 'RETURNED' as const,
|
||||
changedAt: returnDate.toISOString(),
|
||||
performedBy: r.performedBy ?? null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return this.emptyReturns.save(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load returned empties onto an export departure. A wagon takes ONE 40ft or
|
||||
* TWO 20ft — never a mix, never three. Empties already sitting on a wagon of
|
||||
|
||||
@@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady(
|
||||
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** One container line on the load manifest notice. */
|
||||
interface LoadManifestLists {
|
||||
reference: string;
|
||||
companyId: string | null;
|
||||
trainNumber: string | null;
|
||||
originStation: string | null;
|
||||
destinationStation: string | null;
|
||||
departureAt: Date | null;
|
||||
loaded: string[];
|
||||
leftBehind: string[];
|
||||
}
|
||||
|
||||
/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */
|
||||
function summarizeNumbers(numbers: string[], max = 5): string {
|
||||
if (numbers.length === 0) return 'none';
|
||||
const shown = numbers.slice(0, max).join(', ');
|
||||
const rest = numbers.length - max;
|
||||
return rest > 0 ? `${shown} +${rest} more` : shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read what actually went on the train and what did not. Left behind = every
|
||||
* container the customer declared minus the ones sitting on a LOADED/DEPARTED
|
||||
* wagon, so a booking loaded in parts reports honestly on both halves.
|
||||
*/
|
||||
export async function loadManifestLists(
|
||||
dataSource: DataSource,
|
||||
bookingId: string,
|
||||
trainScheduleId: string,
|
||||
): Promise<LoadManifestLists | null> {
|
||||
const [booking]: Array<{ reference: string; companyId: string | null }> =
|
||||
await dataSource.query(
|
||||
`SELECT reference, company_id AS "companyId"
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking) return null;
|
||||
|
||||
const [train]: Array<{
|
||||
trainNumber: string | null;
|
||||
originStation: string | null;
|
||||
destinationStation: string | null;
|
||||
departureAt: Date | null;
|
||||
}> = await dataSource.query(
|
||||
`SELECT s.train_number AS "trainNumber",
|
||||
so.label AS "originStation",
|
||||
sd.label AS "destinationStation",
|
||||
s.scheduled_departure_date AS "departureAt"
|
||||
FROM freight.train_schedules s
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
WHERE s.id = $1 AND s.deleted_at IS NULL`,
|
||||
[trainScheduleId],
|
||||
);
|
||||
|
||||
const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query(
|
||||
`SELECT DISTINCT ci.container_number AS "containerNumber"
|
||||
FROM freight.wagon_allocation_container_items ci
|
||||
JOIN freight.wagon_booking_allocations a
|
||||
ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1
|
||||
AND ci.deleted_at IS NULL
|
||||
AND a.status IN ('LOADED', 'DEPARTED')
|
||||
ORDER BY 1`,
|
||||
[bookingId],
|
||||
);
|
||||
const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query(
|
||||
`SELECT DISTINCT u.container_number AS "containerNumber"
|
||||
FROM freight.booking_container_units u
|
||||
JOIN freight.booking_container l
|
||||
ON l.id = u.booking_container_id AND l.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1 AND u.deleted_at IS NULL
|
||||
ORDER BY 1`,
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[];
|
||||
const loadedSet = new Set(loaded);
|
||||
const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter(
|
||||
(n) => !loadedSet.has(n),
|
||||
);
|
||||
|
||||
return {
|
||||
reference: booking.reference,
|
||||
companyId: booking.companyId,
|
||||
trainNumber: train?.trainNumber ?? null,
|
||||
originStation: train?.originStation ?? null,
|
||||
destinationStation: train?.destinationStation ?? null,
|
||||
departureAt: train?.departureAt ?? null,
|
||||
loaded,
|
||||
leftBehind,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer what boarded the train and what did not, over in-app + SMS
|
||||
* + email, and raise a warehouse-desk notice for anything left behind so
|
||||
* somebody owns finding it space. A booking is routinely loaded in parts, and
|
||||
* before this the customer learnt about it only by reading the sheet.
|
||||
*
|
||||
* Best-effort throughout: loading must never roll back because a provider is
|
||||
* down.
|
||||
*/
|
||||
export async function notifyLoadManifest(
|
||||
dataSource: DataSource,
|
||||
notifications: NotificationsService,
|
||||
inbox: NotificationInboxService,
|
||||
bookingId: string,
|
||||
trainScheduleId: string,
|
||||
warehouseNotificationPermission: string,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const m = await loadManifestLists(dataSource, bookingId, trainScheduleId);
|
||||
if (!m) return;
|
||||
|
||||
const route =
|
||||
m.originStation && m.destinationStation
|
||||
? ` ${m.originStation} → ${m.destinationStation}`
|
||||
: '';
|
||||
const departs = m.departureAt
|
||||
? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}`
|
||||
: '';
|
||||
const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train';
|
||||
|
||||
const headline =
|
||||
`Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` +
|
||||
`${route}${departs}.`;
|
||||
const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : '';
|
||||
const leftLine =
|
||||
m.leftBehind.length > 0
|
||||
? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` +
|
||||
' These stay with EDR — once a warehouse is assigned you will receive the GRN.'
|
||||
: '';
|
||||
const body = headline + loadedLine + leftLine;
|
||||
|
||||
if (m.companyId) {
|
||||
await inbox.notify({
|
||||
recipients: { companyId: m.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded',
|
||||
// The in-app copy carries every number; SMS and email get the summary.
|
||||
body:
|
||||
headline +
|
||||
(m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') +
|
||||
(m.leftBehind.length > 0
|
||||
? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.`
|
||||
: ''),
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: {
|
||||
bookingId,
|
||||
reference: m.reference,
|
||||
trainNumber: m.trainNumber,
|
||||
loaded: m.loaded,
|
||||
leftBehind: m.leftBehind,
|
||||
},
|
||||
});
|
||||
await sendCompanyChannels(dataSource, notifications, m.companyId, body);
|
||||
}
|
||||
|
||||
// Nothing left behind is nothing for the warehouse desk to place.
|
||||
if (m.leftBehind.length > 0) {
|
||||
await inbox.notify({
|
||||
recipients: { permissionKeys: [warehouseNotificationPermission] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`,
|
||||
body:
|
||||
`${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` +
|
||||
`${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`,
|
||||
link: `/dashboard/booking-requests/${bookingId}`,
|
||||
data: { bookingId, reference: m.reference, leftBehind: m.leftBehind },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ import {
|
||||
directionScopeSql,
|
||||
} from "../user-trade-access/trade-scope.util";
|
||||
|
||||
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
|
||||
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
|
||||
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
|
||||
|
||||
export type OverviewBookingKpisRow = {
|
||||
total: number;
|
||||
totalActive: number;
|
||||
@@ -147,7 +143,6 @@ export class OverviewRepository {
|
||||
"submittedToday",
|
||||
)
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.setParameters({
|
||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||
@@ -337,7 +332,6 @@ export class OverviewRepository {
|
||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("booking.created_at::date")
|
||||
@@ -357,7 +351,6 @@ export class OverviewRepository {
|
||||
.select("booking.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.status")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
@@ -427,7 +420,6 @@ export class OverviewRepository {
|
||||
.addSelect("booking.payment_currency", "paymentCurrency")
|
||||
.addSelect("booking.created_at", "createdAt")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.orderBy("booking.created_at", "DESC")
|
||||
.limit(limit)
|
||||
@@ -463,7 +455,6 @@ export class OverviewRepository {
|
||||
.select("booking.freight_type", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.freight_type")
|
||||
@@ -485,7 +476,6 @@ export class OverviewRepository {
|
||||
.select("booking.payment_currency", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.payment_currency")
|
||||
@@ -602,7 +592,6 @@ export class OverviewRepository {
|
||||
this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(bookingScope.sql, bookingScope.params)
|
||||
.andWhere(windowSql("booking.created_at"), { days, offsetDays })
|
||||
.getCount(),
|
||||
@@ -802,7 +791,6 @@ export class OverviewRepository {
|
||||
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
|
||||
@@ -1457,7 +1445,6 @@ export class OverviewRepository {
|
||||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||||
GROUP BY 1
|
||||
ORDER BY count DESC
|
||||
@@ -1475,7 +1462,6 @@ export class OverviewRepository {
|
||||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
|
||||
@@ -2,8 +2,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import { CURRENCY_FILTER, PAYER_EXPR, currencyOf } from '../revenue-classification';
|
||||
|
||||
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
|
||||
|
||||
@@ -13,13 +15,22 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
// to now() in SQL when the filter is unset (see the COALESCE below).
|
||||
const asOf = (params.asOf as string | null) ?? null;
|
||||
|
||||
// Both payer joins are LEFT: an invoice billed to a shipping line carries no
|
||||
// company, and an INNER join on `companies` silently drops its balance out of
|
||||
// the arrears total.
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Invoice, 'i')
|
||||
.innerJoin(Company, 'c', 'c.id = i.company_id')
|
||||
.leftJoin(Company, 'c', 'c.id = i.company_id')
|
||||
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id')
|
||||
.where('i.deleted_at IS NULL')
|
||||
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
|
||||
.andWhere('i.balance_amount > 0')
|
||||
// Stored casing has drifted ("usd" rows exist), and one arrears figure
|
||||
// cannot span two currencies.
|
||||
.andWhere('UPPER(i.currency) = :currency', {
|
||||
currency: currencyOf(params).toUpperCase(),
|
||||
})
|
||||
.setParameter('asOf', asOf);
|
||||
|
||||
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
||||
@@ -32,9 +43,15 @@ export const agingReceivablesReport: ReportDefinition = {
|
||||
title: 'Aging Receivables',
|
||||
description: 'Outstanding customer balances bucketed by days overdue',
|
||||
group: 'Finance',
|
||||
filters: [{ key: 'asOf', label: 'As of', type: 'date' }],
|
||||
filters: [{ key: 'asOf', label: 'As of', type: 'date' }, CURRENCY_FILTER],
|
||||
columns: [
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{
|
||||
key: 'customer',
|
||||
label: 'Customer',
|
||||
type: 'string',
|
||||
sortable: true,
|
||||
sortExpr: PAYER_EXPR,
|
||||
},
|
||||
{ key: 'invoices', label: 'Invoices', type: 'number' },
|
||||
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
|
||||
{ key: 'current', label: 'Current', type: 'money' },
|
||||
@@ -46,7 +63,7 @@ export const agingReceivablesReport: ReportDefinition = {
|
||||
defaultSort: { key: 'outstanding', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('c.name', 'customer')
|
||||
.select(PAYER_EXPR, 'customer')
|
||||
.addSelect('COUNT(*)::int', 'invoices')
|
||||
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
|
||||
.addSelect(
|
||||
@@ -72,15 +89,19 @@ export const agingReceivablesReport: ReportDefinition = {
|
||||
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
|
||||
'overdue90plus',
|
||||
)
|
||||
.groupBy('c.name');
|
||||
.groupBy(PAYER_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
|
||||
.addSelect('COUNT(DISTINCT c.id)::int', 'customers')
|
||||
.addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' },
|
||||
{
|
||||
label: 'Outstanding',
|
||||
value: Number(row?.outstanding ?? 0),
|
||||
unit: currencyOf(ctx.params),
|
||||
},
|
||||
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) },
|
||||
];
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BookingStatus } from '@edr/types';
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
@@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types';
|
||||
// One resolver behind "Booking per status, per port/train/date/cargo/contract
|
||||
// type" — the same breakdown Operation, Marketing, Global Logistics and the
|
||||
// Operation Report each ask for verbatim. Embed once, reuse everywhere.
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const TONS = bookingTonsSql('b');
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
|
||||
const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user