Merge branch 'dev' into freight/nati-2

Conflict in ClearanceDocumentsPage: this branch migrated the page to the
pill FilterBar, dev added filters to the Select stack it replaced. Kept
the FilterBar and carried dev's additions across as a "Booked by"
(customerKind) FilterDef plus the shipping-line search placeholder; dev's
startOfDayIso/endOfDayIso went away because dateRangeParams already does
that. The Ship icon import is needed by dev's shipping-line customer cell,
which merged cleanly on its own.
This commit is contained in:
Nathnael
2026-08-17 12:43:35 +00:00
117 changed files with 6115 additions and 830 deletions

View File

@@ -0,0 +1,9 @@
import pg from 'pg';
import fs from 'fs';
const env = Object.fromEntries(fs.readFileSync('.env','utf8').split('\n').filter(l=>/^[A-Z_]+=/.test(l)).map(l=>{const i=l.indexOf('=');return [l.slice(0,i),l.slice(i+1).replace(/^"|"$/g,'')]}));
const c = new pg.Client({host:env.DB_HOST,port:+env.DB_PORT,database:env.DB_NAME,user:env.DB_USER,password:env.DB_PASSWORD});
await c.connect();
const sql = process.argv[2];
const r = await c.query(sql);
console.log(JSON.stringify(r.rows,null,1));
await c.end();

View File

@@ -0,0 +1,72 @@
import eimsConfigFactory from "./eims.config";
const REQUIRED = {
EIMS_ENABLED: "true",
EIMS_CLIENT_ID: "cid",
EIMS_CLIENT_SECRET: "secret",
EIMS_API_KEY: "apikey",
EIMS_TIN: "0000000000",
};
const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
const prior: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(vars)) {
prior[key] = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
fn();
} finally {
for (const [key, value] of Object.entries(prior)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
};
describe("eims.config — private key / certificate resolution", () => {
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
},
);
});
it("leaves a PEM with real newlines untouched", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
},
);
});
it("throws naming all three key/cert options when none are set", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY_PATH: undefined,
EIMS_PRIVATE_KEY_BASE64: undefined,
EIMS_PRIVATE_KEY: undefined,
EIMS_CERTIFICATE_PATH: "/dev/null",
},
() => {
expect(() => eimsConfigFactory()).toThrow(
/EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/,
);
},
);
});
it("is satisfied by any single one of the three key options", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
expect(() => eimsConfigFactory()).not.toThrow();
},
);
});
});

View File

@@ -30,6 +30,23 @@ export interface EimsConfig {
privateKeyPath: string; privateKeyPath: string;
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
certificatePath: string; certificatePath: string;
/**
* Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a
* container that can't be given a host bind mount can still receive it as a plain env var.
* Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64`
* > `privateKeyPath`.
*/
privateKeyBase64: string;
/** Inline alternative to `certificatePath`, same precedence rule as the key. */
certificateBase64: string;
/**
* The PEM key pasted directly into the env var, no encoding step at all — the most direct of the
* three inline forms, and the hardest for a broken transport step to mangle since there's no
* decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set.
*/
privateKeyPem: string;
/** Inline alternative to `certificateBase64`, same precedence rule. */
certificatePem: string;
httpTimeoutMs: number; httpTimeoutMs: number;
/** Re-authenticate this many ms before the access token actually expires. */ /** Re-authenticate this many ms before the access token actually expires. */
tokenSkewMs: number; tokenSkewMs: number;
@@ -80,7 +97,19 @@ export interface EimsInvoiceConfig {
paymentMode: string; paymentMode: string;
paymentTerm: string; paymentTerm: string;
unitDefault: string; unitDefault: string;
/**
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
*/
buyerCountryCode: string | null; buyerCountryCode: string | null;
/**
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
* this is not validated against a fixed digit pattern, only looked up by name.
*/
buyerCountryCodes: Record<string, string>;
/** /**
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
@@ -89,6 +118,14 @@ export interface EimsInvoiceConfig {
buyerRegionCodes: Record<string, string>; buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>; buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
* mapping.
*/
buyerCityCodes: Record<string, string>;
/** /**
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
@@ -115,14 +152,14 @@ export interface EimsInvoiceConfig {
buyerIdNumber: string | null; buyerIdNumber: string | null;
} }
const REQUIRED_VARS = [ const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
"EIMS_CLIENT_ID",
"EIMS_CLIENT_SECRET", // Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
"EIMS_API_KEY", // from REQUIRED_VARS since it's "at least one of", not "this exact var".
"EIMS_TIN", const REQUIRED_ANY_OF: string[][] = [
"EIMS_PRIVATE_KEY_PATH", ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
"EIMS_CERTIFICATE_PATH", ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
] as const; ];
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
if (raw === undefined || raw === "") return fallback; if (raw === undefined || raw === "") return fallback;
@@ -143,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record<string, string> => {
return map; return map;
}; };
// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline
// and expect the caller to write "\n" as two characters instead. If the raw value already has a
// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses.
const normalizePem = (raw: string | undefined): string => {
if (!raw) return "";
return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n");
};
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ /** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
const optionalNumber = (raw: string | undefined, name: string): number | null => { const optionalNumber = (raw: string | undefined, name: string): number | null => {
if (raw === undefined || raw === "") return null; if (raw === undefined || raw === "") return null;
@@ -169,6 +214,10 @@ export default registerAs("eims", (): EimsConfig => {
systemType: process.env.EIMS_SYSTEM_TYPE ?? "", systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "",
privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY),
certificatePem: normalizePem(process.env.EIMS_CERTIFICATE),
certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
httpTimeoutMs, httpTimeoutMs,
tokenSkewMs, tokenSkewMs,
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
@@ -208,8 +257,10 @@ export default registerAs("eims", (): EimsConfig => {
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES),
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
@@ -223,7 +274,10 @@ export default registerAs("eims", (): EimsConfig => {
if (!enabled) return base; if (!enabled) return base;
const missing = REQUIRED_VARS.filter((name) => !process.env[name]); const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]);
for (const vars of REQUIRED_ANY_OF) {
if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or "));
}
if (missing.length > 0) { if (missing.length > 0) {
throw new Error( throw new Error(
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Debit/credit note filing — confirmed directly by MoR support: same `/v1/register` endpoint,
* distinguished by `DocumentDetails.Type` ("DEB"/"CRE") + a `Reason`, linked to the original
* invoice via `ReferenceDetails.RelatedDocument`. See `Invoice.eimsDocumentType`.
*/
export class EimsDebitCreditNotes3550000000000 implements MigrationInterface {
name = "EimsDebitCreditNotes3550000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_document_type varchar(8) NOT NULL DEFAULT 'INV',
ADD COLUMN IF NOT EXISTS eims_reason text,
ADD COLUMN IF NOT EXISTS related_invoice_id uuid REFERENCES freight.invoices(id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_document_type,
DROP COLUMN IF EXISTS eims_reason,
DROP COLUMN IF EXISTS related_invoice_id
`);
}
}

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity"; import { ExternalProfile } from "../companies/entities/external-profile.entity";
@@ -11,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto";
import { import {
ForgotPasswordService, ForgotPasswordService,
RESET_LINK_TTL_MS, RESET_LINK_TTL_MS,
type ResetTicket,
} from "./forgot-password.service"; } from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util"; import { maskOtpTarget } from "./mask-target.util";
import { isDomesticPhone } from "../otp/otp.service"; import { isDomesticPhone, type OtpTarget } from "../otp/otp.service";
/** The account a staff-triggered reset would land on. */ /** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget { export interface CustomerResetTarget {
@@ -116,6 +116,22 @@ export class CustomerResetService {
channel: ResetChannel, channel: ResetChannel,
options?: { scope?: string; allowWithoutCredential?: boolean }, options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink | null> { ): Promise<SentResetLink | null> {
const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options);
return sent[0] ?? null;
}
/**
* One ticket, several channels. Minting retires every earlier ticket for the
* user (`mintResetTicket`), so sending email and SMS as two separate mints
* makes the first link dead on arrival — the same link must go to both.
* Returns one entry per channel that was actually sent (unreachable channels
* are skipped, not errors).
*/
async sendResetLinkToUserOnChannels(
userId: string,
channels: ResetChannel[],
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink[]> {
const user = options?.allowWithoutCredential const user = options?.allowWithoutCredential
? await this.forgotPasswordService.resolveActivatableUserById(userId) ? await this.forgotPasswordService.resolveActivatableUserById(userId)
: await this.forgotPasswordService.resolveActiveUserById(userId); : await this.forgotPasswordService.resolveActiveUserById(userId);
@@ -128,51 +144,58 @@ export class CustomerResetService {
: " (or has no active credential — pass allowWithoutCredential for first-time activation)" : " (or has no active credential — pass allowWithoutCredential for first-time activation)"
}`, }`,
); );
return null; return [];
} }
return this.deliverResetLink(user, user.id, channel, options?.scope); // Mint once, before any send: a failed send leaves an unused ticket that
// simply expires, whereas sending a link before the ticket exists would
// hand the customer a URL that is dead on arrival.
let ticket: ResetTicket | null = null;
const sent: SentResetLink[] = [];
for (const channel of channels) {
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) continue;
// The gateway silently drops foreign numbers — treat like a missing phone
// rather than reporting "link sent" for a message that will never arrive.
// The backoffice disables the channel up front via `phoneIsDomestic`; this
// guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
continue;
}
ticket ??= await this.forgotPasswordService.mintResetTicket(
user.id,
RESET_LINK_TTL_MS,
);
const result = await this.deliverResetLink(
target,
user.id,
channel,
ticket,
options?.scope,
);
if (result) sent.push(result);
}
return sent;
} }
/** /**
* Shared tail: target selection → SMS reachability → mint → send → report. * Shared tail: send the already-minted ticket to a resolved target → report.
* Callers have already resolved `user` to an active account.
*/ */
private async deliverResetLink( private async deliverResetLink(
user: User, target: OtpTarget,
userId: string, userId: string,
channel: ResetChannel, channel: ResetChannel,
ticket: ResetTicket,
scope?: string, scope?: string,
): Promise<SentResetLink | null> { ): Promise<SentResetLink | null> {
this.logger.log(
`Staff-triggered shipping line ${"link"}`,
);
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
// A foreign number is unreachable by the domestic-only SMS gateway — treat // A foreign number is unreachable by the domestic-only SMS gateway — treat
// it like a missing phone rather than reporting "link sent" for a message // it like a missing phone rather than reporting "link sent" for a message
// that will never arrive. The backoffice disables the channel up front via
// `phoneIsDomestic`; this guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
return null;
}
// Mint first, send second: a failed send leaves an unused ticket that simply
// expires, whereas sending a link before the ticket exists would hand the
// customer a URL that is dead on arrival.
const ticket = await this.forgotPasswordService.mintResetTicket(
userId,
RESET_LINK_TTL_MS,
);
const link = this.buildResetLink(ticket.userId, ticket.verificationCode); const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
this.logger.log(
`Staff-triggered shipping line ${link}`,
);
const { queued } = target.email const { queued } = target.email
? await this.emailClient.sendEmail({ ? await this.emailClient.sendEmail({

View File

@@ -212,7 +212,9 @@ export class ForgotPasswordService {
* is the proof). * is the proof).
*/ */
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> { async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
const code = randomBytes(24).toString("base64url"); // Hex, not base64url: the token rides in an SMS, and the GSM-7 alphabet has
// no "_" — gateways substitute a space and the link arrives broken.
const code = randomBytes(24).toString("hex");
const verificationCode = await hashPassword(code); const verificationCode = await hashPassword(code);
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Get, Get,
@@ -29,6 +30,7 @@ import { actorLabel } from "../warehouses/current-actor.util";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service"; import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
import { IssueMemoDto } from "./dto/issue-memo.dto";
@ApiTags("billing") @ApiTags("billing")
@Controller("billing") @Controller("billing")
@@ -38,6 +40,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.invoices.export,
FREIGHT_PERMS.invoices.confirmOffline, FREIGHT_PERMS.invoices.confirmOffline,
FREIGHT_PERMS.invoices.memoIssue,
]) ])
@ApiBearerAuth() @ApiBearerAuth()
export class BillingController { export class BillingController {
@@ -63,6 +66,23 @@ export class BillingController {
}); });
} }
@Get("invoices/summary")
@ApiOperation({
summary:
"Total collected (paidAmount) across every filtered invoice, grouped by currency",
})
async collectedSummary(
@Query() query: FilterInvoiceDto,
@CurrentUser() user: TCurrentUser,
) {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.billingService.collectedSummary({
...query,
tradeDirections: allowed ?? undefined,
});
}
@Get("invoices/:id") @Get("invoices/:id")
@ApiOperation({ summary: "Get an invoice with its line items" }) @ApiOperation({ summary: "Get an invoice with its line items" })
findById(@Param("id", ParseUUIDPipe) id: string) { findById(@Param("id", ParseUUIDPipe) id: string) {
@@ -72,7 +92,7 @@ export class BillingController {
@Get("offline-usd") @Get("offline-usd")
@ApiOperation({ @ApiOperation({
summary: summary:
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context", "Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context",
}) })
findOfflineUsd(@Query() query: FilterInvoiceDto) { findOfflineUsd(@Query() query: FilterInvoiceDto) {
return this.billingService.findOfflineUsdPaginated(query); return this.billingService.findOfflineUsdPaginated(query);
@@ -84,7 +104,7 @@ export class BillingController {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: summary:
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance",
}) })
confirmOffline( confirmOffline(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -99,11 +119,31 @@ export class BillingController {
}); });
} }
@Post("invoices/:id/memo")
@BookingStaff(FREIGHT_PERMS.invoices.memoIssue)
@ApiOperation({
summary:
"Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.",
})
issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) {
return this.billingService.issueMemo(id, dto);
}
@Get("invoices/:id/document") @Get("invoices/:id/document")
@BookingStaff(FREIGHT_PERMS.invoices.export) @BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed invoice PDF" }) @ApiOperation({
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { summary:
const { filename, buffer } = await this.billingService.document(id); 'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).',
})
async document(
@Param("id", ParseUUIDPipe) id: string,
@Query("format") format: string | undefined,
@Res() res: Response,
) {
if (format !== undefined && format !== "a4" && format !== "thermal") {
throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`);
}
const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4");
sendPdf(res, filename, buffer); sendPdf(res, filename, buffer);
} }

View File

@@ -119,6 +119,159 @@ describe("BillingService.generateInvoice", () => {
}); });
}); });
describe("BillingService.issueMemo", () => {
const ORIGINAL_ID = "original-invoice-1";
function originalInvoice(overrides: Record<string, unknown> = {}) {
return {
id: ORIGINAL_ID,
invoiceNumber: "INV-20260807-00042",
eimsIrn: "irn-value",
eimsDocumentType: "INV",
eimsStatus: "REGISTERED",
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
companyId: "company-1",
companyProfileId: "profile-1",
shippingLineCompanyId: null,
currency: "ETB",
totalAmount: 1500,
lines: [
{ chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null },
{ chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null },
],
...overrides,
};
}
function build(original: ReturnType<typeof originalInvoice>) {
const savedLines: unknown[] = [];
const manager = makeManager(savedLines);
const dataSource = {
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
manager,
};
const invoices = { findById: jest.fn().mockResolvedValue(original) };
const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) };
const service = new BillingService(
dataSource as never,
invoices as never,
invoiceLines as never,
makeEvents() as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ get: () => undefined } as never,
);
return { service, manager, savedLines };
}
it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => {
const { service, savedLines } = build(originalInvoice());
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" });
expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/);
expect(memo.totalAmount).toBe(1500);
expect(memo.status).toBe(Freight.InvoiceStatus.Paid);
expect((memo as unknown as Record<string, unknown>).eimsDocumentType).toBe("CRE");
expect((memo as unknown as Record<string, unknown>).eimsReason).toBe("Overbilled freight charge");
expect((memo as unknown as Record<string, unknown>).relatedInvoiceId).toBe(ORIGINAL_ID);
expect((memo as unknown as Record<string, unknown>).paidAmount).toBe(1500);
expect((memo as unknown as Record<string, unknown>).balanceAmount).toBe(0);
expect(savedLines).toHaveLength(2);
});
it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => {
const { service } = build(originalInvoice());
const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" });
expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/);
expect(memo.status).toBe(Freight.InvoiceStatus.Pending);
expect(memo.balanceAmount).toBe(1500);
expect(memo.paidAmount).toBe(0);
});
it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => {
const { service } = build(originalInvoice());
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" });
expect(memo.sourceId).toBe(ORIGINAL_ID);
expect(memo.sourceId).not.toBe("booking-1");
});
it("allows a partial memo with explicit lines instead of copying the original", async () => {
const { service } = build(originalInvoice());
const memo = await service.issueMemo(ORIGINAL_ID, {
type: "CRE",
reason: "Partial credit",
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }],
});
expect(memo.totalAmount).toBe(200);
});
it("refuses a memo against an invoice never registered with EIMS", async () => {
const { service } = build(originalInvoice({ eimsIrn: null }));
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({
response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }),
});
});
it("refuses a memo against a memo", async () => {
const { service } = build(originalInvoice({ eimsDocumentType: "CRE" }));
await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow(
"cannot issue a memo against a memo",
);
});
it("refuses a memo against an EIMS-cancelled invoice", async () => {
const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" }));
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow(
"cancelled with EIMS",
);
});
it("refuses a credit memo whose total exceeds the original", async () => {
const { service } = build(originalInvoice({ totalAmount: 1500 }));
await expect(
service.issueMemo(ORIGINAL_ID, {
type: "CRE",
reason: "too much",
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }],
}),
).rejects.toThrow(/exceeds/);
});
it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => {
const { service } = build(originalInvoice({ totalAmount: 1500 }));
const memo = await service.issueMemo(ORIGINAL_ID, {
type: "DEB",
reason: "additional charge",
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }],
});
expect(memo.totalAmount).toBe(5000);
});
it("refuses a blank reason", async () => {
const { service } = build(originalInvoice());
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow(
"requires a reason",
);
});
});
describe("BillingService.markInvoiceAsPaid", () => { describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
const open = { const open = {
@@ -774,6 +927,7 @@ describe("BillingService.document", () => {
const build = (invoice: Record<string, unknown>) => { const build = (invoice: Record<string, unknown>) => {
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
const service = new BillingService( const service = new BillingService(
{} as never, {} as never,
{ findById: jest.fn().mockResolvedValue(invoice) } as never, { findById: jest.fn().mockResolvedValue(invoice) } as never,
@@ -781,7 +935,7 @@ describe("BillingService.document", () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{ render } as never, { render, renderThermal } as never,
{} as never, {} as never,
{ {
get: (key: string) => get: (key: string) =>
@@ -790,7 +944,7 @@ describe("BillingService.document", () => {
: undefined, : undefined,
} as never, // config } as never, // config
); );
return { service, render }; return { service, render, renderThermal };
}; };
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
@@ -847,4 +1001,24 @@ describe("BillingService.document", () => {
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
}); });
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);
await service.document("inv-1");
expect(render).toHaveBeenCalledTimes(1);
expect(renderThermal).not.toHaveBeenCalled();
});
it("calls renderThermal (not render) for format 'thermal'", async () => {
const { service, render, renderThermal } = build(invoiceRow());
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
await service.document("inv-1", "thermal");
expect(renderThermal).toHaveBeenCalledTimes(1);
expect(render).not.toHaveBeenCalled();
});
}); });

View File

@@ -10,14 +10,16 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter"; import { EventEmitter2 } from "@nestjs/event-emitter";
import { logCtx } from "@edr/api-common"; import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm"; import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line // Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table. // payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service"; import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
import { FilesService } from "../files/files.service"; import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service"; import { PaymentService } from "../payment/payment.service";
@@ -25,6 +27,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import { import {
InvoiceDocumentModel, InvoiceDocumentModel,
InvoiceDocumentService, InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service"; } from "./documents/invoice-document.service";
import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity";
@@ -46,10 +49,18 @@ export interface PayInvoiceOptions {
export interface OfflineUsdBookingInfo { export interface OfflineUsdBookingInfo {
id: string; id: string;
reference: string; reference: string;
tradeDirection: string | null;
paymentDeadline: Date | null; paymentDeadline: Date | null;
paymentStatus: string; paymentStatus: string;
} }
/** Row shape of the manual-payments worklist. */
export type OfflineUsdInvoiceRow = Invoice & {
booking: OfflineUsdBookingInfo | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
};
/** A single manual/offline settlement to record against an invoice. */ /** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput { export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */ /** Amount settled by this payment; must be > 0. */
@@ -145,6 +156,18 @@ export interface GenerateInvoiceInput {
status?: Freight.InvoiceStatus; status?: Freight.InvoiceStatus;
} }
/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */
export type MemoType = "CRE" | "DEB";
/** Everything needed to issue a credit or debit memo against an already-registered invoice. */
export interface IssueMemoInput {
type: MemoType;
/** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */
reason: string;
/** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */
lines?: InvoiceLineInput[];
}
/** Payload broadcast on `${source}.invoice.<event>`. */ /** Payload broadcast on `${source}.invoice.<event>`. */
export interface InvoiceEventPayload { export interface InvoiceEventPayload {
invoiceId: string; invoiceId: string;
@@ -192,6 +215,40 @@ export class BillingService {
* company (customer detail "Invoices" tab) and/or status/search (global * company (customer detail "Invoices" tab) and/or status/search (global
* invoices page). * invoices page).
*/ */
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
private applyInvoiceFilters(
qb: SelectQueryBuilder<Invoice>,
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
},
) {
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
return qb;
}
async findAllPaginated( async findAllPaginated(
filter: { filter: {
companyId?: string; companyId?: string;
@@ -215,50 +272,100 @@ export class BillingService {
.skip((page - 1) * pageSize) .skip((page - 1) * pageSize)
.take(pageSize); .take(pageSize);
if (filter.companyId) { this.applyInvoiceFilters(qb, filter);
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
const [items, total] = await qb.getManyAndCount(); const [items, total] = await qb.getManyAndCount();
return { items, total }; return { items: await this.attachShippingLineCompanies(items), total };
} }
/** /**
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer, * Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
* never through the gateway), open ones by default or a single status when * line (`companyId` null). No relation on `Invoice` to eager-load — see the
* filtered. Booking-sourced rows carry the booking's reference and pay-window * entity's doc comment — so this is a second query keyed off the ids
* deadline so the UI can show the countdown and link to the booking. * already loaded, same shape as `company`.
*/
private async attachShippingLineCompanies<T extends Invoice>(
invoices: T[],
): Promise<T[]> {
const ids = [
...new Set(
invoices
.map((i) => i.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return invoices;
const lines = await this.dataSource
.getRepository(ShippingLineCompany)
.find({ where: { id: In(ids) } });
const byId = new Map(lines.map((l) => [l.id, l]));
return invoices.map((invoice) => {
const line = invoice.shippingLineCompanyId
? byId.get(invoice.shippingLineCompanyId)
: undefined;
return line
? ({
...invoice,
shippingLineCompany: {
id: line.id,
name: line.name,
email: line.email,
phoneNumber: line.phoneNumber,
},
} as T)
: invoice;
});
}
/**
* Total collected (`paidAmount`) across every invoice matching the same
* filters as `findAllPaginated`, grouped by currency — unpaginated, so the
* invoices summary card reflects the whole filtered set, not just the
* visible page.
*/
async collectedSummary(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
} = {},
): Promise<Record<string, number>> {
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.select("invoice.currency", "currency")
.addSelect("SUM(invoice.paidAmount)", "collected")
.groupBy("invoice.currency");
this.applyInvoiceFilters(qb, filter);
const rows: { currency: string; collected: string }[] =
await qb.getRawMany();
return Object.fromEntries(
rows.map((row) => [row.currency, Number(row.collected) || 0]),
);
}
/**
* 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.
*/ */
async findOfflineUsdPaginated( async findOfflineUsdPaginated(
filter: { filter: {
status?: Freight.InvoiceStatus; status?: Freight.InvoiceStatus;
search?: string; search?: string;
currency?: "USD" | "ETB";
page?: number; page?: number;
pageSize?: number; pageSize?: number;
} = {}, } = {},
): Promise<{ ): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
total: number;
}> {
const page = filter.page && filter.page > 0 ? filter.page : 1; const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize = const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
@@ -267,11 +374,16 @@ export class BillingService {
.getRepository(Invoice) .getRepository(Invoice)
.createQueryBuilder("invoice") .createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company") .leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) = 'USD'") .where("UPPER(invoice.currency) IN ('USD', 'ETB')")
.orderBy("invoice.issuedAt", "DESC") .orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize) .skip((page - 1) * pageSize)
.take(pageSize); .take(pageSize);
if (filter.currency) {
qb.andWhere("UPPER(invoice.currency) = :currency", {
currency: filter.currency,
});
}
if (filter.status) { if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status }); qb.andWhere("invoice.status = :status", { status: filter.status });
} else { } else {
@@ -284,7 +396,8 @@ export class BillingService {
); );
} }
const [items, total] = await qb.getManyAndCount(); const [rawItems, total] = await qb.getManyAndCount();
const items = await this.attachShippingLineCompanies(rawItems);
const bookingIds = items const bookingIds = items
.filter((i) => i.source === "booking") .filter((i) => i.source === "booking")
@@ -292,11 +405,43 @@ export class BillingService {
const bookings = bookingIds.length const bookings = bookingIds.length
? await this.dataSource.getRepository(Booking).find({ ? await this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) }, where: { id: In(bookingIds) },
select: ["id", "reference", "paymentDeadline", "paymentStatus"], select: [
"id",
"reference",
"tradeDirection",
"paymentDeadline",
"paymentStatus",
],
}) })
: []; : [];
const byId = new Map(bookings.map((b) => [b.id, b])); const byId = new Map(bookings.map((b) => [b.id, b]));
// Shipping-line credit invoices bill many bookings at once; each credit
// keeps its own booking link, so collect them per invoice.
const creditInvoiceIds = items
.filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit)
.map((i) => i.id);
const credits = creditInvoiceIds.length
? await this.dataSource.getRepository(ShippingLineCredit).find({
where: { invoiceId: In(creditInvoiceIds) },
relations: { booking: true },
})
: [];
const bookingsByInvoice = new Map<
string,
OfflineUsdInvoiceRow["bookings"]
>();
for (const c of credits) {
if (!c.invoiceId || !c.booking) continue;
const list = bookingsByInvoice.get(c.invoiceId) ?? [];
list.push({
id: c.booking.id,
reference: c.booking.reference,
tradeDirection: c.booking.tradeDirection ?? null,
});
bookingsByInvoice.set(c.invoiceId, list);
}
return { return {
items: items.map((inv) => { items: items.map((inv) => {
const b = byId.get(inv.sourceId); const b = byId.get(inv.sourceId);
@@ -306,19 +451,22 @@ export class BillingService {
? { ? {
id: b.id, id: b.id,
reference: b.reference, reference: b.reference,
tradeDirection: b.tradeDirection ?? null,
paymentDeadline: b.paymentDeadline ?? null, paymentDeadline: b.paymentDeadline ?? null,
paymentStatus: b.paymentStatus, paymentStatus: b.paymentStatus,
} }
: null, : null,
} as Invoice & { booking: OfflineUsdBookingInfo | null }; bookings: bookingsByInvoice.get(inv.id) ?? [],
} as OfflineUsdInvoiceRow;
}), }),
total, total,
}; };
} }
/** /**
* Finance confirms a USD invoice as paid by bank transfer: stores the slip * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
* against the invoice and settles the FULL outstanding balance through * or counter payment: stores the slip against the invoice and settles the
* FULL outstanding balance through
* {@link recordPayment}, which flips the invoice to PAID and (for bookings) * {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so * emits `booking.invoice.paid` — the same event an online payment fires, so
* the booking advances exactly as if it had been paid through the gateway. * the booking advances exactly as if it had been paid through the gateway.
@@ -337,11 +485,6 @@ export class BillingService {
): Promise<Invoice> { ): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId); const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.currency?.toUpperCase() !== "USD") {
throw new BadRequestException(
"Offline confirmation is only for USD invoices — this invoice is paid online.",
);
}
if (!file) { if (!file) {
throw new BadRequestException("The bank payment slip file is required."); throw new BadRequestException("The bank payment slip file is required.");
} }
@@ -388,21 +531,30 @@ export class BillingService {
relations: { company: true, companyProfile: true }, relations: { company: true, companyProfile: true },
}); });
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
const lines = await this.invoiceLines.findAll({ const lines = await this.invoiceLines.findAll({
where: { invoiceId: id }, where: { invoiceId: id },
order: { createdAt: "ASC" }, order: { createdAt: "ASC" },
}); });
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
} }
// ── Documents (central PDF) ────────────────────────────────────────────────── // ── Documents (central PDF) ──────────────────────────────────────────────────
/** Sealed PDF invoice for any source, rendered by the shared document service. */ /**
async document(id: string): Promise<{ filename: string; buffer: Buffer }> { * Sealed PDF invoice for any source, rendered by the shared document service. `format`
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
* boundary check, not a business rule.
*/
async document(
id: string,
format: "a4" | "thermal" = "a4",
): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id); const invoice = await this.findById(id);
return this.invoiceDocuments.render( const model = await this.toDocumentModel(invoice, "INVOICE");
await this.toDocumentModel(invoice, "INVOICE"), return format === "thermal"
); ? this.invoiceDocuments.renderThermal(model)
: this.invoiceDocuments.render(model);
} }
/** Sealed PDF receipt; available once any payment has been recorded. */ /** Sealed PDF receipt; available once any payment has been recorded. */
@@ -418,15 +570,6 @@ export class BillingService {
); );
} }
/**
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
*/
private renderEimsQr(signedQr: string): string {
return `data:image/png;base64,${signedQr}`;
}
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */ /** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
private async bookingSummaryRows( private async bookingSummaryRows(
invoice: Invoice, invoice: Invoice,
@@ -542,7 +685,7 @@ export class BillingService {
currency: l.currency, currency: l.currency,
})), })),
totals, totals,
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null, qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
}; };
} }
@@ -709,11 +852,16 @@ export class BillingService {
// ── Generation ─────────────────────────────────────────────────────────────── // ── Generation ───────────────────────────────────────────────────────────────
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ /**
private nextInvoiceNumber(mg: EntityManager): Promise<string> { * `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
* daily sequence (different prefix hashes to a different advisory lock, see
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
*/
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
return nextDailyInvoiceNumber(mg, { return nextDailyInvoiceNumber(mg, {
table: "freight.invoices", table: "freight.invoices",
code: "INV", code,
}); });
} }
@@ -736,9 +884,123 @@ export class BillingService {
return manager ? run(manager) : this.dataSource.transaction(run); return manager ? run(manager) : this.dataSource.transaction(run);
} }
/**
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
* unchanged: it has no side effects (no events, no notifications, no payment records — every
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
* is just an ordinary invoice with three extra columns set.
*
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
*
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
*/
async issueMemo(
originalId: string,
input: IssueMemoInput,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const reason = input.reason?.trim();
if (!reason) {
throw new BadRequestException("A memo requires a reason.");
}
const original = await this.findById(originalId);
if (!original.eimsIrn) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
});
}
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
throw new BadRequestException(
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
);
}
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
throw new BadRequestException(
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
);
}
const sourceLines = input.lines?.length ? input.lines : original.lines;
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
chargeType: l.chargeType,
description: l.description,
quantity: Number(l.quantity),
unitRate: Number(l.unitRate),
amount: Number(l.amount),
currency: l.currency,
metadata: l.metadata ?? null,
}));
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.");
}
// Only a credit note is bounded by the original — it can only give back what was charged. A
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
// assume the credit-note ceiling is correct for DEB).
if (input.type === "CRE" && total > Number(original.totalAmount)) {
throw new BadRequestException(
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
);
}
const code = input.type === "CRE" ? "CRE" : "DEB";
const settled = input.type === "CRE";
return this.dataSource.transaction(async (mg) => {
const memo = await this.createInvoice(
{
source: original.source as Freight.InvoiceSource,
sourceId: original.id,
type: input.type === "CRE" ? "credit_note" : "debit_note",
companyId: original.companyId,
companyProfileId: original.companyProfileId,
shippingLineCompanyId: original.shippingLineCompanyId,
lines,
currency: original.currency,
subtotalAmount: total,
taxAmount: 0,
totalAmount: total,
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
},
mg,
code,
);
const patch: Record<string, unknown> = {
eimsDocumentType: input.type,
eimsReason: reason,
relatedInvoiceId: original.id,
...(settled
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
: {}),
};
await mg.update(Invoice, memo.id, patch);
this.logger.log(
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
);
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
});
}
private async createInvoice( private async createInvoice(
input: GenerateInvoiceInput, input: GenerateInvoiceInput,
mg: EntityManager, mg: EntityManager,
code = "INV",
): Promise<Invoice & { lines: InvoiceLine[] }> { ): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB"; const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending; const status = input.status ?? Freight.InvoiceStatus.Pending;
@@ -786,7 +1048,7 @@ export class BillingService {
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
); );
const invoiceNumber = await this.nextInvoiceNumber(mg); const invoiceNumber = await this.nextInvoiceNumber(mg, code);
const invoice = await mg.save( const invoice = await mg.save(
mg.create(Invoice, { mg.create(Invoice, {

View File

@@ -44,3 +44,46 @@ describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
); );
}); });
}); });
describe("InvoiceDocumentService.buildThermalHtml", () => {
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
it("renders no seal markup at all — dropped for thermal, not shrunk", () => {
const html = service.buildThermalHtml(model());
expect(html).not.toContain('class="seal"');
expect(html).not.toContain("seal-image");
});
it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => {
const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
expect(html).toContain('class="qr"');
expect(html).toContain('src="data:image/png;base64,QR"');
expect(html).not.toContain("position: absolute");
});
it("wraps a long IRN summary value rather than truncating it", () => {
const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] }));
expect(html).toContain(irn);
expect(html).toContain("overflow-wrap: anywhere");
});
it("renders a line item as stacked description + qty x rate = amount, not a table row", () => {
const html = service.buildThermalHtml(
model({
lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }],
}),
);
expect(html).not.toContain("<table");
expect(html).not.toContain("<td");
expect(html).toContain("40ft container rail freight");
expect(html).toContain("12 x");
expect(html).toContain("2,948,207.4 Birr (ETB)");
});
it("uses fluid, full-width layout — no fixed-px A4 geometry", () => {
const html = service.buildThermalHtml(model());
expect(html).not.toContain("width: 330px");
expect(html).not.toContain("right: 160px");
});
});

View File

