mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
9
apps/edr-freight-api/.q.mjs
Normal file
9
apps/edr-freight-api/.q.mjs
Normal 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();
|
||||
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,23 @@ export interface EimsConfig {
|
||||
privateKeyPath: string;
|
||||
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
|
||||
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;
|
||||
/** Re-authenticate this many ms before the access token actually expires. */
|
||||
tokenSkewMs: number;
|
||||
@@ -80,7 +97,19 @@ export interface EimsInvoiceConfig {
|
||||
paymentMode: string;
|
||||
paymentTerm: 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;
|
||||
/**
|
||||
* 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`
|
||||
* ("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>;
|
||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||
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` +
|
||||
* `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;
|
||||
}
|
||||
|
||||
const REQUIRED_VARS = [
|
||||
"EIMS_CLIENT_ID",
|
||||
"EIMS_CLIENT_SECRET",
|
||||
"EIMS_API_KEY",
|
||||
"EIMS_TIN",
|
||||
"EIMS_PRIVATE_KEY_PATH",
|
||||
"EIMS_CERTIFICATE_PATH",
|
||||
] as const;
|
||||
const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
|
||||
|
||||
// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
|
||||
// from REQUIRED_VARS since it's "at least one of", not "this exact var".
|
||||
const REQUIRED_ANY_OF: string[][] = [
|
||||
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
|
||||
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
|
||||
];
|
||||
|
||||
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
@@ -143,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record<string, string> => {
|
||||
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. */
|
||||
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
||||
if (raw === undefined || raw === "") return null;
|
||||
@@ -169,6 +214,10 @@ export default registerAs("eims", (): EimsConfig => {
|
||||
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
|
||||
privateKeyPath: process.env.EIMS_PRIVATE_KEY_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,
|
||||
tokenSkewMs,
|
||||
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
|
||||
@@ -208,8 +257,10 @@ export default registerAs("eims", (): EimsConfig => {
|
||||
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
|
||||
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_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),
|
||||
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_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;
|
||||
|
||||
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) {
|
||||
throw new Error(
|
||||
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
@@ -11,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
import {
|
||||
ForgotPasswordService,
|
||||
RESET_LINK_TTL_MS,
|
||||
type ResetTicket,
|
||||
} from "./forgot-password.service";
|
||||
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. */
|
||||
export interface CustomerResetTarget {
|
||||
@@ -116,6 +116,22 @@ export class CustomerResetService {
|
||||
channel: ResetChannel,
|
||||
options?: { scope?: string; allowWithoutCredential?: boolean },
|
||||
): 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
|
||||
? await this.forgotPasswordService.resolveActivatableUserById(userId)
|
||||
: await this.forgotPasswordService.resolveActiveUserById(userId);
|
||||
@@ -128,51 +144,58 @@ export class CustomerResetService {
|
||||
: " (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.
|
||||
* Callers have already resolved `user` to an active account.
|
||||
* Shared tail: send the already-minted ticket to a resolved target → report.
|
||||
*/
|
||||
private async deliverResetLink(
|
||||
user: User,
|
||||
target: OtpTarget,
|
||||
userId: string,
|
||||
channel: ResetChannel,
|
||||
ticket: ResetTicket,
|
||||
scope?: string,
|
||||
): 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
|
||||
// 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 expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
||||
this.logger.log(
|
||||
`Staff-triggered shipping line ${link}`,
|
||||
);
|
||||
|
||||
const { queued } = target.email
|
||||
? await this.emailClient.sendEmail({
|
||||
|
||||
@@ -212,7 +212,9 @@ export class ForgotPasswordService {
|
||||
* is the proof).
|
||||
*/
|
||||
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);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -29,6 +30,7 @@ import { actorLabel } from "../warehouses/current-actor.util";
|
||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
import { IssueMemoDto } from "./dto/issue-memo.dto";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@@ -38,6 +40,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
FREIGHT_PERMS.invoices.memoIssue,
|
||||
])
|
||||
@ApiBearerAuth()
|
||||
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")
|
||||
@ApiOperation({ summary: "Get an invoice with its line items" })
|
||||
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@@ -72,7 +92,7 @@ export class BillingController {
|
||||
@Get("offline-usd")
|
||||
@ApiOperation({
|
||||
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) {
|
||||
return this.billingService.findOfflineUsdPaginated(query);
|
||||
@@ -84,7 +104,7 @@ export class BillingController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
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(
|
||||
@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")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.document(id);
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
@@ -774,6 +927,7 @@ describe("BillingService.document", () => {
|
||||
|
||||
const build = (invoice: Record<string, unknown>) => {
|
||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
||||
const service = new BillingService(
|
||||
{} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||
@@ -781,7 +935,7 @@ describe("BillingService.document", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ render } as never,
|
||||
{ render, renderThermal } as never,
|
||||
{} as never,
|
||||
{
|
||||
get: (key: string) =>
|
||||
@@ -790,7 +944,7 @@ describe("BillingService.document", () => {
|
||||
: undefined,
|
||||
} as never, // config
|
||||
);
|
||||
return { service, render };
|
||||
return { service, render, renderThermal };
|
||||
};
|
||||
|
||||
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.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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,14 +10,16 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
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";
|
||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||
// payers straight off the table.
|
||||
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 { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
@@ -25,6 +27,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
@@ -46,10 +49,18 @@ export interface PayInvoiceOptions {
|
||||
export interface OfflineUsdBookingInfo {
|
||||
id: string;
|
||||
reference: string;
|
||||
tradeDirection: string | null;
|
||||
paymentDeadline: Date | null;
|
||||
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. */
|
||||
export interface RecordPaymentInput {
|
||||
/** Amount settled by this payment; must be > 0. */
|
||||
@@ -145,6 +156,18 @@ export interface GenerateInvoiceInput {
|
||||
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>`. */
|
||||
export interface InvoiceEventPayload {
|
||||
invoiceId: string;
|
||||
@@ -192,6 +215,40 @@ export class BillingService {
|
||||
* company (customer detail "Invoices" tab) and/or status/search (global
|
||||
* 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(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
@@ -215,50 +272,100 @@ export class BillingService {
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
this.applyInvoiceFilters(qb, filter);
|
||||
|
||||
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,
|
||||
* never through the gateway), open ones by default or a single status when
|
||||
* filtered. Booking-sourced rows carry the booking's reference and pay-window
|
||||
* deadline so the UI can show the countdown and link to the booking.
|
||||
* Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
|
||||
* line (`companyId` null). No relation on `Invoice` to eager-load — see the
|
||||
* entity's doc comment — so this is a second query keyed off the ids
|
||||
* 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(
|
||||
filter: {
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<{
|
||||
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
|
||||
total: number;
|
||||
}> {
|
||||
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
@@ -267,11 +374,16 @@ export class BillingService {
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) = 'USD'")
|
||||
.where("UPPER(invoice.currency) IN ('USD', 'ETB')")
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.currency) {
|
||||
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
||||
currency: filter.currency,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} 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
|
||||
.filter((i) => i.source === "booking")
|
||||
@@ -292,11 +405,43 @@ export class BillingService {
|
||||
const bookings = bookingIds.length
|
||||
? await this.dataSource.getRepository(Booking).find({
|
||||
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]));
|
||||
|
||||
// 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 {
|
||||
items: items.map((inv) => {
|
||||
const b = byId.get(inv.sourceId);
|
||||
@@ -306,19 +451,22 @@ export class BillingService {
|
||||
? {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
tradeDirection: b.tradeDirection ?? null,
|
||||
paymentDeadline: b.paymentDeadline ?? null,
|
||||
paymentStatus: b.paymentStatus,
|
||||
}
|
||||
: null,
|
||||
} as Invoice & { booking: OfflineUsdBookingInfo | null };
|
||||
bookings: bookingsByInvoice.get(inv.id) ?? [],
|
||||
} as OfflineUsdInvoiceRow;
|
||||
}),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
|
||||
* against the invoice and settles the FULL outstanding balance through
|
||||
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
|
||||
* 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)
|
||||
* 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.
|
||||
@@ -337,11 +485,6 @@ export class BillingService {
|
||||
): Promise<Invoice> {
|
||||
const invoice = await this.invoices.findById(invoiceId);
|
||||
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) {
|
||||
throw new BadRequestException("The bank payment slip file is required.");
|
||||
}
|
||||
@@ -388,21 +531,30 @@ export class BillingService {
|
||||
relations: { company: true, companyProfile: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
|
||||
const lines = await this.invoiceLines.findAll({
|
||||
where: { invoiceId: id },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
return this.invoiceDocuments.render(
|
||||
await this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
const model = 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. */
|
||||
@@ -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. */
|
||||
private async bookingSummaryRows(
|
||||
invoice: Invoice,
|
||||
@@ -542,7 +685,7 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -709,11 +852,16 @@ export class BillingService {
|
||||
|
||||
// ── 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, {
|
||||
table: "freight.invoices",
|
||||
code: "INV",
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -736,9 +884,123 @@ export class BillingService {
|
||||
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(
|
||||
input: GenerateInvoiceInput,
|
||||
mg: EntityManager,
|
||||
code = "INV",
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const currency = input.currency ?? "ETB";
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
@@ -786,7 +1048,7 @@ export class BillingService {
|
||||
(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(
|
||||
mg.create(Invoice, {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,36 @@ import {
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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). */
|
||||
export interface InvoiceDocumentLine {
|
||||
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)} · ${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
|
||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||
@@ -241,18 +382,7 @@ export class InvoiceDocumentService {
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
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 date = formatDate;
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
@@ -283,7 +413,7 @@ export class InvoiceDocumentService {
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(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("");
|
||||
|
||||
|
||||
@@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = `
|
||||
}
|
||||
</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 {
|
||||
/** Label used in logs to identify the document kind. */
|
||||
label?: string;
|
||||
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
||||
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
|
||||
* 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;
|
||||
}
|
||||
@@ -54,17 +82,37 @@ export class PdfRenderService {
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
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.emulateMediaType("print");
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
landscape: opts.landscape ?? false,
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
const pdf = thermal
|
||||
? await page.pdf({
|
||||
width: `${THERMAL_PAGE_WIDTH_MM}mm`,
|
||||
height: `${await this.thermalContentHeightMm(page)}mm`,
|
||||
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);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
@@ -79,6 +127,15 @@ export class PdfRenderService {
|
||||
}
|
||||
} catch (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);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
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 {
|
||||
if (html.includes("edr-pdf-print-fix")) return html;
|
||||
if (html.includes("</head>")) {
|
||||
|
||||
@@ -39,4 +39,11 @@ export class FilterInvoiceDto {
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(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";
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -60,8 +60,11 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
||||
unitDefault: "PCS",
|
||||
incomeWithholdValue: 0,
|
||||
transactionWithholdValue: 0,
|
||||
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
|
||||
buyerCountryCodes: {},
|
||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||
buyerWeredaCodes: {},
|
||||
buyerCityCodes: {},
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -92,6 +95,9 @@ describe("toEimsInvoice", () => {
|
||||
|
||||
expect(doc.BuyerDetails).toEqual({
|
||||
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",
|
||||
HouseNumber: "NEW",
|
||||
IdNumber: null,
|
||||
@@ -100,7 +106,6 @@ describe("toEimsInvoice", () => {
|
||||
LegalName: "ABC Trading PLC",
|
||||
Phone: "0912345678",
|
||||
Region: "13",
|
||||
Country: null,
|
||||
Zone: "SHA",
|
||||
Kebele: "03",
|
||||
VatNumber: "123475885858",
|
||||
@@ -212,6 +217,59 @@ describe("toEimsInvoice", () => {
|
||||
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", () => {
|
||||
expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow(
|
||||
/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/);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
|
||||
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
|
||||
|
||||
@@ -20,8 +20,15 @@ import { round2 } from "./invoice-settlement.util";
|
||||
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
|
||||
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 {
|
||||
City: string | null;
|
||||
@@ -60,7 +67,9 @@ export interface EimsDocumentDetails {
|
||||
DocumentNumber: string;
|
||||
/** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */
|
||||
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 {
|
||||
@@ -212,10 +221,26 @@ export interface EimsMapperContext {
|
||||
unitDefault: string;
|
||||
incomeWithholdValue: 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;
|
||||
/** 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;
|
||||
/** 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.
|
||||
*
|
||||
@@ -232,9 +257,15 @@ export interface EimsMapperContext {
|
||||
* fail locally on an unmapped name rather than file a guess.
|
||||
*/
|
||||
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;
|
||||
buyerIdNumber?: string | null;
|
||||
buyerCity?: string | null;
|
||||
/** Required when the invoice currency is not ETB. */
|
||||
exchangeRate?: number | null;
|
||||
invoiceDiscount?: number | null;
|
||||
@@ -279,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string =>
|
||||
* exchange rate.
|
||||
*/
|
||||
/**
|
||||
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
|
||||
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
|
||||
* a guessed code onto a tax document is worse than refusing to file.
|
||||
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
|
||||
* numeric, otherwise looked up by name (case- and space-insensitive).
|
||||
*
|
||||
* 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(
|
||||
field: "Region" | "Wereda",
|
||||
field: "Region" | "Wereda" | "City",
|
||||
value: string | null | undefined,
|
||||
codes: Record<string, string>,
|
||||
envVar: string,
|
||||
invoiceNumber: string,
|
||||
): string {
|
||||
opts: { required?: boolean } = {},
|
||||
): string | null {
|
||||
const raw = (value ?? "").trim();
|
||||
if (LOCATION_CODE.test(raw)) return raw;
|
||||
|
||||
@@ -299,12 +335,61 @@ function resolveLocationCode(
|
||||
)?.[1];
|
||||
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
|
||||
|
||||
if (opts.required === false) return null;
|
||||
|
||||
throw new Error(
|
||||
`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}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
invoice: EimsMapperInvoice,
|
||||
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);
|
||||
if (Number.isNaN(issuedAt.getTime())) {
|
||||
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
|
||||
@@ -400,7 +505,16 @@ export function toEimsInvoice(
|
||||
|
||||
return {
|
||||
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,
|
||||
HouseNumber: company.houseNo ?? null,
|
||||
IdNumber: context.buyerIdNumber ?? null,
|
||||
@@ -415,7 +529,12 @@ export function toEimsInvoice(
|
||||
"EIMS_BUYER_REGION_CODES",
|
||||
invoice.invoiceNumber,
|
||||
),
|
||||
Country: context.buyerCountryCode ?? null,
|
||||
Country: resolveCountryCode(
|
||||
company.country,
|
||||
context.buyerCountryCodes,
|
||||
context.buyerCountryCode ?? null,
|
||||
invoice.invoiceNumber,
|
||||
),
|
||||
Zone: company.zone ?? null,
|
||||
Kebele: company.kebele ?? null,
|
||||
VatNumber: company.vatNumber ?? null,
|
||||
@@ -430,7 +549,8 @@ export function toEimsInvoice(
|
||||
DocumentDetails: {
|
||||
DocumentNumber: context.documentNumber,
|
||||
Date: (context.formatDate ?? formatEimsDate)(issuedAt),
|
||||
Type: EIMS_DOCUMENT_TYPE,
|
||||
Type: documentType,
|
||||
...(documentType !== "INV" ? { Reason: context.reason! } : {}),
|
||||
},
|
||||
ItemList,
|
||||
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },
|
||||
|
||||
@@ -180,4 +180,27 @@ export class Invoice extends BaseEntity {
|
||||
|
||||
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
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 { 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 { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
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 { BookingBatchService } from '../train-scheduling/booking-batch.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 { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.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
|
||||
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
|
||||
* configure it in the normal rates UI; the wagon flow requires the PER_WAGON
|
||||
* unit so the fee scales with the cancelled wagon count.
|
||||
* configure it in the normal rates UI, one PER_WAGON rate per trade direction
|
||||
* + 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';
|
||||
|
||||
/** `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. */
|
||||
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
|
||||
|
||||
@@ -62,8 +70,20 @@ interface RequestedCut {
|
||||
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):
|
||||
* 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 bookingsRepository: BookingsRepository,
|
||||
private readonly billing: BillingService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
@Inject(forwardRef(() => ContractBookingService))
|
||||
private readonly contractBooking: ContractBookingService,
|
||||
@Inject(forwardRef(() => ClearanceMilestoneService))
|
||||
@@ -120,14 +141,13 @@ export class BookingWagonCancellationService {
|
||||
}> {
|
||||
const booking = await this.loadCancellableBooking(bookingId);
|
||||
const cut = await this.resolveRequestedCut(booking, dto);
|
||||
const rate = await this.feeRate();
|
||||
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
|
||||
const fee = await this.priceFee(booking, cut);
|
||||
return {
|
||||
wagons: cut.wagons,
|
||||
weightTons: cut.weightTons,
|
||||
feePerWagon: Number(rate.rateValue),
|
||||
feeAmount,
|
||||
feeCurrency: rate.currency,
|
||||
feePerWagon: fee.perWagon,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
creditAmount: this.creditFor(booking, cut.wagons),
|
||||
};
|
||||
}
|
||||
@@ -146,8 +166,8 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
|
||||
const cut = await this.resolveRequestedCut(booking, dto);
|
||||
const rate = await this.feeRate();
|
||||
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
|
||||
const fee = await this.priceFee(booking, cut);
|
||||
const feeAmount = fee.amount;
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
@@ -156,9 +176,11 @@ export class BookingWagonCancellationService {
|
||||
weightTons: cut.weightTons,
|
||||
cancelledQuantities: cut.quantities,
|
||||
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,
|
||||
feeCurrency: rate.currency,
|
||||
feeCurrency: fee.currency,
|
||||
status: 'FEE_PENDING',
|
||||
reason: dto.reason ?? null,
|
||||
requestedByUserId: userId ?? null,
|
||||
@@ -173,15 +195,15 @@ export class BookingWagonCancellationService {
|
||||
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: rate.currency,
|
||||
currency: fee.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CANCELLATION_FEE',
|
||||
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
|
||||
quantity: cut.wagons,
|
||||
unitRate: Number(rate.rateValue),
|
||||
unitRate: fee.perWagon,
|
||||
amount: feeAmount,
|
||||
currency: rate.currency,
|
||||
currency: fee.currency,
|
||||
metadata: { wagonCancellationId: row.id },
|
||||
},
|
||||
],
|
||||
@@ -343,12 +365,28 @@ export class BookingWagonCancellationService {
|
||||
const preSplitQuantities =
|
||||
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, {
|
||||
wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)),
|
||||
cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
|
||||
totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)),
|
||||
wagonsRequired: Math.max(0, wagonsLeft),
|
||||
cargoTotalWeightVgm: Math.max(
|
||||
0,
|
||||
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
|
||||
),
|
||||
totalAmount: Math.max(
|
||||
0,
|
||||
round2(Number(booking.totalAmount) - Number(row.creditAmount)),
|
||||
),
|
||||
isSplit: true,
|
||||
preSplitQuantities,
|
||||
...(isFull
|
||||
? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null }
|
||||
: {}),
|
||||
} as never);
|
||||
|
||||
await manager.getRepository(BookingWagonCancellation).update(row.id, {
|
||||
@@ -360,11 +398,15 @@ export class BookingWagonCancellationService {
|
||||
});
|
||||
|
||||
const booking = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking);
|
||||
if (booking) {
|
||||
const whole = booking.status === 'CANCELLED';
|
||||
this.notifyCustomer(
|
||||
booking,
|
||||
'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 ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
|
||||
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(
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async rebook(
|
||||
@@ -401,6 +470,8 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
|
||||
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(
|
||||
source.contractId,
|
||||
createDto,
|
||||
@@ -413,9 +484,13 @@ export class BookingWagonCancellationService {
|
||||
|
||||
// The freight is already paid (credit) — mark PAID and let the existing
|
||||
// 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, {
|
||||
paymentStatus: 'PAID',
|
||||
status: 'PAID',
|
||||
totalAmount: Number(row.creditAmount),
|
||||
paymentCurrency: source.paymentCurrency,
|
||||
});
|
||||
await this.copyClearanceState(source, newBookingId);
|
||||
|
||||
@@ -532,16 +607,16 @@ export class BookingWagonCancellationService {
|
||||
const live = liveBySize.get(cut.containerSize) ?? 0;
|
||||
if (cut.quantity > live) {
|
||||
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;
|
||||
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize));
|
||||
wagons += cut.quantity * wagonsPerUnitForSize(sizeFtOf(cut.containerSize));
|
||||
}
|
||||
wagons = round2(wagons);
|
||||
if (wagons >= totalWagons) {
|
||||
if (wagons > totalWagons) {
|
||||
throw new BadRequestException(
|
||||
'That would cancel the whole booking — use 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
|
||||
@@ -577,9 +652,11 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
const weightShare = round3(
|
||||
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
|
||||
);
|
||||
// Whole-booking cut takes the exact total, no ratio rounding.
|
||||
const weightShare =
|
||||
wagons >= totalWagons
|
||||
? round3(Number(booking.cargoTotalWeightVgm))
|
||||
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
|
||||
return {
|
||||
wagons,
|
||||
weightTons: weightShare,
|
||||
@@ -594,17 +671,19 @@ export class BookingWagonCancellationService {
|
||||
if (!wagons || wagons <= 0) {
|
||||
throw new BadRequestException('Specify how many wagons to cancel.');
|
||||
}
|
||||
if (wagons >= totalWagons) {
|
||||
if (wagons > totalWagons) {
|
||||
throw new BadRequestException(
|
||||
'That would cancel the whole booking — use 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
|
||||
// rounding happens here too; switch to items_per_wagon_map sizing if bulk
|
||||
// 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;
|
||||
tons = isPerItem ? Math.floor(tons) : round3(tons);
|
||||
tons = isPerItem && !isFull ? Math.floor(tons) : round3(tons);
|
||||
if (tons <= 0) {
|
||||
throw new BadRequestException('The requested cut is too small to release cargo.');
|
||||
}
|
||||
@@ -641,19 +720,23 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
|
||||
const wagons = allocations.length;
|
||||
if (wagons >= totalWagons) {
|
||||
if (wagons > totalWagons) {
|
||||
throw new BadRequestException(
|
||||
'That would cancel the whole booking — use 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') {
|
||||
const allocated = allocations.reduce(
|
||||
(s, a) => s + Number(a.allocatedWeightTons || 0),
|
||||
0,
|
||||
);
|
||||
const tons =
|
||||
allocated > 0
|
||||
// Whole-booking cut takes the exact total; partial takes the wagons'
|
||||
// allocated tonnage (ratio fallback when nothing is allocated yet).
|
||||
const tons = isFull
|
||||
? round3(Number(booking.cargoTotalWeightVgm))
|
||||
: allocated > 0
|
||||
? round3(allocated)
|
||||
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
|
||||
return {
|
||||
@@ -714,21 +797,83 @@ export class BookingWagonCancellationService {
|
||||
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: {
|
||||
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
|
||||
rateUnit: 'PER_WAGON',
|
||||
status: 'LIVE',
|
||||
tradeDirection: booking.tradeDirection,
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
if (!rate) {
|
||||
throw new BadRequestException(
|
||||
'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).',
|
||||
const missing = (scope: string): BadRequestException =>
|
||||
new BadRequestException(
|
||||
`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);
|
||||
if (live < toDrop) {
|
||||
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) {
|
||||
@@ -795,7 +940,7 @@ export class BookingWagonCancellationService {
|
||||
});
|
||||
await manager.getRepository(BookingContainer).update(line.id, {
|
||||
quantity: qty - drop,
|
||||
wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))),
|
||||
wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(sizeFtOf(size))),
|
||||
totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm),
|
||||
hazardousQuantity: keptUnits.filter((u) => u.isHazardous).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));
|
||||
await manager.getRepository(BookingContainer).update(line.id, {
|
||||
quantity: kept.length,
|
||||
wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))),
|
||||
wagonsRequired: round2(kept.length * wagonsPerUnitForSize(sizeFtOf(size))),
|
||||
totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm),
|
||||
hazardousQuantity: kept.filter((u) => u.isHazardous).length,
|
||||
reeferQuantity: kept.filter((u) => u.isReefer).length,
|
||||
@@ -902,9 +1047,9 @@ export class BookingWagonCancellationService {
|
||||
booking: Booking,
|
||||
tons: number,
|
||||
): Promise<void> {
|
||||
if (tons >= Number(booking.cargoTotalWeightVgm)) {
|
||||
if (tons > Number(booking.cargoTotalWeightVgm)) {
|
||||
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) {
|
||||
|
||||
@@ -121,6 +121,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
BookingPricingService,
|
||||
ContainerValidationService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
BookingTransitionService,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.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 { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
@@ -80,6 +81,8 @@ export interface BookingListFilterOptions {
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
isGovernment?: 'true' | 'false';
|
||||
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
|
||||
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
|
||||
consolidationPaired?: string;
|
||||
}
|
||||
|
||||
@@ -781,16 +784,20 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
// by TypeORM and crashes).
|
||||
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
|
||||
.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');
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
// Free-text search spans joined columns (company, contract) that only this
|
||||
// list query joins — so it lives here, not in applyListFilters (shared
|
||||
// with getListSummaryMetrics, whose query builder has no joins).
|
||||
// Free-text search spans joined columns (company, shipping line, contract)
|
||||
// that only this list query joins — so it lives here, not in
|
||||
// applyListFilters (shared with getListSummaryMetrics, whose query builder
|
||||
// has no joins).
|
||||
if (options.search) {
|
||||
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}%` },
|
||||
);
|
||||
}
|
||||
@@ -1043,6 +1050,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} else if (options.isGovernment === '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) {
|
||||
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||
tradeDirection: options.tradeDirection,
|
||||
|
||||
@@ -1766,6 +1766,38 @@ export class BookingsService {
|
||||
pending.has(b.id);
|
||||
}
|
||||
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,
|
||||
destinationYardId: filter.destinationYardId,
|
||||
isGovernment: filter.isGovernment,
|
||||
customerKind: filter.customerKind,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
// DTO carries 'true'/'false' strings (query params); the repo option is a
|
||||
// real boolean — convert, preserving "not filtered" when absent.
|
||||
@@ -2048,6 +2081,7 @@ export class BookingsService {
|
||||
originYardId: filter.originYardId,
|
||||
destinationYardId: filter.destinationYardId,
|
||||
isGovernment: filter.isGovernment,
|
||||
customerKind: filter.customerKind,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
};
|
||||
|
||||
@@ -2134,6 +2168,8 @@ export class BookingsService {
|
||||
{ path: "booking" },
|
||||
);
|
||||
|
||||
await this.attachShippingLineCompanies([booking]);
|
||||
|
||||
if (booking.files && booking.files.length > 0) {
|
||||
booking.files = await Promise.all(
|
||||
booking.files.map(async (file: FileRecord) => {
|
||||
|
||||
@@ -67,9 +67,16 @@ export class ContainerValidationService {
|
||||
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
|
||||
if (!has20ft) return [];
|
||||
|
||||
const units = await this.load20ftUnits(booking);
|
||||
if (units.length < 2) return [];
|
||||
return this.validate20ftPairingUnits(await this.load20ftUnits(booking));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
return validate20ftWeightPairing(units, maxDiff);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,14 @@ export class FilterBookingDto {
|
||||
@IsIn(['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({
|
||||
enum: ['true', 'false'],
|
||||
description: 'Filter customs vs self-clearance (non-customs) bookings',
|
||||
@@ -140,7 +148,7 @@ export class FilterBookingDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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()
|
||||
@Transform(({ value }) =>
|
||||
|
||||
@@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
// Consumed by NotificationInboxModule for portal recipient targeting.
|
||||
ExternalProfileRepository,
|
||||
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 { }
|
||||
|
||||
@@ -110,6 +110,7 @@ function makeService(overrides?: {
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
isDeliveryOrderFileCode,
|
||||
@@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
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';
|
||||
|
||||
@@ -155,6 +157,7 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
) {}
|
||||
|
||||
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||
@@ -988,7 +991,39 @@ export class BookingClearanceService {
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
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[]> {
|
||||
@@ -1008,6 +1043,6 @@ export class BookingClearanceService {
|
||||
filtered.push(b);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
return this.attachContractSummary(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
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
|
||||
* 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 = (
|
||||
opts: {
|
||||
@@ -19,6 +23,7 @@ const build = (
|
||||
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
|
||||
candidate?: { id: string; invoiceNumber: string } | null;
|
||||
register?: jest.Mock;
|
||||
managerRow?: { eimsStatus: EimsInvoiceStatus } | null;
|
||||
} = {},
|
||||
) => {
|
||||
const register =
|
||||
@@ -34,12 +39,17 @@ const build = (
|
||||
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(
|
||||
{ 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,
|
||||
{ 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" };
|
||||
@@ -121,6 +131,40 @@ describe("EimsAutoSubmitService.tick", () => {
|
||||
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 () => {
|
||||
let release: () => void = () => {};
|
||||
const register = jest.fn().mockImplementation(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
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.
|
||||
@@ -67,21 +69,62 @@ export class EimsAutoSubmitService {
|
||||
const candidate = await this.nextCandidate();
|
||||
if (!candidate) return;
|
||||
|
||||
const view = await this.registration.registerInvoiceWithEims(candidate.id);
|
||||
this.logger.log(
|
||||
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
|
||||
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
|
||||
);
|
||||
try {
|
||||
const view = await this.registration.registerInvoiceWithEims(candidate.id);
|
||||
this.logger.log(
|
||||
`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) {
|
||||
// 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
|
||||
// next tick at the guard above.
|
||||
// (FAILED or UNKNOWN with the gateway's own message, or drained by failStalledCandidate
|
||||
// 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}`);
|
||||
} finally {
|
||||
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. */
|
||||
private async systemBlockReason(): Promise<string | null> {
|
||||
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =
|
||||
|
||||
@@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
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.
|
||||
*
|
||||
@@ -24,25 +35,61 @@ export class EimsCredentialsProvider {
|
||||
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 {
|
||||
if (this.privateKey) return this.privateKey;
|
||||
|
||||
const path = this.cfg.privateKeyPath;
|
||||
if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
|
||||
const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg;
|
||||
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;
|
||||
try {
|
||||
key = createPrivateKey(readFileSync(path));
|
||||
key = createPrivateKey(bytes);
|
||||
} 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(
|
||||
`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") {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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");
|
||||
|
||||
let bytes: Buffer;
|
||||
|
||||
@@ -157,6 +157,12 @@ export interface EimsContextInput {
|
||||
session: EimsSessionContext;
|
||||
/** Required when the invoice currency is not ETB. */
|
||||
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 {
|
||||
@@ -200,11 +206,16 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||
buyerCountryCode: invoice.buyerCountryCode,
|
||||
buyerCountryCodes: invoice.buyerCountryCodes,
|
||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||
buyerCityCodes: invoice.buyerCityCodes,
|
||||
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||
buyerIdType: invoice.buyerIdType,
|
||||
buyerIdNumber: invoice.buyerIdNumber,
|
||||
exchangeRate: input.exchangeRate ?? null,
|
||||
documentType: input.documentType,
|
||||
reason: input.reason ?? null,
|
||||
relatedDocument: input.relatedDocument ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { EimsAuthService } from "./eims-auth.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 { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
|
||||
@@ -172,6 +174,9 @@ const build = (
|
||||
} as unknown as EimsAuthService,
|
||||
{ notify } as unknown as NotificationInboxService,
|
||||
{ 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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
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 () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import {
|
||||
EimsDocumentType,
|
||||
EimsInvoiceRequest,
|
||||
EimsMapperLine,
|
||||
toEimsInvoice,
|
||||
@@ -25,13 +26,10 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { EimsAuthService } from "./eims-auth.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 {
|
||||
assertEimsInvoiceConfig,
|
||||
buildEimsContext,
|
||||
buildEimsSeller,
|
||||
} from "./eims-invoice-context";
|
||||
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||
import {
|
||||
EimsInvoiceError,
|
||||
EimsInvoiceStatus,
|
||||
@@ -82,6 +80,7 @@ export class EimsInvoiceRegistrationService {
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly sellerCache: EimsSellerCacheService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
@@ -96,6 +95,27 @@ export class EimsInvoiceRegistrationService {
|
||||
const invoice = await this.loadInvoiceForMapping(invoiceId);
|
||||
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
|
||||
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
|
||||
const session = await this.auth.getSessionContext();
|
||||
@@ -103,24 +123,29 @@ export class EimsInvoiceRegistrationService {
|
||||
const reservation = await this.reserve(invoiceId, session.systemNumber);
|
||||
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 ackDate: string | undefined;
|
||||
let signedQR: string | undefined;
|
||||
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.
|
||||
const result = await this.submit(request);
|
||||
irn = result.irn;
|
||||
@@ -446,6 +471,15 @@ export class EimsInvoiceRegistrationService {
|
||||
* 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.
|
||||
*
|
||||
* 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(
|
||||
invoiceId: string,
|
||||
@@ -453,10 +487,12 @@ export class EimsInvoiceRegistrationService {
|
||||
err: unknown,
|
||||
): Promise<void> {
|
||||
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 localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
|
||||
const lastError: EimsInvoiceError = {
|
||||
kind: api?.kind ?? "UNKNOWN",
|
||||
kind: api?.kind ?? localKind,
|
||||
message: (err as Error)?.message ?? "unknown error",
|
||||
httpStatus: api?.httpStatus,
|
||||
details: api?.details,
|
||||
@@ -563,10 +599,14 @@ export class EimsInvoiceRegistrationService {
|
||||
type: NotificationType.GENERIC,
|
||||
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
|
||||
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",
|
||||
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.`,
|
||||
link: `/dashboard/invoices/${invoiceId}`,
|
||||
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
|
||||
@@ -639,7 +679,7 @@ export class EimsInvoiceRegistrationService {
|
||||
): Promise<Invoice & { lines: EimsMapperLine[] }> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId },
|
||||
relations: { company: true, companyProfile: true },
|
||||
relations: { company: true, companyProfile: true, relatedInvoice: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
|
||||
|
||||
@@ -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 type { Response } from "express";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { sendPdf } from "../billing/billing.controller";
|
||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
@@ -110,4 +112,16 @@ export class EimsInvoiceController {
|
||||
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { DataSource } from "typeorm";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
@@ -40,10 +41,16 @@ class FakeDb {
|
||||
}
|
||||
|
||||
private manager = {
|
||||
findOne: async (entity: unknown, options: { where: { id: string } }) =>
|
||||
entity === Invoice
|
||||
? (this.invoices.get(options.where.id) ?? null)
|
||||
: (this.receipts.get(options.where.id) ?? null),
|
||||
findOne: async (
|
||||
entity: unknown,
|
||||
options: { where: { id?: string; invoiceId?: string } },
|
||||
) => {
|
||||
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 } }) =>
|
||||
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
|
||||
save: async (_entity: unknown, data: Record<string, unknown>) => {
|
||||
@@ -71,6 +78,7 @@ const build = (
|
||||
db: FakeDb,
|
||||
postBearer: jest.Mock,
|
||||
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||
documents: { render: jest.Mock } = { render: jest.fn() },
|
||||
) =>
|
||||
new EimsReceiptService(
|
||||
db.asDataSource(),
|
||||
@@ -78,6 +86,7 @@ const build = (
|
||||
{ postBearer } as unknown as EimsClientService,
|
||||
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
||||
{ directSend } as unknown as NotificationsService,
|
||||
documents as unknown as InvoiceDocumentService,
|
||||
);
|
||||
|
||||
const okResponse = (over: Record<string, unknown> = {}) => ({
|
||||
@@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
import { toReceiptDocumentModel } from "./eims-receipt-document.mapper";
|
||||
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
@@ -52,6 +54,7 @@ export class EimsReceiptService {
|
||||
private readonly client: EimsClientService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly documents: InvoiceDocumentService,
|
||||
) {}
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async submit(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -97,8 +97,14 @@ describe("EimsSignerService", () => {
|
||||
});
|
||||
|
||||
describe("EimsCredentialsProvider", () => {
|
||||
const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) =>
|
||||
new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService);
|
||||
const providerFor = (cfg: {
|
||||
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", () => {
|
||||
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
|
||||
@@ -115,4 +121,52 @@ describe("EimsCredentialsProvider", () => {
|
||||
writeFileSync(emptyPath, "");
|
||||
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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
||||
paymentTerm: "IMMIDIATE",
|
||||
unitDefault: "PCS",
|
||||
buyerCountryCode: null,
|
||||
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
|
||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
|
||||
taxCodeByChargeType: {},
|
||||
taxRateByChargeType: {},
|
||||
exciseByChargeType: {},
|
||||
@@ -57,6 +59,10 @@ export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
|
||||
systemType: EIMS_SYSTEM_TYPE,
|
||||
privateKeyPath: "/dev/null",
|
||||
certificatePath: "/dev/null",
|
||||
privateKeyBase64: "",
|
||||
certificateBase64: "",
|
||||
privateKeyPem: "",
|
||||
certificatePem: "",
|
||||
httpTimeoutMs: 30_000,
|
||||
tokenSkewMs: 45_000,
|
||||
autoSubmit: false,
|
||||
|
||||
@@ -10,7 +10,9 @@ export type EimsFailureKind =
|
||||
| "FORBIDDEN"
|
||||
| "RULE_VALIDATION"
|
||||
| "SERVER"
|
||||
| "UNKNOWN";
|
||||
| "UNKNOWN"
|
||||
| "CONFIG"
|
||||
| "LOCAL";
|
||||
|
||||
/** Raised when EIMS is disabled or its credential files are unusable. */
|
||||
export class EimsConfigException extends ServiceUnavailableException {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
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 { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
@@ -13,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||
import { EimsInvoiceController } from "./eims-invoice.controller";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsReceiptService } from "./eims-receipt.service";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
import { EimsReceipt } from "./entities/eims-receipt.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]),
|
||||
NotificationInboxModule,
|
||||
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],
|
||||
providers: [
|
||||
@@ -40,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
EimsAutoSubmitService,
|
||||
EimsCancellationService,
|
||||
EimsReceiptService,
|
||||
EimsSellerCacheService,
|
||||
],
|
||||
exports: [
|
||||
EimsAuthService,
|
||||
|
||||
@@ -53,7 +53,7 @@ export class CreateRateDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: CARGO_KINDS,
|
||||
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()
|
||||
@IsIn([...CARGO_KINDS])
|
||||
|
||||
@@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => {
|
||||
).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", () => {
|
||||
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
|
||||
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);
|
||||
|
||||
@@ -16,8 +16,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
|
||||
* 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
|
||||
* 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
|
||||
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
|
||||
* 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
|
||||
* 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
|
||||
@@ -29,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
|
||||
export function allowedRateUnits(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
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;
|
||||
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
|
||||
cargoUnitOfMeasure?: CargoUom;
|
||||
@@ -64,7 +63,9 @@ function unitsForShape(input: {
|
||||
// wagon the empties ride back on, or a flat fee.
|
||||
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
|
||||
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':
|
||||
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||
// fees per ton or per wagon. Billed on the booking invoice.
|
||||
|
||||
@@ -25,6 +25,19 @@ import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.reposito
|
||||
|
||||
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
|
||||
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. */
|
||||
interface YardScope {
|
||||
@@ -152,7 +165,11 @@ export class RatesService {
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
): 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 {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = 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') {
|
||||
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
|
||||
@@ -255,29 +275,29 @@ export class RatesService {
|
||||
// that absence is what marks it as the bulk fee.
|
||||
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
// fertilizer clear differently).
|
||||
// The bulk fee names the commodity it covers (sugar and fertilizer
|
||||
// clear — and cancel — differently).
|
||||
if (cargoKind === 'BULK' && !cargoTypeId) {
|
||||
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) {
|
||||
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;
|
||||
@@ -547,22 +567,21 @@ export class RatesService {
|
||||
const trigger = dto.trigger as Rate['trigger'];
|
||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||
// the engine never accidentally narrows a surcharge by container/direction.
|
||||
// Exceptions: customs clearance and empty-container return keep direction +
|
||||
// container type — both are sold per lane (and per container type).
|
||||
// Exceptions: the directed surcharges (customs clearance, cancellation,
|
||||
// empty-container return, lashing, fuel) keep direction + cargo scope.
|
||||
const isSurcharge = trigger !== 'ALWAYS';
|
||||
const cargoKind =
|
||||
trigger === 'CUSTOMS_CLEARANCE'
|
||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||
: null;
|
||||
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
|
||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||
: null;
|
||||
const containerTypeId =
|
||||
trigger === 'WITH_RETURN' ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
|
||||
? (dto.containerTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.containerTypeId ?? null);
|
||||
const cargoTypeId =
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
? (dto.cargoTypeId ?? null)
|
||||
@@ -574,10 +593,7 @@ export class RatesService {
|
||||
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
||||
// nothing about the direction.)
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||
? (dto.tradeDirection ?? null)
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
@@ -758,16 +774,15 @@ export class RatesService {
|
||||
|
||||
// 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).
|
||||
const cargoKind =
|
||||
trigger !== 'CUSTOMS_CLEARANCE'
|
||||
? null
|
||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||
const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger)
|
||||
? null
|
||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||
|
||||
const keepsContainerType =
|
||||
!isSurcharge ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
|
||||
const containerTypeId = !keepsContainerType
|
||||
? null
|
||||
: dto.containerTypeId !== undefined
|
||||
@@ -775,7 +790,7 @@ export class RatesService {
|
||||
: existing.containerTypeId;
|
||||
const keepsCargoType =
|
||||
!isSurcharge ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL';
|
||||
const cargoTypeId = !keepsCargoType
|
||||
@@ -784,10 +799,7 @@ export class RatesService {
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection
|
||||
|
||||
@@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => {
|
||||
previewTrainSchedule: jest.fn(),
|
||||
unassignBooking: jest.fn(),
|
||||
assignBookingsToSchedule: jest.fn(),
|
||||
windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}),
|
||||
emitWindowState: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
schedulingRescheduleRepository = {
|
||||
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
|
||||
@@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => {
|
||||
});
|
||||
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
|
||||
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(
|
||||
'sched-1',
|
||||
@@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => {
|
||||
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
|
||||
'sched-1',
|
||||
'DRAFT',
|
||||
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
|
||||
{
|
||||
scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'),
|
||||
windowClosesAt: newCloses,
|
||||
},
|
||||
txManager,
|
||||
);
|
||||
expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith(
|
||||
schedule,
|
||||
new Date('2099-06-22T10:00:00.000Z'),
|
||||
);
|
||||
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
|
||||
@@ -227,17 +227,25 @@ export class SchedulingRescheduleService {
|
||||
// through this manager without editing TrainSchedulingService. A failure
|
||||
// between those steps and this block can still leave partial state; a human
|
||||
// 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) => {
|
||||
if (newDeparture) {
|
||||
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
|
||||
// delegate to TrainSchedulingService.updateScheduleDate, which only
|
||||
// permits a date change while windowPhase === 'PRE_WINDOW' and would
|
||||
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
|
||||
// the booking-window fields are NOT re-derived for the new date here.
|
||||
// Raw write of scheduledDepartureDate: updateScheduleDate only permits a
|
||||
// date change while windowPhase === 'PRE_WINDOW' and would reject
|
||||
// reschedules of already-open (SCHEDULED) trains.
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
schedule.status as TrainScheduleStatus,
|
||||
{ scheduledDepartureDate: newDeparture },
|
||||
{ scheduledDepartureDate: newDeparture, ...windowFields },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
@@ -263,6 +271,7 @@ export class SchedulingRescheduleService {
|
||||
// `newDeparture` is null when the date was unchanged, so retained customers
|
||||
// are not falsely told the train was rescheduled.
|
||||
await this.notifyRescheduleOutcome(dto, newDeparture);
|
||||
if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
|
||||
|
||||
return { plan, schedule: assignResult };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { In, Repository } from "typeorm";
|
||||
import { BookingPricingService } from "../bookings/booking-pricing.service";
|
||||
import { BookingTransitionService } from "../bookings/booking-transition.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 { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
@@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
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. */
|
||||
private async requireShippingLine(userId: string) {
|
||||
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
|
||||
// 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
|
||||
@@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService {
|
||||
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 {
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
warnings: computed.warnings,
|
||||
overweightLines: computed.overweightLines,
|
||||
pairingErrors,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -132,37 +132,33 @@ export class ShippingLineCompaniesService {
|
||||
* 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
|
||||
* addition when the number is domestic, since the gateway silently drops
|
||||
* anything else (see `CustomerResetService`). Two links are two independent
|
||||
* single-use tickets; whichever the line opens first works.
|
||||
* anything else (see `CustomerResetService`). Both carry the SAME single-use
|
||||
* 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.
|
||||
*/
|
||||
async sendActivationLink(shippingLine: ShippingLineCompany) {
|
||||
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,
|
||||
ResetChannel.Email,
|
||||
channels,
|
||||
{ scope, allowWithoutCredential: true },
|
||||
);
|
||||
const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null;
|
||||
|
||||
if (!emailed) {
|
||||
this.logger.error(
|
||||
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
|
||||
);
|
||||
}
|
||||
|
||||
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
if (channels.includes(ResetChannel.Phone) && !sent.some((s) => s.channel === ResetChannel.Phone)) {
|
||||
this.logger.warn(`Activation SMS not sent for shipping line ${shippingLine.id}`);
|
||||
}
|
||||
|
||||
return emailed;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces
|
||||
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
||||
|
||||
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";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
@@ -37,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu
|
||||
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
|
||||
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-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 {
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
@@ -516,9 +520,14 @@ export class TrainSchedulingController {
|
||||
|
||||
@Post("schedules/:id/dispatch")
|
||||
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
|
||||
@ApiOperation({ summary: "Dispatch a scheduled train" })
|
||||
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
@ApiOperation({
|
||||
summary: "Dispatch a scheduled train (optional actual departure time, past allowed)",
|
||||
})
|
||||
dispatchSchedule(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: DispatchScheduleDto,
|
||||
) {
|
||||
return this.trainSchedulingService.dispatchSchedule(id, dto);
|
||||
}
|
||||
|
||||
@Get("intercity/bookings")
|
||||
@@ -956,6 +965,20 @@ export class TrainSchedulingController {
|
||||
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")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
|
||||
|
||||
export class RecordCheckpointDto {
|
||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||
@IsInt()
|
||||
@@ -24,17 +22,17 @@ export class RecordCheckpointDto {
|
||||
kind?: TrainCheckpointKind;
|
||||
|
||||
/**
|
||||
* A checkpoint records where the train is as staff observe it, and the final
|
||||
* one arrives the schedule — so a backdated value rewrites the journey after
|
||||
* the fact. Only "now" is accepted; omit the field and the service stamps it.
|
||||
* When the train was actually at the station — staff often log after the
|
||||
* fact, so a past value is allowed. The service rejects the future and any
|
||||
* value out of order with the neighbouring legs.
|
||||
*/
|
||||
@ApiProperty({
|
||||
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()
|
||||
@IsISO8601()
|
||||
@IsNotBackdated()
|
||||
occurredAt?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@@ -43,3 +41,30 @@ export class RecordCheckpointDto {
|
||||
@MaxLength(500)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => {
|
||||
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
|
||||
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
|
||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||
let trainCheckpointEventsRepository: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
// findGroupSiblings runs a query builder off dataSource.manager; default it
|
||||
@@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => {
|
||||
findByIdWithFullGraph: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
update: jest.fn(),
|
||||
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
trainScheduleBookingsRepository = {
|
||||
@@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const trainCheckpointEventsRepository = {
|
||||
trainCheckpointEventsRepository = {
|
||||
findBySchedule: jest.fn().mockResolvedValue([]),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
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', () => {
|
||||
const effective = (booking: unknown): number =>
|
||||
(service as never as { effectiveWagonsRequired(b: unknown): number })
|
||||
@@ -1764,5 +1821,31 @@ describe('TrainSchedulingService', () => {
|
||||
expect(written.windowPhase).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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,8 @@ import {
|
||||
} from '../booking-batch.constants';
|
||||
import { orderConsistWagons } from '../consist-order.util';
|
||||
import {
|
||||
bookingCloseCutoff,
|
||||
clampCloseToOfficeHours,
|
||||
computeExportWindowTimes,
|
||||
computeImportWindowTimes,
|
||||
earliestSchedulableDeparture,
|
||||
@@ -174,7 +176,11 @@ import {
|
||||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from '../booking-journey.service';
|
||||
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 { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
|
||||
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
|
||||
@@ -469,7 +475,7 @@ export class TrainSchedulingService {
|
||||
* used for lifecycle changes outside the window tick (create, cancel,
|
||||
* finalize, restamp). A push failure must never break the mutation.
|
||||
*/
|
||||
private async emitWindowState(scheduleId: string): Promise<void> {
|
||||
async emitWindowState(scheduleId: string): Promise<void> {
|
||||
try {
|
||||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
// 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
|
||||
* 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:
|
||||
* Booking-window fields that must follow a train's departure moving to
|
||||
* `departure` (any window phase). Shared by every reschedule path so the
|
||||
* "closes in" countdown always tracks the real departure.
|
||||
*
|
||||
* - 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 only
|
||||
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
|
||||
* schedule mid- or post-window keeps its timeline untouched.
|
||||
* 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).
|
||||
*
|
||||
* 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(
|
||||
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;
|
||||
|
||||
// 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.
|
||||
async windowFieldsForNewDeparture(
|
||||
schedule: TrainSchedule,
|
||||
departure: Date,
|
||||
): Promise<
|
||||
Partial<
|
||||
Pick<
|
||||
TrainSchedule,
|
||||
| 'windowOpensAt'
|
||||
| 'windowClosesAt'
|
||||
| 'windowPhase'
|
||||
| 'bookingWindowStatus'
|
||||
| 'docReviewCompletedAt'
|
||||
| 'docReviewEndsAt'
|
||||
| 'paymentPhaseEndsAt'
|
||||
>
|
||||
>
|
||||
> {
|
||||
const reopenFromDone =
|
||||
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
|
||||
const shiftOpenClose =
|
||||
schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null;
|
||||
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 () => {
|
||||
const merged = effectiveWindowConfig(
|
||||
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, {
|
||||
scheduledDepartureDate: departure,
|
||||
@@ -1306,7 +1368,7 @@ export class TrainSchedulingService {
|
||||
* 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
|
||||
* times they were shown, so those stay frozen. Returns the count re-stamped.
|
||||
*/
|
||||
*/
|
||||
async restampPendingWindows(): Promise<number> {
|
||||
const cfg = await this.getWindowConfig();
|
||||
const now = new Date();
|
||||
@@ -2160,7 +2222,15 @@ export class TrainSchedulingService {
|
||||
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);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -2287,7 +2357,9 @@ export class TrainSchedulingService {
|
||||
const removedBooking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.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(
|
||||
`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);
|
||||
}
|
||||
|
||||
async dispatchSchedule(scheduleId: string) {
|
||||
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -2643,6 +2715,9 @@ export class TrainSchedulingService {
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||||
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);
|
||||
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
|
||||
// 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) => {
|
||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||
if (setLocomotiveIds.length) {
|
||||
@@ -4150,6 +4224,7 @@ export class TrainSchedulingService {
|
||||
? TrainCheckpointKind.Arrived
|
||||
: TrainCheckpointKind.Passed);
|
||||
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.
|
||||
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) {
|
||||
await this.arriveSchedule(scheduleId);
|
||||
await this.arriveSchedule(scheduleId, occurredAt);
|
||||
} else {
|
||||
// Mid-corridor auto-unload: bookings destined for this yard alight the
|
||||
// moment the train is recorded here — the yard operator no longer has to
|
||||
@@ -4210,11 +4291,133 @@ export class TrainSchedulingService {
|
||||
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
|
||||
* 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);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -4223,7 +4426,9 @@ export class TrainSchedulingService {
|
||||
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.trainSchedulesRepository.updateStatus(
|
||||
@@ -4409,6 +4614,7 @@ export class TrainSchedulingService {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
shippingLineCompany: true,
|
||||
},
|
||||
order: { [sortBy]: sortOrder } as never,
|
||||
skip,
|
||||
@@ -6107,6 +6313,10 @@ export class TrainSchedulingService {
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
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.
|
||||
train: schedule.trainSet?.train
|
||||
? {
|
||||
@@ -8997,40 +9207,101 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
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) =>
|
||||
allocRepo.find({ where: { trainSetWagonId } });
|
||||
allocRepo.find({
|
||||
where: { trainSetWagonId },
|
||||
relations: { booking: { cargoType: { wagonTypes: true } } },
|
||||
});
|
||||
const sourceAllocs = await loadAllocations(source.id);
|
||||
if (!sourceAllocs.length) {
|
||||
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
|
||||
// built train (physical wagon with no slot row yet).
|
||||
// Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing —
|
||||
// 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 wagonForTarget = slotById
|
||||
? null
|
||||
: schedule.trainSet?.trainId
|
||||
? await this.dataSource.getRepository(Wagon).findOne({
|
||||
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
})
|
||||
: null;
|
||||
let wagonForTarget: Wagon | null = null;
|
||||
if (!slotById) {
|
||||
const wagon = await this.dataSource.getRepository(Wagon).findOne({
|
||||
where: { id: dto.targetWagonId },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
const onThisTrain =
|
||||
!!wagon &&
|
||||
((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) ||
|
||||
slots.some((w) => w.physicalWagonId === wagon.id));
|
||||
wagonForTarget = onThisTrain ? wagon : null;
|
||||
}
|
||||
if (!slotById && !wagonForTarget) {
|
||||
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 =
|
||||
slotById ??
|
||||
(wagonForTarget
|
||||
? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null)
|
||||
? (slots.find(
|
||||
(w) =>
|
||||
w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan),
|
||||
) ?? null)
|
||||
: null);
|
||||
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) : [];
|
||||
if (targetSlot && targetSlot.id === source.id) {
|
||||
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 load’s leg.',
|
||||
);
|
||||
}
|
||||
|
||||
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
|
||||
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
|
||||
@@ -9043,10 +9314,25 @@ export class TrainSchedulingService {
|
||||
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
|
||||
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
|
||||
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 = (
|
||||
allocs: WagonBookingAllocation[],
|
||||
label: string,
|
||||
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
|
||||
wagonType:
|
||||
| { id?: string; code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean }
|
||||
| null
|
||||
| undefined,
|
||||
capacityTons: number,
|
||||
) => {
|
||||
const incoming = loadTypesOf(allocs);
|
||||
@@ -9057,6 +9343,7 @@ export class TrainSchedulingService {
|
||||
const ok =
|
||||
supported.includes(loadType) ||
|
||||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
|
||||
(loadType === 'BULK' && bulkCargoAllows(allocs, wagonType.id)) ||
|
||||
supported.length === 0;
|
||||
if (!ok) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -121,8 +121,46 @@ export class WagonsService {
|
||||
* (yard workspace, coupling pickers) walk the pages client-side — see
|
||||
* `wagonService.listAll` in the backoffice.
|
||||
*/
|
||||
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
||||
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
||||
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> {
|
||||
|
||||
@@ -569,6 +569,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:invoices:eims_receipt_register",
|
||||
"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
|
||||
// the invoice. Moves money state, so it is its own grant, not part of view.
|
||||
perm(
|
||||
@@ -1874,6 +1882,7 @@ export const FREIGHT_PERMS = {
|
||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||
memoIssue: "edr_freight_app:invoices:memo_issue",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
},
|
||||
firstMile: {
|
||||
@@ -2405,10 +2414,14 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
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,
|
||||
// eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so
|
||||
// filing is not a Finance job function — the endpoints exist for controlled testing and
|
||||
// exceptional operations, and are assigned to named admins rather than a role preset.
|
||||
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
|
||||
// (the cron sweep runs as the system); these are the *manual* exceptional-operations
|
||||
// endpoints, and stay off the general Finance role. They are granted to the `chief` position
|
||||
// instead — see below — the same maker–checker split already used for shipping-line credit
|
||||
// mark-paid/cancel (Finance raises, chief decides).
|
||||
FREIGHT_PERMS.payments.view,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
// 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.invoices.view,
|
||||
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,
|
||||
// Decision side of the credit-invoice two-step: finance raises
|
||||
// mark-paid/cancel requests, the chief approves or rejects them.
|
||||
|
||||
@@ -315,7 +315,7 @@ const App = () => {
|
||||
}
|
||||
/>
|
||||
{/* 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
|
||||
tab hides itself if the user lacks the permission it used to be
|
||||
routed on. */}
|
||||
@@ -352,7 +352,7 @@ const App = () => {
|
||||
/>
|
||||
<Route
|
||||
path="usd-payments"
|
||||
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
|
||||
element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="invoices/:id"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
@@ -29,12 +29,37 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
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 (
|
||||
<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">
|
||||
<MetricTile
|
||||
label="Cargo type"
|
||||
value={booking.cargoType?.label ?? booking.freightType}
|
||||
label={isBulk ? "Commodity" : "Cargo type"}
|
||||
value={
|
||||
isBulk
|
||||
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "—")
|
||||
: (cargoDescription ?? booking.freightType)
|
||||
}
|
||||
/>
|
||||
<MetricTile label="Total VGM" value={`${tons} tons`} />
|
||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||
|
||||
@@ -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 { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
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";
|
||||
|
||||
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "edr-green",
|
||||
FAILED: "red",
|
||||
UNKNOWN: "orange",
|
||||
CANCELLED: "gray",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "Filed",
|
||||
FAILED: "Rejected",
|
||||
UNKNOWN: "Unacknowledged",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
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'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.
|
||||
*
|
||||
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
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(
|
||||
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
|
||||
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canFile && (
|
||||
<Group gap="sm">
|
||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||
{status !== "REGISTERED" && status !== "UNKNOWN" && (
|
||||
<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>
|
||||
)}
|
||||
</Group>
|
||||
{status === "CANCELLED" && (
|
||||
<Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
|
||||
{eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
|
||||
{eims.eimsCancellationRemark}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
|
||||
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ const RuleEngineFormDialog = ({
|
||||
next.containerTypeId = "";
|
||||
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).
|
||||
if (name === "cargoKind") {
|
||||
next.containerTypeId = "";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
@@ -111,7 +112,12 @@ export function LogPassYardWorkModal({
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
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 yardWorkQuery = useQuery(
|
||||
@@ -133,7 +139,13 @@ export function LogPassYardWorkModal({
|
||||
const doLogPass = () => {
|
||||
if (!station) return;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
|
||||
{
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
sequenceNo: station.sequenceNo,
|
||||
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
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">
|
||||
<Text size="xs" c="dimmed">
|
||||
{logged && pendingBoarders.length > 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment } from "react";
|
||||
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 type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
|
||||
@@ -14,6 +14,8 @@ export interface RouteCorridorTrackProps {
|
||||
canLog: boolean;
|
||||
loggingSeq?: number | null;
|
||||
onLogCheckpoint?: (sequenceNo: number) => void;
|
||||
/** Present when logged legs may be corrected (dispatched or arrived). */
|
||||
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
|
||||
}
|
||||
|
||||
const COLUMN_WIDTH = 150;
|
||||
@@ -31,6 +33,7 @@ export function RouteCorridorTrack({
|
||||
canLog,
|
||||
loggingSeq,
|
||||
onLogCheckpoint,
|
||||
onEditCheckpoint,
|
||||
}: RouteCorridorTrackProps) {
|
||||
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
||||
const lastIndex = stations.length - 1;
|
||||
@@ -160,14 +163,28 @@ export function RouteCorridorTrack({
|
||||
|
||||
{/* checkpoint time or action */}
|
||||
{checkpoint ? (
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
<Stack gap={2} align="center">
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</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 ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -95,7 +95,10 @@ export const QUERY_KEYS = {
|
||||
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||
offlineUsd: (filter?: InvoiceListFilter) =>
|
||||
["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,
|
||||
eimsReceipts: (id: string) => ["invoices", "eims", id, "receipts"] as const,
|
||||
},
|
||||
|
||||
BOOKINGS: {
|
||||
|
||||
@@ -134,8 +134,10 @@ export const URL_CONSTANTS = {
|
||||
|
||||
BILLING: {
|
||||
INVOICES: "/billing/invoices",
|
||||
INVOICES_SUMMARY: "/billing/invoices/summary",
|
||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||
INVOICE_MEMO: (id: string) => `/billing/invoices/${id}/memo`,
|
||||
OFFLINE_USD: "/billing/offline-usd",
|
||||
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
|
||||
},
|
||||
@@ -146,6 +148,12 @@ export const URL_CONSTANTS = {
|
||||
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
|
||||
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
|
||||
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: {
|
||||
@@ -482,6 +490,8 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||
CHECKPOINTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||
CHECKPOINT: (id: string, sequenceNo: number) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints/${sequenceNo}`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
RESCHEDULE_PREVIEW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||
|
||||
@@ -20,9 +20,13 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
reference: booking.reference,
|
||||
contractReference: booking.contractReference ?? null,
|
||||
contractId: booking.contractId ?? null,
|
||||
customerLabel: booking.isGovernment
|
||||
? (booking.governmentInstitution ?? "Government")
|
||||
: labelFromRef(booking.company, booking.companyId ?? undefined),
|
||||
// Shipping-line bookings have no customer company — the line IS the customer.
|
||||
customerLabel: booking.shippingLineCompany
|
||||
? 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),
|
||||
status: booking.status,
|
||||
scheduledDate: booking.scheduledDate,
|
||||
|
||||
@@ -145,10 +145,16 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:invoices:view",
|
||||
export: "edr_freight_app:invoices:export",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
|
||||
// irreversible at the tax authority, and resolving clears a system-wide filing block.
|
||||
// Filing with MoR EIMS. Off the general Finance role — automatic filing needs no permission
|
||||
// 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",
|
||||
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: {
|
||||
view: "edr_freight_app:first_mile:view",
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
Truck,
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -174,7 +176,14 @@ export default function BookingRequestDetailPage() {
|
||||
};
|
||||
|
||||
const company = booking.company;
|
||||
const shippingLine = booking.shippingLineCompany ?? null;
|
||||
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 containers = booking.bookingContainers ?? [];
|
||||
@@ -237,10 +246,27 @@ export default function BookingRequestDetailPage() {
|
||||
}
|
||||
subtitle={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<EntityLink
|
||||
to={company?.id ? `/dashboard/customers/${company.id}` : null}
|
||||
label={customerName ?? "—"}
|
||||
/>
|
||||
{shippingLine ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<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">
|
||||
· Scheduled {booking.scheduledDate}
|
||||
</Text>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
User,
|
||||
} from "lucide-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" },
|
||||
];
|
||||
|
||||
/** 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. */
|
||||
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
|
||||
([value, { label }]) => ({ value, label }),
|
||||
@@ -132,6 +139,7 @@ export default function BookingRequestsPage() {
|
||||
// split), so a deep link can never land behind "More filters" unseen.
|
||||
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: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{
|
||||
@@ -301,8 +309,17 @@ export default function BookingRequestsPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
<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.isShippingLine ? (
|
||||
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
|
||||
Shipping line
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -483,7 +500,7 @@ export default function BookingRequestsPage() {
|
||||
<FilterBar
|
||||
defs={bookingFilterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract or customer…"
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="booking-requests"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -67,6 +67,12 @@ const OWNERSHIP_OPTIONS = [
|
||||
{ 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() {
|
||||
const navigate = useNavigate();
|
||||
const { filterOptions } = useMyTradeAccess();
|
||||
@@ -91,6 +97,7 @@ export default function ClearanceDocumentsPage() {
|
||||
options: filterOptions(TRADE_DIRECTION_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: "created",
|
||||
@@ -131,17 +138,29 @@ export default function ClearanceDocumentsPage() {
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const customer = b.isGovernment
|
||||
? (b.governmentInstitution ?? "Government")
|
||||
: (b.company?.name ?? "—");
|
||||
const isShippingLine = Boolean(b.shippingLineCompany ?? b.shippingLineCompanyId);
|
||||
const customer = isShippingLine
|
||||
? (b.shippingLineCompany?.name ?? "Shipping line")
|
||||
: b.isGovernment
|
||||
? (b.governmentInstitution ?? "Government")
|
||||
: (b.company?.name ?? "—");
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<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 className="min-w-0">
|
||||
<p className="font-medium text-foreground">
|
||||
<p className="flex items-center gap-1.5 font-medium text-foreground">
|
||||
{customer}
|
||||
{isShippingLine ? (
|
||||
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
|
||||
Shipping line
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="size-3 shrink-0 opacity-70" />
|
||||
@@ -245,7 +264,7 @@ export default function ClearanceDocumentsPage() {
|
||||
<FilterBar
|
||||
defs={filterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract or customer…"
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="clearance-documents"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -52,6 +52,47 @@ import {
|
||||
summarizeRequestedCargo,
|
||||
} from "@/features/clearance/requestedCargo";
|
||||
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 }) {
|
||||
return customs ? (
|
||||
@@ -118,8 +159,8 @@ export default function ContractClearanceListPage() {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
|
||||
originLabel: b.originYard?.name ?? "—",
|
||||
destinationLabel: b.destinationYard?.name ?? "—",
|
||||
originLabel: yardLabel(b.originYard),
|
||||
destinationLabel: yardLabel(b.destinationYard),
|
||||
tradeDirection: b.tradeDirection ?? "—",
|
||||
freightType: b.freightType ?? "—",
|
||||
status: b.status,
|
||||
@@ -430,11 +471,10 @@ function ShipmentBookingsTable({
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">{row.original.originLabel}</Text>
|
||||
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm">{row.original.destinationLabel}</Text>
|
||||
</Group>
|
||||
<RouteLabel
|
||||
origin={row.original.originLabel}
|
||||
destination={row.original.destinationLabel}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -600,13 +640,13 @@ function ShipmentBookingsTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<DataTable<ShipmentBookingRow, unknown>
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={loading ? "loading" : error ? "error" : "success"}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import "./contract-clearance-table.css";
|
||||
|
||||
const prettyStatus = (s?: string | null) =>
|
||||
(s ?? "")
|
||||
@@ -214,15 +215,24 @@ function RouteCell({
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500}>
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
|
||||
normally (cells are otherwise nowrap) so it never spills over. */}
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
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>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={direction} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
@@ -676,7 +686,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<DataTable<ShipmentRow, unknown>
|
||||
columns={shipmentColumns}
|
||||
data={pagedShipmentRows}
|
||||
@@ -694,7 +704,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
manualPagination: true,
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -353,6 +353,10 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
|
||||
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
|
||||
{ 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: [
|
||||
// Run numbers are optional — a wagon sits in the fleet unassigned to any
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useAuth } from "@/auth/useAuth";
|
||||
@@ -8,13 +8,15 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
import InvoicesPanel from "./InvoicesPage";
|
||||
import UsdPaymentsPanel from "./UsdPaymentsPage";
|
||||
import PaymentsPanel from "../payments/PaymentsPage";
|
||||
|
||||
/**
|
||||
* Invoices, Payments, and USD Payments used to be three separate routes/pages
|
||||
* with near-identical chrome. They're merged here as URL-linkable tabs
|
||||
* (`?tab=`) on one page — each tab keeps the permission it was individually
|
||||
* gated on before, and just doesn't render if the user lacks it.
|
||||
* Invoices and USD Payments used to be separate routes/pages with
|
||||
* near-identical chrome. They're merged here as URL-linkable tabs (`?tab=`)
|
||||
* on one page — each tab keeps the permission it was individually gated on
|
||||
* 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 = [
|
||||
{
|
||||
@@ -27,21 +29,13 @@ const TABS = [
|
||||
Panel: InvoicesPanel,
|
||||
},
|
||||
{
|
||||
key: "payments",
|
||||
label: "Payments",
|
||||
icon: Wallet,
|
||||
permission: FREIGHT_PERMS.payments.view,
|
||||
subtitle: "View and reconcile booking payment transactions.",
|
||||
Panel: PaymentsPanel,
|
||||
},
|
||||
{
|
||||
key: "usd-payments",
|
||||
label: "USD Payments",
|
||||
key: "manual-payments",
|
||||
label: "Manual Payments",
|
||||
icon: Landmark,
|
||||
// Same gate as Invoices, not a dedicated key — mirrors the old route.
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
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,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -8,16 +8,18 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
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 { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useState } from "react";
|
||||
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 }) {
|
||||
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[] = [
|
||||
{ label: "Profile", value: invoice.companyProfile?.reference },
|
||||
{ label: "TIN", value: company?.tin },
|
||||
@@ -91,7 +115,7 @@ function RecipientCard({ invoice }: { invoice: Invoice }) {
|
||||
return (
|
||||
<LinkedEntityCard
|
||||
icon={Building2}
|
||||
title="Recipient"
|
||||
title="Billed to"
|
||||
name={company?.name ?? "Unnamed company"}
|
||||
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
|
||||
rows={rows}
|
||||
@@ -145,6 +169,7 @@ export default function InvoiceDetailPage() {
|
||||
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
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;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { data } = await invoicesService.downloadDocument(id);
|
||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
||||
const { data } = await invoicesService.downloadDocument(id, format);
|
||||
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 {
|
||||
setDownloading(false);
|
||||
}
|
||||
@@ -202,17 +242,34 @@ export default function InvoiceDetailPage() {
|
||||
subtitle={humanize(invoice.source)}
|
||||
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
disabled={!canExport}
|
||||
loading={downloading}
|
||||
onClick={() => void downloadDocument()}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
disabled={!canExport}
|
||||
loading={downloading}
|
||||
>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -21,7 +28,9 @@ import {
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||
import { api } from "@/services/api";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
@@ -79,6 +88,21 @@ export default function InvoicesPanel() {
|
||||
[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(
|
||||
() => [
|
||||
{
|
||||
@@ -95,7 +119,9 @@ export default function InvoicesPanel() {
|
||||
header: "Billed to",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||
{row.original.company?.name ?? "—"}
|
||||
{row.original.company?.name ??
|
||||
row.original.shippingLineCompany?.name ??
|
||||
"—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -170,7 +196,33 @@ export default function InvoicesPanel() {
|
||||
);
|
||||
|
||||
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}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
@@ -264,6 +316,7 @@ export default function InvoicesPanel() {
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
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)}`;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
useEffect(() => {
|
||||
if (!deadline) return;
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [deadline]);
|
||||
return now;
|
||||
}
|
||||
|
||||
function PayWindowCell({ deadline }: { deadline: string | null }) {
|
||||
const now = useNow(deadline);
|
||||
|
||||
if (!deadline) {
|
||||
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 {
|
||||
const deadline = row.booking?.paymentDeadline;
|
||||
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
|
||||
/**
|
||||
* "Confirm paid" for one row. Booking invoices are only confirmable while the
|
||||
* booking's pay window is open (the API refuses otherwise): no window yet →
|
||||
* 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() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
@@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() {
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
|
||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [reference, setReference] = useState("");
|
||||
@@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() {
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
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(
|
||||
@@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() {
|
||||
header: "Customer",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||
{row.original.company?.name ?? "—"}
|
||||
{row.original.company?.name ??
|
||||
row.original.shippingLineCompany?.name ??
|
||||
"—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() {
|
||||
header: "Booking",
|
||||
cell: ({ row }) => {
|
||||
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) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<ExternalLink size={13} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||
}}
|
||||
>
|
||||
{booking.reference}
|
||||
</Button>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<ExternalLink size={13} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||
}}
|
||||
>
|
||||
{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",
|
||||
header: "Status",
|
||||
@@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() {
|
||||
header: "",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => {
|
||||
const paid = row.original.status === "PAID";
|
||||
if (paid || !canConfirm) return null;
|
||||
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>
|
||||
);
|
||||
if (row.original.status === "PAID" || !canConfirm) return null;
|
||||
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
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
|
||||
size="sm"
|
||||
radius="md"
|
||||
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<Box miw={1160}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No USD invoices match your search."
|
||||
: "No USD invoices awaiting confirmation."
|
||||
? "No invoices match your search."
|
||||
: "No invoices awaiting manual payment confirmation."
|
||||
}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load USD invoices.",
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
@@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() {
|
||||
opened={confirming !== null}
|
||||
onClose={closeConfirm}
|
||||
title={
|
||||
<Text fw={700}>Confirm bank transfer payment</Text>
|
||||
<Text fw={700}>Confirm manual payment</Text>
|
||||
}
|
||||
radius="md"
|
||||
size="md"
|
||||
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
|
||||
<Text size="sm" c="dimmed">
|
||||
Confirming settles {confirming.invoiceNumber} in full (
|
||||
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
|
||||
marks the booking as paid. Upload the customer's bank slip
|
||||
first — this cannot be undone.
|
||||
marks the booking as paid — exactly as if the customer had paid
|
||||
online. Upload the customer's bank slip or receipt first —
|
||||
this cannot be undone.
|
||||
</Text>
|
||||
|
||||
<PhasedFileDropzone
|
||||
label="Bank payment slip"
|
||||
description="PDF or image of the customer's transfer slip."
|
||||
label="Payment slip / receipt"
|
||||
description="PDF or image of the customer's bank transfer slip or payment receipt."
|
||||
value={slip}
|
||||
onChange={setSlip}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Bank reference"
|
||||
description="Optional — the transfer reference from the slip."
|
||||
label="Payment reference"
|
||||
description="Optional — the transfer or receipt reference from the slip."
|
||||
placeholder="e.g. FT24091234567"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
|
||||
@@ -580,14 +580,10 @@ const RuleEngineResourcePage = () => {
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canUpdateControls}
|
||||
onEdit={
|
||||
config.slug === "container-types"
|
||||
? undefined
|
||||
: (record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}
|
||||
}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules"
|
||||
@@ -958,9 +954,7 @@ const RuleEngineResourcePage = () => {
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
readOnly={!canUpdate && !canDelete}
|
||||
onEdit={
|
||||
canUpdate && config.slug !== "container-types" ? openEdit : undefined
|
||||
}
|
||||
onEdit={canUpdate ? openEdit : undefined}
|
||||
onDelete={canDelete ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules"
|
||||
|
||||
@@ -187,7 +187,7 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ label: "Penalty", value: "CONSOLIDATION" },
|
||||
{ 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: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
||||
@@ -265,6 +265,13 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["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 });
|
||||
|
||||
/**
|
||||
@@ -304,7 +311,8 @@ const unitsForShape = (
|
||||
// Container-only service — per returned container, per wagon, or flat.
|
||||
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
// Wagon cancellation fee — scales with the cancelled wagons only.
|
||||
return ["PER_WAGON"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||
return cargoKind === "BULK"
|
||||
@@ -1054,9 +1062,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
|
||||
},
|
||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||
// scoped surcharges (customs clearance; empty-container return, which is
|
||||
// import-only for now so export is not offered) ────────────────────────
|
||||
// ── Trade direction — Bulk & Container base freight, plus the directed
|
||||
// surcharges (customs clearance, cancellation, lashing, fuel; empty-
|
||||
// container return, which is import-only for now so export is not
|
||||
// offered) ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
@@ -1074,9 +1083,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
!isShippingLineRate(v) &&
|
||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(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
|
||||
// the customer's contract — so the direction is stated, not asked. Shown
|
||||
@@ -1097,8 +1110,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
computeValue: () => "IMPORT",
|
||||
showIf: hasShippingLine,
|
||||
},
|
||||
// ── Cargo kind — customs clearance is priced separately for containers
|
||||
// (one rate per container type) and bulk ───────────────────────────────
|
||||
// ── Cargo kind — customs clearance and the cancellation fee are priced
|
||||
// separately for containers (one rate per container type) and bulk (one
|
||||
// rate per commodity) ──────────────────────────────────────────────────
|
||||
{
|
||||
name: "cargoKind",
|
||||
label: "Cargo kind",
|
||||
@@ -1107,11 +1121,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: INTERCITY_KINDS,
|
||||
placeholder: "Is this fee for containers or bulk?",
|
||||
description:
|
||||
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
|
||||
"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) => v.appliesTo === "OTHER" && isCargoKindTrigger(v),
|
||||
// Not a stored column: a container fee carries its containerTypeId, a
|
||||
// bulk fee carries none.
|
||||
// bulk fee its cargoTypeId.
|
||||
getInitialValue: (record) =>
|
||||
record.containerTypeId ? "CONTAINER" : "BULK",
|
||||
},
|
||||
@@ -1124,10 +1137,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Which container type this fee covers",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||
isCargoKindTrigger(v) &&
|
||||
v.cargoKind === "CONTAINER",
|
||||
},
|
||||
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
|
||||
// ── Bulk cargo type — the bulk fee names its commodity ────────────────
|
||||
{
|
||||
name: "cargoTypeId",
|
||||
label: "Bulk cargo type",
|
||||
@@ -1136,7 +1149,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Which bulk commodity this fee covers",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||
isCargoKindTrigger(v) &&
|
||||
v.cargoKind === "BULK",
|
||||
},
|
||||
// ── Cargo type — a fuel rate names the commodity it covers (different
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MapPin,
|
||||
Navigation,
|
||||
PackageCheck,
|
||||
Pencil,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -29,9 +30,10 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import type { TrackStation } from "@/types/trainScheduling";
|
||||
import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
@@ -156,6 +158,16 @@ export default function TrainScheduleTrackPage() {
|
||||
const recordCheckpoint = useMutation(
|
||||
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.
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
@@ -241,15 +253,30 @@ export default function TrainScheduleTrackPage() {
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
|
||||
if (!station) return;
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
if (station && stationHasWork(station)) {
|
||||
if (stationHasWork(station)) {
|
||||
setYardModal({ station, isFinal, alreadyLogged: false });
|
||||
return;
|
||||
}
|
||||
setLogModal({ station, isFinal });
|
||||
};
|
||||
|
||||
const submitLog = (values: { occurredAt: string; note: string }) => {
|
||||
if (!logModal) return;
|
||||
const { station, isFinal } = logModal;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo } },
|
||||
{
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
sequenceNo: station.sequenceNo,
|
||||
occurredAt: values.occurredAt,
|
||||
...(values.note ? { note: values.note } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLogModal(null);
|
||||
toast({
|
||||
title: isFinal
|
||||
? "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
|
||||
// boarder there that is still unloaded can be loaded until the next pass.
|
||||
const currentStationObj = track.stations.find(
|
||||
@@ -502,6 +555,7 @@ export default function TrainScheduleTrackPage() {
|
||||
: null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
onEditCheckpoint={canEdit ? setEditModal : undefined}
|
||||
/>
|
||||
|
||||
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||
@@ -595,24 +649,38 @@ export default function TrainScheduleTrackPage() {
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{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>
|
||||
}
|
||||
>
|
||||
@@ -630,6 +698,39 @@ export default function TrainScheduleTrackPage() {
|
||||
)}
|
||||
</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
|
||||
opened={yardModal !== null}
|
||||
onClose={() => setYardModal(null)}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Navigation,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Grid3x3,
|
||||
Route as RouteIcon,
|
||||
Ruler,
|
||||
Send,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
Weight,
|
||||
Workflow as WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
@@ -57,6 +59,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||
@@ -125,6 +128,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||
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 [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
||||
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
|
||||
@@ -477,7 +487,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const runDispatch = async () => {
|
||||
setDispatchConfirmOpen(false);
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
await dispatch.mutateAsync({
|
||||
id: scheduleId,
|
||||
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
|
||||
});
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
successDescription: "Marshalling document generated for the dispatched train.",
|
||||
@@ -873,7 +886,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
radius="md"
|
||||
leftSection={<Send size={18} />}
|
||||
loading={dispatch.isPending}
|
||||
onClick={() => setDispatchConfirmOpen(true)}
|
||||
onClick={openDispatchConfirm}
|
||||
>
|
||||
Dispatch train
|
||||
</Button>
|
||||
@@ -1271,6 +1284,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
||||
Leg capacity
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
|
||||
Leg board
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
@@ -1359,6 +1375,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<LegCapacityPanel schedule={schedule} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="leg-board">
|
||||
<LegLoadBoardPanel
|
||||
schedule={schedule}
|
||||
onChanged={() => void detailQuery.refetch()}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
||||
</Tabs.Panel>
|
||||
@@ -1444,6 +1467,17 @@ export default function TrainScheduleV2DetailPage() {
|
||||
undone.
|
||||
</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 ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
@@ -134,6 +135,8 @@ export default function TrainScheduleV2ListPage() {
|
||||
// confirmation.
|
||||
const [dispatchTarget, setDispatchTarget] =
|
||||
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.
|
||||
const [cancelTarget, setCancelTarget] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
@@ -362,6 +365,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
{row.original.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={row.original} />
|
||||
</Group>
|
||||
<Box maw={220}>
|
||||
<RouteCorridor
|
||||
@@ -507,7 +511,10 @@ export default function TrainScheduleV2ListPage() {
|
||||
{canDispatch && schedule.status === "SCHEDULED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Play size={15} />}
|
||||
onClick={() => setDispatchTarget(schedule)}
|
||||
onClick={() => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchTarget(schedule);
|
||||
}}
|
||||
>
|
||||
Start (dispatch) train
|
||||
</Menu.Item>
|
||||
@@ -742,7 +749,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
onRowClick={(schedule) =>
|
||||
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={
|
||||
schedulesQuery.isError
|
||||
? {
|
||||
@@ -954,6 +965,16 @@ export default function TrainScheduleV2ListPage() {
|
||||
wagons or cargo not yet marked loaded — those warnings are shown
|
||||
there, not here.
|
||||
</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">
|
||||
<Button variant="default" onClick={() => setDispatchTarget(null)}>
|
||||
Cancel
|
||||
@@ -965,7 +986,12 @@ export default function TrainScheduleV2ListPage() {
|
||||
onClick={async () => {
|
||||
if (!dispatchTarget) return;
|
||||
try {
|
||||
await dispatchSchedule.mutateAsync(dispatchTarget.id);
|
||||
await dispatchSchedule.mutateAsync({
|
||||
id: dispatchTarget.id,
|
||||
payload: dispatchAt
|
||||
? { actualDepartureAt: dispatchAt.toISOString() }
|
||||
: {},
|
||||
});
|
||||
toast({ title: "Train dispatched" });
|
||||
setDispatchTarget(null);
|
||||
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
|
||||
* 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 }) {
|
||||
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
||||
// renders rather than reading 0 used on every train.
|
||||
@@ -1133,6 +1173,7 @@ function ScheduleCard({
|
||||
withBorder
|
||||
onClick={onOpen}
|
||||
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">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
@@ -1182,6 +1223,7 @@ function ScheduleCard({
|
||||
{schedule.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={schedule} />
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||
|
||||
@@ -961,6 +961,7 @@ interface StandaloneReturnModalProps {
|
||||
|
||||
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
|
||||
const [containerNumber, setContainerNumber] = useState<string>("");
|
||||
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
@@ -1021,6 +1022,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
containers: [
|
||||
{
|
||||
containerNumber,
|
||||
containerSize: containerSize ?? undefined,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
yard: selectedYard?.name,
|
||||
@@ -1034,6 +1036,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
});
|
||||
|
||||
setContainerNumber("");
|
||||
setContainerSize(null);
|
||||
setReturnedBy(null);
|
||||
setReturnDate(new Date().toISOString().split("T")[0]);
|
||||
setWarehouse(null);
|
||||
@@ -1071,6 +1074,17 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
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
|
||||
label="Return Warehouse"
|
||||
placeholder="Select warehouse for container return"
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
} from "@/types/fileUploadSettings";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceCollectedSummary,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
@@ -80,6 +81,8 @@ import type {
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
@@ -173,7 +176,7 @@ import { customersService } from "./customers.service";
|
||||
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
||||
import { shippingLineCreditsService } from "./shippingLineCredits.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 { dropdownSettingsService } from "./dropdownSettings.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",
|
||||
"dispatch-schedule",
|
||||
(id) => trainSchedulingService.dispatchSchedule(id),
|
||||
({ id, payload }) => trainSchedulingService.dispatchSchedule(id, payload),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
@@ -917,6 +923,18 @@ export const api = {
|
||||
() => 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>(
|
||||
"train-scheduling",
|
||||
"arrive-schedule",
|
||||
@@ -3137,6 +3155,16 @@ export const api = {
|
||||
({ 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<
|
||||
{ filter: InvoiceListFilter },
|
||||
PaginatedOfflineUsdInvoices
|
||||
@@ -3189,6 +3217,51 @@ export const api = {
|
||||
undefined,
|
||||
({ 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: {
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface BookingListFilter {
|
||||
scheduledTo?: string;
|
||||
originYardId?: 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. */
|
||||
isGovernment?: "true" | "false";
|
||||
/** 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.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||
}
|
||||
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
|
||||
params,
|
||||
@@ -184,6 +187,7 @@ export const bookingsService = {
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||
if (filter.customsClearingEnabled)
|
||||
params.customsClearingEnabled = filter.customsClearingEnabled;
|
||||
if (filter.search) params.search = filter.search;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
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.
|
||||
@@ -36,4 +41,45 @@ export const eimsService = {
|
||||
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
|
||||
.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",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceCollectedSummary,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
@@ -23,19 +24,42 @@ export const invoicesService = {
|
||||
.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> {
|
||||
return apiClient
|
||||
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
|
||||
.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), {
|
||||
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(
|
||||
filter: InvoiceListFilter,
|
||||
): Promise<PaginatedOfflineUsdInvoices> {
|
||||
@@ -46,7 +70,7 @@ export const invoicesService = {
|
||||
.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> {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
|
||||
@@ -28,6 +28,8 @@ import type {
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
@@ -524,10 +526,11 @@ export const trainSchedulingService = {
|
||||
|
||||
dispatchSchedule: async (
|
||||
scheduleId: string,
|
||||
payload: DispatchSchedulePayload = {},
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
|
||||
{},
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
@@ -696,6 +699,18 @@ export const trainSchedulingService = {
|
||||
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> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface Wagon {
|
||||
lengthMeters?: number;
|
||||
} | null;
|
||||
status: Freight.WagonStatus;
|
||||
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
|
||||
lastMaintenanceAt?: string | null;
|
||||
lastAvailableAt?: string | null;
|
||||
currentYardId: string | null;
|
||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
|
||||
|
||||
@@ -251,6 +251,10 @@ export interface BookingDetail {
|
||||
updatedAt: string;
|
||||
// customer?: BookingNamedRef & { companyName?: string };
|
||||
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;
|
||||
destinationYard?: BookingNamedRef;
|
||||
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. */
|
||||
contractId?: string | null;
|
||||
customerLabel: string;
|
||||
/** True when the booking is owned by a shipping line rather than a customer company. */
|
||||
isShippingLine?: boolean;
|
||||
status: BookingStatus;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
|
||||
@@ -65,3 +65,16 @@ export interface EimsVerifyResult {
|
||||
[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];
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface InvoiceListFilter {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
/** Manual-payments worklist only. */
|
||||
currency?: "USD" | "ETB";
|
||||
}
|
||||
|
||||
/** 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
|
||||
* carry the shipment's pay-window deadline so the list can show the same
|
||||
* countdown the customer sees — Finance must confirm before it closes.
|
||||
* A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced
|
||||
* rows carry the shipment's trade direction and pay-window deadline so the list
|
||||
* can show the same countdown the customer sees — Finance must confirm before
|
||||
* it closes.
|
||||
*/
|
||||
export interface OfflineUsdInvoice extends Invoice {
|
||||
booking: {
|
||||
id: string;
|
||||
reference: string;
|
||||
tradeDirection: string | null;
|
||||
paymentDeadline: string | null;
|
||||
paymentStatus: string;
|
||||
} | null;
|
||||
/** Shipping-line credit invoices span many bookings — one entry per credit. */
|
||||
bookings: { id: string; reference: string; tradeDirection: string | null }[];
|
||||
}
|
||||
|
||||
export interface PaginatedOfflineUsdInvoices {
|
||||
items: OfflineUsdInvoice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
|
||||
export type InvoiceCollectedSummary = Record<string, number>;
|
||||
|
||||
@@ -196,6 +196,9 @@ export interface TrainScheduleListItem {
|
||||
origin: string | null;
|
||||
destination: string | 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. */
|
||||
train?: {
|
||||
id: string;
|
||||
@@ -907,10 +910,22 @@ export interface TrainTrackResponse {
|
||||
export interface RecordCheckpointPayload {
|
||||
sequenceNo: number;
|
||||
kind?: TrainCheckpointKind;
|
||||
/** When the train was at the station; defaults to now. Past OK, future rejected. */
|
||||
occurredAt?: 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 {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
/**
|
||||
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
|
||||
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
|
||||
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
|
||||
* VGM column showed a 20-item / 100T booking as "20 tons".
|
||||
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Container
|
||||
* bookings never store a total at all — `cargoTotalWeightVgm` stays 0 and the
|
||||
* 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: {
|
||||
freightType?: string | null;
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
bookingContainers?: Array<{
|
||||
quantity?: number | string | null;
|
||||
vgmPerUnitTons?: number | string | null;
|
||||
}> | null;
|
||||
}): { tons: number; items: number | null } {
|
||||
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
|
||||
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
if (booking.freightType === "BULK" && bulkTons > 0) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Menu as MenuIcon,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
// Search,
|
||||
Settings,
|
||||
Upload,
|
||||
User,
|
||||
@@ -423,7 +423,7 @@ export function AppLayout({
|
||||
)}
|
||||
|
||||
{/* Search pill */}
|
||||
<Group
|
||||
{/* <Group
|
||||
gap={8}
|
||||
align="center"
|
||||
visibleFrom="sm"
|
||||
@@ -441,7 +441,7 @@ export function AppLayout({
|
||||
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
|
||||
Search shipments, bookings…
|
||||
</Text>
|
||||
</Group>
|
||||
</Group> */}
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationBellContainer />
|
||||
@@ -549,7 +549,7 @@ export function AppLayout({
|
||||
navigate("/bookings/new", { state: { fresh: true } })
|
||||
}
|
||||
>
|
||||
New Booking
|
||||
New Contract
|
||||
</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
|
||||
@@ -158,7 +158,9 @@ export function ReadonlyBookingView({
|
||||
// the customer. Hide the customer's rebook everywhere and tell them GL will
|
||||
// handle it. Non-customs bookings stay self-service.
|
||||
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 isClearance = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
@@ -219,7 +221,9 @@ export function ReadonlyBookingView({
|
||||
subtitle={
|
||||
status === "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}
|
||||
onRebook={canSelfRebook ? onRebook : undefined}
|
||||
@@ -348,6 +352,7 @@ export function ReadonlyBookingView({
|
||||
<Tabs.Panel value="wagons">
|
||||
<WagonsTab
|
||||
bookingId={booking.id}
|
||||
currency={booking.paymentCurrency}
|
||||
cancellable={
|
||||
booking.status === "PAID" &&
|
||||
booking.paymentStatus === "PAID" &&
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
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 toast from "react-hot-toast";
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* onto another shipment day — plus the booking's cancellation history.
|
||||
* Wagons leave the schedule at request time; the fee settles the credit.
|
||||
@@ -90,10 +90,13 @@ export function WagonCancellationCard({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const status = booking.status as string;
|
||||
const eligible =
|
||||
status === "PAID" &&
|
||||
(booking.paymentStatus as string) === "PAID" &&
|
||||
!!booking.contractId;
|
||||
const paidContract =
|
||||
(booking.paymentStatus as string) === "PAID" && !!booking.contractId;
|
||||
// New cuts only on a live PAID booking; a booking fully cancelled through
|
||||
// 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 detail = booking as BookingDetail;
|
||||
@@ -226,12 +229,13 @@ export function WagonCancellationCard({
|
||||
});
|
||||
|
||||
if (!eligible) return null;
|
||||
if (!canRequest && !ownRows.length) return null;
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<CardTitle>Wagon Cancellation</CardTitle>
|
||||
{!openRow && !creditRow && (
|
||||
{/* {canRequest && !openRow && !creditRow && (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
@@ -240,7 +244,7 @@ export function WagonCancellationCard({
|
||||
>
|
||||
Cancel wagons
|
||||
</Button>
|
||||
)}
|
||||
)} */}
|
||||
</Group>
|
||||
|
||||
{openRow ? (
|
||||
@@ -305,9 +309,9 @@ export function WagonCancellationCard({
|
||||
</Stack>
|
||||
) : (
|
||||
<Text fz={13} c="#475569">
|
||||
Need fewer wagons than you paid for? Cancel part of this booking for
|
||||
a per-wagon fee — the freed freight amount becomes a credit you can
|
||||
rebook onto another shipment day.
|
||||
Need fewer wagons than you paid for — or none? Cancel part or all of
|
||||
this booking for a per-wagon fee — the freed freight amount becomes a
|
||||
credit you can rebook onto another shipment day.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -401,16 +405,16 @@ export function WagonCancellationCard({
|
||||
{booking.reference}
|
||||
</Text>{" "}
|
||||
to cancel. A per-wagon fee applies; once it's paid the wagons
|
||||
are released and the freed amount becomes a rebooking credit. At
|
||||
least one wagon must remain — to cancel everything, cancel the
|
||||
whole booking instead.
|
||||
are released and the freed amount becomes a rebooking credit.
|
||||
Cancelling every wagon cancels the whole booking — the full freight
|
||||
amount becomes your credit.
|
||||
</Text>
|
||||
|
||||
{isBulk ? (
|
||||
<NumberInput
|
||||
label="Wagons to cancel"
|
||||
min={1}
|
||||
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined}
|
||||
max={wagonsRequired > 0 ? wagonsRequired : undefined}
|
||||
allowDecimal={false}
|
||||
value={wagons}
|
||||
onChange={(v) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user