@@ -18,6 +18,36 @@ import {
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
/**
* MoR returns `signedQR`/`qr` as a base64 PNG already rendered server-side — confirmed against the
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), not a
* payload we encode ourselves. Wrap, don't encode. Shared by `Invoice.eimsSignedQr`
* (`BillingService`) and `EimsReceipt.qr` (`eims-receipt-document.mapper.ts`) — same convention,
* same gateway.
*/
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ──────────────────────────────────
// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already-
// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here.
function esc(value: unknown): string {
return String(value ?? "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function money(amount: unknown, currency: string): string {
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
}
function formatDate(value: unknown): string {
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
}
/** One billed line on the document (charge type / fee type agnostic). */ /** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine { export interface InvoiceDocumentLine {
description: string | null; description: string | null;
@@ -120,6 +150,117 @@ export class InvoiceDocumentService {
}; };
} }
/**
* 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within
* `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since
* thermal print mechanisms have a dead zone at the roll edge they can't reach either way.
*
* A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is
* absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`,
* `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width.
* No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS
* thermal receipt carries one, and thermal heads render rotated circles badly) and line items
* are stacked (description, then `qty x rate = amount` below it) rather than a table — a real
* multi-column table leaves ~10-14 chars for description at this width, truncating almost every
* line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`.
*/
async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> {
const logoImageUrl =
model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl();
// Seal deliberately dropped — never fetched, so no stampSettings call either.
const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null };
const html = this.buildThermalHtml(resolvedModel);
return {
filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, {
label: `${model.title} thermal invoice`,
thermal: true,
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
// printer output" — fail loudly instead; the caller has the A4 download to fall back to.
noFallback: true,
}),
};
}
buildThermalHtml(model: InvoiceDocumentModel): string {
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo");
const summaryRows = model.summary
.map(
(row) =>
`<div class="row"><span class="label">${esc(row.label)}</span><span class="value">${esc(row.value)}</span></div>`,
)
.join("");
const itemBlocks = model.lines
.map((item) => {
const currency = item.currency ?? model.currency;
return `<div class="item">
<div class="item-desc">${esc(item.description)}</div>
<div class="item-calc">${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = <strong>${esc(money(item.amount, currency))}</strong></div>
</div>`;
})
.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("");
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>`
: "";
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(heading)}</title>
<style>
body { font-family: Arial, sans-serif; font-size: 9px; color: #0f172a; margin: 0; }
.doc { width: 100%; box-sizing: border-box; }
.thermal-logo { display: block; max-height: 28px; max-width: 100%; object-fit: contain; margin: 0 auto 4px; }
.brand { text-align: center; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; }
.title { text-align: center; font-size: 13px; font-weight: 800; margin: 2px 0; }
.meta { text-align: center; font-size: 8px; color: #475569; margin-bottom: 4px; }
.rule { border-top: 1px dashed #334155; margin: 6px 0; }
.row { display: flex; justify-content: space-between; gap: 6px; font-family: monospace; font-size: 8.5px; padding: 1px 0; }
.row .label { color: #64748b; white-space: nowrap; }
.row .value { text-align: right; overflow-wrap: anywhere; }
.item { margin: 4px 0; }
.item-desc { font-size: 9px; overflow-wrap: anywhere; }
.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; }
.qr { text-align: center; margin: 8px 0; }
.qr img { width: 150px; height: 150px; }
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
.footer { text-align: center; font-size: 7px; color: #94a3b8; margin-top: 8px; }
</style>
</head>
<body>
<div class="doc">
${logoInner}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<div class="title">${esc(heading)}</div>
<div class="meta">${esc(model.documentNumber)} &middot; ${esc(formatDate(model.issuedAt))}</div>
<div class="rule"></div>
${summaryRows}
<div class="rule"></div>
${itemBlocks}
<div class="rule"></div>
${totalRows}
${qrMarkup}
<div class="footer">Thank you</div>
</div>
</body>
</html>`;
}
/** /**
* Vector-drawn styled invoice/receipt used when headless Chromium is * Vector-drawn styled invoice/receipt used when headless Chromium is
* unavailable. Mirrors the HTML layout closely enough to pass as the same * unavailable. Mirrors the HTML layout closely enough to pass as the same
@@ -241,18 +382,7 @@ export class InvoiceDocumentService {
} }
buildHtml(model: InvoiceDocumentModel): string { buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) => const date = formatDate;
String(value ?? "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const money = (amount: unknown, currency = model.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const showCategory = Boolean(model.categoryHeader); const showCategory = Boolean(model.categoryHeader);
const sealText = const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
@@ -283,7 +413,7 @@ export class InvoiceDocumentService {
const totalRows = model.totals const totalRows = model.totals
.map( .map(
(total) => (total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`, `<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
) )
.join(""); .join("");

View File

@@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = `
} }
</style>`; </style>`;
/**
* Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream
* ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll
* it physically can't reach, so the page itself must stay 80mm (matching the roll the printer
* driver expects) with the safe area carved out by margin, not by shrinking the page.
*/
const THERMAL_PAGE_WIDTH_MM = 80;
const THERMAL_MARGIN_MM = 4;
/** Extra length past the measured content, so the cut isn't flush against the last line. */
const THERMAL_FEED_MM = 6;
/** Guard against a runaway line-item list producing an absurd page. */
const THERMAL_MAX_HEIGHT_MM = 1500;
export interface PdfRenderOptions { export interface PdfRenderOptions {
/** Label used in logs to identify the document kind. */ /** Label used in logs to identify the document kind. */
label?: string; label?: string;
/** Landscape A4 instead of the default portrait — wide tables need it. */ /** Landscape A4 instead of the default portrait — wide tables need it. */
landscape?: boolean; landscape?: boolean;
/**
* Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is
* measured and the page height grows to fit it, rather than a fixed page with the format's
* `format: "A4"`.
*/
thermal?: boolean;
/**
* Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a
* generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer
* output" (it silently hands back a different document shape than what was asked for); the
* caller has an existing A4 download to point the user at instead. Ignored when `fallback` is
* also supplied — an explicit fallback always wins.
*/
noFallback?: boolean;
/** /**
* Degraded renderer used when Chromium is unavailable. Receives the * Degraded renderer used when Chromium is unavailable. Receives the
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
* header). When omitted, a generic single-page fallback is produced. * header). When omitted (and `noFallback` is not set), a generic single-page fallback is
* produced.
*/ */
fallback?: (preparedHtml: string) => Buffer; fallback?: (preparedHtml: string) => Buffer;
} }
@@ -54,17 +82,37 @@ export class PdfRenderService {
const browser = await puppeteer.default.launch(launchOptions); const browser = await puppeteer.default.launch(launchOptions);
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); const thermal = opts.thermal ?? false;
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
// Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight
// is defined as the LARGER of the content's height and the viewport's own height, so a
// receipt shorter than the viewport would otherwise report the viewport height back, not
// its true content height — a real page-length trailing blank space bug, not theoretical
// (confirmed by actually rendering one). A short viewport forces content to overflow it,
// so scrollHeight always reflects the content, never the viewport.
await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
await page.emulateMediaType("print"); await page.emulateMediaType("print");
await new Promise((resolve) => setTimeout(resolve, 250)); await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({ const pdf = thermal
format: "A4", ? await page.pdf({
landscape: opts.landscape ?? false, width: `${THERMAL_PAGE_WIDTH_MM}mm`,
printBackground: true, height: `${await this.thermalContentHeightMm(page)}mm`,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, printBackground: true,
}); margin: {
top: `${THERMAL_MARGIN_MM}mm`,
bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`,
left: `${THERMAL_MARGIN_MM}mm`,
right: `${THERMAL_MARGIN_MM}mm`,
},
})
: await page.pdf({
format: "A4",
landscape: opts.landscape ?? false,
printBackground: true,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
});
const buffer = Buffer.from(pdf); const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) { if (!this.isValidPdf(buffer)) {
@@ -79,6 +127,15 @@ export class PdfRenderService {
} }
} catch (error) { } catch (error) {
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
if (!opts.fallback && opts.noFallback) {
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
// printer output" — it silently hands back a different document than what was asked for.
// Fail loudly instead; the caller already has a working A4 download to fall back to.
throw new InternalServerErrorException(
`${label} could not be generated — thermal rendering requires Chromium. ` +
"Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.",
);
}
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
if (this.isValidPdf(fallback)) { if (this.isValidPdf(fallback)) {
this.logger.warn( this.logger.warn(
@@ -92,6 +149,20 @@ export class PdfRenderService {
} }
} }
/**
* Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered
* content's actual height and adds feed clearance, so the PDF page is exactly as long as the
* receipt, not a fixed A4-length page with blank space at the bottom.
*/
private async thermalContentHeightMm(page: import("puppeteer").Page): Promise<number> {
// String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document`
// isn't a known global to type-check against — the string is evaluated in the page's own
// browser context regardless, same as the closure form would be.
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100);
}
private injectPdfPrintStyles(html: string): string { private injectPdfPrintStyles(html: string): string {
if (html.includes("edr-pdf-print-fix")) return html; if (html.includes("edr-pdf-print-fix")) return html;
if (html.includes("</head>")) { if (html.includes("</head>")) {

View File

@@ -39,4 +39,11 @@ export class FilterInvoiceDto {
@IsOptional() @IsOptional()
@IsIn(Object.values(Freight.InvoiceStatus)) @IsIn(Object.values(Freight.InvoiceStatus))
status?: Freight.InvoiceStatus; status?: Freight.InvoiceStatus;
/** Manual-payments worklist only: restrict to one currency. */
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
@IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["USD", "ETB"])
currency?: "USD" | "ETB";
} }

View File

@@ -0,0 +1,71 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsIn,
IsNumber,
IsObject,
IsOptional,
IsString,
Length,
ValidateNested,
} from "class-validator";
/** One line on a memo; omit the whole `lines` array on the parent DTO to copy the original's. */
export class MemoLineDto {
@ApiProperty()
@IsString()
chargeType!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
quantity?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
unitRate?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
amount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional()
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}
/** `POST billing/invoices/:id/memo` body — see `BillingService.issueMemo`. */
export class IssueMemoDto {
@ApiProperty({ enum: ["CRE", "DEB"], description: "MoR DocumentDetails.Type for the memo." })
@IsIn(["CRE", "DEB"])
type!: "CRE" | "DEB";
@ApiProperty({ description: "Why the memo was issued — MoR DocumentDetails.Reason." })
@IsString()
@Length(1, 500)
reason!: string;
@ApiPropertyOptional({
type: [MemoLineDto],
description: "Omit to copy every line of the original invoice verbatim.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MemoLineDto)
lines?: MemoLineDto[];
}

View File

@@ -60,8 +60,11 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
unitDefault: "PCS", unitDefault: "PCS",
incomeWithholdValue: 0, incomeWithholdValue: 0,
transactionWithholdValue: 0, transactionWithholdValue: 0,
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
buyerCountryCodes: {},
buyerRegionCodes: { "Addis Ababa": "13" }, buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {}, buyerWeredaCodes: {},
buyerCityCodes: {},
...over, ...over,
}); });
@@ -92,6 +95,9 @@ describe("toEimsInvoice", () => {
expect(doc.BuyerDetails).toEqual({ expect(doc.BuyerDetails).toEqual({
City: null, City: null,
// company.country is "Ethiopia" (the domestic default) — resolves to context's flat
// buyerCountryCode fallback, not null, per resolveCountryCode.
Country: "231",
Email: "buyer@abc.et", Email: "buyer@abc.et",
HouseNumber: "NEW", HouseNumber: "NEW",
IdNumber: null, IdNumber: null,
@@ -100,7 +106,6 @@ describe("toEimsInvoice", () => {
LegalName: "ABC Trading PLC", LegalName: "ABC Trading PLC",
Phone: "0912345678", Phone: "0912345678",
Region: "13", Region: "13",
Country: null,
Zone: "SHA", Zone: "SHA",
Kebele: "03", Kebele: "03",
VatNumber: "123475885858", VatNumber: "123475885858",
@@ -212,6 +217,59 @@ describe("toEimsInvoice", () => {
expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/); expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/);
}); });
describe("debit/credit notes — confirmed by MoR support, same /v1/register endpoint", () => {
it("defaults DocumentDetails.Type to INV with no Reason field", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.DocumentDetails.Type).toBe("INV");
expect(doc.DocumentDetails).not.toHaveProperty("Reason");
});
it("files a credit note with Type, Reason and RelatedDocument", () => {
const doc = toEimsInvoice(
invoice(),
seller,
context({
documentType: "CRE",
reason: "Overbilled freight charge",
relatedDocument: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
}),
);
expect(doc.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" });
expect(doc.ReferenceDetails.RelatedDocument).toBe(
"9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
);
});
it("files a debit note the same way", () => {
const doc = toEimsInvoice(
invoice(),
seller,
context({ documentType: "DEB", reason: "Additional handling fee", relatedDocument: "IRN-1" }),
);
expect(doc.DocumentDetails).toMatchObject({ Type: "DEB", Reason: "Additional handling fee" });
});
it("throws when a credit/debit note has no reason", () => {
expect(() =>
toEimsInvoice(
invoice(),
seller,
context({ documentType: "CRE", reason: null, relatedDocument: "IRN-1" }),
),
).toThrow(/needs a reason/);
});
it("throws when a credit/debit note has no relatedDocument", () => {
expect(() =>
toEimsInvoice(
invoice(),
seller,
context({ documentType: "CRE", reason: "Overbilled", relatedDocument: null }),
),
).toThrow(/needs.*relatedDocument/);
});
});
it("throws when the lines do not sum to the invoice total", () => { it("throws when the lines do not sum to the invoice total", () => {
expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow( expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow(
/lines sum to 11000 but the invoice total is 9000/, /lines sum to 11000 but the invoice total is 9000/,
@@ -282,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => {
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
}); });
it("derives City from the buyer's zone via the city code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
seller,
context({ buyerCityCodes: { Kirkos: "101" } }),
);
expect(doc.BuyerDetails.City).toBe("101");
});
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
seller,
context({ buyerCityCodes: {} }),
);
expect(doc.BuyerDetails.City).toBeNull();
});
it("maps a buyer country name to its code via the country code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
seller,
context({ buyerCountryCodes: { Djibouti: "071" } }),
);
expect(doc.BuyerDetails.Country).toBe("071");
});
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
);
expect(doc.BuyerDetails.Country).toBe("231");
});
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
),
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" })); const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
expect(doc.ItemList[0].NatureOfSupplies).toBe("service"); expect(doc.ItemList[0].NatureOfSupplies).toBe("service");

View File

@@ -20,8 +20,15 @@ import { round2 } from "./invoice-settlement.util";
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ /** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
const EIMS_VERSION = "1"; const EIMS_VERSION = "1";
/** The only `DocumentDetails.Type` observed in the supplied material. */ /**
const EIMS_DOCUMENT_TYPE = "INV"; * `DocumentDetails.Type`. `"INV"` is the only value observed in the collection; `"DEB"`/`"CRE"`
* (debit/credit note) were confirmed directly by MoR support — same `/v1/register` endpoint, no
* separate API. MoR's answer, verbatim: "the same endpoint used for registration should be used
* ... within the Document Detail object, you should specify DEB for a debit note, CRE for a
* credit note... add a Reason attribute under document detail object".
*/
export const EIMS_DOCUMENT_TYPES = ["INV", "DEB", "CRE"] as const;
export type EimsDocumentType = (typeof EIMS_DOCUMENT_TYPES)[number];
export interface EimsBuyerDetails { export interface EimsBuyerDetails {
City: string | null; City: string | null;
@@ -60,7 +67,9 @@ export interface EimsDocumentDetails {
DocumentNumber: string; DocumentNumber: string;
/** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */ /** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */
Date: string; Date: string;
Type: string; Type: EimsDocumentType;
/** Only for DEB/CRE, per MoR support — why the debit/credit note was issued. Absent for INV. */
Reason?: string;
} }
export interface EimsInvoiceItem { export interface EimsInvoiceItem {
@@ -212,10 +221,26 @@ export interface EimsMapperContext {
unitDefault: string; unitDefault: string;
incomeWithholdValue: number; incomeWithholdValue: number;
transactionWithholdValue: number; transactionWithholdValue: number;
/** Null for an ordinary invoice; set only for a real related-document case. */ /**
* `DocumentDetails.Type`. Defaults to `"INV"`. For `"DEB"`/`"CRE"` both `reason` and
* `relatedDocument` become required — confirmed directly by MoR support, not the collection.
*/
documentType?: EimsDocumentType;
/** Required when `documentType` is `"DEB"`/`"CRE"` — why the note was issued. Unused for INV. */
reason?: string | null;
/**
* `ReferenceDetails.RelatedDocument`. Null for an ordinary invoice; required for a DEB/CRE —
* the original registered invoice's IRN, per MoR's own IRC-P06/P07 checklist ("credit memo
* from a registered invoice").
*/
relatedDocument?: string | null; relatedDocument?: string | null;
/** MoR numeric country code for the buyer; our DB stores the country name. */ /**
* Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already
* in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer.
*/
buyerCountryCode?: string | null; buyerCountryCode?: string | null;
/** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */
buyerCountryCodes: Record<string, string>;
/** /**
* Region name → MoR numeric code, for buyers whose stored region is free text. * Region name → MoR numeric code, for buyers whose stored region is free text.
* *
@@ -232,9 +257,15 @@ export interface EimsMapperContext {
* fail locally on an unmapped name rather than file a guess. * fail locally on an unmapped name rather than file a guess.
*/ */
buyerWeredaCodes: Record<string, string>; buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the
* closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already
* accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail
* the mapping.
*/
buyerCityCodes: Record<string, string>;
buyerIdType?: string | null; buyerIdType?: string | null;
buyerIdNumber?: string | null; buyerIdNumber?: string | null;
buyerCity?: string | null;
/** Required when the invoice currency is not ETB. */ /** Required when the invoice currency is not ETB. */
exchangeRate?: number | null; exchangeRate?: number | null;
invoiceDiscount?: number | null; invoiceDiscount?: number | null;
@@ -279,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string =>
* exchange rate. * exchange rate.
*/ */
/** /**
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric, * A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending * numeric, otherwise looked up by name (case- and space-insensitive).
* a guessed code onto a tax document is worse than refusing to file. *
* Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax
* document is worse than refusing to file. City is optional (`required: false`, City's own
* caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to
* null instead of blocking the invoice.
*/ */
function resolveLocationCode( function resolveLocationCode(
field: "Region" | "Wereda", field: "Region" | "Wereda" | "City",
value: string | null | undefined, value: string | null | undefined,
codes: Record<string, string>, codes: Record<string, string>,
envVar: string, envVar: string,
invoiceNumber: string, invoiceNumber: string,
): string { opts: { required?: boolean } = {},
): string | null {
const raw = (value ?? "").trim(); const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw; if (LOCATION_CODE.test(raw)) return raw;
@@ -299,12 +335,61 @@ function resolveLocationCode(
)?.[1]; )?.[1];
if (mapped && LOCATION_CODE.test(mapped)) return mapped; if (mapped && LOCATION_CODE.test(mapped)) return mapped;
if (opts.required === false) return null;
throw new Error( throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
); );
} }
/**
* A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies
* `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default).
* A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same
* "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's
* Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`.
*/
function resolveCountryCode(
country: string | null | undefined,
codes: Record<string, string>,
domesticFallback: string | null,
invoiceNumber: string,
): string | null {
const raw = (country ?? "").trim();
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped) return mapped;
if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` +
"MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.",
);
}
/**
* Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an
* error to and that must never throw — currently only `EimsSellerCacheService`, resolving
* e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code,
* name lookup, `undefined` on no match — the caller falls back to static config either way.
*/
export function resolveOptionalCode(
value: string | null | undefined,
codes: Record<string, string>,
): string | undefined {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined;
}
export function toEimsInvoice( export function toEimsInvoice(
invoice: EimsMapperInvoice, invoice: EimsMapperInvoice,
seller: EimsSellerDetails, seller: EimsSellerDetails,
@@ -326,6 +411,26 @@ export function toEimsInvoice(
); );
} }
const documentType = context.documentType ?? "INV";
if (!EIMS_DOCUMENT_TYPES.includes(documentType)) {
throw new Error(
`EIMS mapping: invoice ${invoice.invoiceNumber} has documentType "${documentType}", must be one of ${EIMS_DOCUMENT_TYPES.join(", ")}`,
);
}
if (documentType !== "INV") {
if (!context.reason?.trim()) {
throw new Error(
`EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs a reason`,
);
}
if (!context.relatedDocument?.trim()) {
throw new Error(
`EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs ` +
"relatedDocument — the original registered invoice's IRN",
);
}
}
const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt); const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt);
if (Number.isNaN(issuedAt.getTime())) { if (Number.isNaN(issuedAt.getTime())) {
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
@@ -400,7 +505,16 @@ export function toEimsInvoice(
return { return {
BuyerDetails: { BuyerDetails: {
City: context.buyerCity ?? null, // No dedicated city column on Company — Zone is the closest match; optional (see
// resolveLocationCode's City comment).
City: resolveLocationCode(
"City",
company.zone,
context.buyerCityCodes,
"EIMS_BUYER_CITY_CODES",
invoice.invoiceNumber,
{ required: false },
),
Email: company.email ?? null, Email: company.email ?? null,
HouseNumber: company.houseNo ?? null, HouseNumber: company.houseNo ?? null,
IdNumber: context.buyerIdNumber ?? null, IdNumber: context.buyerIdNumber ?? null,
@@ -415,7 +529,12 @@ export function toEimsInvoice(
"EIMS_BUYER_REGION_CODES", "EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber, invoice.invoiceNumber,
), ),
Country: context.buyerCountryCode ?? null, Country: resolveCountryCode(
company.country,
context.buyerCountryCodes,
context.buyerCountryCode ?? null,
invoice.invoiceNumber,
),
Zone: company.zone ?? null, Zone: company.zone ?? null,
Kebele: company.kebele ?? null, Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null, VatNumber: company.vatNumber ?? null,
@@ -430,7 +549,8 @@ export function toEimsInvoice(
DocumentDetails: { DocumentDetails: {
DocumentNumber: context.documentNumber, DocumentNumber: context.documentNumber,
Date: (context.formatDate ?? formatEimsDate)(issuedAt), Date: (context.formatDate ?? formatEimsDate)(issuedAt),
Type: EIMS_DOCUMENT_TYPE, Type: documentType,
...(documentType !== "INV" ? { Reason: context.reason! } : {}),
}, },
ItemList, ItemList,
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term }, PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },

View File

@@ -180,4 +180,27 @@ export class Invoice extends BaseEntity {
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true }) @Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
eimsCancellationRemark?: string | null; eimsCancellationRemark?: string | null;
/**
* `DocumentDetails.Type` to file this invoice as — "INV" (default), "DEB" or "CRE". Confirmed
* by MoR support directly (not the collection): debit/credit notes go through this same
* `/v1/register` endpoint, distinguished only by `Type` + `Reason`, linked via
* `ReferenceDetails.RelatedDocument` to the original invoice's IRN. This module does not create
* debit/credit note invoices — that is a freight-workflow decision — it only files one
* correctly once these columns are set on an existing row.
*/
@Column({ name: "eims_document_type", type: "varchar", length: 8, default: "INV" })
eimsDocumentType!: string;
/** Required by MoR when `eimsDocumentType` is DEB/CRE — why the note was issued. */
@Column({ name: "eims_reason", type: "text", nullable: true })
eimsReason?: string | null;
/** The original registered invoice this debit/credit note adjusts. Required for DEB/CRE. */
@Column({ name: "related_invoice_id", type: "uuid", nullable: true })
relatedInvoiceId?: string | null;
@ManyToOne(() => Invoice)
@JoinColumn({ name: "related_invoice_id" })
relatedInvoice?: Invoice | null;
} }

View File

@@ -0,0 +1,42 @@
import { BadRequestException } from '@nestjs/common';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
/**
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
* cut is allowed and takes the exact cargo total; over-cut is rejected; a
* partial cut stays proportional.
*/
describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
const svc = Object.create(BookingWagonCancellationService.prototype) as {
resolveRequestedCut(booking: unknown, dto: unknown): Promise<{
wagons: number;
weightTons: number;
quantities: { bulkTons?: number };
}>;
};
const booking = {
id: 'b1',
freightType: 'BULK',
wagonsRequired: 4,
cargoTotalWeightVgm: 250.5,
bulkTotalWeightTons: null,
};
it('cancels every wagon with the exact total tonnage', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
});
it('rejects more wagons than the booking has', async () => {
await expect(svc.resolveRequestedCut(booking, { wagons: 5 })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('sizes a partial cut proportionally', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 1 });
expect(cut.wagons).toBe(1);
expect(cut.weightTons).toBeCloseTo(62.625, 3);
});
});

View File

@@ -7,8 +7,9 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In } from 'typeorm'; import { DataSource, EntityManager, In, IsNull } from 'typeorm';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { ContractBookingService } from '../contracts/contract-booking.service'; import { ContractBookingService } from '../contracts/contract-booking.service';
@@ -18,9 +19,11 @@ import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.en
import { FirstMileService } from '../first-mile/first-mile.service'; import { FirstMileService } from '../first-mile/first-mile.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Rate } from '../rule-engine/entities/rate.entity'; import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
@@ -46,10 +49,15 @@ import {
/** /**
* rates.rate_type of the cancellation fee — an existing rate-engine type * rates.rate_type of the cancellation fee — an existing rate-engine type
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff * (trigger CANCELLATION, never auto-applied to booking pricing). Staff
* configure it in the normal rates UI; the wagon flow requires the PER_WAGON * configure it in the normal rates UI, one PER_WAGON rate per trade direction
* unit so the fee scales with the cancelled wagon count. * + cargo kind + type (20ft / 40ft container type, or bulk commodity), so the
* fee scales with the cancelled wagon count and differs by what was booked.
*/ */
export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE'; export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
/** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */
const sizeFtOf = (size: string | number | null | undefined): number =>
parseInt(String(size ?? ''), 10);
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */ /** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE'; export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
@@ -62,8 +70,20 @@ interface RequestedCut {
quantities: CancelledQuantities; quantities: CancelledQuantities;
} }
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
interface PricedFee {
amount: number;
currency: string;
/** Effective per-wagon fee (amount / wagons) — one number for the customer. */
perWagon: number;
/** Rate rows used; the first is recorded on the ledger row. */
rates: Rate[];
}
/** /**
* Partial wagon cancellation on a PAID booking, with a rebooking credit. * Wagon cancellation on a PAID booking (partial or whole), with a rebooking
* credit. Cutting every wagon ends the source booking CANCELLED at T2; the
* credit then rebooks as a fresh booking under the same contract.
* *
* Lifecycle (one ledger row per cycle, see BookingWagonCancellation): * Lifecycle (one ledger row per cycle, see BookingWagonCancellation):
* T1 request — validate + price the fee, open the fee invoice. Nothing else * T1 request — validate + price the fee, open the fee invoice. Nothing else
@@ -91,6 +111,7 @@ export class BookingWagonCancellationService {
private readonly repo: BookingWagonCancellationsRepository, private readonly repo: BookingWagonCancellationsRepository,
private readonly bookingsRepository: BookingsRepository, private readonly bookingsRepository: BookingsRepository,
private readonly billing: BillingService, private readonly billing: BillingService,
private readonly exchangeService: ExchangeService,
@Inject(forwardRef(() => ContractBookingService)) @Inject(forwardRef(() => ContractBookingService))
private readonly contractBooking: ContractBookingService, private readonly contractBooking: ContractBookingService,
@Inject(forwardRef(() => ClearanceMilestoneService)) @Inject(forwardRef(() => ClearanceMilestoneService))
@@ -120,14 +141,13 @@ export class BookingWagonCancellationService {
}> { }> {
const booking = await this.loadCancellableBooking(bookingId); const booking = await this.loadCancellableBooking(bookingId);
const cut = await this.resolveRequestedCut(booking, dto); const cut = await this.resolveRequestedCut(booking, dto);
const rate = await this.feeRate(); const fee = await this.priceFee(booking, cut);
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
return { return {
wagons: cut.wagons, wagons: cut.wagons,
weightTons: cut.weightTons, weightTons: cut.weightTons,
feePerWagon: Number(rate.rateValue), feePerWagon: fee.perWagon,
feeAmount, feeAmount: fee.amount,
feeCurrency: rate.currency, feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, cut.wagons), creditAmount: this.creditFor(booking, cut.wagons),
}; };
} }
@@ -146,8 +166,8 @@ export class BookingWagonCancellationService {
} }
const cut = await this.resolveRequestedCut(booking, dto); const cut = await this.resolveRequestedCut(booking, dto);
const rate = await this.feeRate(); const fee = await this.priceFee(booking, cut);
const feeAmount = round2(Number(rate.rateValue) * cut.wagons); const feeAmount = fee.amount;
const creditAmount = this.creditFor(booking, cut.wagons); const creditAmount = this.creditFor(booking, cut.wagons);
const row = await this.repo.create({ const row = await this.repo.create({
@@ -156,9 +176,11 @@ export class BookingWagonCancellationService {
weightTons: cut.weightTons, weightTons: cut.weightTons,
cancelledQuantities: cut.quantities, cancelledQuantities: cut.quantities,
creditAmount, creditAmount,
feeRateId: rate.id, // ponytail: one FK for a mixed-size container cut records the first
// size's rate; the invoice line carries the effective per-wagon fee.
feeRateId: fee.rates[0].id,
feeAmount, feeAmount,
feeCurrency: rate.currency, feeCurrency: fee.currency,
status: 'FEE_PENDING', status: 'FEE_PENDING',
reason: dto.reason ?? null, reason: dto.reason ?? null,
requestedByUserId: userId ?? null, requestedByUserId: userId ?? null,
@@ -173,15 +195,15 @@ export class BookingWagonCancellationService {
type: WAGON_CANCEL_FEE_INVOICE_TYPE, type: WAGON_CANCEL_FEE_INVOICE_TYPE,
companyId: booking.companyId, companyId: booking.companyId,
companyProfileId: booking.companyProfileId, companyProfileId: booking.companyProfileId,
currency: rate.currency, currency: fee.currency,
lines: [ lines: [
{ {
chargeType: 'CANCELLATION_FEE', chargeType: 'CANCELLATION_FEE',
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`, description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
quantity: cut.wagons, quantity: cut.wagons,
unitRate: Number(rate.rateValue), unitRate: fee.perWagon,
amount: feeAmount, amount: feeAmount,
currency: rate.currency, currency: fee.currency,
metadata: { wagonCancellationId: row.id }, metadata: { wagonCancellationId: row.id },
}, },
], ],
@@ -343,12 +365,28 @@ export class BookingWagonCancellationService {
const preSplitQuantities = const preSplitQuantities =
booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight)); booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight));
// Whole-booking cut: nothing is left to ship, so the booking ends
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
// train. The credit row still points at it for T3.
const wagonsLeft = round2(
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
);
const isFull = wagonsLeft <= 0;
await manager.getRepository(Booking).update(booking.id, { await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)), wagonsRequired: Math.max(0, wagonsLeft),
cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), cargoTotalWeightVgm: Math.max(
totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)), 0,
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
),
totalAmount: Math.max(
0,
round2(Number(booking.totalAmount) - Number(row.creditAmount)),
),
isSplit: true, isSplit: true,
preSplitQuantities, preSplitQuantities,
...(isFull
? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null }
: {}),
} as never); } as never);
await manager.getRepository(BookingWagonCancellation).update(row.id, { await manager.getRepository(BookingWagonCancellation).update(row.id, {
@@ -360,11 +398,15 @@ export class BookingWagonCancellationService {
}); });
const booking = await this.bookingsRepository.findById(row.bookingId); const booking = await this.bookingsRepository.findById(row.bookingId);
if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking);
if (booking) { if (booking) {
const whole = booking.status === 'CANCELLED';
this.notifyCustomer( this.notifyCustomer(
booking, booking,
'Wagon cancellation confirmed', whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
`${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, whole
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
); );
} }
this.logger.log( this.logger.log(
@@ -372,6 +414,33 @@ export class BookingWagonCancellationService {
); );
} }
/**
* Whole-booking cut: take the cancelled booking OFF its train entirely —
* schedule link, leftover wagon slots, window status — via the ops unassign
* path (no "removed from train" notice: the customer cancelled it). A stale
* link would keep showing the booking on the schedule AND poison every later
* auto wagon allocation on that train (the whole-train re-plan rejects a
* CANCELLED booking). Then re-run allocation so bookings held back by it
* (e.g. the rebooked credit) get their wagons.
*/
private async detachFromSchedule(booking: Booking): Promise<void> {
const links = await this.dataSource
.getRepository(TrainScheduleBooking)
.find({ where: { bookingId: booking.id } });
for (const link of links) {
try {
await this.trainScheduling.unassignBooking(link.trainScheduleId, booking.id, undefined, {
notifyCustomer: false,
});
await this.trainScheduling.tryAutoWagonAllocation(link.trainScheduleId);
} catch (err) {
this.logger.error(
`Detach of cancelled booking ${booking.reference} from schedule ${link.trainScheduleId} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// ── T3: rebook ────────────────────────────────────────────────────────────── // ── T3: rebook ──────────────────────────────────────────────────────────────
async rebook( async rebook(
@@ -401,6 +470,8 @@ export class BookingWagonCancellationService {
} }
const createDto = this.buildRebookDto(row, dto.scheduledDate); const createDto = this.buildRebookDto(row, dto.scheduledDate);
// Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
const created = await this.contractBooking.createUnderContract( const created = await this.contractBooking.createUnderContract(
source.contractId, source.contractId,
createDto, createDto,
@@ -413,9 +484,13 @@ export class BookingWagonCancellationService {
// The freight is already paid (credit) — mark PAID and let the existing // The freight is already paid (credit) — mark PAID and let the existing
// paid-booking machinery place it. No invoice is generated for it. // paid-booking machinery place it. No invoice is generated for it.
// Its price IS the credit (already paid, in the source currency) — not a
// fresh live-rate quote; a later cut of the rebooked booking credits from it.
await this.dataSource.getRepository(Booking).update(newBookingId, { await this.dataSource.getRepository(Booking).update(newBookingId, {
paymentStatus: 'PAID', paymentStatus: 'PAID',
status: 'PAID', status: 'PAID',
totalAmount: Number(row.creditAmount),
paymentCurrency: source.paymentCurrency,
}); });
await this.copyClearanceState(source, newBookingId); await this.copyClearanceState(source, newBookingId);
@@ -532,16 +607,16 @@ export class BookingWagonCancellationService {
const live = liveBySize.get(cut.containerSize) ?? 0; const live = liveBySize.get(cut.containerSize) ?? 0;
if (cut.quantity > live) { if (cut.quantity > live) {
throw new BadRequestException( throw new BadRequestException(
`Cannot cancel ${cut.quantity} × ${cut.containerSize}ft — the booking only has ${live}.`, `Cannot cancel ${cut.quantity} × ${sizeFtOf(cut.containerSize)}ft — the booking only has ${live}.`,
); );
} }
bySize[cut.containerSize] = cut.quantity; bySize[cut.containerSize] = cut.quantity;
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize)); wagons += cut.quantity * wagonsPerUnitForSize(sizeFtOf(cut.containerSize));
} }
wagons = round2(wagons); wagons = round2(wagons);
if (wagons >= totalWagons) { if (wagons > totalWagons) {
throw new BadRequestException( throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.', `Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
); );
} }
// Snapshot the LIFO-picked physical units up front (read-only — cargo is // Snapshot the LIFO-picked physical units up front (read-only — cargo is
@@ -577,9 +652,11 @@ export class BookingWagonCancellationService {
} }
} }
} }
const weightShare = round3( // Whole-booking cut takes the exact total, no ratio rounding.
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons), const weightShare =
); wagons >= totalWagons
? round3(Number(booking.cargoTotalWeightVgm))
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return { return {
wagons, wagons,
weightTons: weightShare, weightTons: weightShare,
@@ -594,17 +671,19 @@ export class BookingWagonCancellationService {
if (!wagons || wagons <= 0) { if (!wagons || wagons <= 0) {
throw new BadRequestException('Specify how many wagons to cancel.'); throw new BadRequestException('Specify how many wagons to cancel.');
} }
if (wagons >= totalWagons) { if (wagons > totalWagons) {
throw new BadRequestException( throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.', `Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
); );
} }
// Whole-booking cut: all cargo, exactly. Otherwise proportional sizing.
// ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item // ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item
// rounding happens here too; switch to items_per_wagon_map sizing if bulk // rounding happens here too; switch to items_per_wagon_map sizing if bulk
// PER_ITEM cancels ever need to be exact per item. // PER_ITEM cancels ever need to be exact per item.
let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons); const isFull = wagons >= totalWagons;
let tons = Number(booking.cargoTotalWeightVgm) * (isFull ? 1 : wagons / totalWagons);
const isPerItem = booking.bulkTotalWeightTons != null; const isPerItem = booking.bulkTotalWeightTons != null;
tons = isPerItem ? Math.floor(tons) : round3(tons); tons = isPerItem && !isFull ? Math.floor(tons) : round3(tons);
if (tons <= 0) { if (tons <= 0) {
throw new BadRequestException('The requested cut is too small to release cargo.'); throw new BadRequestException('The requested cut is too small to release cargo.');
} }
@@ -641,19 +720,23 @@ export class BookingWagonCancellationService {
} }
const wagons = allocations.length; const wagons = allocations.length;
if (wagons >= totalWagons) { if (wagons > totalWagons) {
throw new BadRequestException( throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.', `Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
); );
} }
const isFull = wagons >= totalWagons;
if (booking.freightType !== 'CONTAINER') { if (booking.freightType !== 'CONTAINER') {
const allocated = allocations.reduce( const allocated = allocations.reduce(
(s, a) => s + Number(a.allocatedWeightTons || 0), (s, a) => s + Number(a.allocatedWeightTons || 0),
0, 0,
); );
const tons = // Whole-booking cut takes the exact total; partial takes the wagons'
allocated > 0 // allocated tonnage (ratio fallback when nothing is allocated yet).
const tons = isFull
? round3(Number(booking.cargoTotalWeightVgm))
: allocated > 0
? round3(allocated) ? round3(allocated)
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons)); : round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return { return {
@@ -714,21 +797,83 @@ export class BookingWagonCancellationService {
return round2(Number(booking.totalAmount) * (wagons / totalWagons)); return round2(Number(booking.totalAmount) * (wagons / totalWagons));
} }
private async feeRate(): Promise<Rate> { /**
const rate = await this.dataSource.getRepository(Rate).findOne({ * Price the cut off the LIVE per-wagon cancellation rates for the booking's
* trade direction. Bulk bills the rate scoped to the booking's commodity ×
* cancelled wagons; a container cut bills each size at its own container
* type's rate × the wagons that size occupies (two 20ft share one). A
* booking owned by a shipping line prices off that line's rates only —
* standard rates are never a fallback, matching booking pricing.
*/
private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> {
const raw = await this.priceFeeInRateCurrency(booking, cut);
// Bill in the booking's own currency (rates are configured in USD; ETB
// bookings pay ETB) — same USD→ETB conversion booking pricing applies.
const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
const from = raw.currency === 'ETB' ? 'ETB' : 'USD';
if (from === target) return raw;
const fx = await this.exchangeService.getRate(from, target);
return {
...raw,
amount: round2(raw.amount * fx),
perWagon: round2(raw.perWagon * fx),
currency: target,
};
}
private async priceFeeInRateCurrency(
booking: Booking,
cut: RequestedCut,
): Promise<PricedFee> {
const rates = await this.dataSource.getRepository(Rate).find({
where: { where: {
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE, rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
rateUnit: 'PER_WAGON', rateUnit: 'PER_WAGON',
status: 'LIVE', status: 'LIVE',
tradeDirection: booking.tradeDirection,
shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(),
}, },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
}); });
if (!rate) { const missing = (scope: string): BadRequestException =>
throw new BadRequestException( new BadRequestException(
'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).', `No LIVE per-wagon cancellation fee is configured for ${scope} on ${booking.tradeDirection} — ask EDR to set it in the rate engine (surcharge: Cancellation).`,
); );
if (booking.freightType !== 'CONTAINER') {
const rate = rates.find(
(r) => !r.containerTypeId && !!r.cargoTypeId && r.cargoTypeId === booking.cargoTypeId,
);
if (!rate) throw missing(`bulk cargo type ${booking.cargoType?.cargoTypeName ?? booking.cargoTypeId ?? '?'}`);
const amount = round2(Number(rate.rateValue) * cut.wagons);
return { amount, currency: rate.currency, perWagon: Number(rate.rateValue), rates: [rate] };
} }
return rate;
// Container: split the cancelled wagons across sizes in proportion to the
// wagon-space each size's units occupy, so the total always equals
// cut.wagons (whole wagons on an allocation cut, fractional on a quantity cut).
const bySize = Object.entries(cut.quantities.bySize ?? {}).filter(([, qty]) => qty > 0);
const spaceOf = ([size, qty]: [string, number]) => qty * wagonsPerUnitForSize(sizeFtOf(size));
const totalSpace = bySize.reduce((s, e) => s + spaceOf(e), 0);
if (!bySize.length || totalSpace <= 0) throw missing('containers');
const containerTypes = await this.dataSource.getRepository(ContainerType).find();
const used: Rate[] = [];
let amount = 0;
let currency = '';
for (const entry of bySize) {
const [size] = entry;
const sizeFt = sizeFtOf(size);
const typeIds = new Set(
containerTypes.filter((ct) => Number(ct.sizeFt) === sizeFt).map((ct) => ct.id),
);
const rate = rates.find((r) => !!r.containerTypeId && typeIds.has(r.containerTypeId));
if (!rate) throw missing(`${sizeFt || '?'}ft containers`);
currency = rate.currency;
used.push(rate);
amount += Number(rate.rateValue) * cut.wagons * (spaceOf(entry) / totalSpace);
}
amount = round2(amount);
return { amount, currency, perWagon: round2(amount / cut.wagons), rates: used };
} }
/** /**
@@ -751,7 +896,7 @@ export class BookingWagonCancellationService {
const live = lines.reduce((s, l) => s + Number(l.quantity ?? 0), 0); const live = lines.reduce((s, l) => s + Number(l.quantity ?? 0), 0);
if (live < toDrop) { if (live < toDrop) {
throw new BadRequestException( throw new BadRequestException(
`Booking changed since the request: only ${live} × ${size}ft left, cannot cancel ${toDrop}.`, `Booking changed since the request: only ${live} × ${sizeFtOf(size)}ft left, cannot cancel ${toDrop}.`,
); );
} }
for (const line of lines) { for (const line of lines) {
@@ -795,7 +940,7 @@ export class BookingWagonCancellationService {
}); });
await manager.getRepository(BookingContainer).update(line.id, { await manager.getRepository(BookingContainer).update(line.id, {
quantity: qty - drop, quantity: qty - drop,
wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))), wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(sizeFtOf(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm), totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm),
hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length, hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length,
reeferQuantity: keptUnits.filter((u) => u.isReefer).length, reeferQuantity: keptUnits.filter((u) => u.isReefer).length,
@@ -883,7 +1028,7 @@ export class BookingWagonCancellationService {
const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
await manager.getRepository(BookingContainer).update(line.id, { await manager.getRepository(BookingContainer).update(line.id, {
quantity: kept.length, quantity: kept.length,
wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))), wagonsRequired: round2(kept.length * wagonsPerUnitForSize(sizeFtOf(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm), totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm),
hazardousQuantity: kept.filter((u) => u.isHazardous).length, hazardousQuantity: kept.filter((u) => u.isHazardous).length,
reeferQuantity: kept.filter((u) => u.isReefer).length, reeferQuantity: kept.filter((u) => u.isReefer).length,
@@ -902,9 +1047,9 @@ export class BookingWagonCancellationService {
booking: Booking, booking: Booking,
tons: number, tons: number,
): Promise<void> { ): Promise<void> {
if (tons >= Number(booking.cargoTotalWeightVgm)) { if (tons > Number(booking.cargoTotalWeightVgm)) {
throw new BadRequestException( throw new BadRequestException(
'Booking changed since the request: the cut no longer leaves any cargo.', 'Booking changed since the request: the cut exceeds the cargo left on the booking.',
); );
} }
if (booking.bulkTotalWeightTons != null) { if (booking.bulkTotalWeightTons != null) {

View File

@@ -121,6 +121,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsService, BookingsService,
BookingsRepository, BookingsRepository,
BookingPricingService, BookingPricingService,
ContainerValidationService,
BookingInvoiceService, BookingInvoiceService,
BookingLifecycleNotifierService, BookingLifecycleNotifierService,
BookingTransitionService, BookingTransitionService,

View File

@@ -16,6 +16,7 @@ import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity'; import { Contract } from '../contracts/entities/contract.entity';
import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
@@ -80,6 +81,8 @@ export interface BookingListFilterOptions {
originYardId?: string; originYardId?: string;
destinationYardId?: string; destinationYardId?: string;
isGovernment?: 'true' | 'false'; isGovernment?: 'true' | 'false';
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
consolidationPaired?: string; consolidationPaired?: string;
} }
@@ -781,16 +784,20 @@ export class BookingsRepository extends BaseRepository<Booking> {
// by TypeORM and crashes). // by TypeORM and crashes).
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.addSelect('contract.reference', 'contract_reference') .addSelect('contract.reference', 'contract_reference')
// Shipping-line owner name for search only (no relation, see entity) —
// the list rows get `shippingLineCompany` hydrated by the service.
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = booking.shipping_line_company_id')
.where('booking.deleted_at IS NULL'); .where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options); this.applyListFilters(qb, options);
// Free-text search spans joined columns (company, contract) that only this // Free-text search spans joined columns (company, shipping line, contract)
// list query joins — so it lives here, not in applyListFilters (shared // that only this list query joins — so it lives here, not in
// with getListSummaryMetrics, whose query builder has no joins). // applyListFilters (shared with getListSummaryMetrics, whose query builder
// has no joins).
if (options.search) { if (options.search) {
qb.andWhere( qb.andWhere(
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)', '(booking.reference ILIKE :search OR company.name ILIKE :search OR slc.name ILIKE :search OR contract.reference ILIKE :search)',
{ search: `%${options.search}%` }, { search: `%${options.search}%` },
); );
} }
@@ -1043,6 +1050,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
} else if (options.isGovernment === 'false') { } else if (options.isGovernment === 'false') {
qb.andWhere('booking.is_government = FALSE'); qb.andWhere('booking.is_government = FALSE');
} }
if (options.customerKind === 'SHIPPING_LINE') {
qb.andWhere('booking.shipping_line_company_id IS NOT NULL');
} else if (options.customerKind === 'CUSTOMER') {
qb.andWhere('booking.shipping_line_company_id IS NULL');
}
if (omit !== 'tradeDirection' && options.tradeDirection) { if (omit !== 'tradeDirection' && options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', { qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection, tradeDirection: options.tradeDirection,

View File

@@ -1766,6 +1766,38 @@ export class BookingsService {
pending.has(b.id); pending.has(b.id);
} }
this.attachPaymentDrainEnds(bookings); this.attachPaymentDrainEnds(bookings);
await this.attachShippingLineCompanies(bookings);
}
/**
* Batched name lookup for shipping-line-owned bookings (`companyId` null,
* `shippingLineCompanyId` set). No relation on the entity — the shipping-line
* module sits above bookings — so a raw query keyed off the loaded ids fills
* `shippingLineCompany` the way `company` is filled for customers.
*/
private async attachShippingLineCompanies(bookings: Booking[]): Promise<void> {
const ids = [
...new Set(
bookings
.map((b) => b.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return;
const rows: Array<{ id: string; name: string; email: string | null; phoneNumber: string | null }> =
await this.dataSource.query(
`SELECT id, name, email, phone_number AS "phoneNumber"
FROM freight.shipping_line_companies
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
[ids],
);
const byId = new Map(rows.map((r) => [r.id, r]));
for (const b of bookings) {
const line = b.shippingLineCompanyId ? byId.get(b.shippingLineCompanyId) : undefined;
if (line) {
(b as Booking & { shippingLineCompany?: typeof line }).shippingLineCompany = line;
}
}
} }
/** /**
@@ -1822,6 +1854,7 @@ export class BookingsService {
originYardId: filter.originYardId, originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId, destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment, isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired, consolidationPaired: filter.consolidationPaired,
// DTO carries 'true'/'false' strings (query params); the repo option is a // DTO carries 'true'/'false' strings (query params); the repo option is a
// real boolean — convert, preserving "not filtered" when absent. // real boolean — convert, preserving "not filtered" when absent.
@@ -2048,6 +2081,7 @@ export class BookingsService {
originYardId: filter.originYardId, originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId, destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment, isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired, consolidationPaired: filter.consolidationPaired,
}; };
@@ -2134,6 +2168,8 @@ export class BookingsService {
{ path: "booking" }, { path: "booking" },
); );
await this.attachShippingLineCompanies([booking]);
if (booking.files && booking.files.length > 0) { if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all( booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => { booking.files.map(async (file: FileRecord) => {

View File

@@ -67,9 +67,16 @@ export class ContainerValidationService {
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
if (!has20ft) return []; if (!has20ft) return [];
const units = await this.load20ftUnits(booking); return this.validate20ftPairingUnits(await this.load20ftUnits(booking));
if (units.length < 2) return []; }
/**
* Same rule over units that are not (yet) persisted — a completion payload
* being previewed or submitted. Shipping-line completion uses this: its
* cargo only hits the DB after the check passes.
*/
async validate20ftPairingUnits(units: Container20ftUnit[]): Promise<PairingViolation[]> {
if (units.length < 2) return [];
const maxDiff = await this.maxPairDiffTons(); const maxDiff = await this.maxPairDiffTons();
return validate20ftWeightPairing(units, maxDiff); return validate20ftWeightPairing(units, maxDiff);
} }

View File

@@ -111,6 +111,14 @@ export class FilterBookingDto {
@IsIn(['true', 'false']) @IsIn(['true', 'false'])
isGovernment?: 'true' | 'false'; isGovernment?: 'true' | 'false';
@ApiPropertyOptional({
enum: ['SHIPPING_LINE', 'CUSTOMER'],
description: 'Who booked: a shipping line (owned by shipping_line_company_id) or an ordinary customer company',
})
@IsOptional()
@IsIn(['SHIPPING_LINE', 'CUSTOMER'])
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: ['true', 'false'], enum: ['true', 'false'],
description: 'Filter customs vs self-clearance (non-customs) bookings', description: 'Filter customs vs self-clearance (non-customs) bookings',
@@ -140,7 +148,7 @@ export class FilterBookingDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Free-text search across booking reference, company name, and contract reference.', 'Free-text search across booking reference, customer / shipping-line company name, and contract reference.',
}) })
@IsOptional() @IsOptional()
@Transform(({ value }) => @Transform(({ value }) =>

View File

@@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
// Consumed by NotificationInboxModule for portal recipient targeting. // Consumed by NotificationInboxModule for portal recipient targeting.
ExternalProfileRepository, ExternalProfileRepository,
CompanyProfileRepository, CompanyProfileRepository,
// Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup
// already used for every customer company at onboarding, reused for EDR's own TIN.
ETradeService,
], ],
}) })
export class CompaniesModule { } export class CompaniesModule { }

View File

@@ -110,6 +110,7 @@ function makeService(overrides?: {
.fn() .fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents } as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
); );
return { return {

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { import {
ContractDocPhase, ContractDocPhase,
isDeliveryOrderFileCode, isDeliveryOrderFileCode,
@@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service'; import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service'; import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
@@ -155,6 +157,7 @@ export class BookingClearanceService {
private readonly notifier: BookingLifecycleNotifierService, private readonly notifier: BookingLifecycleNotifierService,
private readonly glExchangeService: GlExchangeService, private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService, private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
) {} ) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> { private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -988,7 +991,39 @@ export class BookingClearanceService {
const milestones = await this.workflowService.listMilestonesForBooking(b.id); const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
} }
return filtered; return this.attachContractSummary(filtered);
}
/**
* Queue rows show the parent contract's reference and lane. Booking has no
* contract relation, and a bare initiated instance may not carry yards yet —
* so batch-load the contracts (with routes) and fill in what's missing:
* `contractReference` always, origin/destination yards only when the booking
* lacks them (its own route wins).
*/
private async attachContractSummary(bookings: Booking[]): Promise<Booking[]> {
const ids = [...new Set(bookings.map((b) => b.contractId).filter(Boolean))] as string[];
if (!ids.length) return bookings;
const contracts = await this.contractsRepository.findAll({
where: { id: In(ids) },
relations: { routes: { originYard: true, destinationYard: true } },
});
const byId = new Map(contracts.map((c) => [c.id, c]));
for (const b of bookings) {
const contract = b.contractId ? byId.get(b.contractId) : undefined;
if (!contract) continue;
const row = b as Booking & { contractReference?: string | null };
row.contractReference = contract.reference ?? null;
if (b.originYard && b.destinationYard) continue;
const routes = contract.routes ?? [];
const route =
routes.find((r) => r.id === b.contractRouteId) ??
(routes.length === 1 ? routes[0] : undefined);
if (!route) continue;
b.originYard = b.originYard ?? route.originYard;
b.destinationYard = b.destinationYard ?? route.destinationYard;
}
return bookings;
} }
async djQueue(): Promise<Booking[]> { async djQueue(): Promise<Booking[]> {
@@ -1008,6 +1043,6 @@ export class BookingClearanceService {
filtered.push(b); filtered.push(b);
} }
} }
return filtered; return this.attachContractSummary(filtered);
} }
} }

View File

@@ -1,3 +1,4 @@
import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
@@ -12,6 +13,9 @@ const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
/** /**
* `query` is answered by shape: the first call is the system-state guard, the second is the * `query` is answered by shape: the first call is the system-state guard, the second is the
* candidate lookup. Keeps the fake honest about the order the service actually asks in. * candidate lookup. Keeps the fake honest about the order the service actually asks in.
*
* `managerRow` backs `dataSource.manager.findOne`/`.update` — only exercised by the
* pre-reservation-rejection path (`failStalledCandidate`), so it defaults to the candidate itself.
*/ */
const build = ( const build = (
opts: { opts: {
@@ -19,6 +23,7 @@ const build = (
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null }; state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
candidate?: { id: string; invoiceNumber: string } | null; candidate?: { id: string; invoiceNumber: string } | null;
register?: jest.Mock; register?: jest.Mock;
managerRow?: { eimsStatus: EimsInvoiceStatus } | null;
} = {}, } = {},
) => { ) => {
const register = const register =
@@ -34,12 +39,17 @@ const build = (
return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []); return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []);
}); });
const managerUpdate = jest.fn().mockResolvedValue(undefined);
const managerFindOne = jest
.fn()
.mockResolvedValue(opts.managerRow === undefined ? { eimsStatus: EimsInvoiceStatus.NotSubmitted } : opts.managerRow);
const service = new EimsAutoSubmitService( const service = new EimsAutoSubmitService(
{ query } as unknown as DataSource, { query, manager: { findOne: managerFindOne, update: managerUpdate } } as unknown as DataSource,
{ get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService, { get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService,
{ registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService, { registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService,
); );
return { service, register, query }; return { service, register, query, managerUpdate, managerFindOne };
}; };
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" }; const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
@@ -121,6 +131,40 @@ describe("EimsAutoSubmitService.tick", () => {
expect(register).toHaveBeenCalledTimes(1); expect(register).toHaveBeenCalledTimes(1);
}); });
it("drains a pre-reservation rejection so the sweep advances, without touching the DB row's own reservation state", async () => {
const register = jest
.fn()
.mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" }));
const { service, managerFindOne, managerUpdate } = build({ candidate, register });
await expect(service.tick()).resolves.toBeUndefined();
expect(managerFindOne).toHaveBeenCalledTimes(1);
expect(managerUpdate).toHaveBeenCalledWith(
expect.anything(),
INVOICE_ID,
expect.objectContaining({
eimsStatus: EimsInvoiceStatus.Failed,
eimsLastError: expect.objectContaining({ message: "no related invoice" }),
}),
);
});
it("leaves a row alone if it already moved past NOT_SUBMITTED by the time the rejection is handled", async () => {
const register = jest
.fn()
.mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" }));
const { service, managerUpdate } = build({
candidate,
register,
managerRow: { eimsStatus: EimsInvoiceStatus.Submitting },
});
await expect(service.tick()).resolves.toBeUndefined();
expect(managerUpdate).not.toHaveBeenCalled();
});
it("does not start a second tick while one is still filing", async () => { it("does not start a second tick while one is still filing", async () => {
let release: () => void = () => {}; let release: () => void = () => {};
const register = jest.fn().mockImplementation( const register = jest.fn().mockImplementation(

View File

@@ -1,12 +1,14 @@
import { Injectable, Logger } from "@nestjs/common"; import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Cron } from "@nestjs/schedule"; import { Cron } from "@nestjs/schedule";
import { InjectDataSource } from "@nestjs/typeorm"; import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsInvoiceStatus } from "./eims-registration.types"; import { EimsInvoiceError, EimsInvoiceStatus } from "./eims-registration.types";
/** /**
* Files issued invoices with MoR EIMS on a timer. * Files issued invoices with MoR EIMS on a timer.
@@ -67,21 +69,62 @@ export class EimsAutoSubmitService {
const candidate = await this.nextCandidate(); const candidate = await this.nextCandidate();
if (!candidate) return; if (!candidate) return;
const view = await this.registration.registerInvoiceWithEims(candidate.id); try {
this.logger.log( const view = await this.registration.registerInvoiceWithEims(candidate.id);
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + this.logger.log(
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
); (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
);
} catch (err) {
// Every other failure path inside registerInvoiceWithEims persists FAILED/UNKNOWN itself
// (settleFailure) before throwing. A BadRequestException is the one exception: it is only
// ever thrown *before* a reservation is taken (config assertion, DEB/CRE validation), so
// nothing is persisted — left alone, this candidate is picked again next tick forever, a
// permanent head-of-line block on every invoice behind it. Drain it instead.
if (err instanceof BadRequestException) {
await this.failStalledCandidate(candidate, err);
} else {
throw err;
}
}
} catch (err) { } catch (err) {
// Never let a filing failure kill the job. The outcome is already persisted on the invoice // Never let a filing failure kill the job. The outcome is already persisted on the invoice
// (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the // (FAILED or UNKNOWN with the gateway's own message, or drained by failStalledCandidate
// next tick at the guard above. // above), and a blocked system number stops the next tick at the guard above.
this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`); this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`);
} finally { } finally {
this.running = false; this.running = false;
} }
} }
/**
* Mark a pre-reservation rejection as FAILED so the sweep advances past it — but only if the
* invoice is still exactly where this tick left it. A reservation's own transactions
* (SUBMITTING/UNKNOWN, or a system-wide block) are authoritative; this must never clobber them,
* so the status is re-read fresh rather than trusted from the stale `candidate` row.
*/
private async failStalledCandidate(
candidate: { id: string; invoiceNumber: string },
err: BadRequestException,
): Promise<void> {
const current = await this.dataSource.manager.findOne(Invoice, { where: { id: candidate.id } });
if (current?.eimsStatus !== EimsInvoiceStatus.NotSubmitted) {
this.logger.warn(
`EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation, but is ` +
`no longer NOT_SUBMITTED (${current?.eimsStatus ?? "not found"}) — leaving state untouched.`,
);
return;
}
const lastError: EimsInvoiceError = { kind: "VALIDATION", message: err.message, at: new Date().toISOString() };
await this.dataSource.manager.update(Invoice, candidate.id, {
eimsStatus: EimsInvoiceStatus.Failed,
eimsLastError: lastError,
} as QueryDeepPartialEntity<Invoice>);
this.logger.error(
`EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation: ${err.message}`,
);
}
/** Why filing is currently impossible for this system number, or null when it is free. */ /** Why filing is currently impossible for this system number, or null when it is free. */
private async systemBlockReason(): Promise<string | null> { private async systemBlockReason(): Promise<string | null> {
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] = const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =

View File

@@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { EimsConfigException } from "./eims.errors"; import { EimsConfigException } from "./eims.errors";
const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/;
/**
* A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the
* first line. Never the actual key material — PEM headers aren't secret, the base64 body is.
*/
const describeBytes = (bytes: Buffer): string => {
const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?");
return `${bytes.length} bytes, starts with "${preview}"`;
};
/** /**
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
* *
@@ -24,25 +35,61 @@ export class EimsCredentialsProvider {
return this.config.get<EimsConfig>("eims")!; return this.config.get<EimsConfig>("eims")!;
} }
/** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ /**
* RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM
* text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a
* literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if
* none is usable.
*/
getPrivateKey(): KeyObject { getPrivateKey(): KeyObject {
if (this.privateKey) return this.privateKey; if (this.privateKey) return this.privateKey;
const path = this.cfg.privateKeyPath; const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg;
if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); const source = privateKeyPem
? "EIMS_PRIVATE_KEY"
: privateKeyBase64
? "EIMS_PRIVATE_KEY_BASE64"
: `EIMS_PRIVATE_KEY_PATH (${path})`;
if (!privateKeyPem && !privateKeyBase64 && !path) {
throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
}
let bytes: Buffer;
try {
bytes = privateKeyPem
? Buffer.from(privateKeyPem, "utf8")
: privateKeyBase64
? Buffer.from(privateKeyBase64, "base64")
: readFileSync(path);
} catch (err) {
throw new EimsConfigException(
`EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
);
}
// Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own
// error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a
// genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes.
if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) {
throw new EimsConfigException(
`EIMS private key from ${source} does not look like a PEM key after decoding ` +
`(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` +
`or truncation, and not base64 applied twice.`,
);
}
let key: KeyObject; let key: KeyObject;
try { try {
key = createPrivateKey(readFileSync(path)); key = createPrivateKey(bytes);
} catch (err) { } catch (err) {
// The path is operational information, not a secret; the key material never appears. // The source is operational information, not a secret; the key material never appears.
throw new EimsConfigException( throw new EimsConfigException(
`EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`, `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
); );
} }
if (key.asymmetricKeyType !== "rsa") { if (key.asymmetricKeyType !== "rsa") {
throw new EimsConfigException( throw new EimsConfigException(
`EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, `EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
); );
} }
@@ -51,11 +98,26 @@ export class EimsCredentialsProvider {
return key; return key;
} }
/** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ /**
* Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued.
* `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the
* pasted text respectively); otherwise read from `certificatePath`.
*/
getCertificateBase64(): string { getCertificateBase64(): string {
if (this.certificateBase64) return this.certificateBase64; if (this.certificateBase64) return this.certificateBase64;
const path = this.cfg.certificatePath; const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg;
if (pem) {
this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64");
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`);
return this.certificateBase64;
}
if (inline) {
this.certificateBase64 = inline;
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`);
return this.certificateBase64;
}
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
let bytes: Buffer; let bytes: Buffer;

View File

@@ -157,6 +157,12 @@ export interface EimsContextInput {
session: EimsSessionContext; session: EimsSessionContext;
/** Required when the invoice currency is not ETB. */ /** Required when the invoice currency is not ETB. */
exchangeRate?: number | null; exchangeRate?: number | null;
/** `DocumentDetails.Type` — defaults to "INV" in the mapper when omitted. */
documentType?: EimsMapperContext["documentType"];
/** Required (by the mapper) when documentType is DEB/CRE. */
reason?: string | null;
/** `ReferenceDetails.RelatedDocument` — the original invoice's IRN, required for DEB/CRE. */
relatedDocument?: string | null;
} }
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext { export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
@@ -200,11 +206,16 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
incomeWithholdValue: invoice.incomeWithholdValue!, incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode, buyerCountryCode: invoice.buyerCountryCode,
buyerCountryCodes: invoice.buyerCountryCodes,
buyerRegionCodes: invoice.buyerRegionCodes, buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes, buyerWeredaCodes: invoice.buyerWeredaCodes,
buyerCityCodes: invoice.buyerCityCodes,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType. // TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType, buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber, buyerIdNumber: invoice.buyerIdNumber,
exchangeRate: input.exchangeRate ?? null, exchangeRate: input.exchangeRate ?? null,
documentType: input.documentType,
reason: input.reason ?? null,
relatedDocument: input.relatedDocument ?? null,
}; };
} }

View File

@@ -10,8 +10,10 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb
import { NotificationsService } from "../notifications/notifications.service"; import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service"; import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors"; import { EimsApiException, EimsConfigException } from "./eims.errors";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types"; import { EimsInvoiceStatus } from "./eims-registration.types";
@@ -172,6 +174,9 @@ const build = (
} as unknown as EimsAuthService, } as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService, { notify } as unknown as NotificationInboxService,
{ directSend } as unknown as NotificationsService, { directSend } as unknown as NotificationsService,
// Same static-config seller the real EimsSellerCacheService falls back to when it has never
// successfully fetched e-Trade — matches prior behavior for every test in this file.
{ getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService,
); );
/** /**
@@ -295,6 +300,58 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
}); });
it("files a credit note with Type/Reason/RelatedDocument from the invoice row", async () => {
const original = invoiceRow({
id: "original-invoice",
invoiceNumber: "INV-20260807-00001",
eimsIrn: IRN,
});
const db = new FakeDb([
invoiceRow({
eimsDocumentType: "CRE",
eimsReason: "Overbilled freight charge",
relatedInvoice: original,
} as Partial<Invoice>),
]);
const postSigned = jest.fn().mockResolvedValue(okResponse());
await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" });
expect(request.ReferenceDetails.RelatedDocument).toBe(IRN);
});
it("refuses a credit/debit note whose related invoice was never registered, before touching a counter", async () => {
const original = invoiceRow({ id: "original-invoice", eimsIrn: null });
const db = new FakeDb([
invoiceRow({
eimsDocumentType: "DEB",
eimsReason: "Additional handling",
relatedInvoice: original,
} as Partial<Invoice>),
]);
const postSigned = jest.fn();
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state).toMatchObject({ nextInvoiceCounter: 7 }); // unchanged — never reserved
});
it("refuses a credit/debit note with no related invoice set at all", async () => {
const db = new FakeDb([
invoiceRow({ eimsDocumentType: "CRE", eimsReason: "x", relatedInvoice: null } as Partial<Invoice>),
]);
const postSigned = jest.fn();
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(postSigned).not.toHaveBeenCalled();
});
it("takes SourceSystem from the token session, not from configuration", async () => { it("takes SourceSystem from the token session, not from configuration", async () => {
const db = new FakeDb([invoiceRow()]); const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse()); const postSigned = jest.fn().mockResolvedValue(okResponse());
@@ -422,6 +479,59 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
}); });
}); });
it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest
.fn()
.mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed"));
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsConfigException,
);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Failed,
eimsIrn: null,
eimsLastError: expect.objectContaining({ kind: "CONFIG" }),
});
expect(db.state).toMatchObject({
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 7,
});
});
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
]);
const postSigned = jest.fn();
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
// known exception types.
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR country code mapping/,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Failed,
eimsLastError: expect.objectContaining({ kind: "LOCAL" }),
});
expect(db.state).toMatchObject({
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 7,
});
});
it("treats a success response with no IRN as a failed registration", async () => { it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]); const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } }); const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });

View File

@@ -13,6 +13,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity"; import { Invoice } from "../billing/entities/invoice.entity";
import { import {
EimsDocumentType,
EimsInvoiceRequest, EimsInvoiceRequest,
EimsMapperLine, EimsMapperLine,
toEimsInvoice, toEimsInvoice,
@@ -25,13 +26,10 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service"; import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors"; import { EimsApiException, EimsConfigException } from "./eims.errors";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity";
import { import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
assertEimsInvoiceConfig,
buildEimsContext,
buildEimsSeller,
} from "./eims-invoice-context";
import { import {
EimsInvoiceError, EimsInvoiceError,
EimsInvoiceStatus, EimsInvoiceStatus,
@@ -82,6 +80,7 @@ export class EimsInvoiceRegistrationService {
private readonly auth: EimsAuthService, private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService, private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly sellerCache: EimsSellerCacheService,
) {} ) {}
private get cfg(): EimsConfig { private get cfg(): EimsConfig {
@@ -96,6 +95,27 @@ export class EimsInvoiceRegistrationService {
const invoice = await this.loadInvoiceForMapping(invoiceId); const invoice = await this.loadInvoiceForMapping(invoiceId);
if (invoice.eimsIrn) return this.toView(invoice); if (invoice.eimsIrn) return this.toView(invoice);
// Debit/credit notes (confirmed by MoR support: same endpoint, Type DEB/CRE + Reason,
// ReferenceDetails.RelatedDocument = the original's IRN) must fail here — before a counter is
// touched — if the original was never actually registered.
const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV";
let relatedDocument: string | null = null;
if (documentType !== "INV") {
if (!invoice.relatedInvoice) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_REQUIRED",
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`,
});
}
if (!invoice.relatedInvoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`,
});
}
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
// Authenticate before reserving: the source system comes from the token, and the state row is // Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet. // keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext(); const session = await this.auth.getSessionContext();
@@ -103,24 +123,29 @@ export class EimsInvoiceRegistrationService {
const reservation = await this.reserve(invoiceId, session.systemNumber); const reservation = await this.reserve(invoiceId, session.systemNumber);
if (!reservation) return this.getEimsStatus(invoiceId); if (!reservation) return this.getEimsStatus(invoiceId);
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
const request = toEimsInvoice(
invoice,
buildEimsSeller(cfg),
buildEimsContext(cfg, {
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
}),
);
let irn: string; let irn: string;
let ackDate: string | undefined; let ackDate: string | undefined;
let signedQR: string | undefined; let signedQR: string | undefined;
try { try {
// The request can only be built now: InvoiceCounter and PreviousIrn come from the
// reservation. Building it — and everything after — stays inside this try: a reservation is
// held from here on, and *any* failure past this point, mapper or wire, must release it
// through settleFailure rather than leave it orphaned as a permanent system-wide block.
const request = toEimsInvoice(
invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
documentType,
reason: invoice.eimsReason,
relatedDocument,
}),
);
// Deliberately outside every transaction — no DB lock is held across the wire. // Deliberately outside every transaction — no DB lock is held across the wire.
const result = await this.submit(request); const result = await this.submit(request);
irn = result.irn; irn = result.irn;
@@ -446,6 +471,15 @@ export class EimsInvoiceRegistrationService {
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset. * when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
* *
* An ambiguous result keeps both: MoR may have counted and stored the document. * An ambiguous result keeps both: MoR may have counted and stored the document.
*
* Any error that is *not* an `EimsApiException` is also deterministic, on a different basis:
* every error that actually touches the wire is normalized to `EimsApiException` before it gets
* here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call
* threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` —
* pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by
* touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer
* country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before
* any HTTP call went out, and releasing the reservation is always safe, never a guess.
*/ */
private async settleFailure( private async settleFailure(
invoiceId: string, invoiceId: string,
@@ -453,10 +487,12 @@ export class EimsInvoiceRegistrationService {
err: unknown, err: unknown,
): Promise<void> { ): Promise<void> {
const api = err instanceof EimsApiException ? err : null; const api = err instanceof EimsApiException ? err : null;
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; // Never touched the wire (see the doc comment above) — always safe to release, whatever it is.
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
const lastError: EimsInvoiceError = { const lastError: EimsInvoiceError = {
kind: api?.kind ?? "UNKNOWN", kind: api?.kind ?? localKind,
message: (err as Error)?.message ?? "unknown error", message: (err as Error)?.message ?? "unknown error",
httpStatus: api?.httpStatus, httpStatus: api?.httpStatus,
details: api?.details, details: api?.details,
@@ -563,10 +599,14 @@ export class EimsInvoiceRegistrationService {
type: NotificationType.GENERIC, type: NotificationType.GENERIC,
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH, priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
title: deterministic title: deterministic
? "EIMS rejected an invoice" ? error.kind === "CONFIG" || error.kind === "LOCAL"
? "EIMS filing failed before reaching MoR"
: "EIMS rejected an invoice"
: "EIMS filing unresolved — all further filing is blocked", : "EIMS filing unresolved — all further filing is blocked",
body: deterministic body: deterministic
? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` ? error.kind === "CONFIG" || error.kind === "LOCAL"
? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it and file again.`
: `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`, : `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
link: `/dashboard/invoices/${invoiceId}`, link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" }, data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
@@ -639,7 +679,7 @@ export class EimsInvoiceRegistrationService {
): Promise<Invoice & { lines: EimsMapperLine[] }> { ): Promise<Invoice & { lines: EimsMapperLine[] }> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({ const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId }, where: { id: invoiceId },
relations: { company: true, companyProfile: true }, relations: { company: true, companyProfile: true, relatedInvoice: true },
}); });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);

View File

@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common"; import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { BookingStaff } from "../../common/booking-guards"; import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { sendPdf } from "../billing/billing.controller";
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto"; import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
@@ -110,4 +112,16 @@ export class EimsInvoiceController {
listReceipts(@Param("id", ParseUUIDPipe) id: string) { listReceipts(@Param("id", ParseUUIDPipe) id: string) {
return this.receipts.listReceipts(id); return this.receipts.listReceipts(id);
} }
@Get(":id/eims/receipts/:receiptId/document")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed receipt PDF (RRN + QR) for a filed EIMS receipt" })
async receiptDocument(
@Param("id", ParseUUIDPipe) id: string,
@Param("receiptId", ParseUUIDPipe) receiptId: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.receipts.document(id, receiptId);
sendPdf(res, filename, buffer);
}
} }

View File

@@ -0,0 +1,107 @@
import { Invoice } from "../billing/entities/invoice.entity";
import {
InvoiceDocumentModel,
pngDataUrl,
} from "../billing/documents/invoice-document.service";
import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity";
import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types";
/**
* Maps a filed `EimsReceipt` onto the shared invoice/receipt document layout — mirrors
* `eims-invoice.mapper.ts`'s role for `/v1/register`: a pure function, no I/O.
*
* The amounts (collected amount, mode of payment, withholding amount) live only in
* `receipt.request` — the exact body this app sent, typed and written in exactly one place
* (`EimsReceiptService`). Reading it back is a cast, not a new source of truth; real columns
* would mean a migration + backfill for data already present in a stable shape.
*
* Throws rather than returning a model for anything not actually filed: a sealed, stamped PDF
* for a receipt MoR rejected, never acknowledged, or whose request was somehow never recorded
* 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 {
if (receipt.status !== EimsReceiptStatus.Registered) {
throw new Error(
`Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`,
);
}
if (!receipt.request) {
throw new Error(`Receipt ${receipt.receiptNumber} has no stored request body — cannot render its amounts.`);
}
const isSales = receipt.kind === "SALES";
if (isSales) {
const req = receipt.request as unknown as EimsSalesReceiptRequest;
return build(receipt, invoice, {
title: "Sales Receipt",
currency: req.ReceiptCurrency,
amountLabel: "Collected",
lineDescription: `Payment received against invoice ${invoice.invoiceNumber}`,
amount: req.CollectedAmount,
// A sales receipt is a real payment — this is the one case the shared layout's own default
// ("EDR PAID" for kind RECEIPT) is already correct, but set it explicitly so it never drifts
// if that default changes for an unrelated reason.
sealText: "EDR PAID",
extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }],
});
}
const req = receipt.request as unknown as EimsWithholdReceiptRequest;
return build(receipt, invoice, {
title: "Withholding Receipt",
currency: req.InvoiceDetail.Currency,
amountLabel: "Withheld",
lineDescription: `Withholding (${req.WithholdDetail.Type}) against invoice ${invoice.invoiceNumber}`,
amount: req.WithholdDetail.WithholdingAmount,
// A withholding receipt is not a payment — the shared layout's "EDR PAID" default would be
// wrong here, so this is the one case that MUST override it.
sealText: "EDR",
extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }],
});
}
function build(
receipt: EimsReceipt,
invoice: Invoice,
opts: {
title: string;
currency: string;
amountLabel: string;
lineDescription: string;
amount: number;
sealText: string;
extraSummary: Array<{ label: string; value: string | null }>;
},
): InvoiceDocumentModel {
return {
kind: "RECEIPT",
title: opts.title,
documentNumber: receipt.receiptNumber,
issuedAt: receipt.submittedAt ?? null,
status: receipt.status,
currency: opts.currency,
summary: [
{ label: "Invoice", value: invoice.invoiceNumber },
{ label: "Invoice IRN", value: invoice.eimsIrn ?? null },
{ label: "RRN", value: receipt.rrn ?? null },
{ label: "Ack status", value: receipt.ackStatus ?? null },
...opts.extraSummary,
],
// No line items on a receipt — one synthetic line, since buildHtml renders the line table
// unconditionally and an empty `lines: []` would print a header-only empty table.
lines: [
{
description: opts.lineDescription,
quantity: 1,
unitRate: opts.amount,
amount: opts.amount,
currency: opts.currency,
},
],
totals: [{ label: opts.amountLabel, amount: opts.amount, grand: true }],
sealText: opts.sealText,
qrImageUrl: receipt.qr ? pngDataUrl(receipt.qr) : null,
};
}

View File

@@ -4,6 +4,7 @@ import { DataSource } from "typeorm";
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity"; import { Invoice } from "../billing/entities/invoice.entity";
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service"; import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service"; import { EimsClientService } from "./eims-client.service";
@@ -40,10 +41,16 @@ class FakeDb {
} }
private manager = { private manager = {
findOne: async (entity: unknown, options: { where: { id: string } }) => findOne: async (
entity === Invoice entity: unknown,
? (this.invoices.get(options.where.id) ?? null) options: { where: { id?: string; invoiceId?: string } },
: (this.receipts.get(options.where.id) ?? null), ) => {
if (entity === Invoice) return this.invoices.get(options.where.id!) ?? null;
const receipt = options.where.id ? this.receipts.get(options.where.id) : undefined;
if (!receipt) return null;
if (options.where.invoiceId && receipt.invoiceId !== options.where.invoiceId) return null;
return receipt;
},
find: async (_entity: unknown, options: { where: { invoiceId: string } }) => find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId), [...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
save: async (_entity: unknown, data: Record<string, unknown>) => { save: async (_entity: unknown, data: Record<string, unknown>) => {
@@ -71,6 +78,7 @@ const build = (
db: FakeDb, db: FakeDb,
postBearer: jest.Mock, postBearer: jest.Mock,
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined), directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
documents: { render: jest.Mock } = { render: jest.fn() },
) => ) =>
new EimsReceiptService( new EimsReceiptService(
db.asDataSource(), db.asDataSource(),
@@ -78,6 +86,7 @@ const build = (
{ postBearer } as unknown as EimsClientService, { postBearer } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService, { getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ directSend } as unknown as NotificationsService, { directSend } as unknown as NotificationsService,
documents as unknown as InvoiceDocumentService,
); );
const okResponse = (over: Record<string, unknown> = {}) => ({ const okResponse = (over: Record<string, unknown> = {}) => ({
@@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => {
expect(list).toHaveLength(2); expect(list).toHaveLength(2);
}); });
}); });
describe("EimsReceiptService.document", () => {
it("renders a sealed PDF for a registered sales receipt, with RRN and QR in the model", async () => {
const db = new FakeDb([invoiceRow()]);
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
const receipt = await service.registerSalesReceipt(INVOICE_ID, {
modeOfPayment: "CASH",
collectedAmount: 500,
} as never);
await service.document(INVOICE_ID, receipt.id);
expect(documents.render).toHaveBeenCalledTimes(1);
const model = documents.render.mock.calls[0][0];
expect(model.kind).toBe("RECEIPT");
expect(model.qrImageUrl).toBe("data:image/png;base64,iVBORw0KGgo...");
expect(model.summary).toContainEqual({ label: "RRN", value: "rrn-value" });
expect(model.lines[0].amount).toBe(500);
expect(model.sealText).toBe("EDR PAID");
});
it("renders a withholding receipt with the withheld amount and a non-PAID seal", async () => {
const db = new FakeDb([invoiceRow()]);
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
const receipt = await service.registerWithholdingReceipt(INVOICE_ID, {
type: "TWHT",
preTaxAmount: 1000,
withholdingAmount: 20,
} as never);
await service.document(INVOICE_ID, receipt.id);
const model = documents.render.mock.calls[0][0];
expect(model.lines[0].amount).toBe(20);
expect(model.sealText).toBe("EDR");
expect(model.sealText).not.toContain("PAID");
});
it("refuses to render a receipt that was never acknowledged by MoR", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out"));
const documents = { render: jest.fn() };
const service = build(db, postBearer, undefined, documents);
await expect(
service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
).rejects.toBeInstanceOf(EimsApiException);
const [receipt] = [...db.receipts.values()];
await expect(service.document(INVOICE_ID, receipt.id as string)).rejects.toBeInstanceOf(BadRequestException);
expect(documents.render).not.toHaveBeenCalled();
});
it("scopes the lookup to the given invoice — a receipt from another invoice is not found", async () => {
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const service = build(db, jest.fn().mockResolvedValue(okResponse()));
const receipt = await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
await expect(service.document(OTHER_INVOICE_ID, receipt.id)).rejects.toThrow(/not found/);
});
});

View File

@@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity"; import { Invoice } from "../billing/entities/invoice.entity";
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service"; import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util"; import { sendCompanyChannels } from "../notifications/notify-company.util";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service"; import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors"; import { EimsApiException } from "./eims.errors";
import { toReceiptDocumentModel } from "./eims-receipt-document.mapper";
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity"; import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
@@ -52,6 +54,7 @@ export class EimsReceiptService {
private readonly client: EimsClientService, private readonly client: EimsClientService,
private readonly auth: EimsAuthService, private readonly auth: EimsAuthService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly documents: InvoiceDocumentService,
) {} ) {}
private get cfg(): EimsConfig { private get cfg(): EimsConfig {
@@ -154,6 +157,31 @@ export class EimsReceiptService {
}); });
} }
/**
* Sealed PDF for one filed receipt (RRN + QR), scoped to the invoice it belongs to. Not on
* `loadRegisteredInvoice` — a receipt refused/never-acknowledged by MoR must not render as a
* sealed tax document, and `toReceiptDocumentModel` is the one place that guards it.
*/
async document(invoiceId: string, receiptId: string): Promise<{ filename: string; buffer: Buffer }> {
const receipt = await this.dataSource.manager.findOne(EimsReceipt, {
where: { id: receiptId, invoiceId },
});
if (!receipt) throw new NotFoundException(`Receipt ${receiptId} not found on invoice ${invoiceId}`);
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
let model: ReturnType<typeof toReceiptDocumentModel>;
try {
model = toReceiptDocumentModel(receipt, invoice);
} 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
// itself throws.
throw new BadRequestException((err as Error).message);
}
return this.documents.render(model);
}
// ── internals ──────────────────────────────────────────────────────────────────────────────── // ── internals ────────────────────────────────────────────────────────────────────────────────
private async submit( private async submit(

View File

@@ -0,0 +1,155 @@
import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
const registrationData = (over: Record<string, unknown> = {}) => ({
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
region: "Addis Ababa",
zone: "Bole",
woreda: "Yeka",
mobilePhone: "0911000000",
regularPhone: "",
...over,
});
const build = (cfg: EimsConfig = eimsConfig()) => {
const resolveCompanyData = jest.fn();
const extractRegistrationData = jest.fn().mockReturnValue(registrationData());
const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService;
const config = { get: () => cfg } as unknown as ConfigService;
const service = new EimsSellerCacheService(etrade, config);
return { service, resolveCompanyData, extractRegistrationData, cfg };
};
const CODES = {
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" },
buyerCityCodes: { Bole: "101" },
};
describe("EimsSellerCacheService.getSellerDetails", () => {
it("static config wins over a conflicting e-Trade value", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({
companyInfo: {},
businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked
});
await service.refresh();
const seller = service.getSellerDetails(cfg);
// The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's
// differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)".
expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C.");
});
it("e-Trade fills a field only when the static value is blank", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({
sellerLegalName: "",
sellerRegion: "",
sellerWereda: "",
sellerCity: null,
...CODES,
}),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await service.refresh();
const seller = service.getSellerDetails(cfg);
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
expect(seller.Region).toBe("13");
expect(seller.Wereda).toBe("99");
expect(seller.City).toBe("101");
});
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await service.refresh();
const seller = service.getSellerDetails(cfg);
expect(seller.VatNumber).toBe("0000000000");
expect(seller.Email).toBe("finance@example.et");
});
it("falls back to the static config entirely when e-Trade has never been reachable", () => {
const cfg = eimsConfig();
const { service } = build(cfg);
// No refresh() ever called/succeeded — cached stays null.
const seller = service.getSellerDetails(cfg);
expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName);
expect(seller.Region).toBe(cfg.invoice.sellerRegion);
});
it("does no I/O at all — filing never triggers an e-Trade request", () => {
const { service, resolveCompanyData, cfg } = build();
service.getSellerDetails(cfg);
service.getSellerDetails(cfg);
expect(resolveCompanyData).not.toHaveBeenCalled();
});
});
describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot when a refresh fails", async () => {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down"));
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
});
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
jest.useFakeTimers();
try {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves
const refreshing = service.refresh();
await jest.advanceTimersByTimeAsync(10_000);
await refreshing;
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
} finally {
jest.useRealTimers();
}
});
it("does not start a second e-Trade request while one is already in flight", async () => {
const { service, resolveCompanyData } = build();
let resolveCall: (value: unknown) => void = () => {};
resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve)));
const first = service.refresh();
const second = service.refresh();
resolveCall({ companyInfo: {}, businessInfo: {} });
await Promise.all([first, second]);
expect(resolveCompanyData).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,147 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
import { buildEimsSeller } from "./eims-invoice-context";
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
/**
* EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same
* e-Trade business-registry lookup already used for every customer company at onboarding — instead
* of the whole thing being hand-maintained `EIMS_SELLER_*` config.
*
* **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates
* `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested
* with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a
* value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the
* e-Trade-derived value when the static one is blank; a static value, once set, is never replaced.
* This also means the durable fallback is the static config, not this cache — the in-memory
* snapshot disappearing on a process restart is harmless, not a reliability gap: every field it
* could supply already has a working static value today, so filing is unaffected either way.
*
* `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response
* shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field
* exists anywhere in what e-Trade returns. They stay on static config permanently, same as
* `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch.
*
* Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/
* position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain
* field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a
* refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate
* deviations from that precedent, both because `ETradeService` has no request timeout configured
* at all (confirmed by reading it) and is a third-party dependency, unlike the DB:
* - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot;
* - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight
* returns the same in-flight promise instead of starting a duplicate request.
*
* `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration
* never depends on e-Trade being reachable at that moment, whether or not it ever has been.
*/
@Injectable()
export class EimsSellerCacheService implements OnModuleInit {
private readonly logger = new Logger(EimsSellerCacheService.name);
/** Only the e-Trade-derived fields, used solely to fill a blank static value. */
private cached: Partial<EimsSellerDetails> | null = null;
/** Concurrency guard — a second `refresh()` call while one is running joins it. */
private refreshing: Promise<void> | null = null;
// ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a
// filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever
// matters; EDR's own business registration changes rarely enough that this is a generous
// ceiling, not a real one.
private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Bounded locally since `ETradeService` itself sets none — see the class comment. */
private static readonly REFRESH_TIMEOUT_MS = 10_000;
constructor(
private readonly etrade: ETradeService,
private readonly config: ConfigService,
) {}
onModuleInit(): void {
void this.refresh();
const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS);
timer.unref?.();
}
/**
* Static config wins whenever it's non-blank — that's the value already confirmed against MoR.
* e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on
* every registration.
*/
getSellerDetails(cfg: EimsConfig): EimsSellerDetails {
const fallback = buildEimsSeller(cfg);
const e = this.cached;
return {
...fallback,
LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName),
Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone),
Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region),
Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda),
City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City),
};
}
/** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */
async refresh(): Promise<void> {
if (this.refreshing) return this.refreshing;
this.refreshing = this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(): Promise<void> {
try {
const cfg = this.config.get<EimsConfig>("eims")!;
const { companyInfo, businessInfo } = await this.withTimeout(
this.etrade.resolveCompanyData(cfg.tin),
EimsSellerCacheService.REFRESH_TIMEOUT_MS,
);
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
const codes = cfg.invoice;
this.cached = {
LegalName: data.companyName || undefined,
Phone: data.mobilePhone || data.regularPhone || undefined,
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
// getSellerDetails' static-config fallback.
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
};
} catch (err) {
this.logger.warn(
`EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`,
);
}
}
/**
* `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only
* stops *waiting* on the request — nothing cancels the underlying HTTP call (no
* `AbortController` wired into `ETradeService`), so a timed-out request may still complete in
* the background; its result is simply never read.
*/
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
},
);
});
}
}

View File

@@ -97,8 +97,14 @@ describe("EimsSignerService", () => {
}); });
describe("EimsCredentialsProvider", () => { describe("EimsCredentialsProvider", () => {
const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => const providerFor = (cfg: {
new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); privateKeyPath?: string;
certificatePath?: string;
privateKeyBase64?: string;
certificateBase64?: string;
privateKeyPem?: string;
certificatePem?: string;
}) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService);
it("fails clearly when the key path is unset", () => { it("fails clearly when the key path is unset", () => {
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
@@ -115,4 +121,52 @@ describe("EimsCredentialsProvider", () => {
writeFileSync(emptyPath, ""); writeFileSync(emptyPath, "");
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
}); });
it("loads the key from inline base64, no file involved", () => {
const keyBase64 = readFileSync(keyPath).toString("base64");
const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("prefers inline base64 over the path when both are set", () => {
const keyBase64 = readFileSync(keyPath).toString("base64");
// A path that would fail if it were ever actually read.
const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("loads the certificate from inline base64 as-is, no re-encoding", () => {
const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64");
expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64);
});
it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => {
// Simulates the real failure this guards against: a truncated/mangled env var still decodes
// as *some* bytes, but not a key — OpenSSL's own error here gives no hint why.
const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64");
expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow(
/does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s,
);
});
it("loads the key from the raw PEM env var directly, no encoding step", () => {
const pem = readFileSync(keyPath).toString("utf8");
const key = providerFor({ privateKeyPem: pem }).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("prefers the raw PEM var over base64 and path when all three are set", () => {
const pem = readFileSync(keyPath).toString("utf8");
const key = providerFor({
privateKeyPem: pem,
privateKeyBase64: Buffer.from("garbage").toString("base64"),
privateKeyPath: join(dir, "nope.key"),
}).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("loads the certificate from the raw PEM env var, re-encoded to base64", () => {
const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64();
expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"));
});
}); });

View File

@@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentTerm: "IMMIDIATE", paymentTerm: "IMMIDIATE",
unitDefault: "PCS", unitDefault: "PCS",
buyerCountryCode: null, buyerCountryCode: null,
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
buyerRegionCodes: { "Addis Ababa": "13" }, buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
taxCodeByChargeType: {}, taxCodeByChargeType: {},
taxRateByChargeType: {}, taxRateByChargeType: {},
exciseByChargeType: {}, exciseByChargeType: {},
@@ -57,6 +59,10 @@ export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
systemType: EIMS_SYSTEM_TYPE, systemType: EIMS_SYSTEM_TYPE,
privateKeyPath: "/dev/null", privateKeyPath: "/dev/null",
certificatePath: "/dev/null", certificatePath: "/dev/null",
privateKeyBase64: "",
certificateBase64: "",
privateKeyPem: "",
certificatePem: "",
httpTimeoutMs: 30_000, httpTimeoutMs: 30_000,
tokenSkewMs: 45_000, tokenSkewMs: 45_000,
autoSubmit: false, autoSubmit: false,

View File

@@ -10,7 +10,9 @@ export type EimsFailureKind =
| "FORBIDDEN" | "FORBIDDEN"
| "RULE_VALIDATION" | "RULE_VALIDATION"
| "SERVER" | "SERVER"
| "UNKNOWN"; | "UNKNOWN"
| "CONFIG"
| "LOCAL";
/** Raised when EIMS is disabled or its credential files are unusable. */ /** Raised when EIMS is disabled or its credential files are unusable. */
export class EimsConfigException extends ServiceUnavailableException { export class EimsConfigException extends ServiceUnavailableException {

View File

@@ -3,6 +3,8 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity"; import { Invoice } from "../billing/entities/invoice.entity";
import { DocumentsModule } from "../billing/documents/documents.module";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { NotificationsModule } from "../notifications/notifications.module"; import { NotificationsModule } from "../notifications/notifications.module";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
@@ -13,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsInvoiceController } from "./eims-invoice.controller"; import { EimsInvoiceController } from "./eims-invoice.controller";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service"; import { EimsReceiptService } from "./eims-receipt.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSignerService } from "./eims-signer.service"; import { EimsSignerService } from "./eims-signer.service";
import { EimsReceipt } from "./entities/eims-receipt.entity"; import { EimsReceipt } from "./entities/eims-receipt.entity";
import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity";
@@ -29,6 +32,13 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]), TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
NotificationInboxModule, NotificationInboxModule,
NotificationsModule, NotificationsModule,
// For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
DocumentsModule,
// For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with
// ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule,
// so this stays a plain one-directional import, not a new cycle.
CompaniesModule,
], ],
controllers: [EimsInvoiceController], controllers: [EimsInvoiceController],
providers: [ providers: [
@@ -40,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
EimsAutoSubmitService, EimsAutoSubmitService,
EimsCancellationService, EimsCancellationService,
EimsReceiptService, EimsReceiptService,
EimsSellerCacheService,
], ],
exports: [ exports: [
EimsAuthService, EimsAuthService,

View File

@@ -53,7 +53,7 @@ export class CreateRateDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: CARGO_KINDS, enum: CARGO_KINDS,
description: description:
'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.', 'Whether a customs clearance / cancellation rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or CANCELLATION. Not stored — container fees carry a containerTypeId, bulk fees a cargoTypeId.',
}) })
@IsOptional() @IsOptional()
@IsIn([...CARGO_KINDS]) @IsIn([...CARGO_KINDS])

View File

@@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => {
).toEqual(["PER_TON"]); ).toEqual(["PER_TON"]);
}); });
it("bills the wagon cancellation fee per wagon only, whatever the cargo kind", () => {
for (const cargoKind of ["CONTAINER", "BULK"] as const) {
expect(
allowedRateUnits({ appliesTo: "OTHER", trigger: "CANCELLATION", cargoKind }),
).toEqual(["PER_WAGON"]);
}
expect(
allowedRateUnits({
appliesTo: "OTHER",
trigger: "CANCELLATION",
cargoKind: "BULK",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_WAGON"]);
});
it("treats per-ton and per-item as the same booking quantity", () => { it("treats per-ton and per-item as the same booking quantity", () => {
expect(isBulkQuantityUnit("PER_TON")).toBe(true); expect(isBulkQuantityUnit("PER_TON")).toBe(true);
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true); expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);

View File

@@ -16,8 +16,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
* Which rate units make sense for a given rate shape. The weighting basis is * Which rate units make sense for a given rate shape. The weighting basis is
* driven by the *type* of thing being billed — a container leg bills per * driven by the *type* of thing being billed — a container leg bills per
* container, bulk freight per ton, an intercity move can be per-km, a * container, bulk freight per ton, an intercity move can be per-km, a
* cancellation is a flat/per-invoice fee, and overweight is always per excess * cancellation is a per-wagon fee, and overweight is always per excess ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* only pick a unit the pricing engine knows how to apply. * only pick a unit the pricing engine knows how to apply.
* *
* A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers * A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers
@@ -29,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
export function allowedRateUnits(input: { export function allowedRateUnits(input: {
appliesTo: RateAppliesTo; appliesTo: RateAppliesTo;
trigger: RateTrigger; trigger: RateTrigger;
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */ /** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null; cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */ /** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom; cargoUnitOfMeasure?: CargoUom;
@@ -64,7 +63,9 @@ function unitsForShape(input: {
// wagon the empties ride back on, or a flat fee. // wagon the empties ride back on, or a flat fee.
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT']; return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
case 'CANCELLATION': case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE']; // Wagon cancellation fee — scales with the cancelled wagon count, so
// per wagon is the only unit the wagon-cancel flow can apply.
return ['PER_WAGON'];
case 'CUSTOMS_CLEARANCE': case 'CUSTOMS_CLEARANCE':
// Sold per cargo kind: container fees bill per box or per wagon, bulk // Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice. // fees per ton or per wagon. Billed on the booking invoice.

View File

@@ -25,6 +25,19 @@ import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.reposito
/** Categories priced per rail leg — they carry an origin → destination yard pair. */ /** Categories priced per rail leg — they carry an origin → destination yard pair. */
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY']; const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
/**
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
*/
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'CANCELLATION',
'WITH_RETURN',
'LASHING',
'FUEL',
];
/** The yard pair a rate scopes to, already validated against its direction. */ /** The yard pair a rate scopes to, already validated against its direction. */
interface YardScope { interface YardScope {
@@ -152,7 +165,11 @@ export class RatesService {
appliesTo: Rate['appliesTo'], appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'], trigger: Rate['trigger'],
): boolean { ): boolean {
return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING'; return (
this.isRouteScoped(appliesTo, trigger) ||
trigger === 'LASHING' ||
trigger === 'CANCELLATION'
);
} }
/** /**
@@ -244,10 +261,13 @@ export class RatesService {
}): void { }): void {
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input; const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input; const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') { if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
// Both fees are sold per direction + cargo kind + type: customs clearance
// per lane, the wagon cancellation fee per direction only.
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException( throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.', `A ${fee} rate must say whether it covers IMPORT or EXPORT.`,
); );
} }
// Sold per cargo kind: a container fee names the container type it covers // Sold per cargo kind: a container fee names the container type it covers
@@ -255,29 +275,29 @@ export class RatesService {
// that absence is what marks it as the bulk fee. // that absence is what marks it as the bulk fee.
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') { if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
throw new BadRequestException( throw new BadRequestException(
'A customs clearance rate must say whether it covers containers or bulk.', `A ${fee} rate must say whether it covers containers or bulk.`,
); );
} }
if (cargoKind === 'CONTAINER' && !containerTypeId) { if (cargoKind === 'CONTAINER' && !containerTypeId) {
throw new BadRequestException( throw new BadRequestException(
'A container customs clearance rate must name the container type it covers.', `A container ${fee} rate must name the container type it covers.`,
); );
} }
if (cargoKind === 'BULK' && containerTypeId) { if (cargoKind === 'BULK' && containerTypeId) {
throw new BadRequestException( throw new BadRequestException(
'A bulk customs clearance rate cannot be scoped to a container type.', `A bulk ${fee} rate cannot be scoped to a container type.`,
); );
} }
// The bulk customs fee names the commodity it covers (sugar and // The bulk fee names the commodity it covers (sugar and fertilizer
// fertilizer clear differently). // clear — and cancel — differently).
if (cargoKind === 'BULK' && !cargoTypeId) { if (cargoKind === 'BULK' && !cargoTypeId) {
throw new BadRequestException( throw new BadRequestException(
'A bulk customs clearance rate must name the bulk cargo type it covers.', `A bulk ${fee} rate must name the bulk cargo type it covers.`,
); );
} }
if (cargoKind === 'CONTAINER' && cargoTypeId) { if (cargoKind === 'CONTAINER' && cargoTypeId) {
throw new BadRequestException( throw new BadRequestException(
'A container customs clearance rate cannot be scoped to a bulk cargo type.', `A container ${fee} rate cannot be scoped to a bulk cargo type.`,
); );
} }
return; return;
@@ -547,22 +567,21 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger']; const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction. // the engine never accidentally narrows a surcharge by container/direction.
// Exceptions: customs clearance and empty-container return keep direction + // Exceptions: the directed surcharges (customs clearance, cancellation,
// container type — both are sold per lane (and per container type). // empty-container return, lashing, fuel) keep direction + cargo scope.
const isSurcharge = trigger !== 'ALWAYS'; const isSurcharge = trigger !== 'ALWAYS';
const cargoKind = const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
trigger === 'CUSTOMS_CLEARANCE' ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) : null;
: null;
const containerTypeId = const containerTypeId =
trigger === 'WITH_RETURN' || trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER') (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
? (dto.containerTypeId ?? null) ? (dto.containerTypeId ?? null)
: isSurcharge : isSurcharge
? null ? null
: (dto.containerTypeId ?? null); : (dto.containerTypeId ?? null);
const cargoTypeId = const cargoTypeId =
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
trigger === 'LASHING' || trigger === 'LASHING' ||
trigger === 'FUEL' trigger === 'FUEL'
? (dto.cargoTypeId ?? null) ? (dto.cargoTypeId ?? null)
@@ -574,10 +593,7 @@ export class RatesService {
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says // intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
// nothing about the direction.) // nothing about the direction.)
const tradeDirection = const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.tradeDirection ?? null) ? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY' : isSurcharge || appliesTo === 'INTERCITY'
? null ? null
@@ -758,16 +774,15 @@ export class RatesService {
// A patch that leaves the cargo kind unsaid keeps the one the rate already // A patch that leaves the cargo kind unsaid keeps the one the rate already
// has — read back off its container scope (container fees carry the type). // has — read back off its container scope (container fees carry the type).
const cargoKind = const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger)
trigger !== 'CUSTOMS_CLEARANCE' ? null
? null : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? (existing.containerTypeId ? 'CONTAINER' : 'BULK'));
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const keepsContainerType = const keepsContainerType =
!isSurcharge || !isSurcharge ||
trigger === 'WITH_RETURN' || trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER'); (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
const containerTypeId = !keepsContainerType const containerTypeId = !keepsContainerType
? null ? null
: dto.containerTypeId !== undefined : dto.containerTypeId !== undefined
@@ -775,7 +790,7 @@ export class RatesService {
: existing.containerTypeId; : existing.containerTypeId;
const keepsCargoType = const keepsCargoType =
!isSurcharge || !isSurcharge ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
trigger === 'LASHING' || trigger === 'LASHING' ||
trigger === 'FUEL'; trigger === 'FUEL';
const cargoTypeId = !keepsCargoType const cargoTypeId = !keepsCargoType
@@ -784,10 +799,7 @@ export class RatesService {
? dto.cargoTypeId ? dto.cargoTypeId
: existing.cargoTypeId; : existing.cargoTypeId;
const tradeDirection = const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? dto.tradeDirection !== undefined ? dto.tradeDirection !== undefined
? dto.tradeDirection ? dto.tradeDirection
: existing.tradeDirection : existing.tradeDirection

View File

@@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => {
previewTrainSchedule: jest.fn(), previewTrainSchedule: jest.fn(),
unassignBooking: jest.fn(), unassignBooking: jest.fn(),
assignBookingsToSchedule: jest.fn(), assignBookingsToSchedule: jest.fn(),
windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}),
emitWindowState: jest.fn().mockResolvedValue(undefined),
}; };
schedulingRescheduleRepository = { schedulingRescheduleRepository = {
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }), createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
@@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => {
}); });
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined); trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' }); trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' });
// An OPEN window's close must follow the new departure (this is the
// portal's "closes in" countdown) — the derived fields ride along with the
// date write.
const newCloses = new Date('2099-06-22T08:00:00.000Z');
trainSchedulingService.windowFieldsForNewDeparture.mockResolvedValue({
windowClosesAt: newCloses,
});
const result = await service.maintenanceReschedule( const result = await service.maintenanceReschedule(
'sched-1', 'sched-1',
@@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => {
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith( expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
'sched-1', 'sched-1',
'DRAFT', 'DRAFT',
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') }, {
scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'),
windowClosesAt: newCloses,
},
txManager, txManager,
); );
expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith(
schedule,
new Date('2099-06-22T10:00:00.000Z'),
);
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith( expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
trigger: 'TRAIN_MAINTENANCE', trigger: 'TRAIN_MAINTENANCE',

View File

@@ -227,17 +227,25 @@ export class SchedulingRescheduleService {
// through this manager without editing TrainSchedulingService. A failure // through this manager without editing TrainSchedulingService. A failure
// between those steps and this block can still leave partial state; a human // between those steps and this block can still leave partial state; a human
// must finish the full cross-service transaction threading. // must finish the full cross-service transaction threading.
// The booking window must follow the new departure (an OPEN window's
// "closes in" countdown is capped at departure close offset; PRE_WINDOW /
// DONE re-derive their open/close). Same math as maintenanceReschedule.
const windowFields = newDeparture
? await this.trainSchedulingService.windowFieldsForNewDeparture(
schedule,
newDeparture,
)
: {};
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
if (newDeparture) { if (newDeparture) {
// M7: raw write of scheduledDepartureDate. We deliberately do NOT // Raw write of scheduledDepartureDate: updateScheduleDate only permits a
// delegate to TrainSchedulingService.updateScheduleDate, which only // date change while windowPhase === 'PRE_WINDOW' and would reject
// permits a date change while windowPhase === 'PRE_WINDOW' and would // reschedules of already-open (SCHEDULED) trains.
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
// the booking-window fields are NOT re-derived for the new date here.
await this.trainSchedulesRepository.updateStatus( await this.trainSchedulesRepository.updateStatus(
scheduleId, scheduleId,
schedule.status as TrainScheduleStatus, schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: newDeparture }, { scheduledDepartureDate: newDeparture, ...windowFields },
manager, manager,
); );
} }
@@ -263,6 +271,7 @@ export class SchedulingRescheduleService {
// `newDeparture` is null when the date was unchanged, so retained customers // `newDeparture` is null when the date was unchanged, so retained customers
// are not falsely told the train was rescheduled. // are not falsely told the train was rescheduled.
await this.notifyRescheduleOutcome(dto, newDeparture); await this.notifyRescheduleOutcome(dto, newDeparture);
if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
return { plan, schedule: assignResult }; return { plan, schedule: assignResult };
} }

View File

@@ -10,6 +10,8 @@ import { In, Repository } from "typeorm";
import { BookingPricingService } from "../bookings/booking-pricing.service"; import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service"; import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service"; import { BookingsService } from "../bookings/bookings.service";
import type { Container20ftUnit } from "../bookings/container-pairing.util";
import { ContainerValidationService } from "../bookings/container-validation.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity"; import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity"; import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
@@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService {
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
private readonly creditsService: ShippingLineCreditsService, private readonly creditsService: ShippingLineCreditsService,
private readonly containerValidationService: ContainerValidationService,
) {} ) {}
/**
* 20ft weight-pairing check over the completion payload — the same rule the
* customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t):
* two 20ft sharing a wagon must be within the cap. Preview surfaces the
* messages; completion hard-blocks on them. Runs off the DTO so nothing is
* persisted before the check passes.
*/
private async pairingViolationMessages(
dto: CompleteShippingLineBookingDto,
): Promise<string[]> {
const units: Container20ftUnit[] = [];
for (const line of dto.containers ?? []) {
const containerType = await this.resolveContainerType(line);
if (containerType.sizeFt !== 20) continue;
(line.units ?? []).forEach((u, idx) =>
units.push({
label: u.containerNumber || `20ft-${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
}),
);
}
const violations =
await this.containerValidationService.validate20ftPairingUnits(units);
return violations.map((v) => v.message);
}
/** Same session→owner resolution every shipping-line entry point uses. */ /** Same session→owner resolution every shipping-line entry point uses. */
private async requireShippingLine(userId: string) { private async requireShippingLine(userId: string) {
const shippingLine = const shippingLine =
@@ -193,6 +222,17 @@ export class ShippingLineBookingCompletionService {
); );
} }
// Unbalanced 20ft pairs can never be planned onto wagons — refuse before
// any cargo/credit write below. Same block the contract path applies.
if (booking.freightType === "CONTAINER") {
const pairing = await this.pairingViolationMessages(dto);
if (pairing.length) {
throw new BadRequestException(
`Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`,
);
}
}
// Completion is booking time. A lane with trains DEDICATED to this line // Completion is booking time. A lane with trains DEDICATED to this line
// has no window concept at all: the line books whenever it wants until the // has no window concept at all: the line books whenever it wants until the
// train's close offset. Only a lane with no dedicated train falls back to // train's close offset. Only a lane with no dedicated train falls back to
@@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService {
computed.appliedModifiers, computed.appliedModifiers,
); );
// Pairing is reported, not thrown: the confirm modal shows it next to the
// price (as the customer form does) and disables confirm; /complete
// hard-blocks the same payload.
const pairingErrors =
booking.freightType === "CONTAINER"
? await this.pairingViolationMessages(dto)
: [];
return { return {
totalAmount: computed.totalAmount, totalAmount: computed.totalAmount,
currency: computed.currency, currency: computed.currency,
lineItems: computed.lineItems, lineItems: computed.lineItems,
warnings: computed.warnings, warnings: computed.warnings,
overweightLines: computed.overweightLines,
pairingErrors,
}; };
} }

View File

@@ -132,37 +132,33 @@ export class ShippingLineCompaniesService {
* Email always goes out — it is required at registration and is the only * Email always goes out — it is required at registration and is the only
* channel guaranteed to reach a foreign-registered line. SMS is sent in * channel guaranteed to reach a foreign-registered line. SMS is sent in
* addition when the number is domestic, since the gateway silently drops * addition when the number is domestic, since the gateway silently drops
* anything else (see `CustomerResetService`). Two links are two independent * anything else (see `CustomerResetService`). Both carry the SAME single-use
* single-use tickets; whichever the line opens first works. * ticket: minting retires earlier tickets, so two mints would kill the email
* link the moment the SMS went out.
* *
* Reports the email send, as that is the one that is always attempted. * Reports the email send, as that is the one that is always attempted.
*/ */
async sendActivationLink(shippingLine: ShippingLineCompany) { async sendActivationLink(shippingLine: ShippingLineCompany) {
const scope = `shipping line ${shippingLine.id}`; const scope = `shipping line ${shippingLine.id}`;
const channels = [ResetChannel.Email];
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
channels.push(ResetChannel.Phone);
}
const emailed = await this.customerResetService.sendResetLinkToUser( const sent = await this.customerResetService.sendResetLinkToUserOnChannels(
shippingLine.userId, shippingLine.userId,
ResetChannel.Email, channels,
{ scope, allowWithoutCredential: true }, { scope, allowWithoutCredential: true },
); );
const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null;
if (!emailed) { if (!emailed) {
this.logger.error( this.logger.error(
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`, `Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
); );
} }
if (channels.includes(ResetChannel.Phone) && !sent.some((s) => s.channel === ResetChannel.Phone)) {
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) { this.logger.warn(`Activation SMS not sent for shipping line ${shippingLine.id}`);
const texted = await this.customerResetService.sendResetLinkToUser(
shippingLine.userId,
ResetChannel.Phone,
{ scope, allowWithoutCredential: true },
);
if (!texted) {
this.logger.warn(
`Activation SMS not sent for shipping line ${shippingLine.id}`,
);
}
} }
return emailed; return emailed;

View File

@@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
import { import {
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res, Body, Controller, Delete, Get, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, Res,
} from "@nestjs/common"; } from "@nestjs/common";
import { CurrentUser } from "@edr/api-common"; import { CurrentUser } from "@edr/api-common";
import { import {
@@ -37,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto"; import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto"; import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto"; import {
DispatchScheduleDto,
RecordCheckpointDto,
UpdateCheckpointDto,
} from "../dto/record-checkpoint.dto";
import { import {
ImportDjiboutiActionDto, ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto, UploadImportDjiboutiDocumentDto,
@@ -516,9 +520,14 @@ export class TrainSchedulingController {
@Post("schedules/:id/dispatch") @Post("schedules/:id/dispatch")
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch) @BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
@ApiOperation({ summary: "Dispatch a scheduled train" }) @ApiOperation({
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) { summary: "Dispatch a scheduled train (optional actual departure time, past allowed)",
return this.trainSchedulingService.dispatchSchedule(id); })
dispatchSchedule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: DispatchScheduleDto,
) {
return this.trainSchedulingService.dispatchSchedule(id, dto);
} }
@Get("intercity/bookings") @Get("intercity/bookings")
@@ -956,6 +965,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.recordCheckpoint(id, dto); return this.trainSchedulingService.recordCheckpoint(id, dto);
} }
@Patch("schedules/:id/checkpoints/:sequenceNo")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Edit a logged leg's time/note (no side effects; allowed while dispatched or after arrival)",
})
updateCheckpoint(
@Param("id", ParseUUIDPipe) id: string,
@Param("sequenceNo", ParseIntPipe) sequenceNo: number,
@Body() dto: UpdateCheckpointDto,
) {
return this.trainSchedulingService.updateCheckpoint(id, sequenceNo, dto);
}
@Post("schedules/:id/arrive") @Post("schedules/:id/arrive")
@TrainSchedulingUpdate() @TrainSchedulingUpdate()
@ApiOperation({ @ApiOperation({

View File

@@ -10,8 +10,6 @@ import {
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
export class RecordCheckpointDto { export class RecordCheckpointDto {
@ApiProperty({ description: 'Station position along the route (0 = origin).' }) @ApiProperty({ description: 'Station position along the route (0 = origin).' })
@IsInt() @IsInt()
@@ -24,17 +22,17 @@ export class RecordCheckpointDto {
kind?: TrainCheckpointKind; kind?: TrainCheckpointKind;
/** /**
* A checkpoint records where the train is as staff observe it, and the final * When the train was actually at the station — staff often log after the
* one arrives the schedule — so a backdated value rewrites the journey after * fact, so a past value is allowed. The service rejects the future and any
* the fact. Only "now" is accepted; omit the field and the service stamps it. * value out of order with the neighbouring legs.
*/ */
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.', description:
'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.',
}) })
@IsOptional() @IsOptional()
@IsISO8601() @IsISO8601()
@IsNotBackdated()
occurredAt?: string; occurredAt?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@@ -43,3 +41,30 @@ export class RecordCheckpointDto {
@MaxLength(500) @MaxLength(500)
note?: string; note?: string;
} }
/** Edit an already-logged leg's time/note — no side effects (no unload, no arrival). */
export class UpdateCheckpointDto {
@ApiProperty({
required: false,
description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.',
})
@IsOptional()
@IsISO8601()
occurredAt?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string | null;
}
export class DispatchScheduleDto {
@ApiProperty({
required: false,
description: 'Actual departure time; defaults to now. Past allowed, future rejected.',
})
@IsOptional()
@IsISO8601()
actualDepartureAt?: string;
}

View File

@@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => {
let wagonBookingAllocationsRepository: Record<string, jest.Mock>; let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>; let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>; let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
let trainCheckpointEventsRepository: Record<string, jest.Mock>;
beforeEach(() => { beforeEach(() => {
// findGroupSiblings runs a query builder off dataSource.manager; default it // findGroupSiblings runs a query builder off dataSource.manager; default it
@@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => {
findByIdWithFullGraph: jest.fn(), findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(), findAll: jest.fn(),
updateStatus: jest.fn(), updateStatus: jest.fn(),
update: jest.fn(),
maxReferenceSequence: jest.fn().mockResolvedValue(0), maxReferenceSequence: jest.fn().mockResolvedValue(0),
}; };
trainScheduleBookingsRepository = { trainScheduleBookingsRepository = {
@@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => {
findAll: jest.fn().mockResolvedValue([]), findAll: jest.fn().mockResolvedValue([]),
}; };
const trainCheckpointEventsRepository = { trainCheckpointEventsRepository = {
findBySchedule: jest.fn().mockResolvedValue([]), findBySchedule: jest.fn().mockResolvedValue([]),
findAll: jest.fn().mockResolvedValue([]), findAll: jest.fn().mockResolvedValue([]),
create: jest.fn(), create: jest.fn(),
@@ -1638,6 +1640,61 @@ describe('TrainSchedulingService', () => {
}); });
}); });
describe('updateCheckpoint — leg time correction', () => {
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
const schedule = {
id: 'sch-track',
status: 'ARRIVED',
routeId: null,
originStationId: 'y0',
destinationStationId: 'y1',
actualDepartureAt: t(8),
};
const events = () => [
{ id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) },
{ id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) },
];
beforeEach(() => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events());
});
it('rejects a leg time earlier than the previous leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }),
).rejects.toThrow(/cannot be earlier than/);
expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled();
});
it('rejects a leg time later than the next leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }),
).rejects.toThrow(/cannot be later than/);
});
it('rejects a future time', async () => {
const future = new Date(Date.now() + 3_600_000).toISOString();
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: future }),
).rejects.toThrow(/future/);
});
it('accepts an in-order past time and re-stamps arrival for the final leg', async () => {
await service.updateCheckpoint('sch-track', 1, {
occurredAt: t(11).toISOString(),
note: 'late log',
});
expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', {
occurredAt: t(11),
note: 'late log',
});
expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', {
actualArrivalAt: t(11),
});
});
});
describe('effectiveWagonsRequired', () => { describe('effectiveWagonsRequired', () => {
const effective = (booking: unknown): number => const effective = (booking: unknown): number =>
(service as never as { effectiveWagonsRequired(b: unknown): number }) (service as never as { effectiveWagonsRequired(b: unknown): number })
@@ -1764,5 +1821,31 @@ describe('TrainSchedulingService', () => {
expect(written.windowPhase).toBeUndefined(); expect(written.windowPhase).toBeUndefined();
expect(written.windowOpensAt).toBeUndefined(); expect(written.windowOpensAt).toBeUndefined();
}); });
it('moves an OPEN export close to the new departure but keeps the open', async () => {
const opensAt = new Date('2027-06-19T03:00:00.000Z');
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
doneExportSchedule({
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
windowOpensAt: opensAt,
windowClosesAt: new Date('2027-06-20T03:00:00.000Z'),
}),
);
// Departure pushed 3 days later → close = new departure 120min; the
// open customers already booked against stays untouched.
await service.maintenanceReschedule('sch-done', {
newDepartureDate: '2027-06-23T05:00:00.000Z',
} as never);
const written = scheduleUpdate.mock.calls[0][1];
expect(written.scheduledDepartureDate).toEqual(
new Date('2027-06-23T05:00:00.000Z'),
);
expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z'));
expect(written.windowOpensAt).toBeUndefined();
expect(written.windowPhase).toBeUndefined();
});
}); });
}); });

View File

@@ -164,6 +164,8 @@ import {
} from '../booking-batch.constants'; } from '../booking-batch.constants';
import { orderConsistWagons } from '../consist-order.util'; import { orderConsistWagons } from '../consist-order.util';
import { import {
bookingCloseCutoff,
clampCloseToOfficeHours,
computeExportWindowTimes, computeExportWindowTimes,
computeImportWindowTimes, computeImportWindowTimes,
earliestSchedulableDeparture, earliestSchedulableDeparture,
@@ -174,7 +176,11 @@ import {
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service'; import { BookingJourneyService } from '../booking-journey.service';
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository'; import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto'; import {
DispatchScheduleDto,
RecordCheckpointDto,
UpdateCheckpointDto,
} from '../dto/record-checkpoint.dto';
import { RouteMilestone } from '../../routes/entities/route-milestone.entity'; import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util'; import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service'; import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
@@ -469,7 +475,7 @@ export class TrainSchedulingService {
* used for lifecycle changes outside the window tick (create, cancel, * used for lifecycle changes outside the window tick (create, cancel,
* finalize, restamp). A push failure must never break the mutation. * finalize, restamp). A push failure must never break the mutation.
*/ */
private async emitWindowState(scheduleId: string): Promise<void> { async emitWindowState(scheduleId: string): Promise<void> {
try { try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId); const fresh = await this.trainSchedulesRepository.findById(scheduleId);
// Dedicated shipping-line departures are never announced to the portal — // Dedicated shipping-line departures are never announced to the portal —
@@ -1155,66 +1161,73 @@ export class TrainSchedulingService {
} }
/** /**
* Maintenance reschedule: the admin moves a train (with everything aboard) to * Booking-window fields that must follow a train's departure moving to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window * `departure` (any window phase). Shared by every reschedule path so the
* phase and inside the booking lead window — a maintenance move is an * "closes in" countdown always tracks the real departure.
* operational fact, not a planning choice. What moves and what stays:
* *
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every * PRE_WINDOW: the stamped open/close were derived from the old departure
* aboard/targeted booking's scheduledDate (the day-pool queries key on it, * and the window hasn't opened yet, so re-derive them from the schedule's
* so a booking left on the old day would fall out of its own train's pool). * own rule snapshot against the new date (joining the target day's route
* - STAYS: train set, wagon assignments, schedule↔booking links, route, * group timeline when one exists, exactly like updateScheduleDate).
* maxWagons, and the window RULE snapshot. Stamped window times are only
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
* schedule mid- or post-window keeps its timeline untouched.
* *
* Customers of every moved booking are notified (maintenanceMoved). * DONE: the window already finished (e.g. the close offset hit and then the
* train was moved to a later departure). The window must follow the new
* departure, so it REOPENS: re-derive open/close the same way, reset the
* phase to PRE_WINDOW and clamp a past open into the present so the tick
* opens it immediately. A FULL train stays closed — there is nothing left
* to sell — and so does one whose re-derived window would already be over.
*
* OPEN: customers are already booking against the open they were shown, so
* the open stays put — but the close was capped at the OLD departure's
* cutoff, so it must follow the new one (import: open + duration under
* office hours, capped at the cutoff; export: the cutoff itself). Moving
* the train later extends the "closes in" countdown, moving it earlier
* shortens it (a close now in the past is picked up by the next tick).
*
* DOC_REVIEW/PAYMENT keep their running timeline.
*/ */
async maintenanceReschedule( async windowFieldsForNewDeparture(
id: string, schedule: TrainSchedule,
dto: MaintenanceRescheduleDto, departure: Date,
): Promise<TrainSchedule> { ): Promise<
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); Partial<
if (!schedule) { Pick<
throw new NotFoundException(`Train schedule ${id} not found`); TrainSchedule,
} | 'windowOpensAt'
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { | 'windowClosesAt'
throw new BadRequestException( | 'windowPhase'
`Cannot reschedule a ${schedule.status.toLowerCase()} train`, | 'bookingWindowStatus'
); | 'docReviewCompletedAt'
} | 'docReviewEndsAt'
| 'paymentPhaseEndsAt'
const departure = new Date(dto.newDepartureDate); >
if (Number.isNaN(departure.getTime())) { >
throw new BadRequestException('Invalid departure date.'); > {
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
// PRE_WINDOW: the stamped open/close were derived from the old departure
// and the window hasn't opened yet, so re-derive them from the schedule's
// own rule snapshot against the new date (joining the target day's route
// group timeline when one exists, exactly like updateScheduleDate).
//
// DONE: the window already finished (e.g. the close offset hit and then the
// train was moved to a later departure). The window must follow the new
// departure, so it REOPENS: re-derive open/close the same way, reset the
// phase to PRE_WINDOW and clamp a past open into the present so the tick
// opens it immediately. A FULL train stays closed — there is nothing left
// to sell — and so does one whose re-derived window would already be over.
//
// Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline.
const reopenFromDone = const reopenFromDone =
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL'; schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
const shiftOpenClose =
schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null;
const windowFields = const windowFields =
schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone shiftOpenClose
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
await this.getWindowConfig(),
);
const opensAt = schedule.windowOpensAt!;
const cutoff = bookingCloseCutoff(departure, schedule.direction, merged);
let closesAt = cutoff;
if (schedule.direction !== 'EXPORT') {
closesAt = clampCloseToOfficeHours(
opensAt,
new Date(opensAt.getTime() + merged.windowDurationHours * 3_600_000),
merged,
);
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
}
return { windowClosesAt: closesAt };
})()
: schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
? await (async () => { ? await (async () => {
const merged = effectiveWindowConfig( const merged = effectiveWindowConfig(
schedule, schedule,
@@ -1262,6 +1275,55 @@ export class TrainSchedulingService {
}; };
})() })()
: {}; : {};
return windowFields;
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); an
* OPEN schedule keeps its open but its close follows the new departure;
* DOC_REVIEW/PAYMENT keep their timeline untouched.
*
* Customers of every moved booking are notified (maintenanceMoved).
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
const windowFields = await this.windowFieldsForNewDeparture(schedule, departure);
await this.dataSource.getRepository(TrainSchedule).update(id, { await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure, scheduledDepartureDate: departure,
@@ -1306,7 +1368,7 @@ export class TrainSchedulingService {
* in the future) using the CURRENT global-rules config. Schedules already OPEN or * in the future) using the CURRENT global-rules config. Schedules already OPEN or
* past their window are left untouched — customers may have booked against the * past their window are left untouched — customers may have booked against the
* times they were shown, so those stay frozen. Returns the count re-stamped. * times they were shown, so those stay frozen. Returns the count re-stamped.
*/ */
async restampPendingWindows(): Promise<number> { async restampPendingWindows(): Promise<number> {
const cfg = await this.getWindowConfig(); const cfg = await this.getWindowConfig();
const now = new Date(); const now = new Date();
@@ -2160,7 +2222,15 @@ export class TrainSchedulingService {
return { ...detail, warnings, deferredBookings }; return { ...detail, warnings, deferredBookings };
} }
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) { async unassignBooking(
scheduleId: string,
bookingId: string,
userId?: string,
opts: {
/** false = system detach (e.g. booking cancelled) — no "removed from train, rebook" notice. */
notifyCustomer?: boolean;
} = {},
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`); throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -2287,7 +2357,9 @@ export class TrainSchedulingService {
const removedBooking = await this.dataSource const removedBooking = await this.dataSource
.getRepository(Booking) .getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { company: true } }); .findOne({ where: { id: bookingId }, relations: { company: true } });
if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking); if (removedBooking && opts.notifyCustomer !== false) {
this.bookingNotifier.removedFromTrain(removedBooking);
}
this.logger.log( this.logger.log(
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
); );
@@ -2635,7 +2707,7 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId); return this.getTrainScheduleById(scheduleId);
} }
async dispatchSchedule(scheduleId: string) { async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`); throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -2643,6 +2715,9 @@ export class TrainSchedulingService {
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
throw new BadRequestException('Only SCHEDULED trains can be dispatched'); throw new BadRequestException('Only SCHEDULED trains can be dispatched');
} }
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');
await this.assertImportDjiboutiMayDepart(schedule); await this.assertImportDjiboutiMayDepart(schedule);
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon) // Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
// never blocks departure — the dispatch confirm dialog warns and staff decide. // never blocks departure — the dispatch confirm dialog warns and staff decide.
@@ -2667,7 +2742,6 @@ export class TrainSchedulingService {
} }
} }
const now = new Date();
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule); const trainNumber = await this.assignTrainNumber(manager, schedule);
if (setLocomotiveIds.length) { if (setLocomotiveIds.length) {
@@ -4150,6 +4224,7 @@ export class TrainSchedulingService {
? TrainCheckpointKind.Arrived ? TrainCheckpointKind.Arrived
: TrainCheckpointKind.Passed); : TrainCheckpointKind.Passed);
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt);
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
const [existing] = await this.trainCheckpointEventsRepository.findAll({ const [existing] = await this.trainCheckpointEventsRepository.findAll({
@@ -4173,8 +4248,14 @@ export class TrainSchedulingService {
}); });
} }
// The origin DEPARTED checkpoint IS the departure — keep the schedule's
// headline timestamp on the same clock the operator just entered.
if (dto.sequenceNo === 0) {
await this.trainSchedulesRepository.update(scheduleId, { actualDepartureAt: occurredAt });
}
if (dto.sequenceNo === finalSeq) { if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId); await this.arriveSchedule(scheduleId, occurredAt);
} else { } else {
// Mid-corridor auto-unload: bookings destined for this yard alight the // Mid-corridor auto-unload: bookings destined for this yard alight the
// moment the train is recorded here — the yard operator no longer has to // moment the train is recorded here — the yard operator no longer has to
@@ -4210,11 +4291,133 @@ export class TrainSchedulingService {
return this.getScheduleCheckpoints(scheduleId); return this.getScheduleCheckpoints(scheduleId);
} }
/**
* Correct an already-logged leg's time/note. Pure edit: no auto-unload, no
* position fix, no arrival — those already happened when the leg was logged.
* Allowed on DISPATCHED and ARRIVED trains (a journey is corrected after the
* fact as often as during it). The origin/final legs also re-stamp the
* schedule's departure/arrival so the headline figures follow the edit.
*/
async updateCheckpoint(scheduleId: string, sequenceNo: number, dto: UpdateCheckpointDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (
schedule.status !== TrainScheduleStatusEnum.Dispatched &&
schedule.status !== TrainScheduleStatusEnum.Arrived
) {
throw new BadRequestException('Only DISPATCHED or ARRIVED trains have checkpoints to edit');
}
const stations = await this.buildScheduleStations(schedule);
const station = stations.find((s) => s.sequenceNo === sequenceNo);
if (!station) {
throw new BadRequestException(`Station ${sequenceNo} is not on this route`);
}
// Match by yard, like getScheduleCheckpoints — legacy rows may carry an
// older station numbering.
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const existing =
events.find((e) => e.yardId === station.yardId) ??
events.find((e) => e.sequenceNo === sequenceNo);
if (!existing) {
throw new BadRequestException(`Station ${station.label} has not been logged yet`);
}
const patch: Partial<TrainCheckpointEvent> = {};
if (dto.occurredAt) {
const occurredAt = new Date(dto.occurredAt);
await this.assertCheckpointTime(schedule, stations, sequenceNo, occurredAt, existing.id);
patch.occurredAt = occurredAt;
}
if (dto.note !== undefined) patch.note = dto.note;
if (Object.keys(patch).length) {
await this.trainCheckpointEventsRepository.update(existing.id, patch);
}
if (patch.occurredAt) {
const finalSeq = stations[stations.length - 1].sequenceNo;
if (sequenceNo === 0) {
await this.trainSchedulesRepository.update(scheduleId, {
actualDepartureAt: patch.occurredAt,
});
} else if (sequenceNo === finalSeq && schedule.status === TrainScheduleStatusEnum.Arrived) {
await this.trainSchedulesRepository.update(scheduleId, {
actualArrivalAt: patch.occurredAt,
});
}
}
return this.getScheduleCheckpoints(scheduleId);
}
private assertNotFuture(at: Date, what: string) {
if (Number.isNaN(at.getTime())) {
throw new BadRequestException(`${what} is not a valid date`);
}
// Small skew allowance so an honest "now" from a client clock passes.
if (at.getTime() > Date.now() + 60_000) {
throw new BadRequestException(`${what} cannot be in the future`);
}
}
/**
* A leg's time must not be in the future and must sit in corridor order:
* no earlier than every logged leg before it (and the dispatch time, for
* legs after the origin), no later than every logged leg after it.
* `ignoreEventId` excludes the row being edited from its own bounds.
*/
private async assertCheckpointTime(
schedule: TrainSchedule,
stations: { sequenceNo: number; yardId: string; label: string }[],
sequenceNo: number,
occurredAt: Date,
ignoreEventId?: string,
) {
this.assertNotFuture(occurredAt, 'Checkpoint time');
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const labelBySeq = new Map(stations.map((s) => [s.sequenceNo, s.label]));
const events = (await this.trainCheckpointEventsRepository.findBySchedule(schedule.id)).filter(
(e) => e.id !== ignoreEventId,
);
const seqOf = (e: TrainCheckpointEvent) => seqByYard.get(e.yardId) ?? e.sequenceNo;
const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
let floor: { at: Date; label: string } | null = null;
let ceil: { at: Date; label: string } | null = null;
for (const e of events) {
const s = seqOf(e);
if (s < sequenceNo && (!floor || e.occurredAt > floor.at)) {
floor = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
}
if (s > sequenceNo && (!ceil || e.occurredAt < ceil.at)) {
ceil = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
}
}
// The origin leg rewrites the departure itself; every later leg must
// follow it.
if (sequenceNo > 0 && schedule.actualDepartureAt && (!floor || schedule.actualDepartureAt > floor.at)) {
floor = { at: schedule.actualDepartureAt, label: 'departure' };
}
if (floor && occurredAt < floor.at) {
throw new BadRequestException(
`Checkpoint time cannot be earlier than ${floor.label} (${fmt(floor.at)})`,
);
}
if (ceil && occurredAt > ceil.at) {
throw new BadRequestException(
`Checkpoint time cannot be later than ${ceil.label} (${fmt(ceil.at)})`,
);
}
}
/** /**
* Mark a dispatched train arrived: close out the schedule, move the locomotive * Mark a dispatched train arrived: close out the schedule, move the locomotive
* and wagons to the destination yard, and free the assets for re-use. * and wagons to the destination yard, and free the assets for re-use.
*/ */
async arriveSchedule(scheduleId: string) { async arriveSchedule(scheduleId: string, arrivedAt?: Date) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`); throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -4223,7 +4426,9 @@ export class TrainSchedulingService {
throw new BadRequestException('Only DISPATCHED trains can arrive'); throw new BadRequestException('Only DISPATCHED trains can arrive');
} }
const now = new Date(); // The arrival clock: the operator's entered time when arriving via the final
// checkpoint (already order/future-checked there), else now.
const now = arrivedAt ?? new Date();
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus( await this.trainSchedulesRepository.updateStatus(
@@ -4409,6 +4614,7 @@ export class TrainSchedulingService {
originStation: true, originStation: true,
destinationStation: true, destinationStation: true,
scheduleBookings: { booking: true }, scheduleBookings: { booking: true },
shippingLineCompany: true,
}, },
order: { [sortBy]: sortOrder } as never, order: { [sortBy]: sortOrder } as never,
skip, skip,
@@ -6107,6 +6313,10 @@ export class TrainSchedulingService {
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination: destination:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
// Dedicated shipping-line departure (hidden from customers) — the list
// highlights these rows so staff can tell them apart at a glance.
shippingLineCompanyId: schedule.shippingLineCompanyId ?? null,
shippingLineCompanyName: schedule.shippingLineCompany?.name ?? null,
// Built train (Train Builder) behind this departure, when scheduled by train. // Built train (Train Builder) behind this departure, when scheduled by train.
train: schedule.trainSet?.train train: schedule.trainSet?.train
? { ? {
@@ -8997,40 +9207,101 @@ export class TrainSchedulingService {
} }
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation); const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
// Cargo type → allowed wagon types rides along: for bulk, the commodity's
// own wagon-type list (the planner's rule) decides, not only the wagon
// type's generic supportedLoadTypes.
const loadAllocations = (trainSetWagonId: string) => const loadAllocations = (trainSetWagonId: string) =>
allocRepo.find({ where: { trainSetWagonId } }); allocRepo.find({
where: { trainSetWagonId },
relations: { booking: { cargoType: { wagonTypes: true } } },
});
const sourceAllocs = await loadAllocations(source.id); const sourceAllocs = await loadAllocations(source.id);
if (!sourceAllocs.length) { if (!sourceAllocs.length) {
throw new BadRequestException('Source wagon has no load to move'); throw new BadRequestException('Source wagon has no load to move');
} }
// Target: a slot of this train set, or an empty consist-only wagon of the // Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing —
// built train (physical wagon with no slot row yet). // Gelan→Adama and Adama→Doraleh loads ride the same wagon in two slots), so
// "the slot on that wagon" only means the one whose leg overlaps the moving
// load's leg. Null board/alight = the schedule's own endpoints.
const stops = await this.stopYardsForSchedule(schedule);
const spanOf = (slot: {
boardYardId?: string | null;
alightYardId?: string | null;
}): [number, number] => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : stops.length - 1;
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
};
const overlaps = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1];
const sourceSpan = spanOf(source);
// Target: a slot of this train set, or a physical wagon of this train —
// coupled-but-empty consist wagon (built train), or a wagon already pinned
// by another slot of this set (then: the overlapping-leg slot, or a fresh
// slot for a free leg).
const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null; const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
const wagonForTarget = slotById let wagonForTarget: Wagon | null = null;
? null if (!slotById) {
: schedule.trainSet?.trainId const wagon = await this.dataSource.getRepository(Wagon).findOne({
? await this.dataSource.getRepository(Wagon).findOne({ where: { id: dto.targetWagonId },
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId }, relations: { wagonType: true },
relations: { wagonType: true }, });
}) const onThisTrain =
: null; !!wagon &&
((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) ||
slots.some((w) => w.physicalWagonId === wagon.id));
wagonForTarget = onThisTrain ? wagon : null;
}
if (!slotById && !wagonForTarget) { if (!slotById && !wagonForTarget) {
throw new NotFoundException('Target wagon is not part of this schedule'); throw new NotFoundException('Target wagon is not part of this schedule');
} }
// A physical wagon holds at most one slot. When the caller addressed the
// wagon directly but a slot is already pinned to it, move into that slot
// rather than minting a second one on the same wagon.
const targetSlot = const targetSlot =
slotById ?? slotById ??
(wagonForTarget (wagonForTarget
? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null) ? (slots.find(
(w) =>
w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan),
) ?? null)
: null); : null);
const consistWagon = targetSlot ? null : wagonForTarget; const consistWagon = targetSlot ? null : wagonForTarget;
// Leg clash guard: after the move, no two slots on one physical wagon may
// ride the same edge. Source load → target wagon; on a swap, target load →
// source wagon.
const targetPhysicalId = targetSlot?.physicalWagonId ?? consistWagon?.id ?? null;
const clashOn = (
physicalWagonId: string | null,
excludeSlotId: string | null,
span: [number, number],
) =>
!!physicalWagonId &&
slots.some(
(w) =>
w.physicalWagonId === physicalWagonId &&
w.id !== excludeSlotId &&
w.id !== source.id &&
(w.allocations?.length ?? 0) > 0 &&
overlaps(spanOf(w), span),
);
if (clashOn(targetPhysicalId, targetSlot?.id ?? null, sourceSpan)) {
throw new BadRequestException(
'That wagon already carries another load on the same leg — pick a wagon free on that leg.',
);
}
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
if (targetSlot && targetSlot.id === source.id) { if (targetSlot && targetSlot.id === source.id) {
return this.getTrainScheduleById(scheduleId); return this.getTrainScheduleById(scheduleId);
} }
if (
targetSlot &&
targetAllocs.length &&
clashOn(source.physicalWagonId ?? null, source.id, spanOf(targetSlot))
) {
throw new BadRequestException(
'Swap refused: the source wagon already carries another load on the incoming loads leg.',
);
}
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
@@ -9043,10 +9314,25 @@ export class TrainSchedulingService {
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`; slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) => const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon'); slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
// Bulk is allowed on a wagon type when every bulk load's cargo type lists
// it (cargo-type ↔ wagon-type config, same rule the wagon planner uses).
const bulkCargoAllows = (allocs: WagonBookingAllocation[], wagonTypeId?: string) => {
const bulk = allocs.filter((a) => (a.loadType ?? 'CONTAINER').toUpperCase() === 'BULK');
return (
!!wagonTypeId &&
bulk.length > 0 &&
bulk.every((a) =>
(a.booking?.cargoType?.wagonTypes ?? []).some((wt) => wt.id === wagonTypeId),
)
);
};
const checkReceives = ( const checkReceives = (
allocs: WagonBookingAllocation[], allocs: WagonBookingAllocation[],
label: string, label: string,
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined, wagonType:
| { id?: string; code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean }
| null
| undefined,
capacityTons: number, capacityTons: number,
) => { ) => {
const incoming = loadTypesOf(allocs); const incoming = loadTypesOf(allocs);
@@ -9057,6 +9343,7 @@ export class TrainSchedulingService {
const ok = const ok =
supported.includes(loadType) || supported.includes(loadType) ||
(loadType === 'CONTAINER' && wagonType.supportsContainer) || (loadType === 'CONTAINER' && wagonType.supportsContainer) ||
(loadType === 'BULK' && bulkCargoAllows(allocs, wagonType.id)) ||
supported.length === 0; supported.length === 0;
if (!ok) { if (!ok) {
throw new BadRequestException( throw new BadRequestException(

View File

@@ -121,8 +121,46 @@ export class WagonsService {
* (yard workspace, coupling pickers) walk the pages client-side — see * (yard workspace, coupling pickers) walk the pages client-side — see
* `wagonService.listAll` in the backoffice. * `wagonService.listAll` in the backoffice.
*/ */
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> { async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
await this.attachStatusDates(page.items);
return page;
}
/**
* Latest status-flip dates from the audit log, for the wagons desk columns:
* when the wagon last went to MAINTENANCE and when it last became AVAILABLE.
* One grouped query per page; null when the log has no such flip.
*/
private async attachStatusDates(wagons: Wagon[]): Promise<void> {
if (!wagons.length) return;
const rows: Array<{
wagonId: string;
lastMaintenanceAt: Date | null;
lastAvailableAt: Date | null;
}> = await this.dataSource
.getRepository(WagonStatusLog)
.createQueryBuilder('l')
.select('l.wagon_id', 'wagonId')
.addSelect(
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Maintenance}')`,
'lastMaintenanceAt',
)
.addSelect(
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Available}')`,
'lastAvailableAt',
)
.where('l.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
.groupBy('l.wagon_id')
.getRawMany();
const byId = new Map(rows.map((r) => [r.wagonId, r]));
for (const w of wagons) {
const r = byId.get(w.id);
Object.assign(w, {
lastMaintenanceAt: r?.lastMaintenanceAt ?? null,
lastAvailableAt: r?.lastAvailableAt ?? null,
});
}
} }
async findById(id: string): Promise<Wagon> { async findById(id: string): Promise<Wagon> {

View File

@@ -569,6 +569,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:invoices:eims_receipt_register", "edr_freight_app:invoices:eims_receipt_register",
"Register a sales or withholding receipt with MoR EIMS", "Register a sales or withholding receipt with MoR EIMS",
), ),
// Issuing a credit/debit memo is itself filing-equivalent — auto-submit picks it up like any
// other issued invoice — so it carries the same restricted grant as the eims_* actions above,
// not invoices:export.
perm(
"d2b00001-0001-4000-8000-00000000000a",
"edr_freight_app:invoices:memo_issue",
"Issue a credit or debit memo against a registered invoice",
),
// USD bookings are paid by bank transfer; Finance uploads the slip and settles // USD bookings are paid by bank transfer; Finance uploads the slip and settles
// the invoice. Moves money state, so it is its own grant, not part of view. // the invoice. Moves money state, so it is its own grant, not part of view.
perm( perm(
@@ -1874,6 +1882,7 @@ export const FREIGHT_PERMS = {
eimsResolve: "edr_freight_app:invoices:eims_resolve", eimsResolve: "edr_freight_app:invoices:eims_resolve",
eimsCancel: "edr_freight_app:invoices:eims_cancel", eimsCancel: "edr_freight_app:invoices:eims_cancel",
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register", eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
memoIssue: "edr_freight_app:invoices:memo_issue",
confirmOffline: "edr_freight_app:invoices:confirm_offline", confirmOffline: "edr_freight_app:invoices:confirm_offline",
}, },
firstMile: { firstMile: {
@@ -2405,10 +2414,14 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.invoices.export,
// Manual settlement (bank transfer / counter) of USD and ETB invoices.
FREIGHT_PERMS.invoices.confirmOffline,
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
// eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so // eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
// filing is not a Finance job function — the endpoints exist for controlled testing and // (the cron sweep runs as the system); these are the *manual* exceptional-operations
// exceptional operations, and are assigned to named admins rather than a role preset. // endpoints, and stay off the general Finance role. They are granted to the `chief` position
// instead — see below — the same makerchecker split already used for shipping-line credit
// mark-paid/cancel (Finance raises, chief decides).
FREIGHT_PERMS.payments.view, FREIGHT_PERMS.payments.view,
FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationView,
// Shipping-line credit ledger is a Finance surface: bill batches into // Shipping-line credit ledger is a Finance surface: bill batches into
@@ -2525,6 +2538,14 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.governmentExpedite, FREIGHT_PERMS.bookings.governmentExpedite,
FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.invoices.export,
// Manual MoR EIMS actions and credit/debit memo issuance: kept off the general Finance role
// (see that preset's comment) and granted here instead — the chief is already the decision
// side of every other sensitive finance action (mark-paid/cancel approval below), and these
// are irreversible-at-MoR or receivable-creating in the same way.
FREIGHT_PERMS.invoices.eimsCancel,
FREIGHT_PERMS.invoices.eimsResolve,
FREIGHT_PERMS.invoices.eimsReceiptRegister,
FREIGHT_PERMS.invoices.memoIssue,
FREIGHT_PERMS.payments.view, FREIGHT_PERMS.payments.view,
// Decision side of the credit-invoice two-step: finance raises // Decision side of the credit-invoice two-step: finance raises
// mark-paid/cancel requests, the chief approves or rejects them. // mark-paid/cancel requests, the chief approves or rejects them.

View File

@@ -315,7 +315,7 @@ const App = () => {
} }
/> />
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via {/* Merged Invoices / Payments / USD Payments hub — tabs switch via
?tab=invoices|payments|usd-payments (default invoices). Access is ?tab=invoices|payments|manual-payments (default invoices). Access is
OR'd across both keys so a user with just one still gets in; each OR'd across both keys so a user with just one still gets in; each
tab hides itself if the user lacks the permission it used to be tab hides itself if the user lacks the permission it used to be
routed on. */} routed on. */}
@@ -352,7 +352,7 @@ const App = () => {
/> />
<Route <Route
path="usd-payments" path="usd-payments"
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />} element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
/> />
<Route <Route
path="invoices/:id" path="invoices/:id"

View File

@@ -1,5 +1,5 @@
import { Package } from "lucide-react"; import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core"; import { SimpleGrid, Divider, Box, Group, Table, Text, Badge } from "@mantine/core";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -29,12 +29,37 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0, Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
); );
const isBulk = booking.freightType === "BULK";
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
// the freight kind, with the shipper's own description alongside.
const cargoHeadline = isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo")
: "Containers";
const cargoDescription = booking.cargoFreeText?.trim() || null;
return ( return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange"> <SectionCard icon={Package} title="Cargo specifications" accent="orange">
<Group gap="sm" align="center" mb="md" wrap="wrap">
<Text fw={800} fz={22} lh={1.1}>
{cargoHeadline}
</Text>
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
{isBulk ? "Bulk" : "Container"}
</Badge>
{cargoDescription ? (
<Text size="sm" c="dimmed">
{cargoDescription}
</Text>
) : null}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm"> <SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile <MetricTile
label="Cargo type" label={isBulk ? "Commodity" : "Cargo type"}
value={booking.cargoType?.label ?? booking.freightType} value={
isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "—")
: (cargoDescription ?? booking.freightType)
}
/> />
<MetricTile label="Total VGM" value={`${tons} tons`} /> <MetricTile label="Total VGM" value={`${tons} tons`} />
{items != null && <MetricTile label="Items" value={`${items}`} />} {items != null && <MetricTile label="Items" value={`${items}`} />}

View File

@@ -1,11 +1,30 @@
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
NumberInput,
Radio,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react"; import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { EimsInvoiceStatus } from "@/types/eims"; import { eimsService } from "@/services/eims.service";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = { const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
REGISTERED: "edr-green", REGISTERED: "edr-green",
FAILED: "red", FAILED: "red",
UNKNOWN: "orange", UNKNOWN: "orange",
CANCELLED: "gray",
}; };
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = { const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
REGISTERED: "Filed", REGISTERED: "Filed",
FAILED: "Rejected", FAILED: "Rejected",
UNKNOWN: "Unacknowledged", UNKNOWN: "Unacknowledged",
CANCELLED: "Cancelled",
}; };
function Field({ label, value }: { label: string; value?: string | number | null }) { function Field({ label, value }: { label: string; value?: string | number | null }) {
@@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null
); );
} }
/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
const CANCEL_REASON_CODES = [
{ value: "1", label: "1 — Duplicate" },
{ value: "2", label: "2 — Buyer request" },
{ value: "3", label: "3 — Data entry error" },
{ value: "6", label: "6 — Calculation error" },
];
function CancelModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [reasonCode, setReasonCode] = useState<string | null>(null);
const [remark, setRemark] = useState("");
const cancel = useMutation(
api.invoices.eimsCancel.mutationOptions({
onSuccess: () => {
onClose();
toast({ title: "Cancelled with MoR" });
},
onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }),
}),
);
return (
<Modal opened={opened} onClose={onClose} title="Cancel EIMS registration" centered>
<Stack gap="md">
<Text size="sm" c="dimmed">
Cancels this invoice&apos;s registered document at MoR. Irreversible an already-cancelled
invoice refuses a second attempt.
</Text>
<Select
label="Reason code"
withAsterisk
data={CANCEL_REASON_CODES}
value={reasonCode}
onChange={setReasonCode}
placeholder="Select a reason"
/>
<Textarea
label="Remark"
placeholder="Optional note"
value={remark}
onChange={(e) => setRemark(e.currentTarget.value)}
autosize
minRows={2}
/>
<Button
color="red"
leftSection={<Ban size={16} />}
loading={cancel.isPending}
disabled={!reasonCode}
onClick={() => cancel.mutate({ id: invoiceId, reasonCode: reasonCode!, remark: remark.trim() || undefined })}
>
Cancel with MoR
</Button>
</Stack>
</Modal>
);
}
function SalesReceiptModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [modeOfPayment, setModeOfPayment] = useState<EimsModeOfPayment | null>(null);
const [collectedAmount, setCollectedAmount] = useState<number | "">("");
const [reason, setReason] = useState("");
const register = useMutation(
api.invoices.eimsRegisterSalesReceipt.mutationOptions({
onSuccess: (receipt) => {
onClose();
toast({ title: "Sales receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
},
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
}),
);
return (
<Modal opened={opened} onClose={onClose} title="File sales receipt" centered>
<Stack gap="md">
<Select
label="Mode of payment"
withAsterisk
data={EIMS_MODE_OF_PAYMENT.map((v) => ({ value: v, label: v }))}
value={modeOfPayment}
onChange={(v) => setModeOfPayment(v as EimsModeOfPayment)}
placeholder="Select"
/>
<NumberInput
label="Collected amount"
placeholder="Defaults to the invoice's paid amount"
min={0}
decimalScale={2}
value={collectedAmount}
onChange={(v) => setCollectedAmount(v === "" ? "" : Number(v))}
/>
<TextInput
label="Reason"
placeholder='Defaults to "Payment received"'
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={register.isPending}
disabled={!modeOfPayment}
onClick={() =>
register.mutate({
id: invoiceId,
modeOfPayment: modeOfPayment!,
collectedAmount: collectedAmount === "" ? undefined : collectedAmount,
reason: reason.trim() || undefined,
})
}
>
File with MoR
</Button>
</Stack>
</Modal>
);
}
function WithholdingReceiptModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [type, setType] = useState("TWHT");
const [preTaxAmount, setPreTaxAmount] = useState<number | "">("");
const [withholdingAmount, setWithholdingAmount] = useState<number | "">("");
const [reason, setReason] = useState("");
const register = useMutation(
api.invoices.eimsRegisterWithholdingReceipt.mutationOptions({
onSuccess: (receipt) => {
onClose();
toast({ title: "Withholding receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
},
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
}),
);
const valid = preTaxAmount !== "" && withholdingAmount !== "";
return (
<Modal opened={opened} onClose={onClose} title="File withholding receipt" centered>
<Stack gap="md">
<TextInput label="Type" withAsterisk value={type} onChange={(e) => setType(e.currentTarget.value)} />
<NumberInput
label="Pre-tax amount"
withAsterisk
min={0}
decimalScale={2}
value={preTaxAmount}
onChange={(v) => setPreTaxAmount(v === "" ? "" : Number(v))}
/>
<NumberInput
label="Withholding amount"
withAsterisk
min={0}
decimalScale={2}
value={withholdingAmount}
onChange={(v) => setWithholdingAmount(v === "" ? "" : Number(v))}
/>
<TextInput
label="Reason"
placeholder='Defaults to "Withholding"'
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={register.isPending}
disabled={!valid}
onClick={() =>
register.mutate({
id: invoiceId,
type,
preTaxAmount: preTaxAmount as number,
withholdingAmount: withholdingAmount as number,
reason: reason.trim() || undefined,
})
}
>
File with MoR
</Button>
</Stack>
</Modal>
);
}
function MemoModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [type, setType] = useState<"CRE" | "DEB">("CRE");
const [reason, setReason] = useState("");
const issue = useMutation(
api.invoices.issueMemo.mutationOptions({
onSuccess: (memo) => {
onClose();
toast({ title: "Memo issued", description: `${memo.invoiceNumber} — file it with MoR separately` });
},
onError: (error) => toast({ title: "Could not issue memo", description: error.message, variant: "destructive" }),
}),
);
return (
<Modal opened={opened} onClose={onClose} title="Issue credit/debit memo" centered>
<Stack gap="md">
<Text size="sm" c="dimmed">
Creates a new invoice linked to this one, with every line copied verbatim. Filing it with
MoR is a separate step it does not happen automatically here.
</Text>
<Radio.Group value={type} onChange={(v) => setType(v as "CRE" | "DEB")} label="Type">
<Stack gap="xs" mt="xs">
<Radio value="CRE" label="Credit memo" description="Reduces what the buyer owes; created settled." />
<Radio value="DEB" label="Debit memo" description="An additional charge; created as a new open invoice." />
</Stack>
</Radio.Group>
<Textarea
label="Reason"
withAsterisk
placeholder="Why this memo is being issued"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
/>
<Button
color="edr-green"
loading={issue.isPending}
disabled={!reason.trim()}
onClick={() => issue.mutate({ id: invoiceId, type, reason: reason.trim() })}
>
Issue memo
</Button>
</Stack>
</Modal>
);
}
function ReceiptsSection({ invoiceId, canFile }: { invoiceId: string; canFile: boolean }) {
const { toast } = useToast();
const { data: receipts } = useQuery(api.invoices.eimsReceipts.queryOptions({ input: { id: invoiceId } }));
const [salesOpen, setSalesOpen] = useState(false);
const [withholdingOpen, setWithholdingOpen] = useState(false);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const download = async (receiptId: string, receiptNumber: string) => {
setDownloadingId(receiptId);
try {
const { data } = await eimsService.downloadReceiptDocument(invoiceId, receiptId);
openPdfBlob(data, `${receiptNumber}.pdf`);
} catch (error) {
toast({
title: "Could not download receipt",
description: error instanceof Error ? error.message : undefined,
variant: "destructive",
});
} finally {
setDownloadingId(null);
}
};
return (
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600} size="sm" c="edr-text">
Receipts
</Text>
{canFile && (
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => setSalesOpen(true)}>
File sales receipt
</Button>
<Button size="xs" variant="light" onClick={() => setWithholdingOpen(true)}>
File withholding receipt
</Button>
</Group>
)}
</Group>
{receipts && receipts.length > 0 ? (
<Table striped withTableBorder={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Kind</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>RRN</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{receipts.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.kind}</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[r.status] ?? "gray"} variant="light" size="sm">
{STATUS_LABEL[r.status] ?? r.status}
</Badge>
</Table.Td>
<Table.Td style={{ fontFamily: "monospace" }}>{r.rrn ?? "—"}</Table.Td>
<Table.Td>
{r.status === "REGISTERED" && (
<Button
size="xs"
variant="subtle"
leftSection={<Download size={14} />}
loading={downloadingId === r.id}
onClick={() => void download(r.id, r.receiptNumber)}
>
PDF
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text size="sm" c="dimmed">
No receipts filed yet.
</Text>
)}
<SalesReceiptModal invoiceId={invoiceId} opened={salesOpen} onClose={() => setSalesOpen(false)} />
<WithholdingReceiptModal invoiceId={invoiceId} opened={withholdingOpen} onClose={() => setWithholdingOpen(false)} />
</Stack>
);
}
/** /**
* MoR EIMS filing state for one invoice, with the manual actions. * MoR EIMS filing state for one invoice, with the manual actions.
* *
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister); const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
const canCancel = hasPermission(user, FREIGHT_PERMS.invoices.eimsCancel);
const canFileReceipt = hasPermission(user, FREIGHT_PERMS.invoices.eimsReceiptRegister);
const canIssueMemo = hasPermission(user, FREIGHT_PERMS.invoices.memoIssue);
const [cancelOpen, setCancelOpen] = useState(false);
const [memoOpen, setMemoOpen] = useState(false);
const { data: eims, isLoading } = useQuery( const { data: eims, isLoading } = useQuery(
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }), api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
</Alert> </Alert>
)} )}
{canFile && ( {status === "CANCELLED" && (
<Group gap="sm"> <Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */} {eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
{status !== "REGISTERED" && status !== "UNKNOWN" && ( {eims.eimsCancellationRemark}
<Button </Alert>
size="xs"
variant="light"
radius="md"
loading={register.isPending}
disabled={busy}
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
onClick={() => register.mutate({ id: invoiceId })}
>
{status === "FAILED" ? "File again" : "File with MoR"}
</Button>
)}
{eims.eimsIrn && (
<Button
size="xs"
variant="light"
radius="md"
loading={verify.isPending}
disabled={busy}
leftSection={<ShieldCheck size={14} />}
onClick={() => verify.mutate({ id: invoiceId })}
>
Verify with MoR
</Button>
)}
</Group>
)} )}
<Group gap="sm">
{canFile && (
<>
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
{status !== "REGISTERED" && status !== "UNKNOWN" && status !== "CANCELLED" && (
<Button
size="xs"
variant="light"
radius="md"
loading={register.isPending}
disabled={busy}
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
onClick={() => register.mutate({ id: invoiceId })}
>
{status === "FAILED" ? "File again" : "File with MoR"}
</Button>
)}
{eims.eimsIrn && (
<Button
size="xs"
variant="light"
radius="md"
loading={verify.isPending}
disabled={busy}
leftSection={<ShieldCheck size={14} />}
onClick={() => verify.mutate({ id: invoiceId })}
>
Verify with MoR
</Button>
)}
</>
)}
{canCancel && eims.eimsIrn && status !== "CANCELLED" && (
<Button
size="xs"
variant="light"
color="red"
radius="md"
leftSection={<Ban size={14} />}
onClick={() => setCancelOpen(true)}
>
Cancel with MoR
</Button>
)}
{canIssueMemo && status === "REGISTERED" && (
<Button
size="xs"
variant="light"
radius="md"
leftSection={<FileText size={14} />}
onClick={() => setMemoOpen(true)}
>
Issue credit/debit memo
</Button>
)}
</Group>
{eims.eimsIrn && <ReceiptsSection invoiceId={invoiceId} canFile={canFileReceipt} />}
</Stack> </Stack>
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
</Card> </Card>
); );
} }

View File

@@ -256,7 +256,7 @@ const RuleEngineFormDialog = ({
next.containerTypeId = ""; next.containerTypeId = "";
next.cargoTypeId = ""; next.cargoTypeId = "";
} }
// Cargo kind (customs / lashing) decides both the container-type scope // Cargo kind (customs / cancellation) decides both the container-type scope
// and the legal units (container → per box/wagon, bulk → per ton/wagon). // and the legal units (container → per box/wagon, bulk → per ton/wagon).
if (name === "cargoKind") { if (name === "cargoKind") {
next.containerTypeId = ""; next.containerTypeId = "";

View File

@@ -0,0 +1,103 @@
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useEffect, useState } from "react";
/**
* Time + note for one leg of a train's journey — used both to log a pass
* (defaults to now) and to correct an already-logged leg (prefilled). Past
* times are allowed (staff record after the fact); the future is not, and the
* server additionally keeps legs in corridor order.
*/
export function CheckpointTimeModal({
opened,
onClose,
title,
icon,
description,
initialOccurredAt,
initialNote,
submitLabel,
submitColor = "edr-green",
loading,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
title: string;
icon?: React.ReactNode;
description?: string;
/** ISO; omit to default to now. */
initialOccurredAt?: string | null;
initialNote?: string | null;
submitLabel: string;
submitColor?: string;
loading: boolean;
onSubmit: (values: { occurredAt: string; note: string }) => void;
}) {
const [at, setAt] = useState<Date | null>(null);
const [note, setNote] = useState("");
useEffect(() => {
if (!opened) return;
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
setNote(initialNote ?? "");
}, [opened, initialOccurredAt, initialNote]);
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
title={
<Group gap={8}>
{icon}
<Text fw={700}>{title}</Text>
</Group>
}
>
<Stack gap="md">
{description ? (
<Text size="sm" c="dimmed">
{description}
</Text>
) : null}
<DateTimePicker
label="Time"
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
<Textarea
label="Note"
placeholder="Optional"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
maxRows={4}
maxLength={500}
radius="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
color={submitColor}
loading={loading}
disabled={!at}
onClick={() =>
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,423 @@
import { Fragment, useEffect, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
Alert,
Badge,
Button,
Group,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Stop = { yardId: string; label: string };
type Span = [number, number];
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
interface WagonRow {
key: string;
physicalWagonId: string | null;
label: string;
position: number;
typeCode: string | null;
capacityTons: number;
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
}
const round1 = (n: number) => Math.round(n * 10) / 10;
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
/**
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
* same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance.
* Loads move by click: pick a load, then click a wagon that is free on that
* load's legs (move) or another load (swap). Same API as the consist strip.
*/
export function LegLoadBoardPanel({
schedule,
onChanged,
}: {
schedule: TrainScheduleDetail;
onChanged?: () => void;
}) {
const { toast } = useToast();
const stops: Stop[] = schedule.stops ?? [];
const legs = useMemo(
() => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })),
[stops],
);
const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status);
const spanOf = (slot: Slot): Span => {
const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0;
const to = slot.alightYardId
? stops.findIndex((s) => s.yardId === slot.alightYardId)
: stops.length - 1;
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
};
const rows: WagonRow[] = useMemo(() => {
const byKey = new Map<string, WagonRow>();
for (const slot of schedule.trainSet?.wagons ?? []) {
const key = slot.physicalWagonId ?? `slot:${slot.id}`;
let row = byKey.get(key);
if (!row) {
row = {
key,
physicalWagonId: slot.physicalWagonId ?? null,
label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`,
position: slot.position ?? slot.sequenceNo,
typeCode: slot.wagonType?.code ?? null,
capacityTons: slot.capacityTons ?? 0,
slots: [],
};
byKey.set(key, row);
}
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
if (!slot.consistOnly) {
row.slots.push({
slot,
span: spanOf(slot),
loaded: (slot.allocations?.length ?? 0) > 0,
});
}
}
return [...byKey.values()].sort((a, b) => a.position - b.position);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule.trainSet?.wagons, stops]);
const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>(
null,
);
useEffect(() => {
if (!picked) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [picked]);
const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions());
const doMove = async (targetWagonId: string, swap: boolean) => {
if (!picked || moveMutation.isPending) return;
try {
await moveMutation.mutateAsync({
scheduleId: schedule.id,
wagonId: picked.slotId,
targetWagonId,
});
toast({ title: swap ? "Loads swapped" : "Load moved" });
setPicked(null);
onChanged?.();
} catch (error) {
const message = isAxiosError(error)
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ??
null)
: null;
toast({
title: "Could not move the load",
description: Array.isArray(message)
? message.join(", ")
: (message ?? "The move was rejected — check wagon type, payload and leg."),
variant: "destructive",
});
}
};
if (stops.length < 2) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
This schedule has no corridor stops yet the leg board needs a route with at least
two stops.
</Alert>
);
}
if (!rows.length) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
No wagons on this train yet.
</Alert>
);
}
const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length;
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fw={700} size="sm">
Loads per wagon per leg
</Text>
<Text size="xs" c="dimmed">
One row per physical wagon, one column per leg. A wagon reused on different legs
shows one load per leg.{" "}
{canRearrange
? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap."
: "Read-only — the train has departed."}
</Text>
</Stack>
<Group gap="xs">
{sharedRows > 0 ? (
<Badge variant="light" color="violet" radius="sm">
{sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs
</Badge>
) : null}
{picked ? (
<Button
size="xs"
variant="default"
leftSection={<X size={14} />}
onClick={() => setPicked(null)}
>
Cancel move (Esc)
</Button>
) : null}
</Group>
</Group>
<Paper withBorder radius="md" style={{ overflowX: "auto" }}>
<Table verticalSpacing={6} horizontalSpacing="sm" style={{ minWidth: 640 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1, width: 180 }}>
Wagon
</Table.Th>
{legs.map((leg) => (
<Table.Th key={leg.idx} style={{ minWidth: 200 }}>
<Group gap={4} wrap="nowrap">
<Text size="xs" fw={700} truncate>
{leg.from.label}
</Text>
<MoveRight size={12} />
<Text size="xs" fw={700} truncate>
{leg.to.label}
</Text>
</Group>
</Table.Th>
))}
<Table.Th style={{ width: 110 }}>Cargo</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const cargoTons = row.slots.reduce(
(s, x) =>
s +
((x.slot.allocations ?? []).reduce(
(a, al) => a + (al.allocatedWeightTons ?? 0),
0,
) || x.slot.assignedWeightTons || 0),
0,
);
const isPickedRow = picked?.rowKey === row.key;
// A row can take the picked load when nothing loaded on it rides
// any of the picked load's legs.
const rowFreeForPicked =
!!picked &&
!isPickedRow &&
!row.slots.some((s) => s.loaded && overlaps(s.span, picked.span));
// Where a "move here" lands: an existing empty slot on those legs,
// else the physical wagon itself (the API mints the slot).
const emptyTargetSlot = picked
? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span))
: undefined;
const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null;
// Lay slots into leg columns; uncovered legs render as empty cells.
const cells: React.ReactNode[] = [];
let col = 0;
const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]);
// Empty cell = uncovered leg (target: the physical wagon) or an
// empty slot (target: that slot). Both take the picked load when
// the row is free on its legs.
const emptyCell = (from: number, to: number, targetId = moveTargetId) => {
const droppable = rowFreeForPicked && canRearrange && !!targetId &&
!!picked && overlaps([from, to], picked.span);
return (
<Table.Td
key={`e-${from}`}
colSpan={Math.max(1, to - from)}
onClick={droppable ? () => void doMove(targetId!, false) : undefined}
style={{
cursor: droppable ? "pointer" : "default",
background: droppable ? "var(--mantine-color-teal-0)" : undefined,
outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined,
outlineOffset: -3,
borderRadius: 6,
}}
>
{droppable ? (
<Text size="xs" c="teal.7" fw={600} ta="center">
Move here
</Text>
) : (
<Text size="xs" c="dimmed" ta="center">
</Text>
)}
</Table.Td>
);
};
for (const s of sorted) {
if (s.span[0] > col) cells.push(emptyCell(col, s.span[0]));
if (!s.loaded) {
cells.push(emptyCell(s.span[0], s.span[1], s.slot.id));
col = Math.max(col, s.span[1]);
continue;
}
const isPicked = picked?.slotId === s.slot.id;
const swappable =
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
const allocs = s.slot.allocations ?? [];
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
const containers = allocs.flatMap((a) => a.containerItems ?? []);
cells.push(
<Table.Td
key={s.slot.id}
colSpan={Math.max(1, s.span[1] - s.span[0])}
onClick={
!canRearrange
? undefined
: s.loaded && !picked
? () => setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span })
: swappable
? () => void doMove(s.slot.id, true)
: isPicked
? () => setPicked(null)
: undefined
}
style={{
cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default",
padding: 4,
}}
>
{s.loaded ? (
<Paper
radius="sm"
px={8}
py={6}
style={{
background: bulk
? "var(--mantine-color-orange-0)"
: "var(--mantine-color-cyan-0)",
borderLeft: `4px solid ${
bulk ? "var(--mantine-color-orange-6)" : "var(--mantine-color-cyan-6)"
}`,
outline: isPicked
? "2px solid var(--mantine-color-edr-green-6)"
: swappable
? "1px dashed var(--mantine-color-orange-6)"
: undefined,
outlineOffset: 1,
}}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
{bulk ? <Wheat size={13} /> : <Boxes size={13} />}
<Text size="xs" fw={700} truncate>
{[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")}
</Text>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{round1(
allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
)}{" "}
t
</Text>
</Group>
<Group gap={4} mt={2} wrap="wrap">
{bulk
? allocs.map((a) =>
a.bulkLoad ? (
<Badge key={a.id} size="xs" variant="light" color="orange" radius="sm">
{a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t
</Badge>
) : null,
)
: containers.map((c) => (
<Badge key={c.id} size="xs" variant="light" color="cyan" radius="sm">
{c.containerNumber ?? "no number"}
</Badge>
))}
{swappable ? (
<Badge size="xs" color="orange" radius="sm" leftSection={<ArrowLeftRight size={10} />}>
swap
</Badge>
) : null}
</Group>
</Paper>
) : (
<Text size="xs" c="dimmed" ta="center">
empty
</Text>
)}
</Table.Td>,
);
col = Math.max(col, s.span[1]);
}
if (col < legs.length) cells.push(emptyCell(col, legs.length));
return (
<Table.Tr
key={row.key}
style={{
background: isPickedRow
? "var(--mantine-color-green-0)"
: rowFreeForPicked
? undefined
: picked
? "var(--mantine-color-gray-0)"
: undefined,
opacity: picked && !isPickedRow && !rowFreeForPicked ? 0.55 : 1,
}}
>
<Table.Td style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1 }}>
<Group gap={6} wrap="nowrap">
<Badge variant="outline" color="gray" radius="sm" size="sm">
#{row.position}
</Badge>
<Stack gap={0}>
<Text size="sm" fw={700}>
{row.label}
</Text>
<Text size="xs" c="dimmed">
{row.typeCode ?? "—"} · {round1(row.capacityTons)} t
</Text>
</Stack>
{row.slots.filter((s) => s.loaded).length > 1 ? (
<Tooltip label="This wagon carries different loads on different legs">
<Badge size="xs" color="violet" variant="light" radius="sm">
shared
</Badge>
</Tooltip>
) : null}
</Group>
</Table.Td>
{cells.map((c, i) => (
<Fragment key={i}>{c}</Fragment>
))}
<Table.Td>
<Text size="xs" fw={600} c={cargoTons > row.capacityTons + 0.001 ? "red.7" : undefined}>
{round1(cargoTons)} / {round1(row.capacityTons)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Paper>
</Stack>
);
}

View File

@@ -12,6 +12,7 @@ import {
ThemeIcon, ThemeIcon,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
CheckCircle2, CheckCircle2,
@@ -111,7 +112,12 @@ export function LogPassYardWorkModal({
}) { }) {
const { toast } = useToast(); const { toast } = useToast();
const [justLogged, setJustLogged] = useState(false); const [justLogged, setJustLogged] = useState(false);
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]); // When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
useEffect(() => {
setJustLogged(false);
setPassAt(new Date());
}, [station?.sequenceNo, opened]);
const logged = alreadyLogged || justLogged; const logged = alreadyLogged || justLogged;
const yardWorkQuery = useQuery( const yardWorkQuery = useQuery(
@@ -133,7 +139,13 @@ export function LogPassYardWorkModal({
const doLogPass = () => { const doLogPass = () => {
if (!station) return; if (!station) return;
recordCheckpoint.mutate( recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } }, {
id: scheduleId,
payload: {
sequenceNo: station.sequenceNo,
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
},
},
{ {
onSuccess: () => { onSuccess: () => {
setJustLogged(true); setJustLogged(true);
@@ -373,6 +385,20 @@ export function LogPassYardWorkModal({
</> </>
)} )}
{!logged ? (
<DateTimePicker
label={isFinal ? "Arrival time" : "Time at station"}
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={passAt}
onChange={(v) => setPassAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
maw={320}
/>
) : null}
<Group justify="space-between" mt="xs"> <Group justify="space-between" mt="xs">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{logged && pendingBoarders.length > 0 {logged && pendingBoarders.length > 0

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core"; import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { Check, Flag, MapPin, Train } from "lucide-react"; import { Check, Flag, MapPin, Pencil, Train } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand"; import { freightBrand } from "@/theme/freight-brand";
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling"; import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
@@ -14,6 +14,8 @@ export interface RouteCorridorTrackProps {
canLog: boolean; canLog: boolean;
loggingSeq?: number | null; loggingSeq?: number | null;
onLogCheckpoint?: (sequenceNo: number) => void; onLogCheckpoint?: (sequenceNo: number) => void;
/** Present when logged legs may be corrected (dispatched or arrived). */
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
} }
const COLUMN_WIDTH = 150; const COLUMN_WIDTH = 150;
@@ -31,6 +33,7 @@ export function RouteCorridorTrack({
canLog, canLog,
loggingSeq, loggingSeq,
onLogCheckpoint, onLogCheckpoint,
onEditCheckpoint,
}: RouteCorridorTrackProps) { }: RouteCorridorTrackProps) {
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c])); const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
const lastIndex = stations.length - 1; const lastIndex = stations.length - 1;
@@ -160,14 +163,28 @@ export function RouteCorridorTrack({
{/* checkpoint time or action */} {/* checkpoint time or action */}
{checkpoint ? ( {checkpoint ? (
<Text size="10px" c="dimmed" ta="center"> <Stack gap={2} align="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, { <Text size="10px" c="dimmed" ta="center">
month: "short", {new Date(checkpoint.occurredAt).toLocaleString(undefined, {
day: "numeric", month: "short",
hour: "2-digit", day: "numeric",
minute: "2-digit", hour: "2-digit",
})} minute: "2-digit",
</Text> })}
</Text>
{onEditCheckpoint ? (
<Button
size="compact-xs"
radius="md"
variant="subtle"
color="gray"
leftSection={<Pencil size={11} />}
onClick={() => onEditCheckpoint(checkpoint)}
>
Edit time
</Button>
) : null}
</Stack>
) : isNext ? ( ) : isNext ? (
<Button <Button
size="compact-xs" size="compact-xs"

View File

@@ -95,7 +95,10 @@ export const QUERY_KEYS = {
byId: (id: string) => ["invoices", "detail", id] as const, byId: (id: string) => ["invoices", "detail", id] as const,
offlineUsd: (filter?: InvoiceListFilter) => offlineUsd: (filter?: InvoiceListFilter) =>
["invoices", "offline-usd", filter ?? {}] as const, ["invoices", "offline-usd", filter ?? {}] as const,
summary: (filter?: Omit<InvoiceListFilter, "page" | "pageSize">) =>
["invoices", "summary", filter ?? {}] as const,
eimsStatus: (id: string) => ["invoices", "eims", id] as const, eimsStatus: (id: string) => ["invoices", "eims", id] as const,
eimsReceipts: (id: string) => ["invoices", "eims", id, "receipts"] as const,
}, },
BOOKINGS: { BOOKINGS: {

View File

@@ -134,8 +134,10 @@ export const URL_CONSTANTS = {
BILLING: { BILLING: {
INVOICES: "/billing/invoices", INVOICES: "/billing/invoices",
INVOICES_SUMMARY: "/billing/invoices/summary",
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`, INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`, INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
INVOICE_MEMO: (id: string) => `/billing/invoices/${id}/memo`,
OFFLINE_USD: "/billing/offline-usd", OFFLINE_USD: "/billing/offline-usd",
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`, CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
}, },
@@ -146,6 +148,12 @@ export const URL_CONSTANTS = {
REGISTER: (id: string) => `/invoices/${id}/eims/register`, REGISTER: (id: string) => `/invoices/${id}/eims/register`,
VERIFY: (id: string) => `/invoices/${id}/eims/verify`, VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`, RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`,
CANCEL: (id: string) => `/invoices/${id}/eims/cancel`,
RECEIPT_SALES: (id: string) => `/invoices/${id}/eims/receipt/sales`,
RECEIPT_WITHHOLDING: (id: string) => `/invoices/${id}/eims/receipt/withholding`,
RECEIPTS: (id: string) => `/invoices/${id}/eims/receipts`,
RECEIPT_DOCUMENT: (id: string, receiptId: string) =>
`/invoices/${id}/eims/receipts/${receiptId}/document`,
}, },
CUSTOMERS_API: { CUSTOMERS_API: {
@@ -482,6 +490,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/intercity/marshalling/document`, `/train-scheduling/schedules/${id}/intercity/marshalling/document`,
CHECKPOINTS: (id: string) => CHECKPOINTS: (id: string) =>
`/train-scheduling/schedules/${id}/checkpoints`, `/train-scheduling/schedules/${id}/checkpoints`,
CHECKPOINT: (id: string, sequenceNo: number) =>
`/train-scheduling/schedules/${id}/checkpoints/${sequenceNo}`,
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`, ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) => RESCHEDULE_PREVIEW: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/preview`, `/train-scheduling/schedules/${id}/reschedule/preview`,

View File

@@ -20,9 +20,13 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
reference: booking.reference, reference: booking.reference,
contractReference: booking.contractReference ?? null, contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null, contractId: booking.contractId ?? null,
customerLabel: booking.isGovernment // Shipping-line bookings have no customer company — the line IS the customer.
? (booking.governmentInstitution ?? "Government") customerLabel: booking.shippingLineCompany
: labelFromRef(booking.company, booking.companyId ?? undefined), ? booking.shippingLineCompany.name
: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
isShippingLine: Boolean(booking.shippingLineCompany ?? booking.shippingLineCompanyId),
// customerLabel: labelFromRef(booking.customer, booking.customerId), // customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status, status: booking.status,
scheduledDate: booking.scheduledDate, scheduledDate: booking.scheduledDate,

View File

@@ -145,10 +145,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:invoices:view", view: "edr_freight_app:invoices:view",
export: "edr_freight_app:invoices:export", export: "edr_freight_app:invoices:export",
confirmOffline: "edr_freight_app:invoices:confirm_offline", confirmOffline: "edr_freight_app:invoices:confirm_offline",
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is // Filing with MoR EIMS. Off the general Finance role — automatic filing needs no permission
// irreversible at the tax authority, and resolving clears a system-wide filing block. // at all (the cron sweep runs as the system); these are the manual, exceptional-operations
// actions, granted to the `chief` position (maker-checker, same as shipping-line credit
// mark-paid/cancel approval) rather than every Finance user.
eimsRegister: "edr_freight_app:invoices:eims_register", eimsRegister: "edr_freight_app:invoices:eims_register",
eimsResolve: "edr_freight_app:invoices:eims_resolve", eimsResolve: "edr_freight_app:invoices:eims_resolve",
eimsCancel: "edr_freight_app:invoices:eims_cancel",
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
// Issuing a credit/debit memo is filing-equivalent — same restricted grant as the eims_* keys.
memoIssue: "edr_freight_app:invoices:memo_issue",
}, },
firstMile: { firstMile: {
view: "edr_freight_app:first_mile:view", view: "edr_freight_app:first_mile:view",

View File

@@ -13,12 +13,14 @@ import {
MoreHorizontal, MoreHorizontal,
Package, Package,
RefreshCw, RefreshCw,
Ship,
Truck, Truck,
Wallet, Wallet,
Weight, Weight,
} from "lucide-react"; } from "lucide-react";
import { import {
ActionIcon, ActionIcon,
Badge,
Box, Box,
Button, Button,
Center, Center,
@@ -174,7 +176,14 @@ export default function BookingRequestDetailPage() {
}; };
const company = booking.company; const company = booking.company;
const shippingLine = booking.shippingLineCompany ?? null;
const customerName = toBookingListRow(booking).customerLabel; const customerName = toBookingListRow(booking).customerLabel;
// What is being shipped, in words: bulk → the commodity (Wheat, Steel…);
// containers → the shipper's own description when given.
const cargoLabel =
booking.freightType === "BULK"
? (booking.cargoType?.label ?? booking.cargoType?.name ?? null)
: (booking.cargoFreeText?.trim() || null);
const amount = Number(booking.totalAmount); const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? []; const containers = booking.bookingContainers ?? [];
@@ -237,10 +246,27 @@ export default function BookingRequestDetailPage() {
} }
subtitle={ subtitle={
<Group gap={6} wrap="wrap"> <Group gap={6} wrap="wrap">
<EntityLink {shippingLine ? (
to={company?.id ? `/dashboard/customers/${company.id}` : null} <Group gap={6} wrap="nowrap">
label={customerName ?? "—"} <Ship size={14} />
/> <Text size="sm" fw={600}>
{shippingLine.name}
</Text>
<Badge size="xs" radius="sm" variant="light" color="teal">
Shipping line
</Badge>
</Group>
) : (
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
)}
{cargoLabel ? (
<Text size="sm" c="dimmed">
· {cargoLabel}
</Text>
) : null}
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
· Scheduled {booking.scheduledDate} · Scheduled {booking.scheduledDate}
</Text> </Text>

View File

@@ -18,6 +18,7 @@ import {
Package, Package,
Plus, Plus,
RefreshCw, RefreshCw,
Ship,
User, User,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
@@ -62,6 +63,12 @@ const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
{ value: "GENERAL_CONTRACT", label: "General booking" }, { value: "GENERAL_CONTRACT", label: "General booking" },
]; ];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
/** Status options for the filter select — built from the shared status styles. */ /** Status options for the filter select — built from the shared status styles. */
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map( const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
([value, { label }]) => ({ value, label }), ([value, { label }]) => ({ value, label }),
@@ -132,6 +139,7 @@ export default function BookingRequestsPage() {
// split), so a deep link can never land behind "More filters" unseen. // split), so a deep link can never land behind "More filters" unseen.
const bookingFilterDefs: FilterDef[] = useMemo( const bookingFilterDefs: FilterDef[] = useMemo(
() => [ () => [
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS }, { key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{ {
@@ -301,8 +309,17 @@ export default function BookingRequestsPage() {
</Badge> </Badge>
</div> </div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground"> <p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" /> {b.isShippingLine ? (
<Ship className="size-3 shrink-0 opacity-70" />
) : (
<User className="size-3 shrink-0 opacity-70" />
)}
{b.customerLabel} {b.customerLabel}
{b.isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p> </p>
</div> </div>
</div> </div>
@@ -483,7 +500,7 @@ export default function BookingRequestsPage() {
<FilterBar <FilterBar
defs={bookingFilterDefs} defs={bookingFilterDefs}
controls={controls} controls={controls}
searchPlaceholder="Search booking, contract or customer…" searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests" viewId="booking-requests"
/> />
</Box> </Box>

View File

@@ -8,7 +8,7 @@ import {
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, User } from "lucide-react"; import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -67,6 +67,12 @@ const OWNERSHIP_OPTIONS = [
{ value: "false", label: "Private" }, { value: "false", label: "Private" },
]; ];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
export default function ClearanceDocumentsPage() { export default function ClearanceDocumentsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { filterOptions } = useMyTradeAccess(); const { filterOptions } = useMyTradeAccess();
@@ -91,6 +97,7 @@ export default function ClearanceDocumentsPage() {
options: filterOptions(TRADE_DIRECTION_OPTIONS), options: filterOptions(TRADE_DIRECTION_OPTIONS),
}, },
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS }, { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS },
{ {
key: "created", key: "created",
@@ -131,17 +138,29 @@ export default function ClearanceDocumentsPage() {
header: () => <span className={bookingTable.headerCell}>Customer</span>, header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => { cell: ({ row }) => {
const b = row.original; const b = row.original;
const customer = b.isGovernment const isShippingLine = Boolean(b.shippingLineCompany ?? b.shippingLineCompanyId);
? (b.governmentInstitution ?? "Government") const customer = isShippingLine
: (b.company?.name ?? ""); ? (b.shippingLineCompany?.name ?? "Shipping line")
: b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—");
return ( return (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}> <div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} /> {isShippingLine ? (
<Ship className="size-4" strokeWidth={1.75} />
) : (
<User className="size-4" strokeWidth={1.75} />
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-foreground"> <p className="flex items-center gap-1.5 font-medium text-foreground">
{customer} {customer}
{isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p> </p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground"> <p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" /> <FileText className="size-3 shrink-0 opacity-70" />
@@ -245,7 +264,7 @@ export default function ClearanceDocumentsPage() {
<FilterBar <FilterBar
defs={filterDefs} defs={filterDefs}
controls={controls} controls={controls}
searchPlaceholder="Search booking, contract or customer…" searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="clearance-documents" viewId="clearance-documents"
/> />
</Box> </Box>

View File

@@ -52,6 +52,47 @@ import {
summarizeRequestedCargo, summarizeRequestedCargo,
} from "@/features/clearance/requestedCargo"; } from "@/features/clearance/requestedCargo";
import { contractsService } from "@/services/contracts.service"; import { contractsService } from "@/services/contracts.service";
import "./contract-clearance-table.css";
/** Yards carry `label` (API) — older shapes used `name`/`code`. */
function yardLabel(
yard?: { label?: string; code?: string; name?: string } | null,
): string {
if (!yard) return "—";
return yard.label ?? yard.name ?? yard.code ?? "—";
}
/**
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
* text wraps normally (the table's cells are otherwise nowrap) so a long
* lane never spills into the next column.
*/
function RouteLabel({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
return (
<Text
size="sm"
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
);
}
function CustomsBadge({ customs }: { customs: boolean }) { function CustomsBadge({ customs }: { customs: boolean }) {
return customs ? ( return customs ? (
@@ -118,8 +159,8 @@ export default function ContractClearanceListPage() {
id: b.id, id: b.id,
reference: b.reference, reference: b.reference,
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—", customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
originLabel: b.originYard?.name ?? "—", originLabel: yardLabel(b.originYard),
destinationLabel: b.destinationYard?.name ?? "—", destinationLabel: yardLabel(b.destinationYard),
tradeDirection: b.tradeDirection ?? "—", tradeDirection: b.tradeDirection ?? "—",
freightType: b.freightType ?? "—", freightType: b.freightType ?? "—",
status: b.status, status: b.status,
@@ -430,11 +471,10 @@ function ShipmentBookingsTable({
id: "route", id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>, header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap={6} wrap="nowrap"> <RouteLabel
<Text size="sm">{row.original.originLabel}</Text> origin={row.original.originLabel}
<ArrowRight size={13} className="shrink-0 text-muted-foreground" /> destination={row.original.destinationLabel}
<Text size="sm">{row.original.destinationLabel}</Text> />
</Group>
), ),
}, },
{ {
@@ -600,13 +640,13 @@ function ShipmentBookingsTable({
} }
return ( return (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs"> <Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<DataTable<ShipmentBookingRow, unknown> <DataTable<ShipmentBookingRow, unknown>
columns={columns} columns={columns}
data={rows} data={rows}
status={loading ? "loading" : error ? "error" : "success"} status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)} onRowClick={(row) => onOpen(row.id)}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
/> />
</Box> </Box>
); );

View File

@@ -45,6 +45,7 @@ import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings"; import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import "./contract-clearance-table.css";
const prettyStatus = (s?: string | null) => const prettyStatus = (s?: string | null) =>
(s ?? "") (s ?? "")
@@ -214,15 +215,24 @@ function RouteCell({
}) { }) {
return ( return (
<Stack gap={4} py={2}> <Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap"> {/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
<Text size="sm" fw={500}> normally (cells are otherwise nowrap) so it never spills over. */}
{origin} <Text
</Text> size="sm"
<ArrowRight size={14} className="shrink-0 text-muted-foreground" /> fw={500}
<Text size="sm" fw={500}> maw={120}
{destination} lh={1.35}
</Text> style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
</Group> >
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
<Group gap={8} align="center"> <Group gap={8} align="center">
<DirectionIcon direction={direction} /> <DirectionIcon direction={direction} />
<Badge size="xs" variant="default" radius="sm"> <Badge size="xs" variant="default" radius="sm">
@@ -676,7 +686,7 @@ export default function GlDjiboutiClearanceListPage() {
) : null} ) : null}
</Stack> </Stack>
) : ( ) : (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs"> <Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<DataTable<ShipmentRow, unknown> <DataTable<ShipmentRow, unknown>
columns={shipmentColumns} columns={shipmentColumns}
data={pagedShipmentRows} data={pagedShipmentRows}
@@ -694,7 +704,7 @@ export default function GlDjiboutiClearanceListPage() {
manualPagination: true, manualPagination: true,
pageCount, pageCount,
}} }}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={DataTableFooter} footer={DataTableFooter}
/> />
</Box> </Box>

View File

@@ -0,0 +1,94 @@
/*
* Scoped to .edr-clearance-table — the DataTable container div on the
* Document Clearance hubs (GL Ethiopia + GL Djibouti). Mirrors the portal's /bookings table
* (bookings-table.css): content-sized columns with a 100px floor, no
* truncation, horizontal scroll when the table outgrows the card, sticky
* header row and a sticky shadowed action column.
*/
.edr-clearance-table {
overflow-x: auto;
max-width: 100%;
min-width: 0;
}
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-clearance-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
/* 100px floor, no ceiling: cells grow to fit their text, nothing is clipped. */
.edr-clearance-table th,
.edr-clearance-table td:not([colspan]) {
min-width: 100px;
max-width: none;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
* to their text so the column grows to fit them.
*/
.edr-clearance-table .mantine-Badge-root {
max-width: none;
}
/*
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
* In an auto-width table cell that resolves against min-content and collapses
* the badges/text in the Type, Route and Status columns to nothing. Let group
* children size to their content; the column grows and the container scrolls.
*/
.edr-clearance-table .mantine-Group-root > * {
max-width: none;
flex-shrink: 0;
}
/* Sticky header row. */
.edr-clearance-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column, shrunk to its content. The width overrides the inline
* width DataTable stamps from tanstack's column size — hence !important.
* `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-clearance-table th:last-child,
.edr-clearance-table td:last-child:not([colspan]) {
width: 1% !important;
min-width: 0;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-clearance-table td:last-child:not([colspan]) {
background: #f5f8fb;
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-clearance-table th:last-child {
background: #f4f7fa;
z-index: 3;
}

View File

@@ -353,6 +353,10 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" }, { id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
// From the status-flip log: last time the wagon went to maintenance, and
// last time it became available again (dash = never logged).
{ id: "lastMaintenanceAt", header: "Last to maintenance", accessorKey: "lastMaintenanceAt", format: "date" },
{ id: "lastAvailableAt", header: "Available since", accessorKey: "lastAvailableAt", format: "date" },
], ],
formFields: [ formFields: [
// Run numbers are optional — a wagon sits in the fleet unassigned to any // Run numbers are optional — a wagon sits in the fleet unassigned to any

View File

@@ -1,5 +1,5 @@
import { Tabs } from "@mantine/core"; import { Tabs } from "@mantine/core";
import { Landmark, Receipt, Wallet } from "lucide-react"; import { Landmark, Receipt } from "lucide-react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
@@ -8,13 +8,15 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import InvoicesPanel from "./InvoicesPage"; import InvoicesPanel from "./InvoicesPage";
import UsdPaymentsPanel from "./UsdPaymentsPage"; import UsdPaymentsPanel from "./UsdPaymentsPage";
import PaymentsPanel from "../payments/PaymentsPage";
/** /**
* Invoices, Payments, and USD Payments used to be three separate routes/pages * Invoices and USD Payments used to be separate routes/pages with
* with near-identical chrome. They're merged here as URL-linkable tabs * near-identical chrome. They're merged here as URL-linkable tabs (`?tab=`)
* (`?tab=`) on one page — each tab keeps the permission it was individually * on one page — each tab keeps the permission it was individually gated on
* gated on before, and just doesn't render if the user lacks it. * before, and just doesn't render if the user lacks it.
*
* The Payments tab was removed; its summary (total collected, ETB/USD) now
* lives as a card at the top of the Invoices tab instead.
*/ */
const TABS = [ const TABS = [
{ {
@@ -27,21 +29,13 @@ const TABS = [
Panel: InvoicesPanel, Panel: InvoicesPanel,
}, },
{ {
key: "payments", key: "manual-payments",
label: "Payments", label: "Manual Payments",
icon: Wallet,
permission: FREIGHT_PERMS.payments.view,
subtitle: "View and reconcile booking payment transactions.",
Panel: PaymentsPanel,
},
{
key: "usd-payments",
label: "USD Payments",
icon: Landmark, icon: Landmark,
// Same gate as Invoices, not a dedicated key — mirrors the old route. // Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view, permission: FREIGHT_PERMS.invoices.view,
subtitle: subtitle:
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.", "Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel, Panel: UsdPaymentsPanel,
}, },
] as const; ] as const;

View File

@@ -8,16 +8,18 @@ import {
Grid, Grid,
Group, Group,
Loader, Loader,
Menu,
SimpleGrid, SimpleGrid,
Stack, Stack,
Table, Table,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Building2, Download, FileText } from "lucide-react"; import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard"; import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
import { useToast } from "@/hooks/use-toast";
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
@@ -77,9 +79,31 @@ function InfoField({
); );
} }
/** Billed-to company, with its contact/registration details as quick-info rows. */ /**
* Billed-to party: a customer company, or — for shipping-line credit invoices
* (`companyId` null) — the shipping line itself. The two payers are mutually
* exclusive (DB-enforced), so exactly one branch has data.
*/
function RecipientCard({ invoice }: { invoice: Invoice }) { function RecipientCard({ invoice }: { invoice: Invoice }) {
const company = invoice.company; const company = invoice.company;
const shippingLine = invoice.shippingLineCompany;
if (!company && shippingLine) {
const rows: FieldRowProps[] = [
{ label: "Phone", value: shippingLine.phoneNumber },
{ label: "Email", value: shippingLine.email },
];
return (
<LinkedEntityCard
icon={Building2}
title="Billed to"
name={shippingLine.name}
rows={rows}
emptyMessage="No additional shipping line details available."
/>
);
}
const rows: FieldRowProps[] = [ const rows: FieldRowProps[] = [
{ label: "Profile", value: invoice.companyProfile?.reference }, { label: "Profile", value: invoice.companyProfile?.reference },
{ label: "TIN", value: company?.tin }, { label: "TIN", value: company?.tin },
@@ -91,7 +115,7 @@ function RecipientCard({ invoice }: { invoice: Invoice }) {
return ( return (
<LinkedEntityCard <LinkedEntityCard
icon={Building2} icon={Building2}
title="Recipient" title="Billed to"
name={company?.name ?? "Unnamed company"} name={company?.name ?? "Unnamed company"}
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null} to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
rows={rows} rows={rows}
@@ -145,6 +169,7 @@ export default function InvoiceDetailPage() {
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export); const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast();
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const { data: invoice, isLoading } = useQuery( const { data: invoice, isLoading } = useQuery(
@@ -154,12 +179,27 @@ export default function InvoiceDetailPage() {
}), }),
); );
const downloadDocument = async () => { const downloadDocument = async (format?: "a4" | "thermal") => {
if (!id) return; if (!id) return;
setDownloading(true); setDownloading(true);
try { try {
const { data } = await invoicesService.downloadDocument(id); const { data } = await invoicesService.downloadDocument(id, format);
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`); const suffix = format === "thermal" ? "-thermal" : "";
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}${suffix}.pdf`);
} catch (error) {
// Thermal rendering deliberately fails loudly rather than silently returning an A4-shaped,
// QR-less document (see PdfRenderService's `noFallback`) — surface that here rather than
// let it become a silent unhandled rejection with just a spinner stopping.
toast({
title: format === "thermal" ? "Could not generate the thermal invoice" : "Could not download the invoice",
description:
format === "thermal"
? "Thermal rendering requires Chromium on the server. The A4 PDF is still available."
: error instanceof Error
? error.message
: undefined,
variant: "destructive",
});
} finally { } finally {
setDownloading(false); setDownloading(false);
} }
@@ -202,17 +242,34 @@ export default function InvoiceDetailPage() {
subtitle={humanize(invoice.source)} subtitle={humanize(invoice.source)}
meta={<InvoiceStatusBadge status={invoice.status} />} meta={<InvoiceStatusBadge status={invoice.status} />}
action={ action={
<ActionIcon <Menu position="bottom-end" withinPortal>
variant="default" <Menu.Target>
size="lg" <ActionIcon
radius="md" variant="default"
aria-label="Download invoice" size="lg"
disabled={!canExport} radius="md"
loading={downloading} aria-label="Download invoice"
onClick={() => void downloadDocument()} disabled={!canExport}
> loading={downloading}
<Download size={16} /> >
</ActionIcon> <Download size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Download size={14} />}
onClick={() => void downloadDocument("a4")}
>
Download PDF (A4)
</Menu.Item>
<Menu.Item
leftSection={<Printer size={14} />}
onClick={() => void downloadDocument("thermal")}
>
Download thermal invoice (80mm)
</Menu.Item>
</Menu.Dropdown>
</Menu>
} }
/> />

View File

@@ -11,7 +11,14 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { RefreshCw, Search, X } from "lucide-react"; import {
Banknote,
CircleDollarSign,
Landmark,
RefreshCw,
Search,
X,
} from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -21,7 +28,9 @@ import {
formatMoney, formatMoney,
humanize, humanize,
} from "@/components/customers"; } from "@/components/customers";
import { KpiStrip } from "@/components/page";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions"; import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice"; import type { Invoice } from "@/types/invoice";
import { import {
@@ -79,6 +88,21 @@ export default function InvoicesPanel() {
[pendingActions], [pendingActions],
); );
// Summary card: total collected (paidAmount) across every invoice matching
// the current search/status filters, not just the visible page.
const { data: summary, isLoading: summaryLoading } = useQuery(
api.invoices.collectedSummary.queryOptions({
input: {
filter: { search: debouncedQuery, status: statusFilter || undefined },
},
}),
);
const { data: exchangeSettings } = useExchangeSettingsQuery();
const etbCollected = summary?.ETB ?? 0;
const usdCollected = summary?.USD ?? 0;
const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate;
const etbFromUsd = rate ? usdCollected * rate : null;
const columns: ColumnDef<Invoice>[] = useMemo( const columns: ColumnDef<Invoice>[] = useMemo(
() => [ () => [
{ {
@@ -95,7 +119,9 @@ export default function InvoicesPanel() {
header: "Billed to", header: "Billed to",
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}> <Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"} {row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
</Text> </Text>
), ),
}, },
@@ -170,7 +196,33 @@ export default function InvoicesPanel() {
); );
return ( return (
<Card p={0}> <Stack gap="md">
<KpiStrip
loading={summaryLoading}
items={[
{
label: "Total collected",
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only",
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"),
icon: CircleDollarSign,
color: "edr-green",
},
{
label: "Collected in ETB",
value: formatMoney(etbCollected, "ETB"),
icon: Banknote,
color: "blue",
},
{
label: "Collected in USD",
value: formatMoney(usdCollected, "USD"),
icon: Landmark,
color: "violet",
},
]}
/>
<Card p={0}>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap"> <Group justify="space-between" gap="md" wrap="wrap">
@@ -264,6 +316,7 @@ export default function InvoicesPanel() {
</Box> </Box>
</Box> </Box>
</Stack> </Stack>
</Card> </Card>
</Stack>
); );
} }

View File

@@ -11,6 +11,7 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
@@ -55,14 +56,19 @@ function formatRemaining(deadlineMs: number, now: number): string | null {
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; : `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
} }
function PayWindowCell({ deadline }: { deadline: string | null }) { /** Ticks once a second while a deadline is set, so window state updates live. */
function useNow(deadline: string | null): number {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
if (!deadline) return; if (!deadline) return;
const interval = setInterval(() => setNow(Date.now()), 1000); const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [deadline]); }, [deadline]);
return now;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
const now = useNow(deadline);
if (!deadline) { if (!deadline) {
return ( return (
@@ -88,13 +94,56 @@ function PayWindowCell({ deadline }: { deadline: string | null }) {
); );
} }
/** True once the pay window has closed — the API refuses confirmation then. */ /**
function windowClosed(row: OfflineUsdInvoice): boolean { * "Confirm paid" for one row. Booking invoices are only confirmable while the
const deadline = row.booking?.paymentDeadline; * booking's pay window is open (the API refuses otherwise): no window yet →
return Boolean(deadline && new Date(deadline).getTime() <= Date.now()); * no button; window closed → button disabled with the reason, and it flips
* live the second the countdown hits zero. Non-booking invoices (warehouse,
* clearance…) have no window and stay confirmable.
*/
function ConfirmCell({
row,
onConfirm,
}: {
row: OfflineUsdInvoice;
onConfirm: (row: OfflineUsdInvoice) => void;
}) {
const deadline = row.booking?.paymentDeadline ?? null;
const now = useNow(deadline);
if (row.booking && !deadline) return null;
const closed = Boolean(deadline && new Date(deadline).getTime() <= now);
return (
<Tooltip
label="Pay window closed — the booking can no longer be confirmed as paid."
disabled={!closed}
withArrow
>
<span>
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={closed}
onClick={(e) => {
e.stopPropagation();
onConfirm(row);
}}
>
Confirm paid
</Button>
</span>
</Tooltip>
);
} }
/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */ /**
* Manual Payments tab body of `FinanceHubPage` — page chrome lives in the
* parent. Lists open USD and ETB invoices (import and export alike) that
* Finance settles by hand; confirming records the payment the same way an
* online payment would, so the booking advances identically.
*/
export default function UsdPaymentsPanel() { export default function UsdPaymentsPanel() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() {
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>( const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"", "",
); );
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null); const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null); const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState(""); const [reference, setReference] = useState("");
@@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() {
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
search: debouncedQuery, search: debouncedQuery,
status: statusFilter || undefined, status: statusFilter || undefined,
currency: currency || undefined,
}), }),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], [
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
statusFilter,
currency,
],
); );
const { data, isLoading, isError, refetch, isFetching } = useQuery( const { data, isLoading, isError, refetch, isFetching } = useQuery(
@@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() {
header: "Customer", header: "Customer",
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}> <Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"} {row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
</Text> </Text>
), ),
}, },
@@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() {
header: "Booking", header: "Booking",
cell: ({ row }) => { cell: ({ row }) => {
const booking = row.original.booking; const booking = row.original.booking;
const bookings = row.original.bookings ?? [];
if (!booking && bookings.length) {
// Shipping-line credit invoice: one link per billed booking.
return (
<Group gap={4} wrap="wrap" maw={280}>
{bookings.map((b) => (
<Button
key={b.id}
variant="subtle"
size="compact-xs"
rightSection={<ExternalLink size={11} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${b.id}`);
}}
>
{b.reference}
</Button>
))}
</Group>
);
}
if (!booking) { if (!booking) {
return ( return (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
); );
} }
return ( return (
<Button <Group gap={6} wrap="nowrap">
variant="subtle" <Button
size="compact-sm" variant="subtle"
rightSection={<ExternalLink size={13} />} size="compact-sm"
onClick={(e) => { rightSection={<ExternalLink size={13} />}
e.stopPropagation(); onClick={(e) => {
navigate(`/dashboard/booking-requests/${booking.id}`); e.stopPropagation();
}} navigate(`/dashboard/booking-requests/${booking.id}`);
> }}
{booking.reference} >
</Button> {booking.reference}
</Button>
{booking.tradeDirection && (
<Badge size="xs" variant="light" radius="sm" color="gray">
{humanize(booking.tradeDirection)}
</Badge>
)}
</Group>
); );
}, },
}, },
{
id: "currency",
header: "Currency",
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
radius="sm"
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
>
{row.original.currency}
</Badge>
),
},
{ {
id: "status", id: "status",
header: "Status", header: "Status",
@@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() {
header: "", header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" }, meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => { cell: ({ row }) => {
const paid = row.original.status === "PAID"; if (row.original.status === "PAID" || !canConfirm) return null;
if (paid || !canConfirm) return null; return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
return (
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={windowClosed(row.original)}
onClick={(e) => {
e.stopPropagation();
setConfirming(row.original);
}}
>
Confirm paid
</Button>
);
}, },
}, },
], ],
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
style={{ flex: 1, minWidth: "240px" }} style={{ flex: 1, minWidth: "240px" }}
radius="lg" radius="lg"
/> />
<SegmentedControl
size="sm"
radius="md"
value={currency || "all"}
onChange={(v) => {
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
]}
/>
<SegmentedControl <SegmentedControl
size="sm" size="sm"
radius="md" radius="md"
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
</Box> </Box>
<Box style={{ overflowX: "auto" }} w="100%"> <Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}> <Box miw={1160}>
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)} onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={ emptyMessage={
debouncedQuery debouncedQuery
? "No USD invoices match your search." ? "No invoices match your search."
: "No USD invoices awaiting confirmation." : "No invoices awaiting manual payment confirmation."
} }
error={ error={
isError isError
? { ? {
message: "Failed to load USD invoices.", message: "Failed to load invoices.",
onRetry: () => void refetch(), onRetry: () => void refetch(),
} }
: undefined : undefined
@@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() {
opened={confirming !== null} opened={confirming !== null}
onClose={closeConfirm} onClose={closeConfirm}
title={ title={
<Text fw={700}>Confirm bank transfer payment</Text> <Text fw={700}>Confirm manual payment</Text>
} }
radius="md" radius="md"
size="md" size="md"
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Confirming settles {confirming.invoiceNumber} in full ( Confirming settles {confirming.invoiceNumber} in full (
{formatMoney(confirming.balanceAmount, confirming.currency)}) and {formatMoney(confirming.balanceAmount, confirming.currency)}) and
marks the booking as paid. Upload the customer&apos;s bank slip marks the booking as paid exactly as if the customer had paid
first this cannot be undone. online. Upload the customer&apos;s bank slip or receipt first
this cannot be undone.
</Text> </Text>
<PhasedFileDropzone <PhasedFileDropzone
label="Bank payment slip" label="Payment slip / receipt"
description="PDF or image of the customer's transfer slip." description="PDF or image of the customer's bank transfer slip or payment receipt."
value={slip} value={slip}
onChange={setSlip} onChange={setSlip}
/> />
<TextInput <TextInput
label="Bank reference" label="Payment reference"
description="Optional — the transfer reference from the slip." description="Optional — the transfer or receipt reference from the slip."
placeholder="e.g. FT24091234567" placeholder="e.g. FT24091234567"
value={reference} value={reference}
onChange={(e) => setReference(e.target.value)} onChange={(e) => setReference(e.target.value)}

View File

@@ -580,14 +580,10 @@ const RuleEngineResourcePage = () => {
config={config} config={config}
layout="row" layout="row"
readOnly={!canUpdateControls} readOnly={!canUpdateControls}
onEdit={ onEdit={(record) => {
config.slug === "container-types" setEditing(record);
? undefined setFormOpen(true);
: (record) => { }}
setEditing(record);
setFormOpen(true);
}
}
onDelete={setDeleteTarget} onDelete={setDeleteTarget}
onViewChain={ onViewChain={
config.slug === "approval-rules" config.slug === "approval-rules"
@@ -958,9 +954,7 @@ const RuleEngineResourcePage = () => {
totalCount={totalCount} totalCount={totalCount}
onPaginationChange={setPagination} onPaginationChange={setPagination}
readOnly={!canUpdate && !canDelete} readOnly={!canUpdate && !canDelete}
onEdit={ onEdit={canUpdate ? openEdit : undefined}
canUpdate && config.slug !== "container-types" ? openEdit : undefined
}
onDelete={canDelete ? setDeleteTarget : undefined} onDelete={canDelete ? setDeleteTarget : undefined}
onViewChain={ onViewChain={
config.slug === "approval-rules" config.slug === "approval-rules"

View File

@@ -187,7 +187,7 @@ const RATE_TRIGGERS = [
{ label: "Shipping line mapped", value: "SHIPPING_LINE" }, { label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Penalty", value: "CONSOLIDATION" }, { label: "Penalty", value: "CONSOLIDATION" },
{ label: "Lashing (bulk, per cargo type)", value: "LASHING" }, { label: "Lashing (bulk, per cargo type)", value: "LASHING" },
{ label: "Cancellation", value: "CANCELLATION" }, { label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" }, { label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
{ label: "Fuel (per lane + cargo type)", value: "FUEL" }, { label: "Fuel (per lane + cargo type)", value: "FUEL" },
@@ -265,6 +265,13 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
(String(values.appliesTo ?? "") === "OTHER" && (String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? ""))); ["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
/**
* Surcharges sold per cargo kind: the admin says container or bulk, then names
* the container type or bulk commodity the fee covers.
*/
const isCargoKindTrigger = (values: Record<string, unknown>) =>
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value }); const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/** /**
@@ -304,7 +311,8 @@ const unitsForShape = (
// Container-only service — per returned container, per wagon, or flat. // Container-only service — per returned container, per wagon, or flat.
return ["PER_CONTAINER", "PER_WAGON", "FLAT"]; return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
case "CANCELLATION": case "CANCELLATION":
return ["FLAT", "PER_INVOICE"]; // Wagon cancellation fee — scales with the cancelled wagons only.
return ["PER_WAGON"];
case "CUSTOMS_CLEARANCE": case "CUSTOMS_CLEARANCE":
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon. // Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return cargoKind === "BULK" return cargoKind === "BULK"
@@ -1054,9 +1062,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showIf: (v) => showIf: (v) =>
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE", hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
}, },
// ── Trade direction — Bulk & Container base freight, plus the route- // ── Trade direction — Bulk & Container base freight, plus the directed
// scoped surcharges (customs clearance; empty-container return, which is // surcharges (customs clearance, cancellation, lashing, fuel; empty-
// import-only for now so export is not offered) ──────────────────────── // container return, which is import-only for now so export is not
// offered) ─────────────────────────────────────────────────────────────
{ {
name: "tradeDirection", name: "tradeDirection",
label: "Trade direction", label: "Trade direction",
@@ -1074,9 +1083,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
!isShippingLineRate(v) && !isShippingLineRate(v) &&
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) || (["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" && (String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes( [
String(v.trigger ?? ""), "CUSTOMS_CLEARANCE",
))), "CANCELLATION",
"WITH_RETURN",
"LASHING",
"FUEL",
].includes(String(v.trigger ?? "")))),
}, },
// Shipping lines only ever ship import — the export leg is sold through // Shipping lines only ever ship import — the export leg is sold through
// the customer's contract — so the direction is stated, not asked. Shown // the customer's contract — so the direction is stated, not asked. Shown
@@ -1097,8 +1110,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
computeValue: () => "IMPORT", computeValue: () => "IMPORT",
showIf: hasShippingLine, showIf: hasShippingLine,
}, },
// ── Cargo kind — customs clearance is priced separately for containers // ── Cargo kind — customs clearance and the cancellation fee are priced
// (one rate per container type) and bulk ─────────────────────────────── // separately for containers (one rate per container type) and bulk (one
// rate per commodity) ──────────────────────────────────────────────────
{ {
name: "cargoKind", name: "cargoKind",
label: "Cargo kind", label: "Cargo kind",
@@ -1107,11 +1121,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: INTERCITY_KINDS, options: INTERCITY_KINDS,
placeholder: "Is this fee for containers or bulk?", placeholder: "Is this fee for containers or bulk?",
description: description:
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.", "Container fees are set per container type; bulk fees per commodity. Customs: container per box or wagon, bulk per ton or wagon. Cancellation: per wagon.",
showIf: (v) => showIf: (v) => v.appliesTo === "OTHER" && isCargoKindTrigger(v),
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
// Not a stored column: a container fee carries its containerTypeId, a // Not a stored column: a container fee carries its containerTypeId, a
// bulk fee carries none. // bulk fee its cargoTypeId.
getInitialValue: (record) => getInitialValue: (record) =>
record.containerTypeId ? "CONTAINER" : "BULK", record.containerTypeId ? "CONTAINER" : "BULK",
}, },
@@ -1124,10 +1137,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Which container type this fee covers", placeholder: "Which container type this fee covers",
showIf: (v) => showIf: (v) =>
v.appliesTo === "OTHER" && v.appliesTo === "OTHER" &&
v.trigger === "CUSTOMS_CLEARANCE" && isCargoKindTrigger(v) &&
v.cargoKind === "CONTAINER", v.cargoKind === "CONTAINER",
}, },
// ── Bulk cargo type — the bulk customs fee names its commodity ──────── // ── Bulk cargo type — the bulk fee names its commodity ────────────────
{ {
name: "cargoTypeId", name: "cargoTypeId",
label: "Bulk cargo type", label: "Bulk cargo type",
@@ -1136,7 +1149,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Which bulk commodity this fee covers", placeholder: "Which bulk commodity this fee covers",
showIf: (v) => showIf: (v) =>
v.appliesTo === "OTHER" && v.appliesTo === "OTHER" &&
v.trigger === "CUSTOMS_CLEARANCE" && isCargoKindTrigger(v) &&
v.cargoKind === "BULK", v.cargoKind === "BULK",
}, },
// ── Cargo type — a fuel rate names the commodity it covers (different // ── Cargo type — a fuel rate names the commodity it covers (different

View File

@@ -10,6 +10,7 @@ import {
MapPin, MapPin,
Navigation, Navigation,
PackageCheck, PackageCheck,
Pencil,
Train, Train,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -29,9 +30,10 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { PageContainer } from "@/components/page"; import { PageContainer } from "@/components/page";
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal"; import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import type { TrackStation } from "@/types/trainScheduling"; import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
import { import {
RouteCorridor, RouteCorridor,
StatusPill, StatusPill,
@@ -156,6 +158,16 @@ export default function TrainScheduleTrackPage() {
const recordCheckpoint = useMutation( const recordCheckpoint = useMutation(
api.trainScheduling.recordCheckpoint.mutationOptions(), api.trainScheduling.recordCheckpoint.mutationOptions(),
); );
const updateCheckpoint = useMutation(
api.trainScheduling.updateCheckpoint.mutationOptions(),
);
// Time-entry dialogs: logging a pass at a yard with no work (the yard-work
// modal carries its own picker), and correcting an already-logged leg.
const [logModal, setLogModal] = useState<{
station: TrackStation;
isFinal: boolean;
} | null>(null);
const [editModal, setEditModal] = useState<TrainCheckpoint | null>(null);
// Yard work drives the log-pass modal: which bookings board/alight per stop. // Yard work drives the log-pass modal: which bookings board/alight per stop.
const yardWorkQuery = useQuery( const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({ api.trainScheduling.yardWork.queryOptions({
@@ -241,15 +253,30 @@ export default function TrainScheduleTrackPage() {
const handleLog = (sequenceNo: number) => { const handleLog = (sequenceNo: number) => {
const station = track.stations.find((s) => s.sequenceNo === sequenceNo); const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
if (!station) return;
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
if (station && stationHasWork(station)) { if (stationHasWork(station)) {
setYardModal({ station, isFinal, alreadyLogged: false }); setYardModal({ station, isFinal, alreadyLogged: false });
return; return;
} }
setLogModal({ station, isFinal });
};
const submitLog = (values: { occurredAt: string; note: string }) => {
if (!logModal) return;
const { station, isFinal } = logModal;
recordCheckpoint.mutate( recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo } }, {
id: scheduleId,
payload: {
sequenceNo: station.sequenceNo,
occurredAt: values.occurredAt,
...(values.note ? { note: values.note } : {}),
},
},
{ {
onSuccess: () => { onSuccess: () => {
setLogModal(null);
toast({ toast({
title: isFinal title: isFinal
? "Train arrived — assets freed, moved to destination yard" ? "Train arrived — assets freed, moved to destination yard"
@@ -266,6 +293,32 @@ export default function TrainScheduleTrackPage() {
); );
}; };
const submitEdit = (values: { occurredAt: string; note: string }) => {
if (!editModal) return;
updateCheckpoint.mutate(
{
id: scheduleId,
sequenceNo: editModal.sequenceNo,
payload: { occurredAt: values.occurredAt, note: values.note || null },
},
{
onSuccess: () => {
setEditModal(null);
toast({ title: "Checkpoint updated" });
},
onError: (err) =>
toast({
title: "Could not update checkpoint",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
// Legs stay correctable for as long as the journey exists — while rolling
// and after arrival.
const canEdit = track.status === "DISPATCHED" || track.status === "ARRIVED";
// "Forgot to load" catch: while the train sits at the current station, any // "Forgot to load" catch: while the train sits at the current station, any
// boarder there that is still unloaded can be loaded until the next pass. // boarder there that is still unloaded can be loaded until the next pass.
const currentStationObj = track.stations.find( const currentStationObj = track.stations.find(
@@ -502,6 +555,7 @@ export default function TrainScheduleTrackPage() {
: null : null
} }
onLogCheckpoint={handleLog} onLogCheckpoint={handleLog}
onEditCheckpoint={canEdit ? setEditModal : undefined}
/> />
{/* Cargo the operator forgot: boarders at the CURRENT station stay {/* Cargo the operator forgot: boarders at the CURRENT station stay
@@ -595,24 +649,38 @@ export default function TrainScheduleTrackPage() {
) )
} }
title={ title={
<Group gap="sm"> <Group gap="sm" justify="space-between" wrap="nowrap">
<Text fw={700} size="sm"> <Group gap="sm">
{cp.label ?? `Station ${cp.sequenceNo}`} <Text fw={700} size="sm">
</Text> {cp.label ?? `Station ${cp.sequenceNo}`}
<Badge </Text>
size="xs" <Badge
radius="sm" size="xs"
variant="light" radius="sm"
color={ variant="light"
cp.kind === "ARRIVED" color={
? "teal" cp.kind === "ARRIVED"
: cp.kind === "DEPARTED" ? "teal"
? "blue" : cp.kind === "DEPARTED"
: "edr-green" ? "blue"
} : "edr-green"
> }
{cp.kind} >
</Badge> {cp.kind}
</Badge>
</Group>
{canEdit ? (
<Button
size="compact-xs"
radius="md"
variant="light"
color="gray"
leftSection={<Pencil size={12} />}
onClick={() => setEditModal(cp)}
>
Edit
</Button>
) : null}
</Group> </Group>
} }
> >
@@ -630,6 +698,39 @@ export default function TrainScheduleTrackPage() {
)} )}
</Paper> </Paper>
<CheckpointTimeModal
opened={logModal !== null}
onClose={() => setLogModal(null)}
title={
logModal?.isFinal
? `Mark arrived at ${logModal.station.label}`
: `Log pass at ${logModal?.station.label ?? "station"}`
}
icon={logModal?.isFinal ? <Flag size={18} /> : <MapPin size={18} />}
description={
logModal?.isFinal
? "Marks the train arrived: remaining bookings arrive, assets are freed."
: undefined
}
submitLabel={logModal?.isFinal ? "Mark arrived" : "Log pass"}
submitColor={logModal?.isFinal ? "teal" : "edr-green"}
loading={recordCheckpoint.isPending}
onSubmit={submitLog}
/>
<CheckpointTimeModal
opened={editModal !== null}
onClose={() => setEditModal(null)}
title={`Edit ${editModal?.label ?? "checkpoint"}`}
icon={<Pencil size={18} />}
description="Corrects this leg's time and note only — nothing else changes."
initialOccurredAt={editModal?.occurredAt}
initialNote={editModal?.note}
submitLabel="Save"
loading={updateCheckpoint.isPending}
onSubmit={submitEdit}
/>
<LogPassYardWorkModal <LogPassYardWorkModal
opened={yardModal !== null} opened={yardModal !== null}
onClose={() => setYardModal(null)} onClose={() => setYardModal(null)}

View File

@@ -34,6 +34,7 @@ import {
Navigation, Navigation,
Package, Package,
PackageCheck, PackageCheck,
Grid3x3,
Route as RouteIcon, Route as RouteIcon,
Ruler, Ruler,
Send, Send,
@@ -41,6 +42,7 @@ import {
Weight, Weight,
Workflow as WorkflowIcon, Workflow as WorkflowIcon,
} from "lucide-react"; } from "lucide-react";
import { DateTimePicker } from "@mantine/dates";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -57,6 +59,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"; import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal"; import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal"; import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel"; import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
@@ -125,6 +128,13 @@ export default function TrainScheduleV2DetailPage() {
const [gatepassFileUrl, setGatepassFileUrl] = useState(""); const [gatepassFileUrl, setGatepassFileUrl] = useState("");
const [gatepassNotes, setGatepassNotes] = useState(""); const [gatepassNotes, setGatepassNotes] = useState("");
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false); const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
// Actual departure — staff often dispatch on paper first and record it later,
// so the time is picked (defaults to now when the dialog opens).
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
const openDispatchConfirm = () => {
setDispatchAt(new Date());
setDispatchConfirmOpen(true);
};
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null); const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
const [visualization3DOpen, setVisualization3DOpen] = useState(false); const [visualization3DOpen, setVisualization3DOpen] = useState(false);
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false); const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
@@ -477,7 +487,10 @@ export default function TrainScheduleV2DetailPage() {
const runDispatch = async () => { const runDispatch = async () => {
setDispatchConfirmOpen(false); setDispatchConfirmOpen(false);
try { try {
await dispatch.mutateAsync(scheduleId); await dispatch.mutateAsync({
id: scheduleId,
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
});
await openMarshallingDocument({ await openMarshallingDocument({
title: "Train dispatched", title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.", successDescription: "Marshalling document generated for the dispatched train.",
@@ -873,7 +886,7 @@ export default function TrainScheduleV2DetailPage() {
radius="md" radius="md"
leftSection={<Send size={18} />} leftSection={<Send size={18} />}
loading={dispatch.isPending} loading={dispatch.isPending}
onClick={() => setDispatchConfirmOpen(true)} onClick={openDispatchConfirm}
> >
Dispatch train Dispatch train
</Button> </Button>
@@ -1271,6 +1284,9 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}> <Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
Leg capacity Leg capacity
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
Leg board
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}> <Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
History History
</Tabs.Tab> </Tabs.Tab>
@@ -1359,6 +1375,13 @@ export default function TrainScheduleV2DetailPage() {
<LegCapacityPanel schedule={schedule} /> <LegCapacityPanel schedule={schedule} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="leg-board">
<LegLoadBoardPanel
schedule={schedule}
onChanged={() => void detailQuery.refetch()}
/>
</Tabs.Panel>
<Tabs.Panel value="history"> <Tabs.Panel value="history">
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null} {scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
</Tabs.Panel> </Tabs.Panel>
@@ -1444,6 +1467,17 @@ export default function TrainScheduleV2DetailPage() {
undone. undone.
</Text> </Text>
<DateTimePicker
label="Actual departure"
description="When the train left — defaults to now; a past time is fine."
value={dispatchAt}
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
{hasDispatchWarnings ? ( {hasDispatchWarnings ? (
<Alert <Alert
color="orange" color="orange"

View File

@@ -18,6 +18,7 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { import {
@@ -134,6 +135,8 @@ export default function TrainScheduleV2ListPage() {
// confirmation. // confirmation.
const [dispatchTarget, setDispatchTarget] = const [dispatchTarget, setDispatchTarget] =
useState<TrainScheduleListItem | null>(null); useState<TrainScheduleListItem | null>(null);
// Actual departure — defaults to now when the dialog opens; past is fine.
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires. // Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] = const [cancelTarget, setCancelTarget] =
useState<TrainScheduleListItem | null>(null); useState<TrainScheduleListItem | null>(null);
@@ -362,6 +365,7 @@ export default function TrainScheduleV2ListPage() {
{row.original.direction} {row.original.direction}
</Badge> </Badge>
) : null} ) : null}
<ShippingLineBadge schedule={row.original} />
</Group> </Group>
<Box maw={220}> <Box maw={220}>
<RouteCorridor <RouteCorridor
@@ -507,7 +511,10 @@ export default function TrainScheduleV2ListPage() {
{canDispatch && schedule.status === "SCHEDULED" ? ( {canDispatch && schedule.status === "SCHEDULED" ? (
<Menu.Item <Menu.Item
leftSection={<Play size={15} />} leftSection={<Play size={15} />}
onClick={() => setDispatchTarget(schedule)} onClick={() => {
setDispatchAt(new Date());
setDispatchTarget(schedule);
}}
> >
Start (dispatch) train Start (dispatch) train
</Menu.Item> </Menu.Item>
@@ -742,7 +749,11 @@ export default function TrainScheduleV2ListPage() {
onRowClick={(schedule) => onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`) navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
} }
rowStyle={(schedule) => directionRowStyle(schedule.direction)} rowStyle={(schedule) =>
schedule.shippingLineCompanyId
? SHIPPING_LINE_ROW_STYLE
: directionRowStyle(schedule.direction)
}
error={ error={
schedulesQuery.isError schedulesQuery.isError
? { ? {
@@ -954,6 +965,16 @@ export default function TrainScheduleV2ListPage() {
wagons or cargo not yet marked loaded those warnings are shown wagons or cargo not yet marked loaded those warnings are shown
there, not here. there, not here.
</Text> </Text>
<DateTimePicker
label="Actual departure"
description="When the train left — defaults to now; a past time is fine."
value={dispatchAt}
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDispatchTarget(null)}> <Button variant="default" onClick={() => setDispatchTarget(null)}>
Cancel Cancel
@@ -965,7 +986,12 @@ export default function TrainScheduleV2ListPage() {
onClick={async () => { onClick={async () => {
if (!dispatchTarget) return; if (!dispatchTarget) return;
try { try {
await dispatchSchedule.mutateAsync(dispatchTarget.id); await dispatchSchedule.mutateAsync({
id: dispatchTarget.id,
payload: dispatchAt
? { actualDepartureAt: dispatchAt.toISOString() }
: {},
});
toast({ title: "Train dispatched" }); toast({ title: "Train dispatched" });
setDispatchTarget(null); setDispatchTarget(null);
void schedulesQuery.refetch(); void schedulesQuery.refetch();
@@ -1052,6 +1078,20 @@ export default function TrainScheduleV2ListPage() {
* bookings that have not paid yet — that space is claimed, so it is not * bookings that have not paid yet — that space is claimed, so it is not
* bookable. * bookable.
*/ */
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */
const SHIPPING_LINE_ROW_STYLE = {
backgroundColor: "var(--mantine-color-edr-green-0)",
} as const;
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
if (!schedule.shippingLineCompanyId) return null;
return (
<Badge size="xs" variant="light" color="edr-green">
{schedule.shippingLineCompanyName ?? "Shipping line"}
</Badge>
);
}
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) { function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still // Pre-deploy API rows carry only wagonCount; fall back so the chip still
// renders rather than reading 0 used on every train. // renders rather than reading 0 used on every train.
@@ -1133,6 +1173,7 @@ function ScheduleCard({
withBorder withBorder
onClick={onOpen} onClick={onOpen}
className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
style={schedule.shippingLineCompanyId ? SHIPPING_LINE_ROW_STYLE : undefined}
> >
<Stack gap="sm" p="md"> <Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
@@ -1182,6 +1223,7 @@ function ScheduleCard({
{schedule.direction} {schedule.direction}
</Badge> </Badge>
) : null} ) : null}
<ShippingLineBadge schedule={schedule} />
</Group> </Group>
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" /> <MetricChip value={schedule.bookingsCount} label="bkg" />

View File

@@ -961,6 +961,7 @@ interface StandaloneReturnModalProps {
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) { function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
const [containerNumber, setContainerNumber] = useState<string>(""); const [containerNumber, setContainerNumber] = useState<string>("");
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null); const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]); const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null); const [warehouse, setWarehouse] = useState<string | null>(null);
@@ -1021,6 +1022,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
containers: [ containers: [
{ {
containerNumber, containerNumber,
containerSize: containerSize ?? undefined,
returnDate, returnDate,
warehouse: selectedWarehouse?.name || warehouse, warehouse: selectedWarehouse?.name || warehouse,
yard: selectedYard?.name, yard: selectedYard?.name,
@@ -1034,6 +1036,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
}); });
setContainerNumber(""); setContainerNumber("");
setContainerSize(null);
setReturnedBy(null); setReturnedBy(null);
setReturnDate(new Date().toISOString().split("T")[0]); setReturnDate(new Date().toISOString().split("T")[0]);
setWarehouse(null); setWarehouse(null);
@@ -1071,6 +1074,17 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
required required
/> />
<Select
label="Container Type"
placeholder="Select container size"
value={containerSize}
onChange={(val) => setContainerSize(val as EmptyContainerSize | null)}
data={[
{ value: "20", label: "20 ft" },
{ value: "40", label: "40 ft" },
]}
/>
<Select <Select
label="Return Warehouse" label="Return Warehouse"
placeholder="Select warehouse for container return" placeholder="Select warehouse for container return"

View File

@@ -39,6 +39,7 @@ import type {
} from "@/types/fileUploadSettings"; } from "@/types/fileUploadSettings";
import type { import type {
Invoice, Invoice,
InvoiceCollectedSummary,
InvoiceListFilter, InvoiceListFilter,
PaginatedInvoices, PaginatedInvoices,
PaginatedOfflineUsdInvoices, PaginatedOfflineUsdInvoices,
@@ -80,6 +81,8 @@ import type {
LocomotiveRecord, LocomotiveRecord,
PinWagonsPayload, PinWagonsPayload,
RecordCheckpointPayload, RecordCheckpointPayload,
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow, StaffBookingWindow,
ScheduleMergePreview, ScheduleMergePreview,
TrainScheduleDetail, TrainScheduleDetail,
@@ -173,7 +176,7 @@ import { customersService } from "./customers.service";
import { shippingLineCompaniesService } from "./shippingLineCompanies.service"; import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
import { shippingLineCreditsService } from "./shippingLineCredits.service"; import { shippingLineCreditsService } from "./shippingLineCredits.service";
import { eimsService } from "./eims.service"; import { eimsService } from "./eims.service";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims"; import type { EimsInvoiceStatusView, EimsModeOfPayment, EimsReceiptView, EimsVerifyResult } from "@/types/eims";
import { invoicesService } from "./invoices.service"; import { invoicesService } from "./invoices.service";
import { dropdownSettingsService } from "./dropdownSettings.service"; import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service";
@@ -796,10 +799,13 @@ export const api = {
], ],
), ),
dispatchSchedule: endpoint<string, TrainScheduleDetail>( dispatchSchedule: endpoint<
{ id: string; payload?: DispatchSchedulePayload },
TrainScheduleDetail
>(
"train-scheduling", "train-scheduling",
"dispatch-schedule", "dispatch-schedule",
(id) => trainSchedulingService.dispatchSchedule(id), ({ id, payload }) => trainSchedulingService.dispatchSchedule(id, payload),
undefined, undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS, () => TRAIN_SCHEDULING_INVALIDATIONS,
), ),
@@ -917,6 +923,18 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS, () => TRAIN_SCHEDULING_INVALIDATIONS,
), ),
updateCheckpoint: endpoint<
{ id: string; sequenceNo: number; payload: UpdateCheckpointPayload },
TrainTrackResponse
>(
"train-scheduling",
"update-checkpoint",
({ id, sequenceNo, payload }) =>
trainSchedulingService.updateCheckpoint(id, sequenceNo, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
arriveSchedule: endpoint<string, TrainScheduleDetail>( arriveSchedule: endpoint<string, TrainScheduleDetail>(
"train-scheduling", "train-scheduling",
"arrive-schedule", "arrive-schedule",
@@ -3137,6 +3155,16 @@ export const api = {
({ id }) => QUERY_KEYS.INVOICES.byId(id), ({ id }) => QUERY_KEYS.INVOICES.byId(id),
), ),
collectedSummary: endpoint<
{ filter: Omit<InvoiceListFilter, "page" | "pageSize"> },
InvoiceCollectedSummary
>(
"invoices",
"collectedSummary",
({ filter }) => invoicesService.collectedSummary(filter),
({ filter }) => QUERY_KEYS.INVOICES.summary(filter),
),
listOfflineUsd: endpoint< listOfflineUsd: endpoint<
{ filter: InvoiceListFilter }, { filter: InvoiceListFilter },
PaginatedOfflineUsdInvoices PaginatedOfflineUsdInvoices
@@ -3189,6 +3217,51 @@ export const api = {
undefined, undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)], ({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
), ),
eimsCancel: endpoint<{ id: string; reasonCode: string; remark?: string }, EimsInvoiceStatusView>(
"invoices",
"eimsCancel",
({ id, reasonCode, remark }) => eimsService.cancel(id, { reasonCode, remark }),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
),
eimsReceipts: endpoint<{ id: string }, EimsReceiptView[]>(
"invoices",
"eimsReceipts",
({ id }) => eimsService.listReceipts(id),
({ id }) => QUERY_KEYS.INVOICES.eimsReceipts(id),
),
eimsRegisterSalesReceipt: endpoint<
{ id: string; modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
EimsReceiptView
>(
"invoices",
"eimsRegisterSalesReceipt",
({ id, ...input }) => eimsService.registerSalesReceipt(id, input),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
),
eimsRegisterWithholdingReceipt: endpoint<
{ id: string; type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
EimsReceiptView
>(
"invoices",
"eimsRegisterWithholdingReceipt",
({ id, ...input }) => eimsService.registerWithholdingReceipt(id, input),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
),
issueMemo: endpoint<{ id: string; type: "CRE" | "DEB"; reason: string }, Invoice>(
"invoices",
"issueMemo",
({ id, ...input }) => invoicesService.issueMemo(id, input),
undefined,
() => [QUERY_KEYS.INVOICES.ROOT],
),
}, },
overview: { overview: {

View File

@@ -36,6 +36,8 @@ export interface BookingListFilter {
scheduledTo?: string; scheduledTo?: string;
originYardId?: string; originYardId?: string;
destinationYardId?: string; destinationYardId?: string;
/** SHIPPING_LINE = booked by a shipping line; CUSTOMER = ordinary customer company. */
customerKind?: "SHIPPING_LINE" | "CUSTOMER";
/** "true" = government bookings only, "false" = private only. */ /** "true" = government bookings only, "false" = private only. */
isGovernment?: "true" | "false"; isGovernment?: "true" | "false";
/** Free-text search: booking reference, customer name, contract reference (server-side). */ /** Free-text search: booking reference, customer name, contract reference (server-side). */
@@ -151,6 +153,7 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId; if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId; if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment; if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
} }
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, { const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
params, params,
@@ -184,6 +187,7 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId; if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId; if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment; if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
if (filter.customsClearingEnabled) if (filter.customsClearingEnabled)
params.customsClearingEnabled = filter.customsClearingEnabled; params.customsClearingEnabled = filter.customsClearingEnabled;
if (filter.search) params.search = filter.search; if (filter.search) params.search = filter.search;

View File

@@ -1,6 +1,11 @@
import { api as apiClient } from "@/auth/http"; import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS"; import { URL_CONSTANTS } from "@/constants/URLS";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims"; import type {
EimsInvoiceStatusView,
EimsModeOfPayment,
EimsReceiptView,
EimsVerifyResult,
} from "@/types/eims";
/** /**
* MoR EIMS filing actions on an invoice. * MoR EIMS filing actions on an invoice.
@@ -36,4 +41,45 @@ export const eimsService = {
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input) .post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
.then((r) => r.data); .then((r) => r.data);
}, },
/** Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled one. */
cancel(
invoiceId: string,
input: { reasonCode: string; remark?: string },
): Promise<EimsInvoiceStatusView> {
return apiClient
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.CANCEL(invoiceId), input)
.then((r) => r.data);
},
registerSalesReceipt(
invoiceId: string,
input: { modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
): Promise<EimsReceiptView> {
return apiClient
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_SALES(invoiceId), input)
.then((r) => r.data);
},
registerWithholdingReceipt(
invoiceId: string,
input: { type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
): Promise<EimsReceiptView> {
return apiClient
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_WITHHOLDING(invoiceId), input)
.then((r) => r.data);
},
listReceipts(invoiceId: string): Promise<EimsReceiptView[]> {
return apiClient
.get<EimsReceiptView[]>(URL_CONSTANTS.EIMS.RECEIPTS(invoiceId))
.then((r) => r.data);
},
/** Blob download — same pattern as `invoicesService.downloadDocument`. */
downloadReceiptDocument(invoiceId: string, receiptId: string) {
return apiClient.get<Blob>(URL_CONSTANTS.EIMS.RECEIPT_DOCUMENT(invoiceId, receiptId), {
responseType: "blob",
});
},
}; };

View File

@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS"; import { URL_CONSTANTS } from "@/constants/URLS";
import type { import type {
Invoice, Invoice,
InvoiceCollectedSummary,
InvoiceListFilter, InvoiceListFilter,
PaginatedInvoices, PaginatedInvoices,
PaginatedOfflineUsdInvoices, PaginatedOfflineUsdInvoices,
@@ -23,19 +24,42 @@ export const invoicesService = {
.then((r) => r.data); .then((r) => r.data);
}, },
/** Total collected (paidAmount) across every filtered invoice, by currency. */
collectedSummary(
filter: Omit<InvoiceListFilter, "page" | "pageSize">,
): Promise<InvoiceCollectedSummary> {
return apiClient
.get<InvoiceCollectedSummary>(URL_CONSTANTS.BILLING.INVOICES_SUMMARY, {
params: cleanParams(filter),
})
.then((r) => r.data);
},
getById(id: string): Promise<Invoice> { getById(id: string): Promise<Invoice> {
return apiClient return apiClient
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id)) .get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
.then((r) => r.data); .then((r) => r.data);
}, },
downloadDocument(id: string) { /** `format` omitted or "a4" → standard A4 PDF; "thermal" → 80mm thermal layout (ADD-P001). */
downloadDocument(id: string, format?: "a4" | "thermal") {
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), { return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
responseType: "blob", responseType: "blob",
params: format ? { format } : undefined,
}); });
}, },
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */ /** Issue a credit/debit memo against a registered invoice (MoR DEB/CRE) — filing-equivalent. */
issueMemo(
id: string,
input: { type: "CRE" | "DEB"; reason: string },
): Promise<Invoice> {
return apiClient
.post<Invoice>(URL_CONSTANTS.BILLING.INVOICE_MEMO(id), input)
.then((r) => r.data);
},
/** Finance worklist: USD and ETB invoices awaiting manual payment confirmation. */
listOfflineUsd( listOfflineUsd(
filter: InvoiceListFilter, filter: InvoiceListFilter,
): Promise<PaginatedOfflineUsdInvoices> { ): Promise<PaginatedOfflineUsdInvoices> {
@@ -46,7 +70,7 @@ export const invoicesService = {
.then((r) => r.data); .then((r) => r.data);
}, },
/** Confirm a USD invoice paid by bank transfer — the slip file is required. */ /** Confirm an invoice (USD or ETB) paid manually — the slip file is required. */
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> { confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
const body = new FormData(); const body = new FormData();
body.append("file", file); body.append("file", file);

View File

@@ -28,6 +28,8 @@ import type {
LocomotiveRecord, LocomotiveRecord,
PinWagonsPayload, PinWagonsPayload,
RecordCheckpointPayload, RecordCheckpointPayload,
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow, StaffBookingWindow,
ScheduleMergePreview, ScheduleMergePreview,
TrainScheduleDetail, TrainScheduleDetail,
@@ -524,10 +526,11 @@ export const trainSchedulingService = {
dispatchSchedule: async ( dispatchSchedule: async (
scheduleId: string, scheduleId: string,
payload: DispatchSchedulePayload = {},
): Promise<TrainScheduleDetail> => { ): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>( const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId), URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
{}, payload,
); );
return unwrap(response.data); return unwrap(response.data);
}, },
@@ -696,6 +699,18 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
updateCheckpoint: async (
scheduleId: string,
sequenceNo: number,
payload: UpdateCheckpointPayload,
): Promise<TrainTrackResponse> => {
const response = await client.patch<TrainTrackResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINT(scheduleId, sequenceNo),
payload,
);
return unwrap(response.data);
},
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => { arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>( const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId), URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),

View File

@@ -26,6 +26,9 @@ export interface Wagon {
lengthMeters?: number; lengthMeters?: number;
} | null; } | null;
status: Freight.WagonStatus; status: Freight.WagonStatus;
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
lastMaintenanceAt?: string | null;
lastAvailableAt?: string | null;
currentYardId: string | null; currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null; currentYard?: { id: string; label?: string; code?: string } | null;
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */ /** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */

View File

@@ -251,6 +251,10 @@ export interface BookingDetail {
updatedAt: string; updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string }; // customer?: BookingNamedRef & { companyName?: string };
company?: BookingNamedRef & Partial<BookingCompany>; company?: BookingNamedRef & Partial<BookingCompany>;
/** Set when booked by a shipping line (then `companyId`/`company` are null). */
shippingLineCompanyId?: string | null;
/** Owner when booked by a shipping line (then `company` is absent). Hydrated server-side. */
shippingLineCompany?: { id: string; name: string; email?: string | null; phoneNumber?: string | null };
originYard?: BookingNamedRef; originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef; destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean }; serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
@@ -273,6 +277,8 @@ export interface BookingListRow {
/** Needed to link the reference to the contract's detail page. */ /** Needed to link the reference to the contract's detail page. */
contractId?: string | null; contractId?: string | null;
customerLabel: string; customerLabel: string;
/** True when the booking is owned by a shipping line rather than a customer company. */
isShippingLine?: boolean;
status: BookingStatus; status: BookingStatus;
scheduledDate: string; scheduledDate: string;
totalAmount: number; totalAmount: number;

View File

@@ -65,3 +65,16 @@ export interface EimsVerifyResult {
[section: string]: unknown; [section: string]: unknown;
}; };
} }
/** MoR `ModeOfPayment` enum — confirmed by a live schema error, verbatim spelling/casing. */
export const EIMS_MODE_OF_PAYMENT = [
"CASH",
"CHEQUE",
"CPO",
"Local Bank Transfer",
"SWIFT",
"Wire Transfer",
"Letter of Credit",
"Card",
] as const;
export type EimsModeOfPayment = (typeof EIMS_MODE_OF_PAYMENT)[number];

View File

@@ -13,6 +13,8 @@ export interface InvoiceListFilter {
companyId?: string; companyId?: string;
status?: Freight.InvoiceStatus; status?: Freight.InvoiceStatus;
search?: string; search?: string;
/** Manual-payments worklist only. */
currency?: "USD" | "ETB";
} }
/** Standard paginated list envelope (matches the customers/bookings service shape). */ /** Standard paginated list envelope (matches the customers/bookings service shape). */
@@ -22,20 +24,27 @@ export interface PaginatedInvoices {
} }
/** /**
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows * A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced
* carry the shipment's pay-window deadline so the list can show the same * rows carry the shipment's trade direction and pay-window deadline so the list
* countdown the customer sees — Finance must confirm before it closes. * can show the same countdown the customer sees — Finance must confirm before
* it closes.
*/ */
export interface OfflineUsdInvoice extends Invoice { export interface OfflineUsdInvoice extends Invoice {
booking: { booking: {
id: string; id: string;
reference: string; reference: string;
tradeDirection: string | null;
paymentDeadline: string | null; paymentDeadline: string | null;
paymentStatus: string; paymentStatus: string;
} | null; } | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
} }
export interface PaginatedOfflineUsdInvoices { export interface PaginatedOfflineUsdInvoices {
items: OfflineUsdInvoice[]; items: OfflineUsdInvoice[];
total: number; total: number;
} }
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
export type InvoiceCollectedSummary = Record<string, number>;

View File

@@ -196,6 +196,9 @@ export interface TrainScheduleListItem {
origin: string | null; origin: string | null;
destination: string | null; destination: string | null;
freightType?: FreightType | null; freightType?: FreightType | null;
/** Set when the departure is dedicated to one shipping line (hidden from customers). */
shippingLineCompanyId?: string | null;
shippingLineCompanyName?: string | null;
/** Built train (Train Builder) behind this departure, when scheduled by train. */ /** Built train (Train Builder) behind this departure, when scheduled by train. */
train?: { train?: {
id: string; id: string;
@@ -907,10 +910,22 @@ export interface TrainTrackResponse {
export interface RecordCheckpointPayload { export interface RecordCheckpointPayload {
sequenceNo: number; sequenceNo: number;
kind?: TrainCheckpointKind; kind?: TrainCheckpointKind;
/** When the train was at the station; defaults to now. Past OK, future rejected. */
occurredAt?: string; occurredAt?: string;
note?: string; note?: string;
} }
/** Edit an already-logged leg — pure correction, no side effects. */
export interface UpdateCheckpointPayload {
occurredAt?: string;
note?: string | null;
}
export interface DispatchSchedulePayload {
/** Actual departure; defaults to now. Past OK, future rejected. */
actualDepartureAt?: string;
}
export interface TrainScheduleFilters { export interface TrainScheduleFilters {
originStationId?: string; originStationId?: string;
destinationStationId?: string; destinationStationId?: string;

View File

@@ -1,18 +1,32 @@
/** /**
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the * Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other * ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Container
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw * bookings never store a total at all — `cargoTotalWeightVgm` stays 0 and the
* VGM column showed a 20-item / 100T booking as "20 tons". * weight lives per line (`quantity × vgmPerUnitTons`). Every other booking
* stores tons in `cargoTotalWeightVgm` directly. Rendering the raw VGM column
* showed a 20-item / 100T booking as "20 tons" and every container booking as
* "0 tons".
*/ */
export function cargoTonsAndItems(booking: { export function cargoTonsAndItems(booking: {
freightType?: string | null; freightType?: string | null;
cargoTotalWeightVgm?: number | string | null; cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null; bulkTotalWeightTons?: number | string | null;
bookingContainers?: Array<{
quantity?: number | string | null;
vgmPerUnitTons?: number | string | null;
}> | null;
}): { tons: number; items: number | null } { }): { tons: number; items: number | null } {
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0); const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
const vgm = Number(booking.cargoTotalWeightVgm ?? 0); const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
if (booking.freightType === "BULK" && bulkTons > 0) { if (booking.freightType === "BULK" && bulkTons > 0) {
return { tons: bulkTons, items: vgm > 0 ? vgm : null }; return { tons: bulkTons, items: vgm > 0 ? vgm : null };
} }
if (vgm <= 0 && booking.bookingContainers?.length) {
const lineTons = booking.bookingContainers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0) * Number(c.vgmPerUnitTons ?? 0),
0,
);
return { tons: Math.round(lineTons * 1000) / 1000, items: null };
}
return { tons: vgm, items: null }; return { tons: vgm, items: null };
} }

View File

@@ -26,7 +26,7 @@ import {
Menu as MenuIcon, Menu as MenuIcon,
Plus, Plus,
RefreshCw, RefreshCw,
Search, // Search,
Settings, Settings,
Upload, Upload,
User, User,
@@ -423,7 +423,7 @@ export function AppLayout({
)} )}
{/* Search pill */} {/* Search pill */}
<Group {/* <Group
gap={8} gap={8}
align="center" align="center"
visibleFrom="sm" visibleFrom="sm"
@@ -441,7 +441,7 @@ export function AppLayout({
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}> <Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
Search shipments, bookings… Search shipments, bookings…
</Text> </Text>
</Group> </Group> */}
{/* Notifications */} {/* Notifications */}
<NotificationBellContainer /> <NotificationBellContainer />
@@ -549,7 +549,7 @@ export function AppLayout({
navigate("/bookings/new", { state: { fresh: true } }) navigate("/bookings/new", { state: { fresh: true } })
} }
> >
New Booking New Contract
</Menu.Item> </Menu.Item>
<Divider /> <Divider />
<Menu.Item <Menu.Item

View File

@@ -158,7 +158,9 @@ export function ReadonlyBookingView({
// the customer. Hide the customer's rebook everywhere and tell them GL will // the customer. Hide the customer's rebook everywhere and tell them GL will
// handle it. Non-customs bookings stay self-service. // handle it. Non-customs bookings stay self-service.
const isCustoms = Boolean(booking.customsClearingEnabled); const isCustoms = Boolean(booking.customsClearingEnabled);
const canSelfRebook = !isCustoms; // A PAID booking cancelled through wagon cancellation rebooks via its credit
// (WagonCancellationCard), not the fresh-booking rebook link.
const canSelfRebook = !isCustoms && booking.paymentStatus !== "PAID";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
const isClearance = [ const isClearance = [
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
@@ -219,7 +221,9 @@ export function ReadonlyBookingView({
subtitle={ subtitle={
status === "REJECTED" status === "REJECTED"
? "This booking request has been rejected." ? "This booking request has been rejected."
: "This booking process has been terminated." : booking.paymentStatus === "PAID"
? "All wagons were cancelled. Your paid freight is held as a credit — rebook it from the Wagon Cancellation card below."
: "This booking process has been terminated."
} }
reason={booking.latestChangeRequestNote} reason={booking.latestChangeRequestNote}
onRebook={canSelfRebook ? onRebook : undefined} onRebook={canSelfRebook ? onRebook : undefined}
@@ -348,6 +352,7 @@ export function ReadonlyBookingView({
<Tabs.Panel value="wagons"> <Tabs.Panel value="wagons">
<WagonsTab <WagonsTab
bookingId={booking.id} bookingId={booking.id}
currency={booking.paymentCurrency}
cancellable={ cancellable={
booking.status === "PAID" && booking.status === "PAID" &&
booking.paymentStatus === "PAID" && booking.paymentStatus === "PAID" &&

View File

@@ -11,7 +11,7 @@ import {
Textarea, Textarea,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react"; import { CheckCircle2, Clock, CreditCard } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@@ -76,7 +76,7 @@ const apiErrorMessage = (error: unknown, fallback: string) => {
const th = { color: "#9AA8B5", fontSize: 11 } as const; const th = { color: "#9AA8B5", fontSize: 11 } as const;
/** /**
* Partial wagon cancellation on a PAID contract booking: request a cut (fee * Wagon cancellation (partial or whole) on a PAID contract booking: request a cut (fee
* previewed first), pay the cancellation fee, then rebook the freed credit * previewed first), pay the cancellation fee, then rebook the freed credit
* onto another shipment day — plus the booking's cancellation history. * onto another shipment day — plus the booking's cancellation history.
* Wagons leave the schedule at request time; the fee settles the credit. * Wagons leave the schedule at request time; the fee settles the credit.
@@ -90,10 +90,13 @@ export function WagonCancellationCard({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const status = booking.status as string; const status = booking.status as string;
const eligible = const paidContract =
status === "PAID" && (booking.paymentStatus as string) === "PAID" && !!booking.contractId;
(booking.paymentStatus as string) === "PAID" && // New cuts only on a live PAID booking; a booking fully cancelled through
!!booking.contractId; // this flow (status CANCELLED) still shows the card so its credit can be
// paid for / rebooked.
const canRequest = status === "PAID" && paidContract;
const eligible = paidContract && (status === "PAID" || status === "CANCELLED");
const isBulk = booking.freightType === "BULK"; const isBulk = booking.freightType === "BULK";
const detail = booking as BookingDetail; const detail = booking as BookingDetail;
@@ -226,12 +229,13 @@ export function WagonCancellationCard({
}); });
if (!eligible) return null; if (!eligible) return null;
if (!canRequest && !ownRows.length) return null;
return ( return (
<SectionCard> <SectionCard>
<Group justify="space-between" align="center" mb="sm"> <Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle> <CardTitle>Wagon Cancellation</CardTitle>
{!openRow && !creditRow && ( {/* {canRequest && !openRow && !creditRow && (
<Button <Button
variant="default" variant="default"
radius="md" radius="md"
@@ -240,7 +244,7 @@ export function WagonCancellationCard({
> >
Cancel wagons Cancel wagons
</Button> </Button>
)} )} */}
</Group> </Group>
{openRow ? ( {openRow ? (
@@ -305,9 +309,9 @@ export function WagonCancellationCard({
</Stack> </Stack>
) : ( ) : (
<Text fz={13} c="#475569"> <Text fz={13} c="#475569">
Need fewer wagons than you paid for? Cancel part of this booking for Need fewer wagons than you paid for or none? Cancel part or all of
a per-wagon fee the freed freight amount becomes a credit you can this booking for a per-wagon fee the freed freight amount becomes a
rebook onto another shipment day. credit you can rebook onto another shipment day.
</Text> </Text>
)} )}
@@ -401,16 +405,16 @@ export function WagonCancellationCard({
{booking.reference} {booking.reference}
</Text>{" "} </Text>{" "}
to cancel. A per-wagon fee applies; once it&apos;s paid the wagons to cancel. A per-wagon fee applies; once it&apos;s paid the wagons
are released and the freed amount becomes a rebooking credit. At are released and the freed amount becomes a rebooking credit.
least one wagon must remain to cancel everything, cancel the Cancelling every wagon cancels the whole booking the full freight
whole booking instead. amount becomes your credit.
</Text> </Text>
{isBulk ? ( {isBulk ? (
<NumberInput <NumberInput
label="Wagons to cancel" label="Wagons to cancel"
min={1} min={1}
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined} max={wagonsRequired > 0 ? wagonsRequired : undefined}
allowDecimal={false} allowDecimal={false}
value={wagons} value={wagons}
onChange={(v) => { onChange={(v) => {

Some files were not shown because too many files have changed in this diff Show More